最近经常有人问我在Java中使用堆外(off heap)内存的好处与用途何在。我想其他面临几样选择的人应该也会对这个答案感兴趣吧。

堆外内存其实并无特别之处。线程栈,应用程序代码,NIO缓存用的都是堆外内存。事实上在C或者C++中,你只能使用未托管内存,因为它们默认是没有托管堆(managed heap)的。在Java中使用托管内存或者“堆”内存是这门语言的一个特性。注意:Java并非唯一这么做的语言。

new Object() vs 对象池 vs 堆外内存

1、new Object()

在Java 5.0以前,对象池一度非常流行。那个时候创建对象的开销是非常昂贵的。然而,从Java 5.0以后,对象创建及垃圾回收已经变得非常廉价了,开发人员发现性能得到了提升后,便简化了代码,废弃了对象池,需要的时候就去创建新的对象就好了。在Java 5.0以前,几乎所有对象,包括对象池本身,都通过对象池来提升性能,而在5.0以后,只有那些特别昂贵的对象才有必要池化了,比方说线程,Socket,以及数据库连接。

2、对象池

在低时延领域它仍是有一定的用武之处的,由于可变对象的循环使用减轻了CPU缓存的压力,进而使得性能得到了提升。这些对象的生命周期和结构都必须尽可能简单,但这么做之后你会发现系统性能及抖动都会得到大幅度的改善。

还有一个领域也比较适合使用对象池,譬如需要加载海量数据且其中包含许多冗余对象时。使用对象池能显著减少内存的使用量以及需要GC的对象数,进而换来更短的GC时间以及更高的吞吐量。

这类对象池通常都会设计得比较轻量级,而非简单地使用一个同步的HashMap,因此它们仍是有存在的价值的。

StringInterner类来作一个例子。你可以将一个包含你想要的文本的可重复使用的可变StringBuilder作为参数传给它,它会返回你一个匹配的字符串。直接传递String对象的效率会很低,因为你已经把这个对象创建出来了。StringBuilder则是可以重复使用的。

注意:这个结构有一个很有意思的特性就是它不需要额外的线程安全的机制,比方说volatile或者synchronized,仅需Java所保障的最低限度的线程安全就足够了。你能正确地访问到String内部的final字段,顶多就是读到了不一致的引用而已。

public class StringInterner {  
    privatefinal String[] interner;  
    privatefinal int mask;  
    publicStringInterner(intcapacity) {  
        intn = Maths.nextPower2(capacity, 128);  
        interner = newString[n];  
        mask = n - 1;  
    }  
   
    private static boolean isEqual(@NullableCharSequence s, @NotNullCharSequence cs) {  
        if(s == null)returnfalse;  
        if(s.length() != cs.length()) returnfalse;  
        for(inti = 0; i < cs.length(); i++)  
            if(s.charAt(i) != cs.charAt(i))  
                returnfalse;  
        returntrue;  
    }  
   
    @NotNull  
    public String intern(@NotNullCharSequence cs) {  
        longhash = 0;  
        for(inti = 0; i < cs.length(); i++)  
            hash = 57* hash + cs.charAt(i);  
        inth = (int) Maths.hash(hash) & mask;  
        String s = interner[h];  
        if(isEqual(s, cs))  
            returns;  
        String s2 = cs.toString();  
        returninterner[h] = s2;  
    }  
}  

3、堆外内存的使用

使用堆外内存与对象池都能减少GC的暂停时间,这是它们唯一的共同点。生命周期短的可变对象,创建开销大,或者生命周期虽长但存在冗余的可变对象都比较适合使用对象池。生命周期适中,或者复杂的对象则比较适合由GC来进行处理。然而,中长生命周期的可变对象就比较棘手了,堆外内存则正是它们的菜。

3.1、ehcache、memcache中都有堆外内存的使用。

分别见《ehcache基本原理》中的“

ehcache存储方式

1、堆内存储:速度快,但是容量有限。

2、堆外(OffHeapStore)存储:被称为BigMemory,只在企业版本的Ehcache中提供,原理是利用nio的DirectByteBuffers实现,比存储到磁盘上快,而且完全不受GC的影响,可以保证响应时间的稳定性;但是direct buffer的在分配上的开销要比heap buffer大,而且要求必须以字节数组方式存储,因此对象必须在存储过程中进行序列化,读取则进行反序列化操作,它的速度大约比堆内存储慢一个数量级。

(注:direct buffer不受GC影响,但是direct buffer归属的的JAVA对象是在堆上且能够被GC回收的,一旦它被回收,JVM将释放direct buffer的堆外空间。)

3.2、spark

见《Spark Tungsten in-heap / off-heap 内存管理机制

 

4、堆外内存的优点和缺点

堆外内存,其实就是不受JVM控制的内存。相比于堆内内存有几个优势: 
  1 、减少了垃圾回收的工作,因为垃圾回收会暂停其他的工作(可能使用多线程或者时间片的方式,根本感觉不到) 。
  2 、加快了复制的速度。因为堆内在flush到远程时,会先复制到直接内存(非堆内存),然后在发送;而堆外内存相当于省略掉了这个工作。 
  3、可以在进程间共享,减少JVM间的对象复制,使得JVM的分割部署更容易实现。
  4、可以扩展至更大的内存空间。比如超过1TB甚至比主存还大的空间。

 而福之祸所依,自然也有不好的一面: 
  1 堆外内存难以控制,如果内存泄漏,那么很难排查 
  2 堆外内存相对来说,不适合存储很复杂的对象。一般简单的对象或者扁平化的比较适合。

站在系统设计的角度来看,使用堆外内存可以为你的设计提供更多可能。最重要的提升并不在于性能,而是决定性的。

堆内在flush到远程时,会先复制到直接内存(非堆内存),然后在发送的说明:

HeapByteBuffer与DirectByteBuffer,在原理上,前者可以看出分配的buffer是在heap区域的,其实真正flush到远程的时候会先拷贝得到直接内存,再做下一步操作(考虑细节还会到OS级别的内核区直接内存),其实发送静态文件最快速的方法是通过OS级别的send_file,只会经过OS一个内核拷贝,而不会来回拷贝;在NIO的框架下,很多框架会采用DirectByteBuffer来操作,这样分配的内存不再是在java heap上,而是在C heap上,经过性能测试,可以得到非常快速的网络交互,在大量的网络交互下,一般速度会比HeapByteBuffer要快速好几倍。

直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用,而且也可能导致OutOfMemoryError 异常出现,所以我们放到这里一起讲解。 
在JDK 1.4 中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O 方式,它可以使用Native 函数库直接分配堆外内存,然后通过一个存储在Java 堆里面的DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java 堆和Native 堆中来回复制数据。

import sun.nio.ch.DirectBuffer;

import java.nio.ByteBuffer;

public class Main {

    public static void main(String[] args) throws InterruptedException {
        System.out.println("Hello World!");
        ByteBuffer bb = ByteBuffer.allocateDirect(1024 * 1024 * 128);
        Thread.sleep(10000);
        ((DirectBuffer)bb).cleaner().clean();
        Thread.sleep(10000);
    }
}

 

堆外内存及测试

高性能计算领域最大的一个难点在于重现那些隐蔽的BUG,并证实问题已经得到修复。通过将输入事件及数据以持久化的形式存储到堆外内存中,你可以将你的关键系统变成一系列的复杂状态机。(简单的情况下只有一个状态机)。这样的话在测试环境便能够复现出生产环境出现的行为及性能问题了。

许多投行都通过这项技术来可靠地重现当天系统对某个事件的响应,并分析出该事件之所以这么处理的原因。更为重要的是,你能够立即证明线上的故障已经得到了解决,而不是发现一个问题后,寄希望于它就是引发线上故障的根源。确定性的行为还伴随着确定性的性能。

你可以在测试环境中按照真实的时间来回放事件,由此得到的时延分布也必定是生产环境中所出现的。由于硬件的不同,一些系统的抖动可能难以复现,不过这在数据分析的角度而言已经相当接近真实的情况了。为了避免出现花一整天的时间来回话前一天的数据的情况,你还可以增加一个阈值,比方说,如果两个事件的间隔超过10ms的话你可以就只等待10ms。这样你能够在一个小时内根据实际的时间来回放出一天的事件,来检查下你的改动是否对时延分布有所改善。

这样做是否就损失了“一次编译,处处执行”的好处了?

一定程度上来讲是这样的,但其实的影响比你想像的要小得多。越接近处理器,你就更依赖于处理器或者操作系统的行为。所幸的是,绝大多数系统使用的都是AMD/Intel的CPU,甚至是ARM处理器在底层上也越来越与这两家兼容了。操作系统之间也存在差别,因此相对于Windows而言,这项技术更适合在Linux系统上使用。如果你是在Mac OS X或者Windows上开发,然后生产环境是部署在Linux上的话,就一点问题都没有了。我们在Higher Frequency Trading中也是这么做的。

使用堆外内存会引入什么新的问题

天下没有免费的午餐,堆外内存也不例外。最大的问题在于你的数据结构变得有些别扭。要么就是需要一个简单的数据结构以便于直接映射到堆外内存,要么就使用复杂的数据结构并序列化及反序列化到内存中。很明显使用序列化的话会比较头疼且存在性能瓶颈。使用序列化比使用堆对象的性能还差。

在金融领域,许多高频率的数据都是扁平的简单结构,全部由基础类型组成,非常适合映射到堆外内存。然而,并非所有的应用程序都是这样的,可能会有一些嵌套得很深的数据结构,比如说图,你还不得不将这些对象缓存在堆上。

另外一个问题就是JVM会制约到你对操作系统的使用。你不用再担心JVM会给系统造成过重的负载。使用堆外内存后,某些限制已经不复存在了,你可以使用比主存还大的数据结构,不过如果你这么做的话又得考虑一下使用的是什么磁盘子系统了。比如说,你肯定不会希望分页到一块只有80 IOPS(Input/Ouput Operations per Second,每秒的IO操作)的HDD硬盘上,最好是IOPS能到80,000的SSD硬盘,当然了,1000x的话更好。

OpenHFT能做些什么?

OpenHFT包含许多类库,它们向你屏蔽了使用本地内存来存储数据的细节。这些数据结构都是持久化的,使用它们不会产生垃圾或者只有很少。使用了它的应用程序可以运行一整天也没有一次Minor GC.

Chronicle Queue——持久化的事件队列。支持同一台机器上多个JVM的并发写,以及多台机器间的并发读。微秒级的延迟,并能持续保持每秒上百万消息的吞吐量。

Chronicle Map——kv表的本地或持久化存储。它能在同一台机器的不同JVM间共享,数据是通过UDP或者TCP来复制的,并通过TCP来进行远程访问。微秒级的延迟,单台机器能保持每秒百万级的读写操作。

Thread Affinity ——将关键线程绑定到独立的CPU核或者逻辑CPU上,以减少系统抖动。抖动可以减小到原来的千分之一。