在上文中,我们使用C++和Java分别开发了一个队列,可以作为时钟发生器。今天我们将其用作度量工具。
今天的问题是:为每个新消息分配新内存,还是使用内存池?我观察到的网上讨论中,老派C程序员通常避免分配内存,而Java程序员则倾向于分配新内存。本文中我们将详细分析两种做法。
该问题适用于批处理或者软实时应用。对批处理程序来说,程序的吞吐量更加重要,对于软实时程序来说,则存在延迟问题,如果处理某个消息的时间太长,程序会错过一些传入的消息。本文将分别研究这两种情况。事实上也存在第三种情况,网络服务器,同时有延迟和吞吐量的限制,在本文中暂不讨论。
关于实时程序
一些读者可能想知道为什么有人甚至尝试使用Java写实时程序。 每个人都知道Java不是实时平台。 实际上普通Windows或Linux都不是实时操作系统。没有人会用Java编写真正的实时程序(例如自动驾驶仪)。 在本文中,实时程序是指接近实时的程序(即软实时程序):那些允许发生少量事件丢失的程序。距离来说比如网络流量分析器,如果在一百万个数据包中丢失一两百个包,通常不是大问题。 这样的程序几乎可以用任何语言(包括Java)开发,并可以在常规操作系统上运行。 我们将使用这种分析器的极其简模型作为示例程序。
GC的影响
为什么在分配内存和内存池之间进行选择非常重要?对于Java而言 ,最重要的因素是垃圾收集器( GC ),因为它确实可以暂停整个程序的执行(称为“停止世界”)。
最简单的形式的垃圾收集器:
真正的垃圾收集器采用各种技巧来提高性能,消除长时间停顿并降低对活动对象数量的敏感性:
Java
这些改进通常需要生成代码,例如写屏障,甚至读屏障。这些都降低了执行速度,但在许多情况下,仍可通过减少垃圾收集暂停来证明其合理性。
但是,这些改进不会影响两个基本的GC规则:分配更多内存时,GC调用频率更高;而当存在更多活动对象时,GC运行时间更长。
总是分配新内存和内存池这两种方法对GC的影响并不相同。分配新内存策略通常使活动对象的数量保持较小,但会导致GC被频繁调用。内存池策略减少了内存分配,但所有缓冲区都是活动Java对象,导致GC的调用频率较低,但运行时间更长。
我们在内存池版本中创建许多缓冲区的原因是,我们希望在短时间内同时使用多个缓冲区的。对于分配新内存的方案,这意味着频繁且长期运行的GC。
显然,缓冲区不是程序分配的唯一对象。一些程序保留了许多永久分配的数据结构(映射,缓存,事务日志),相比之下缓冲区反而变得微不足道了。其他一些分配了如此多的临时对象,从而使得分配缓冲区变得微不足道了。本文的例子不适用于这些情况。
其他问题
单独进行内存分配会产生其他成本。通常,获取新对象的地址很快(特别是对于采用线程本地内存池的Java实现)。然而,也将内存清零并调用构造函数等开销。
另一方面,池化也涉及一些开销。必须仔细跟踪每个缓冲区的使用情况,以便一旦缓冲区变空就可以将其返回到空闲池。在多线程情况下,这可能会变得非常棘手。无法跟踪缓冲区可能导致缓冲区泄漏,类似于C程序中的经典内存泄漏。
混合版本
一种常用的方法是保留一定容量的池,并在需求超出此容量时分配缓冲区。如果释放的缓冲区未满,则仅将其返回池中,否则将被丢弃。这种方法在池化和分配新内存之间提供了很好权衡,因此值得测试。
测试
我们将模拟网络分析器,该程序从网络接口捕获数据包,解码协议并收集统计信息。我们使用一个非常简化的模型,该模型包括:
DirectByteBuffer
INTERNAL_QUEUE_SIZE
ArrayDeque
STORED_COUNT
我们暂时先研究单线程的情况:处理程序和数据源将在同一线程中运行。稍后我们将考虑多线程情况。
为了减少代码量,我们将以与池化方法相同的方式来实现混合解决方案,唯一的区别是池大小: MIX_POOL_SIZE或POOL_SIZE 。
我们将使用两个数据源:
Java队列,该线程每隔SOURCE_INTERVAL_NS纳秒向队列写入时钟信号(序列号)。接收到信号后,数据源将生成数据包。源队列总容量将以毫秒为单位定义为MAX_CAPTURING_DELAY_MS 。如果在此时间段内不为队列提供服务,则源数据包将丢失(此丢失将使用序列号检测到)。在这里,我们将关注数据包不会丢失的SOURCE_INTERVAL_NS(两次包间隔时间)的最小值。我们假设MAX_CAPTURING_DELAY_MS
我们将对三种场景进行测试:
场景A:接收数据包,解析并且丢弃大部分数据包。几乎没有保存在内存中。
场景B:使用了大量的数据包,但仍然远远少于预先分配的内存;
场景C:几乎所有预分配的内存都将被使用。
这些情况对应于消息处理器的不同反应:
A :负载异常低, 或者,数据速率可能很高,但是大多数数据包被早期过滤掉,并且不会存储在任何地方。
B :负载较为实际;在大多数情况下,这种情况是可以预期的;
C ; 负载异常高;我们不希望这种情况持续很长时间,但必须处理。这种情况是预先分配多缓冲区的原因。
这是我们使用的参数:
源代码在这里(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 -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暂停(一秒或更长时间)以上的某个值才能缓解,但这显然也是有问题的。
这是所有测试的合并结果。使用以下图例:
请注意,内存池用于处理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时间):
在大多数情况下,执行速度会变慢,在10%到130%之间,但在情况A和B中,池化策略速度更快。
分析垃圾收集器日志。现在更加复杂了,因为G1日志中的每一行并非都表示暂停。有些表示异步操作,实际不会停止程序执行。
结果看起来比CMS更好,并有望为B场景提供可行的解决方案。让我们运行实时测试:
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时间):
在某些情况下,性能会有所下降,而在大部分些情况下,性能会有所提高。ZGC确实比以前的GC更好。
我没有找到带有暂停时间的完整ZGC日志转储的JVM命令行参数,因此我暂时跳过这部分。这是ZGC的实时测试结果:
所有场景的结果都不错,可以说处理一个数据包需要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。
这是批处理结果:
结果(除分配内存策略在C场景情况下 )看起来非常好,并且所有结果都比我们到目前为止所看到的要好得多。这似乎是批处理的理想选择。
让我们看一下实时结果:
注意新的符号:“ 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批处理结果差。
实时测试的结果:
虽然看起来比a场景下cms结果差一点,但是依然有进步。
本地缓冲区,ZGC
现在让我们在批处理测试中尝试ZGC(将结果与没有本地缓冲区的ZGC结果进行比较):
几乎所有场景都有明显的改进,尤其是在分配内存策略测试中。但是G1,尤其是CMS的结果仍然好得多。
最后,这是实时测试结果:
现在我们为所有策略和所有场景提供了一个可行的解决方案。甚至在C场景分配内存策略的情况下都可以使用。
尝试C ++
我们已经看到内存管理确实影响Java程序的性能。我们可以尝试通过使用自己的堆外内存管理器来减少这些开销(我将在以下文章之一中探讨这种技术)。 然而我们也可以尝试用C ++来写。
C ++中不存在垃圾回收问题;我们可以根据需要保留尽可能多的活动对象,不会引起任何暂停。它可能会由于缓存性能差而降低性能,但这是另一回事。
这使得分配内存策略和池化策略之间的选择显而易见:无论分配内存的成本多么小,池化的成本均为零。因此,池化必将获胜。让我们测试一下。
我们的第一个版本将是Java版本的直接翻译,具有相同的设计特性。具体来说,我们将在需要时分配ipheader和ipv4address对象。这使得dummy版本泄漏内存,因为同一个缓冲区对象多次重复使用而不返回池中,并且没有人在过程中删除这些对象。
这是批处理结果:
115223307Pooling111233274
结果看起来不错,但令人惊讶的是,效果并不理想。在使用Java的本地缓冲区+CMS解决方案中,我们已经得到了更好的结果。其他一些组合,Java版的结果也更好。分配内存策略的结果与Java中的大多数结果一样糟糕,而且令人惊讶的是,dummy的结果也很糟糕。这表明内存分配在C ++中非常昂贵,即使没有GC也比Java中昂贵得多。
以下是实时测试的结果:
结果看起来不错(至少涵盖了所有情况),但是使用ZGC和本机缓冲区的Java数字起来更好。使用C++的方法必须尽可能减少内存分配。
C ++:无分配
以前的解决方案是以Java方式实现的:在需要时分配一个对象(例如IPv4Address )。在Java中 我们别无选择,但是在C ++中,我们可以在缓冲区内为最常用的对象保留内存。这将导致在分组处理期间将内存分配减少到零。我们将其称为flat C ++版本。
这是批处理结果:
所有这些结果都比对应的Java测试要好得多。从绝对意义上讲,mix和池化也非常好。
实时测试结果如下所示:
某些Java版本为分配内存策略提供了更好的结果。本机ZGC在C场景下甚至表现更好,这可以归因于C ++内存管理器的缓慢和不可预测的特性。但是,其他版本的性能都很好。池化版本在C场景下每秒可以处理400万个数据包,在B场景下每秒可以处理500万个数据包,可以达到我们的期望值。A场景的处理速度绝对是惊人的(两千万),但是我们必须记住,在这种情况下,我们会丢弃这些数据包。
由于在池化过程中根本不执行任何内存分配,因此场景A , B和C之间的速度差异只能由已用内存的总容量不同来解释–所用内存更多和随机访问模式会降低缓存效率。
汇总
让我们将所有结果汇总在一个表中。我们将忽略dummpy的结果以及使用高得离谱的堆内存大小获得的结果。
让我们首先看一下批处理测试:
每列中的绝对最佳结果被标记为绿色,并且所有这三个都恰好来自flat C ++ 。
最佳和次佳Java结果分别标记为黄色和红色。它们来自“ Native CMS”,这表明CMS垃圾收集器距离退役为时尚早。它仍然可以很好地用于批处理程序。
最后,这是实时测试的主要结果:
深灰色块表示缺少解决方案(数据包始终丢失)。否则,配色方案相同。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
参考阅读:
一种灵活的API设计模式:在Spring Boot中支持GraphQL
支付核心系统设计:Airbnb的分布式事务方案简介
算力提升117%,资源使用下降50%,打开集群优化正确姿势
Golang实现单机百万长连接服务 - 美图的三年优化经验
几款流行监控系统简介
本文作者pzemtsov ,由方圆翻译,转载请注明出处,技术原创及架构实践文章,欢迎通过公众号菜单「联系我们」进行投稿。
高可用架构
改变互联网的构建方式