总览

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

堆外内存没什么特别的。 线程堆栈,应用程序代码,NIO缓冲区都在堆外。 实际上,在C和C ++中,您只有非托管内存,因为默认情况下它没有托管堆。 Java中托管内存或“堆”的使用是该语言的一个特殊功能。 注意:Java不是执行此操作的唯一语言。

新的Object()vs对象池vs Off堆内存

新的Object()

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

对象池

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

使用对象池的另一个有意义的领域是当使用许多重复的对象加载大量数据时。 随着内存使用量的显着减少以及GC必须管理的对象数量的减少,您看到了GC时间的减少和吞吐量的增加。

与使用同步HashMap相比,这些对象池的重量更轻,因此它们仍然可以提供帮助。

以这个StringInterner类为例。 您将所需文本的可循环使用的可变StringBuilder作为字符串传递给它,它将提供匹配的字符串。 传递String效率不高,因为您已经创建了对象。 StringBuilder可以回收。

注意:此结构具有一个有趣的属性,除了最低Java保证所提供的功能外,不需要任何其他线程安全功能(例如volatile或sync)。 也就是说,您可以正确地看到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处理。 但是,中型到长寿命的可变对象受堆内存解决的许多方式的困扰。

堆外内存提供;

  • 大于主内存。
  • 名义上对GC暂停时间的影响。
  • 在进程之间共享,减少JVM之间的重复,并使分裂JVM更容易。
  • 持久性,可以更快地重新启动或回复测试中的生产数据。

在设计系统方面,堆外内存的使用为您提供了更多选择。 最重要的改进不是性能,而是确定性。

堆外和测试

高性能计算中的最大挑战之一是再现难以理解的错误,并能够证明已解决了这些错误。 通过以持久方式将所有输入事件和数据存储在堆外,您可以将关键系统变成一系列复杂的状态机。 (或者在简单的情况下,只有一个状态机)以这种方式,您可以在测试和生产之间获得可重现的行为和性能。

问题的问题。

确定性行为与确定性行为一起出现。 在测试环境中,您可以按照实际的时间重播事件,并显示期望在生产中获得的延迟分布。 如果硬件不同,则无法重现某些系统抖动,但是从统计角度看,您可能会非常接近。 为了避免花一天时间重放一天的数据,您可以添加一个阈值。 例如,如果事件之间的时间超过10毫秒,则您可能只能等待10毫秒。 这样一来,您可以在不到一个小时的时间内按实际时间重播一天的事件,并查看您的更改是否改善了延迟分配。

通过降低级别,您不会失去“一次编译,随处运行”的某些功能吗?

在某种程度上,这是正确的,但远没有您想像的要多。 当您靠近处理器工作时,您将更加依赖处理器或OS的行为方式。 幸运的是,大多数系统使用AMD / Intel处理器,就其提供的低级别保证而言,甚至ARM处理器也变得更加兼容。 操作系统之间也存在差异,并且这些技术在Linux上比Windows上更有效。 但是,如果您在MacOSX或Windows上进行开发并使用Linux进行生产,则应该没有任何问题。 这就是我们在高频交易中所做的。

我们通过使用堆产生了哪些新问题?

没有什么是免费的,堆外情况就是这样。 堆外的最大问题是您的数据结构变得不太自然。 您或者需要一个可以直接映射到堆外的简单数据结构,或者需要一个序列化和反序列化的复杂数据结构以使其脱离堆。 显然使用序列化有其自身的麻烦和性能损失。 因此,使用序列化要比在堆对象上慢得多。

在金融世界中,最昂贵的数据结构是扁平且简单的,充满了原语,可以很好地在堆外映射,几乎没有开销。 但是,这并不适用于所有应用程序,您可以获得复杂的嵌套数据结构,例如图形,最终还必须在堆上缓存某些对象。

另一个问题是,JVM限制了您可以使用的系统数量。 您不必担心JVM会使系统过载太多。 有了堆外处理,就解除了一些限制,您可以使用比主内存大得多的数据结构,并且开始担心如果要这样做,您将拥有哪种磁盘子系统。 例如,您不希望分页至具有80 IOPS的HDD,而您可能希望拥有80,000 IOPS(每秒输入/输出操作)或更高(即快1000倍)的SSD。

OpenHFT如何提供帮助?

少量收集即可全天运行的应用程序中

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

编年史地图 –键值地图的本机或持久存储。 可以在同一台机器上的JVM之间共享,通过UDP或TCP复制和/或通过TCP远程访问。 微秒级延迟和持续的读/写速率,每台机器每秒可进行数百万次操作。

线程亲和性 –将关键线程绑定到隔离的内核或逻辑CPU,以最大程度地减少抖动。 可以将抖动降低1000倍。

使用哪个API?

如果您需要记录每个事件->编年史队列

如果您只需要最新结果作为唯一键->编年史地图

如果您关心20微秒抖动->线程亲和力

结论

堆外内存可能会带来挑战,但也会带来很多好处。 您可以在其中看到最大的收益,并与其他为实现可伸缩性而引入的解决方案进行比较。 与在堆缓存,消息传递解决方案或进程外数据库上使用分区/分片相比,堆外可能更简单,更快。 通过提高速度,您可能会发现不再需要为获得所需性能所需的一些技巧。 例如,堆外解决方案可以支持对OS的同步写入,而不必以异步方式执行写入操作,而存在数据丢失的风险。

但是,最大的收益就是启动时间,从而使您的生产系统重新启动的速度更快。 例如,映射到1 TB数据集中可能需要10毫秒,并且通过重播每个事件以使您每次都获得相同的行为,可以简化测试的可重复性。 这使您可以创建可以依靠的质量体系。