Java堆外内存的使用
最近经常听到有人问Java中使用堆外(off heap)内存的好处与用途何在。我想其他面临几样选择的人应该也会对这个答案感兴趣吧。
堆外内存其实并无特别之处。线程栈,应用程序代码,NIO缓存用的都是堆外内存。事实上在C或者C++中,你只能使用未托管内存,因为它们默认是没有托管堆(managed heap)的。在Java中使用托管内存或者“堆”内存是这门语言的一个特性。注意:Java并非唯一这么做的语言。
new Object() vs 对象池 vs 堆外内存
new Object()
在Java 5.0以前,对象池一度非常流行。那个时候创建对象的开销是非常昂贵的。然而,从Java 5.0以后,对象创建及垃圾回收已经变得非常廉价了,开发人员发现性能得到了提升后,便简化了代码,废弃了对象池,需要的时候就去创建新的对象就好了。在Java 5.0以前,几乎所有对象,包括对象池本身,都通过对象池来提升性能,而在5.0以后,只有那些特别昂贵的对象才有必要池化了,比方说线程,Socket,以及数据库连接。
对象池
在低时延领域它仍是有一定的用武之处的,由于可变对象的循环使用减轻了CPU缓存的压力,进而使得性能得到了提升。这些对象的生命周期和结构都必须尽可能简单,但这么做之后你会发现系统性能及抖动都会得到大幅度的改善。
还有一个领域也比较适合使用对象池,譬如需要加载海量数据且其中包含许多冗余对象时。使用对象池能显著减少内存的使用量以及需要GC的对象数,进而换来更短的GC时间以及更高的吞吐量。
这类对象池通常都会设计得比较轻量级,而非简单地使用一个同步的HashMap,因此它们仍是有存在的价值的。
拿StringInterner类来作一个例子。你可以将一个包含你想要的文本的可重复使用的可变StringBuilder作为参数传给它,它会返回你一个匹配的字符串。直接传递String对象的效率会很低,因为你已经把这个对象创建出来了。StringBuilder则是可以重复使用的。
注意:这个结构有一个很有意思的特性就是它不需要额外的线程安全的机制,比方说volatile或者synchronized,仅需Java所保障的最低限度的线程安全就足够了。你能正确地访问到String内部的final字段,顶多就是读到了不一致的引用而已。
public class StringInterner {
private final String[] interner;
private final int mask;
public StringInterner(int capacity) {
int n = Maths.nextPower2(capacity, 128);
interner = new String[n];
mask = n - 1;
}
private static boolean isEqual(@Nullable CharSequence s, @NotNull CharSequence cs) {
if (s == null) return false;
if (s.length() != cs.length()) return false;
for (int i = 0; i < cs.length(); i++)
if (s.charAt(i) != cs.charAt(i))
return false;
return true;
}
@NotNull
public String intern(@NotNull CharSequence cs) {
long hash = 0;
for (int i = 0; i < cs.length(); i++)
hash = 57 * hash + cs.charAt(i);
int h = (int) Maths.hash(hash) & mask;
String s = interner[h];
if (isEqual(s, cs))
return s;
String s2 = cs.toString();
return interner[h] = s2;
}
}
堆外内存的使用
使用堆外内存与对象池都能减少GC的暂停时间,这是它们唯一的共同点。生命周期短的可变对象,创建开销大,或者生命周期虽长但存在冗余的可变对象都比较适合使用对象池。生命周期适中,或者复杂的对象则比较适合由GC来进行处理。然而,中长生命周期的可变对象就比较棘手了,堆外内存则正是它们的菜。
堆外内存的好处是:
- 可以扩展至更大的内存空间。比如超过1TB甚至比主存还大的空间。
- 理论上能减少GC暂停时间。
- 可以在进程间共享,减少JVM间的对象复制,使得JVM的分割部署更容易实现。
- 它的持久化存储可以支持快速重启,同时还能够在测试环境中重现生产数据。
站在系统设计的角度来看,使用堆外内存可以为你的设计提供更多可能。最重要的提升并不在于性能,而是决定性的。
堆外内存及测试
高性能计算领域最大的一个难点在于重现那些隐蔽的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上,以减少系统抖动。抖动可以减小到原来的千分之一。
使用哪个API?
如果你需要记录每个事件的话 ——> Chronicle Queue
如果你只需要某个唯一主键最近的一条结果 ——> Chronicle Map
如果你更关心那20微秒的抖动的话 ——> Thread Affinity
总结
堆外内存是把双刃剑。它的价值你已经看到了,可以和别的实现可伸缩性的方案进行下比较。与在堆缓存或者消息队列,甚至是进程外的数据库中进行分区/分片相比,使用堆外内存要更为简单高效。不仅如此,以前用来提升性能的某些技巧也已经不再需要了。比如说,堆外内存可以支持操作系统的同步写,就不再需要异步去执行了,那样还会面临数据丢失的风险。不过最大的好处应该就是启动时间了,生产环境下的系统的重启速度会大大缩短,映射1 TB的数据只需要10毫秒,同时你还能在测试环境按生产环境的顺序复现每一个事件,以还原线上现场。通过它你可以建立起一个可靠的质量体系。