Skip to content

运行数据区概述

虚拟机栈

虚拟机栈描述的是Java方法运行时的线程内存模型:每个方法被执行时,JVM会同步创建一个栈帧,用于存储局部变量表、操作数栈、动态链接、方法返回地址等信息,虚拟机栈区域是线程私有的,它的生命周期与线程相同。栈顶存放的是当前方法。

  1. 局部变量表:存放编译期可知的数据类型:8种基本数据类型、对象引用类型、返回类型。这些数据类型在栈中用slot来表示,除了long & double占用2个slot,其余的都为1个。

  2. 虚拟机栈包含用于执行native方法的本地方法栈。它们都会抛出OOM和StackOverFlow异常。

  3. 虚拟机中的线程与操作系统的本地线程直接映射,操作系统负责将所有的线程调度到可用的CPU上,一旦本地线程初始化成功,就会调用Java虚拟机线程中的run方法。

  4. 动态链接:类加载时将常量池中的符号引用转换为运行时常量池中方法的直接引用,这个引用被称为动态链接。

虚拟机堆

这是一块线程共享的内存区域,几乎全部的对象实例、数组都在堆上分配(小对象可以在栈上分配)。

从内存回收角度看,堆被逻辑的分为:年轻代(包括eden、from、to三个区域)、老年代

从内存分配角度看,堆被分为多个线程私有的内存分配缓冲区(TLAB)

TLAB

Thread Local Allocation Buffer(本地线程缓冲区),原有的虚拟机给对象分配内存时,采用是CAS + 失败重试的方式。而TLAB是:

  1. 通预先给每个线程在堆中分配一小块区域。
  2. 哪个线程创建对象,就在哪个线程的TLAB中分配内存。
  3. 如果这个线程的TLAB空间不够分配时,就通过同步锁定给这个线程分配新的TLAB
  4. -XX:+/-UseTLAB来开启和关闭TLAB。

元数据区

JDK1.8起,方法区改名为元数据区(MetaSpace),是线程共享的区域,是堆的一个逻辑部分,用于存储JVM加载的类型信息、常量、静态变量及即时编译后的方法代码等数据。会抛出OOM异常。

常量池分类

  • Class文件中的常量池

主要存放字面量 & 符号引用。前者主要是文本字符串、八种基本数据类型、final修饰的常量等,后者包含:类和接口的全限定名、字段的名称和描述符、方法的名称和描述符。在类被加载后会存放到运行时常量池中。

  • 运行时常量池

属于元数据区中的一部分,类在被JVM加载后,类的版本、字段、方法和常量池等都会进入该区域。JVM会为每个加载的class维护一个运行时常量池,同时其中存储的是引用,实际对象还在堆中。日常我们所称的常量池就是运行时常量池。

  • 全局字符串常量池

JDK7后位于堆中,运行时存在的用于记录interned string的全局表StringTabel。其中存放的是String实例的引用,实际的String对象仍存在于堆。

String.intern():如果字符串常量池已存在该字符串引用,那么就返回已存在的字符串的引用。若没有就将引用保存到字符串常量池并返回引用。

字符常量的执行流程

  • 首先编译期会将字面量、符号引用等放入Class文件的常量池中。

  • 在JVM类加载的过程中,除了字面量,类的字段、方法等信息都会加载到当前类运行时常量池。此时运行时常量池中存放的是CONSTANT-UnresolvedString,表明尚未resolve,只有在解析后存放的是CONSTANT_String,内容是实际的String对象的引用,和字符串常量池的引用一致。

  • 因为JVM类加载过程中的解析(resolve)阶段是可以懒执行的,只有当执行ldc指令时,通过存放在运行时常量池的索引去字符串常量池查找是否存在对应的String实例,如果存在就直接返回该引用,不存在就先在堆中创建对应的String对象,并将引用记录在字符串常量池中,再返回该引用。

    ldc指令:将int、float或String类型的常量值从常量池推送至栈顶

    资料来源:https://www.zhihu.com/question/55994121/answer/408891707


程序计数器

程序计数器(Program Counter Register),当前线程所执行的字节码的行号指示器。分支、循环、异常处理都是依赖计数器实现,该区域是线程私有的。

直接内存

直接内存并不是JVM运行时数据区的一部分。常见于NIO类使用:通过Native方法分配堆外内存,在Java堆中持有该内存区域的引用实现操作,相比之前在Java堆和Native堆之间来回复制的方式,提升了效率。


JVM中的对象

对象的创建

  1. Class类的常量池中寻找该类的符号引用,并通过该符号引用判断类是否被加载。
  2. 如果类没有被加载,那么JVM就会执行相应的类加载过程。
  3. 给对象分配内存空间共有两种方式:指针碰撞 & 空闲列表
  4. 在对象分配内存的线程安全问题,默认是通过CAS + 失败重试实现,也可以选择TLAB
  5. 初始化内存空间为零值,并对Mark Word进行必要设置(根据是否启动偏向锁设置信息)。
  6. 最终调用对象的构造函数进行初始化。

对象的构成

对象在堆中的布局分为三个部分:对象头、实例数据和对齐填充。而对象头中又包含:对象自身的运行时数据(Mark Word)、对象指向它类型元数据的指针以及数组长度(如果对象是数组)

对象头
  • Mark Word

    用于记录存储对象自身运行时的数据。比如HashCode、锁状态标识等。

  • 类型指针

    对象头中指向该对象类型元数据(元数据区)的指针,通过类型指针,JVM可以判断当前对象是哪个类的实例

    并不是所有的虚拟机都会在对象头中保留类型指针。此问题查看对象的引用

  • 数组长度

    如果当前对象是数组,那么在对象头中还有一部分用于存储数组长度的数据

实例数据

即保存代码中定义的各种类型的字段内容(包括父类继承),其存储顺序除了受到代码中定义的影响,还由JVM参数-XX:FiedlsAllocationStyle决定。

对齐填充

对齐填充并不是必然存在的,因为HotSpot要求对象的大小必须是8的整数倍,对象头已经是8的整数倍,如果实例数据不是8的整数倍,那么就需要使用对齐填充来补全。

对象的引用

对象的创建是为了能够使用该对象,我们通过栈上的reference数据来操作堆上的具体对象。但对象的访问方式由虚拟机自行决定,目前主流的有两种:句柄 & 指针

  1. 句柄:就是在堆中额外划分一块内存作为句柄池,栈中的reference存放的就是句柄池地址。句柄池中包含对象实例数据 & 类型数据的内存地址
  2. 直接指针:栈中reference存放的是堆中的对象地址,对象头中又包含对象类型数据指针
  3. 句柄的优点在于GC回收移动对象时,只需要修改句柄池中的实例数据指针。而指针的优点在于访问更快,减少一次查找。

模拟各区域OOM

java
/**
  * -Xmx10m 模拟堆OOM
  */
public static void main(String[] args) {
    List<Object> list = new ArrayList<>();
    while (true) {
        list.add(new Object());
    }
}

  • stackOverFlow
java
/**
  * -Xss1m
  */
public static void main(String[] args) {
    Stack stack = new Stack();
    // stackOverFlow
    stack.stackOverFlow();
}

void stackOverFlow() {
    stackOverFlow();
}
  • OOM
java
/**
  * -Xss1m
  */
public static void main(String[] args) {
    Stack stack = new Stack();
    // oom
    stack.oom();
}
void oom() {
    while (true) {
        new Thread(() -> {
            while (true) {

            }
        }).start();
    }
}

相比OOM,stackOverFlow更容易发生。

元数据区

  • 字符串常量池OOM
java
/**
  * 1.7前 -XX:MaxPermSize=10m
  * 1.7后 -Xmx10m
  */
public static void main(String[] args) {
    List<String> list = new ArrayList<>();
    int i = 0;
    while (true) {
        list.add(("hello" + i++).intern());
    }
}

需要注意在JDK7及以上版本中不会抛出之前的PemGen space异常,因为字符串常量池被移到了堆中,如果我们限制堆的大小,会抛出Java heap space异常。

  • 元数据OOM
java
/**
  * -XX:MaxMetaspaceSize=10m
  */
public static void main(String[] args) {
    while (true) {
        Enhancer enhancer = new Enhancer();
        enhancer.setSuperclass(Object.class);
        enhancer.setUseCache(false);
        enhancer.setCallback((MethodInterceptor) (o, method, objects, methodProxy) 
                             -> methodProxy.invoke(o, objects));
        enhancer.create();
    }
}

因为元数据区存放类型的相关信息:类名、方法描述等,通过大量创建cglib代理类实现Metaspace OOM

直接内存

java
/**
  * -XX:MaxDirectMemorySize=10m
  */
public static void main(String[] args) throws IllegalAccessException {
    // 反射获取unsafe类
    Field unsafeField = Unsafe.class.getDeclaredFields()[0];
    unsafeField.setAccessible(true);
    Unsafe unsafe = (Unsafe)unsafeField.get(null);
    while (true) {
        // 分配直接内存
        unsafe.allocateMemory(1024 * 1024);
    }
}

直接内存由:-XX:MaxDirectMemorySize指定,如果不指定则和-Xmx一致。


常量池实战

查看汇编指令

bash
javac -encoding utf-8 StringTest.java
javap -v StringTest.class
  • 字符串拼接(编译器优化)
java
public class StringTest {
    public static void main(String[] args) {
        String s1 = "hello";
        String s2 = "he" + "llo"; // 编译器会自动转成 ldc "hello"指令
        // s1 == s2?
    }
}

因为编译器的优化,s2会被编译成"ldc hello"(汇编指令可见),s1 和 s2 都指向字符串常量池"hello"的引用,所以s1 == s2成立。

  • 字符串拼接(编译器不优化)
java
public class StringTest {
    public static void main(String[] args) {
		String s1 = new String("he") + new String("llo"); // 编译器不会优化
        s1.intern();
        String s2 = "hello"
        // s1 == s2?
    }
}
  1. 编译器不会优化s1,堆中会创建"he"、"llo"对象,并将两个对象的引用放入字符串常量池。继而通过+ (底层StringBuilder)创建"hello"对象,但不会放入字符串常量池
  2. 此时字符串常量池"hello"的引用,s1.intern()会将堆中"hello"对象的引用放入字符串常量池并返回引用。
  3. s2 = "hello",执行ldc指令,发现字符串常量池已存在"hello"的引用,返回引用(即s1引用)给s2,所以s1 == s2成立。
  4. 下图汇编指令中,需要注意观察,在s1.intern()之前,并没有ldc "hello",进一步说明在此之前字符串常量池只存在"he"、"llo"两个对象的引用。

  • new String("")问题
java
public class StringTest {
    public static void main(String[] args) {
		String s1 = new String("hello");
        String s2 = "hello";
        // s1 == s2?
    }
}

基于ldc指令,若字符串常量池不存在该字符串就会在堆中创建字符串实例,并将引用保存在字符串常量池中

此时s1 = new String("hello")共创建两个对象:一个由显示的new创建,一个由JVM创建。s1指向堆中的"hello"对象,而s2指向的是字符串常量池中持有的实例。所以s1 == s2不成立。

  • intern()
java
public class StringTest {
    public static void main(String[] args) {
		String s1 = new String("hello");
        s1 = s1.intern();
        String s2 = "hello";
        // s1 == s2?
    }
}

String.intern()方法会返回该字符串在字符串常量池中的引用s2 = "hello"也会先去字符串常量池查看是否存在该字符串的引用,有就返回引用。最终s1 & s2都指向字符串常量池中的hello引用。所以s1 == s2成立。