转载

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

在上文中,我们使用C++和Java分别开发了一个队列,可以作为时钟发生器。今天我们将其用作度量工具。

今天的问题是:为每个新消息分配新内存,还是使用内存池?我观察到的网上讨论中,老派C程序员通常避免分配内存,而Java程序员则倾向于分配新内存。本文中我们将详细分析两种做法。

该问题适用于批处理或者软实时应用。对批处理程序来说,程序的吞吐量更加重要,对于软实时程序来说,则存在延迟问题,如果处理某个消息的时间太长,程序会错过一些传入的消息。本文将分别研究这两种情况。事实上也存在第三种情况,网络服务器,同时有延迟和吞吐量的限制,在本文中暂不讨论。

关于实时程序

一些读者可能想知道为什么有人甚至尝试使用Java写实时程序。 每个人都知道Java不是实时平台。 实际上普通Windows或Linux都不是实时操作系统。没有人会用Java编写真正的实时程序(例如自动驾驶仪)。 在本文中,实时程序是指接近实时的程序(即软实时程序):那些允许发生少量事件丢失的程序。距离来说比如网络流量分析器,如果在一百万个数据包中丢失一两百个包,通常不是大问题。 这样的程序几乎可以用任何语言(包括Java)开发,并可以在常规操作系统上运行。 我们将使用这种分析器的极其简模型作为示例程序。

GC的影响

为什么在分配内存和内存池之间进行选择非常重要?对于Java而言 ,最重要的因素是垃圾收集器( GC ),因为它确实可以暂停整个程序的执行(称为“停止世界”)。

最简单的形式的垃圾收集器:

  • 当内存分配请求失败时调用,该请求以与分配内存速率成比例的频率发生;
  • 运行时间与活动对象的数量成正比。

真正的垃圾收集器采用各种技巧来提高性能,消除长时间停顿并降低对活动对象数量的敏感性:

  • 他们根据对象的生存时间将对象划分为两个或更多的空间(“世代”),并假设存在了一段时间的物体可能会存活更久,而新分配的对象则可能很快死亡。从统计上来说,这是正确的,因为

    Java

    中程序分配了许多临时对象。
  • 他们运行垃圾回收器的快速,轻量级版本,该版本适用于新生代,并且在内存耗尽之前很长一段时间内被经常调用。这使得FULL GC的频率降低了很多,但是并不能完全消除FULL GC。
  • GC在大部分时间与用户程序并行执行,从而使暂停时间缩短,或者避免暂停。

这些改进通常需要生成代码,例如写屏障,甚至读屏障。这些都降低了执行速度,但在许多情况下,仍可通过减少垃圾收集暂停来证明其合理性。


但是,这些改进不会影响两个基本的GC规则:分配更多内存时,GC调用频率更高;而当存在更多活动对象时,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
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
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
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
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
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
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 B C
Dummy 145 164 164
Allocation 270 560 616
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

参考阅读:

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

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

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

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

  • 几款流行监控系统简介

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

高可用架构

改变互联网的构建方式

设计模式之争:新分配内存还是内存池?(含评测) 长按二维码 关注「高可用架构」公众号

原文  http://mp.weixin.qq.com/s?__biz=MzAwMDU1MTE1OQ==&mid=2653551542&idx=1&sn=d460517057ed3e1b21d7a050f44b06a6
正文到此结束
Loading...