深入理解Java虚拟机之Java内存区域与内存溢出异常
Java内存区域与内存溢出异常
运行时数据区域
程序计数器
- 用于记录从内存执行的下一条指令的地址,线程私有的一小块内存,也是唯一不会报出OOM异常的区域
Java虚拟机栈
- Java虚拟机栈(Java Virtual Machine Stack)是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是Java方法执行的线程内存模型:每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程
- 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出
StackOverflowError
异常
- 如果Java虚拟机栈容量可以动态扩展,当栈扩展时无法申请到足够的内存会抛出
OutOfMemoryError
异常
本地方法栈
- 与Java虚拟机栈类似,只不过服务对象不一样,本地方法栈为虚拟机使用到的本地方法服务,Java虚拟机栈为虚拟机执行Java方法(字节码)服务
Java堆
- 对于Java应用程序来说,Java堆(Java Heap)是虚拟机所管理的内存中最大的一块。Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,Java世界里“几乎”所有的对象实例都在这里分配内存
- 当堆内存没有足够空间给对象实例分配内存并且堆内存无法扩展时都会抛出OOM异常
方法区
- 方法区与Java堆类似,也是各个线程共享的区域,它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据
- 通常用别名“非堆”来与Java堆做区分
- 当方法区没有足够空间满足内存分配要求时,也会抛出OOM异常
运行时常量池
- 运行时常量池是方法区的一部分,用于存放编译期生成的各种字面量与符号引用
- 受方法区内存限制,当常量池无法再申请到内存时会抛出OOM异常
直接内存
- 直接内存并不是运行时数据区的一部分,但它受总内存限制,也可能会出现OOM异常
HotSpot虚拟机对象探秘
对象的创建
在类加载检查通过后,接下来虚拟机将为新生对象分配内存,而内存分配方式主要有两种:
-
指针碰撞
-
空闲列表
对象的内存布局
在HotSpot虚拟机里,对象在堆内存中的存储布局可以划分为三个部分:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)
-
对象头
- 存储对象自身运行时数据(Mark Word),如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等
- 类型指针(对象指向其类型元数据的指针)
-
实例数据
- 对象真正存储的有效信息,即代码中的各类型字段内容
-
对齐填充
- 由于HotSpot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,即任何对象大小都是8字节的整数倍,故实例数据部分没有对齐的话需要对齐填充来充当占位符补全
对象的访问定位
Java程序会通过栈上的reference(一个指向对象的引用)数据来操作堆上的具体对象,具体的访问方式由虚拟机实现。
主流访问方式主要有两种:
-
句柄
-
直接指针
实战OOM异常
采用不同的JDK及垃圾回收收集器均可能会产生不同的结果,以下实战均以JDK8,ParallelGC垃圾收集器为例运行代码
# 查看默认垃圾收集器VM参数
-XX:+PrintCommandLineFlags -version
Java堆溢出
只要不断创建对象实例,同时又避免垃圾收集器回收,这样达到最大堆容量限制后便能产生OOM异常
public class Hello {
/**
* -Xms:最小堆内存20M -Xmx:最大堆内存20M 两者设置一样避免自动扩展
* VM参数:-Xms20M -Xmx20M -XX:+HeapDumpOnOutOfMemoryError
*/
public static void main(String[] args) {
List<Hello> hellos = new ArrayList<>();
while (true) {
hellos.add(new Hello());
}
}
}
Java虚拟机栈和本地方法栈溢出
《Java虚拟机规范》明确允许Java虚拟机实现自行选择是否支持栈的动态扩展,而HotSpot虚拟机的选择是不支持扩展,所以除非在创建线程申请内存时就因无法获得足够内存而出现OutOfMemoryError异常,否则在线程运行时是不会因为扩展而导致内存溢出的,只会因为栈容量无法容纳新的栈帧而导致StackOverflowError异常
- 使用-Xss参数减少栈容量
public class Hello {
/**
* VM参数:-Xss128k
*/
private int stackLength = 1;
public void stackLeak() {
stackLength++;
// 递归调用方法,不断入栈
stackLeak();
}
public static void main(String[] args) throws Throwable {
Hello oom = new Hello();
try {
// 调用方法,入栈
oom.stackLeak();
} catch (Throwable e) {
System.out.println("stack length:" + oom.stackLength);
throw e;
}
}
}
- 定义了大量的本地变量,增大此方法帧中本地变量表的长度(即调整栈帧大小)
public class Hello {
private static int stackLength = 0;
public static void test() {
// 局部变量多,栈帧增大
long unused1, unused2, unused3, unused4, unused5,
unused6, unused7, unused8, unused9, unused10,
unused11, unused12, unused13, unused14, unused15,
unused16, unused17, unused18, unused19, unused20,
unused21, unused22, unused23, unused24, unused25,
unused26, unused27, unused28, unused29, unused30,
unused31, unused32, unused33, unused34, unused35,
unused36, unused37, unused38, unused39, unused40,
unused41, unused42, unused43, unused44, unused45,
unused46, unused47, unused48, unused49, unused50,
unused51, unused52, unused53, unused54, unused55,
unused56, unused57, unused58, unused59, unused60,
unused61, unused62, unused63, unused64, unused65,
unused66, unused67, unused68, unused69, unused70,
unused71, unused72, unused73, unused74, unused75,
unused76, unused77, unused78, unused79, unused80,
unused81, unused82, unused83, unused84, unused85,
unused86, unused87, unused88, unused89, unused90,
unused91, unused92, unused93, unused94, unused95,
unused96, unused97, unused98, unused99, unused100;
stackLength++;
// 递归调用,不断入栈
test();
unused1 = unused2 = unused3 = unused4 = unused5 = unused6 = unused7 = unused8 = unused9 = unused10
= unused11 = unused12 = unused13 = unused14 = unused15 = unused16 = unused17 = unused18 = unused19
= unused20 = unused21 = unused22 = unused23 = unused24 = unused25 = unused26 = unused27 = unused28
= unused29 = unused30 = unused31 = unused32 = unused33 = unused34 = unused35 = unused36 = unused37
= unused38 = unused39 = unused40 = unused41 = unused42 = unused43 = unused44 = unused45 = unused46
= unused47 = unused48 = unused49 = unused50 = unused51 = unused52 = unused53 = unused54 = unused55
= unused56 = unused57 = unused58 = unused59 = unused60 = unused61 = unused62 = unused63 = unused64
= unused65 = unused66 = unused67 = unused68 = unused69 = unused70 = unused71 = unused72 = unused73
= unused74 = unused75 = unused76 = unused77 = unused78 = unused79 = unused80 = unused81 = unused82
= unused83 = unused84 = unused85 = unused86 = unused87 = unused88 = unused89 = unused90 = unused91
= unused92 = unused93 = unused94 = unused95 = unused96 = unused97 = unused98 = unused99 = unused100 = 0;
}
public static void main(String[] args) {
try {
test();
} catch (Error e) {
System.out.println("stack length:" + stackLength);
throw e;
}
}
}
方法区和运行时常量池溢出
- 方法区容量控制
public class Hello {
/**
* JDK8前VM参数: -XX:PermSize=6M -XX:MaxPermSize=6M
* JDK8VM参数:-XX:MetaspaceSize=6M -XX:MaxMetaspaceSize=6M
*/
public static void main(String[] args) {
// 使用Set保持常量池引用,避免Full GC回收常量池行为
Set<String> set = new HashSet<>();
// 在short范围内足以让6M大小的PermSize(永久代,JDK8前有,JDK8及之后版本都已采用元空间替代)产生OOM了
short i = 0;
// JDK8前,抛出OOM异常
// JDK8下,正常情况会进入死循环,并不会抛出任何异常
while (true) {
// String.intern()进入字符串常量池
set.add(String.valueOf(i++).intern());
}
}
}
上述代码在JDK8环境下并不会抛出任何异常,这是因为字符串常量池已经被移至Java堆之中,控制方法区容量的大小对Java堆并没有什么影响
String.intern()
方法介绍:如果字符串常量池中已经包含一个等于此String对象的字符串,则返回常量池中这个字符串的String对象;否则,将此String对象包含的字符复制添加到常量池中,并返回此String对象的引用
/**
* JDK6:false false
* JDK8:true false
*/
public static void main(String[] args) {
String str1 = new StringBuilder("计算机").append("软件").toString();
System.out.println(str1.intern() == str1);
String str2 = new StringBuilder("ja").append("va").toString();
System.out.println(str2.intern() == str2);
}
-
JDK6因为
new StringBuilder()
分配到的是Java堆内存,而String.intern()
会把首次遇到的字符串复制到的是字符串常量池(方法区),所以都是false -
JDK8因为字符串常量池都移动到了Java堆中,
new StringBuilder()
分配到Java堆内存后,字符串常量池也记录到了首次遇到的实例引用,那么String.intern()
和new StringBuilder()
都是同一个了(true);而因为java
字符串在sun.misc.Version
类加载时已进入常量池,那么intern()
方法就返回当前常量池的String对象,new StringBuilder()
在堆中重新创建了一个,自然也就不一样了(false) -
方法区的主要职责是用于存放类型的相关信息,如类名、访问修饰符、常量池、字段描述、方法描述等,因此运行时产生大量的类填满方法区也可以造成方法区溢出
/*
* 借助CGLib造成方法区溢出
* VM参数:-XX:MetaspaceSize=10M -XX:MaxMetaspaceSize=10M
*/
public class Hello {
public static void main(String[] args) {
while (true) {
// 创建CgLib增强对象
Enhancer enhancer = new Enhancer();
// 设置被代理的类
enhancer.setSuperclass(OOMObject.class);
enhancer.setUseCache(false);
// 指定拦截器
enhancer.setCallback(new MethodInterceptor() {
@Override
public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable {
return proxy.invokeSuper(obj, args);
}
});
// 创建代理对象
enhancer.create();
}
}
static class OOMObject {
}
}
本机直接内存溢出
直接内存(Direct Memory)的容量大小可通过-XX:MaxDirectMemorySize参数来指定,如果不去指定,则默认与Java堆最大值(由-Xmx指定)一致
// 使用unsafe分配本机内存
public class Hello {
// VM参数:-Xmx20M -XX:MaxDirectMemorySize=10M
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);
}
}
}
参考资料
《深入理解Java虚拟机》(第三版) 第2章:Java内存区域与内存溢出异常