对于Java程序员来说,Java虚拟机是再熟悉不过了,尤其是对Hotspot VM最熟悉了,因为它是Sun JDK和OpenJDK中所带的虚拟机,也是目前使用范围最广泛的Java虚拟机。相比C、C++开发人员来说,有了JVM,Java程序员不再需要为每一个new操作去写配对的delete/free代码,不容易出现内存泄漏和内存溢出的问题。但也正因为内存控制完全交由JVM,一旦出现内存泄漏和溢出的问题,如果没有深入了解过JVM的工作机制,那么排查问题将会很困难,所以我准备对JVM相关的东西做一下研究整理。
如上图(图片摘自 深入理解Java虚拟机),堆内存和方法区的内容是所有线程共享的,虚拟机栈、本地方法栈和程序计数器是每个线程独有的。其中方法区就是jdk中永久代(jdk1.7之后叫metaspace),本地方法栈一般是指java native方法。
接下来,就对最为流程的JVM进行详细的研究学习
对象创建的内存分配方式
一般有两种分配方式:一种是指针碰撞,但前提是堆内存规整的情况下;
另一种是空闲列表(可以在堆内存不规整的情况下)。
Serial、ParNew(带压缩整理) GC算法,通常采用指针碰撞;CMS(标记清除)算法,通常采用空闲列表。
堆内存分配并发处理
一种是对内存空间的动作进行同步处理,实际上JVM采用CAS+失败重试的方式;
另一种是把内存分配的动作按照线程分在不同空间之中进行,也就是说每个线程在java堆中预先分配一小块内存,称为本地线程分配缓冲(TLAB)。虚拟机是否使用TLAB,可通过-XX: +/-UseTLAB来设定,默认是开启的。
对象的内存布局
可分为3块区域:对象头、实例数据、对齐填充;
**对象头:**包含两部分信息,一是用于存储对象自身的运行时数据(如:哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳),这些又称为“Mark Word”;另一部分是类型指针,即对象指向它的类元数据的指针,JVM通过这个指针来确定这个对象是哪个类的实例。注意:如果是java数组的话,对象头中还必须有一块用于记录数组长度的数据,因为JVM可以通过普通java对象的元数据信息确定java对象的大小,但是从数组的元数据中却无法确定数组的大小。
**实例数据:**是对象真正存储的有效信息,也是在程序中所定义的各种类型的字段内容。
对齐填充: 因为HotspotVM要求对象起始地址必须是8字节的整数倍,也就是说对象大小必须是8字节的整数倍,所以对象实例数据部分没有对齐时,就需要通过对齐填充来补全。
对象的访问定位方式
有两种方式:一种是使用句柄访问,在java堆中将会分出一块内存来作为句柄池,栈中reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据和类型数据各自的具体地址信息,如下图(图片来自 深入理解Java虚拟机):
另一种方式是使用直接指针访问,reference中存储的直接就是对象地址
说明:两者优缺点
这两种方式各有优势,使用句柄来访问,最大的好处就是reference中存储的是稳定的句柄地址,不管对象怎么被移动(垃圾收集时移动对象是非常普遍的行为),只需要改变句柄中的实例数据指针,而reference本身不需要修改;
使用指针访问,最大的好处就是速度更快,它节省了一次指针定位的时间开销,但是对象变化的频繁,会带来栈指针变化的成本。
/**
* @className: JavaHeapOOM
* @summary: Java堆内存溢出案例
* @Description: OOM
* VM 参数:-Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
* 说明:以上参数限制java堆的大小为20MB,不可扩展(堆最大值和最小值设置为一样的)
* -XX:+HeapDumpOnOutOfMemoryError参数的作用是让JVM出现内存溢出异常时,
* Dump出当前的内存堆转储快照以便事后进行分析。
* @author: helon
* date: 2018/10/28 1:13 PM
* version: v1.0
*/
public class JavaHeapOOM {
static class OOMObject {
}
public static void main(String[] args) {
List<OOMObject> list = new ArrayList<>();
while (true) {
list.add(new OOMObject());
}
}
}
在启动运行之前,我们要设置以下idea的VM参数,如下截图:
配置完成后开始启动main方法,发现报出了OOM,如下信息:
java.lang.OutOfMemoryError: Java heap space
Dumping heap to java_pid2342.hprof ...
Heap dump file created [27819705 bytes in 0.095 secs]
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at java.util.Arrays.copyOf(Arrays.java:3210)
at java.util.Arrays.copyOf(Arrays.java:3181)
at java.util.ArrayList.grow(ArrayList.java:265)
at java.util.ArrayList.ensureExplicitCapacity(ArrayList.java:239)
at java.util.ArrayList.ensureCapacityInternal(ArrayList.java:231)
at java.util.ArrayList.add(ArrayList.java:462)
at com.helon.JavaHeapOOM.main(JavaHeapOOM.java:27)
Dumping heap to java_pid2342.hprof … 报错之后dump了一份hprof堆转储快照文件,可以使用Eclipse的MAT(Memory Analyzer Tool)工具进行分析(idea貌似没有类似的工具,找了好久没有找到…),确认对象是否是必要的,也就是说先分析清楚到底是出现了内存泄漏(Memory Leak)还是内存溢出(Memory Overflow)。如果是内存泄漏了,那么可以通过工具查看泄漏对象到GC ROOTS(可达性分析对象到GC ROOTS的引用链关系,包括虚拟机栈中引用的对象、方法区中类静态属性引用的对象、方法区中常量引用的对象、本地方法栈中JNI引用的对象)的引用链,看是什么原因导致对象没有被回收。如果不是内存泄漏,那么就要分析一下,堆内存参数配置对比物理内存是否可以适当调大,或者尝试减少程序运行期的内存消耗。
/**
* @className: JavaVMStackSOF
* @summary: 虚拟机栈和本地方法栈OOM测试
* @Description: 设置虚拟机栈容量 -Xss, -Xoss为本地方法栈大小,但设置不小
* 设置 -Xss大小为160k
* 单线程运行
* @author: helon
* date: 2018/10/28 4:09 PM
* version: v1.0
*/
public class JavaVMStackSOF {
private int stackLength = 1;
public void stackLeak() {
stackLength++;
stackLeak();
}
public static void main(String[] args) throws Throwable{
JavaVMStackSOF oom = new JavaVMStackSOF();
try {
oom.stackLeak();
} catch (Throwable e) {
System.out.println("====栈深度:" + oom.stackLength);
throw e;
}
}
}
在代码运行前,首先配置一下VM参数,将虚拟机栈大小设置为160k,如下截图:
运行main方法,每次报错都是StackOverflowError,如果定义大量的本地变量,结果也是抛出StackOverflowError:
====栈深度:772
Exception in thread "main" java.lang.StackOverflowError
at com.helon.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:18)
at com.helon.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:19)
at com.helon.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:19)
at com.helon.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:19)
at com.helon.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:19)
at com.helon.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:19)
...
测试结果表明:在单线程情况下,无论是由于栈帧太大还是虚拟机栈容量太小,当内存无法分配时,JVM都是抛出StackOverflowError异常。
那么我们在多线程情况下做个测试,如下代码:
/**
* @className: JavaVMStackOOM
* @summary: 多线程场景下导致虚拟机栈OOM
* @Description: 设置-Xss2m
* @author: helon
* date: 2018/10/28 4:44 PM
* version: v1.0
*/
public class JavaVMStackOOM {
private void dontStop() {
while (true) {
}
}
public void stackLeakByThread() {
while (true) {
new Thread(() -> {
dontStop();
}).start();
}
}
public static void main(String[] args) throws Throwable {
JavaVMStackOOM oom = new JavaVMStackOOM();
oom.stackLeakByThread();
}
}
该代码执行过程中,可能会造成操作系统卡死,最终运行结果:
Exception in thread "main" java.lang.OutOfMemoryError: unable to create new native thread
测试表明,在多线程情况下可以产生栈内存溢出异常,但是这样产生的内存溢出异常与栈空间是否足够大,并不存在任何联系,或者说为每个线程的栈分配的内存越大,反而越容易产生内存溢出异常。因为堆内存总大小减去Xmx最大堆容量,再减去MaxPermSize最大方法区容量,程序计数器可以忽略不计,那么剩下的内存就是虚拟机栈和本地方法栈的内存大小了。如果每个线程分配到的内存容量越大,那么可以建立的线程数量就越少。
/**
* @className: RuntimeConstantPoolOOM
* @summary: JDK1.6及以前版本 使用以下案例可以测出OOM:PermGen space
* @Description: 设置 -XX:PermSize=10M -XX:MaxPermSize=10M
* @author: helon
* date: 2018/10/28 5:30 PM
* version: v1.0
*/
public class RuntimeConstantPoolOOM {
public static void main(String[] args) {
//使用List保持着常量池引用,避免Full GC回收常量池行为
List<String> list = new ArrayList<>();
//10MB的PermSize在integer范围内足够产生OOM了
int i = 0;
while (true) {
list.add(String.valueOf(i++).intern());
}
}
}
首先解释下String的intern()方法,它是一个Native方法,作用是:如果字符串常量池中已经包含了一个等于此String对象的字符串,那么就返回常量池中这个字符串的String对象,如果不包含就将此String对象包含的字符串添加到常量池中,并返回引用。
以上代码需要在JDK1.6及之前版本能够测出OOM异常,我本机为JDK1.8环境,测试不出异常,因为在JDK1.7及以上版本已经去“永久代”,设置PermSize参数是无效的。并且,JDK1.7之后,intern方法不会再复制对象实例,只是在常量池中记录首次出现的引用。
除了以上测试方式,还可以借助CGLib直接操作字节码运行时生成大量的动态类,让其填满方法区,直到溢出,参照以下代码:
/**
* @className: JavaMethodAreaOOM
* @summary: 借助CGLib使方法区出现内存溢出异常
* @Description: JDK1.6及之前版本,设置-XX:PermSize=10m -XX:MaxPermSize=10m
* JDK1.7及之后版本,设置-XX:MetaspaceSize=10m -XX:MaxMetaspaceSize=10m
* @author: helon
* date: 2018/10/28 6:01 PM
* version: v1.0
*/
public class JavaMethodAreaOOM {
static class OOMObject {
}
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();
}
}
}
启动main方法前,先设置一下VM参数:JDK1.6及之前版本,设置-XX:PermSize=10m -XX:MaxPermSize=10m;JDK1.7及之后版本,设置-XX:MetaspaceSize=10m -XX:MaxMetaspaceSize=10m。根据本地环境来设置不同的参数,我使用的是JDK1.8环境,设置的是metaspace的大小,设置完毕运行main方法,运行一小会时间结果抛出了OOM,如下错误信息:
Exception in thread "main" java.lang.OutOfMemoryError: Metaspace
其实,在当前的很多主流框架里,如:Spring、Hibernate在对类进行增强的时候,都会使用到CGLib这类字节码技术,增强类越多,就需要越大的方法区来保证动态生成的Class可以加载入内存。如果滥用类似字节码增强技术或动态语言(如groovy等),并未做好控制的话,很可能会导致以上异常。
好了,以上就是JVM常见的几种OOM,并且也总结了虚拟机中的内存划分,后续我还会经常分享一些JVM的其他知识,如果有疑义的地方,欢迎指正批评。