当前位置:Java -> Java集合开销

Java集合开销

Java虚拟机在优化性能的同时实现了Java应用的跨平台特性。在考虑性能,尤其是内存利用方面,理解Java集合框架特别是ArrayList处理大小和容量的方式至关重要。

本文将着重探讨包含两到三个元素的列表所带来的额外开销。这是因为这种情况更为常见,很容易被忽视。

大小和容量的区别

列表的大小指的是当前存储在其中的元素数量。 随着添加或删除元素,它可能会发生变化。方法List.size()提供了这个数量。如果我们有一个包含十个项目的列表,它的大小就是十。

列表的容量是指为存储元素而分配的内存量,不管这些内存位置当前是否在使用。容量主要是与由数组支持的列表有关,比如ArrayList容量表示列表在调整其内部存储数组大小之前可以容纳的最大元素数量。 容量始终大于或等于列表的大小。

如果我们初始化一个ArrayList并向其添加十个项目,则其大小是十。但是,底层数组可能有容量可以容纳十五个项目。 这就意味着向列表添加五个以上的项目不会触发底层数组的扩展。

理解大小和容量之间的区别至关重要。 尺寸确定了实际数据计数,容量影响了内存利用,并可能影响性能,因为可能需要数组调整大小和数据复制。

列表的初始容量

ArrayList类具有默认的初始容量。截至Java 17,这个容量是十。如果我们知道将有更多或更少的元素,通常最好设置初始容量以减少调整大小的次数。

例如,LinkedList没有容量的概念。它是一个双向链表,意味着每个元素都指向其前驱和后继。这里没有需要调整大小的底层数组。

在考虑JVM性能时,了解列表的初始容量以及它们如何增长至关重要。设置适当的初始容量可以减少列表调整大小的需求,减少内存使用并提高性能。

代码的影响

让我们进行两个测试来比较代码的性能:

@Benchmark 
@BenchmarkMode(Mode.AverageTime)
@Fork(value = 1, jvmArgs = {"-Xlog:gc:file=list-creation-%t.txt,filecount=10,filesize=40gb -Xmx6gb 
-Xms6gb"}) 
public void listCreationBenchmark(HeapDumperState heapDumperState, Blackhole blackhole) {    
   final List<Integer> result = new ArrayList<>();    
   result.add(1);    
   result.add(2);    
   result.add(3);    
   blackhole.consume(result); 
}
@Benchmark 
@BenchmarkMode(Mode.AverageTime) 
@Fork(value = 2, jvmArgs = {"-Xlog:gc:file=limited-capacity-list-creation-
%t.txt,filecount=10,filesize=40gb -Xmx6gb -Xms6gb"}) 
public void limitedCapacityListCreationBenchmark(HeapDumperState heapDumperState, Blackhole blackhole) {    
final List<Integer> result = new ArrayList<>(3);    
result.add(1);    
result.add(2);    
result.add(3);    
blackhole.consume(result); 
}


请注意,HeapDumperState是一个状态,每次迭代后都会触发对堆的转储,以便获取有关创建对象的信息。所有测试在两个独立的分支中运行了十个十分钟迭代。每个测试的总持续时间约为一个小时四十分钟。

总的来说,在这些测试中并没有显示出显著的差异,并且据偶然情况来看,所有运行结果实际上显示默认容量的第一个选项可能更快:

基准 模式 Cnt 得分 误差 单位
OverheadBenchmark.limitedCapacityListCreationBenchmark thrpt 20 116365294.187 ± 4748264.227 ops/s
OverheadBenchmark.listCreationBenchmark thrpt 20 121014905.085 ± 188451.671 ops/s


单个操作的平均时间如此微小,以至于在这些测试中很难看出差异。请记住,我们在测量创建一个具有三个元素的ArrayList与十个元素的列表。

内存印记

在优化性能时,了解内存分配决策对JVM的影响非常重要。我们可能会认为ArrayList的默认容量是无害的。然而,在某些情况下,这不仅对大型列表或高频列表操作有影响,对于元素数量较少的列表也有影响。

让我们深入研究这些测试产生的垃圾收集日志。我们将使用HeapHero进行分析。最初的猜测可能是,具有默认容量的ArrayList测试会占用更多的堆空间,有更多的垃圾收集周期,并降低吞吐量。

内存使用的增加

当使用默认构造函数初始化ArrayList(即,未指定容量时),列表将为十个元素分配内存。如果我们仅添加三个元素,则使用了所分配内存的30%,留下了70%未使用。

如果我们使用容量为三的ArrayList进行初始化(new ArrayList<>(3)),它将只为这三个元素分配内存。因此,浪费较少。

这一点在平均堆大小的差异中清晰地表现出来。具有显式声明容量的ArrayList提供了以下结果:

图1:具有显式容量的ArraysList的内存分配 图1:具有显式容量的ArraysList的内存分配

同时,正如我们预期的那样,具有默认容量的ArrayList结果消耗的内存更多:

具有默认容量的内存分配图2: 具有默认容量的ArraysList的内存分配 图2:具有默认容量的ArraysList的内存分配
本质上,对于存储少于其容量的元素使用默认容量会导致不必要的内存分配。在我们的测试案例中,这种差异可能看似微不足道,但想象一下在具有更长寿命的应用程序中创建数千或数百万这样的列表,内存的浪费会累积起来。

对吞吐量的影响

由于内存占用,JVM必须更积极地管理内存,进行更频繁的垃圾回收周期。让我们比较一下这两种情况的关键绩效指标:

显示具有显式容量的ArraysList的内存分配 图3:显示具有显式容量的ArraysList的内存分配
显示具有默认容量的ArraysList的吞吐量 图4:显示具有默认容量的ArraysList的吞吐量
之前提到的两个问题都导致了吞吐量降低,因为JVM需要花费更多时间进行内存管理。再次强调,吞吐量并没有戏剧性的不同,但即便在这个简单的测试中,我们也能看到差异。其中一个主要原因是对象的创建速率在使用默认容量的ArrayLists测试中明显更高。

请考虑,对象几乎瞬间就会变得不可达。然而,在繁忙的网络服务器上,这可能会引发更多问题。

yCrash 分析

尽管之前的指标确定了问题,但它们没有确定问题的来源。为了找出问题的根源,我们可以使用堆转储分析来了解堆的状态。特别是,我们将集中在集合效率低下部分。

该部分提供了对由于过大集合而造成的内存浪费的概要。这个问题的主要原因是集合的容量和大小之间的差异。

堆转储是在进行垃圾回收周期之前捕获的。这样,我们可以更好地看到我们堆中的集合对象:

Inefficient collection information 图 5: yCrash中的低效集合信息

从这里我们可以看出,我们浪费了内存,并且我们的大多数集合(几乎所有)占用的空间比它们实际所需的多。

结论

对应用进行持续监视和分析对其健康和高性能运行至关重要。有时,偶尔的堆转储和垃圾回收日志难以发现问题。这就是为什么拥有一个能够持续分析应用程序的系统至关重要。yCrash应用程序可以帮助监控,并不仅可以提供更好的用户体验,还可以使服务在市场上获得竞争优势。

推荐阅读: 银行等金融行业春招信息汇总(更新)

本文链接: Java集合开销