引言
作为Java程序猿,开发过程中根本不必care垃圾回收的事情,但是当需要排查内存溢出、内存泄漏的问题,或者垃圾收集成为系统达到更高并发量的瓶颈是,就需要对这些“自动化”的技术实施必要的监控和调节。另外作为面试必问,还是很有必要掌握的。这篇文章介绍
1.哪些内存需要回收
2.垃圾回收算法
3.HotSpot 什么时候发起回收动作
4.几种常用的垃圾收集器是怎么回收的
怎么判定对象死了
我们知道,垃圾回收主要发生在堆(Heap)中,程序在运行期才会创建对象,在某个时刻,对象可能孤立无用了,如果不处理这些无用对象,堆迟早会撑爆,这部分的内存回收就是垃圾收集。
那怎么判定对象已死,或者说对象“无用”呢?有两种算法判定,
1.引用计数算法:
这个简单来说,就是给对象中添加一个引用计数器,每当有一个地方引用它,计数器+1;引用失效时,计数器-1;当对象的计数器为0的时候,这个对象就不可用了。这个 算法简单高效,但是不能解决循环引用的问题:比如objA引用objB, objB又引用ObjA,但是并没有其他地方用到这两个对象,所以这两个对象都应该回收;但是如果是引用计数算法,这两个对象的引用计数器值都大于0,没法回收。
2.可达性分析算法
这个算法可以这样描述:先规定一系列“GC Roots”作为起点,如果对象直接或者间接引用到这些起点,则判定对象存活;如果对象引用不到这些起点,则判定这些对象是可回收的;从起点到对象中间走过的路径成为引用链。
在Java语言中,可作为起点,即“GC Roots”的对象包括
a.虚拟机栈(栈帧中的本地变量表)中引用的对象
b.方法区中类静态变量引用的对象
c.方法区中常量引用的对象
d.本地方法栈中JNI(Native)方法引用的对象
即使一个对象在可达性分析算法中判定可回收了,也不是“非死不可的”,它们暂处“缓刑”,要真正宣告对象死亡,至少要经历两次标记过程:如果 在可达性分析后发现没有与GC Roots相连接的引用链,那么它先会被打上标记,然后看这个对象是否重写了finalize()方法,如果重写了的finalize()没有执行过,并且在finalize()方法中这个对象重新连上了GC Roots的引用链(相当于自证清白),则这个对象自救成功,此次不必回收了。即使如此,也只有这一次自救机会,因为任何对象的finalize()方法只会被系统调用一次,当然并不鼓励用这种方式“拯救”对象。
垃圾收集算法
在介绍垃圾收集器之前,先了解一下垃圾收集算法的思想,因为垃圾收集器就是使用这些算法来回进行收垃圾的
1.标记-清除算法:
过程如下:先找出堆中所有需要回收的对象(可达性分析算法),打上标记,然后再统一回收被标记的对象,如图:
这是最基础收集算法,后续的收集算法都是基于这种思路改进的。它存在两个明显的不足:一是效率问题,标记和清除两个过程效率都不高;二是这样清除之后空间不连续,如果后面需要给大对象分配内存时,没有足够的空间,这样会提交触发另一次垃圾收集。
2.复制算法
复制算法解决了标记-清除算法的效率问题,算法描述如下:将可用内存分为大小相等两部分A和B,每次只使用A,当A的内存不足以给新来的对象分配内存时,就把存活的对象复制到了B上面,然后再把A清空。这样解决了内存碎片的问题,复制的效率也比清除高;代价就是牺牲了一般的内存。如图:
基于HotSpot,在新生代中,绝大部分对象是“朝生暮死”的,所以JVM把新生代内存按 8 : 1 : 1 分为Eden区和两块Survivor区(Survivor From 和Survivor To),回收时将Eden和Survivor中活着的对象一次性复制到另一个Survivor中,然后清理掉Eden和刚才用过的Survivor空间。
缺点就是浪费10%的空间,另外如果对象存活率较高时,要么另一个Survivor放不下(需要老年代分配担保,放到后面的文章中讲),要么复制就很费劲了。
3.标记-整理算法
这个算法和标记-清除算法类似:先标记出需要回收的对象,然后让所有存活的对象都向着一端移动,最后清理掉端边界以为的内存。
好像不太好理解,好比在内存中画一条线,左边有需要回收的对象和存活的对象,右边是没有用过的内存区域,然后把左边存活的对象移到右边,最后清理掉左边的内存区域。看图:
这样就解决了 复制算法 浪费的10%的以及空间不足的问题了。
4.分代收集算法
这个不是什么新鲜玩意,就是根据各个年代(新生代,老年代)的不同特性采用不同的收集算法而已。比如新生代,每次垃圾收集时有大批对象可回收,就用复制算法;老年代中对象存活率高,没有额外空间进行分配担保,就是用“标记-清除”或者“标记-整理”算法。
HotSpot怎么发起垃圾收集
前面说到,收集垃圾前要先找到哪些对象可回收,从可达性分析算法找引用链这个操作为例,很多应用仅仅方法区就有数百兆,如果要逐个检查里面的引用,那必然会消耗很多时间。另外在标记和回收的过程中,如果对象的引用关系还在不断发生变化,那么准确性就得不到保障。针对这两个问题,HotSpot的解决方案是,先“Stop The World”,停顿所有的Java线程,然后使用一组称为OopMap的数据结构,计算并保存对象内哪个位置是什么数据类型,就可以知道哪里存放这对象引用,进而找到不可用对象。
那么问题来了,什么时候Stop The World呢?(不足以给对象分配内存空间的时候,废话!)细化到代码层面,通常当代码执行到 a.循环的末尾;b.方法临返回前;c.调用方法之后;d.抛异常的位置 ,这些位置时STW(Stop The World),这些位置成为***安全点***。在GC发生时,首先中断所有线程,如果有线程中断的地方不在安全点上,就恢复线程,让它“跑”到安全点上再挂起。
好像还有一个问题:假如线程处于sleep或者Blocked状态呢?没法“跑”到安全点再中断挂起啊,显然JVM也不可能等线程分配到CPU时间片去执行。这种情况下需要***安全区***来解决。安全区可以看成是扩展了的安全点,线程执行到安全区的代码时,如果这时候发生GC,就不管这个线程了;在线程要离开安全区时,它要检查系统是否完成了找无用对象(或者整个GC过程),如果完成了就继续执行,没有完成就等着吧,收到可以离开安全区的信号才能继续执行。
几种垃圾收集器
上面简单介绍了HotSpot如何去发起内存回收,但是如何具体进行内存回收动作仍未涉及,因为内存如何回收取决于虚拟机采用哪种GC收集器,但是虚拟机中往往不止一种GC收集器,比如HotSpot中就有这么多GC收集器,作用于不同分代,收集器间配合使用关系(重点了解CMS收集器和G1收集器)看图:(两个收集器间存在连线,说明可以搭配使用)
我们重点在CMS和G1收集器,其他的简单介绍一下,混个脸熟
1.Serial收集器,最老的,单线程收集器,作用于新生代,采用复制算法,它进行垃圾收集的时候,必须暂停其他所有的工作线程。
2.ParNew收集器,Serial收集器的多线程版本,作用于新生代,复制算法
3.Prarallel Scavenge收集器,多线程,作用于新生代,复制算法
4.Serial Old收集器,Serial的老年代版本,单线程,作用于老年代,使用标记-整理算法
5.Parallel Old收集器,Parallel Scavenge的老年代版本,作用于多线程,使用标记-整理算法
6.CMS收集器,作用于老年代,多线程并发,使用标记-清除算法
目标:获取最短回收停顿时间(Stop The World时间尽量短)
怎么做的呢?(初始标记和重新标记仍需STW)分为下面4步
a.初始标记(需要Stop The World)
先标记GC Roots直接关联的对象,速度很快
b.并发标记
GC tracing过程,找引用链
c.重新标记(需要Stop The World)
修正并发标记阶段因程序继续运行而导致标记变动的一部分对象的标记记录
d.并发清除
标记-清除算法,清除无用对象
这几个步骤的所花费的时间顺序为:
并发清除 > 并发标记 >> 重新标记 > 初始标记
花费时间最长的两部分都可以与用户线程一起工作,算是一款优秀的收集器了,接下来来看下CMS收集器的运行示意图:
但是CMS收集器有下面3个缺点
- 因为是并发,所以堆CPU依赖大。CMS默认回收线程数是(CPU数量+3)/4, 所以并发回收垃圾时会至少占用25%的CPU资源
- 并发清除过程中,程序还在运行,会产生新的浮动垃圾,这次清理过程中没法处理
- 它是基于标记-清除算法的,会产生内存碎片
G1收集器
Garbage-First,号称最牛逼的垃圾收集器,面向服务端应用,在未来比较长的时间内代替CMS收集器,来看看它是怎样工作的:
首先把整个Java堆分成多个大小相等的独立区域(Region),新生代,老年代不在是物理隔离的,只是一部分不需要连续的Region的集合。然后每个Region有一个对应的记忆集 Remembered Set,用来记录这个Region中对象间被其他的对象引用了,比如Region1中的ObjA对象引用了Region2中的ObjB对象了,则Region2对应的Remembered Set会记录相关的引用信息;如果是同一个Region中的对象互相引用就不记录了。这样在进行内存回收时,在找引用链的时候加上Remembered Set即可保证不进行全堆扫描也不会有遗漏。在垃圾回收时,G1会优先回收价值最大的Region(后台会维护一个优先列表,记录Region回收所获得的空间大小以及所需时间)
如果不算维护Remembered Set的操作,G1收集器的运作大致可以分为以下四步:
- 初始标记
- 并发标记
- 最终标记
- 筛选回收
好像和CMS有点像哦,他的流程是这样的:
初始标记阶段,标记GC Roots直接关联的对象,并让下一阶段用户程序运行时,能在正确可用的Region中创建新对象,这阶段需要STW,但耗时很短
并发标记,从GC Roots开始对堆中对象进行可达性分析,找出存活对象,耗时较长,但是与用户程序并发进行(这期间创建的新对象分配在指定的Region中了)
最终标记,修正在并发标记期间由于用户程序继续运行导致标记产生变动的那一部分标记记录,虚拟机把这段时间对象变化记录在线程Remembered Set Logs中,最终标记阶段需要吧Remembered Set Logs的数据合并到Remembered Set 中,这阶段需要STW,但是可并行执行。
筛选阶段
先对各个Region回收按价值排序,根据用户所期望的GC停顿时间来制定回收计划。还是看图:
关于垃圾收集就到这里,下篇文章聊聊内存分配与回收策略