本篇文章将从三点切入:什么是垃圾,什么时候回收,如何回收。
1.什么是垃圾:
运行过程中没有任何指针指向的对象,就是需要被回收的垃圾。那么jvm是怎样判别哪个是没有被指向的对象呢?
1)引用计数算法:
在对象中添加一个引用计数器,有地方引用时+1,引用失效-1。但Java中不用该算法,是因为其无法解决循环引用的问题。比如两对象互相引用着,那么他们的计数器始找不可能为0,也就不会被当作垃圾。
2)可达性分析算法:
通过一些列称为“GC Roots”的根对象作为起始节点集,从这些结点开始根据引用关系向下搜索,能直接或间接的被搜索到的都是存货对象。当某个对象不可达,则被判定为可回收对象。
那么哪些对象可被当作GC Roots呢?(全局性的引用与执行上下文)
1.虚拟机栈中局部变量表引用的对象/本地方法栈中Native方法引用的对象。
2.方法区中类静态属性引用的对象/方法区中常量引用的对象。
3.虚拟机内部的引用:如基本数据类型对应的class对象,常驻的异常对象,类加载器。
4.被同步锁持有的对象。
5.Java堆中的某一块区域发起垃圾收集,其他不同区域作为GC Roots。如老年代引用着新生代中的对象。
2.什么时候回收:
其实这个问题要结合着1问题来看:由于类,常量太多了,HotSpot中使用了一组称为OopMap的数据结构来得到合适的GC Roots集。标记GC Roots能直接关联的对象的这个工作我们将其称为根节点枚举,在这个过程中是必须要暂停用户线程的。但是线程不能说停就停,必须到一个安全点才可以进行刚才我们所说的根节点枚举的工作。Java采用的是主动式中断用户线程来实现的,其具体思想史是:设置一标志位,各个线程主动的轮询这个标志位,一旦发现标志位为真我们就在最近的安全点上主动中断挂起。
3.如何回收:
这一部分主要说两个内容,一个是如何回收的思想,也就是算法,另一个是算法的具体实现,也就是具体的垃圾回收器。
算法:
1):标记-清除算法:
首先标记出所有需要回收的对象(即可达性算法),标记完成后,统一回收所有被标记的对象。
缺点:1.若大量对象都需要被回收,那么标记和清除两个过程的执行效率就太低了。
2.清除后会产生大量不连续的内存碎片。若我们想再放一个大对象但却找不到合适的内存空间,这就不得不又触发一次GC。
3.因为有内存碎片,所以只能采用空闲列表的方式分配内存。
2):标记-复制算法:
将可用内存分为两个区域,每次存放对象仅使用其中的一块。当有对象的区域填满时,就将该区域所有的存活对象移至另一块区域,再将已使用过的一次性清理完。
优点:1.当大量对象都需要被回收,那么需要复制的只是占少数的对象,并且回收是对半个区进行回收。
2.无内存碎片,采用指针碰撞的方式分配内存。
缺点:1.可用内存变为原来的一半。(对于新生代来说,大部分对象都是朝生夕死的,所以不需要1:1来划分新生代空间。将新生代分为Eden,Survior1,Survior2,比例为8:1:1,老年代作为逃生门/存活时间长的对象)。
2.当大量对象都是存活的,会产生大量的内存间复制开销。
3):标记-整理算法:
针对老年代的死亡特征,将回收区域的所有存活对象标记出来,让其向内存空间一端移动,然后直接清除掉边界外的内存。
优点:无内存碎片。缺点:大量对象存活时,所需STW时间较大。
实现(垃圾收集器):
首先了解垃圾收集器的分类,然后再说具体的垃圾收集器。
根据是否需要STW(Stop The World)分为独占式和并发式:独占式就是只有垃圾收集器工作,而并发式则是用户线程和垃圾收集器交替工作。
独占式又可分为串行和并行:串行就是仅有一条垃圾收集器工作,而并行则是多条垃圾收集器工作。这两个都有STW.
根据内存空间又可分为:新生代垃圾回收器或老年代垃圾收集器。
接下来就是具体的垃圾收集器了:
1.Serial:是新生代中的收集器,采用单线程串行收集,复制算法进行垃圾回收。没有线程交互的开销,可获得最高的单线程收集效率。是客户端模式很好的选择
2.ParNew:是新生代中的收集器,采用多线程并行收集,复制算法进行垃圾回收。除了串并行,与Serial无太对差别。
3.Parallel:是新生代中的收集器,采用多线程并行收集,复制式算法进行垃圾回收。它最大的特点式可控制吞吐量,自适应调节策略
4.Serial Old:是老年代中的收集器,采用单线程串行收集,标记-整理算法。可与Serial,ParNew(9之前),Parallel(6前)搭配使用。
5.Parallel Old:是老年代中的收集器,多线程并行收集。6之前Parallel只能与Serial Old搭配使用,因为一个是并行一个是串行,所以并不能实现吞吐量的最大化。
6.CMS:是老年代中的收集器,多线程并发,标记-清除算法。因为清除时没有内存碎片的整理,所以没有STW。以下是其具体步骤。
还需要注意的是,并发清除是并行的,该阶段还会有新的对象进来,所以不能让老年代放慢了才开始回收。在JDK5下默认使用68%就激活CMS,6提升至92%。但仍存在预留空间不够新对象存储,就会出现并发示标,此时就启动后备方案(Serial Old)。
不可避免的还有内存碎片的问题:多次进行CMS后内存不够则触发FULL GC,在下一次Full GC前进行一次碎片整理。
7.G1
两个代都可以起作用。原因是其将Java堆分为多个大小相同的Region,注意此时堆仍然有分代的思想,不过是不要求每个代的内存的是连续的。G1跟踪各个Region里垃圾堆价值的大小,在后台维护一个优先级表,每次根据用户设置的停顿时间优先处理价值大的Region。
根据以上的描述我们可以得出G1的三个优点:
1.因为其设置的的是Region优先级表,所以可以同时兼顾新生代和老年代。
2.每次处理的都是Region的整数倍,就是可预测的停顿时间。
3.优先处理价值大的Region提高了执行效率。
具体步骤:
1.初始标记:对用户线程做短暂暂停,标记GC Roots能直接关联到的对象,并修改TAMS指针。
2.并发标记:从GC Roots进行可达性分析,与用户线程并发执行,并重新处理SATB记录下的在并发时有引用变动的对象。
3.最终标记:对用户线程做短暂暂停,处理并发时有变动的对象。
4.筛选回收:对用户线程做短暂暂停,对Region的回收价值和成本进行排序,根据用户希望的停顿时间选择多个Region回收集,把决定回收Region内的存活对象移至空Region,再清理。
那G1是如何解决以下问题呢?
1.Region里存在跨Region引用的对象:如老年代引用了新生代对象
使用记忆集避免全堆作为GC Roots扫描,G1中使用的是卡表,存储了哪个Region引用了当前Region里的对象,就避免了全局扫描
2.并发标记中如何保证用户线程改变对象引用关系/添加新的对象时,对堆里的对象正确标记。即怎样缺点第三步的变动对象?
首先我们先来分析错误是如何产生的。(三色标记)
黑色:该对象已经被标记过且其所有的引用也已经被扫描了。
灰色:该对象已经被扫描了,但其引用至少还有一个未被扫描。
蓝色:未被扫描的对象。
当完成所有的扫描之后,结果应如图所示
那么怎样能出现错误呢?
那么扫描完成后结果将是这样:
(改错:视为)
我们可以看出,出现这种错误必须有两点:1.插入例如一条或多条从黑色对象到蓝色对象的引用。2.删除了全部灰色对象到该蓝色对象的直接或间接引用。只要打破两者间的一个那么错误就不会出现。
增量更新:将该黑色对象插入的引用记录下来,并发扫描结束后,重新扫描时将记录的黑色对象作为GC Roots再一次扫描。(CMS)
原始快照:将该灰色对象删除的引用记录下来,并发扫描结束后,重新扫描时将记录的灰色对象作为GC Roots再一次扫描。(G1)
以上只解决了改变引用,没有解决添加对象:
为每个Region设计两个名为TAMS指针,默认添加对象只能在这两个指针之间添加,且默认该区域不会作为回收范围。