程序员登高之路——JAVA篇——2.JVM的垃圾回收
如何判断对象死亡?
目前主流的判断对象死亡的方法有两种:
1.引用计数法:
每个对象对象包含一个引用计数器,每当对象被引用,引用计数器便加一,引用失效就减一。当对象的引用计数器为0时,则表示对象可被回收。此方法无法解决解决对象循环引用的情况,如:
// 产生循环引用的代码 A objectA = new A(); B objectB = new B(); A.b = objectB; B.a = objectA;
若采用引用计数法,对象A和B的引用计数器值永远不会小于1,那么就产生了内存泄漏。(据说Python使用的就是引用计数法,关于如何解决的循环引用问题,感兴趣的朋友可以去查一查)。
2.可达性分析算法:
通过一系列可以被看做GCRoots的根节点出发,向下搜索,构成引用链,未在引用链之内的则会被视为可回收对象。
GCRoots:
(1)虚拟机栈中的引用
(2)方法区中的静态变量
(3)方法区中的常量对象
(4)本地虚拟机栈中的引用
除此之外还有synchronized持有的对象,minjorGC时存在跨代引用的老年代对象等。
垃圾回收算法
上面说了如何判断对象可否回收,接下来说一说JVM如何回收这些对象。
1.标记清除法:
首先扫描并标记出对象是否需要清除,扫描完成后一次清除需要清除的对象。
上图红色方块表示垃圾,绿色方块表示存活对象,左图为垃圾清除之前的内存,右边为垃圾清除之后的内存。标记清除算法的缺点就是会产生大量的内存碎片,比如在垃圾回收之后,系统需要创建一个占4个小格的对象,此时内存内剩余空间明明大于4,系统却只会报内存溢出的异常。
2.复制算法:
复制算法将内存分为了两部分,每次仅使用一部分,当使用那部分满了的时候,就会将所有存活的对象移到另一个区域。
上图可以看出,在经过复制算法之后,所有存活的对象都被移到了另一半内存中,之后清空了之前使用的内存区。复制算法的弊端也很容易看出来,就是虚拟机每次仅能使用一半的内存,对于"寸土寸金"的RAM来说,这真是用着肉疼。
3.标记整理算法:
标记整理算法会在判断完垃圾之后,将存活的对象向一侧移动,之后清除掉剩余的内存。
在上图中,存活对象都像左侧移动,移动后需要占用7格内存,最后将边界外(7格之后)的内存全部清除。与标记清除算法相比,它不会产生内存碎片,与复制算法相比,它不会浪费内存。不过移动对象的花费仍然无法避免。
JVM中的垃圾收集器
上面的三种垃圾回收算法,并没有最优解,只是各自适用于不同场景,为此JVM也实现了各种垃圾收集器。
1.Serial
新生代垃圾收集器,采用复制算法。
特点:单线程垃圾收集器,在垃圾收集的时候会停止所有其他的用户线程。
2.SerialOld
老年代垃圾收集器,采用标记整理算法。可以看作老年代版本的Serial,垃圾收集的时候也会停止所有其他用户线程
3.ParNew
新生代垃圾收集器可以看作多线程版的Serial收集器,垃圾回收的时候也会停止其他所有用户线程。默认线程数与CPU数相等,所以在单核CPU的情况下甚至可以看成Serial。
4.Parallel Scavenge
新生代垃圾收集器,采用复制算法,垃圾清除时会停止其他用户线程,存在的目的是为了控制垃圾收集的吞吐量
5.Parallel Old
老年代垃圾收集器,采用标记整理算法,垃圾清除时会停止其他用户线程,存在的目的是为了控制垃圾收集的吞吐量。
6.CMS *******
老年代垃圾收集器,采用标记清除。减少了垃圾回收时停止用户线程的时间。CMS将标记分为了三步:
1.初始标记:单线程标记所有GCRoots直接指向的对象,因为不会涉及到可达树的遍历,所以非常快,此时会停止其他用户线程。
2.并发标记:与其他用户线程一起执行,根据步骤一获取的结果遍历可达树并标记。
3.重新标记:步骤2时,系统可能会产生新的对象,这一步的目的就是标记这些新产生的对象,此时会停止其他线程。
4.清除垃圾:与其他用户线程一起执行。
可以看出CMS就是将最费时间的全局可达树遍历与用户线程一起执行,从而减少用户线程暂停时间,不过缺点也显而易见:CPU敏感,会产生浮动垃圾,有内存碎片。
7.G1 *******
G1收集器与以往的垃圾收集器不同,他并没有直接将堆区分成了新生代老年代,而是将堆划分成了一个一个的内存块,内存块可能是新生代,也可能是老年代。并且额外增加了humongous用来保存大对象。这样做的好处是新生代与老年代的大小不再固定,并且若某一内存块很多对象需要进入老年代,直接将内存块标记为老年代即可,减少了对象复制的开销。
算法:新生代采用复制算法,老年代采用标记整理算法。
特点:可以设置最大暂停时间,每次GC会选择暂停时间左右效率最高的内存区域。
步骤:
1.初始标记:单线程标记所有GCRoots直接指向的对象,因为不会涉及到可达树的遍历,所以非常快,此时会停止其他用户线程。
2.并发标记:与其他用户线程一起执行,标记1可达的对象。同时标记并发标记中产生的对象(认为不是垃圾),划一块内存区域,用来保存并发标记产生对象的指针。
3.多线程最终标记:停止其他用户线程
4.多线程并发清除:停止其他用户线程
CMS与G1的选择
G1优点较多,但CMS不一定不G1差,例如G1为了存放并发标记产生的对象需要占用内存进行存放。
并发标记中的三色标记
黑色:根对象,以及当前对象和子对象都标记完成,或者没有子对象,则当前对象为黑色,表示扫描完成且不会被GC
灰色:扫描完当前对象,但仍有子对象没有进行标记,
白色:所有对象初始为白色,扫描后仍为白色表示对象没有被可达,可以回收。
如何解决并发标记的漏标问题?
场景:再并发标记时,线程A已经完成了标记,线程B仍在标记。此时用户线程将B正在标记的可达树下B未标记的对象置为null,并又将此对象的指针交给了A扫描过的对象。此时因线程A已经扫描结束,线程B扫描不到这个对象,那这个不应该回收的对象就会被回收。
CMS解决办法:增量更新,若发现一个白色对象被黑色对象引用,则将黑色对象置为灰,垃圾回收器发现节点为灰,则会从头再次扫描。
G1解决办法:STAB(快照),在并发标记前拍一个快照信息,若在标记的时候发先有一个引用消失了,则将快照信息推送到GC的堆栈内,则快照内的引用还会存在。
特点:G1的解决方式会产生更多的浮动垃圾,不过不需要像CMS一样重新扫描。.
安全点和安全区域
无论什么垃圾收集器,都会出现需要暂停用户线程的时间段,为了让程序正确运行,用户线程只有运行到安全点,才会被暂停。安全点包括:
1.方法调用前
2.方法返回后
3.循环末尾
4.抛出异常未知。
安全区域:若线程进入了sleep或blocked,此时线程无法进入安全点,则认为此时线程处于安全区域,处于安全区域的线程,只有再用户线程的暂停结束后,才能继续执行。