JVM初探- 使用堆外內存減少Full GC

MilLong 7年前發布 | 18K 次閱讀 JVM Java開發

JVM初探-使用堆外內存減少Full GC

問題: 大部分主流互聯網企業線上Server JVM選用了CMS收集器(如Taobao、LinkedIn、Vdian), 雖然CMS可與用戶線程并發GC以降低STW時間, 但它也并非十分完美, 尤其是當出現 Concurrent Mode Failure 由并行GC轉入串行時, 將導致非常長時間的 Stop The World 

解決: 由 GCIH 可以聯想到: 將長期存活的對象(如Local Cache)移入堆外內存(off-heap, 又名 直接內存/direct-memory ) , 從而減少CMS管理的對象數量, 以降低Full GC的次數和頻率, 達到提高系統響應速度的目的.

引入

這個idea最初來源于TaobaoJVM對OpenJDK定制開發的GCIH部分, 其中GCIH就是將CMS Old Heap區的一部分劃分出來, 這部分內存雖然還在堆內, 但已不被GC所管理. 將長生命周期Java對象放在Java堆外, GC不能管理GCIH內Java對象(GC Invisible Heap) :

  • 這樣做有兩方面的好處:

    1. 減少GC管理內存:
      由于GCIH會從Old區 “切出” 一塊, 因此導致GC管理區域變小, 可以明顯降低GC工作量, 提高GC效率, 降低Full GC STW時間(且由于這部分內存仍屬于堆, 因此其訪問方式/速度不變- 不必付出序列化/反序列化的開銷 ).
    2. GCIH內容進程間共享:
      由于這部分區域不再是JVM運行時數據的一部分, 因此GCIH內的對象可供對個JVM實例所共享(如一臺Server跑多個MR-Job可共享同一份Cache數據), 這樣一臺Server也就可以跑更多的VM實例.

(實際測試數據/圖示可下載 撒迦 分享 PPT ).

但是大部分的互聯公司不能像阿里這樣可以有專門的工程師針對自己的業務特點定制JVM, 因此我們只能”眼饞”GCIH帶來的性能提升卻無法”享用”. 但通用的JVM開放了接口可直接向操作系統申請堆外內存( ByteBuffer or Unsafe ), 而這部分內存也是GC所顧及不到的, 因此我們可用JVM堆外內存來模擬GCIH的功能(但相比GCIH不足的是需要付出serialize/deserialize的開銷).

JVM堆外內存

在JVM初探 -JVM內存模型一文中介紹的 Java運行時數據區域 中是找不到堆外內存區域的:

因為它并不是JVM運行時數據區的一部分, 也不是Java虛擬機規范中定義的內存區域, 這部分內存區域直接被操作系統管理.

在JDK 1.4以前, 對這部分內存訪問沒有光明正大的做法: 只能通過反射拿到 Unsafe 類, 然后調用 allocateMemory()/freeMemory() 來申請/釋放這塊內存. 1.4開始新加入了NIO, 它引入了一種基于Channel與Buffer的I/O方式, 可以使用Native函數庫直接分配堆外內存, 然后通過一個存儲在Java堆里面的 DirectByteBuffer 對象作為這塊內存的引用進行操作, ByteBuffer 提供了如下常用方法來跟堆外內存打交道:

API 描述
static ByteBuffer allocateDirect(int capacity) Allocates a new direct byte buffer.
ByteBuffer put(byte b) Relative put method (optional operation).
ByteBuffer put(byte[] src) Relative bulk put method (optional operation).
ByteBuffer putXxx(Xxx value) Relative put method for writing a Char/Double/Float/Int/Long/Short value (optional operation).
ByteBuffer get(byte[] dst) Relative bulk get method.
Xxx getXxx() Relative get method for reading a Char/Double/Float/Int/Long/Short value.
XxxBuffer asXxxBuffer() Creates a view of this byte buffer as a Char/Double/Float/Int/Long/Short buffer.
ByteBuffer asReadOnlyBuffer() Creates a new, read-only byte buffer that shares this buffer’s content.
boolean isDirect() Tells whether or not this byte buffer is direct.
ByteBuffer duplicate() Creates a new byte buffer that shares this buffer’s content.

下面我們就用通用的JDK API來使用堆外內存來實現一個 local cache .

示例1.: 使用JDK API實現堆外Cache

注: 主要邏輯都集中在方法 invoke() 內, 而 AbstractAppInvoker 是一個自定義的性能測試框架, 在后面會有詳細的介紹.

/**

  • @author jifang
  • @since 2016/12/31 下午6:05. */ public class DirectByteBufferApp extends AbstractAppInvoker {

    @Test @Override public void invoke(Object... param) {

     Map<String, FeedDO> map = createInHeapMap(SIZE);
    
     // move in off-heap
     byte[] bytes = serializer.serialize(map);
     ByteBuffer buffer = ByteBuffer.allocateDirect(bytes.length);
     buffer.put(bytes);
     buffer.flip();
    
     // for gc
     map = null;
     bytes = null;
     System.out.println("write down");
     // move out from off-heap
     byte[] offHeapBytes = new byte[buffer.limit()];
     buffer.get(offHeapBytes);
     Map<String, FeedDO> deserMap = serializer.deserialize(offHeapBytes);
     for (int i = 0; i < SIZE; ++i) {
         String key = "key-" + i;
         FeedDO feedDO = deserMap.get(key);
         checkValid(feedDO);
    
         if (i % 10000 == 0) {
             System.out.println("read " + i);
         }
     }
    
     free(buffer);
    

    }

    private Map<String, FeedDO> createInHeapMap(int size) {

     long createTime = System.currentTimeMillis();
    
     Map<String, FeedDO> map = new ConcurrentHashMap<>(size);
     for (int i = 0; i < size; ++i) {
         String key = "key-" + i;
         FeedDO value = createFeed(i, key, createTime);
         map.put(key, value);
     }
    
     return map;
    

    } }</code></pre>

    由JDK提供的堆外內存訪問API只能申請到一個類似一維數組的 ByteBuffer , JDK并未提供基于堆外內存的實用數據結構實現(如堆外的 Map 、 Set ), 因此想要實現Cache的功能只能在 write() 時先將數據 put() 到一個堆內的 HashMap , 然后再將整個 Map 序列化后 MoveIn 到 DirectMemory , 取緩存則反之. 由于需要在堆內申請 HashMap , 因此可能會導致多次Full GC. 這種方式雖然可以使用堆外內存, 但性能不高、無法發揮堆外內存的優勢.

    幸運的是開源界的前輩開發了諸如 EhcacheMapDBChronicle Map 等一系列優秀的堆外內存框架, 使我們可以在使用簡潔API訪問堆外內存的同時又不損耗額外的性能.

    其中又以Ehcache最為強大, 其提供了in-heap、off-heap、on-disk、cluster四級緩存, 且Ehcache企業級產品( BigMemory Max / BigMemory Go )實現的BigMemory也是Java堆外內存領域的先驅.

    示例2: MapDB API實現堆外Cache

    public class MapDBApp extends AbstractAppInvoker {

    private static HTreeMap<String, FeedDO> mapDBCache;

    static {

     mapDBCache = DBMaker.hashMapSegmentedMemoryDirect()
             .expireMaxSize(SIZE)
             .make();
    

    }

    @Test @Override public void invoke(Object... param) {

     for (int i = 0; i < SIZE; ++i) {
         String key = "key-" + i;
         FeedDO feed = createFeed(i, key, System.currentTimeMillis());
    
         mapDBCache.put(key, feed);
     }
    
     System.out.println("write down");
     for (int i = 0; i < SIZE; ++i) {
         String key = "key-" + i;
         FeedDO feedDO = mapDBCache.get(key);
         checkValid(feedDO);
    
         if (i % 10000 == 0) {
             System.out.println("read " + i);
         }
     }
    

    } }</code></pre>

    結果 & 分析

    • DirectByteBufferApp
    S0     S1     E      O      P     YGC     YGCT    FGC    FGCT     GCT
    0.00   0.00   5.22  78.57  59.85     19    2.902    13    7.251   10.153
    • the last one jstat of MapDBApp
    S0     S1     E      O      P     YGC     YGCT    FGC    FGCT     GCT
    0.00   0.03   8.02   0.38  44.46    171    0.238     0    0.000    0.238

    運行 DirectByteBufferApp.invoke() 會發現有看到很多Full GC的產生, 這是因為HashMap需要一個很大的連續數組, Old區很快就會被占滿, 因此也就導致頻繁Full GC的產生.

    而運行 MapDBApp.invoke() 可以看到有一個 DirectMemory 持續增長的過程, 但FullGC卻一次都沒有了.

    實驗: 使用堆外內存減少Full GC

    實驗環境

    • java -version
    java version "1.7.0_79"
    Java(TM) SE Runtime Environment (build 1.7.0_79-b15)
    Java HotSpot(TM) 64-Bit Server VM (build 24.79-b02, mixed mode)
    • VM Options
    -Xmx512M
    -XX:MaxDirectMemorySize=512M
    -XX:+PrintGC
    -XX:+UseConcMarkSweepGC
    -XX:+CMSClassUnloadingEnabled
    -XX:CMSInitiatingOccupancyFraction=80
    -XX:+UseCMSInitiatingOccupancyOnly
    • 實驗數據

      170W條動態(FeedDO).

    實驗代碼

    第1組: in-heap、affect by GC、no serialize

    • ConcurrentHashMapApp
    public class ConcurrentHashMapApp extends AbstractAppInvoker {

    private static final Map<String, FeedDO> cache = new ConcurrentHashMap<>();

    @Test @Override public void invoke(Object... param) {

     // write
     for (int i = 0; i < SIZE; ++i) {
         String key = String.format("key_%s", i);
         FeedDO feedDO = createFeed(i, key, System.currentTimeMillis());
         cache.put(key, feedDO);
     }
    
     System.out.println("write down");
     // read
     for (int i = 0; i < SIZE; ++i) {
         String key = String.format("key_%s", i);
         FeedDO feedDO = cache.get(key);
         checkValid(feedDO);
    
         if (i % 10000 == 0) {
             System.out.println("read " + i);
         }
     }
    

    } }</code></pre>

    GuavaCacheApp類似, 詳細代碼可參考完整項目.

    第2組: off-heap、not affect by GC、need serialize

    • EhcacheApp
    public class EhcacheApp extends AbstractAppInvoker {

    private static Cache<String, FeedDO> cache;

    static {

     ResourcePools resourcePools = ResourcePoolsBuilder.newResourcePoolsBuilder()
             .heap(1000, EntryUnit.ENTRIES)
             .offheap(480, MemoryUnit.MB)
             .build();
    
     CacheConfiguration<String, FeedDO> configuration = CacheConfigurationBuilder
             .newCacheConfigurationBuilder(String.class, FeedDO.class, resourcePools)
             .build();
    
     cache = CacheManagerBuilder.newCacheManagerBuilder()
             .withCache("cacher", configuration)
             .build(true)
             .getCache("cacher", String.class, FeedDO.class);
    
    

    }

    @Test @Override public void invoke(Object... param) {

     for (int i = 0; i < SIZE; ++i) {
         String key = String.format("key_%s", i);
         FeedDO feedDO = createFeed(i, key, System.currentTimeMillis());
         cache.put(key, feedDO);
     }
    
     System.out.println("write down");
     // read
     for (int i = 0; i < SIZE; ++i) {
         String key = String.format("key_%s", i);
         Object o = cache.get(key);
         checkValid(o);
    
         if (i % 10000 == 0) {
             System.out.println("read " + i);
         }
     }
    

    } }</code></pre>

    MapDBApp與前同.

    第3組: off-process、not affect by GC、serialize、affect by process communication

    • LocalRedisApp
    public class LocalRedisApp extends AbstractAppInvoker {

    private static final Jedis cache = new Jedis("localhost", 6379);

    private static final IObjectSerializer serializer = new Hessian2Serializer();

    @Test @Override public void invoke(Object... param) {

     // write
     for (int i = 0; i < SIZE; ++i) {
         String key = String.format("key_%s", i);
         FeedDO feedDO = createFeed(i, key, System.currentTimeMillis());
    
         byte[] value = serializer.serialize(feedDO);
         cache.set(key.getBytes(), value);
    
         if (i % 10000 == 0) {
             System.out.println("write " + i);
         }
     }
    
     System.out.println("write down");
     // read
     for (int i = 0; i < SIZE; ++i) {
         String key = String.format("key_%s", i);
         byte[] value = cache.get(key.getBytes());
         FeedDO feedDO = serializer.deserialize(value);
         checkValid(feedDO);
    
         if (i % 10000 == 0) {
             System.out.println("read " + i);
         }
     }
    

    } }</code></pre>

    RemoteRedisApp類似, 詳細代碼可參考下面完整項目.

    實驗結果

    * ConcurrentMap Guava
    TTC 32166ms/32s 47520ms/47s
    Minor C/T 31/1.522 29/1.312
    Full C/T 24/23.212 36/41.751
         
      MapDB Ehcache
    TTC 40272ms/40s 30814ms/31s
    Minor C/T 511/0.557 297/0.430
    Full C/T 0/0.000 0/0.000
         
      LocalRedis NetworkRedis
    TTC 176382ms/176s 1h+
    Minor C/T 421/0.415 -
    Full C/T 0/0.000 -

    備注:

    - TTC: Total Time Cost 總共耗時

    - C/T: Count/Time 次數/耗時(seconds)

    結果分析

    對比前面幾組數據, 可以有如下總結:

    • 將長生命周期的大對象(如cache)移出heap可大幅度降低Full GC次數與耗時;
    • 使用off-heap存儲對象需要付出serialize/deserialize成本;
    • 將cache放入分布式緩存需要付出進程間通信/網絡通信的成本(UNIX Domain/TCP IP)

    附:

    off-heap的Ehcache能夠跑出比in-heap的HashMap/Guava更好的成績確實是我始料未及的O(∩_∩)O~, 但確實這些數據和堆內存的搭配導致in-heap的Full GC太多了, 當heap堆開大之后就肯定不是這個結果了. 因此在使用堆外內存降低Full GC前, 可以先考慮是否可以將heap開的更大.

    附: 性能測試框架

    在main函數啟動時, 掃描 com.vdian.se.apps 包下的所有繼承了 AbstractAppInvoker 的類, 然后使用 Javassist 為每個類生成一個代理對象: 當 invoke() 方法執行時首先檢查他是否標注了 @Test 注解(在此, 我們借用junit定義好了的注解), 并在執行的前后記錄方法執行耗時, 并最終對比每個實現類耗時統計.

    • 依賴
    <dependency>
     <groupId>org.apache.commons</groupId>
     <artifactId>commons-proxy</artifactId>
     <version>${commons.proxy.version}</version>
    </dependency>
    <dependency>
     <groupId>org.javassist</groupId>
     <artifactId>javassist</artifactId>
     <version>${javassist.version}</version>
    </dependency>
    <dependency>
     <groupId>com.caucho</groupId>
     <artifactId>hessian</artifactId>
     <version>${hessian.version}</version>
    </dependency>
    <dependency>
     <groupId>com.google.guava</groupId>
     <artifactId>guava</artifactId>
     <version>${guava.version}</version>
    </dependency>
    <dependency>
     <groupId>junit</groupId>
     <artifactId>junit</artifactId>
     <version>${junit.version}</version>
    </dependency>

    啟動類: OffHeapStarter

    /**

  • @author jifang
  • @since 2017/1/1 上午10:47. */ public class OffHeapStarter {

    private static final Map<String, Long> STATISTICS_MAP = new HashMap<>();

    public static void main(String[] args) throws IOException, IllegalAccessException, InstantiationException {

     Set<Class<?>> classes = PackageScanUtil.scanPackage("com.vdian.se.apps");
     for (Class<?> clazz : classes) {
         AbstractAppInvoker invoker = createProxyInvoker(clazz.newInstance());
         invoker.invoke();
    
         //System.gc();
     }
    
     System.out.println("********************* statistics **********************");
     for (Map.Entry<String, Long> entry : STATISTICS_MAP.entrySet()) {
         System.out.println("method [" + entry.getKey() + "] total cost [" + entry.getValue() + "]ms");
     }
    

    }

    private static AbstractAppInvoker createProxyInvoker(Object invoker) {

     ProxyFactory factory = new JavassistProxyFactory();
     Class<?> superclass = invoker.getClass().getSuperclass();
     Object proxy = factory
             .createInterceptorProxy(invoker, new ProfileInterceptor(), new Class[]{superclass});
     return (AbstractAppInvoker) proxy;
    

    }

    private static class ProfileInterceptor implements Interceptor {

     @Override
     public Object intercept(Invocation invocation) throws Throwable {
         Class<?> clazz = invocation.getProxy().getClass();
         Method method = clazz.getMethod(invocation.getMethod().getName(), Object[].class);
    
         Object result = null;
         if (method.isAnnotationPresent(Test.class)
                 && method.getName().equals("invoke")) {
    
             String methodName = String.format("%s.%s", clazz.getSimpleName(), method.getName());
             System.out.println("method [" + methodName + "] start invoke");
    
             long start = System.currentTimeMillis();
             result = invocation.proceed();
             long cost = System.currentTimeMillis() - start;
    
             System.out.println("method [" + methodName + "] total cost [" + cost + "]ms");
    
             STATISTICS_MAP.put(methodName, cost);
         }
    
         return result;
     }
    

    } }</code></pre>

    • 包掃描工具: PackageScanUtil
    public class PackageScanUtil {

    private static final String CLASS_SUFFIX = ".class";

    private static final String FILE_PROTOCOL = "file";

    public static Set<Class<?>> scanPackage(String packageName) throws IOException {

     Set<Class<?>> classes = new HashSet<>();
     String packageDir = packageName.replace('.', '/');
     Enumeration<URL> packageResources = Thread.currentThread().getContextClassLoader().getResources(packageDir);
     while (packageResources.hasMoreElements()) {
         URL packageResource = packageResources.nextElement();
    
         String protocol = packageResource.getProtocol();
         // 只掃描項目內class
         if (FILE_PROTOCOL.equals(protocol)) {
             String packageDirPath = URLDecoder.decode(packageResource.getPath(), "UTF-8");
             scanProjectPackage(packageName, packageDirPath, classes);
         }
     }
    
     return classes;
    

    }

    private static void scanProjectPackage(String packageName, String packageDirPath, Set<Class<?>> classes) {

     File packageDirFile = new File(packageDirPath);
     if (packageDirFile.exists() && packageDirFile.isDirectory()) {
    
         File[] subFiles = packageDirFile.listFiles(new FileFilter() {
             @Override
             public boolean accept(File pathname) {
                 return pathname.isDirectory() || pathname.getName().endsWith(CLASS_SUFFIX);
             }
         });
    
         for (File subFile : subFiles) {
             if (!subFile.isDirectory()) {
                 String className = trimClassSuffix(subFile.getName());
                 String classNameWithPackage = packageName + "." + className;
    
                 Class<?> clazz = null;
                 try {
                     clazz = Class.forName(classNameWithPackage);
                 } catch (ClassNotFoundException e) {
                     // ignore
                 }
                 assert clazz != null;
    
                 Class<?> superclass = clazz.getSuperclass();
                 if (superclass == AbstractAppInvoker.class) {
                     classes.add(clazz);
                 }
             }
         }
     }
    

    }

    // trim .class suffix private static String trimClassSuffix(String classNameWithSuffix) {

     int endIndex = classNameWithSuffix.length() - CLASS_SUFFIX.length();
     return classNameWithSuffix.substring(0, endIndex);
    

    } }</code></pre>

    注: 在此僅掃描 項目目錄 下的 單層目錄 的class文件, 功能更強大的包掃描工具可參考Spring源代碼或 Touch源代碼中的 PackageScanUtil 類 .

    AppInvoker基類: AbstractAppInvoker

    提供通用測試參數 & 工具函數.

    public abstract class AbstractAppInvoker {

    protected static final int SIZE = 170_0000;

    protected static final IObjectSerializer serializer = new Hessian2Serializer();

    protected static FeedDO createFeed(long id, String userId, long createTime) {

     return new FeedDO(id, userId, (int) id, userId + "_" + id, createTime);
    

    }

    protected static void free(ByteBuffer byteBuffer) {

     if (byteBuffer.isDirect()) {
         ((DirectBuffer) byteBuffer).cleaner().clean();
     }
    

    }

    protected static void checkValid(Object obj) {

     if (obj == null) {
         throw new RuntimeException("cache invalid");
     }
    

    }

    protected static void sleep(int time, String beforeMsg) {

     if (!Strings.isNullOrEmpty(beforeMsg)) {
         System.out.println(beforeMsg);
     }
    
     try {
         Thread.sleep(time);
     } catch (InterruptedException ignored) {
         // no op
     }
    

    }

/**
 * 供子類繼承 & 外界調用
 *
 * @param param
 */
public abstract void invoke(Object... param);

}</code></pre>

序列化/反序列化接口與實現

public interface IObjectSerializer {

<T> byte[] serialize(T obj);

<T> T deserialize(byte[] bytes);

}</code></pre>

public class Hessian2Serializer implements IObjectSerializer {

private static final Logger LOGGER = LoggerFactory.getLogger(Hessian2Serializer.class);

@Override
public <T> byte[] serialize(T obj) {
    if (obj != null) {
        try (ByteArrayOutputStream os = new ByteArrayOutputStream()) {

            Hessian2Output out = new Hessian2Output(os);
            out.writeObject(obj);
            out.close();
            return os.toByteArray();

        } catch (IOException e) {
            LOGGER.error("Hessian serialize error ", e);
            throw new CacherException(e);
        }
    }
    return null;
}

@SuppressWarnings("unchecked")
@Override
public <T> T deserialize(byte[] bytes) {
    if (bytes != null) {
        try (ByteArrayInputStream is = new ByteArrayInputStream(bytes)) {

            Hessian2Input in = new Hessian2Input(is);
            T obj = (T) in.readObject();
            in.close();

            return obj;

        } catch (IOException e) {
            LOGGER.error("Hessian deserialize error ", e);
            throw new CacherException(e);
        }
    }
    return null;
}

}</code></pre>

GC統計工具

#!/bin/bash

pid=jps | grep $1 | awk '{print $1}' jstat -gcutil ${pid} 400 10000</code></pre>

  • 使用

    sh jstat-uti.sh ${u-main-class}

附加: 為什么在實驗中in-heap cache的Minor GC那么少?

現在我還不能給出一個確切地分析答案, 有的同學說是因為CMS Full GC會連帶一次Minor GC, 而用 jstat 會直接計入Full GC, 但查看詳細的GC日志也并未發現什么端倪. 希望有了解的同學可以在下面評論區可以給我留言, 再次先感謝了O(∩_∩)O~.

 

來自:http://blog.csdn.net/zjf280441589/article/details/54406665

 

 本文由用戶 MilLong 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!