# 运行时数据区域

# 程序计数器
是一块较小的内存空间,当前线程所执行的字节码的行号指示器。
- 仅概念模型,各 JVM 实现不一样。
- 字节码解释器读/写该计数器。
- 各线程独立储存计数器,称 “线程私有” 内存。
- 线程正执行 Java 方法,计数器记录的是正在执行的虚拟机字节码指令地址。
- 线程正执行 Native 方法,计数器值为空(Undefined);
- 此内存区域是唯一一个在 Java 虚拟机规范中未规定任何 OutOfMemoryError 情况的区域。
# Java 虚拟机栈
是 Java 方法执行的内存模型;每个方法在执行的同时都创建一个栈帧(Stack Frame);栈帧用于储存局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程:
- 方法:调用 -------> 执行完成
- 栈帧:虚拟机栈 (入栈 -------> 出栈)
- 线程私有。
- 生命周期与线程相同。
- 局部变量表存放编译期基本数据类型(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 指令的处理
- 检查指令的参数能否在常量池中定位到类的符号引用;并检查这个符号引用代表的类是否被加载、解析和初始化过。
- 如果没有,那必须先执行相应的类加载过程。
- 加载检查通过后,为新生对象分配确定大小的内存。
- 将分配到的内存空间都初始化为零值(不包括对象头),保证实例字段不初始化就可以使用对应的零值。
- 设置对象头,记录各种信息:GC 分代年龄、对象的哈希码、元数据信息等。
- 至此,对象已经产生。执行 new 指令之后,接着执行
<init>方法(由 invokespecial 指令决定)。
# 内存分配方式
- 指针碰撞(Bump the Pointer):用过的内存放在一边,空闲的内存放在另外一边,中间放置分界点的指示器;通过移动指示器来分配内存。Serial、ParNew 等 Compact 过程收集器采用该算法。
- 空闲列表(Free List):虚拟机维护一个列表,记录哪些内存块是可用的,从中分配足够的内存空间给对象实例,并更新记录。CMS 收集器基于 Mark-Sweep 算法,采用该方式。
分配内存比较频繁,仅改变一个指针移动,在并发情况下不是线性安全的,解决方法:
- 虚拟机采用 CAS 同步,并配上失败重试的方式保证更新操作的原子性。
- 每个线程在 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 | | |
+--------------------------------+--------------------------------+ |
数组对象:
+--------------------------------------------------------------------------------------+ | |
| 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 lock | lock | 状态 | 存储内容 |
|---|---|---|---|
| 0 | 01 | 未锁定 | 对象哈希码、对象分代年龄 |
| 1 | 01 | 可偏向 | 偏向线程 ID、偏向时间戳、对象分代年龄 |
| 0 | 00 | 轻量级锁 | 指向锁记录的指针 |
| 0 | 10 | 重量级锁(膨胀) | 指向重量级锁的指针 |
| 0 | 11 | GC 标记 | 空,不需要记录信息 |
- 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 存储的是对象的句柄地址(指针的指针)。

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

优点:速度更快,节省一次指针定位的时间开销;对象访问在 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
排查:
- 检查是否是内存泄露,通过工具查看泄露对象到 GC Roots 的引链。
- 如果不存在泄露,检查虚拟机参数(-Xmx 与 - Xms);
- 从代码上检查是否存在某些对象生命周期过长、持有状态时间过长的情况。
# 虚拟机栈和本地方法栈溢出
- 如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出 StackOverflowError 异常。
- 如果虚拟机在扩展时无法申请到足够的内存空间,则抛出 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(); | |
} | |
} |
- 如果出现 StackOverflowError 异常时,可参考堆栈。虚拟机默认参数,栈深度在大多数情况下达到 1000~2000 完全没问题,正常方法调用(包括递归)完全够用。
- 如果是建立过多线程导致的内存溢出,在不能减少线程数或者更换 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,则可能是这个原因。