问题: 大部分主流互联网企业线上Server JVM选用了CMS收集器(如Taobao、LinkedIn、Vdian), 虽然CMS可与用户线程并发GC以降低STW时间, 但它也并非十分完美, 尤其是当出现Concurrent Mode Failure由并行GC转入串行时, 将导致非常长时间的Stop The World(详细可参考JVM初探- 内存分配、GC原理与垃圾收集器).
解决: 由GCIH可以联想到: 将长期存活的对象(如Local Cache)移入堆外内存(off-heap, 又名直接内存/direct-memory), 从而减少CMS管理的对象数量, 以降低Full GC的次数和频率, 达到提高系统响应速度的目的.
引入
这个idea最初来源于TaobaoJVM对OpenJDK定制开发的GCIH部分(详见撒迦的分享-JVM定制改进@淘宝), 其中GCIH就是将CMS Old Heap区的一部分划分出来, 这部分内存虽然还在堆内, 但已不被GC所管理.将长生命周期Java对象放在Java堆外, GC不能管理GCIH内Java对象(GC Invisible Heap):
(图片来源: JVM@Taobao PPT)
这样做有两方面的好处:
- 减少GC管理内存:
由于GCIH会从Old区“切出”一块, 因此导致GC管理区域变小, 可以明显降低GC工作量, 提高GC效率, 降低Full GC STW时间(且由于这部分内存仍属于堆, 因此其访问方式/速度不变- 不必付出序列化/反序列化的开销).
- GCIH内容进程间共享:
由于这部分区域不再是JVM运行时数据的一部分, 因此GCIH内的对象可供多个JVM实例所共享(如一台Server跑多个MR-Job可共享同一份Cache数据), 这样一台Server也就可以跑更多的VM实例.
但是大部分的互联公司不能像阿里这样可以有专门的工程师针对自己的业务特点定制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提供了如下常用方法来跟堆外内存打交道:
下面我们就用通用的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;
}
}
由JDK提供的堆外内存访问API只能申请到一个类似一维数组的ByteBuffer, JDK并未提供基于堆外内存的实用数据结构实现(如堆外的Map、Set), 因此想要实现Cache的功能只能在write()时先将数据put()到一个堆内的HashMap, 然后再将整个Map序列化后MoveIn到DirectMemory, 取缓存则反之. 由于需要在堆内申请HashMap, 因此可能会导致多次Full GC. 这种方式虽然可以使用堆外内存, 但性能不高、无法发挥堆外内存的优势.
幸运的是开源界的前辈开发了诸如Ehcache、MapDB、Chronicle 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 {