首先我们先回顾下JVM内存模型:
按照JVM规范,JAVA虚拟机在运行时会管理以下的内存区域:
- 程序计数器:当前线程执行的字节码的行号指示器,线程私有
- JAVA虚拟机栈:Java方法执行的内存模型,每个Java方法的执行对应着一个栈帧的进栈和出栈的操作。
- 本地方法栈:类似“ JAVA虚拟机栈 ”,但是为native方法的运行提供内存环境。
- JAVA堆:对象内存分配的地方,内存垃圾回收的主要区域,所有线程共享。可分为新生代,老生代。
- 方法区:用于存储已经被JVM加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。Hotspot中的“永久代”。1.8称为Metaspace元空间
- 运行时常量池:方法区的一部分,存储常量信息,如各种字面量、符号引用等。
- 直接内存:并不是JVM运行时数据区的一部分, 可直接访问的内存, 比如NIO会用到这部分。
按照JVM规范,除了程序计数器不会抛出OOM外,其他各个内存区域都可能会抛出OOM。
最常见的OOM情况有以下三种:
- java.lang.OutOfMemoryError: Java heap space ------>java堆内存溢出,此种情况最常见,一般由于内存泄露或者堆的大小设置不当引起。对于内存泄露,需要通过内存监控软件查找程序中的泄露代码,而堆大小可以通过虚拟机参数-Xms,-Xmx等修改。
- java.lang.OutOfMemoryError: PermGen/Metaspace space ------>java永久代/元空间溢出,即方法区溢出了,一般出现于大量Class或者jsp页面,或者采用cglib等反射机制的情况,因为上述情况会产生大量的Class信息存储于方法区。此种情况可以通过更改方法区的大小来解决,使用类似-XX:MetaspaceSize=512m -XX:MaxMetaspaceSize=512m的形式修改。另外,过多的常量尤其是字符串也会导致方法区溢出(JDK1.7之前,1.7之后StringTable挪到堆内存中)。
- java.lang.StackOverflowError ------> 不会抛OOM error,但也是比较常见的Java内存溢出。JAVA虚拟机栈溢出,一般是由于程序中存在死循环或者深度递归调用造成的,栈大小设置太小也会出现此种溢出。可以通过虚拟机参数-Xss来设置栈的大小。
我们后续将重点围绕以上三块区域进行重点讲解以及分析,一步一图彻底掌握。
这里我们先将直接内存的概念以及直接内存为什么会发生OOM给大家做一个解释普及。
JVM内存模型之直接内存
直接内存(Direct Memory) 并不是虚拟机运行时数据区的一部分, 也不是《Java虚拟机规范》 中定义的内存区域。 但是这部分内存也被频繁地使用, 而且也可能导致OutOfMemoryError异常出现, 所以我们放到这里一起讲解。
在JDK 1.4中新加入了NIO(New Input/Output) 类, 引入了一种基于通道(Channel) 与缓冲区(Buffer) 的I/O方式, 它可以使用Native函数库直接分配堆外内存, 然后通过一个存储在Java堆里面的DirectByteBuffer对象作为这块内存的引用进行操作。 这样能在一些场景中显著提高性能, 因为避免了在Java堆和Native堆中来回复制数据。
这里简单给大家回顾下NIO的知识:
我们不使用ByteBuff的方式读取本地磁盘文件,是通过先将磁盘文件读取到系统内存中的缓冲区,而系统中的缓存区Java代码是无法直接操作的,需要借助于本地函数进行copy到 Java内存的缓冲区byte[] 中。
直接内存的划分相当于就是直接在系统内存中开辟出了一块空间,直接供Java代码使用,避免了数据拷贝带来的效率问题
显然, 本机直接内存的分配不会受到Java堆大小的限制, 但是, 既然是内存, 则肯定还是会受到本机总内存(包括物理内存、 SWAP分区或者分页文件) 大小以及处理器寻址空间的限制, 一般服务器管理员配置虚拟机参数时, 会根据实际内存去设置-Xmx等参数信息, 但经常忽略掉直接内存, 使得各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制) , 从而导致动态扩展时出现
OutOfMemoryError异常。
因此我们对直接内存做一个小结:
- 本机直接内存的分配不会受到Java 堆大小的限制,受到本机总内存大小限制
- 直接内存也可以由 -XX:MaxDirectMemorySize 指定
- 直接内存申请空间耗费更高的性能
- 直接内存IO读写的性能要优于普通的堆内存,常见于NIO操作时,用于数据缓冲区
当我们需要频繁访问大的内存而不是申请和释放空间时,通过使用直接内存可以提高性能。
Java创建直接内存
Java中ByteBuffer用于创建内存缓存,其类的继承关系如下:
其中HeapByteBuffer用于创建JVM堆内缓存区,DirectByteBuffer用于创建Native缓存区。通过调用ByteBuffer
的静态方法allocate
和allocateDirect
方法分别创建两种缓存区。
MappedByteBuffer
是NIO提供的文件内存映射的实现方案,可以把整个文件或文件段映射到Native堆内存。MappedByteBuffer
是抽象类,DirectByteBuffer
才是提供实际功能的其直接子类,他同时实现了DirectBuffer
接口,此接口提供了cleaner
用于GC管理。
代码演示
这里我们通过以下代码来演示Java在直接本机内存中进行分配对象与回收:
public class ByteBufferDemo {
static int _1Gb=1024*1024*1024;
public static void main(String[] args) throws IOException {
System.out.println("准备申请直接内存前");
System.in.read();
//拿到 DirectByteBuffer 这个对象来操作直接内存1G
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(_1Gb);
System.out.println("分配直接内存1G完毕");
System.in.read();
System.out.println("开始释放1G直接内存");
byteBuffer = null;
System.out.println("开始准备垃圾回收");
System.in.read();
//手动触发Full GC
System.gc();
System.out.println("垃圾回收完毕");
System.in.read();
}
}
1) 程序运行起来,准备申请直接内存前
我们看下我们目前的内存使用:
目前该Java类运行起来后只占据了17.5M
2)通过ByteBuff申请1G直接内存
代码执行:ByteBuffer byteBuffer = ByteBuffer.allocateDirect(_1Gb);
后内存如下:
瞬间吃掉了内存1G
3)byteBuffer = null;
控制台打印如下:
准备申请直接内存前
分配直接内存1G完毕
开始释放1G直接内存
开始准备垃圾回收
注意这一步仅仅只是将 byteBuff 置为了 null ,内存还并没未被释放!
4)System.gc();
手动触发垃圾回收:
可以看到该行代码执行完毕后,直接内存才真正被释放完毕,回收掉byteBuffer对象
直接内存溢出测试-DirectByteBuffer
DirectByteBuffer在对内存进行操作的时候是提供了对堆外内存的自动回收的
测试代码如下,运行时添加参数 -verbose:gc -XX:+PrintGCDetails -XX:MaxDirectMemorySize=20M
/**
* vm参数:-verbose:gc -XX:+PrintGCDetails -XX:MaxDirectMemorySize=20M
*/
public class ByteBufferOOMTest {
static int _1Gb=1024*1024*1024;
public static void main(String[] args) {
while(true){
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(_1Gb);
}
}
}
控制台输出 OOM : Direct buffer memory!!!
DirectByteBuffer源码分析
通过查看DirectByteBuffer源码可以发现,其实内部底层使用的是 Unsafe类来进行操作的:
通过借助于一个 cleaner线程对象进行垃圾回收,里面提供了一个clean()方法:
该方法用来开启线程的执行,对应的run方法中调用了 UNSAFE的freeMemory() 方法对直接内存进行释放
如果仔细查看Cleaner这个对象 你发现其实该对象使用了虚引用的技术:
public class Cleaner extends PhantomReference<Object> {
private static final ReferenceQueue<Object> dummyQueue = new ReferenceQueue();
在之前讲解对象引用的时候我们专门说过虚引用:
为一个对象设置虚引用关联的唯一目的在于跟踪垃圾回收过程。比如:能在这个对象被收集器回收时收到一个系统通知。
由于虚引用可以跟踪对象的回收时间,因此,也可以将一些资源释放操作放置在虚引用中执行和记录。
直接内存溢出测试-Unsafe
因此我们可以直接使用Unsafe来进行直接内存的申请:
/**
* vm参数:-verbose:gc -XX:+PrintGCDetails -XX:MaxDirectMemorySize=20M
*/
public class UnsafeOOMTest {
private static final int _1M = 1024 * 1024;
public static void main(String[] args) throws NoSuchFieldException, IllegalAccessException {
// 通过反射获取rt.jar下的Unsafe类
Field theUnsafeInstance = Unsafe.class.getDeclaredFields()[0];
theUnsafeInstance.setAccessible(true);
// (Unsafe) theUnsafeInstance.get(null);是等价的
Unsafe unsafe = (Unsafe) theUnsafeInstance.get(Unsafe.class);
while(true) {
unsafe.allocateMemory(_1M);
}
}
}
运行后依然爆出OOM异常:
注意 :使用JDK内部的Unsafe类直接使用堆外内存,JVM是不会自动进行内存回收的! 需要配合unsafe.freeMemory(point); 方法手动进行回收释放
直接内存OOM小结:
当我们想在堆外内存中直接申请空间进行分配时,可以使用 DirectByteBuffer来进行操作,当遇到JVM FullGC的时候会自动进行堆外内存的回收,或者我们手动调用 System.GC() 进行主动回收。
由于堆外内存并没有 JVM 协助管理内存,需要我们自己来管理堆外内存,为了防止内存溢出,避免一直没有 Full GC,最终导致物理内存被耗完,建议大家在配置JVM参数的时候,通过:-XX:MaxDirectMemerySize来指定,当达到阈值的时候,调用system.gc来进行一次full gc,把那些没有被使用的直接内存回收掉。