Java。对象重用如何减少延迟和提高性能
发布者::Per Minborg inCore Java January 27th, 2022 0 Views
通过阅读本文熟悉对象重用的艺术,了解多线程Java应用程序中不同重用策略的利弊。这可以让你写出性能更高的代码,延迟更小。
虽然在面向对象的语言(如Java)中使用对象提供了一种抽象化复杂性的极好方法,但频繁地创建对象会带来内存压力和垃圾收集方面的弊端,这将对应用程序的延迟和性能产生不利影响。
谨慎地重用对象提供了一种保持性能的方法,同时保持大部分预期的抽象水平。这篇文章探讨了几种重用对象的方法。
问题所在
默认情况下,JVM会在堆上分配新对象。这意味着这些新对象将在堆上积累,一旦这些对象超出范围(即不再被引用),在一个叫做 "垃圾回收 "或简称GC的过程中,所占用的空间最终将不得不被回收。当创建和删除对象的几个周期过去后,内存往往变得越来越碎片化。
虽然这在对性能要求不高或没有要求的应用中运行良好,但在对性能敏感的应用中却成为一个重要的瓶颈。更糟糕的是,这些问题在有许多CPU内核和跨NUMA区域的服务器环境中往往会加剧。
内存访问延迟
从主内存访问数据的速度相对较慢(大约100个周期,所以在目前的硬件上,与使用寄存器的亚纳秒访问相比,大约是30纳秒),特别是如果一个内存区域很长时间没有被访问(导致TLB缺失甚至页面故障的概率增加)。随着L3、L2、L1 CPU缓存中的数据更加本地化,直至实际的CPU寄存器本身,延迟会有数量级的提高。因此,保持一个小的数据工作集变得非常必要。
内存延迟和分散数据的后果
当新的对象在堆上被创建时,CPU必须将这些对象写入内存位置,由于靠近初始对象的内存被分配,这些对象不可避免地会被越拉越远。在对象创建过程中,这可能不是一个深远的问题,因为缓存和TLB污染会随着时间的推移而分散,并在应用中形成一个统计学上合理的均匀分布的性能降低。
然而,一旦这些对象要被回收,就会出现由GC创造的内存访问 "风暴",在短时间内访问大量不相关的内存空间。这有效地使CPU缓存失效,并使内存带宽饱和,从而导致显著的、非确定的应用程序性能下降。
更糟糕的是,如果应用程序以GC无法在合理时间内完成的方式突变内存,一些GC会介入并停止所有应用程序线程,以便它能够完成其任务。这就造成了大规模的应用延迟,有可能是几秒钟甚至更久。这被称为 "停止世界的集合"。
改进的GCs
近年来,GC算法有了很大的改进,可以缓解上述的一些问题。然而,在创建大量的新对象时,基本的内存访问带宽限制和CPU缓存耗尽问题仍然是一个因素。
重用对象并不容易
在阅读了上面的问题之后,我们可能会觉得重用对象是一个低垂的果实,可以很容易地随意摘取。事实证明,情况并非如此,因为对对象的重用有一些限制。
一个不可变的对象总是可以被重用,并在线程之间传递,这是因为它的字段是最终的,并由构造函数设置,以确保完全可见。因此,重用不可变的对象是直接的,而且几乎总是可取的,但不可变的模式会导致高度的对象创建。
然而,一旦构建了一个易变的实例,Java的内存模型规定,在读写正常的实例字段(即一个不易变的字段)时,要应用正常的读写语义。因此,这些变化只能保证对写入字段的同一线程可见。
因此,与许多人的想法相反,创建一个POJO,在一个线程中设置一些值,然后把这个POJO交给另一个线程,这根本行不通。接收的线程可能没有看到更新,可能看到部分更新(比如一个长条的低四位被更新了,但上面的没有),也可能看到所有更新。更糟糕的是,这些变化可能在100纳秒后、1秒后才被看到,或者根本就没有被看到。根本没有办法知道。
各种解决方案
避免POJO问题的一个方法是将原始字段(如int和long字段)声明为volatile,并对引用字段使用原子变体。将一个数组声明为易失性意味着只有引用本身是易失性的,而不为元素提供易失性语义。这个问题可以解决,但一般的解决方案不在本文的范围内,尽管Atomic*Array类提供了一个好的开始。声明所有的字段都是易失性的,并使用并发的封装类可能会产生一些性能上的损失。
另一种重用对象的方法是通过ThreadLocal变量,它将为每个线程提供不同的、时间不变的实例。这意味着可以使用正常的高性能内存语义。此外,由于一个线程只按顺序执行代码,所以也有可能在不相关的方法中重用同一个对象。假设在一些方法中需要一个StringBuilder作为抓取变量(然后在每次使用之间将StringBuilder的长度重置为0),那么一个为特定线程持有相同实例的ThreadLocal可以在这些不相关的方法中重复使用(只要没有方法调用共享重复使用的方法,包括方法本身)。不幸的是,围绕获取ThreadLocal的内部实例的机制产生了一些开销。还有一些与使用代码共享的ThreadLocal变量有关的罪魁祸首,使得它们。
- 使用后难以清理。
- 容易发生内存泄漏。
- 潜在的不可扩展性。特别是由于Java即将推出的虚拟线程功能提倡创建大量的线程。
- 有效地构成了一个线程的全局变量。
另外,可以提到的是,线程上下文可以用来保存可重用的对象和资源。这通常意味着线程上下文将以某种方式暴露在API中,但其结果是,它提供了对线程重复使用对象的快速访问。 因为对象可以在线程上下文中直接访问,所以它提供了一种更直接和确定的方式来释放资源。例如,当线程上下文被关闭时。
最后,ThreadLocal和线程上下文的概念可以混合在一起,提供一个无污染的API,同时提供简化的资源清理,从而避免了内存泄漏。
应该指出的是,还有其他方法可以确保内存的一致性。例如,使用也许不太知名的Java类Exchanger。后者允许消息的交换,从而保证在交换之前,所有由自线程进行的内存操作都发生在对线程的任何内存操作之前。
还有一种方法是使用开源的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",这样通过观察标准输出就可以清楚地发现任何潜在的GCs。一旦应用程序启动,在插入最初的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,所以在采样间隔期间没有收集任何对象。
在不创建任何对象的情况下设计这样一个相对复杂的代码路径,同时考虑到上述所有的约束条件,这当然不是一件容易的事,这表明该库在性能方面已经达到了一定的成熟度。
剖析方法
执行过程中调用的剖析方法显示,Chronicle Queue正在使用ThreadLocal变量。
它花了大约7%的时间通过以下方法查找线程本地变量
ThreadLocal$ThreadLocalMap.getEntry(ThreadLocal) 方法来查找线程本地变量,但与即时创建对象相比,这样做是非常值得的。
可以看出,Chronicle Queue花了大部分时间来访问POJO中的字段值,并使用Java反射将其写入队列中。尽管这是一个很好的指标,即预期的动作(即从POJO复制值到队列)出现在接近顶部的地方,但有办法通过提供手工制作的序列化方法来大幅减少执行时间,从而进一步提高性能。但这是另一个故事。
下一步是什么?
在性能方面,还有其他一些功能,如能够隔离CPU并将Java线程锁定在这些隔离的CPU上,大大减少应用程序的抖动,以及编写自定义的序列化程序。
最后,还有一个企业版,具有跨服务器集群的队列复制功能,为实现高可用性和提高分布式架构的性能铺平了道路。企业版还包括一系列其他功能,如加密、时区滚动和异步消息处理。
资源
Chronicle Queue(开源)
Chronicle主页
由我们JCG项目的合伙人Per Minborg授权发表在Java Code Geeks上。点击这里查看原文。Java。对象重用如何减少延迟和提高性能 Java Code Geeks撰稿人所表达的观点属于他们自己。 |
2022-01-27
