设计模式之争:新分配内存还是内存池?(含评测)
在上文中,我们使用C++和Java分别开发了一个队列,可以作为时钟发生器。今天我们将其用作度量工具。
今天的问题是:为每个新消息分配新内存,还是使用内存池?我观察到的网上讨论中,老派C程序员通常避免分配内存,而Java程序员则倾向于分配新内存。本文中我们将详细分析两种做法。
该问题适用于批处理或者软实时应用。对批处理程序来说,程序的吞吐量更加重要,对于软实时程序来说,则存在延迟问题,如果处理某个消息的时间太长,程序会错过一些传入的消息。本文将分别研究这两种情况。事实上也存在第三种情况,网络服务器,同时有延迟和吞吐量的限制,在本文中暂不讨论。
关于实时程序
一些读者可能想知道为什么有人甚至尝试使用Java写实时程序。 每个人都知道Java不是实时平台。 实际上普通Windows或Linux都不是实时操作系统。没有人会用Java编写真正的实时程序(例如自动驾驶仪)。 在本文中,实时程序是指接近实时的程序(即软实时程序):那些允许发生少量事件丢失的程序。距离来说比如网络流量分析器,如果在一百万个数据包中丢失一两百个包,通常不是大问题。 这样的程序几乎可以用任何语言(包括Java)开发,并可以在常规操作系统上运行。 我们将使用这种分析器的极其简模型作为示例程序。
GC的影响
为什么在分配内存和内存池之间进行选择非常重要?对于Java而言 ,最重要的因素是垃圾收集器( GC ),因为它确实可以暂停整个程序的执行(称为“停止世界”)。
最简单的形式的垃圾收集器:
- 当内存分配请求失败时调用,该请求以与分配内存速率成比例的频率发生;
- 运行时间与活动对象的数量成正比。
真正的垃圾收集器采用各种技巧来提高性能,消除长时间停顿并降低对活动对象数量的敏感性:
- 他们根据对象的生存时间将对象划分为两个或更多的空间(“世代”),并假设存在了一段时间的物体可能会存活更久,而新分配的对象则可能很快死亡。从统计上来说,这是正确的,因为
Java
中程序分配了许多临时对象。 - 他们运行垃圾回收器的快速,轻量级版本,该版本适用于新生代,并且在内存耗尽之前很长一段时间内被经常调用。这使得FULL GC的频率降低了很多,但是并不能完全消除FULL GC。
- GC在大部分时间与用户程序并行执行,从而使暂停时间缩短,或者避免暂停。
这些改进通常需要生成代码,例如写屏障,甚至读屏障。这些都降低了执行速度,但在许多情况下,仍可通过减少垃圾收集暂停来证明其合理性。
但是,这些改进不会影响两个基本的GC规则:分配更多内存时,GC调用频率更高;而当存在更多活动对象时,GC运行时间更长。
总是分配新内存和内存池这两种方法对GC的影响并不相同。分配新内存策略通常使活动对象的数量保持较小,但会导致GC被频繁调用。内存池策略减少了内存分配,但所有缓冲区都是活动Java对象,导致GC的调用频率较低,但运行时间更长。
我们在内存池版本中创建许多缓冲区的原因是,我们希望在短时间内同时使用多个缓冲区的。对于分配新内存的方案,这意味着频繁且长期运行的GC。
显然,缓冲区不是程序分配的唯一对象。一些程序保留了许多永久分配的数据结构(映射,缓存,事务日志),相比之下缓冲区反而变得微不足道了。其他一些分配了如此多的临时对象,从而使得分配缓冲区变得微不足道了。本文的例子不适用于这些情况。
其他问题
单独进行内存分配会产生其他成本。通常,获取新对象的地址很快(特别是对于采用线程本地内存池的Java实现)。然而,也将内存清零并调用构造函数等开销。
另一方面,池化也涉及一些开销。必须仔细跟踪每个缓冲区的使用情况,以便一旦缓冲区变空就可以将其返回到空闲池。在多线程情况下,这可能会变得非常棘手。无法跟踪缓冲区可能导致缓冲区泄漏,类似于C程序中的经典内存泄漏。
混合版本
一种常用的方法是保留一定容量的池,并在需求超出此容量时分配缓冲区。如果释放的缓冲区未满,则仅将其返回池中,否则将被丢弃。这种方法在池化和分配新内存之间提供了很好权衡,因此值得测试。
测试
我们将模拟网络分析器,该程序从网络接口捕获数据包,解码协议并收集统计信息。我们使用一个非常简化的模型,该模型包括:
- packet类,由byte buffer及其解析结果组成。我们将使用byte buffer而非array,以便更容易切换到
DirectByteBuffer
。byte buffer将由array支持,这将增加分配对象的数量; - 数据源,它获取缓冲区并填充一些与IP相关的随机信息(地址,端口和协议)。除缓冲区外,data source不分配任何内存。
- 队列,用于存储等待处理的缓冲区。在我们的初始模型中,队列是大小为
INTERNAL_QUEUE_SIZE
的FIFO结构(ArrayDeque
),其唯一的目的是存储一定数量的活动缓冲区。在此阶段也没有分配内存。 - 处理程序,它将解析缓冲区,在进程中分配一些临时对象。某些选定的对象(在我们的模型中将以1/16的频率出现的TCP数据包)及其解析结果将在
STORED_COUNT
大小的结构中存储更长的时间(模仿TCP流重构器的工作)。
我们暂时先研究单线程的情况:处理程序和数据源将在同一线程中运行。稍后我们将考虑多线程情况。
为了减少代码量,我们将以与池化方法相同的方式来实现混合解决方案,唯一的区别是池大小: MIX_POOL_SIZE或POOL_SIZE 。
我们将使用两个数据源:
- 批处理数据源,它负责尽可能快的生成对象。关注最大吞吐量;
- 实时数据源,它启动本地(“源”)线程并建立Native-
Java队列,该线程每隔SOURCE_INTERVAL_NS纳秒向队列写入时钟信号(序列号)。接收到信号后,数据源将生成数据包。源队列总容量将以毫秒为单位定义为MAX_CAPTURING_DELAY_MS 。如果在此时间段内不为队列提供服务,则源数据包将丢失(此丢失将使用序列号检测到)。在这里,我们将关注数据包不会丢失的SOURCE_INTERVAL_NS(两次包间隔时间)的最小值。我们假设MAX_CAPTURING_DELAY_MS为100毫秒。 以每秒一百万个数据包的速率,则意味着消息队列大小为100,000,对于硬件探测来说可能已经太长了,我们的目标是更高的数据速率(也许不是一千万,但可能是五百万每秒)。
我们将对三种场景进行测试:
场景A:接收数据包,解析并且丢弃大部分数据包。几乎没有保存在内存中。
场景B:使用了大量的数据包,但仍然远远少于预先分配的内存;
场景C:几乎所有预分配的内存都将被使用。
这些情况对应于消息处理器的不同反应:
A :负载异常低, 或者,数据速率可能很高,但是大多数数据包被早期过滤掉,并且不会存储在任何地方。
B :负载较为实际;在大多数情况下,这种情况是可以预期的;
C ; 负载异常高;我们不希望这种情况持续很长时间,但必须处理。这种情况是预先分配多缓冲区的原因。
这是我们使用的参数:
Variable | A | B | C |
---|---|---|---|
MAX_CAPTURING_DELAY_MS | 100 | 100 | 100 |
POOL_SIZE | 1,000,000 | 1,000,000 | 1,000,000 |
MIX_POOL_SIZE | 200,000 | 200,000 | 200,000 |
INTERNAL_QUEUE_SIZE | 10,000 | 100,000 | 495,000 |
STORED_COUNT | 10,000 | 100,000 | 495,000 |
源代码在这里(https://github.com/pzemtsov/article-allocate-or-pool)。
批量策略
为了衡量测试框架的成本,我们将引入另一种缓冲区分配策略:Dummy策略,其中我们只有一个数据包,其他地方都使用这一个数据包。
我们将在2.40GHz的双Xeon®CPU E5-2620 v3上运行该程序,使用Linux内核版本为3.17.4和Java 的版本为1.8.142并使用2G堆内存。使用如下JVM参数:
# java -Xloggc:gclog -Xms2g -Xmx2g -server Main X [strategy] batch
测试结果(以纳秒为单位):
策略 | A | B | C |
---|---|---|---|
Dummy | 59 | 57 | 66 |
Allocation | 400 | 685 | 4042 |
Mix | 108 | 315 | 466 |
Pooling | 346 | 470 | 415 |
到目前为止,分配内存策略是最差的(在C场景下很糟糕),这似乎回答了我们的问题,池化模式更加合适,混合策略在A和B情况下是最好的,而在C场景下则稍差一些,这使其成为批处理的理想策略。
测试代码跑得飞快(60 ns),内存分配,清零和垃圾回收拖慢了速度。
导致此测试性能下降的三个可能因素是:频繁内存分配,频繁垃圾回收和高垃圾回收成本。 分配内存策略在C场景下同时受到这三个方面的影响;难怪它的表现如此悲摧。
在A场景中,我们看到了频繁但快速的GC与罕见但缓慢的GC之间的竞争(在第一种选择中增加了分配和清零成本)。罕见但缓慢的GC赢了。
当我们查看垃圾收集统计信息时,总体情况就变得不那么乐观了,池化策略的优势也变得不那么明显了。让我们看一下这些文件。它们都包含大量有关GC暂停的记录,只是其持续时间,频率和类型(不同。以下是这些文件的分析结果:
Case | Strategy | Max GC pause, ms | Avg GC pause, ms | GC count / sec | GC fraction | Object count, mil | GC time / object, ns |
---|---|---|---|---|---|---|---|
A | Allocation | 44 | 9 | 4.5 | 4% | 0.045 | 194 |
Mix | 35 | 6 | 1.9 | 1% | 0.639 | 10 | |
Pooling | 940 | 823 | 0.8 | 67% | 3.039 | 271 | |
B | Allocation | 176 | 66 | 4.5 | 30% | 1.134 | 58 |
Mix | 63 | 40 | 0.8 | 3% | 1.134 | 34 | |
Pooling | 911 | 712 | 0.6 | 40% | 3.534 | 201 | |
C | Allocation | 866 | 365 | 2.3 | 89% | 5.454 | 67 |
Mix | 790 | 488 | 0.6 | 27% | 5.478 | 89 | |
Pooling | 576 | 446 | 0.6 | 29% | 5.508 | 81 |
这里的“ GC计数”是平均每秒GC调用次数,“ GC百分比”是在执行GC花费的时间的百分比。
根据GC的暂停数据,在进行实时操作时,池化策略实际上是最差的一种。它根本行不通,几乎没有任何显示一秒钟的行为以视为实时。实际上我们的策略针对场景C都不工作。
C场景下使用分配内存策略非常糟糕:它花费了89%的时间在GC上。在分配内存和清理内存上花费了很多时间。然而池化模式也可能很糟糕(情况A占67%)。目前尚不清楚为什么A中的GC负载比B和C中的GC负载重得多。
出于好奇,我测量了活动对象数量和每个对象平均GC时间(最后两列)。GC时间与活动对象数量并不完全成正比,但总的来活动对象数据量高则回收速度慢。回收每个对象的时间惊人地长。回收每个对象大约100纳秒,回收100万个对象就消耗100毫秒,而一百万个对象实际上并不多。大多数现实的Java程序更加复杂, 内存中有更多(数亿)的对象。 这些程序在使用CMS垃圾收集器的时候无法实时运行。
实时测试
对于分配内存策略和A场景和源间隔为1000 ns的实时测试,这是参数和结果:
# java -Djava.library.path=. -Xloggc:gclog -Xms2g -Xmx2g \
-server Main A alloc 1000
Test: ALLOC
Input queue size: 100000
Input queue capacity, ms: 99.99999999999999
Internal queue: 1000 = 1 ms
Stored: 1000
6.0; 6.0; lost: 0
7.0; 1.0; lost: 0
8.0; 1.0; lost: 5717
9.0; 1.0; lost: 0
10.1; 1.0; lost: 0
11.0; 1.0; lost: 0
12.0; 1.0; lost: 0
没有任何数据包丢失,这意味着测试程序可以处理负载(我们可以忍受初始性能不足)。
随着传入的数据包速率增加, 结果逐步恶化。在500 ns时,我们在27秒后丢弃了约80K数据包,此后再无丢弃。300 ns的输出如下所示:
5.5; 5.5; lost: 279184
5.8; 0.3; lost: 113569
6.2; 0.3; lost: 111238
6.5; 0.4; lost: 228014
6.9; 0.3; lost: 143214
7.5; 0.6; lost: 296348
8.1; 0.6; lost: 1334374
实验表明,不丢失数据包的最小延迟为400 ns(2.5M数据包/秒),与批处理结果非常匹配。
现在让我们看一下池化策略:
# java -Djava.library.path=. -Xloggc:gclog -Xms2g -Xmx2g \
-server Main A pool 1000
Test: POOL, size = 1000000
Input queue size: 100000
Input queue capacity, ms: 99.99999999999999
Internal queue: 1000 = 1 ms
Stored: 1000
6.0; 6.0; lost: 0
7.0; 1.0; lost: 0
8.0; 1.0; lost: 0
10.3; 2.3; lost: 1250212
11.3; 1.0; lost: 0
12.3; 1.0; lost: 0
13.3; 1.0; lost: 0
15.0; 1.8; lost: 756910
16.0; 1.0; lost: 0
17.0; 1.0; lost: 0
18.0; 1.0; lost: 0
19.8; 1.8; lost: 768783
这是我们从批处理测试结果中得出的预测:因为其GC暂停时间长于输入队列容量,合并数据包处理器将无法处理负载。快速浏览gclog文件会发现暂停与批处理测试中的暂停(大约800毫秒)相同,GC大约每四秒钟运行一次。
无论我们做什么,池化策略都无法处理情况A ,更不用说B或C了 。增加堆大小会降低GC的频率,但不会影响其持续时间。增加 源数据包间隔也无济于事,例如,即使数据包间隔10,000 ns,每40秒也会丢失约80K数据包。将源队列的容量增加到GC暂停(一秒或更长时间)以上的某个值才能缓解,但这显然也是有问题的。
这是所有测试的合并结果。使用以下图例:
- 正常值(例如600)是最小源间隔(以纳秒为单位),在该情况下我们不会丢失数据包;
- 如果并非最小源间隔(某些数据包总是丢失),则该单元格包含“ lost”和两个值:在源间隔为1000 ns的情况下,使用2 Gb和10 Gb堆丢失的数据包百分比。
Strategy | A | B | C |
---|---|---|---|
Allocation | 600 | lost: 0.8% (0.3%) | lost: 75% (20%) |
Mix | 150 | 350 | lost: 9% (0.6%) |
Pooling | lost: 17% (0.5%) | lost: 17% (0.5%) | lost: 9% (0.6%) |
请注意,内存池用于处理C场景。 相同的池,但针对B场景的大小称为“mix”,并且效果很好。这意味着,对于我们可以处理的情况,池化策略仍比分配内存策略更好,而在某些情况下无法处理。
增加堆大小可以将损失减少到几乎可以承受的程度,并“几乎解决”了该问题。如人们所料,它在池化策略的情况下效果更好。然而这种方法看起来很荒谬:谁想使用10 Gb RAM而不是2 Gb只是为了将丢包率从17%减少到0.5%?
G1垃圾收集器
到目前为止,我们一直在使用CMS垃圾收集器。G1(“垃圾优先”)收集器。,在Java 9中成为事实标准,但在Java 8中也可以使用。该垃圾收集器对实时性要求较高的场景更加友好。例如,可以在命令行中指定允许的最大GC暂停时间。因此让我们使用G1重复测试。
这是批处理测试的命令行参数:
java -Xloggc:gclog -Xms2g -Xmx2g -XX:+UseG1GC -XX:MaxGCPauseMillis=80 \ -server Main alloc batch
以下是批处理测试的结果(图例:G1时间/ CMS时间):
Strategy | A | B | C |
---|---|---|---|
Dummy | 78 / 59 | 70 / 57 | 81 / 66 |
Allocation | 424 / 400 | 640 / 685 | 4300 / 4042 |
Mix | 134 / 108 | 364 / 315 | 625 / 466 |
Pooling | 140 / 346 | 355 / 470 | 740 / 415 |
在大多数情况下,执行速度会变慢,在10%到130%之间,但在情况A和B中,池化策略速度更快。
分析垃圾收集器日志。现在更加复杂了,因为G1日志中的每一行并非都表示暂停。有些表示异步操作,实际不会停止程序执行。
Case | Strategy | Max GC pause, ms | Avg GC pause, ms | GC count / sec | GC fraction | Object count, mil | GC time / object, ns |
---|---|---|---|---|---|---|---|
A | Allocation | 56 | 20 | 2.4 | 5% | 0.045 | 444 |
Mix | 43 | 24 | 0.5 | 1% | 0.639 | 38 | |
Pooling | 47 | 21 | 1.3 | 3% | 3.039 | 7 | |
B | Allocation | 85 | 48 | 5.8 | 28% | 1.134 | 42 |
Mix | 81 | 65 | 0.3 | 2% | 1.134 | 57 | |
Pooling | 76 | 62 | 0.6 | 3% | 3.534 | 17 | |
C | Allocation | 732 | 118 | 2.4 | 28% | 5.454 | 21 |
Mix | 172 | 110 | 2.3 | 25% | 5.478 | 20 | |
Pooling | 173 | 117 | 2.0 | 23% | 5.508 | 21 |
结果看起来比CMS更好,并有望为B场景提供可行的解决方案。让我们运行实时测试:
Strategy | A | B | C |
---|---|---|---|
Allocation | 750 | 2000 | lost: 76% (13%) |
Mix | 200 | 600 | lost: 4% (1%) |
Pooling | 200 | 600 | lost: 4.4% (0.8%) |
G1收集器的影响参差不齐,然而与传统CMS相比,这样做的性能要差得多。G1并不是解决所有问题的银弹:对于C场景我们仍然没有解决方案。
池化策略仍然比分配内存策略更好。
ZGC
我们从Java 8直接跳到Java 11 ,它具有一个全新的垃圾收集器ZGC,号称能够处理TB级的堆和亿万个对象。
在撰写本文时,此垃圾收集器仅在Linux上可用,并且仅作为实验性功能。让我们吃个螃蟹。
命令行如下所示:
java -Xloggc:gclog -Xms2g -Xmx2g -XX:+UnlockExperimentalVMOptions -XX:+UseZGC -server Main A alloc batch
以下是批处理测试结果(图例为ZGC时间/ G1时间):
Strategy | A | B | C |
---|---|---|---|
Dummy | 72 / 78 | 66 / 70 | 84 / 81 |
Allocation | 523 / 424 | 800 / 640 | 1880 / 4300 |
Mix | 108 / 134 | 403 / 364 | 436 / 625 |
Pooling | 109 / 140 | 403 / 355 | 453 / 740 |
在某些情况下,性能会有所下降,而在大部分些情况下,性能会有所提高。ZGC确实比以前的GC更好。
我没有找到带有暂停时间的完整ZGC日志转储的JVM命令行参数,因此我暂时跳过这部分。这是ZGC的实时测试结果:
Strategy | A | B | C |
---|---|---|---|
Allocation | 540 | 820 | lost: 44% (1.7%) |
Mix | 120 | 420 | 450 |
Pooling | 130 | 420 | 460 |
所有场景的结果都不错,可以说处理一个数据包需要450 ns太多了(每秒只处理200万个数据包),然而即使如此我们以前也做不到。其他场景的数字也不错。池化策略看起来仍然比分配内存策略好。
使用预先分配本机缓冲区的CMS
尽管ZGC似乎可以解决我们的问题,但我们不想就此罢休。毕竟,它仍然是试验性的。如果我们可以提高传统垃圾收集器的性能呢?ZGC是否可以进一步提高吞吐量?
对于传统的收集器,观察到的GC性能似乎有点低,每个对象的延迟似乎很高。为什么会这样?一个想法是我们的内存分配模式与GC所调整的模式不同。Java程序会随时分配对象,通常它们分配“普通”对象(多个字段的结构),而不是大数组。
我们将这些缓冲区移出堆并使堆变小。我们使用DirectByteBuffer在堆外内存中分配它们。分配DirectByteBuffer的代价也是相当高昂的(除其他事项外,它还会调用System.gc() ),并且释放内存也不简单 。这就是为什么在我们的分配内存版本和池化版本中,我们都将这些缓冲区池化,并且我们将在堆外进行。除此之外,分配内存版本将在每次需要它们时分配数据包对象,而池化版本会将它们保留在集合中。尽管数据包的数量与以前相同,但是对象的总数会减少,因为以前我们有byte buffer和byte array,而现在我们只有byte buffer。
也可以说,“分配内存”策略现在不再是真正的“分配”:我们仍然必须为本机缓冲区实现某种池化方案。但我们仍然会测试其性能。
让我们从CMS GC(批处理测试)开始。这是命令行:
java -Xloggc:gclog -Xms1g -Xmx1g -XX:MaxDirectMemorySize=2g -server \ Main A native-alloc batch
Java堆的大小已减少到1 GB。
这是批处理结果:
Strategy | A | B | C |
---|---|---|---|
Dummy | 50 | 53 | 58 |
Allocation | 89 | 253 | 950 |
Mix | 83 | 221 | 298 |
Pooling | 79 | 213 | 260 |
结果(除分配内存策略在C场景情况下 )看起来非常好,并且所有结果都比我们到目前为止所看到的要好得多。这似乎是批处理的理想选择。
让我们看一下实时结果:
Strategy | A | B | C |
---|---|---|---|
Allocation | 140 | lost: 0.8% | lost: 34% |
Mix | 130 | 250; lost: 0.0025% | lost: 0.7% |
Pooling | 120 | 300; lost: 0.03% | lost: 0.7% |
注意新的符号:“ 250; 丢失:0.0025%”表示,尽管我们仍然丢失数据包,但损耗很小,足以引发最小适用间隔的问题。简而言之,这是一个“几乎可行的”解决方案。
池化策略在C场景的GC日志如下所示:
60.618: [GC (Allocation Failure) 953302K->700246K(1010688K), 0.0720599 secs]
62.457: [GC (Allocation Failure) 973142K->717526K(1010176K), 0.0583657 secs]
62.515: [Full GC (Ergonomics) 717526K->192907K(1010176K), 0.4102448 secs]
64.652: [GC (Allocation Failure) 465803K->220331K(1011712K), 0.0403231 secs]
大约每两秒钟就会有一次短暂的GC运行,收集大约200MB内存,但每次仍会增加20MB的内存使用量。最终会内存不足,每60秒就会有一个400毫秒的GC,将导致大约35万个数据包丢弃。
“ B ”场景甚至更好:FULL GC仅每1100秒出现一次,大约相当于丢弃总数据包的0.03%(一百万个中的300个)。对于混合方案而言更是如此。这样甚至可以在生产环境中使用该解决方案。
本地缓冲区,G1
这是批处理结果:
结果比没有本地缓冲区要好,但比cms批处理结果差。
Strategy | Case A | Case B | Case C |
---|---|---|---|
Dummy | 62 | 63 | 79 |
Allocation | 108 | 239 | 1100 |
Mix | 117 | 246 | 432 |
Pooling | 111 | 249 | 347 |
实时测试的结果:
Strategy | A | B | C |
---|---|---|---|
Allocation | 150 | 350 | lost: 6.5% |
Mix | 150 | 400 | 800; lost: 0.075% |
Pooling | 160 | 500 | 700 |
虽然看起来比a场景下cms结果差一点,但是依然有进步。
本地缓冲区,ZGC
现在让我们在批处理测试中尝试ZGC(将结果与没有本地缓冲区的ZGC结果进行比较):
Strategy | A | B | C |
---|---|---|---|
DUMMY | 63/72 | 76/66 | 102/84 |
Allocation | 127/523 | 290/800 | 533/ 1880 |
Mix | 100/108 | 290/403 | 400/436 |
Pooling | 118/109 | 302/403 | 330/453 |
几乎所有场景都有明显的改进,尤其是在分配内存策略测试中。但是G1,尤其是CMS的结果仍然好得多。
最后,这是实时测试结果:
Strategy | A | B | C |
---|---|---|---|
Allocation | 170 | 380 | 550 |
Mix | 120 | 320 | 440 |
Pooling | 130 | 320 | 460 |
现在我们为所有策略和所有场景提供了一个可行的解决方案。甚至在C场景分配内存策略的情况下都可以使用。
尝试C ++
我们已经看到内存管理确实影响Java程序的性能。我们可以尝试通过使用自己的堆外内存管理器来减少这些开销(我将在以下文章之一中探讨这种技术)。 然而我们也可以尝试用C ++来写。
C ++中不存在垃圾回收问题;我们可以根据需要保留尽可能多的活动对象,不会引起任何暂停。它可能会由于缓存性能差而降低性能,但这是另一回事。
这使得分配内存策略和池化策略之间的选择显而易见:无论分配内存的成本多么小,池化的成本均为零。因此,池化必将获胜。让我们测试一下。
我们的第一个版本将是Java版本的直接翻译,具有相同的设计特性。具体来说,我们将在需要时分配ipheader和ipv4address对象。这使得dummy版本泄漏内存,因为同一个缓冲区对象多次重复使用而不返回池中,并且没有人在过程中删除这些对象。
这是批处理结果:
Strategy | A | B | C |
---|---|---|---|
Dummy | 145 | 164 | 164 |
Allocation | 270 | 560 | 616 |
Mix | 115 | 223 | 307 |
Pooling | 111 | 233 | 274 |
结果看起来不错,但令人惊讶的是,效果并不理想。在使用Java的本地缓冲区+CMS解决方案中,我们已经得到了更好的结果。其他一些组合,Java版的结果也更好。分配内存策略的结果与Java中的大多数结果一样糟糕,而且令人惊讶的是,dummy的结果也很糟糕。这表明内存分配在C ++中非常昂贵,即使没有GC也比Java中昂贵得多。
以下是实时测试的结果:
Strategy | A | B | C |
---|---|---|---|
Allocation | 520 | 950 | 950 |
Mix | 280 | 320 | 550 |
Pooling | 250 | 420 | 480 |
结果看起来不错(至少涵盖了所有情况),但是使用ZGC和本机缓冲区的Java数字起来更好。使用C++的方法必须尽可能减少内存分配。
C ++:无分配
以前的解决方案是以Java方式实现的:在需要时分配一个对象(例如IPv4Address )。在Java中 我们别无选择,但是在C ++中,我们可以在缓冲区内为最常用的对象保留内存。这将导致在分组处理期间将内存分配减少到零。我们将其称为flat C ++版本。
这是批处理结果:
Strategy | A | B | C |
---|---|---|---|
Dummy | 16 | 16 | 16 |
Allocation | 163 | 409 | 480 |
Mix | 35 | 153 | 184 |
Pooling | 34 | 148 | 171 |
所有这些结果都比对应的Java测试要好得多。从绝对意义上讲,mix和池化也非常好。
实时测试结果如下所示:
Strategy | A | B | C |
---|---|---|---|
Allocation | 220 | 650 | 700 |
Mix | 50 | 220 | 240 |
Pooling | 50 | 190 | 230 |
某些Java版本为分配内存策略提供了更好的结果。本机ZGC在C场景下甚至表现更好,这可以归因于C ++内存管理器的缓慢和不可预测的特性。但是,其他版本的性能都很好。池化版本在C场景下每秒可以处理400万个数据包,在B场景下每秒可以处理500万个数据包,可以达到我们的期望值。A场景的处理速度绝对是惊人的(两千万),但是我们必须记住,在这种情况下,我们会丢弃这些数据包。
由于在池化过程中根本不执行任何内存分配,因此场景A , B和C之间的速度差异只能由已用内存的总容量不同来解释–所用内存更多和随机访问模式会降低缓存效率。
汇总
让我们将所有结果汇总在一个表中。我们将忽略dummpy的结果以及使用高得离谱的堆内存大小获得的结果。
让我们首先看一下批处理测试:
Solution | Strategy | Case A | Case B | Case C |
---|---|---|---|---|
CMS | Allocation | 400 | 685 | 4042 |
Mix | 108 | 315 | 466 | |
Pooling | 346 | 470 | 415 | |
G1 | Allocation | 424 | 640 | 4300 |
Mix | 134 | 364 | 625 | |
Pooling | 140 | 355 | 740 | |
ZGC | Allocation | 523 | 800 | 1880 |
Mix | 108 | 403 | 436 | |
Pooling | 109 | 403 | 453 | |
Native CMS | Allocation | 89 | 253 | 950 |
Mix | 83 | 221 | 298 | |
Pooling | 79 | 213 | 260 | |
Native G1 | Allocation | 108 | 239 | 1100 |
Mix | 117 | 246 | 432 | |
Pooling | 111 | 249 | 347 | |
Native ZGC | Allocation | 127 | 290 | 533 |
Mix | 100 | 290 | 400 | |
Pooling | 118 | 302 | 330 | |
C++ | Allocation | 270 | 560 | 616 |
Mix | 115 | 223 | 307 | |
Pooling | 111 | 233 | 274 | |
C++ flat | Allocation | 163 | 409 | 480 |
Mix | 35 | 153 | 184 | |
Pooling | 34 | 148 | 171 |
每列中的绝对最佳结果被标记为绿色,并且所有这三个都恰好来自flat C ++ 。
最佳和次佳Java结果分别标记为黄色和红色。它们来自“ Native CMS”,这表明CMS垃圾收集器距离退役为时尚早。它仍然可以很好地用于批处理程序。
最后,这是实时测试的主要结果:
Strategy | Solution | Case A | Case B | Case C |
---|---|---|---|---|
CMS | Allocation | 600 | lost: 0.8% | lost: 75% |
Mix | 150 | 350 | lost: 9% | |
Pooling | lost: 17% | lost: 17% | lost: 9 | |
G1 | Allocation | 750 | 2000 | lost: 76% |
Mix | 200 | 600 | lost: 4% | |
Pooling | 200 | 600 | lost: 4.4% | |
ZGC | Allocation | 540 | 820 | lost: 44% |
Mix | 120 | 420 | 450 | |
Pooling | 130 | 420 | 460 | |
Native CMS | Allocation | 140 | lost: 0.8% | lost: 34% |
Mix | 130 | lost: 0.0025% | lost: 0.7% | |
Pooling | 120 | lost: 0.03% | lost: 0.7% | |
Native G1 | Allocation | 150 | 350 | lost: 6.5% |
Mix | 150 | 400 | lost: 0.075% | |
Pooling | 160 | 500 | 700 | |
Native ZGC | Allocation | 170 | 380 | 550 |
Mix | 120 | 320 | 440 | |
Pooling | 130 | 320 | 460 | |
C++ | Allocation | 520 | 950 | 950 |
Mix | 280 | 320 | 550 | |
Pooling | 250 | 420 | 480 | |
C++ flat | Allocation | 220 | 650 | 700 |
Mix | 50 | 220 | 240 | |
Pooling | 50 | 190 | 230 |
深灰色块表示缺少解决方案(数据包始终丢失)。否则,配色方案相同。flat C ++版本依然是最好的,而最好的和次之的Java版本则来自多个解决方案,最好的是Native ZGC。
结论
如果要编写真正的实时系统,请使用C或C ++编写,并避免分配内存。也可以在Java中实现一些相当不错的实时近似。在这种情况下,它也有助于减少内存分配。
这回答了我们最初的问题(分配内存或池化):池化。 在我们运行的每个测试中,池化的性能要好于分配内存。此外,在大多数Java测试中,分配内存策略在批处理模式下执行得很糟糕,而在实时模式下根本无法执行。
当数据包利用率低时,混合方法非常好。但是,如果利用率增长,则池化变得更好。
垃圾收集器确实是最大的影响因素。池化会引入很多活动对象,这些活动对象会导致偶发但很长的GC延迟。然而分配内存策略会使GC完全过载。此外,在高负载时(我们的C场景 ),无论如何可能存在许多活动对象,并且分配内存策略表现很惨。因此池化仍然是更好的策略。
G1和ZGC收集器尽管经常在批处理模式下表现较差,但它们确实在实时模式下有所改善。ZGC表现特别出色;它甚至可以以合理的性能(每秒200万个数据包)处理C场景。
如果我们分配一千万个缓冲区而不是一百万个缓冲区,或者如果程序使用其他大数据结构,一切都会变得更糟。一种可能的解决方案是将这些结构移到堆外。
在不需要立即响应传入消息的情况下,增加输入队列大小可能会有所帮助。我们可以考虑在C场景下引入另一层以及更高容量的中间队列。如果我们的源队列中可以存储一秒钟的数据包,则即使使用CMS,池化版本也可以正常工作。
原文地址:
https://pzemtsov.github.io/2019/01/17/allocate-or-pool.html
参考阅读:
本文作者pzemtsov ,由方圆翻译,转载请注明出处,技术原创及架构实践文章,欢迎通过公众号菜单「联系我们」进行投稿。
高可用架构
改变互联网的构建方式
长按二维码 关注「高可用架构」公众号