3y:淦,已经熬到了第29面了

3y:上次已经讲完了JVM的内存结构了

3y:但是堆内存那块还没完全补充

3y:要讲那块就得涉及垃圾回收机制的概念了

3y:垃圾回收机制在JVM中是非常重要的,估计是一场持久的面试了。

3y:面试官你好,请问面试可以开始了吗?

面试官:嗯,开始吧

面试官:我还记得上次你讲到JVM内存结构(运行时数据区域)提到了「堆」,然后你说是分了几块区域嘛

面试官:当时感觉再讲下去那我可能就得加班了

面试官:今天有点空了,继续聊聊那块吧

3y:嗯,前面提到了堆分了「新生代」和「老年代」,「新生代」又分为「Eden」和「Survivor」区,「survivor」区又分为「From Survivor」和「To Survivor」区

【对线面试官】垃圾回收机制文字版 - 图1

3y:说到这里,我就想聊聊Java的垃圾回收机制了

面试官:那你开始你的表演吧

3y:我们使用Java的时候,会创建很多对象,但我们未曾「手动」将这些对象进行清除

3y:而如果用C/C++语言的时候,用完是需要自己free(释放)掉的

3y:那为什么在写Java的时候不用我们自己手动释放”垃圾”呢?原因很简单,JVM帮我们做了(自动回收垃圾)

面试官:…

3y:我个人对垃圾的定义:只要对象不再被使用了,那我们就认为该对象就是垃圾,对象所占用的空间就可以被回收

面试官:那是怎么判断对象不再被使用的呢?

3y:常用的算法有两个「引用计数法」和「可达性分析法」

3y:引用计数法思路很简单:当对象被引用则+1,但对象引用失败则-1。当计数器为0时,说明对象不再被引用,可以被可回收

3y:引用计数法最明显的缺点就是:如果对象存在循环依赖,那就无法定位该对象是否应该被回收(A依赖B,B依赖A)

面试官:…

3y:另一种就是可达性分析法:它从「GC Roots」开始向下搜索,当对象到「GC Roots」都没有任何引用相连时,说明对象是不可用的,可以被回收

3y:「GC Roots」是一组必须「活跃」的引用。从「GC Root」出发,程序通过直接引用或者间接引用,能够找到可能正在被使用的对象

面试官:还是不太懂,那「GC Roots」一般是什么?你说它是一组活跃的引用,能不能举个例子,太抽象了。

3y:比如我们上次不是聊到JVM内存结构中的虚拟机栈吗,虚拟机栈里不是有栈帧吗,栈帧不是有局部变量吗?局部变量不就存储着引用嘛。

3y:那如果栈帧位于虚拟机栈的栈顶,是不是就可以说明这个栈帧是活跃的(换言之,是线程正在被调用的)

3y:既然是线程正在调用的,那栈帧里的指向「堆」的对象引用,是不是一定是「活跃」的引用?

3y:所以,当前活跃的栈帧指向堆里的对象引用就可以是「GC Roots」

面试官:…

3y:当然了,能作为「GC Roots」也不单单只有上面那一小块

3y:比如类的静态变量引用是「GC Roots」,被「Java本地方法」所引用的对象也是「GC Roots」等等…

3y:回到理解的重点:「GC Roots」是一组必须「活跃」的「引用」,只要跟「GC Roots」没有直接或者间接引用相连,那就是垃圾

3y:JVM用的就是「可达性分析算法」来判断对象是否垃圾

面试官:懂了

3y:垃圾回收的第一步就是「标记」,标记哪些没有被「GC Roots」引用的对象

【对线面试官】垃圾回收机制文字版 - 图2

3y:标记完之后,我们就可以选择直接「清除」,只要不被「GC Roots」关联的,都可以干掉

3y:过程非常简单粗暴,但也存在很明显的问题

3y:直接清除会有「内存碎片」的问题:可能我有10M的空余内存,但程序申请9M内存空间却申请不下来(10M的内存空间是垃圾清除后的,不连续的)

【对线面试官】垃圾回收机制文字版 - 图3

3y:那解决「内存碎片」的问题也比较简单粗暴,「标记」完,不直接「清除」。

3y:我把「标记」存活的对象「复制」到另一块空间,复制完了之后,直接把原有的整块空间给干掉!这样就没有内存碎片的问题了

3y:这种做法缺点又很明显:内存利用率低,得有一块新的区域给我复制(移动)过去

面试官:…

3y:还有一种「折中」的办法,我未必要有一块「大的完整空间」才能解决内存碎片的问题,我只要能在「当前区域」内进行移动

3y:把存活的对象移到一边,把垃圾移到一边,那再将垃圾一起删除掉,不就没有内存碎片了嘛

3y:这种专业的术语就叫做「整理」

【对线面试官】垃圾回收机制文字版 - 图4

3y:扯了这么久,我们把思维再次回到「堆」中吧

3y:经过研究表明:大部分对象的生命周期都很短,而只有少部分对象可能会存活很长时间

3y:又由于「垃圾回收」是会导致「stop the word」(应用停止访问)

3y:理解「stop the word」应该很简单吧:回收垃圾的时候,程序是有短暂的时间不能正常继续运作啊。不然JVM在回收的时候,用户线程还继续分配修改引用,JVM怎么搞(:

3y:为了使「stop the word」持续的时间尽可能短以及提高并发式GC所能应付的内存分配速率

3y:在很多的垃圾收集器上都会在「物理」或者「逻辑」上,把这两类对象进行区分,死得快的对象所占的区域叫做「年轻代」,活得久的对象所占的区域叫做「老年代」

面试官:…

3y:但也不是所有的「垃圾收集器」都会有,只不过我们现在线上用的可能都是JDK8,JDK8及以下所使用到的垃圾收集器都是有「分代」概念的。

3y:所以,你可以看到我的「堆」是画了「年轻代」和「老年代」

3y:要值得注意的是,高版本所使用的垃圾收集器的ZGC是没有分代的概念的(:

3y:只不过我为了好说明现状,ZGC的话有空我们再聊

面试官:…

3y:在前面更前面提到了垃圾回收的过程,其实就对应着几种「垃圾回收算法」,分别是:

3y:标记清除算法、标记复制算法和标记整理算法【「标记」「复制」「整理」】

3y:经过上面的铺垫之后,这几种算法应该还是比较好理解的

面试官:…

3y:「分代」和「垃圾回收算法」都搞明白了之后,我们就可以看下在JDK8生产环境及以下常见的垃圾回收器了

3y:「年轻代」的垃圾收集器有:Seria、Parallel Scavenge、ParNew

3y:「老年代」的垃圾收集器有:Serial Old、Parallel Old、CMS

3y:看着垃圾收集器有很多,其实还是非常好理解的。Serial是单线程的,Parallel是多线程

3y:这些垃圾收集器实际上就是「实现了」垃圾回收算法(标记复制、标记整理以及标记清除算法)

3y:CMS是「JDK8之前」是比较新的垃圾收集器,它的特点是能够尽可能减少「stop the word」时间。在垃圾回收时让用户线程和 GC 线程能够并发执行!

面试官:…

3y:又可以发现的是,「年轻代」的垃圾收集器使用的都是「标记复制算法」

3y:所以在「堆内存」划分中,将年轻代划分出Survivor区(Survivor From 和Survivor To),目的就是为了有一块完整的内存空间供垃圾回收器进行拷贝(移动)

3y:而新的对象则放入Eden区

3y:我下面重新画下「堆内存」的图,因为它们的大小是有默认的比例的

【对线面试官】垃圾回收机制文字版 - 图5

3y:图我已经画好了,应该就不用我再说明了

面试官:嗯,可以的

面试官:我还想问问,就是,新创建的对象一般是在「新生代」嘛,那在什么时候会到「老年代」中呢?

3y:嗯,我认为简单可以分为两种情况:

3y:1. 如果对象太大了,就会直接进入老年代(对象创建时就很大 || Survivor区没办法存下该对象)

3y:2. 如果对象太老了,那就会晋升至老年代(每发生一次Monor GC ,存活的对象年龄+1,达到默认值15则晋升老年代 || 动态对象年龄判定 可以进入老年代)

面试官:…

面试官:既然你又提到了Monor GC,那Monor GC 什么时候会触发呢?

3y:当Eden区空间不足时,就会触发Monor GC

面试官:Monor GC 在我的理解就是「年轻代」的GC,你前面又提到了「GC Roots」嘛

面试官:那在「年轻代」GC的时候,从GC Roots出发,那不也会扫描到「老年代」的对象吗?那那那..不就相当于全堆扫描吗?

3y:这JVM里也有解决办法的。

3y:HotSpot 虚拟机「老的GC」(G1以下)是要求整个GC堆在连续的地址空间上。

3y:所以会有一条分界线(一侧是老年代,另一侧是年轻代),所以可以通过「地址」就可以判断对象在哪个分代上

3y:当做Monor GC的时候,从GC Roots出发,如果发现「老年代」的对象,那就不往下走了(Monor GC对老年代的区域毫无兴趣)

面试官:…

面试官:但又有个问题,那如果「年轻代」的对象被「老年代」引用了呢?(老年代对象持有年轻代对象的引用),那时候肯定是不能回收掉「年轻代」的对象的。

3y:HotSpot虚拟机下 有「card table」(卡表)来避免全局扫描「老年代」对象

3y:「堆内存」的每一小块区域形成「卡页」,卡表实际上就是卡页的集合。当判断一个卡页中有存在对象的跨代引用时,将这个页标记为「脏页」

3y:那知道了「卡表」之后,就很好办了。每次Monor GC 的时候只需要去「卡表」找到「脏页」,找到后加入至GC Root,而不用去遍历整个「老年代」的对象了。

面试官:…

面试官:嗯嗯嗯,还可以的啊

面试官:要不继续聊聊CMS?

3y:这面试快一个小时了吧,我图也画了这么多了

3y:下次?下次吧?有点儿累了

今日总结

什么是垃圾:只要对象不再被使用,那即是垃圾

如何判断为垃圾:可达性分析算法和引用计算算法,JVM使用的是可达性分析算法

什么是GC Roots:GC Roots是一组必须活跃的引用,跟GC Roots无关联的引用即是垃圾,可被回收

常见的垃圾回收算法:标记清除、标记复制、标记整理

为什么需要分代:大部分对象都死得早,只有少部分对象会存活很长时间。在堆内存上都会在物理或逻辑上进行分代,为了使「stop the word」持续的时间尽可能短以及提高并发式GC所能应付的内存分配速率。

Monor GC:当Eden区满了则触发,从GC Roots往下遍历,年轻代GC不关心老年代对象

什么是card table【卡表】:空间换时间(类似bitmap),能够避免扫描老年代的所有对应进而顺利进行Monor GC (案例:老年代对象持有年轻代对象引用)

堆内存占比:年轻代占堆内存1/3,老年代占堆内存2/3。Eden区占年轻代8/10,Survivor区占年轻代2/10(其中From 和To 各站1/10)

参考资料