GC
垃圾收集一般发生在哪个区
对于程序计数器、虚拟机栈、本地方法栈而言,他们随线程而生,随线程而灭,而且虚拟机栈中的每个栈帧在编译时期就已经确定了内存大小。因此这几个区域的内存分配和回收都具备确定性,不太需要我们的垃圾收集机制。
对于Java堆和方法区,它们的内存分配具有很大的不确定性。比如一个接口的多个实现类占用的内存大小不同、一个方法执行时的不同条件分支占用的内存大小不同。也就是说它们的内存分配是动态的,只有在程序运行阶段我们才能确定他需要的内存,所以需要我们的垃圾收集器来进行正确的内存回收。
判断是否为回收对象?
引用计数器法:
每个对象维护一个引用计数器,被引用时引用计数器+1,引用失效时-1。当引用计数器为0时,表示对象已经无法被使用,即可回收。但这种方法存在很大弊端,比如两个对象循环引用,即使外界已经无法访问它们,但它们的引用计数器依然不为0,就无法被回收,造成内存泄露。
可达性分析算法:
可达性分析是将一系列GC Roots作为初始的存活对象集合,然后从该集合出发,探索所有能被该集合引用到的对象,并把它们加入集合中(GC Roots根有栈帧中的局部变量、方法区中的类静态变量与常量引用的对象、Class对象、sync对象等)。搜索过程中走过的路径就是此GC Roots根的引用链。如果一个对象没有在任何GC Roots根的引用链上,那这个对象就是不可达对象。它解决了两个对象相互引用的回收问题。
不过对象真正成为垃圾对象还需要进行一次判断。若此对象是不可达对象,则再去判断对象是否重写了它的finalize()方法。若重写了,则JVM会创建一个线程来执行此方法,我们在重写的方法逻辑中拯救此对象。当然了,一个对象的finalize()方法只会被调用一次,第二次不可达时,此对象就无法被拯救了。 可达性分析的细节:
JVM使用OopMap这一数据结构来代替对栈帧、方法区中所有GC Roots根的扫描。类加载完成时,JVM会把对象内偏移量与数据类型的映射、栈与寄存区中引用的位置记录到一组名为OopMap的数据结构中。收集器在寻找GC Roots根时直接在此OopMap中查找即可。
但如果我们为每个指令都设置一个安全点,则需要额外大量的存储空间。所以JVM设定只有在“安全点”才会有OopMap,这也就限定了垃圾收集只有在安全点才能停下来进行。当垃圾收集需要中断线程时,它不会直接中断线程,而是在安全点设一个标记位来标识需要中断。当线程执行时会不断轮询标识,一旦发现中断标记就马上去离自己最近的安全点挂起进行STW。(补充:“安全点”的选取位置是以“是否具有让程序长时间执行的特征来选取的”,比如循环跳转、方法调用等)。但是,现在又有一个问题,假如线程正在sleep状态,它无法执行走到安全点即无法响应JVM的中断请求,那么它再次执行将可能产生新的GC Roots根。所以我们把安全点拉伸成安全区,然后保证在安全区中的线程引用关系不变,只要保证线程不出安全区,则就不会产生新的引用。【RE:这里有点不懂】
对应查询GC Roots根的过程我们使用OopMap优化,但从GC Roots根向下遍历对象图生成引用链时若也进行STW进行就会耗费大量时间。所以我们想到并发地进行用户线程和GC线程。但此时又引出了“对象消失”问题: 若在遍历引用链对象时,已经被遍历过的对象A突然又多了一个引用,指向了未被遍历的对象B。与此同时所有指向对象B的引用全部被切断。所以这个对象B就无法被扫描入GC Roots根的引用链,所以它会被回收,导致程序报错!
所以我们的解决方法是:
CMS增量更新:在进行遍历引用链的过程中,记录新产生的引用关系。在并发扫描结束之后,再重新扫描一遍被记录的对象。
G1原始快照:在进行遍历引用链的过程中,记录进行删除引用操作的对象。在并发扫描结束之后,再重新扫描一遍被记录的对象。
Minor GC的发生时机
new出的新对象是在Eden元区中开辟一块内存,而当Eden元区的可用空间达到阈值时,便会触发Minor GC。JVM通过可达性分析判断一个对象的引用是否存在,如果不存在则表示它可以被回收。那么不在GC Roots根上的垃圾对象就会被回收,剩余对象移动到From元区或To元区。
Full GC的发生时机
Full GC是对整个Java堆、方法区来说的,它在以下几种情况被触发:
调用System.gc(),建议JVM进行Full GC。虽然只是建议,但很多情况下它会引起Full GC触发。
年轻代转入的存活对象太多、大对象大数组直接进入老年代、进行youny GC时To元区或From元区空间不足导致剩余对象直接进入老年代等,引发老年代空间不足。所以我们应该增加Minor GC的频率,减少进入老年代的对象。
如果系统中要加载的类、反射的类较多,可能会造成方法区达到上限。所以我们可以增大方法区空间或转而使用CMS GC。
空间分配担保机制:发生Minor GC之前,JVM会判断老年代最大连续可用空间是否大于年轻代所有对象的空间、HandlePromotionFailure是否开启空间分配担保机制、老年代最大连续可用空间是否大于历次进入老年代的对象的平均大小,若开启了空间分配担保,且老年代的连续可用空间大小不满足,则会直接触发Full GC。
说一下STW
JVM中的Stop-the-world是通过安全点机制实现的。当JVM收到STW请求时,它会等待所有线程都到达安全点,才允许发起STW请求的线程进行独占工作。
安全点是一个线程能够到达的稳定执行状态,线程只要不离开安全点,JVM就可以边执行本地代码,边进行垃圾回收。所有我们需要进行安全点检测,判断是否是安全点。
阻塞的线程:阻塞的线程一直处于安全点(它处于JVM线程调度器的掌控之下)
解释执行字节码:字节码和字节码之间皆为安全点。所以当其他线程有安全请求时,所以线程执行一条字节码便进行一次安全点检测。
执行即时编译器生成的机器码:这些机器码之间运行在底层硬件之上,不受JVM的掌控,所以在生成机器码时,即时编译器需要在生成代码的方法出口插入安全点检测,以免机器码长时间没有安全点检测的情况。
经典垃圾收集器
垃圾收集算法
标记-清除算法:
先标记所有需要被回收的对象,标记完成后统一回收被标记的对象(反过来也可)
缺点:执行效率会随着对象数量的增长而降低。标记-清除后会产生大量不连续的内存碎片,后续分配大对象时可能会连续空间不足而导致GC频率加快。
标记-复制算法:
把可用内存分成大小相等的两块,每次只使用一半。GC时标记所有存活对象并复制到另一半上,然后直接清除掉这一半的内存空间。而且Java堆中年轻代的对象有98%熬不过第一轮GC,所以我们根据标记-复制算法的特性,把新生代分成了一块Eden元区和两块Survivor元区,大小比例为8:1:1。在发生垃圾回收时,会将Eden元区和Servivor元区中依然存活的对象一次性复制到另一个Survivor元区中,然后直接清理用过的空间。
优点:适合多数对象需要回收的情况,不会产生内存碎片。
缺点:对象存活率高时就要进行较多的复制操作,效率降低。而且只能使用50%的空间,空间利用率低。 标记-整理算法: 先标记所有存活对象,把所有存活对象都移动到内存空间的一侧,然后直接清理掉存活对象边界以外的内存。
标记-清除算法不需要移动,但需要解决处理内存分配的吞吐量。所以它的停顿时间更短,关注延迟的CMS收集器使用它。而标记-整理算法需要移动的吞吐量,但不需要处理内存分配,但从整个程序的吞吐量来看,标记-整理算法更划算。所以它的吞吐量更大,关注吞吐量的Parallel Old收集器使用它。
(垃圾收集器是利用垃圾回收算法对内存回收的实践者,JVM一般会提供各种参数供用户根据自己的应用来组合出各个内存分代所使用的收集器)
年轻代垃圾收集器
① Serial收集器(JDK3):它是单线程工作的垃圾收集器,在GC线程工作时会进行STW暂停所有用户线程。它简单高效、消耗的内存小、对于单核处理器它不会有线程切换的开销,所以效率会比较高。
②ParNew收集器(JDK4):它是多线程工作的垃圾收集器,允许用户线程和GC线程并行工作。它是激活CMS后默认的年轻代收集器。它默认开启的收集线程数与CPU核心数相同。
③Parallel Scavenge收集器:它也是多线程并行工作的垃圾收集器。Parallel Scavenge收集器关注的是吞吐量,而其他收集器都是关注用户停顿时间。所以它适合不需要交互的后台线程。(补充:在这里吞吐量表示用于运行用户代码的时间占比)
老年代垃圾收集器
① Serial Old收集器(JDK3):它是Serial收集器的老年代版本,基于标记-整理算法。它是作为CMS收集器失效时的后备预案。
② Parallel Old收集器(JDK6):它是Parallel Scavenge收集器的老年代版本,基于标记-整理算法。也是吞吐量优先的垃圾收集器。
③ CMS收集器(JDK5):CMS是一种以最短停顿时间为目标的垃圾收集器。在进行垃圾回收时,JVM会先得到GC Roots根,然后进行STW,进行可达性分析。然后再开启用户线程,从所有GC Roots根开始遍历对象图,得到要回收的对象。然后再STW,修正并发标记阶段产生的标记变动。最后进行并发清除,清除掉没有在GC Roots根引用链上的对象。
缺点:因为它需要占用线程进行GC,所以对CPU资源非常敏感。可能会出现并发失败,即在并发清除的过程中用户线程空间占满导致Full GC发生。此时CMS会启动备用的Serial Old收集器进行垃圾收集。CMS垃圾收集器基于标记清除算法,会产生大量空间碎片,我们可以设置参数指定它在执行若干次后Full GC后,下一次Full GC前先进行碎片整理。
G1收集器(JDK9):
作为CMS收集器的代替者,我们想要设计出一个 “在用户期待的时间长度之内,消耗在垃圾收集上的时间基本一致的垃圾收集器”。而传统的垃圾收集器都是以整个年轻代、老年代、甚至Java堆为单位的,它无法估计这些固定区域中垃圾对象的大小,所以也无法固定需要的时间。所以G1收集器就跳出“分代”这个思维,把整个Java堆分成一个个大小相等的独立区域,然后维护一个优先级列表,记录在规定时间内对哪些区域进行垃圾收集的收益最大,有计划地根据用户期望的停顿时间来优先处理回收收益最大的那些Region。(这些大小相等的区域叫做Region,这些Region去扮演年轻代、老年代的角色。这样的垃圾收集模式叫做Mixed GC)
G1运作过程
首先得到GC Roots根,然后STW标记GC Roots根能够直接关联的对象,并修改TAMS指针的值(补充:每个Region维护两个TAMS指针,用于记录并发回收时新创建对象的地址。G1收集器默认在这个地址上的对象被隐式标记,不纳入回收范围)。然后开启用户线程,并发地从所有GC Roots根开始遍历对象图进行可达性分析,得到要回收的对象。然后再STW,处理并发阶段遗留的原始快照记录(具体在可达性分析中)。最后,对各个Region的回收价值进行排序(每次回收G1都会对每个Region根据它们的回收成本、脏卡数量等计算出衰减平均值用于排序),然后按照用户期望暂停时间来定制回收计划,构成多个Region组成的回收集。然后暂停用户线程,GC线程并行地把回收集中的存活对象复制到空Region中,再清理掉旧Region的全部空间。
G1使用记忆集来解决跨Region引用的收集问题。G1的记忆集本质上是一个HashMap,key是引用了自己的其他Region地址,value是卡表中此Region中的索引号集合,即脏卡。
CMS与G1有什么区别? CMS基于标记-清除(以最短停顿时间为目标),G1整体标记-整理,局部复制(Region)。 CMS会产生浮动垃圾(只能下次垃圾收集进行回收),G1不会产生浮动垃圾 CMS针对老年代,G1采用分区设计思想,时间可控。
ZGC收集器(JDK11)
ZGC上以低延迟作为目标的垃圾收集器。ZGC与G1一样也是采用Region的堆内存布局。但它没有分代的概念,也就不需要记忆集来维护跨代引用从而占用大量内存空间。ZGC的Region具有动态性,可以动态地进行创建销毁等操作变更容量。它专门有小型Region存放256KB小对象,中型Region存放4MB中型对象,以及大型Region。一个大型Region只会存放一个大对象,而且它的容量随对象大小而动态变化。
ZGC收集器采用染色指针技术,它会把收集器需要的标记信息(比如可达性分析的三色表记状态)记录在引用对象的指针上,这样避免了并发移动对象带来的可访问问题(指针自愈)、Region的存活对象被移走之后,这个Region不需要等待修正指向它的引用就可以直接被释放重用(指针自愈)、大幅度减少了内存屏障的使用(RE:内存屏障的使用弊端)。
ZGC的运作过程
首先得到GC Roots根,进行可达性分析。然后开启用户线程,并发地遍历对象图修改对象染色指针中的标记位。ZGC会并发地扫描全堆的Region,选取出本次需要进行垃圾收集的Region区,并把它们组成重分配集。然后ZGC并发地把重分配集中的存活对象复制到新Region上,并为每个Region维护一个转发表,记录新旧对象的转向关系,用于「指针自愈」
对象的标记信息是在记录在引用上的嘛,所以我们仅从引用上就能确定对象是否处于重分配集中。如果用户线程并发地访问了重分配集中的对象,那此次访问会被拦截,然后根据转发表从而将访问转发到复制出的新对象中,这就意味着我们不用等待复制完成后的更新所有指针的过程,因为他会自愈
而且因为指针自愈,ZGC把更新指向旧对象引用的过程直接合并入了下一次收集的并发标记阶段,节省了一次遍历对象图的开销。当所有引用都被修正后,转发表就会被释放了。