搜索
写经验 领红包
 > 生活

并行处理什么意思(并行处理的三种方式)

导语:4700字带你在简历上写精通:并行回收MinorGC与FullGC,太值了!

Minor GC

Minor GC的触发时机是Eden中的内存使用完毕,且无法响应Mutator的分配请求时。但是为了保证回收的效率,在以下两种情况下JVM会直接跳过Minor GC执行Full GC:

1)上一次Minor GC并没有成功,即To空间不为空。

2)老生代的剩余空间可能无法满足新生代需要晋升的对象。新生代晋升对象的大小通过历史晋升对象的大小预测得到。

并行复制回收与第4章介绍的ParNew非常类似,但实现更为简单。并行复制回收的两个重点是:

1)从所有的根出发执行标记复制算法,除了线程栈等传统的根集合之外,还需要考虑代际之间的引用(通过卡表保存),以便加速标记过程。

2)多个任务并行执行,可能会存在线程之间任务不均衡的情况,所以要考虑任务均衡机制。

由于传统的根在其他章节已经介绍过,这里稍微介绍一下代际引用的根标记方法,其本质上与ParNew类似,实现进行了简化。新生代回收将整个老生代划分成多个组,其中每个组包含n个块(n是线程个数),每个块的大小固定为64KB(每个块包含128个卡表,每个卡表为512B,故每个块为512B×128=64KB),同时辅以卡表中记录的对象代际引用信息,然后使用多个线程并行地执行标记复制算法。其中老生代根的划分如图5-9所示。

图5-9 老生代根的划分

在一些场景(较大的内存环境)中,笔者尝试增强并行回收并行处理的粒度(即将64KB修改为更大,如512KB等),实现的方法与ParNew相同,测试发现效果更好。

Full GC

并行回收并没有单独针对老生代的回收,通常是Minor GC触发不能满足Mutator的分配请求,同时老生代也没有足够的内存响应Mutator的内存分配请求,就会触发Full GC。关于Full GC的触发在5.1.3节中介绍过。

算法概述

并行回收中Full GC算法和其他算法的实现都不相同,本节着重介绍其算法实现思想。Full GC是对整个堆空间进行回收,为了提高回收效率,采用多个线程并行处理。但是堆空间只有一个,所以只有设计合理的算法才能高效地并行执行。

Full GC也是采用标记、压缩的算法。其中标记、压缩是不同的阶段,都是并行执行的。并行标记的实现与Minor GC的并行标记类似,从多个根集合出发,每个根集合由一个GC线程执行标记,当线程间任务出现不均衡的时候还可以进行任务均衡,从而加快标记的速度,而并行压缩则需要重新设计算法。

Full GC将整个堆空间划分成固定的块,在32位系统上块大小为256KB,64位系统上块大小为512KB。通过这样的划分方式让多个线程并行地执行压缩,划分如图5-10所示。

图5-10 Full GC堆空间划分方式

多个线程并行执行压缩的前提是:多个线程执行压缩算法时相互之间不存在数据依赖。但是简单的划分方式并不能让多个线程并行执行,因为块之间可能存在数据依赖。假设有两个内存块A和B,数据依赖主要指的是当块A要压缩到另一个块B中时,需要块B已经完成压缩,否则块B的数据可能会被覆盖。

一个事实是:当块A的压缩的源和目的相同,都位于块A时,则可以认为块A不依赖其他块,块A随时都能执行压缩。当然前提是块A在压缩时能准确地找到目的位置,如果位置错误,仍然可能覆盖内存中的数据。

由于每个块的大小是固定的,块中活跃对象的大小最大为块的大小(块中所有对象都是活跃的),活跃对象最小为0(块中所有对象都是死亡对象),所以当一个块压缩移动另外的块时,从目的块的角度出发,有以下3种可能性:

1)块在压缩前、后位于同一个块中,如图5-11所示。

图5-11 内存块的目的和源位于同一个块中

典型的例子就是堆空间的第一个块,该块属于老生代空间,通常有活跃对象,其压缩的目的位置是自身。

2)块在压缩前、后不是同一个块,但是块中的所有活跃对象都可以被目的块容纳,如图5-12所示。

图5-12 内存块A依赖于内存块B

该场景也非常常见,例如块B先压缩,压缩后仍有部分剩余空间,且剩余空间完全可以容纳块A中所有的活跃对象。

3)块在压缩时被放入两个块中,如图5-13所示。

图5-13 内存块A依赖于内存块B和C

如图5-13所示,块B首先压缩,压缩后仍有部分剩余空间,但块B中的剩余空间不足以容纳块A所有的活跃对象,此时块A的部分对象将被放入块B中,另一部分活跃对象要放入紧接块B的另一个块中(图5-13中使用块C表示,实际上块C也有可能是块A,例如块A刚好位于块B的后面)。

一个块压缩到另一个块中,只有这3种情况,不可能存在更多的情况。例如,块A需要3个或者更多的块来保持压缩后的对象(因为块活跃对象大小的最大值和块的大小是一致的,如果块A压缩后需要3个连续块保持活跃对象,则说明块A的活跃对象大小一定大于中间块的大小,也就是说块A中活跃对象的大小超过了块的大小,这是不可能的)。

在讨论块压缩的时候,并没有关注对象跨内存块的情况。因为块是按照大小对齐的,所以一定会有一些对象的前半部分位于前一个块(记为块1)中,对象的后半部分位于相邻的块(记为块2)中。对于跨块的对象有两种可能:死亡或者活跃。对于死亡对象无须处理(不会被标记、压缩),对于活跃对象则分别把对应信息记录到两个块(块1和块2)中。

块1在压缩时仅仅压缩对象的前半部分,块2在压缩时仅仅压缩对象的后半部分,这样的设计要求在压缩时原来相邻的块在压缩后也相邻,即块2的起始位置必须是块1的结束位置。而这一要求并不会影响线程并行操作,只要在并行压缩时能准确计算出块2的起始地址即可,即便是块2先于块1压缩,也能保证压缩后对象的正确性。

寻找目标块的压缩算法本质上是构建一棵依赖树(或者依赖森林,即多棵依赖树,取决于是否存在多个块可以压缩到自身的场景)。一般来说,第一个块是依赖树的根节点,后续的块按顺序向前压缩,构成的依赖树如图5-14所示。

图5-14 内存块构成的依赖树示意图

在这个例子中,块1、块2、块3、块4均被压缩到块1中,块5被压缩到块2中,块6被压缩到块2和块3中。其中块1对应场景1,块2、块3、块4、块5对应场景2,块6对应场景3。

当构建好依赖树以后,就可以尝试使用并行算法进行处理。处理步骤如下:

1)如果树(或者森林)中节点的出度为0,则可以执行压缩。例如在图5-14中只有块1的出度为0,块1首先执行压缩,并且只能由一个线程执行压缩。

2)当出度为0的节点执行完压缩后,将指向该节点的边删除,并将指向该节点的节点出度减1。在图5-14中块1执行完成后,块2、块3、块4的出度减去1,此时块2、块3、块4的出度都为0,也就是说块2、块3、块4可以被并行压缩,可以由3个线程并行执行压缩。

3)循环执行步骤2,直到所有节点压缩完成。例如块6只有在块2、块3都压缩完成后出度才可能变成0,才会执行压缩。

上述算法仅仅描述了并行化的思路,但是在实现中还需要考虑一个问题:树中的节点该由哪个GC工作线程执行?这个问题实际上是多个GC工作线程如何选择出度为0的节点。在算法实现中引入了一个栈结构,用于保持出度为0的节点。多个GC工作线程都从节点栈中获取可以压缩的节点,当节点压缩完成后修改指向该节点的出度。

在并行压缩时,还有一个关键的信息,就是每个块都能准确地找到自己的起始位置,这在并行回收的实现中由一个单独的阶段来完成。

算法实现与演示

Full GC的实现可以分为以下3个步骤。

1)标记(Mark):对整个堆空间进行标记,寻找所有的活跃对象,并使用位图(Bitmap)进行保存。在实现中使用了两个位图,这两个位图分别记录对象的起始地址和终止地址,目的是快速地寻找、定位对象占用的内存空间。该阶段是并行执行的。

2)总结(Summary):对整个堆空间进行划分,分别计算每个块中活跃对象的大小及块压缩后的起始位置,同时计算每个块的出度情况。该阶段是串行执行的。

3)压缩(Compact):因为块的出度已经计算得到,所以可以构建依赖树,并按照5.3.1节介绍的算法由多个GC工作线程执行并行压缩。

整个算法的难点在于构造依赖树。下面通过示意图演示一下算法的运行过程。假设整个堆空间标记完成后如图5-15所示。

图5-15 堆空间标记完成后的状态

在标记阶段,多个GC工作线程从多个根集合出发,并行标记整个堆空间中所有的活跃对象,同时使用位图记录活跃对象。并行回收为了在压缩阶段便于计算活跃对象的大小,使用了两个位图:第一个位图称为beg,记录活跃对象的起始地址;第二个位图称为end,记录活跃对象最后一个字节的地址。

如图5-15所示,整个堆空间有3个活跃对象,beg位图和end位图分别记录这3个对象的起始地址和最后一个字节的地址。

另外,在标记的时候,按照分区的大小划分,同时记录每个分区中活跃对象的大小。该信息在压缩时使用。

标记阶段完成后,进入总结阶段,该阶段的目的就是构造依赖树。将整个堆空间按照分区的大小划分,计算每个分区在压缩时所在的位置。

根据上面介绍的算法,可知每个分区在压缩后的目的分区可能值为0、1、2。在总结阶段结束后,可以得到分区的目标位置,如图5-16所示。

图5-16 计算内存块目标位置示意图

实际上为了使压缩高效地执行,需要为每个分区(内存块)额外记录一些信息,在实现中使用RegionData来保存。RegionData中最关键的信息如下。

1)Destination:分区的第一个活跃对象将要压缩到的地址,该信息有效地描述了本分区的目标位置。

2)Source region:描述分区被压缩后,活跃对象来自的分区。

3)dc:分区中目的分区的个数,即分区压缩后将被移动到几个分区中,可能取值为0、1、2。

4)Live Obj size:当前分区所有活跃对象的大小,包含本分区中跨越到后续分区对象的前半部分信息,但不包括前一个跨分区的对象,其后半部分位于该分区中,信息记录在Partial obj size中。如果分区中的最后一个对象跨了本分区和下一个分区,则仅记录对象前半部分的大小。

5)Partial obj size:只记录活跃对象跨分区到本分区中,且对象的后半部分位于该分区中。注意,最多只能有一个跨分区的对象。

6)Partial obj add:跨分区的对象后半部分的目标位置。

总结阶段本质上就是为每个分区填充上述数据结构,当数据结构填充完成后,依赖树也就建立完成了。在压缩阶段,根据依赖树并行执行。

由于压缩是并行执行的,为了保证多个GC工作线程的执行效率,并行回收中每一个GC工作线程都有一个栈,栈中元素是可以被压缩的分区,简称分区栈。

根据总结阶段的信息构建的依赖树(或依赖森林),将dc为0(出度为0)的分区加入栈。由于存在多个栈,如果整个堆是一片森林,则多个分区栈均有数据;如果整个堆构成一棵树,则只有第一个分区栈有数据。

压缩开始时并行线程的分区栈如图5-17所示。

图5-17 并行线程分区栈示意图

对分区执行压缩动作,当执行完成后,将源节点的出度减1,然后压入本地的分区栈。如果多个线程存在任务不均衡的情况,即有线程无工作可做,则会尝试从其他分区栈进行任务窃取。

注意,并行回收的引用更新工作也在压缩时同时完成。对于压缩后的每个成员变量,计算其位置的计算方式为:根据已知分区第一个活跃对象存放的地址(RegionData中的destination),只需要计算对象相对于目的分区的偏移地址即可(使用位图可以计算得到,使用两个位图可加速计算每个对象的大小)。

当执行完压缩以后,内存完成整理,如图5-18所示。

图5-18 压缩完成后堆空间示意图

当压缩执行完成后,还需要执行一些后处理动作。例如根据参数设置调整GC边界、From/To空间的大小等,关于内存区域的调整不再展开介绍。除此之外,回收完成还有一个重要的工作:代际关系的处理,即卡表的处理。

在前面提到,整个堆空间被划分为老生代、Eden、From和To空间,通常To空间为空。在压缩的时候按照老生代、Eden、From和To空间的顺序保存活跃对象。其设计思路在第3章讨论过,这里也不赘述。

介绍串行回收优化时提到,为了减少压缩时内存的移动,压缩算法设计了MarkSweepDeadRatio的阈值,在压缩的时候从头开始计算,容忍在压缩时跳过一定比例的死亡对象,以减少内存移动。该思想是强分代理论的应用,在并行回收中也有类似的实现,称为Dense Prefix。在计算DensePrefix时会使用MarkSweepDeadRatio,只不过并行回收中MarkSweepDeadRatio被重置为1。同样的道理,虽然在回收时可以跳过死亡对象,但是因为死亡对象可能引用到已经卸载的类元数据,所以在原来死亡对象的位置上需要填充dummy对象(典型的就是int[]对象),从而保证堆的可解性。

本文给大家讲解的内容是JVM垃圾回收器详解:并行回收, Minor GC、Full GC下篇文章给大家讲解的内容是JVM垃圾回收器详解:并行回收,并行任务的负载均衡机制感谢大家的支持!

本文内容由快快网络小思整理编辑!