0
点赞
收藏
分享

微信扫一扫

《深入理解Java虚拟机》笔记---- 第三章(垃圾收集器与内存分配策略)

单调先生 2022-02-26 阅读 47

1、垃圾回收器

1.1、垃圾对象的确认方法

哪些对象可以被确定为待回收的对象?不可能再被任何途径引用的对象即可确认为待回收对象。如何找到这些对象?

1.1.1、引用计数算法

原理:每个对象内添加一个引用计数器,有一个地方引用它时计数器加一;引用失效时计数器减一;计数器为零时表示对象不再被使用。
优点:原理简单,判定效率高
缺点:有很多例外情况需要考虑,例如:循环引用。

public static void main(String[] args) {
        LeetCode a = new LeetCode();    //此处新建对象(对象1),被a引用,引用计数为1
        LeetCode b = new LeetCode();    //此处新建对象(对象2),被b引用,引用计数为1

        a.obj = b;      // 对象2被对象1内属性引用,引用计数为2
        b.obj = a;      // 对象1被对象2内属性引用,引用计数为2

        a = null;   // 对象1被a断开引用,引用计数为1
        b = null;   // 对象2被b断开引用,引用计数为1
        //程序走完,对象1对象2互相引用导致引用计数均不为0,但此时两对象已经无法再被使用
    }

1.1.2、可达性分析算法

原理:从起始节点(GC Roots)出发,根据引用关系向下搜索,可以被搜索到的对象即可达,否则不可达,不可达的对象即可确认为待回收对象。
GC Roots的对象包括
①虚拟机栈引用的对象
②方法区中类静态属性引用的对象
③方法区中常量引用的对象
④本地方法栈中JNI(Native方法)引用的对象
⑤Java虚拟机内部的引用,如基本数据类型对应的Class对象
⑥所有被同步锁(synchronized)持有的对象
⑦反应Java虚拟机内部情况的JMXBean等

1.1.3、其他知识点

引用类型
①强引用:Object obj = new Object(),引用计数、可达性分析中讨论的就是强引用
②软引用:那些还有用但非必须的对象的引用,此类对象在内存不够时会被回收
③弱引用:那些用处不大且非必须的对象的引用,此类对象能生存到下一次垃圾收集发生为止
④虚引用:唯一的目的是在对象回收时能够收到一个系统通知

finalize方法
一个对象在被判定死亡之前至少要经历两次标记,如果在可达性分析后发现引用链上没有该对象会进行第一次标记,如果对象没有覆盖finalize方法或者finalize方法已经被虚拟机调用过、或者finalize方法中没有对该对象进行重新引用则会进行第二次标记,也就是说对象有唯一一次自救的机会,条件是该对象覆盖了finalize方法,该方法没有被虚拟机调用过,而且在finalize方法中对自己进行了再次引用。

回收方法区
方法区的回收主要包含两部分:废弃的常量和不再使用的类型
废弃的常量的回收和Java堆的回收非常相似;
类的回收条件就比较苛刻,需要同时满足三个条件
①该类的所有实例都被回收
②加载该类的类加载器已被回收
③该类对应的java.lang.Class对象未被引用且不可能通过反射访问到

1.2、垃圾收集算法

1.2.1、分代收集理论

分代收集理论是以两条假说为基础建立的
①弱分代假说:绝大多数对象都是朝阳生夕灭的
②强分代假说:熬过越多次垃圾回收的对象就越难以消亡
基于上述理论可以把内存分为两块区域,新生代和老年代,新生代代存放朝生夕灭的对象,老年代存放多次垃圾回收后仍存活的对象, 如此便可将垃圾收集器分为针对新生代的Minor GC/Young GC和针对整堆的Full GC(Old GC、Mixed GC并非主流,为了便于讲述,此处不讨论),这样设计的优势是高频率运行的Minor GC回收的范围不再是整堆,收集范围变成了新生代,提高了垃圾回收的效率,但是如此设计新生代的收集无疑存在一个问题,即可能存在老年代中对象对新生代对象的引用,为了解决该问题又有了第三条假说。
③跨代引用假说:跨代引用相对于同代引用来说仅占极少数量
依据这条假说,可以在新生代上建立一个记忆集,然后将老年代划分为若干个内存小块,将存在跨代引用的内存小块记录在记忆集内,当放生Minor GC时只需要把记忆集也加入到GC Roots进行扫描即可解决跨代引用的问题,这样做虽然会耗费一些空间和性能来保证GC Roots的准确性,但是相对于扫描整个老年代来说仍然时划算的。
个人理解:Old GC之所不是主流,可能的原因一个是维护记忆集的开销,二个是新生代内的对象朝生夕灭的特性,与其耗费性能和空间维护老年代记忆集,倒不如把新生代也纳入收集范围做成整堆收集的Full GC,该范围朝生夕灭的特性同样有较大的概率回收到不少空间。

1.2.2、标记清除算法

工作原理:标记出需要回收的对象,然后清除掉
优点:实现原理简单
缺点:执行效率不稳定,受需要标记清除对象数量的影响;会导致大量的空间碎片,可能导致分配大对象时找不到足够大的连续空间而不得不提前触发下一次垃圾收集动作

1.2.3、标记复制算法

工作原理:将存活的对象复制到另一块空白的内存中。
优点:垃圾回收后内存空间是连续的,分配对象方便快捷;不会出现没有连续空间导致提前触发GC的情况;存活对象复制完成后可以整块删除原来的内存空间中的内容。
缺点:需要预留空间来存放垃圾回收后存活的对象,更耗费空间;如果垃圾回收后剩余存活对象较多复制时非常耗费性能
分区方式:既然需要来回的复制存活对象肯定需要划分内存。
①半区复制:从名字就可以看出,将一块内存分成两半,在这两块内存中来回腾挪存活的对象,此方法空间消耗巨大,可用内存缩小一半。
其实从垃圾回收后剩余存活对象较多复制时非常耗费性能这一特性分析,标记复制算法最时候新生代的垃圾回收,其朝生夕灭的特性决定了垃圾回收后存活的对象很少,如此我们不需要预留一半的空间来存储垃圾回收后存储的对象,于是有了针对新生代的Appel式回收。
②Appel式回收:将新生代划分为一块较大的Eden空间和两块较小的Survivor空间,Eden和Survivor默认比例为8:1,分配对象时只在Eden和一块Survivor进行对象分配,另一块Survivor空间用来放置下次垃圾收集时复制过来的存活的对象,这样就以10%的空间为代价实现了标记复制。这里存在一个问题,万一存活的对象超过新生代空间的10%怎么办?
分配担保机制:当Survivor空间不足以容纳一次Minor GC后存活的对象时,就需要依赖其他的内存区域(实际上大多数是老年代)进行分配担保,个人理解:即Survivor放不下就直接放老年代,老年代还放不下怎么办?启动full GC,full GC后还放不下怎么办?OOM

1.2.3、标记整理算法

背景:前文讲述标记复制算法时讲到,针对新生代可以用老年代进行担保,这样可是使空间利用率达到90%,但是老年代却不合适再使用新生代做担保,老年代如果想使用标记整理算法只能使用半区复制,将付出50%空间的代价,这样的代价对于一些本身内存就小的服务器着实难以接收,于是针对老年代的特性提出了标记整理算法。
工作原理:将存活的对象向内存的一端进行移动,使其重新连续排列
优点:不浪费内存
缺点:整个对象迁移过程中,为了保证外部对这些对象访问的正确性,必须全程暂停应用程序(Stop The Word,后续以STW简称),导致响应时间增加,影响用户体验,尤其是老年代这种存活对象多,更会延长响应时间
其他:还有一种”和稀泥”的处理方式,大多数时间采用标记清理算法,当内存碎片大到影响对象分配时再采用一次标记整理算法

1.2.4、OopMap

背景:目前主流的垃圾确认算法可达性分析算法的GC Roots确认过程需要STW(原因显而易见,我在确认应该以哪些点作为根节点向下查找引用链,而应用程序却在同步改变GC Roots的内容,这样显然是不行的),但是其实GC Roots中有很多空间是可以不做为根节点进行扫描的,以虚拟机栈为例,其中的操作数栈、返回地址、局部变量表中对基本类型的引用等都不需要在GC Roots中,而虚拟机栈中真正需要作为GC Roots的是其局部变量表中指向堆中对象的那部分内存空间,如果能提前存储这些真正的有意义的根节点,那么势必能大大缩减GC Roots枚举的STW时间。
工作原理我的理解是每执行一条导致根节点变化的指令就将当前所有根节点记录在一个OopMap的数据结构中,垃圾回收时根节点直接从OopMap中取。
优点:极大程度上缩减了GC Roots枚举的STW时间
缺点:需要耗费较大的内存来存储来存储OopMap

1.2.5、安全点

针对原本OopMap每执行一条导致根节点变化的指令就存储一份OopMap会极大消耗内存空间的缺陷提出了安全点,只有线程运行到安全点才会将记录一次OopMap,也只有当所有线程达到了安全点才能够执行垃圾回收(所以安全点不宜过密也不宜过疏)

1.2.6、安全区域

背景:安全区域的提出时因为安全点这一方案存在缺陷,例如当内存不够需要进行垃圾回收,其余线程均已达到安全点进入等待垃圾回收状态,而其中一个线程处于Sleep的状态,那此时岂不是整个程序均被这个Sleep线程阻塞住了,这显然是不行的。
定义:一段代码片段内引用关系不会发生变化的区域叫安全区域,该区域内随时可以响应垃圾回收。

1.2.7、记忆集和卡表

前文讲分代收集理论时提到为了解决跨代引用的问题,垃圾回收器再新生代中建立了记忆集,这个记忆集最简单的实现方式是建立一个数组,存储非收集区域中所有对新生代有引用的对象,但是这种方式无论是空间占用还是维护成本都非常高昂,目前最常用的一种方式是卡表。
工作原理:将内存划分为N个区域,每个区域的大小为512个字节,这些区域称做卡页,那么就可以建立这样一张表,card1对应0到511内存空间的卡页,card2对用512到1023的内存空间,依次类推,如果0到511的内存空间存在跨代引用,那么card1将被标识(称为这个元素变脏),那么垃圾回收时只需要把这些脏元素对应的内存空间加入GC Roots就可解决跨代引用的问题。但是随之而来的问题是如何确认某个卡页变脏了?这需要用到下面所讲的写屏障

1.2.8、写屏障

卡表元素变脏有一个很明确的时间点,即发生在引用类型字段赋值的那一刻,而写屏障可以看作是“引用类型字段赋值”这个动作的一个切面,会产生一个环形通知,赋值前的部分叫做写前屏障,赋值后叫做写后屏障,这里修改卡表用的就是写后屏障。

1.2.9、并发的可达性分析

前文讲过,可达性分析会沿GC Roots继续往下遍历整个引用链,那个堆越大,对象图引用链越复杂,耗费时间越长,如果此阶段采用STW那么用户体验将十分糟糕,那么并发的可达性分析就显得时分必要了,但是如何实现?这里先要讲述一下三色标记
白色:引用链遍历过程中还没有被扫描过的对象。初始阶段所有对象均为白色;扫描接收仍为白色的对象即表示不可达
灰色:已经被扫描过的对象,但是该对象为父节点继续向下引用的分支中至少存在一条没有被扫描过
黑色:对象已经每扫描过,且以它为父节点的所有分支均被扫描;如果有其他对象指向黑色对象无须重新扫描一遍;黑色对象不可能直接指向白色对象(直接指向白色对象的只可能是灰色对象)
如果并发进行可达性分析会存这样的问题,程序运行过程中将引用链接上待扫描的白色对象断开链接然后将已扫描的黑色对象指向该白色对象,因黑色对象不会再被扫描,那么这个本不应该是垃圾的白色对象会被当成垃圾回收,那么如何解决该问题?
可以看到这个问题产生需要两个必要条件
①原本在引用链上的白色对象被断开了链接
②断开链接后又重新添加到了引用链的黑色节点上
(注意,不存在只满足条件二不满足条件一的情况,因为如果一个对象原本就不在引用链接上,那么代码中将无法获取该对象,自然无法再将该对象重新添加到引用链上)
只要破坏其中一个条件即可解决误回收的问题,当前主流的又两种方式:
原始快照:将被断开引用的白色对象全部记录下来,以这些对象为根重新扫描一次(个人理解:此次扫描需要STW),其实就是破坏了第一个条件
增量更新:当黑色节点被添加了对白色对象的引用时,将这些黑色节点记录下来,以这些对象为根重新扫描一次(个人理解:同样的,此次扫描需要STW,其实就是破坏了第二个条件

举报

相关推荐

0 条评论