堆内与堆外内存使用


最近有人问我在Java中使用堆外内存的好处和智慧。面临相同选择的其他人可能会对答案感兴趣。

堆外内存没什么特别的。线程堆栈、应用程序代码、NIO缓冲区都是堆外的。事实上,在C和C++中,您只有非托管内存,因为默认情况下它没有托管堆。在Java中使用托管内存或“堆”是该语言的一个特殊特性。注意:Java并不是唯一可以做到这一点的语言。

新对象()对对象池对堆外内存。

新对象()

在Java 5.0之前,使用对象池非常流行。创造物品仍然非常昂贵。然而,从Java 5.0开始,对象分配和垃圾清理变得便宜得多,开发人员发现,通过删除对象池和只在需要时创建新对象,他们提高了性能并简化了代码。在Java 5.0之前,几乎所有的对象池,甚至是使用对象的对象池都提供了改进,从Java 5.0开始,只有昂贵的对象才明显有意义,例如线程、套接字和数据库连接。

对象池

在低延迟空间中,回收可变对象通过减少对中央处理器高速缓存的压力来提高性能仍然是显而易见的。这些对象必须具有简单的生命周期和简单的结构,但是通过使用它们,您可以看到性能和抖动方面的显著改进。

使用对象池有意义的另一个方面是当加载大量带有许多重复对象的数据时。随着内存使用量的显著减少和垃圾收集必须管理的对象数量的减少,垃圾收集时间减少,吞吐量增加。

这些对象池被设计得比使用同步哈希表更轻,所以它们仍然有帮助。

拿着这个StringInterner以类为例。您向它传递一个可循环使用的可变字符串生成器,它将提供一个匹配的字符串。传递一个字符串是低效的,因为您已经创建了对象。StringBuilder可以回收。

注意:这种结构有一个有趣的特性,它不需要额外的线程安全特性,比如易失性或同步性,除了由最低限度的Java保证提供。也就是说,您可以正确地看到字符串中的最后一个字段,并且只能读取一致的引用。


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;
    }
}

堆外内存使用

使用堆外内存和使用对象池都有助于减少垃圾收集暂停,这是它们唯一的相似之处。对象池适用于短期可变对象,创建对象的成本较高,以及长期不可变对象,这些对象存在大量重复。中等寿命的可变对象或复杂对象更有可能由垃圾收集器来处理。然而,中到长寿命的可变对象在许多方面受到影响,堆外内存解决了这些问题。

堆外内存提供;

  • 可扩展至大内存大小,例如超过1 TB,以及更大的比主存还多。
  • 对垃圾收集暂停时间的名义影响。
  • 在进程之间共享,减少虚拟机之间的重复,并且更容易拆分虚拟机。
  • 测试中更快重启或回复生产数据的持久性。

堆外内存的使用在如何设计系统方面给了你更多的选择。最重要的改进不是性能,而是决定论。

堆外和测试

高性能计算面临的最大挑战之一是重现模糊的错误,并能够证明您已经修复了它们。通过以持久化的方式将所有输入事件和数据从堆中存储出来,您可以将关键系统转变成一系列复杂的状态机。(或者在简单的情况下,只有一个状态机)通过这种方式,您可以在测试和生产之间获得可再现的行为和性能。许多投资银行使用这种技术,对一天中的任何事件可靠地重放一个系统,并弄清楚为什么该事件被如此处理。更重要的是,一旦你有了一个解决方案,你就可以显示你已经解决了生产中出现的问题,而不是发现一个问题并希望它是这个问题。伴随确定性行为而来的是确定性性能。在测试环境中,您可以用真实的时间重放事件,并显示您期望在生产中得到的延迟分布。如果硬件不一样,某些系统抖动是无法重现的,但从统计角度看,你可以非常接近。为了避免花费一天时间来重放一天的数据,您可以添加一个阈值。例如,如果事件之间的时间间隔超过10毫秒,您可能只需等待10毫秒。这可以让您在不到一个小时的时间内以真实的时间安排重放一天的事件,并查看您的更改是否改善了延迟分布。

通过更低的级别,你不会失去一些“编译一次,运行在任何地方”?

从某种程度上来说,这是真的,但远不如你想象的那样。当你靠近处理器工作时,你更依赖于处理器或操作系统的行为。幸运的是,大多数系统使用AMD/英特尔处理器,甚至ARM处理器也越来越兼容它们所提供的低级保证。操作系统也有差异,这些技术在Linux上比在Windows上更有效。然而,如果你在苹果操作系统或视窗系统上开发并使用Linux进行生产,你应该不会有任何问题。这就是我们在高频交易中所做的。

我们使用堆外会产生什么新问题?

没有什么是免费的,堆外的就是这种情况。堆外最大的问题是你的数据结构变得不自然。您要么需要一个可以直接映射到堆外的简单数据结构,要么需要一个复杂的数据结构,通过序列化和反序列化将它从堆外移走。显而易见,使用序列化有它自己的麻烦和性能打击。因此,使用序列化比堆对象慢得多。在金融世界中,大多数高滴答声的数据结构是平坦而简单的,充满了原始数据,这些原始数据以很少的开销很好地映射到堆外。然而,这并不适用于所有的应用程序,您可能会得到复杂的嵌套数据结构,例如图形,最终您也可能不得不在堆上缓存一些对象。另一个问题是,JVM限制了您可以使用的系统容量。您不必担心JVM让系统负担过重。离开堆,一些限制被解除,你可以使用比主存大得多的数据结构,如果你这样做,你开始担心你有什么样的磁盘子系统。例如,您不希望分页到具有80 IOPS的硬盘,相反,您可能希望固态硬盘具有80,000 IOPS(每秒输入/输出操作数)或更高,即1000倍的速度。

OpenHFT有什么帮助?

OpenHFT有许多库来隐藏这样一个事实,即您实际上是在使用本机内存来存储数据。这些数据结构是持久化的,可以很少或没有垃圾地使用。这些用于全天运行的应用程序,无需次要收藏

Chronicle Queue-事件的持久队列。支持同一台机器上跨JVM的并发编写器和跨机器的并发读取器。每秒百万条消息的微秒延迟和持续吞吐量。

Chronicle Map-键值映射的本机或持久存储。可以在同一台机器上的虚拟机之间共享、通过UDP或TCP复制和/或通过TCP远程访问。每台机器每秒百万次操作的微秒延迟和持续读/写速率。

Thread Affinity-将关键线程绑定到独立内核或逻辑CPU,以最大限度地减少抖动。可以将抖动降低1000倍。

使用哪个应用编程接口?

如果需要记录每个事件->历史队列

如果您只需要唯一键的最新结果->历史地图

如果你关心20微秒的抖动->线程关联性

结论

堆外内存可能有挑战,但也有很多好处。在这里您可以看到最大的收益,并与为实现可扩展性而引入的其他解决方案进行比较。堆外可能比在堆缓存、消息传递解决方案或进程外数据库上使用分区/分片更简单、更快。通过变得更快,你可能会发现你需要做的一些技巧已经不再需要了。例如,堆外解决方案可以支持对操作系统的同步写入,而不是冒着数据丢失的风险异步执行。然而,最大的收获可能是你的启动时间,给你一个重新启动快得多的生产系统。例如,在1 TB的数据集中进行映射可能需要10毫秒,并且通过重放每个事件来简化测试的可重复性,以便每次都能获得相同的行为。这让你可以生产出你可以依赖的质量体系。