3.垃圾收集器与内存分配
年轻代、老年代、永久代
四种引用类型
3.1
哪些需要回收
什么时候回收
怎么回收
垃圾回收(Garbage Collection,GC),顾名思义就是释放垃圾占用的空间,防止内存泄露。有效的使用可以使用的内存,对内存堆中已经死亡的或者长时间没有使用的对象进行清除和回收。
3.2对象已死
Java堆中存放着几乎Java世界中的所有对象,垃圾收集器在回收对象之前需要知道那些对象“还活着”,那些对象“已经死去”(即不可能再被任何途径使用的对象)。
3.2.1引用计数算法
给每个对象添加一个计数器,当有地方引用该对象时计数器加1,当引用失效时计数器减1。用对象计数器是否为0来判断对象是否可被回收。缺点:无法解决循环引用的问题。
3.2.2可达性分析算法
GC ROOT
的对象作为搜索起始点,通过引用向下搜索,所走过的路径称为引用链。通过对象是否有到达引用链的路径来判断对象是否可被回收GC ROOT
的对象:JNI(通常所说的Native方法)
引用的对象- 在JDK1.2以前,Java中的引用的定义很传统:如果reference类塑的数据中存储的数值代表的是另外一块内存的起始地址,就称这块内存代表着一个引用。这种定义很纯粹,但是太过狭隘,一个对象在这种定义下只有被引用或者没有被引用两种状态,对于如何描述一些“食之无味,弃之可惜”的对象就显得无能为力。我们希望能描述这样一类对象:当内存空间还足够时,则能保留在内存之屮;如果内存空间在进行垃圾收集后还是非常紧张,则可以抛弃这些对象。很多系统的缓存功能都符合这样的应用场景。
- 在JDK 1.2之后,Java对引用的概念进行了扩充,将引用分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)4种,这4种引用强度依次逐渐减弱
2.软引用:是用来描述一些还有用但并非必需的对象。在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。
即使在可达性分析算法中不可达的对象,也不是“非死不可”的。
对象在被标记为不可达之后,如果对象覆盖了finalize()方法并且该对象还没有调用过finalize(),那么这个对象会被放入F-Queue队列中,并在稍后一个由虚拟机建立的、低优先级的Finalize线程中去执行对象的finalize()方法。稍后GC会对F-Queue的对象进行再一次的标记,如果对象的finalize方法中,将对象重新和GC Roots建立了关联,那么在第二次标记中就会被移除出“即将回收”的集合。
但是,finalize线程的优先级很低,GC并不保证会等待对象执行完finalize方法之后再去回收,因而想通过finalize方法区拯救对象的做法,并不靠谱。鉴于finalize()方法这种执行的不确定性,大家其实可以忘记finalize方法在Java中的存在了,无论什么时候,都不要使用finalize方法。
如果该对象没有覆盖finalize()方法或者已经调用过finalize()方法,GC就会回收该对象。
3.2.5回收方法区
Java虚拟机规范中规定不要求虚拟机在方法区实现垃圾收集,而且在方法区实现垃圾收集性价比确实很低。在堆中,尤其是新生代,一次垃圾收集可以回收75%-95%的空间,而永久代的垃圾回收效率远低于此。
永久代的垃圾收集主要回收两部分:废弃常量和无用的类。回收废弃常量与回收Java堆的对象非常相似。
1.以常量池的字面量的回收为例,例如字符串“abc”进入常量池,但是当前系统没有任何一个string对象引用常量池的字符串“abc”,也没有其他地方引用这个字面量,若此时发生回收,则“abc”常量将被回收。常量池中的其他类(接口)、方法、字段的符号引用也与此类似。
2.判定一个类是“无用的”较为苛刻,需满足三个条件,满足以下三个条件无用类才可以被回收(仅仅是可以,是否必然回收虚拟机有其他参数控制):
- 该类所有的实例都已被回收,也就是Java堆中不存在该类的任何实例。
- 加载该类的classloader已被回收。
- 该类对应的java.long.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该方法。
3.3垃圾收集算法
分代收集理论和几种算法思想
从如何判定对象消亡的角度:垃圾收集算法分为 引用计数式垃圾收集(Reference Counting GC 直接垃圾收集)和追踪式垃圾收集(Tracing GC 也被称为 间接垃圾收集)
3.3.1 分代收集理论
分代收集建立在两个分代假说之上:
弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。
强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡。
跨代引用假说(Intergenerational Reference Hypothesis):跨代引用相对于同代引用来说仅占极少数。
收集器将Java堆划分到不同区域,将回收对象依据其年龄分配到不同的区域中存储。如果一个区域中大多数对象都是朝生夕灭,难以熬过垃圾收集过程的话,那么把他们集中放在一起,每次回收时值关注保留少量存活而不不是去标记大量将要回收的对象,就能以叫第代价回收到大量的空间,如果剩下的是难以消亡的对象,那么把他们集中放到一起,虚拟机就可以以较低的频率来回收这个区域,想喝酒同时兼顾了来及收集的时间开销和内存的空间有效利用。
第一种:标记清除 是最经典的垃圾回收算法
它是最基础的收集算法。
原理:分为标记和清除两个阶段:首先标记出所有的需要回收的对象,在标记完成以后统一回收所有被标记的对象。
特点:(1)效率问题,大部分对象需要回收时,标记和清除的效率随对象数量增长而降低;(2)空间的问题,标记清除以后会产生大量不连续的空间碎片,空间碎片太多可能会导致程序运行过程需要分配较大的对象时候,无法找到足够连续内存而不得不提前触发一次垃圾收集。
地方 :适合在老年代进行垃圾回收,比如CMS收集器就是采用该算法进行回收的。
第二种:标记整理
原理:分为标记和整理两个阶段:首先标记出所有需要回收的对象,让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
特点:不会产生空间碎片,但是整理会花一定的时间。对象移动必须全程暂停用户应用线程。移动内存回收时复杂,不移动内存分配时复杂,从垃圾收集时间看,不移动停顿时间更短,移动时间长,从程序吞吐量看(赋值器和收集器效率总和),移动对象更划算
地方:适合老年代进行垃圾收集,parallel Old(针对parallel scanvange gc的) gc和Serial old收集器就是采用该算法进行回收的。
第三种:复制算法
原理:它先将可用的内存按容量划分为大小相同的两块,每次只是用其中的一块。当这块内存用完了,就将还存活着的对象复制到另一块上面,然后把已经使用过的内存空间一次清理掉。
特点:没有内存碎片,只要移动堆顶指针,按顺序分配内存即可。代价是将内存缩小位原来的一半。内训中多数对象是存活的,将会产生大量的内存间复制开销
地方:适合新生代区进行垃圾回收。serial new,parallel new和parallel scanvage
收集器,就是采用该算法进行回收的。
复制算法改进思路:由于新生代都是朝生夕死的,所以不需要1:1划分内存空间,可以将内存划分为一块较大的Eden和两块较小的Suvivor空间。每次使用Eden和其中一块Survivor。当回收的时候,将Eden和Survivor中还活着的对象一次性地复制到另一块Survivor空间上,最后清理掉Eden和刚才使用过的Suevivor空间。其中Eden和Suevivor的大小比例是8:1。缺点是需要老年代进行分配担保,如果第二块的Survovor空间不够的时候,需要对老年代进行垃圾回收,然后存储新生代的对象,这些新生代当然会直接进入来老年代。
优化收集方法的思路
分代收集算法
原理:根据对象存活的周期的不同将内存划分为几块,然后再选择合适的收集算法。
一般是把java堆分成新生代和老年代,这样就可以根据各个年待的特点采用最适合的收集算法。在新生代中,每次垃圾收集都会有大量的对象死去,只有少量存活,所以选用复制算法。老年代因为对象存活率高,没有额外空间对他进行分配担保,所以一般采用标记整理或者标记清除算法进行回收。
和稀泥解决方案,平时多数时间采用标记清除算法,暂时容忍内存碎片存在,直到内存空间碎片化程度达到影响对象分配时,采用标记整理算法收集一次。
3.4 HotSpot 的算法实现细节
3.2 3.3 介绍了常见的对象存活判定算法和垃圾收集算法
3.4.1 根节点枚举,3.4.2安全点,3.4.3安全区域(找到所有的GC Roots 必须暂停所有的用户线程)
3.4.4记忆集(Remembered Set)与卡表
为了解决对象跨代引用所带来的问题,垃圾收集器在新生代中建立了名为记忆集的数据结构
记忆集记录了从非收集区域指向手机区域的指针集合的抽象数据结构
3.4.5写屏障
3.4.6并发的可达性分析
3.1 根节点枚举
- 迄今为止,所有收集器在根节点枚举这一步骤都必须暂停用户线程,会面临Stop The World的困扰
- 当前主流的虚拟机都使用准确是垃圾收集,因此虚拟机有办法直接得到哪些地方存在对象引用。HotSpot使用一组OopMap的数据结构来达到这个目的
- 一旦类加载动作完成,HotSpot 就会把对象内什么偏移量上是什么类型的数据计算出来(在即时编译过程中,也会在特定的位置记录下栈和寄存器中哪些位置是引用),这样收集器在扫描时就能直接得知这些信息,不必从方法区等 GC Roots 开始查找,提高查找效率。
-
固定作为GC Roots的节点主要在全局性引用(例如常量或类静态属性),与执行上下文(例如栈桢中的本地变量表)中
根节点枚举必须在一个能保障一致性的快照中得以进行
一致性:枚举期间执行子系统看起来像被冻结在某个时间点上,不会出现分析过程中,根节点集合的对象引用关系还在不断变化的情况,若是不能这点,就不能保证分析结果的准确性。
3.2 安全点
HotSpot没有为每个指令都生成OopMap,前面提到的在“特定的位置”记录下这些信息,这些位置称为安全点。
强制用户程序必须执行到安全点才能够暂停。就像高速开车只能在服务区停车休息一样。
- 安全点太少,会让收集器等候过久
- 安全点太多,会过分增大运行时的内存负荷
- 安全点的选取以是否具有让程序长时间执行的特征
- “长时间执行”最明显的特征是:指令列的复用,如方法调用、循环跳转、异常跳转等,具有这种功能的指令才会产生安全点。
如何在垃圾收集时让所有线程跑到最近的安全点停顿下来
-
抢先式中断
垃圾收集时,首先把所有用户线程全部中断,如果用户线程没有在安全点,就恢复该线程,直到到达安全点。
几乎不再使用该方式。
-
主动式中断
a. 垃圾收集需要中断线程时,仅简单地设置一个标志位,各线程不停地主动轮询该标志,一旦发现中断标志位为真,就在最近的安全点停下
b. 轮询标志是和安全点重合的
c. hotSpot使用内存保护陷阱的方式把轮询操作精简到只有一条汇编指令。
3.3 安全区域
当程序“不执行”时,如用户线程在Sleep或Blocked,线程无法响应中断到安全点挂起自己,因此需要借助安全区域。
安全区域就是能够确保在某段代码中,引用关系不会发生变化,因此在这个区域任意位置进行GC都是安全的。(可理解为被扩展拉伸的安全点)
原理
二、在海量的对象中如何快速枚举根节点?
我们都知道在枚举根节点或者GC的全过程是需要执行线程暂时停顿下来的,而考虑到效率问题,我们又希望这个停顿越短暂越好。所以,目前主流的Java虚拟机都采用的是准确式GC(相对应的为保守式GC),当执行系统停顿下来后,我们不需要一个不漏地检查完所有执行上下文和全局的引用位置。在HotSpot的实现中,是使用一组称为OOPMap的数据结构来达到这个目的的,首先在类加载完成的时候,HotSpot就把对象内什么偏移量上是什么类型的数据计算出来,在JIT编译过程中,也会在特定的位置记录下栈和寄存器中哪些位置是引用。这样,GC在扫描的时候,就可以根据OOPMap上记录的信息准确定位到哪个区域中有对象的引用,这样大大减少了通过逐个遍历来找出对象引用的时间消耗。
3.4 记忆集与卡表
为了解决跨代引用问题,在新生代引入的记录集(Remember Set)的数据结构(记录从非收集区到收集区的指针集合),避免把整个老年代加入GCRoots扫描范围。
垃圾收集场景中,收集器只需通过记忆集判断出某一块非收集区域是否存在指向收集区域的指针即可,无需了解跨代引用指针的全部细节。
3.4.1 记忆集的实现
可以采用不同的记录粒度,以节省记忆集的存储和维护成本,如:
- 字长精度:每个记录精确到一个机器字长(处理器的寻址位数,如常见的 32 位或 64 位),该字包含跨代指针
- 对象精度:每个记录精确到一个对象,该对象中有字段包含跨代指针
- 卡精度:每个记录精确到一块内存区域,该区域中有对象包含跨代指针
卡表
第三种卡精度是使用一种叫做“卡表”的方式实现记忆集,也是目前最常用的一种方式
记忆集是一种抽象概念,卡表是它的实现方式。它记录了记忆集的记录精度、与堆内存的映射关系等。
卡表是使用一个字节数组实现:CARD_TABLE[this addredd >>9]=0
,每个元素对应着其标识的内存区域一块特定大小的内存块,称为“卡页”。
hotSpot使用的卡页是2^9大小,即512字节
一个卡页中可包含多个对象,只要有一个对象的字段存在跨代指针,其对应的卡表的元素标识就变成1,表示该元素变脏,否则为0.
GC时,只要筛选卡表中变脏的元素加入GCRoots。
3.5 写屏障
3.5.1 卡表的维护
卡表变脏上面已经说了,但是需要知道如何让卡表变脏,即发生引用字段赋值时,如何更新卡表对应的标识为1.
hotSpot使用写屏障维护卡表状态。
可看做在虚拟机层面对“引用类型字段赋值”动作的AOP切面,在赋值时产生一个环形通知。赋值前后都属于写屏障,赋值前称为“写前屏障(Pre-Write Barrier)”,赋值后称为“写后屏障(Post-Write Barrier)”。
3.5.2 写屏障的问题
-
虚拟机会为所有赋值操作生成相应的指令,一旦收集器在写屏障中增加了更新卡表操作,无论更新的是不是老年代对新生代的引用,每次只要对引用进行更新,就会产生额外的开销。
-
伪共享问题
并发场景下,当多个互相独立的变量被读取到一个缓存行时,会影响性能。
伪共享参考:https://blog.csdn.net/weixin_43696529/article/details/104884373解决:
-
加条件
只有卡表元素未被标记时才将其标记为变脏。
- JDK7以后,使用
-XX:+UseCondCardMark
参数设定是否开启卡表更新时的条件判断
- JDK7以后,使用
开启条件自然会增加一个判断开销,但能够避免伪共享问题。根据实际来。
-
3.6 并发的可达性分析
可达性分析工作必须要在能保障一致性的快照中进行,因此必须停止用户进程。
如果用户进程被停止,那不会产生任何问题。
但如果用户进程和GC进程并发进行,就会出现两种后果:
- 错误地标记已经消亡的对象(用户重新建立引用关系)
- 将存活的对象标记为消亡
3.6.1 三色标记
使用三色标记来解释上述问题:
白色: 对象未被收集器访问(未扫描)
黑色:对象已被收集器访问,且该对象所有引用已扫描过。(安全存活)(扫描完毕)
灰色:对象被访问过,但对应至少还有一个引用没有扫描过。(正在扫描)
但如果用户线程在并发标记进行时修改了引用关系,如下情况会出现存活对象消亡的现象:
如上图:
- 原本引用关系为:A->B,B->C
- 扫描到B时,用户线程取消了B到C的引用,反而添加了一条从已扫描过的对象A到对象C的引用
- 2的情况就会造成对象C不被扫描到,而C本应该是存活的,却在这个情况下意外地被标记为”死亡“
同理,当标记到该图的B时。取消 了B到C的引用,添加了A到D的引用,但因为A已经标记过,因此D不会再被扫描,C也不会被扫描,这样D和C也因用户线程的修改“意外死亡”,这就是“对象消失“的问题。
对象消失的原因
- 添加了一条或多条从黑色到白色的新引用
- 删除了全部从灰色到白色的旧引用(直接or间接)
对象消失的解决
原因1和2分别对应两个解决方案:
-
增量更新(CMS用到)
记录下新插入的引用,并发扫描完毕后,重新以记录下的引用关系的黑色对象为根扫描。
即黑色一旦插入了新的到白色的引用,就变成了灰色。
-
原始快照(G1和Shenandoah)
灰色对象要删除指向白色对象的引用时,将该引用记录下来,扫描完毕后,再从被记录下的引用的灰色对象开始重新扫描。
HotSpot主要采用直接指针进行对象访问。