当前位置:首页 > 快讯更新 > 正文

设计模式之争:新分配内存还是内存池?(含评测)

在上文中,我们使用C++和Java分别开发了一个队列,可以作为时钟发生器。今天我们将其用作度量工具。今天的问题是:为每个新消息分配新内存,还是使用内存池?我观察到的网上讨论中,老派C程序员通常避免分配内存,而Java程序员则倾向于分配新内存。本文中我们将详细分析两种做法。该问题适用于批处理或者软实时...

在上文中,我们使用C++和Java分别开发了一个队列,可以作为时钟发生器。今天我们将其用作度量工具。今天的问题是:为每个新消息分配新内存,还是使用内存池?我观察到的网上讨论中,老派C程序员通常避免分配......

在上文中,我们使用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。

我们将使用两个数据源:

我们将对三种场景进行测试:

场景A:接收数据包,解析并且丢弃大部分数据包。几乎没有保存在内存中。

场景B:使用了大量的数据包,但仍然远远少于预先分配的内存;

场景C:几乎所有预分配的内存都将被使用。

这些情况对应于消息处理器的不同反应:

A:负载异常低,或者,数据速率可能很高,但是大多数数据包被早期过滤掉,并且不会存储在任何地方。

B:负载较为实际;在大多数情况下,这种情况是可以预期的;

C;负载异常高;我们不希望这种情况持续很长时间,但必须处理。这种情况是预先分配多缓冲区的原因。

这是我们使用的参数:

VariableABCMAX_CAPTURING_DELAY_MS100100100POOL_SIZE1,000,0001,000,0001,000,000MIX_POOL_SIZE200,000200,000200,000INTERNAL_QUEUE_SIZE10,000100,000495,000STORED_COUNT10,000100,000495,000

源代码在这里()。

批量策略

为了衡量测试框架的成本,我们将引入另一种缓冲区分配策略:Dummy策略,其中我们只有一个数据包,其他地方都使用这一个数据包。

我们将在2.40GHz的双Xeon®CPUE5-2620v3上运行该程序,使用Linux内核版本为3.17.4和Java的版本为1.8.142并使用2G堆内存。使用如下JVM参数:

=.-Xloggc:gclog-Xms2g-Xmx2g\-serverMainAalloc1000Test:ALLOCInputqueuesize:100000Inputqueuecapacity,ms:99.99999999999999Internalqueue:1000=1msStored:10006.0;6.0;lost:07.0;1.0;lost:08.0;1.0;lost:57179.0;1.0;lost:010.1;1.0;lost:011.0;1.0;lost:012.0;1.0;lost:0

没有任何数据包丢失,这意味着测试程序可以处理负载(我们可以忍受初始性能不足)。

随着传入的数据包速率增加,结果逐步恶化。在500ns时,我们在27秒后丢弃了约80K数据包,此后再无丢弃。300ns的输出如下所示:

5.5;5.5;lost:2791845.8;0.3;lost:1135696.2;0.3;lost:1112386.5;0.4;lost:2280146.9;0.3;lost:1432147.5;0.6;lost:2963488.1;0.6;lost:1334374

实验表明,不丢失数据包的最小延迟为400ns(2.5M数据包/秒),与批处理结果非常匹配。

现在让我们看一下池化策略:

#=.-Xloggc:gclog-Xms2g-Xmx2g\-serverMainApool1000Test:POOL,size=1000000Inputqueuesize:100000Inputqueuecapacity,ms:99.99999999999999Internalqueue:1000=1msStored:10006.0;6.0;lost:07.0;1.0;lost:08.0;1.0;lost:010.3;2.3;lost:125021211.3;1.0;lost:012.3;1.0;lost:013.3;1.0;lost:015.0;1.8;lost:75691016.0;1.0;lost:017.0;1.0;lost:018.0;1.0;lost:019.8;1.8;lost:768783

这是我们从批处理测试结果中得出的预测:因为其GC暂停时间长于输入队列容量,合并数据包处理器将无法处理负载。快速浏览gclog文件会发现暂停与批处理测试中的暂停(大约800毫秒)相同,GC大约每四秒钟运行一次。

无论我们做什么,池化策略都无法处理情况A,更不用说B或C了。增加堆大小会降低GC的频率,但不会影响其持续时间。增加源数据包间隔也无济于事,例如,即使数据包间隔10,000ns,每40秒也会丢失约80K数据包。将源队列的容量增加到GC暂停(一秒或更长时间)以上的某个值才能缓解,但这显然也是有问题的。

这是所有测试的合并结果。使用以下图例:

StrategyABCAllocation600lost:0.8%(0.3%)lost:75%(20%)Mix150350lost:9%(0.6%)Poolinglost:17%(0.5%)lost:17%(0.5%)lost:9%(0.6%)

请注意,内存池用于处理C场景。相同的池,但针对B场景的大小称为“mix”,并且效果很好。这意味着,对于我们可以处理的情况,池化策略仍比分配内存策略更好,而在某些情况下无法处理。

增加堆大小可以将损失减少到几乎可以承受的程度,并“几乎解决”了该问题。如人们所料,它在池化策略的情况下效果更好。然而这种方法看起来很荒谬:谁想使用10GbRAM而不是2Gb只是为了将丢包率从17%减少到0.5%?

G1垃圾收集器

到目前为止,我们一直在使用CMS垃圾收集器。G1(“垃圾优先”)收集器。,在Java9中成为事实标准,但在Java8中也可以使用。该垃圾收集器对实时性要求较高的场景更加友好。例如,可以在命令行中指定允许的最大GC暂停时间。因此让我们使用G1重复测试。

这是批处理测试的命令行参数:

java-Xloggc:gclog-Xms2g-Xmx2g-XX:+UseG1GC-XX:MaxGCPauseMillis=80\-serverMainallocbatch

以下是批处理测试的结果(图例:G1时间/CMS时间):

StrategyABCDummy78/5970/5781/66Allocation424/400640/6854300/4042Mix134/108364/315625/466Pooling140/346355/470740/415

在大多数情况下,执行速度会变慢,在10%到130%之间,但在情况A和B中,池化策略速度更快。

分析垃圾收集器日志。现在更加复杂了,因为G1日志中的每一行并非都表示暂停。有些表示异步操作,实际不会停止程序执行。

CaseStrategyMaxGCpause,msAvgGCpause,msGCcount/secGCfractionObjectcount,milGCtime/object,nsAAllocation56202.45%0.045444Mix43240.51%0.63938Pooling47211.33%3.0397BAllocation85485.828%1.13442Mix81650.32%1.13457Pooling76620.63%3.53417CAllocation7321182.428%5.45421Mix1721102.325%5.47820Pooling1731172.023%5.50821

结果看起来比CMS更好,并有望为B场景提供可行的解决方案。让我们运行实时测试:

StrategyABCAllocation7502000lost:76%(13%)Mix200600lost:4%(1%)Pooling200600lost:4.4%(0.8%)

G1收集器的影响参差不齐,然而与传统CMS相比,这样做的性能要差得多。G1并不是解决所有问题的银弹:对于C场景我们仍然没有解决方案。

池化策略仍然比分配内存策略更好。

ZGC

我们从Java8直接跳到Java11,它具有一个全新的垃圾收集器ZGC,号称能够处理TB级的堆和亿万个对象。

在撰写本文时,此垃圾收集器仅在Linux上可用,并且仅作为实验性功能。让我们吃个螃蟹。

命令行如下所示:

java-Xloggc:gclog-Xms2g-Xmx2g-XX:+UnlockExperimentalVMOptions-XX:+UseZGC-serverMainAallocbatch

以下是批处理测试结果(图例为ZGC时间/G1时间):

StrategyABCDummy72/7866/7084/81Allocation523/424800/6401880/4300Mix108/134403/364436/625Pooling109/140403/355453/740

在某些情况下,性能会有所下降,而在大部分些情况下,性能会有所提高。ZGC确实比以前的GC更好。

我没有找到带有暂停时间的完整ZGC日志转储的JVM命令行参数,因此我暂时跳过这部分。这是ZGC的实时测试结果:

StrategyABCAllocation540820lost:44%(1.7%)Mix120420450Pooling130420460

所有场景的结果都不错,可以说处理一个数据包需要450ns太多了(每秒只处理200万个数据包),然而即使如此我们以前也做不到。其他场景的数字也不错。池化策略看起来仍然比分配内存策略好。

使用预先分配本机缓冲区的CMS

尽管ZGC似乎可以解决我们的问题,但我们不想就此罢休。毕竟,它仍然是试验性的。如果我们可以提高传统垃圾收集器的性能呢?ZGC是否可以进一步提高吞吐量?

对于传统的收集器,观察到的GC性能似乎有点低,每个对象的延迟似乎很高。为什么会这样?一个想法是我们的内存分配模式与GC所调整的模式不同。Java程序会随时分配对象,通常它们分配“普通”对象(多个字段的结构),而不是大数组。

我们将这些缓冲区移出堆并使堆变小。我们使用DirectByteBuffer在堆外内存中分配它们。分配DirectByteBuffer的代价也是相当高昂的(除其他事项外,它还会调用()),并且释放内存也不简单。这就是为什么在我们的分配内存版本和池化版本中,我们都将这些缓冲区池化,并且我们将在堆外进行。除此之外,分配内存版本将在每次需要它们时分配数据包对象,而池化版本会将它们保留在集合中。尽管数据包的数量与以前相同,但是对象的总数会减少,因为以前我们有bytebuffer和bytearray,而现在我们只有bytebuffer。

也可以说,“分配内存”策略现在不再是真正的“分配”:我们仍然必须为本机缓冲区实现某种池化方案。但我们仍然会测试其性能。

让我们从CMSGC(批处理测试)开始。这是命令行:

java-Xloggc:gclog-Xms1g-Xmx1g-XX:MaxDirectMemorySize=2g-server\MainAnative-allocbatch

Java堆的大小已减少到1GB。

这是批处理结果:

StrategyABCDummy505358Allocation89253950Mix83221298Pooling79213260

结果(除分配内存策略在C场景情况下)看起来非常好,并且所有结果都比我们到目前为止所看到的要好得多。这似乎是批处理的理想选择。

让我们看一下实时结果:

StrategyABCAllocation140lost:0.8%lost:34%Mix130250;lost:0.0025%lost:0.7%Pooling120300;lost:0.03%lost:0.7%

注意新的符号:“250;丢失:0.0025%”表示,尽管我们仍然丢失数据包,但损耗很小,足以引发最小适用间隔的问题。简而言之,这是一个“几乎可行的”解决方案。

池化策略在C场景的GC日志如下所示:

60.618:[GC(AllocationFailure)953302K-700246K(1010688K),0.0720599secs]62.457:[GC(AllocationFailure)973142K-717526K(1010176K),0.0583657secs]62.515:[FullGC(Ergonomics)717526K-192907K(1010176K),0.4102448secs]64.652:[GC(AllocationFailure)465803K-220331K(1011712K),0.0403231secs]

大约每两秒钟就会有一次短暂的GC运行,收集大约200MB内存,但每次仍会增加20MB的内存使用量。最终会内存不足,每60秒就会有一个400毫秒的GC,将导致大约35万个数据包丢弃。

“B”场景甚至更好:FULLGC仅每1100秒出现一次,大约相当于丢弃总数据包的0.03%(一百万个中的300个)。对于混合方案而言更是如此。这样甚至可以在生产环境中使用该解决方案。

本地缓冲区,G1

这是批处理结果:

结果比没有本地缓冲区要好,但比cms批处理结果差。

StrategyCaseACaseBCaseCDummy626379Allocation1082391100Mix117246432Pooling111249347

实时测试的结果:

StrategyABCAllocation150350lost:6.5%Mix150400800;lost:0.075%Pooling160500700

虽然看起来比a场景下cms结果差一点,但是依然有进步。

本地缓冲区,ZGC

现在让我们在批处理测试中尝试ZGC(将结果与没有本地缓冲区的ZGC结果进行比较):

StrategyABCDUMMY63/7276/66102/84127/523290/800533/1880Mix100/108290/403400/436118/109302/403330/453

几乎所有场景都有明显的改进,尤其是在分配内存策略测试中。但是G1,尤其是CMS的结果仍然好得多。

最后,这是实时测试结果:

StrategyABC170380550Mix120320440130320460

现在我们为所有策略和所有场景提供了一个可行的解决方案。甚至在C场景分配内存策略的情况下都可以使用。

尝试C++

我们已经看到内存管理确实影响Java程序的性能。我们可以尝试通过使用自己的堆外内存管理器来减少这些开销(我将在以下文章之一中探讨这种技术)。然而我们也可以尝试用C++来写。

C++中不存在垃圾回收问题;我们可以根据需要保留尽可能多的活动对象,不会引起任何暂停。它可能会由于缓存性能差而降低性能,但这是另一回事。

这使得分配内存策略和池化策略之间的选择显而易见:无论分配内存的成本多么小,池化的成本均为零。因此,池化必将获胜。让我们测试一下。

我们的第一个版本将是Java版本的直接翻译,具有相同的设计特性。具体来说,我们将在需要时分配ipheader和ipv4address对象。这使得dummy版本泄漏内存,因为同一个缓冲区对象多次重复使用而不返回池中,并且没有人在过程中删除这些对象。

这是批处理结果:

StrategyA
BCDummy145164164270560616Mix
115223307Pooling111233274

结果看起来不错,但令人惊讶的是,效果并不理想。在使用Java的本地缓冲区+CMS解决方案中,我们已经得到了更好的结果。其他一些组合,Java版的结果也更好。分配内存策略的结果与Java中的大多数结果一样糟糕,而且令人惊讶的是,dummy的结果也很糟糕。这表明内存分配在C++中非常昂贵,即使没有GC也比Java中昂贵得多。

以下是实时测试的结果:

StrategyABCAllocation520950950Mix280320550Pooling250420480

结果看起来不错(至少涵盖了所有情况),但是使用ZGC和本机缓冲区的Java数字起来更好。使用C++的方法必须尽可能减少内存分配。

C++:无分配

以前的解决方案是以Java方式实现的:在需要时分配一个对象(例如IPv4Address)。在Java中我们别无选择,但是在C++中,我们可以在缓冲区内为最常用的对象保留内存。这将导致在分组处理期间将内存分配减少到零。我们将其称为flatC++版本。

这是批处理结果:

StrategyABCDummy161616163409480Mix35153184Pooling34148171

所有这些结果都比对应的Java测试要好得多。从绝对意义上讲,mix和池化也非常好。

实时测试结果如下所示:

StrategyABCAllocation220650700Mix50220240Pooling50190230

某些Java版本为分配内存策略提供了更好的结果。本机ZGC在C场景下甚至表现更好,这可以归因于C++内存管理器的缓慢和不可预测的特性。但是,其他版本的性能都很好。池化版本在C场景下每秒可以处理400万个数据包,在B场景下每秒可以处理500万个数据包,可以达到我们的期望值。A场景的处理速度绝对是惊人的(两千万),但是我们必须记住,在这种情况下,我们会丢弃这些数据包。

由于在池化过程中根本不执行任何内存分配,因此场景A,B和C之间的速度差异只能由已用内存的总容量不同来解释–所用内存更多和随机访问模式会降低缓存效率。

汇总

让我们将所有结果汇总在一个表中。我们将忽略dummpy的结果以及使用高得离谱的堆内存大小获得的结果。

让我们首先看一下批处理测试:

SolutionStrategyCaseACaseBCaseCCMSAllocation4006854042Mix108315466Pooling346470415G1Allocation4246404300Mix134364625Pooling140355740ZGCAllocation5238001880Mix108403436Pooling109403453NativeCMSAllocation89253950Mix83221298Pooling79213260NativeG1Allocation1082391100Mix117246432Pooling111249347NativeZGCAllocation127290533Mix100290400Pooling118302330C++Allocation270560616Mix115223307Pooling111233274C++flatAllocation163409480Mix35153184Pooling34148171

每列中的绝对最佳结果被标记为绿色,并且所有这三个都恰好来自flatC++。

最佳和次佳Java结果分别标记为黄色和红色。它们来自“NativeCMS”,这表明CMS垃圾收集器距离退役为时尚早。它仍然可以很好地用于批处理程序。

最后,这是实时测试的主要结果:

StrategySolutionCaseACaseBCaseCCMSAllocation600lost:0.8%lost:75%Mix150350lost:9%Poolinglost:17%lost:17%lost:9G1Allocation7502000lost:76%Mix200600lost:4%Pooling200600lost:4.4%ZGCAllocation540820lost:44%Mix120420450Pooling130420460NativeCMSAllocation140lost:0.8%lost:34%Mix130lost:0.0025%lost:0.7%Pooling120lost:0.03%lost:0.7%NativeG1Allocation150350lost:6.5%Mix150400lost:0.075%Pooling160500700NativeZGCAllocation170380550Mix120320440Pooling130320460C++Allocation520950950Mix280320550Pooling250420480C++flatAllocation220650700Mix50220240Pooling50190230

深灰色块表示缺少解决方案(数据包始终丢失)。否则,配色方案相同。flatC++版本依然是最好的,而最好的和次之的Java版本则来自多个解决方案,最好的是NativeZGC。

结论

如果要编写真正的实时系统,请使用C或C++编写,并避免分配内存。也可以在Java中实现一些相当不错的实时近似。在这种情况下,它也有助于减少内存分配。

这回答了我们最初的问题(分配内存或池化):池化。在我们运行的每个测试中,池化的性能要好于分配内存。此外,在大多数Java测试中,分配内存策略在批处理模式下执行得很糟糕,而在实时模式下根本无法执行。

当数据包利用率低时,混合方法非常好。但是,如果利用率增长,则池化变得更好。

垃圾收集器确实是最大的影响因素。池化会引入很多活动对象,这些活动对象会导致偶发但很长的GC延迟。然而分配内存策略会使GC完全过载。此外,在高负载时(我们的C场景),无论如何可能存在许多活动对象,并且分配内存策略表现很惨。因此池化仍然是更好的策略。

G1和ZGC收集器尽管经常在批处理模式下表现较差,但它们确实在实时模式下有所改善。ZGC表现特别出色;它甚至可以以合理的性能(每秒200万个数据包)处理C场景。

如果我们分配一千万个缓冲区而不是一百万个缓冲区,或者如果程序使用其他大数据结构,一切都会变得更糟。一种可能的解决方案是将这些结构移到堆外。

在不需要立即响应传入消息的情况下,增加输入队列大小可能会有所帮助。我们可以考虑在C场景下引入另一层以及更高容量的中间队列。如果我们的源队列中可以存储一秒钟的数据包,则即使使用CMS,池化版本也可以正常工作。

原文地址:

参考阅读:

一种灵活的API设计模式:在SpringBoot中支持GraphQL

支付核心系统设计:Airbnb的分布式事务方案简介

算力提升117%,资源使用下降50%,打开集群优化正确姿势

Golang实现单机百万长连接服务-美图的三年优化经验

几款流行监控系统简介

本文作者pzemtsov,由方圆翻译,转载请注明出处,技术原创及架构实践文章,欢迎通过公众号菜单「联系我们」进行投稿。

高可用架构

改变互联网的构建方式

最新文章