# 运行时数据区域

runtime_data_area

# 程序计数器

是一块较小的内存空间,当前线程所执行的字节码的行号指示器。

  • 仅概念模型,各 JVM 实现不一样。
  • 字节码解释器读/写该计数器。
  • 各线程独立储存计数器,称 “线程私有” 内存。
  • 线程正执行 Java 方法,计数器记录的是正在执行的虚拟机字节码指令地址。
  • 线程正执行 Native 方法,计数器值为空(Undefined);
  • 此内存区域是唯一一个在 Java 虚拟机规范中未规定任何 OutOfMemoryError 情况的区域。

# Java 虚拟机栈

是 Java 方法执行的内存模型;每个方法在执行的同时都创建一个栈帧(Stack Frame);栈帧用于储存局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程:

  1. 方法:调用 -------> 执行完成
  2. 栈帧:虚拟机栈 (入栈 -------> 出栈)
  • 线程私有。
  • 生命周期与线程相同。
  • 局部变量表存放编译期基本数据类型(8 类)、对象引用和 returnAddress 类型(字节码指令地址)
  • 64 位 long 和 double 类型数据占用 2 个局部变量空间(Slot),其余类型占 1 个。
  • 局部变量表所需内存空间大小在编译期间决定,运行期间不会改变;进入方法时,在帧中分配已确定大小的局部变量空间。
  • 该区域定义两种异常:线程请求的栈深度大于虚拟机所允许的深度,将抛出 StackOverflowError 异常;如果虚拟机可以动态扩展,并扩展时无法申请到足够的内存,就会抛出 OutOfMemoryError 异常。

# 本地方法栈

与虚拟机栈发挥的作用非常相似,区别是虚拟机栈为虚拟机执行 Java 方法(字节码)服务,而本地方法栈为虚拟机使用 Native 方法服务。

该区域没规范,所以具体虚拟机可以自由实现它,Sun HotSpot 虚拟机把本地方法栈和虚拟机栈合二为一。

  • 本地方法栈区域也会抛出 StackOverflowError 和 OutOfMemeryError 异常。

# Java 堆

是 Java 虚拟机管理的内存中最大的一块,是被所有线程共享的一块内存区域,在虚拟机启动时创建。

  • 此内存区域存放对象实例以及数组。
  • 是垃圾收集器管理的主要区域,又称 GC 堆(Garbage Collected Heap)。
  • 内存回收角度:新生代和老年代;细分 Eden 空间、From Survivor 空间、To Survivor 空间;
  • 内存分配角度:可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB)。
  • 可以不需要连续的内存。
  • -Xmx -Xms 控制是否可扩展;如果没有内存完成实例分配,并且堆也无法再扩展,则将抛出 OutOfMemoryError。

# 方法区

是各个线程共享的内存区域,用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

  • 别名 Non-Heap(非堆),是堆的一个逻辑部分。
  • HotSpot 虚拟机的 GC 扩展至方法区,又称 “永久代”(Permanent Generation),受 -XX:MaxPermSize 上限限制,后期可能移除永久代。
  • JDK 1.7 HotSpot 已经从永久代中移除字符串常量池。
  • 无法满足内存分配需求时,将抛出 OutOfMemoryError 异常。

# 运行时常量池

是方法区的一部分。Class 文件中有一项信息是常量池(Constant Pool Table),用于存放编译期生成的各种字面量和符号引用。

  • 运行期间也可以将新的常量放入池中,比如 String.intern () 方法。
  • 内存无法申请时,将抛出 OutOfMemoryError 异常。

# 直接内存

直接内存(Direct Memory),Native 函数库直接分配堆外内存,然后由 Java 堆中的对象引用该内存。以此避免 Java 堆与 Native 堆中来回复制数据。

  • JDK 1.4 新加入 NIO,DirectByteBuffer 对象直接引用 Native 分配的堆外内存。
  • 受本机总内存(RAM+SWAP 区 / 分页文件)大小以及处理器寻址空间限制。内存超出会抛出 OutOfMemoryError 异常。

# HotSpot 虚拟机对象探秘

# 对象的创建

# new 指令的处理

  1. 检查指令的参数能否在常量池中定位到类的符号引用;并检查这个符号引用代表的类是否被加载、解析和初始化过。
  2. 如果没有,那必须先执行相应的类加载过程。
  3. 加载检查通过后,为新生对象分配确定大小的内存。
  4. 将分配到的内存空间都初始化为零值(不包括对象头),保证实例字段不初始化就可以使用对应的零值。
  5. 设置对象头,记录各种信息:GC 分代年龄、对象的哈希码、元数据信息等。
  6. 至此,对象已经产生。执行 new 指令之后,接着执行 <init> 方法(由 invokespecial 指令决定)。

# 内存分配方式

  1. 指针碰撞(Bump the Pointer):用过的内存放在一边,空闲的内存放在另外一边,中间放置分界点的指示器;通过移动指示器来分配内存。Serial、ParNew 等 Compact 过程收集器采用该算法。
  2. 空闲列表(Free List):虚拟机维护一个列表,记录哪些内存块是可用的,从中分配足够的内存空间给对象实例,并更新记录。CMS 收集器基于 Mark-Sweep 算法,采用该方式。

分配内存比较频繁,仅改变一个指针移动,在并发情况下不是线性安全的,解决方法:

  1. 虚拟机采用 CAS 同步,并配上失败重试的方式保证更新操作的原子性。
  2. 每个线程在 Java 堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB),当 TLAB 用完并分配新 TLAB 时,才需同步锁定。由 -XX:+/-UseTLAB 参数决定。

# 对象的内存布局

对象在内存中存储的布局可以分为 3 块区域:对象头、实例数据、对齐填充。

下面以 32 位 JVM 为例。

# 对象头

普通对象:

+-----------------------------------------------------------------+
|                     Object Header: 64-bits                      |
+--------------------------------+--------------------------------+
|       Mark Word: 32-bits       |       Klass Word: 32-bits      |
+--------------------------------+--------------------------------+

数组对象:

t
+--------------------------------------------------------------------------------------+
|                                Object Header: 96-bits                                |
+----------------------------+----------------------------+----------------------------+
|     Mark Word: 32-bits     |    Klass Word: 32-bits     |   array length: 32-bits    |
+----------------------------+----------------------------+----------------------------+

# Mark Word

标记字:记录哈希码,GC 分代年龄、锁状态标志、线程持有的锁、偏向时间戳等,数据长度为 32bit(32 位)或 64bit(64 位),它会根据对象状态复用该存储空间。比如:HotSpot 32 位,未锁定时,25bit 用于对象哈希码,4bit 用于分代年龄,2bit 锁标志位,1bit 固定为 0。

32 位的标记字结构如下:

+-----------------------------------------------------------+--------------------+
|                    Mark Word: 32-bits                     |       State        |
+-----------------------+--------+----------------+---------+--------------------+
| identity_hashcode: 25 | age: 4 | biased_lock: 1 | lock: 2 |       Normal       |
+------------+----------+--------+----------------+---------+--------------------+
| thread: 23 | epoch: 2 | age: 4 | biased_lock: 1 | lock: 2 |       Biased       |
+------------+----------+--------+----------------+---------+--------------------+
|             ptr_to_lock_record: 30              | lock: 2 | Lightweight Locked |
+-------------------------------------------------+---------+--------------------+
|         ptr_to_heavyweight_monitor: 30          | lock: 2 | Heavyweight Locked |
+-------------------------------------------------+---------+--------------------+
|                                                 | lock: 2 |   Marked for GC    |
+-------------------------------------------------+---------+--------------------+
biased locklock状态存储内容
001未锁定对象哈希码、对象分代年龄
101可偏向偏向线程 ID、偏向时间戳、对象分代年龄
000轻量级锁指向锁记录的指针
010重量级锁(膨胀)指向重量级锁的指针
011GC 标记空,不需要记录信息
  • biased_lock: 对象是否启用偏向锁标记:0 - 关闭;1 - 启用
  • age: 对象年龄,最大值为 15。在 GC 中,如果对象在 Survivor 区复制一次,年龄增加 1。当对象达到设定的阈值( -XX:MaxTenuringThreshold )时,将会晋升到老年代。默认情况下,并行 GC 的阈值为 15,并发 GC 的阈值为 6。
  • identity_hashcode: 哈希码,采用延迟加载技术。调用方法 System.identityHashCode() 计算,并会将结果写到该对象头中。当对象被锁定时,该值会移动到管程 Monitor 中。而且计算哈希码后,则不能开启偏向锁。
  • thread: 持有偏向锁的线程 ID。
  • epoch: 偏向时间戳。
  • ptr_to_lock_record: 指向栈中锁记录的指针。
  • ptr_to_heavyweight_monitor 指向管程 Monitor 的指针。

64 位的标记字结构如下:

+------------------------------------------------------------------------------------+--------------------+
|                                 Mark Word: 64-bits                                 |       State        |
+------------+------------+----------+-----------+--------+----------------+---------+--------------------+
| unused: 25 | identity_hashcode: 31 | unused: 1 | age: 4 | biased_lock: 1 | lock: 2 |       Normal       |
+------------+------------+----------+-----------+--------+----------------+---------+--------------------+
|       thread: 54        | epoch: 2 | unused: 1 | age: 4 | biased_lock: 1 | lock: 2 |       Biased       |
+-------------------------+----------+-----------+--------+----------------+---------+--------------------+
|                          ptr_to_lock_record: 62                          | lock: 2 | Lightweight Locked |
+--------------------------------------------------------------------------+---------+--------------------+
|                      ptr_to_heavyweight_monitor: 62                      | lock: 2 | Heavyweight Locked |
+--------------------------------------------------------------------------+---------+--------------------+
|                                                                          | lock: 2 |   Marked for GC    |
+--------------------------------------------------------------------------+---------+--------------------+

# Klass Word

类型指针:指向它的类元数据的指针,JVM 通过这个确定该对象是哪个类的实例。

并不是所有 JVM 实现都必须在对象数据上保留该类型指针,可通过其它途径获取类元数据信息,参考句柄访问

如果对象是一个 Java 数组,那对象头中还必需记录数组长度。

# 实例数据(Instance Data)

存储父类继承下来的与子类定义的字段内容。

这部分存储顺序受虚拟机分配策略参数(FieldsAllocationStyle)和字段在 Java 源码中定义顺序的影响。

HotSpot 虚拟机默认分配策略:longs/doubles、ints、shorts/chars、bytes/booleans、oops (Ordinary Object Pointers),从中看出,相同宽度的字段放在一起。

父类中定义的变量出现在子类之前。如果 CompactFields 参数值为 true(默认),那么子类之中较窄的变量也可能会插入到父类变量的空隙中。

# 对齐填充(Padding)

HotSpot VM 的自动管理系统要求对象起始地址必须是 8 字节的整数倍,因此实例数据部分未对齐,则需要通过对齐填充来补全。

# 对象的访问定位

通过栈上的 reference 数据来操作堆上的具体对象,访问堆中的对象的具体位置,决定虚拟机的实现方式。目前流行的访问方式有使用句柄和直接指针两种。

# 句柄访问

Java 堆中将会划分出一块内存作为句柄池,reference 存储的是对象的句柄地址(指针的指针)。

reference_handler

优点:
在对象被移动时(GC 时移动对象是非常普遍的行为),只会改变句柄中的实例数据指针,而 reference 本身不需要修改。

# 直接指针

reference 直接存储对象的地址,对象的头部需要存储对象类型数据的地址。

reference_direct

优点:速度更快,节省一次指针定位的时间开销;对象访问在 Java 中非常频繁。Sun HotSpot 采用这种方式进行对象访问。

# 实战:OOM 异常

显示运行时详细的 gc 记录

JVM Args: -verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8

# Java 堆溢出

/**
 * JVM Args: -Xms20M -Xmx20M -XX:+HeapDumpOnOutOfMemoryError
 */
public class HeapOOM {
    static class OOMObject {
    }
    public static void main(String[] args) {
        List<OOMObject> list = new ArrayList<>();
        while (true) {
            list.add(new OOMObject());
        }
    }
}

异常:
java.lang.OutOfMemoryError: Java heap space

排查:

  1. 检查是否是内存泄露,通过工具查看泄露对象到 GC Roots 的引链。
  2. 如果不存在泄露,检查虚拟机参数(-Xmx 与 - Xms);
  3. 从代码上检查是否存在某些对象生命周期过长、持有状态时间过长的情况。

# 虚拟机栈和本地方法栈溢出

  1. 如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出 StackOverflowError 异常。
  2. 如果虚拟机在扩展时无法申请到足够的内存空间,则抛出 OutOfMemoryError 异常。

实验:

  • 使用 - Xss 参数减少栈内存容量。结果抛出 StackOverflowError 异常,异常出现时输出的堆栈深度相应缩小。
/**
 * VM Args: -Xss228k
 */
public class JavaVMStackSOF {
    private int stackLength = 1;
    public void stackLeak() {
        stackLength++;
        stackLeak();
    }
    public static void main(String[] args) {
        JavaVMStackSOF oom = new JavaVMStackSOF();
        try {
            oom.stackLeak();
        } catch (Throwable e) {
            System.out.println("stack length: " + oom.stackLength);
            throw e;
        }
    }
}
  • 分配的栈内存越大,可创建的线程数就越少。
/**
 * VM Args: -Xss2M
 */
public class JavaVMStackOOM {
    private void dontStop() {
        while (true) {
        }
    }
    public void stackLeakByThread() {
        while (true) {
            Thread thread = new Thread(new Runnable() {
                @Override
                public void run() {
                    dontStop();
                }
            });
            thread.start();
        }
    }
    public static void main(String[] args) throws Throwable {
        JavaVMStackOOM oom = new JavaVMStackOOM();
        oom.stackLeakByThread();
    }
}
  1. 如果出现 StackOverflowError 异常时,可参考堆栈。虚拟机默认参数,栈深度在大多数情况下达到 1000~2000 完全没问题,正常方法调用(包括递归)完全够用。
  2. 如果是建立过多线程导致的内存溢出,在不能减少线程数或者更换 64 位虚拟机的情况下,就只能通过减少最大堆和减少栈容量来换取更多的线程。

# 方法区和运行时常量池溢出

# 运行时常量溢出

以下在 JDK 1.6 之前有效,1.7 到 1.8 已经将常量池从方法区移除

/**
 * VM Args: -XX:PermSize=10M -XX:MaxPermSize=10M
 */
public class RuntimeConstantPoolOOM {
    public static void main(String[] args) {
        // 使用 List 保持常量池引用,避免 Full GC 回收常量池行为
        List<String> list = new ArrayList<>();
        int i = 0;
        while(true) {
            list.add(String.valueOf(i++).intern());
        }
    }
}

JDK 1.7 以上:

public class RuntimeConstantPoolOOM {
    public static void main(String[] args) {
        String str1 = new StringBuilder("计算机").append("软件").toString();
        //true, 首次出现
        System.out.println(str1.intern() == str1);  
        // 字面量,编译后放在常量池,出现过;Java 1.7 将常量池移到堆中。
        String str = "java";
        String str2 = new StringBuilder("ja").append("va").toString();
        //false, 非首次出现
        System.out.println(str2.intern() == str2);  
    }
}

# 方法区溢出

需要引入 cglib 库

<dependency>
    <groupId>cglib</groupId>
    <artifactId>cglib-nodep</artifactId>
    <version>3.2.4</version>
</dependency>
/**
 * VM Args: -XX:PermSize=10M -XX:MaxPermSize=10M
 */
public class JavaMethodAreaOOM {
    public static void main(String[] args) {
        while(true) {
            Enhancer enhancer = new Enhancer();
            enhancer.setSuperclass(OOMObject.class);
            enhancer.setUseCache(false);
            enhancer.setCallback(new MethodInterceptor() {
                @Override
                public Object intercept(Object o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable {
                    return methodProxy.invokeSuper(o, objects);
                }
            });
            enhancer.create();
        }
    }
    static class OOMObject {}
}

# 本机直接内存溢出

DirectMemory 容量可通过 -XX:MaxDirectMemorySize 指定,默认与 Java 堆最大值(-Xmx)一样。

/**
 * VM Args: -Xmx20M -XX:MaxDirectMemorySize=10M
 */
public class DirectMemoryOOM {
    private static final int _1MB = 1024 * 1024;
    public static void main(String[] args) throws Exception {
        Field unsafeField = Unsafe.class.getDeclaredFields()[0];
        unsafeField.setAccessible(true);
        Unsafe unsafe = (Unsafe) unsafeField.get(null);
        while (true) {
            unsafe.allocateMemory(_1MB);
        }
    }
}

由于 DirectMemory 导致的内存溢出,一个明显的特征是在 Heap Dump 文件中不会看到明显的异常。

如果发现 OOM 之后的 Dump 文件很小,而程序又直接或间接使用 NIO,则可能是这个原因。