堆外內存簡介和使用


1.引子

 

 

最近看了一篇文章《螞蟻消息中間件 (MsgBroker) 在 YGC 優化上的探索》

文章涉及JVM的垃圾回收,主要講的是通過使用「堆外內存」對Young GC進行優化

 

文章中介紹,MsgBroker消息中間件會對消息進行緩存,JVM需要為被緩存的消息分配內存,首先會被分配到年輕代。

 

當緩存中的消息由於各種原因,一直投遞不成功,這些消息會進入老年代,最終呈現的問題是YGC時間太長

隨着新特性的開發和消息量的增長,我們發現 MsgBroker 的 YGC 平均耗時已緩慢增長至 50ms~60ms,甚至部分機房的 YGC 平均耗時已高達 120ms

 

有一個疑問,消息進入老年代,出現堆積,為何會導致YGC時間過長呢?

按着文章中的敘述,回答這個問題。

  1. 在YGC階段,涉及到垃圾標記的過程,從GCRoot開始標記。
  2. 因為YGC不涉及到老年代的回收,一旦從GCRoot掃描到引用了老年代對象時,就中斷本次掃描。這樣做可以減少掃描范圍,加速YGC。
  3. 存在被老年代對象引用的年輕代對象,它們沒有被GCRoot直接或者間接引用。
  4. YGC階段中的old-gen scanning即用於掃描被老年代引用的年輕代對象。
  5. old-gen scanning掃描時間與老年代內存占用大小成正比。
  6. 得到結論,老年代內存占用增大會導致YGC時間變長。

 

總的來說,將消息緩存在JVM內存會對垃圾回收造成一定影響:

  1. 消息最初緩存到年輕代,會增加YGC的頻率。
  2. 消息被提升到老年代,會增加FGC的頻率。
  3. 老年代的消息增長后,會延長old-gen scanning時間,從而增加YGC耗時

 

文章使用「堆外內存」減少了消息對JVM內存的占用,並使用基於Netty的網絡層框架,達到了理想的YGC時間。

注:Netty中也使用了堆外內存。

通過引入自適應投遞限流,在實驗室測試環境下,MsgBroker 在異常場景下的 YGC 耗時進一步從 83ms 降低到 40ms,恢復了正常的水平。

 

2.什么是堆外內存

 

在JAVA中,JVM內存指的是堆內存。

機器內存中,不屬於堆內存的部分即為堆外內存。

堆外內存也被稱為直接內存。

image.png

堆內存和堆外內存

 

堆外內存並不神秘,在C語言中,分配的就是機器內存,和本文中的堆外內存是相似的概念。

在JAVA中,可以通過Unsafe和NIO包下的ByteBuffer來操作堆外內存。

 

2.1 Unsafe類操作堆外內存

sun.misc.Unsafe提供了一組方法來進行堆外內存的分配,重新分配,以及釋放。

  1. public native long allocateMemory(long size); —— 分配一塊內存空間。
  2. public native long reallocateMemory(long address, long size); —— 重新分配一塊內存,把數據從address指向的緩存中拷貝到新的內存塊。
  3. public native void freeMemory(long address); —— 釋放內存。

參考:Unsafe類操作JAVA內存

 

一頓操作猛如虎,直接psvm走起。

 

public static void main(String[] args) {  Unsafe unsafe = new Unsafe();  unsafe.allocateMemory(1024); }

然而Unsafe類的構造器是私有的,報錯 。而且,allocateMemory方法也不是靜態的,不能通過Unsafe.allocateMemory調用。

幸運的是可以通過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操作內存中的Java類和對象

2.2 NIO類操作堆外內存

用NIO包下的ByteBuffer分配直接內存則相對簡單。

 

public class TestDirectByteBuffer {   public static void main(String[] args) throws Exception {  ByteBuffer buffer = ByteBuffer.allocateDirect(10 * 1024 * 1024);  } }

參考:JAVA堆外內存

 

3.堆外內存垃圾回收

對於內存,除了關注怎么分配,還需要關注如何釋放。從JAVA出發,習慣性思維是堆外內存是否有垃圾回收機制。

考慮堆外內存的垃圾回收機制,需要了解以下兩個問題:

  1. 堆外內存會溢出么?
  2. 什么時候會觸發堆外內存回收?

 

 

3.1 堆外內存會溢出么

通過修改JVM參數:-XX:MaxDirectMemorySize=40M,將最大堆外內存設置為40M。

既然堆外內存有限,則必然會發生內存溢出。

為模擬內存溢出,可以設置JVM參數:-XX:+DisableExplicitGC,禁止代碼中顯式調用System.gc()。

可以看到出現OOM。

得到的結論是,堆外內存會溢出,並且其垃圾回收依賴於代碼顯式調用System.gc()。

參考:JAVA堆外內存

 

3.2 什么時候會觸發堆外內存回收

關於堆外內存垃圾回收的時機,首先考慮堆外內存的分配過程。JVM在堆內只保存堆外內存的引用,用DirectByteBuffer對象來表示。每個DirectByteBuffer對象在初始化時,都會創建一個對應的Cleaner對象。

這個Cleaner對象會在合適的時候執行unsafe.freeMemory(address),從而回收這塊堆外內存。

 

當DirectByteBuffer對象在某次YGC中被回收,只有Cleaner對象知道堆外內存的地址。當下一次FGC執行時,Cleaner對象會將自身Cleaner鏈表上刪除,並觸發clean方法清理堆外內存。此時,堆外內存將被回收,Cleaner對象也將在下次YGC時被回收。

 

如果JVM一直沒有執行FGC的話,無法觸發Cleaner對象執行clean方法,從而堆外內存也一直得不到釋放。

其實,在ByteBuffer.allocateDirect方式中,會主動調用System.gc()強制執行FGC。

 

JVM覺得有需要時,就會真正執行GC操作

 

參考:堆外內存的回收機制分析—占小狼

 

4. 什么時候用堆外內存?

堆外內存的使用場景非常巧妙。第三方堆外緩存管理包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

5. 如何用堆外內存?

 

在第一章中介紹了兩種分配堆外內存的方法,Unsafe和NIO。對於兩種方法只是停留在分配和回收的階段,距離真正使用的目標還很遙遠。在第三章中提到堆外內存的使用場景之一是緩存。那是否有一個包,支持分配堆外內存,又支持KV操作,還無需關心GC。

答案當然是有的。有一個很知名的包,Ehcache。Ehcache被廣泛用於Spring,Hibernate緩存,並且支持堆內緩存,堆外緩存,磁盤緩存,分布式緩存。此外,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很簡單,主要做了以下幾步操作:

  1. 新建了一個Map,作為堆內緩存。
  2. 用Ehcache新建了一個堆外緩存,緩存大小為1MB。
  3. 在兩種緩存中,都放入10000個對象。
  4. helloHeap方法做get測試,並統計堆外內存數量,驗證先插入的對象是否被淘汰。

 

使用Java VisualVM工具Dump一個內存鏡像。

Java VisualVM是JDK自帶的工具。

工具位置如下:

/Library/Java/JavaVirtualMachines/jdk1.7.0_71.jdk/Contents/Home/bin/jvisualvm

也可以使用JProfiler工具。

打開鏡像,堆里有10000個InHeapClass,卻沒有OffHeapClass,表示堆外緩存中的對象的確沒有占用JVM內存。

 

接着測試helloHeap方法。

輸出:

{"key":"InHeapKey1","value":"InHeapValue1"}

null

……(此處有大量輸出)

5887

輸出表示堆外內存啟用了淘汰機制,插入10000個對象,最后只剩下5887個對象。

如果堆外緩存總量不超過最大限制,則可以順利get到緩存內容。

總體而言,使用堆外內存可以減少GC的壓力,從而減少GC對業務的影響。

 

 

6.參考

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM