Java对象重用如何降低延迟并提高性能 - Minborg


通过阅读本文熟悉对象重用的艺术,并了解多线程 Java 应用程序中不同重用策略的优缺点。这允许您以更少的延迟编写更高性能的代码。
虽然在 Java 等面向对象的语言中使用对象提供了一种很好的抽象复杂性的方法,但频繁的对象创建可能会带来内存压力和垃圾收集方面的不利影响,这将对应用程序的延迟和性能产生不利影响. 
仔细重用对象提供了一种在保持大部分预期抽象级别的同时保持性能的方法。本文探讨了几种重用对象的方法。
默认情况下,JVM 会在堆上分配新对象。这意味着这些新对象将在堆上累积,并且一旦对象超出范围(即不再被引用),占用的空间最终必须在称为“垃圾收集”或简称 GC 的过程中回收。随着创建和删除对象的几个周期的过去,内存通常会变得越来越碎片化。
这成为对性能敏感的应用程序的一个重要瓶颈。更糟糕的是,这些问题通常在具有许多 CPU 内核和跨 NUMA 区域的服务器环境中加剧。
近年来,GC 算法有了显着的改进,可以缓解上述一些问题。
然而,在创建大量新对象时,基本的内存访问带宽限制和 CPU 缓存耗尽问题仍然是一个因素。
 
重用对象并不容易
不可变的对象总是可以在线程之间重用和传递,这是因为它的字段是最终的并且由构造函数设置,从而确保完全可见性。因此,重用不可变对象很简单,几乎总是可取的,但不可变模式可以导致高度的对象创建。
一旦构造了可变实例,Java 的内存模型要求在读取和写入普通实例字段(即非volatile字段)时应用正常的读写语义。因此,这些更改仅保证对写入字段的同一线程可见。 
因此,与许多看法相反,创建 POJO、在一个线程中设置一些值并将该 POJO 交给另一个线程根本行不通。接收线程可能没有看到更新,可能会看到部分更新(例如long的低四位已更新但高位未更新)或所有更新。更糟糕的是,这些变化可能会在 100 纳秒后、一秒后看到,或者根本看不到。根本没有办法知道。 
 
各种解决方案
1. 避免 POJO 问题的一种方法是将原始字段(例如int和long字段)声明为volatile并为引用字段使用原子变体。
声明所有字段volatile并使用并发包装类可能会导致一些性能损失。

2. 重用对象的另一种方法是使用ThreadLocal变量,该变量将为每个线程提供不同且时间不变的实例。这意味着可以使用正常的高性能内存语义。此外,由于线程只按顺序执行代码,因此也可以在不相关的方法中重用相同的对象。
不幸的是,获取 ThreadLocal 的内部实例的机制会产生一些开销。还有许多其他与使用代码共享的ThreadLocal变量相关的罪魁祸首:

  • 使用后很难清理。
  • 容易发生内存泄漏。
  • 可能无法扩展。尤其是因为 Java 即将推出的虚拟线程特性促进了创建大量线程。
  • 有效地为线程构成了一个全局变量。

 
3. 线程程上下文可用于保存可重用的对象和资源。这通常意味着线程上下文将以某种方式暴露在 API 中,但结果是它提供了对线程重用对象的快速访问。因为对象可以在线程上下文中直接访问,所以它提供了一种更直接和确定性的释放资源的方式:例如,当线程上下文关闭时。 
最后,可以混合使用ThreadLocal和线程上下文的概念,从而提供无污染的 API,同时提供简化的资源清理,从而避免内存泄漏。
  
4. 另一种方法是使用开源Chronicle Queue,它提供了一种高效、线程安全、无需对象创建的方式来在线程之间交换消息。
 
Chronicle Queue重用对象
简单数据对象:
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 亿条额外消息后,进行了新的转储(但是JVM 没有报告 GC
分配的对象数量(大约 1500 个对象)仅略有增加,这表明每个发送的消息都没有进行对象分配,JVM 没有报告 GC,因此在采样间隔期间没有收集任何对象。
执行期间调用的分析方法显示 Chronicle Queue 正在使用ThreadLocal变量:
它花费大约 7% 的时间在ThreadLocal$ThreadLocalMap.getEntry(ThreadLocal)方法上,但与动态创建对象相比,这非常值得。