Java的堆外内存本来是高贵而神秘的东西,只在一些缓存方案的收费企业版里出现,但自从用了Netty,就变成了天天打交道的事情,毕竟堆外内存能减少IO时的内存复制。
好在,Netty所用的堆外内存就是Java NIO的 DirectByteBuffer类,通读一次很快。还有一些sun.misc.*的东东木有源码,要自己跑去 OpenJdk 那看个明白。
在DirectByteBuffer中,首先向Bits类申请额度,Bits类有一个全局的 totalCapacity变量,记录着全部DirectByteBuffer的总大小,每次申请,都会看看是否超限 -- 堆外内存的限额默认与堆内内存(由-XMX 设定)相仿,可以用 -XX:MaxDirectMemorySize 重新设定。
如果已经超限,会主动执行Sytem.gc(),期待能主动回收一点内存。然后休眠一百毫秒,看看totalCapacity降下来没有,如果内存还是不足,就抛出大家最头痛的OOM异常。
如果额度被批准,就调用大名鼎鼎的sun.misc.Unsafe去分配物理内存,返回内存基地址。跑个题,Unsafe的名字是提醒大家这个类只给Sun自家用的,你们别用,不然哪天Sun藏起来了你们就哭死。果然, JDK9里就Oracle可能动手哦
最后,创建一个Cleaner,并把代表清理动作的Deallocator类绑定 -- 向Bits解除内存额度,降低totalCapacity,并调用Unsafe去释放物理内存。Cleaner的触发机制后面再说。
存在于堆内的DirectByteBuffer对象很小,只存着基地址和大小等几个属性,和一个Cleaner,但它代表着后面所分配的一大段内存,是所谓的冰山对象。通过前面说的Cleaner,堆内的DirectByteBuffer对象被GC时,它背后的堆外内存也会被回收。
快速回顾一下堆内的GC机制,当新生代满了,就会发生young gc,如果此时对象还没失效,就不会被回收,撑过几次young gc后,对象被迁移到老生代,当老生代也满了之后,就会发生full gc。
这里可以看到一种尴尬的情况,如果为每个DirectByteBuffer分配一大段内存,它们没有被及时回收的话很容易就把堆外内存耗光。但DirectByteBuffer本身的个头又很小,只要熬过了young gc,即使已经失效了也能在老生代里舒服的呆着,不容易把老生代撑爆触发full gc,如果没有别的大块头进入老生代触发full gc,就一直在那呆着。
这时,就只能靠前面提到的申请额度超限时触发的system.gc()来救场了。但这道最后的保险其实也不很好,首先它让当前线程睡了整整一百毫秒,而且如果gc没在一百毫秒内完成,它仍然会无情的抛出OOM异常。还有,万一,万一大家迷信某个调优指南设置了-DisableExplicitGC禁止了system.gc(),那就不好玩了。
所以,堆外内存还是自己主动点回收更好,比如Netty就是这么做的。
对于Sun的JDK这其实很简单,只要从DirectByteBuffer里取出那个sun.misc.Cleaner,然后调用它的clean()就行。
前面说的,clean()执行时实际调用的是被绑定的Deallocator类,这个类可被重复执行,释放过了就不再释放。所以GC时再被动执行一次clean()也没所谓。
在Netty里,因为不确定跑在Sun的JDK里(比如Android),所以多废了些功夫来确定Cleaner的存在。
涨知识的时间到了,原来JDK除了StrongReference,SoftReference 和 WeakReference之外,还有一种PhantomReference,Phantom是幻影的意思,Cleaner就是PhantomReference的子类。
当GC时发现它除了PhantomReference外已不可达(持有它的DirectByteBuffer失效了),就会把它放进 Reference类静态的pending list里。然后另有一条ReferenceHandler线程,名字叫 "Reference Handler”的,关注着这个pending list,如果看到对象类型是Cleaner,就会执行它的clean()。
我们在Netty一般用池化的 PooledDirectByteBuf,基本不需要头痛堆外内存的释放。