JVM GC之垃圾收集算法
1.垃圾收集概念
GC目的
- 分配内存,为每个新建的对象分配空间
- 确保还在使用的对象的内存一直还在,不能把有用的空间当垃圾回收了
- 释放不再使用的对象所占用的空间
我们把还被引用的对象称为活的,把不再被引用的对象认为是死的,也就是我们说的垃圾。GC 的工作就是找到死的对象,释放(也称为回收)这些对象所使用的空间的过程称为垃圾收集。
我们把 GC 管理的内存称为 堆(heap),垃圾收集启动的时机取决于各个垃圾收集器,通常,垃圾收集发生于整个堆或堆的部分已经被使用光了,或者使用的空间达到了某个百分比阈值
对于内存分配,实现的难点在于在堆中找到一块没有被使用的确定大小的内存空间。所以,对于大部分垃圾回收算法来说避免内存碎片化是非常重要的,它将使得空间分配更加高效。
GC收集器理想状态
安全和全面
活的对象一定不能被清理掉,死的对象一定不能在几个回收周期结束后还在内存中。
高效
不能将我们的应用程序挂起太长时间。我们需要在时间、空间、频次上作出权衡。比如,如果堆内存很小,每次垃圾收集就会很快,但是频次会增加。如果堆内存很大,很久才会被填满,但是每一次回收需要的时间很长。
内存碎片限制
当对垃圾对象的内存被释放时,空闲空间可能会出现在不同区域的小块中,这样在任何一个相邻区域中都可能没有足够的空间用于分配一个大型对象。消除分段的一种方法称为压缩,在下面的各种垃圾收集器设计选择中将讨论。
可伸缩性
可伸缩性也很重要。在多处理器系统中,分配不应该成为多线程应用程序的可伸缩性瓶颈,而且收集也不应该成为瓶颈。
2.GC算法选择
在设计或选择垃圾收集算法时,必须做出一些选择:
串行 vs 并行
并行:多个垃圾回收线程同时工作,互不影响
并发:垃圾回收线程和应用程序线程同时工作,应用程序不需要挂起
串行收集的情况,即使是多核 CPU,也只有一个核心参与收集。使用并行收集器的话,垃圾收集的工作将分配给多个线程在不同的 CPU 上同时进行。并行可以让收集工作更快,缺点是带来的复杂性和内存碎片问题。
并发 vs Stop-the-world
当 stop-the-world 垃圾收集器工作的时候,应用将完全被挂起。与之相对的,并发收集器在大部分工作中都是并发进行的,也许会有少量的 stop-the-world。
stop-the-world 垃圾收集器比并发收集器简单很多,因为应用挂起后堆空间不再发生变化,它的缺点是在某些场景下挂起的时间我们是不能接受的(如 web 应用)。
相应的,并发收集器能够降低挂起时间,但是也更加复杂,因为在收集的过程中,也会有新的垃圾产生,同时,需要有额外的空间用于在垃圾收集过程中应用程序的继续使用。
压缩 vs 不压缩 vs 复制
垃圾回收器确定了内存中哪些对象是活的,哪些是垃圾,它可以压缩内存,将所有的活动对象一起移动,并完全回收剩余的内存。在压缩之后,在第一个空闲位置分配一个新对象是很容易和快速的。可以使用一个简单的指针来跟踪对象分配的下一个位置。
与压缩收集器相反,不压缩的收集器只会就地释放空间,不会移动存活对象。优点就是快速完成垃圾收集,缺点就是潜在的碎片问题。一般来说,从堆中进行分配比从压缩堆中分配更昂贵。可能需要在堆中搜索足够大的连续内存区域以容纳新对象。
第三种选择是复制收集器,它将活动对象复制到另一个内存区域。这样做的好处是,原有区域的空间被清空了,这样后续分配对象空间非常迅速,缺点就是需要进行复制操作和占用额外的空间。
3.性能指标
以下几个是评估垃圾收集器性能的一些指标:
- 吞吐量:应用程序的执行时间占总时间的百分比,当然是越高越好
- 垃圾收集开销:垃圾收集时间占总时间的百分比
- 停顿时间:垃圾收集过程中导致的应用程序挂起时间
- 频次:相对于应用程序来说,垃圾收集的频次
- 空间:垃圾收集占用的内存
- 及时性:当对象变为垃圾和内存可用时之间的时间
在交互式程序中,通常希望是低延时的,而对于非交互式程序,总运行时间比较重要。实时应用程序既要求每次停顿时间足够短,也要求总的花费在收集的时间足够短。在小型个人计算机和嵌入式系统中,则希望占用更小的空间。
4.垃圾收集算法
4.1.标记-清除算法
概念
最基础的垃圾收集算法就是“标记-清除算法”,如同它的名字,该算法分为“标记”和“清除”两个阶段。之所以是最基础算法是因为后续的几种收集算法都是基于这种思路并对其不足进行改进而得到的。
不足
第一,效率问题,标记和清除两个阶段的效率都不高
第二,空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多会导致需要分配较大对象时,无法找到足够的连续内存而不得不提前出发另一次垃圾收集动作。
标记-清除算法示意图
4.2.复制算法
概念
为了解决效率问题,复制算法便出现了,它将可用的内存按照容量等分成两块,每次只使用其中的一块,当这一块的内存用完了,就将存活的对象复制到另外一块内存,然后将原有内存块的空间清理掉。这样每次只对整个半区内存进行垃圾回收,内存分配时就无需考虑内存碎片等复杂的情况了,只需要移动堆顶的指针,按顺序分配内存即可,实现简单,运行效率高。
不足
将内存大小一分为二,只使用其中一份,代价太高
复制算法示意图
使用场景
新生代正是采用复制算法进行垃圾收集,将新生代内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一个Survivor。当回收时,将存活的对象复制到另外一块Survivor空间上,最后清理掉刚才使用过的Eden和Survivor空间。
HotSpot虚拟机默认Eden和Survivor的大小比例是,8:1:1,也就是新生代中可用内存空间为整个新生代容量的90%(80%+10%),只有10%的内存会被“浪费”。但是当Survivor空间不够用时,需要依赖其它内存(老年代)进行分配担保(Handle Promotion)。
如果另外一块Survivor空间没有足够空间存放新生代收集下来的存活对象时,这些对象将直接通过分配担保机制进入老年代。
4.3.标记-整理算法
概念
复制收集算法在对象存活率较高的情况下就要进行较多的复制操作,效率将会变低。更关键的一点,如果不想浪费50%的空间,就需要额外的空间进行分配担保,以应对被使用的内存中的对象都100%存活的极端情况,所以老年代一般不能直接采用这种算法。
根据老年代的特点,“标记-整理算法(Mark-Compact)”就应运而生了,标记过程与“标记-清除算法”一样,但后续不是直接对可回收对象进行清理,而是让所有存活的对象都像一端移动,然后清理掉边界以外的内存。
标记-整理算法示意图
使用场景:老年代
4.4.分代收集算法
当使用分代收集算法时,内存将被分为不同的代(generation),最常见的就是分为年轻代和老年代。
在不同的分代中,可以根据不同的特点使用不同的算法:
- 在新生代,每次垃圾收集时会发现有大批对象死去,只有少量存活,那就选择“复制算法”
- 在老年代,因为对象存活率高、没有额外空间为它进行分配担保,就必须使用“标记-清理”或“标记-整理”算法来进行回收
年轻代中的收集是非常频繁的、高效的、快速的,因为年轻代空间中,通常都是小对象,同时有非常多的不再被引用的对象。
那些经历过多次年轻代垃圾收集还存活的对象会晋升到老年代中,老年代的空间更大,而且占用空间增长比较慢。这样,老年代的垃圾收集是不频繁的,但是进行一次垃圾收集需要的时间更长。
对于新生代,需要选择速度比较快的垃圾回收算法,因为新生代的垃圾回收是频繁的。
对于老年代,需要考虑的是空间,因为老年代占用了大部分堆内存,而且针对该部分的垃圾回收算法,需要考虑到这个区域的垃圾密度比较低。
参考资料:
《深入理解Java虚拟机:Java高级特性与最佳实践》
《Java内存管理白皮书》:http://www.oracle.com/technetwork/java/javase/memorymanagement-whitepaper-150215.pdf