一、判断垃圾对象的方法
引用计数法
给对象中添加一个引用计数器,每当有一个地方引用它,计数器就加1;当引用失效,计数器就减1;任何时候计数器为0 的对象就是不可能再被使用的。
这个方法实现简单,效率高,但是目前主流的虚拟机中并没有选择这个算法来管理内存,其最主要的原因是它很难解决 对象之间相互循环引用的问题。 所谓对象之间的相互引用问题,如下面代码所示:除了对象objA 和 objB 相互引用着对方之外,这两个对象之间再无任何引用。但是他们因为互相引用对方,导致它们的引用计数器都不为0,于是引用计数算法无法通知 GC 回收器回收他们。
public class ReferenceCountingGc { Object instance = null; public static void main(String[] args) { ReferenceCountingGc objA = new ReferenceCountingGc(); ReferenceCountingGc objB = new ReferenceCountingGc(); objA.instance = objB; objB.instance = objA; objA = null; objB = null; } }
可达性分析算法
将 “GC Roots” 对象作为起点,从这些节点开始向下搜索引用的对象,找到的对象都标记为 非垃圾对象 ,其余未标记的对象都是垃圾对象
GC Roots 根节点:
1. 虚拟机栈中引用的对象(栈帧中的局部变量表)
2. 本地方法栈JNI(Java Native Interface)引用的对象
3. 方法区类静态属性引用的对象
4. 方法区常量池引用的对象
5. 活跃线程引用的对象
二、常见引用类型
java的引用类型一般分为四种: 强引用 、 软引用 、弱引用、虚引用
强引用 :普通的变量引用
public static User user = new User();
软引用:将对象用SoftReference软引用类型的对象包裹,正常情况不会被回收,但是GC做完后发现释放不出空间存放新的对象,则会把这些软引用的对象回收掉。软引用可用来实现内存敏感的高速缓存。
public static SoftReference<User> user = new SoftReference<User>(new User());
软引用在实际中有重要的应用,例如浏览器的后退按钮。按后退时,这个后退时显示的网页内容是重新进行请求还是从缓存中取出呢?这就要看具体的实现策略了。
(1)如果一个网页在浏览结束时就进行内容的回收,则按后退查看前面浏览过的页面时,需要重新构建
(2)如果将浏览过的网页存储到内存中会造成内存的大量浪费,甚至会造成内存溢出
弱引用 :将对象用WeakReference软引用类型的对象包裹,弱引用跟没引用差不多, GC会直接回收掉 ,很少用
public static WeakReference<User> user = new WeakReference<User>(new User());
虚引用: 虚引用也称为幽灵引用或者幻影引用,它是最弱的一种引用关系,几乎不用
三、finalize()方法最终判定对象是否存活
即使在可达性分析算法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历再次标记过程。
标记的前提是对象在进行可达性分析后发现没有与GC Roots相连接的引用链。
1. 第一次标记并进行一次筛选。
筛选的条件是此对象是否有必要执行finalize()方法。当对象没有覆盖finalize方法,对象将直接被回收。
2. 第二次标记
如果这个对象覆盖了finalize方法,finalize方法是对象脱逃死亡命运的最后一次机会,如果对象要在finalize()中成功拯救自己,只要重新与引用链上的任何的一个对象建立关联即可,譬如把自己赋值给某个类变量或对象的成员变量,那在第二次标记时它将移除出“即将回收”的集合。如果对象这时候还没逃脱,那基本上它就真的被回收了。
注意:一个对象的finalize()方法只会被执行一次,也就是说通过调用finalize方法自我救命的机会就一次。
四、如何判断一个类是无用的类
方法区主要回收的是无用的类,那么如何判断一个类是无用的类的呢?
类需要同时满足下面3个条件才能算是 “无用的类” :
1. 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。
2. 加载该类的 ClassLoader 已经被回收。
3. 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
五、垃圾回收算法
分代收集算法
按照对象生命周期的不同划分区域以采用不同的垃圾回收算法。几乎所有的Java堆都采用这种算法。
复制算法
将内存划分为对象面和空闲面。当对象面的内存用完时,就将对象面中活中的对象复制到空闲面内存中,然后将对象面的对象内存清除。
特点:不会产生内存碎片,由于是使用连续的内存分配来分配,使得它简单高效,“标记-清除”或“标记-整理”算法会比复制算法慢10倍以上。复制算法适用于对象存活率低的场景,比如年轻代的垃圾回收。需用使用备份内存来处理发生空闲面内存不够用的情况。
标记-清除算法
用可达性算法找到垃圾对象进行标记后,会对堆内存从头到尾进行线性遍历,回收不可达的对象。然后将原来标记为可达对象的标识给清除,以便下一次回收。
缺点:效率较低,无论是标记和清除的效率都不高。容易造成内存碎片化,当有大对象需要分配空间时,容易造成无法找到足够大的连续内存而触发另一次GC.
标记-整理算法
用可达性算法找到垃圾对象进行标记后,移动所有存活的对象,且按照内存地址次序排序,然后将末端内存地址以后的内存全部回收。它回收成本更高,但是不产生内存碎片。适用于对象存活率较高的场景。
六、垃圾收集器
如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。
虽然我们对各个收集器进行比较,但并非为了挑选出一个最好的收集器。因为直到现在为止还没有最好的垃圾收集器出现,更加没有万能的垃圾收集器, 我们能做的就是根据具体应用场景选择适合自己的垃圾收集器 。试想一下:如果有一种四海之内、任何场景下都适用的完美收集器存在,那么我们的Java虚拟机就不会实现那么多不同的垃圾收集器了。
1.1 Serial收集器(-XX:+UseSerialGC -XX:+UseSerialOldGC)
Serial(串行)收集器是最基本、历史最悠久的垃圾收集器了。大家看名字就知道这个收集器是一个单线程收集器了。它的 “单线程” 的意义不仅仅意味着它只会使用一条垃圾收集线程去完成垃圾收集工作,更重要的是它在进行垃圾收集工作的时候必须暂停其他所有的工作线程( "Stop The World" ),直到它收集结束。
新生代采用复制算法,老年代采用标记-整理算法。
虚拟机的设计者们当然知道Stop The World带来的不良用户体验,所以在后续的垃圾收集器设计中停顿时间在不断缩短(仍然还有停顿,寻找最优秀的垃圾收集器的过程仍然在继续)。
但是Serial收集器有没有优于其他垃圾收集器的地方呢?当然有,它 简单而高效(与其他收集器的单线程相比) 。Serial收集器由于没有线程交互的开销,自然可以获得很高的单线程收集效率。
Serial Old收集器是Serial收集器的老年代版本 ,它同样是一个单线程收集器。它主要有两大用途:一种用途是在JDK1.5 以及以前的版本中与Parallel Scavenge收集器搭配使用,另一种用途是作为CMS收集器的后备方案 。
1.2 Parallel Scavenge收集器(-XX:+UseParallelGC(年轻代),-XX:+UseParallelOldGC(老年代))
Parallel 收集器其实 就是Serial收集器的多线程版本 ,除了使用多线程进行垃圾收集外,其余行为(控制参数、收集算法、回收策略等等)和Serial收集器类似。默认的收集线程数跟cpu核数相同,当然也可以用参数(-XX:ParallelGCThreads)指定收集线程数,但是一般不推荐修改。
Parallel Scavenge收集器关注点是吞吐量(高效率的利用CPU)。CMS等垃圾收集器的关注点更多的是用户线程的停 顿时间(提高用户体验)。所谓吞吐量就是CPU中用于运行用户代码的时间与CPU总消耗时间的比值。 Parallel Scavenge收集器提供了很多参数供用户找到最合适的停顿时间或最大吞吐量,如果对于收集器运作不太了解的话,可以选择把内存管理优化交给虚拟机去完成也是一个不错的选择。
新生代采用复制算法,老年代采用标记-整理算法。
Parallel Old收集器是Parallel Scavenge收集器的老年代版本 。使用多线程和“标记-整理”算法。在注重吞吐量以及CPU资源的场合,都可以优先考虑 Parallel Scavenge收集器和Parallel Old收集器(JDK8默认的新生代和老年代收集 器 )。
1.3 ParNew收集器(-XX:+UseParNewGC)
ParNew收集器其实跟Parallel收集器很类似 ,区别主要在于它可以和CMS收集器配合使用。
新生代采用复制算法,老年代采用标记-整理算法。
它是许多运行在Server模式下的虚拟机的首要选择,除了Serial收集器外,只有它能与CMS收集器(真正意义上的并发收集器,后面会介绍到)配合工作。
1.4 CMS收集器(-XX:+UseConcMarkSweepGC(old))
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。它非常符合在注重用户体 验的应用上使用,它是HotSpot虚拟机第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程 (基本上)同时工作。
从名字中的 Mark Sweep 这两个词可以看出,CMS收集器是一种 “标记-清除”算法 实现的,它的运作过程相比于前面几种垃圾收集器来说更加复杂一些。整个过程分为四个步骤:
初始标记: 暂停所有的其他线程(STW),并记录下gc roots 直接能引用的对象 , 速度很快 。
并发标记: 并发标记阶段就是从GC Roots的直接关联对象开始遍历整个对象图的过程, 这个过程耗时较长但是不需要停顿用户线程, 可以与垃圾收集线程一起并发运行。因为用户程序继续运行,可能会有导致已经标记过的对象状态发生改变。
重新标记: 重新标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短。主要用到三 色标记里的 增量更新算法(见下面详解)做重新标记。
并发清理: 开启用户线程,同时GC线程开始对未标记的区域做清扫。这个阶段如果有新增对象会被标记为黑色不做任何处理(见下面三色标记算法详解)。
并发重置: 重置本次GC过程中的标记数据。
从它的名字就可以看出它是一款优秀的垃圾收集器,主要优点: 并发收集、低停顿 。但是它有下面几个明显的缺点:
1. 对CPU资源敏感(会和服务抢资源);
2. 无法处理 浮动垃圾 (在并发标记和并发清理阶段又产生垃圾,这种浮动垃圾只能等到下一次gc再清理了);
3. 它使用的回收算法- “标记-清除”算法 会导致收集结束时会有 大量空间碎片 产生,当然通过参数-
XX:+UseCMSCompactAtFullCollection可以让jvm在执行完标记清除后再做整理。
4. 执行过程中的不确定性,会存在上一次垃圾回收还没执行完,然后垃圾回收又被触发的情况,特别是在并 发标记和并发清理阶段会出现 ,一边回收,系统一边运行,也许没回收完就再次触发full gc,也就是" concurrent mode failure ", 此时会进入stop the world,用serial old垃圾收集器来回收
CMS的相关核心参数
1. -XX:+UseConcMarkSweepGC:启用cms
2. -XX:ConcGCThreads:并发的GC线程数
3. -XX:+UseCMSCompactAtFullCollection:FullGC之后做压缩整理(减少碎片)
4. -XX:CMSFullGCsBeforeCompaction:多少次FullGC之后压缩一次,默认是0,代表每次FullGC后都会压缩一次
5. -XX:CMSInitiatingOccupancyFraction: 当老年代使用达到该比例时会触发FullGC(默认是92,这是百分比)
6. -XX:+UseCMSInitiatingOccupancyOnly:只使用设定的回收阈值(-XX:CMSInitiatingOccupancyFraction设定的值),如果不指定,JVM仅在第一次使用设定值,后续则会自动调整
7. -XX:+CMSScavengeBeforeRemark:在CMS GC前启动一次minor gc,目的在于减少老年代对年轻代的引用,降低CMS GC的标记阶段时的开销,一般CMS的GC耗时 80%都在标记阶段
8. -XX:+CMSParallellnitialMarkEnabled:表示在初始标记的时候多线程执行,缩短STW
9. -XX:+CMSParallelRemarkEnabled:在重新标记的时候多线程执行,缩短STW;
七、垃圾收集底层算法实现
三色标记
在并发标记的过程中 ,因为标记期间应用线程还在继续跑,对象间的引用可能发生变化,多标和漏标的情况就有可能发生。
Gcroots可达性分析遍历对象过程中遇到的对象, 按照“是否访问过”这个条件标记成以下三种颜色:
黑色 : 表示对象已经被垃圾收集器访问过, 且这个对象的所有引用都已经扫描过。 黑色的对象代表已经扫描过, 它是安全存活的, 如果有其他对象引用指向了黑色对象, 无须重新扫描一遍。 黑色对象不可能直接(不经过灰色对象) 指向某个白色对象。
灰色 : 表示对象已经被垃圾收集器访问过, 但这个对象上至少存在一个引用还没有被扫描过。
白色 : 表示对象尚未被垃圾收集器访问过。 显然在可达性分析刚刚开始的阶段, 所有的对象都是白色的, 若在分析结束的阶段, 仍然是白色的对象, 即代表不可达。
多标-浮动垃圾
多标只在垃圾收集过程中由于工作线程在并发执行,导致垃圾对象被当作非垃圾对象标记的情况,因多标而产生的浮动垃圾只能在下一次GC时回收。多标在并发标记和并发清理两阶段发生,有两种情况:一、已经扫描过被标记为非垃圾对象在这阶段被当作垃圾释放,此时会当做非垃圾对象处理。二、产生了新对象,也会当作非垃圾对象处理。
漏标-读写屏障
漏标会导致非垃圾对象被当作垃圾对象回收,属于严重bug。有两种处理方式:
增量更新:一个黑色对象添加了一个白色对象的引用,则将这个引用加入一个集合中,同时黑色对象变成灰色对象。在重新标记阶段,会对灰色对象重新扫描。
原始快照:一个灰色对象对白色对象的引用被删除时,这个引用关系也会被存入一个集合中。在重新标记阶段,会根据这个引用关系找到白色对象,同时将白色对象变为黑色对象,避免被当作垃圾回收。当然,这个过程也可能会产生浮动垃圾。
增量更新和原始快照是处理漏标的两种方式,一般在垃圾收集器中只有其中一种方式存在。它们都是通过写屏障实现的。所谓的写屏障,其实就是指在赋值操作前后,加入一些处理。如新增引用的时候在引用完后采用写后屏障记录,删除引用前采用写前屏障记录。
对于读写屏障,以Java HotSpot VM为例,其并发标记时对漏标的处理方案如下:
CMS:写屏障 + 增量更新
G1,Shenandoah:写屏障 + SATB
ZGC:读屏障
工程实现中,读写屏障还有其他功能,比如写屏障可以用于记录跨代/区引用的变化,读屏障可以用于支持移动对象的并发执行等。功能之外,还有性能的考虑,所以对于选择哪种,每款垃圾回收器都有自己的想法。
为什么G1用SATB?CMS用增量更新?
我的理解:SATB相对增量更新效率会高(当然SATB可能造成更多的浮动垃圾),因为不需要在重新标记阶段再次深度扫描被删除引用对象,而CMS对增量引用的根对象会做深度扫描,G1因为很多对象都位于不同的region,CMS就一块老年代区域,重新深度扫描对象的话G1的代价会比CMS高,所以G1选择SATB不深度扫描对象,只是简单标记,等到下一轮GC再深度扫描。
记忆集与卡表
在做minor GC时,由于老年代可能会引用年轻代的对象(这种概率很小但是会存在),导致要处理这类对象时不得去老年代寻找它们之间的引用关系,如果对老年代全盘扫描,那就失去了minor GC的意义,因此,为处理这种情况,年轻代就使用记忆集记录老年代中存在引用年轻代对象的那部分区域的信息。在minor GC时直接扫描老年代中这部分区域,以提高效率。
说白了,记忆集是 记录从非收集区到收集区的指针集合的一个字节数组。事实上并不只是新生代、 老年代之间才有跨代引用的问题, 所有涉及部分区域收集(Partial GC) 行为的垃圾收集器, 典型的如G1、 ZGC和Shenandoah收集器, 都会面临相同的问题。
垃圾收集场景中,收集器只需通过记忆集判断出某一块非收集区域是否存在指向收集区域的指针即可,无需了解跨代引用指针的全部细节。hotspot使用一种叫做“卡表”(cardtable)的方式实现记忆集,也是目前最常用的一种方式。关于卡表与记忆集的关系,可以类比为Java语言中HashMap与Map的关系。卡表是使用一个字节数组实现:CARD_TABLE[ ],每个元素对应着其标识的内存区域一块特定大小的内存块,称为“卡页”。即老年代被划分为若干个卡页,而年轻代记录了每个卡页的信息,如果老年代中某个对象引用了年轻代对象, 就将这个老年代所在对象的卡页状态更新为dity。mimor GC时只扫描状态为dity的老年代区域。
卡表的维护
卡表变脏上面已经说了,但是需要知道如何让卡表变脏,即发生引用字段赋值时,如何更新卡表对应的标识为1。
Hotspot使用 写屏障 维护卡表状态。
老年代为什么不维护年轻代的引用信息?
因为full GC会回收整个堆。