JVM 内存模型即运行时数据区域

365 阅读17分钟

前言

  • 学习一下JVM的内存划分,以及每个区域的大致作用和内容。
  • 再学习一下内存故障和一些工具的使用以及一些虚拟机参数。

内存划分及其内容

虚拟机栈(VM Stack)

栈线程独有的

  • 栈帧(Stack Frame)是线程独有,JVM的栈内存区域中有多个栈帧。

  • 是JVM(Java虚拟机)为每个线程分配的内存区域,用于存储方法的执行环境(包括局部变量表、操作数栈、动态链接、方法返回地址等)。每个方法在执行时都会创建一个对应的栈帧(Stack Frame)并压入虚拟机栈,方法的调用和返回都是通过虚拟机栈来管理。虚拟机栈的大小可以在启动时通过参数进行调整,它的主要作用是支持方法的调用和执行。

    • 局部变量表(Local Variable Table):局部变量表用于存储方法中定义的局部变量和方法参数。它是一个固定大小的字节码数组,在编译时确定局部变量的数量和类型,并在方法执行时使用。局部变量表可以存储各种类型的数据,包括基本类型(如int、float)和对象引用。
    • 操作数栈(Operand Stack):操作数栈用于执行方法中的操作数(即方法的操作数栈操作)。它是一个后进先出(LIFO)的数据结构,用于存储方法操作中所需的临时数据。操作数栈的操作包括将值压入栈顶、从栈顶弹出值等。JVM的指令集中的指令通常直接操作操作数栈中的数据。
    • 动态链接(Dynamic Linking):动态链接是指在方法调用时,将方法调用的符号引用转换为实际方法的直接引用(内存地支或偏移量)的过程。JVM使用动态链接来支持方法的多态性和动态绑定。动态链接的过程将符号引用解析为实际的方法引用,以确保方法的正确调用。
    • 方法返回地址(Return Address):方法返回地址表示方法执行完成后,程序应该返回的位置。当方法调用其他方法时,当前方法的返回地址会被保存,以便在调用方法执行完毕后回到正确的位置继续执行。方法返回地址通常是一个指向字节码指令的地址。
    • 这些组成部分共同构成了方法的执行环境,通过虚拟机栈中的栈帧来管理。在方法的执行过程中,局部变量表用于存储方法中的变量和参数,操作数栈用于执行方法操作和临时数据存储,动态链接用于解析方法调用,方法返回地址用于指示方法的返回位置。这些机制共同支持方法的正确执行和控制流的跳转。
  • 栈帧中有局部变量表,通过引用指向堆中的对象,或通过句柄来操作堆中的对象。

  • 引用不是对象

  • 栈帧是以方法为单位还是以线程为单位?

    • 栈帧是以方法为单位的,每个方法被调用时,都会在虚拟机栈上创建一个对应的栈帧,用于存储该方法的局部变量表、操作数栈、方法返回地址和异常处理表等信息。
    • 当方法执行结束后,该方法对应的栈帧会被弹出栈并销毁,同时方法的返回值会被压入调用者栈帧的操作数栈中
    • 因此,栈帧的生命周期与方法的调用和返回密切相关,不同的方法调用会在虚拟机栈上创建不同的栈帧,而同一个方法的多次调用则会在虚拟机栈上创建多个对应的栈帧。
    • 虚拟机栈则是以线程为单位的,每个线程都有自己的虚拟机栈,用于存储该线程的方法调用栈。

程序计数器(Program Counter Register)

程序计数器线程独有

  • 程序计数器也是线程私有的。
  • 简单来说就是记录每个线程的程序执行到哪了。
    • 就是程序的线程有可能会被阻塞,CPU需要知道当前线程在阻塞前执行到什么地方,好继续上次暂停的地方继续执行。
  • 程序计数器(Program Counter Register)是一块很小的内存区域,它可以看做是当前线程所执行的字节码的行号指示器。
  • 在虚拟机的概念模型中,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成
  • 由于Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式实现的,在任何一个确定的时刻,一个处理器都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为线程私有的内存。
  • 如果线程正在执行的是 Java 方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;
  • 如果正在执行的是 Native 方法, 这个计数器的值为空
  • 此内存区域是唯一一个在 Java 虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域。

堆(Heap)

堆线程共享

  • 对大多数应用来说,Java 堆是 Java 虚拟机所管理的内存中最大的一块。
  • Java 堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例。
  • Java 堆是垃圾收集器管理的主要区域。从内存回收的角度来看,由于现在收集器基本都采用分代收集算法,所以 Java 堆中还可以分为:新生代和老年代;再细致一点的有 Eden 空间、 From Survivor 空间、To Survivor 空间等。
  • 从内存分配的角度来看,线程共享的 Java 堆中可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB)。
    • TLAB的作用是为每个线程提供一个快速分配内存的缓冲区,减少线程间的竞争。当一个线程需要分配对象时,它会先在自己的TLAB上分配内存,而不是直接在共享的Java堆上进行分配。这样可以避免多线程之间频繁竞争Java堆上的分配锁。
      • 说的是内存区域线程独有,但是线程在上面分配的对象依然是多线程共享的。
  • Java 堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可。 方法区(Method Area)︰存储元信息。永久代(Permanent Generation),从JDK 1.8开始,已经彻底废弃了永久代,使用元空间(meta space)。

方法区(Method Area)

  • 运行时常量池。
  • Class的元数据,(重点看博客的评论) java方法区究竟存储了什么?
  • 方法区(Method Area)与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据
  • 虽然 Java 虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做 Non-Heap(非堆),目的应该是与 Java 堆区分开来。
  • “PermGen space”是方法区。不过方法区和“PermGen space”又有着本质的区别。前者是 JVM 的规范,而后者则是 JVM 规范的一种实现,并且只有 HotSpot 才有 “PermGen space”。
  • HotSpot 虚拟机将 GC 分代收集拓展至方法区,或者说使用永久代来实现方法区。
  • 这样的 HotSpot 的垃圾收集器可以像管理 Java 堆一样管理这部分内存,能够省去专门为方法区编写内存管理代码的工作
  • 如果实现方法区属于虚拟机实现细节,不受虚拟机规范约束,但是用永久代实现方法区,并不是一个好主意,因为这样容易遇到内存溢出问题。
  • 垃圾收集行为在这个区域是比较少出现的,但并非数据进入方法区就永久存在了。这区域的内存回收目标主要是针对常量池的回收和对类型的卸载
  • 在 Java8 中,永久代被删除方法区的 HotSpot 的实现Metaspace 元数据区不放在虚拟机中而放在本地内存中,存储类的元信息; 而将类的静态变量(放在 Class 对象中)和运行时常量池放在堆中.
    • 元空间的垃圾回收不是由JVM自动管理和触发的。元空间的垃圾回收主要是由JVM进行元数据的管理和释放,而不是通过传统的垃圾回收器进行对象回收。
  • 元空间使用的是直接内存,会动态扩展,默认大小21(20.75)M。
  • Java永久代去哪儿了?

运行时常量池(Runtime Constant Pool)

要和字节码中的常量池区分

  • 是方法区的一部分。Class 文件中除了有关的描述信息外,还有一项信息是常量池,用于存放编译器生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。一般来说,除了保存 Class文件中描述的符号引用外,还会把翻译出来的直接引用也存储在运行时常量池。
  • 运行时常量池相对于 Class 文件常量池的一个重要特征是具备动态性,Java 语言并不要求常量一定只有编译期才能产生,运行期间也可能将新的常量放入池中,比如 String 类的intern方法。

直接内存(Direct Memory)

  • 并不是虚拟机运行时数据区的一部分,也不是 Java 虚拟机规范中定义的内存区域,但这部分内存也被频繁使用。
  • JDK 的 NIO 类,引入了一种基于通道和缓冲区的 IO 方式,它可以使用 Native 函数库直接分配堆外内存,然后通过一个存储在堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场合显著提高性能,避免了在 Java 堆和 Native 堆来回复制数据。 直接内存的分配不会受到 Java 堆大小的限制,但会受到本机总内存的限制。
  • 不是Java虚拟机管理的
  • 与NIO密切相关的。
  • JVM 通过堆上的DirectByteBuffer,来直接操作内存。

对象

创建对象

创建对象的3个步骤

  • 在堆内存中创建对象实例。
  • 为对象成员变量赋初值。
  • 返回对象的引用。
  • 往细了说,虚拟机遇到一条 new 指令时,
  • 首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那就执行类加载过程。
  • 在类加载检查通过后,虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。假设 Java 堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间那边挪动一块与对象大小相等的距离,这种分配方式称为指针碰撞
  • 果 Java 堆中的内存不是规整的,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为空闲列表 (Free List)。
  • 选择哪种分配方式由 Java 堆是否规整决定,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。
  • 除如何划分可用空间之外,还有另外一个需要考虑的问题是对象创建在虚拟机中是非常频繁的行为,即使是仅仅修改一个指针所指向的位置,在并发情况下也不是线程安全的,可能出现正在给对象 A 分配内存,指针还没来得及修改,对象 B 又同时使用了原来的指针来分配内存的情况。
  • 解决这个问题有两个方案,一种是对分配内存空间的动作进行同步处理,另一种是把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在 Java 堆中预先分配一小块内存,称为本地线程分配缓冲 TLAB
  • 哪个线程分配内存,就在哪个线程的 TLAB 上分配,只有 TLAB 用完并分配新的 TLAB 时,需要同步锁定。
  • 内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值。接下来,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何找到类的元数据等信息。这些信息存放在对象的对象头之中。上述工作完成后,从虚拟机的视角来看,一个新的对象已经产生, 但从 Java 程序的视角来看,构造方法还没有执行,字段都还为 0。所以执行 new 指令之后会接着执行构造方法等,这样一个对象才算真正产生出来。

对象在内存中的布局

  • 给堆开设置开辟的内存大小是在虚拟内存。
  • 在 HotSpot 虚拟机中,对象在内存中存储的布局可以分为 3 个区域:对象头(Header)实例数据(Instance Data)和对齐填充(Padding)
  • 对象头包括两部分信息,第一部分用于存储对象自身的运行时数据,如哈希码、GC 分代年 龄、锁状态标志等。对象头的另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。 实例数据是对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容。无论是从父类继承的,还是在子类中定义的,都需要记录下来。相同宽度的字段总是被分配到一起,在这个前提下,在父类中定义的变量会出现在子类之前。
  • 对齐填充并不是必然存在的它仅仅起着占位符的作用,HotSpot 虚拟机的自动内存管理系统要求对象起始地址必须是 8 字节的整数倍,即对象大小必须是 8 字节的整数倍,而对象头正好是 8 字节的整数倍。因此,当对象实例数据部分没有对齐时,就需要对齐填充来补全。

对象的访问定位

  • Java程序需要通过栈上的Reference数据来操作堆上的具体对象。由于Reference类型在Java 虚拟机规范中只规定了一个指向对象的引用,并没有定义这个引用应该通过何种方式来定位、 访问堆中对象的具体位置,所以对象访问方式也是取决于虚拟机实现而定的。目标主流的方式有使用句柄和直接指针两种。
  • 句柄型:
    • 如果使用句柄访问的话,那么 Java 堆中将会划分出一块内存来作为句柄池,Reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据和类型数据各自的具体地址信息。
    • 使用句柄来访问的最大好处就是 Reference 中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而 Reference 本身不需要修改。
    • Java中什么是句柄? image.png
  • 直接指针类型,HotSpot虚拟机是这个类型
    • 如果使用直接指针访问,那么 Java 堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而 Reference 中存储的直接就是对象地址。
    • 使用直接指针访问方式的最大好处就是速度更快,它节省了一次指针定位的时间开销。 image.png

内存溢出与内存泄露

  • 演示几种类型的内存溢出。
  • 使用一些VM参数修改VM不同内存区域的大小。

溢出和泄漏的区别

  • 针对Java来说
    • 内存溢出,申请不到足够的内存。
    • 内存泄露,无法释放已申请的内存。

堆内存溢出

命令行:

  • jps 看Java进程pid
  • jstack -F 强制dump dump 表示将信息转储。
  • dump堆错误
    • VM参数:-XX:+HeapDumpOnOutOfMemoryError。
  • Java 堆用于存储对象实例,只要不断增加对象,并且保证 GC Roots 到对象之间有可达路径 来避免垃圾回收机制清除这些对象,那么在对象数量达到最大堆的容量限制后就会产生 OOM 异常。
  • 代码
    • VM Options: -Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError(堆最大最小为20M)
public class HeapOOM { 
    static class OOMObject{ 
    } 
    public static void main(String[] args) {
       List list = new ArrayList<>();
       while(true){ 
       list.add(new OOMObject()); 
       } 
    }
}

image.png

栈溢出(虚拟机栈和本地方法栈)

对于 HotSpot 来说,虽然-Xoss 参数(设置本地方法栈大小)存在,但实际上是无效的,栈容量只由-Xss 参数设定,是设置每个线程的堆栈大小

  • 关于虚拟机栈和本地方法栈,在 JVM 规范中描述了两种异常:
    • 如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出 StackOverflowError 异常。
    • 如果虚拟机在扩展栈时无法申请到足够的内存空间,将抛出 OutOfMemoryError。
  • 代码
public class StackSOF {
    private int stackLength = -1;
    public void stackLeak() {
        stackLength++;
        stackLeak();//递归
    }
    public static void main(String[] args) {
        StackSOF sof = new StackSOF();
        try {
            sof.stackLeak();
        } catch (Throwable e) {
            System.out.println("stack length:" + sof.stackLength);
            throw e;
        }
    }
}

image.png

  • 操作系统分配给每个进程的内存是有限制的,每个线程分配到的栈容量越大,可以建立的线 程数量自然就越少,建立线程时就越容易把剩下的内存耗尽。
  • 如果线程过多导致栈溢出,可以通过减少最大堆和减少栈容量来换取更多的线程。
    • -Xss是设置每个线程的栈大小。

元空间溢出

  • 通过cglib创建动态创建对象
    • 需要导入cglib依赖
  • 代码
    • 因为元空间是直接内存,会动态扩展。所以要设置一下它的大小,不然很难溢出。
    • 别再想着永久区,Java 8 之后就没有了。
    • -XX:MaxMetaspaceSize=10m
public class MetaSpaceTest {
    public static void main(String[] args) {
        while(true){
            Enhancer enhancer = new Enhancer();
            enhancer.setSuperclass(MetaSpaceTest.class);//set父类,用来创建子类
            enhancer.setUseCache(false);
            enhancer.setCallback((MethodInterceptor) (object,method,args1,proxy)
                    -> proxy.invokeSuper(object,args1));
            System.out.println("child will be created");
            enhancer.create();//创建对象
        }
    }
}

image.png

  • 在没有限制元空间大小时。
    • 可以通过jvisualvm观察,发现类是装载在元空间的。
    • 狂增不止。

动画3.gif

直接内存溢出

  • 直接内存可以使用-XX:MaxDirectMemorySize 指定,如果不指定,则默认与 Java 堆最大值相同。
  • 虽然使用 DirectByteBuffer 分配内存也会抛出 OOM 异常,但它抛出异常时并没有真正向 OS 申请分配内存,而是通过计算得知内存无法分配,于是手动抛出异常。 真正申请内存的方法是 unsafe.allocateMemory()。
  • 代码
    • VM Options: -XX:MaxDirectMemorySize=10m
public class DirectMemoryOOM {
    private static final int _1MB = 1024 * 1024;
    public static void main(String[] args) throws IllegalAccessException {
        Field unsafeField = Unsafe.class.getDeclaredFields()[0];
        unsafeField.setAccessible(true);
        Unsafe unsafe = (Unsafe) unsafeField.get(null);
        while(true) {
            unsafe.allocateMemory(_1MB);
        }
    }
}

image.png

再介绍一些工具,就不演示了

  • jstat命令详解
  • jcmd
    • jcmd pid VM.flags:查看JVM的启动参数
    • jcmd pid help:列出当前运行的Java进程可以执行的操作
    • jcmd pid help JFR.dump:查看具体命令的选项
    • jcmd pid PerfCounter,print:查看JVM性能相关的参数
    • jcmd pid VM.uptime:查看JvM的启动时长
    • jcmd pid GC.class_histogram:查看系统中类的统计信息
    • jcmd pid Thread.print:查看线程堆栈信息
    • jcmd pid GC.heap_dump filename:导出Heap_dump文件,导出的文件可以通过jvisualvm查看
    • jcmd pid VM.system properties:查看JVM的属性信息
  • jps -l 可以看pid。