【译】Java:对象重用如何降低延迟并提高性能转载
通过阅读本文熟悉对象重用的艺术,并了解多线程 Java 应用程序中不同重用策略的优缺点。它能让你以更少的延迟编写更高性能的代码。
虽然在 Java 等面向对象语言中使用对象提供了一种抽象复杂性的极好方法,但频繁的对象创建可能会带来内存压力和垃圾收集方面的不利影响,这将对应用程序的延迟和性能产生不利影响.
仔细重用对象提供了一种在保持大部分预期抽象级别的同时保持性能的方法。本文探讨了几种重用对象的方法。
问题
默认情况下,JVM 将在堆上分配新对象。这意味着这些新对象将在堆上累积,一旦对象超出范围(即不再被引用),占用的空间最终将不得不在称为“垃圾收集”或简称 GC 的过程中回收。随着创建和删除对象的几个周期的过去,内存经常变得越来越碎片化。
虽然这适用于对性能要求很少或没有要求的应用程序,但它成为对性能敏感的应用程序的一个重要瓶颈。更糟糕的是,这些问题通常在具有许多 CPU 内核和跨 NUMA 区域的服务器环境中加剧。
内存访问延迟
从主存储器访问数据相对较慢(大约 100 个周期,因此在当前硬件上大约 30 ns 与使用寄存器的子 ns 访问相比)尤其是如果内存区域没有被长时间访问(导致 TLB 未命中或甚至是页面错误)。随着更本地化的数据驻留在 L3、L2、L1 CPU 缓存中直至实际 CPU 寄存器本身,延迟提高了几个数量级。因此,必须保留一小部分工作数据。
内存延迟和分散数据的后果
当在堆上创建新对象时,CPU 必须将这些对象写入内存位置,因为靠近初始对象的内存被分配,因此不可避免地位于越来越远的位置。这在对象创建期间可能不是一个影响深远的问题,因为缓存和 TLB 污染会随着时间的推移而分散,并在应用程序中造成统计上合理均匀分布的性能下降。
然而,一旦这些对象被回收,就会有一个由 GC 创建的内存访问“风暴”,它会在短时间内访问大量不相关的内存空间。这有效地使 CPU 缓存无效并使内存带宽饱和,从而导致显着且不确定的应用程序性能下降。
更糟糕的是,如果应用程序以 GC 无法在合理时间内完成的方式改变内存,一些 GC 会干预并停止所有应用程序线程,以便它可以完成其任务。这会造成大量的应用程序延迟,可能在几秒钟内甚至更糟。这被称为“stop-the-world 集合”。
改进的 GC
近年来,GC 算法有了显着的改进,可以缓解上述一些问题。然而,在创建大量新对象时,基本的内存访问带宽限制和 CPU 缓存耗尽问题仍然是一个因素。
重用对象并不容易
阅读了上述问题后,重用对象似乎是一种容易被随意采摘的低垂果实。事实证明,情况并非如此,因为对对象重用施加了一些限制。
不可变的对象总是可以在线程之间重用和传递,这是因为它的字段是最终的并且由构造函数设置,从而确保完全可见性。因此,重用不可变对象是简单的并且几乎总是可取的,但是不可变模式可以导致高度的对象创建。
但是,一旦构造了可变实例,Java 的内存模型就要求在读取和写入普通实例字段(即非易失性字段)时应用正常的读写语义。因此,这些更改仅保证对写入字段的同一线程可见。
因此,与许多看法相反,创建 POJO、在一个线程中设置一些值并将该 POJO 交给另一个线程根本行不通。接收线程可能没有看到更新,可能会看到部分更新(例如 long 的低四位已更新但高位未更新)或所有更新。更糟糕的是,这些变化可能会在 100 纳秒后、一秒后看到,或者根本看不到。根本没有办法知道。
各种解决方案
避免 POJO 问题的一种方法是将原始字段(例如 int 和 long 字段)声明为 volatile 并为引用字段使用原子变体。将数组声明为易失性意味着只有引用本身是易失性的,并且不向元素提供易失性语义。这可以解决,但通用解决方案超出了本文的范围,尽管 Atomic*Array 类提供了一个良好的开端。声明所有字段 volatile 并使用并发包装类可能会导致一些性能损失。
重用对象的另一种方法是使用 ThreadLocal 变量,该变量将为每个线程提供不同且时间不变的实例。这意味着可以使用正常的高性能内存语义。此外,由于线程仅按顺序执行代码,因此也可以在不相关的方法中重用相同的对象。假设在许多方法中需要一个 StringBuilder 作为临时变量(然后在每次使用之间将 StringBuilder 的长度重置为零),那么可以在这些不相关的方法中重用为特定线程保存相同实例的 ThreadLocal (前提是没有方法调用共享重用的方法,包括方法本身)。不幸的是,获取 ThreadLocal 的内部实例的机制会产生一些开销。
- 使用后很难清理。
- 容易发生内存泄漏。
- 可能无法扩展。尤其是因为 Java 即将推出的虚拟线程特性促进了创建大量线程。
- 有效地为线程构成了一个全局变量。
此外,可以提到线程上下文可用于保存可重用的对象和资源。这通常意味着线程上下文将以某种方式暴露在 API 中,但结果是它提供了对线程重用对象的快速访问。因为对象可以在线程上下文中直接访问,所以它提供了一种更直接和确定性的释放资源的方式。例如,当线程上下文关闭时。
最后,可以混合使用 ThreadLocal 和线程上下文的概念,从而提供无污染的 API,同时提供简化的资源清理,从而避免内存泄漏。
需要注意的是,还有其他方法可以保证内存的一致性。例如,使用可能鲜为人知的 Java 类 Exchanger。后者允许交换消息,从而保证在交换之前由 from-thread 进行的所有内存操作都发生在 to-thread 中的任何内存操作之前。
另一种方法是使用开源Chronicle Queue,它提供了一种高效、线程安全、无需对象创建的方法来在线程之间交换消息。
在 Chronicle Queue 中,消息也被持久化,从而可以从某个点(例如,从队列的开头)重播消息并重建服务的状态(这里,线程及其状态被称为服务)。如果在服务中检测到错误,则可以通过重播输入队列中的所有消息来重新创建错误状态(例如在调试模式下)。这对于测试也非常有用,可以将许多预先制作的队列用作服务的测试输入。
可以通过组合多个更简单的服务来获得更高阶的功能,每个服务都通过一个或多个 Chronicle Queue 进行通信并产生输出结果,也以 Chronicle Queue 的形式。
总和提供了一个完全确定性和解耦的事件驱动微服务解决方案。
重用编年史队列中的对象
在之前的文章中,开源 Chronicle Queue进行了基准测试,并被证明具有高性能。本文的一个目的是仔细研究如何实现这一点,以及对象重用如何在 Chronicle Queue(使用版本 5.22ea6)的底层工作。
与上一篇文章一样,使用了相同的简单数据对象:
public class MarketData extends SelfDescribingMarshallable {
int securityId;
long time;
float last;
float high;
float low;
// Getters and setters not shown for brevity
}
这个想法是创建一个顶层对象,在将大量消息附加到队列时重用,然后在运行此代码时分析整个堆栈的内部对象使用情况:
public static void main(String[] args) {
final MarketData marketData = new MarketData();
final ChronicleQueue q = ChronicleQueue
.single("market-data");
final ExcerptAppender appender = q.acquireAppender();
for (long i = 0; i < 1e9; i++) {
try (final DocumentContext document =
appender.acquireWritingDocument(false)) {
document
.wire()
.bytes()
.writeObject(MarketData.class,
MarketDataUtil.recycle(marketData));
}
}
}
由于 Chronicle Queue 将对象序列化为内存映射文件,因此出于上述性能原因,它不会创建其他不必要的对象,这一点很重要。
内存使用情况
该应用程序使用 VM 选项“-verbose:gc”启动,以便通过观察标准输出清楚地检测到任何潜在的 GC。应用程序启动后,在插入初始 1 亿条消息后,会转储最常用对象的直方图:
pemi@Pers-MBP-2 queue-demo % jmap -histo 8536
num #instances #bytes class name
----------------------------------------------
1: 14901 75074248 [I
2: 50548 26985352 [B
3: 89174 8930408 [C
4: 42355 1694200 java.util.HashMap$KeyIterator
5: 56087 1346088 java.lang.String
…
2138: 1 16 sun.util.resources.LocaleData$LocaleDataResourceBundleControl
Total 472015 123487536
在应用程序在几秒钟后附加了大约 1 亿条额外消息后,进行了新的转储:
pemi@Pers-MBP-2 queue-demo % jmap -histo 8536
num #instances #bytes class name
----------------------------------------------
1: 14901 75014872 [I
2: 50548 26985352 [B
3: 89558 8951288 [C
4: 42355 1694200 java.util.HashMap$KeyIterator
5: 56330 1351920 java.lang.String
…
2138: 1 16 sun.util.resources.LocaleData$LocaleDataResourceBundleControl
Total 473485 123487536
可以看出,分配的对象数量(大约 1500 个对象)仅略有增加,这表明每个发送的消息都没有进行对象分配。JVM 没有报告 GC,因此在采样间隔期间没有收集任何对象。
在考虑上述所有约束的情况下,在不创建任何对象的情况下设计这样一个相对复杂的代码路径当然不是微不足道的,并且表明该库在性能方面已经达到了一定的成熟度。
它花费大约 7% 的时间通过
ThreadLocal$ThreadLocalMap.getEntry(ThreadLocal)
方法,但与动态创建对象相比,这是非常值得的。
可以看出,Chronicle Queue 大部分时间都在访问 POJO 中的字段值,以便使用 Java 反射将其写入队列。尽管预期的操作(即从 POJO 复制值到队列)出现在靠近顶部的某个位置是一个很好的指标,但仍有一些方法可以通过提供手工制作的序列化方法大大减少执行时间来进一步提高性能。不过那是另一回事了。
下一步是什么?
在性能方面,还有其他功能,例如能够隔离 CPU 并将 Java 线程锁定到这些隔离的 CPU,大大减少应用程序抖动以及编写自定义序列化程序。
最后,还有一个企业版,可以跨服务器集群复制队列,为分布式架构的高可用性和改进性能铺平道路。企业版还包括一组其他功能,例如加密、时区滚动和异步消息处理。
参考资料
Chronicle Queue开源
编年史队列企业