fenlan

Everything gonna be fine in the end, if it's not fine, it's not the end.

0%

Java GC

Java堆空间划分

引用计数法(Reference Counting)

引用计数法是最经典也是最古老的一种垃圾收集方法,它的实现很简单,只要为每个对象设置一个整型的计数器即可。但是引用计数法有两个严重的问题:

  • 无法处理循环引用的情况。因此在Java的垃圾回收器中,没有使用这种算法。
  • 引用计数器要求在每次因引用产生和消除的时候,需要伴随一个加法操作和减法操作,对系统性能会有一定的影响。

一个简单的循环引用问题描述如下:有对象A和对象B,对象A中含有对象B的引用,对象B中含有对象A的引用。此时,对象A和对象B的计数器都不为0。但是在系统中,却不存在任何第3飞蛾对象引用了A或B。也就是说,A和B是应该被回收的垃圾对象,但是由于垃圾对象间相互引用,从而使垃圾回收器无法识别,引起内存泄漏。

标记清除法(Mark-Sweep)


标记清除算法是现代垃圾回收算法的思想基础。标记清除算法将垃圾回收分为两个阶段:标记阶段、清除阶段。一种可行的实现是,在标记阶段,首先通过跟节点标记所有从跟节点开始的可达对象。因此,未标记的对象就是未被利用的垃圾对象。然后在清除阶段,清除所有未被标记的对象。标记清除算法可能会产生最大问题是空间碎片。

复制算法(Copying)


复制算法的核心思想是:将原有的内存空间分为两块,每次只使用其中一块,在垃圾回收时,将正在使用的内存中的存活对象复制到未使用的内存块中,之后清除正在使用的内存块中的所有对象,交换两个内存的角色,完成垃圾回收。

如果系统中的垃圾对象很多,复制算法需要赋值的存活对象数量就会相对较少。因此,在真正需要垃圾回收的时刻,复制算法的效率是很高的。又由于对象是在垃圾回收过程中,统一被复制到新的内存空间中的,因此可确保回收后的内存空间是没有碎片的。虽然有以上两大优点,但是复制算法的代价却是将系统内存折半,因此单纯的复制算法也很难让人接收。现代Java回收机制中新生代的Survivor区域在使用这样的回收算法,因为新生代垃圾对象比存活对象多,复制算法高效。新生代Survivor区是两个比较小,但大小相等的内存块,在我的调优参数设置中,Survivor区通常只占用新生代区域的1/8,但也不是固定的,多尝试比例可以有较好的收获。

题外话,我的一个项目上线初期,默认的JVM参数,CPU占用一直高居25%,但是调整后一直保持在20%以下,但同时内存占用却上升了。至于如何调整的,我还需要再深入一下。

记录一下详细的配置过程 : 第一次上线,所有的配置都是默认的,记录Java堆各个代的情况,根据情况进行调整;第二次上线,采取减少堆的大小: -Xmx250m -Xms40m -Xmn15m -XX:SurvivorRatio=10 效果显著,在原来CPU高居25%-30%的情况下,顺利降到20%以下;第三次上线,采取增大堆的大小: -Xmx250m -Xms75m -Xmn25m -XX:SurvivorRatio=10,效果更好,已经降到16%以下。

在这里补充一下Java垃圾回收的大致过程
在垃圾回收时,eden空间中的存活对象会被复制到未使用的survivor空间中(假设死to),正在使用的suvivor空间(假设是from)中的年轻对象也会被赋值到to空间中(大对象,或者老年对象会直接进入老年代,如果to空间已满,则对象也会直接进入老年代)。此时eden空间和from空间中的剩余对象就是垃圾对象,可以直接清空,to空间则存放此次回收后的存活对象。这种改进的复制算法,既保证了空间的连续性,又避免了大量的内存空间浪费。

标记压缩(Mark-Compact)


复制算法的高效性是建立在存活对象少,垃圾对象多的前提下的,这种情况在新生代经常发生,但是在老年代,更常见的情况是大部分对象都是存活对象。如果依然使用复制算法,复制成本也将很高。因此基于老年代垃圾回收的特性,需要使用其他的算法。

标记压缩算法是一种老年代的回收算法。它在标记清除算法的基础上做了一些优化。和标记算法一样,标记压缩算法也首先需要从跟节点开始,对所有可达对象做标记。但之后,它不是简单地清理未标记的对象,而是将所有的存活对象压缩到内存的一端。之后清理边界外所有的空间。这种方法避免了碎片的产生,又不需要两块相同的内存空间,因此其性价比比较高。

分代算法(Generational Collecting)

上述介绍的算法中,并没有一种算法可以完全替代其他算法,它们都具有自己独特的优势和特点。因此根据垃圾回收对象的特性,使用合适的算法回收,才是明智的选择。
分代算法就是基于这种思想,他将内存区间根据对象特点分成几块,根据每块内存空间的特点,使用不同的回收算法,以提高垃圾回收的效率。

一般来说Java虚拟机会将所有的新建对象都放入成为新生代的内存区域,新生代的特点死对象朝生夕灭,大约90%的新建对象会被很快回收,因此,新生代比较适合使用复制算法。当一个对象经过几次回收后依然存活,对象就会被放入成为老年代的内存空间。在老年代中,几乎所有的对象都是经过几次垃圾回收后依然得以存活的。因此,可以认为这些对象在一段时间期内,甚至在应用程序的整个生命周期中,将是常驻内存的。那么这些对象回收就应该采用标记压缩标记清除算法,以提高垃圾回收效率。

对于新生代和老年代来说,通常新生代回收频率很高,但是每次回收的耗时都很短,而老年代回收频率比较低,但是会消耗更多的时间。同时这引来一个问题,当老年代中的对象持有新生代对象的引用时怎么办?为了解决这个问题,虚拟机可能使用一种叫做卡表(Card Table)的数据结构。卡表为一个比特位集合,每一个比特位可以用来表示老年代的某一区域中的所有对象是否持有新生代对象的引用。这样在新生代GC时,可以不用花大量的时间扫描所有老年代对象,来确定每一个对象的引用关系,而可以先扫描卡表,当卡表的标记为1时才扫描给定区域的老年代对象;而当卡表位为0时就不用是扫描所在区域的老年代对象。

分区算法(Region)

分代算法是按照对象的生命周期长短划分成两部分,分区算法将真个给堆空间划分为连续的不同小区间,每个区间都独立使用,独立回收。这种算法的好处是可以控制一次回收多少个小区间。

一般来说,在相同条件下,堆空间越大,一次GC时所需要的时间就越长,从而产生的停顿也越长。为了更好地控制GC产生的停顿时间,将一块大的内存区域分割成多个小块,根据目标停顿时间,每次合理地回收若干个小区间,而不是整个堆空间,从而减少一次GC所产生的停顿。

参考链接