
最近看了一篇文章《螞蟻消息中間件 (MsgBroker) 在 YGC 優(yōu)化上的探索》。
文章涉及JVM的垃圾回收,主要講的是通過使用「堆外內(nèi)存」對Young GC進行優(yōu)化。
文章中介紹,MsgBroker消息中間件會對消息進行緩存,JVM需要為被緩存的消息分配內(nèi)存,首先會被分配到年輕代。
當緩存中的消息由于各種原因,一直投遞不成功,這些消息會進入老年代。
最終呈現(xiàn)的問題是YGC時間太長。
隨著新特性的開發(fā)和消息量的增長,我們發(fā)現(xiàn) MsgBroker 的 YGC 平均耗時已緩慢增長至 50ms~60ms,甚至部分機房的 YGC 平均耗時已高達 120ms。
有一個疑問,消息進入老年代,出現(xiàn)堆積,為何會導致YGC時間過長呢?
按著文章中的敘述,回答這個問題。
- 在YGC階段,涉及到垃圾標記的過程,從GCRoot開始標記。
- 因為YGC不涉及到老年代的回收,一旦從GCRoot掃描到引用了老年代對象時,就中斷本次掃描。這樣做可以減少掃描范圍,加速YGC。
- 存在被老年代對象引用的年輕代對象,它們沒有被GCRoot直接或者間接引用。
- YGC階段中的old-gen scanning即用于掃描被老年代引用的年輕代對象。
- old-gen scanning掃描時間與老年代內(nèi)存占用大小成正比。
- 得到結論,老年代內(nèi)存占用增大會導致YGC時間變長。
總的來說,將消息緩存在JVM內(nèi)存會對垃圾回收造成一定影響:
- 消息最初緩存到年輕代,會增加YGC的頻率。
- 消息被提升到老年代,會增加FGC的頻率。
- 老年代的消息增長后,會延長old-gen scanning時間,從而增加YGC耗時。
文章使用「堆外內(nèi)存」減少了消息對JVM內(nèi)存的占用,并使用基于Netty的網(wǎng)絡層框架,達到了理想的YGC時間。
注:Netty中也使用了堆外內(nèi)存。
通過引入自適應投遞限流,在實驗室測試環(huán)境下,MsgBroker 在異常場景下的 YGC 耗時進一步從 83ms 降低到 40ms,恢復了正常的水平。
一:堆外內(nèi)存是什么?
在JAVA中,JVM內(nèi)存指的是堆內(nèi)存。
機器內(nèi)存中,不屬于堆內(nèi)存的部分即為堆外內(nèi)存。
堆外內(nèi)存也被稱為直接內(nèi)存。

堆外內(nèi)存并不神秘,在C語言中,分配的就是機器內(nèi)存,和本文中的堆外內(nèi)存是相似的概念。
在JAVA中,可以通過Unsafe和NIO包下的ByteBuffer來操作堆外內(nèi)存。
Unsafe類操作堆外內(nèi)存
sun.misc.Unsafe提供了一組方法來進行堆外內(nèi)存的分配,重新分配,以及釋放。
- public native long allocateMemory(long size); —— 分配一塊內(nèi)存空間。
- public native long reallocateMemory(long address, long size); —— 重新分配一塊內(nèi)存,把數(shù)據(jù)從address指向的緩存中拷貝到新的內(nèi)存塊。
- public native void freeMemory(long address); —— 釋放內(nèi)存。
一頓操作猛如虎,直接psvm走起。
public static void main(String[] args) {
Unsafe unsafe = new Unsafe();
unsafe.allocateMemory(1024);
}
然而Unsafe類的構造器是私有的,報錯。
而且,allocateMemory方法也不是靜態(tài)的,不能通過Unsafe.allocateMemory調(diào)用。
幸運的是可以通過Unsafe.getUnsafe()取得Unsafe的實例。
public class UnsafeTest {
public static void main(String[] args) {
Unsafe unsafe = Unsafe.getUnsafe();
unsafe.allocateMemory(1024);
unsafe.reallocateMemory(1024, 1024);
unsafe.freeMemory(1024);
}
}
此外,也可以通過反射獲取unsafe對象實例
參考:危險代碼:如何使用Unsafe操作內(nèi)存中的Java類和對象
NIO類操作堆外內(nèi)存
用NIO包下的ByteBuffer分配直接內(nèi)存則相對簡單。
public class TestDirectByteBuffer {
public static void main(String[] args) throws Exception {
ByteBuffer buffer = ByteBuffer.allocateDirect(10 * 1024 * 1024);
}
}
然而運行時報錯了。
java(51146,0x7000023ed000) malloc: *** error for object 0x400: pointer being realloc'd was not allocated
*** set a breakpoint in malloc_error_break to debug

然而在小伙伴的電腦上跑這段的代碼是可以成功運行的。
二:堆外內(nèi)存垃圾回收
對于內(nèi)存,除了關注怎么分配,還需要關注如何釋放。
從JAVA出發(fā),習慣性思維是堆外內(nèi)存是否有垃圾回收機制。
考慮堆外內(nèi)存的垃圾回收機制,需要了解以下兩個問題:
- 堆外內(nèi)存會溢出么?
- 什么時候會觸發(fā)堆外內(nèi)存回收?
問題一
通過修改JVM參數(shù):-XX:MaxDirectMemorySize=40M,將最大堆外內(nèi)存設置為40M。
既然堆外內(nèi)存有限,則必然會發(fā)生內(nèi)存溢出。
為模擬內(nèi)存溢出,可以設置JVM參數(shù):-XX:+DisableExplicitGC,禁止代碼中顯式調(diào)用System.gc()。
可以看到出現(xiàn)OOM。
得到的結論是,堆外內(nèi)存會溢出,并且其垃圾回收依賴于代碼顯式調(diào)用System.gc()。
問題二
關于堆外內(nèi)存垃圾回收的時機,首先考慮堆外內(nèi)存的分配過程。
JVM在堆內(nèi)只保存堆外內(nèi)存的引用,用DirectByteBuffer對象來表示。
每個DirectByteBuffer對象在初始化時,都會創(chuàng)建一個對應的Cleaner對象。
這個Cleaner對象會在合適的時候執(zhí)行unsafe.freeMemory(address),從而回收這塊堆外內(nèi)存。
當DirectByteBuffer對象在某次YGC中被回收,只有Cleaner對象知道堆外內(nèi)存的地址。
當下一次FGC執(zhí)行時,Cleaner對象會將自身Cleaner鏈表上刪除,并觸發(fā)clean方法清理堆外內(nèi)存。
此時,堆外內(nèi)存將被回收,Cleaner對象也將在下次YGC時被回收。
如果JVM一直沒有執(zhí)行FGC的話,無法觸發(fā)Cleaner對象執(zhí)行clean方法,從而堆外內(nèi)存也一直得不到釋放。
其實,在ByteBuffer.allocateDirect方式中,會主動調(diào)用System.gc()強制執(zhí)行FGC。
JVM覺得有需要時,就會真正執(zhí)行GC操作。

三:為什么用堆外內(nèi)存?
堆外內(nèi)存的使用場景非常巧妙。
第三方堆外緩存管理包ohc(off-heap-cache)給出了詳細的解釋。
摘了其中一段。
When using a very huge number of objects in a very large heap, Virtual machines will suffer from increased GC pressure since it basically has to inspect each and every object whether it can be collected and has to access all memory pages. A cache shall keep a hot set of objects accessible for fast access (e.g. omit disk or network roundtrips). The only solution is to use native memory - and there you will end up with the choice either to use some native code (C/C++) via JNI or use direct memory access.
大概的意思如下:
考慮使用緩存時,本地緩存是最快速的,但會給虛擬機帶來GC壓力。
使用硬盤或者分布式緩存的響應時間會比較長,這時候「堆外緩存」會是一個比較好的選擇。
參考:OHC - An off-heap-cache — Github
四:如何用堆外內(nèi)存?
在第一章中介紹了兩種分配堆外內(nèi)存的方法,Unsafe和NIO。
對于兩種方法只是停留在分配和回收的階段,距離真正使用的目標還很遙遠。
在第三章中提到堆外內(nèi)存的使用場景之一是緩存。
那是否有一個包,支持分配堆外內(nèi)存,又支持KV操作,還無需關心GC。
答案當然是有的。
有一個很知名的包,Ehcache。
Ehcache被廣泛用于Spring,Hibernate緩存,并且支持堆內(nèi)緩存,堆外緩存,磁盤緩存,分布式緩存。
此外,Ehcache還支持多種緩存策略。
其倉庫坐標如下:
<dependency>
<groupId>org.ehcache</groupId>
<artifactId>ehcache</artifactId>
<version>3.4.0</version>
</dependency>
接下來就是寫代碼進行驗證:
public class HelloHeapServiceImpl implements HelloHeapService {
private static Map<String, InHeapClass> inHeapCache = Maps.newHashMap();
private static Cache<String, OffHeapClass> offHeapCache;
static {
ResourcePools resourcePools = ResourcePoolsBuilder.newResourcePoolsBuilder()
.offheap(1, MemoryUnit.MB)
.build();
CacheConfiguration<String, OffHeapClass> configuration = CacheConfigurationBuilder
.newCacheConfigurationBuilder(String.class, OffHeapClass.class, resourcePools)
.build();
offHeapCache = CacheManagerBuilder.newCacheManagerBuilder()
.withCache("cacher", configuration)
.build(true)
.getCache("cacher", String.class, OffHeapClass.class);
for (int i = 1; i < 10001; i++) {
inHeapCache.put("InHeapKey" + i, new InHeapClass("InHeapKey" + i, "InHeapValue" + i));
offHeapCache.put("OffHeapKey" + i, new OffHeapClass("OffHeapKey" + i, "OffHeapValue" + i));
}
}
@Data
@AllArgsConstructor
private static class InHeapClass implements Serializable {
private String key;
private String value;
}
@Data
@AllArgsConstructor
private static class OffHeapClass implements Serializable {
private String key;
private String value;
}
@Override
public void helloHeap() {
System.out.println(JSON.toJSONString(inHeapCache.get("InHeapKey1")));
System.out.println(JSON.toJSONString(offHeapCache.get("OffHeapKey1")));
Iterator iterator = offHeapCache.iterator();
int sum = 0;
while (iterator.hasNext()) {
System.out.println(JSON.toJSONString(iterator.next()));
sum++;
}
System.out.println(sum);
}
}
其中.offheap(1, MemoryUnit.MB)表示分配的是堆外緩存。
Demo很簡單,主要做了以下幾步操作:
- 新建了一個Map,作為堆內(nèi)緩存。
- 用Ehcache新建了一個堆外緩存,緩存大小為1MB。
- 在兩種緩存中,都放入10000個對象。
- helloHeap方法做get測試,并統(tǒng)計堆外內(nèi)存數(shù)量,驗證先插入的對象是否被淘汰。
使用Java VisualVM工具Dump一個內(nèi)存鏡像。
Java VisualVM是JDK自帶的工具。
工具位置如下:
/Library/Java/JavaVirtualMachines/jdk1.7.0_71.jdk/Contents/Home/bin/jvisualvm
也可以使用JProfiler工具。
打開鏡像,堆里有10000個InHeapClass,卻沒有OffHeapClass,表示堆外緩存中的對象的確沒有占用JVM內(nèi)存。

接著測試helloHeap方法。
輸出:
{"key":"InHeapKey1","value":"InHeapValue1"}
null
……(此處有大量輸出)
5887
輸出表示堆外內(nèi)存啟用了淘汰機制,插入10000個對象,最后只剩下5887個對象。
如果堆外緩存總量不超過最大限制,則可以順利get到緩存內(nèi)容。
總體而言,使用堆外內(nèi)存可以減少GC的壓力,從而減少GC對業(yè)務的影響。