内存是好东西,我们常听堆内存,很多人却不知道还有一个堆外内存。
那这两个都是个啥玩意呢?且让本帅博主今天给你好好说道说道。
一、堆内内存
那什么东西是堆内存呢?我们来看看官方的说法。
“Java 虚拟机具有一个堆(Heap),堆是运行时数据区域,所有类实例和数组的内存均从此处分配。堆是在 Java 虚拟机启动时创建的。” |
也就是说,平常我们老遇见的那位,JVM启动时分配的,就叫作堆内存(即堆内内存)。
对象的堆内存由称为垃圾回收器的自动内存管理系统回收。
此外,堆的内存不需要是连续空间,因此堆的大小没有具体要求,既可以固定,也可以扩大和缩小。
我们在jvm参数中只要使用-Xms,-Xmx等参数就可以设置堆的大小和最大值,理解jvm的堆还需要知道下面这个公式:
堆内内存 = 新生代+老年代+持久代 |
如下图:
在使用堆内内存(on-heap memory)的时候,完全遵守JVM虚拟机的内存管理机制,采用垃圾回收器(GC)统一进行内存管理,GC会在某些特定的时间点进行一次彻底回收,也就是Full GC,GC会对所有分配的堆内内存进行扫描。
注意:在这个过程中会对JAVA应用程序的性能造成一定影响,还可能会产生Stop The World。
二、堆外内存
显然,看名字就知道堆外内存与堆内内存是相对应的:Java 虚拟机管理堆之外的内存,称为非堆内存,即堆外内存。
换句话说:堆外内存就是把内存对象分配在Java虚拟机的堆以外的内存,这些内存直接受操作系统管理(而不是虚拟机),这样做的结果就是能够在一定程度上减少垃圾回收对应用程序造成的影响。
那堆外内存都有哪些东西呢?
Java 虚拟机具有一个由所有线程共享的方法区。方法区属于非堆内存。它存储每个类结构,如运行时常数池、字段和方法数据,以及方法和构造方法的代码。它是在 Java 虚拟机启动时创建的。
方法区在逻辑上属于堆,但 Java 虚拟机实现可以选择不对其进行回收或压缩。与堆类似,方法区的内存不需要是连续空间,因此方法区的大小可以固定,也可以扩大和缩小。。
除了方法区外,Java 虚拟机实现可能需要用于内部处理或优化的内存,这种内存也是非堆内存。例如,JIT 编译器需要内存来存储从 Java 虚拟机代码转换而来的本机代码,从而获得高性能。
下面我们来看看堆外内存如何申请与释放。
三、堆外内存的申请和释放
JDK的ByteBuffer
类提供了一个接口allocateDirect(int capacity)进行堆外内存的申请。
底层通过unsafe.allocateMemory(size)实现,Netty、Mina等框架提供的接口也是基于ByteBuffer封装的。
现在我们先看看在JVM层面是如何实现堆外内存申请的。
可以发现,unsafe.allocateMemory(size)的最底层是通过malloc
方法申请的,但是这块内存需要进行手动释放,JVM并不会进行回收,幸好Unsafe
提供了另一个接口freeMemory
可以对申请的堆外内存进行释放。
看完堆外内存申请的底层实现,想必大家对它的实现就有了一些基础了解。
接下来我们再看看DirectByteBuffer()的构造方法。
其构造方法如下:
DirectByteBuffer(int cap) {
super(-1, 0, cap, cap);
//内存是否按页分配对齐
boolean pa = VM.isDirectMemoryPageAligned();
//获取每页内存大小
int ps = Bits.pageSize();
//分配内存的大小,如果是按页对齐方式,需要再加一页内存的容量
long size = Math.max(1L, (long)cap + (pa ? ps : 0));
//用Bits类保存总分配内存(按页分配)的大小和实际内存的大小
Bits.reserveMemory(size, cap);
long base = 0;
try {
//在堆外内存的基地址,指定内存大小
base = unsafe.allocateMemory(size);
} catch (OutOfMemoryError x) {
Bits.unreserveMemory(size, cap);
throw x;
}
unsafe.setMemory(base, size, (byte) 0);
//计算堆外内存的基地址
if (pa && (base % ps != 0)) {
// Round up to page boundary
address = base + ps - (base & (ps - 1));
} else {
address = base;
}
cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
att = null;
}
从上面的代码我们可以知道,在Cleaner 内部中通过一个列表,维护了针对每一个 directBuffer 的一个回收堆外内存的线程对象(Runnable),而回收操作就是发生在 Cleaner 的 clean() 方法中。
Cleaner源码如下:
private Cleaner(Object var1, Runnable var2) {
super(var1, dummyQueue);
this.thunk = var2;
}
public static Cleaner create(Object var0, Runnable var1) {
return var1 == null ? null : add(new Cleaner(var0, var1));
}
public void clean() {
if (remove(this)) {
try {
this.thunk.run(); //此处会调用Deallocator,见下个类
} catch (final Throwable var2) {
AccessController.doPrivileged(new PrivilegedAction<Void>() {
public Void run() {
if (System.err != null) {
(new Error("Cleaner terminated abnormally", var2)).printStackTrace();
}
System.exit(1);
return null;
}
});
}
}
}
Deallocator的源码如下:
private static class Deallocator implements Runnable {
private static Unsafe unsafe = Unsafe.getUnsafe();
private long address;
private long size;
private int capacity;
private Deallocator(long address, long size, int capacity) {
assert (address != 0);
this.address = address;
this.size = size;
this.capacity = capacity;
}
public void run() {
if (address == 0) {
return;
}
unsafe.freeMemory(address);//unsafe提供的方法释放内存
address = 0;
Bits.unreserveMemory(size, capacity);
}
}
四、堆外内存的回收机制
上文说到,“unsafe.allocateMemory(size)的最底层是通过malloc
方法申请的,但是这块内存需要进行手动释放,JVM并不会进行回收,幸好Unsafe
提供了另一个接口freeMemory
可以对申请的堆外内存进行释放。”。那岂不是每一次申请堆外内存的时候,都需要在代码中显式释放吗?
很明显,并不是这样的,这种情况的出现对于Java这门语言来说显然不够合理。那既然JVM不会管理这些堆外内存,它们又是怎么回收的呢?
这里就要祭出大杀器了:DirectByteBuffer。
JDK中使用DirectByteBuffer
对象来表示堆外内存,每个DirectByteBuffer
对象在初始化时,都会创建一个对应的Cleaner
对象,这个Cleaner
对象会在合适的时候执行unsafe.freeMemory(address)
,从而回收这块堆外内存。
当初始化一块堆外内存时,对象的引用关系如下:
其中first
是Cleaner
类的静态变量,Cleaner
对象在初始化时会被添加到Clener
链表中,和first
形成引用关系,ReferenceQueue
是用来保存需要回收的Cleaner
对象。
如果该DirectByteBuffer
对象在一次GC中被回收了,即
此时,只有Cleaner
对象唯一保存了堆外内存的数据(开始地址、大小和容量),在下一次FGC时,把该Cleaner
对象放入到ReferenceQueue
中,并触发clean
方法。
Cleaner
对象的clean
方法主要有两个作用:
- 把自身从
Cleaner
链表删除,从而在下次GC时能够被回收 - 释放堆外内存
源码如下:
public void run() {
if (address == 0) {
// Paranoia
return;
}
unsafe.freeMemory(address);
address = 0;
Bits.unreserveMemory(size, capacity);
}
看到这里,可能有人会想,如果JVM一直没有执行FGC的话,无效的Cleaner
对象就无法放入到ReferenceQueue中,从而堆外内存也一直得不到释放,无效内存就会很大,那怎么办?
这个倒不用担心,那些大神们当然早就考虑到这一种情况了。
其实,在初始化DirectByteBuffer
对象时,会自动去判断,如果堆外内存的环境很友好,那么就申请堆外内存;如果当前堆外内存的条件很苛刻时(即有很多无效内存没有得到释放),这时候就会主动调用System.gc()
强制执行FGC,从而释放那些无效内存。
为了避免这种悲剧的发生,也可以通过-XX:MaxDirectMemorySize来指定最大的堆外内存大小,当使用达到了阈值的时候将调用System.gc来做一次full gc,以此来回收掉没有被使用的堆外内存。
源码如下:
当然,源程序毕竟不是万能的,做项目的时候经常有千奇百怪的情况出现。
比如很多线上环境的JVM参数有-XX:+DisableExplicitGC
,导致了System.gc()
等于一个空函数,根本不会触发FGC,因此在使用堆外内存时,要格外小心,防止内存一直得不到释放,造成线上故障。这一点在使用Netty框架时需要格外注意。
总而言之,不论是什么东西,都不是绝对安全的。对于各类代码,我们都得多加留心。
五、System.gc的作用有哪些
使用了System.gc的作用是什么?
- 做一次full gc
- 执行后会暂停整个进程。
- System.gc我们可以禁掉,使用-XX:+DisableExplicitGC,
其实一般在cms gc下我们通过-XX:+ExplicitGCInvokesConcurrent也可以做稍微高效一点的gc,也就是并行gc。 - 最常见的场景是RMI/NIO下的堆外内存分配等
注:
如果我们使用了堆外内存,并且用了DisableExplicitGC设置为true,那么就是禁止使用System.gc,这样堆外内存将无从触发极有可能造成内存溢出错误(这种情况在四中有提及),在这种情况下可以考虑使用ExplicitGCInvokesConcurrent参数。
说起Full gc我们最先想到的就是stop thd world,这里要先提到VMThread,在jvm里有这么一个线程不断轮询它的队列,这个队列里主要是存一些VM_operation的动作,比如最常见的就是内存分配失败要求做GC操作的请求等,在对gc这些操作执行的时候会先将其他业务线程都进入到安全点,也就是这些线程从此不再执行任何字节码指令,只有当出了安全点的时候才让他们继续执行原来的指令,因此这其实就是我们说的stop the world(STW),整个进程相当于静止了。
六、使用堆外内存的优点
当然,任何一个事物使用起来有优点就会有缺点,堆外内存的缺点就是内存难以控制,使用了堆外内存就间接失去了JVM管理内存的可行性,改由自己来管理,当发生内存溢出时排查起来非常困难。 所以,还是那句话,使用的时候要多留心呀~
- 可以扩展至更大的内存空间。比如超过1TB甚至比主存还大的空间;
- 减少了垃圾回收(因为垃圾回收会暂停其他的工作。);
- 可以在进程间共享,减少JVM间的对象复制,使得JVM的分割部署更容易实现(堆内在flush到远程时,会先复制到直接内存(非堆内存),然后在发送;而堆外内存相当于省略掉了这个工作。);
- 它的持久化存储可以支持快速重启,同时还能够在测试环境中重现生产数据
- 站在系统设计的角度来看,使用堆外内存可以为你的设计提供更多可能。最重要的提升并不在于性能,而是决定性的
好啦,以上就是关于堆外内存的相关知识总结啦,如果大家有什么不明白的地方或者发现文中有描述不好的地方,欢迎大家留言评论,我们一起学习呀。
Biu~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~pia!
参考文章:
https://www.jianshu.com/p/50be08b54bee