目录
1、如何考虑 GC
垃圾收集(Garbage Collection,GC)的历史比Java更久远,1960年诞生于MIT。
GC 需要考虑的三件事情
- 哪些内存需要回收
- 回收的时机
- 具体如何回收
1、哪些内存需要回收
线程私有的空间:程序计数器、虚拟机栈、本地方法栈,栈中的栈帧随着方法的进出而入栈出栈。每个栈帧中分配多少内存,在类加载后就可以确定,当方法或线程结束后,占用的内存自然就可以被全部回收了,不需要考虑太多。
Java堆和方法区,这两个线程共享的内存空间,具有很强的不确定性。因为它们中的内容只有运行时才可知,而且会不断变化。垃圾回收器关注的正是这部分内存,对其进行分配和回收。
2、保守式 GC 与准确式 GC
对象的访问定位方式,是由虚拟机使用的GC方式来决定的:
- 保守式GC:使用句柄访问
- 准确式GC:使用直接指针访问
1、保守式 GC
在GC时,一般使用可达性分析法来标记存活对象,期间涉及到GCRoots的引用链遍历问题。
这就涉及到,如何判断一个栈中的数据是引用地址还是基本数据的问题。
有两种方式:
- 对齐检查:检查数据的位数,JVM 中地址都是 32 位的,不是32位的肯定不是引用地址
- 上下边界检查:如果数据的位数符合地址的位数,就检查它是否处于堆内存的范围内,如果超出堆的范围说明不是引用地址
但是,如果该数据是一个32位的,并且满足堆内存范围的数据,JVM就无法判断它是否属于引用地址。基于这种思想的GC称为“保守式GC”
保守式GC的特点:
- 不能准确判断出一个数据是基本数据还是引用地址
- 伪引用问题。如果无法判断,会保守地认为,该数据是引用地址,造成额外记录某些对象的被引用关系,导致它们无法被清理
- 需要引入句柄池作为中间层
为什么保守式GC必须引入句柄池?
比如有一个引用地址A,指向了一个对象B,而正好有一个基本数据C的值和对象B的引用地址一样
如果B的地址发生了修改,那么虚拟机会认为,A和C都是对B的引用,所以它们的值都应该被修改
问题是,C是一个基本数据,它肯定不应该被修改。
而虚拟机又无法判断,所以引入一个句柄池,所有引用都必须指向句柄池,再从句柄池找到实际对象。
这样,要改变对象的地址,只需要改变句柄池的映射地址就好了,栈上的数据都不用修改,这就杜绝了错误修改基本数据的可能性。
JDK 1.0 就是使用句柄进行对象定位的。
2、准确式 GC
准确式GC的特点是,可以准确地知道栈上的一个数据,表示的是基本数据还是引用地址。
如何做到?
- 在编译过程中是可以知道变量的类型的,记录下类型信息,存在OOPMap中
- 后续根据OOPMap,就可以知道该数据表示的含义了
所以准确式GC是可以通过指针去直接定位对象的,访问对象的效率更高
2、如何确定一个对象“死去”
在堆中存放着几乎所有的对象实例,垃圾回收器先要确定堆中的哪些对象还“活着(有用)”,哪些对象已经“死去(再也不可能通过任何途径被引用)”。
1、引用计数算法
引用计数算法(Reference Counting),原理简单,判断效率也高,但主流的JVM都没有使用它
。
引用计数的做法是,给每个对象设置一个计数器,每当一个地方引用它,计数器+1;引用失效后,计数器-1。一旦计数器为0,则对象已死。
好处:
- 把内存管理的操作平摊到了程序运行时每一次对引用的操作中
- 内存管理时不需要了解运行时的对象具体细节,即不需要了解每个对象的所在位置,只需要检查每个对象的引用计数
缺点:
- 很难解决对象之间循环引用的问题
- 只要存在循环引用,两个对象的计数器就永远不会为0,但他们实际上与程序是脱节的,应当被回收。
- 需要保证线程安全
- 因为多线程环境下,会有多个线程对同一批对象进行各种操作,对引用计数的修改需要保证原子性和可见性
- 每个对象都要占用额外的内存去存储引用计数
- 回收大的数据结构时,依然会面临STW的问题,因为单次回收的对象可能很大
2、可达性分析算法
当前主流的商用程序语言(比如Java、C#)的内存管理子系统,都是采用可达性分析(Reachability Analysis)来判断对象死活的。
基本思路是,通过一系列称为“Gc Roots”的根对象
作为起始节点集,根据引用关系向下搜索,搜索走过的路径称为“引用链”。
如果某个对象到 Gc Roots 之间没有任何引用链(即不可达),说明它不可能被引用到,那么它可以死去了。
1、哪些对象能作为Gc Roots
简单来说,Gc Roots 的对象都满足一个条件:目前或永远不可能被回收。
-
JVM内部的引用(比如:基本类型对应的包装类型对象、常驻的异常对象、系统类加载器)
-
栈帧中的局部变量表中引用的对象
-
方法区中的类的引用类型的静态变量
-
方法区中的常量引用的对象
(比如:字符串常量池中的引用) -
本地方法栈中的Native方法引用的对象
-
被同步锁持有的对象(synchronized关键字)
-
分代收集时,需要考虑其他区域对本区域的跨代引用
除了这些之外,根据用户选用的垃圾回收器,以及当前回收的内存区域,还可以有其他对象临时加入,构成完整的Gc Roots 集合。
比如分代收集和局部回收,如果只针对Java堆中的某一块区域进行垃圾收集,则这块区域的对象也可能被其他区域的对象引用,所以就需要把这些关联区域的对象也加入Gc Roots 集合中,才不会回收掉有用的对象。
为了避免 Gc Roots 集合过于庞大,不同的垃圾回收器也都做了自己的优化。
3、什么是引用
不管是引用计数还是可达性分析,都是通过“该对象是否被引用”来判断对象是否存活的。
1、早期定义
在JDK 1.2时,引用的定义还很单薄:如果 reference 类型的数据中存储的数值,代表的是另外一块内存的起始地址,就称这个reference 类型的数据是代表某个内存、某个对象的引用。
一个对象,只有“被引用”和“未被引用”两种状态。合理,但不够灵活。
早期定义的局限性
在很多场景,需要尽可能做垃圾回收。我们希望,虚拟机可以把一些对象做特殊处理:
- 内存空间尚且足够,就不回收他们
- 内存空间在进行垃圾回收后依然很紧张,就回收掉它们,释放一些内存空间。
2、四种引用状态
-
强引用(Strongly Reference)
- 这是最传统的引用定义,指程序代码中的引用赋值操作。
- 无论什么情况,垃圾回收器永远不会回收被强引用的对象。
-
软引用(Soft Reference)
- 描述一些还有用,但并非必须存在的对象
- 在Java中用 java.lang.ref.SoftReference类来表示
- 被软引用的对象,在系统OOM之前会把这些对象列入回收范围进行二次回收。如果内存还是不足,才会真正抛出OOM
-
弱引用(Weak Reference)
- 和软引用类似,但强度更弱。被弱引用的对象,只能生存到下一次垃圾回收发生为止
- 当垃圾回收器开始工作,无论当前内存是否充足,都会回收掉只被弱引用的对象。
- 弱引用必须和一个引用队列(ReferenceQueue)联合使用。如果弱引用所引用的对象被垃圾回收,Java虚拟机就会把这个弱引用加入到与之关联的引用队列中。
-
虚引用(Phantom Reference)
-
虚引用必须和引用队列(ReferenceQueue)联合使用
-
如果一个对象具有虚引用,在它被回收之前,把这个虚引用加入到与之关联的引用队列中。
程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收,进而做一些事情
-
Java中用PhantomReference来实现虚引用
-
4、对象“非死不可”吗
被可达性分析算法判定为不可达的对象,也不会立即被回收,而是处于“死缓”阶段。
要真正判断一个对象死亡,要经过两次标记过程
:
- 第一次,进行可达性分析,发现没有与GC Roots相连的引用链
- 第二次,进行一次筛选,判断此对象是否有必要执行 finalize() 方法。成功执行finalize()方法是对象避免死亡的最后一次机会。
finalize()
如果此对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,那么“没有必要执行finalize()方法”。
如果这个对象被判断为“有必要执行finalize()方法”,它就会被放入一个F-Queue的队列中,并在稍后由一条Finalizer线程去执行它们的finalize()方法。这个Finalizer线程是由虚拟机建立的,调度优先级低。
在执行它们的finalize()方法时,虚拟机只会保证方法被开始执行,但不会等待它执行完毕,因为finalize()方法可能会执行缓慢或死循环,如果等待每个finalize()执行完,可能会导致F-Queue的其他对象永久等待,甚至导致整个内存回收子系统的崩溃。
执行完毕finalize()后,收集器会对F-Queue中的对象进行二次标记。只要对象在finalize()阶段成功加入了引用链,那它就可以继续存活下去。
这种自救机会只有一次,因为一个对象的finalize()方法最多被虚拟机自动调用一次。
(这个方法实际上完全不推荐使用!)
5、回收方法区
虚拟机规范提到,不强制要求虚拟机在方法区中实现垃圾收集。
方法区中的垃圾收集,效率是比较低的:堆空间的一次垃圾收集可以释放70~99%的内存空间,而方法区回收不出来多少内存。
方法区主要回收两部分内容:
- 废弃的常量
- 不再使用的类
如何回收
常量很容易判断,只要没有任何地方引用它,它就是可以被回收的。
类型卸载比较麻烦,需要同时满足三个条件:
- 这个类的所有对象都已经被回收
- 加载这个类的类加载器已经被回收(比较难实现)
- 这个类的Class对象没有被引用。即不可能通过反射来访问这个类
方法区的内存回收是有必要的
在大量使用反射、动态代理、CGLib等字节码框架,这类频繁自定义类加载器的场景中,确实需要虚拟机具备类型卸载的能力,否则方法区可能会OOM。
3、分代收集理论
1、早期的两个分代假说
分代收集理论(Generational Collection)建立在两个分代假说上:
- 弱分代假说:绝大多数对象都是“朝生夕灭”的
- 强分代假说:熬过越多次垃圾回收的对象,就越难消亡
注意,此时还有一条重要的假说未被提及。
2、假说的现实意义
这两个假说奠定了常用垃圾回收器的设计原则:
- 收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(熬过垃圾回收的次数)分配到不同的区域中进行存储。
- 如果一个区域中大多数对象都是朝生夕灭,就把它们集中放在一起,回收的频率可以高一些
- 如果剩下的都是难以消亡的对象,就把它们放在一起,虚拟机以较低的频率回收这个区域
这样就兼顾了垃圾回收的时间开销和内存的有效利用。
3、分代收集理论的完善
Java堆分出不同区域后,垃圾回收器可以每次只回收某一个区域或某几个区域,出现了:
- Minor GC:只针对新生代的垃圾回收
- Major GC:只针对老年代的垃圾回收
- Full GC:全堆回收
针对不同区域,也有相匹配的垃圾回收算法“标记-复制”、“标记-清除”、“标记-整理”。
4、新生代与老年代
商用JVM实现的分代收集理论,至少会把Java堆分为两部分:
- 新生代(Young Generation)
- 老年代(Old Generation)
这两部分的含义是:新生代中每次垃圾收集都会有大量的对象死去,每次回收后存活的少量对象,会逐步晋升到老年代中存放。
5、分代收集与跨代引用
分代收集不能只是简单的划分区域,然后分别收集,因为对象不是孤立的,对象之间可能存在跨代引用
。
要考虑这个因素,那么要进行一次新生代的GC,为了找到被老年代引用的对象,就必须遍历整个老年代,这是非常耗时的
。
所以补充了第三条假说:跨代引用相对于同代引用来说,占极少数
。
这个假说也不是凭空得来的,而是根据前两条假说推理出的隐含依据:存在互相引用关系的两个对象,倾向于同时存活或消亡。
比如有一个新生代的对象存在跨代引用,会导致它能在每次垃圾回收时存活,经历几个周期,它就会晋升到老年代,跨代引用也就被擦除了。
6、跨代引用下的垃圾收集
理论说明,跨代引用的数量是比较少的。
Remembered Set
不必为了少量的跨代引用而扫描整个老年代,可以在新生代上建立一个全局数据结构(记忆集,Remembered Set)。
把老年代划分为若干小块,标识出哪一块存在跨代引用,在发生新生代GC时,只需要遍历这一小块区域即可。
这些老年代对象会被加入GC Roots进行可达性分析,从而避免它们引用的新生代对象被回收
比起记录每个引用或者遍历整个老年代,这样的效率显然更高。
Card Table
HotSpot 给出的解决方案是一项叫做卡表(Card Table )的技术。
该技术将整个堆划分为一个个大小为 512 字节的卡,并且维护一个卡表,用来存储每张卡的一个标识位。
这个标识位代表,对应的卡是否可能存有指向新生代对象的引用。如果可能存在,那么我们就认为这张卡是脏的。
在进行新生代GC时,在卡表中寻找脏卡,然后把脏卡中的对象加入到 Minor GC 的 GC Roots 里。
当完成所有脏卡的扫描之后,Java 虚拟机便会将所有脏卡的标识位清零。
注意,新生代GC时会发生对象的复制,对象的地址会发生变化,所以此时也应该更新引用所在卡的标识位,可以确保脏卡中必定包含指向新生代对象的引用。
7、针对不同分代的垃圾收集
- 部分收集(Partial GC):指目标不是整个Java堆,具体分为这些:
- 新生代收集(Minor GC/ Young GC):只针对新生代的垃圾收集(非常普遍)
- 老年代收集(Major GC/ Old GC):只针对老年代的垃圾收集。(只有CMS收集器会有这种单独收集老年代的行为)
- 混合收集(Mixed GC):针对整个新生代和部分老年代的垃圾收集。(只有G1收集器有这种行为)
- 全堆收集(Full GC):针对整个Java堆和方法区的垃圾收集
8、动态的分代年龄判断
对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置,默认为15
Hotspot 遍历所有对象时,按照年龄从小到大对其所占用的大小进行累积。
当累积的某个年龄大小超过了 survivor 区的一半时
,取这个年龄和 MaxTenuringThreshold 中更小的⼀个值,作为新的晋升年龄阈值。
那么超过这个新阈值的分代年龄对象,都会晋升到老年代中,提前腾出survivor的空间。
4、垃圾回收算法
1、概述
从如何判断对象死亡的角度来看,垃圾收集算法可以划分为两类:
- 引用计数式垃圾收集(直接垃圾收集),主流JVM都不用这种方法
- 追踪式垃圾收集(间接垃圾收集),常见的垃圾回收都属于这种方式
2、标记-清除算法
标记-清除算法(Mark-Sweep),是最早出现、最基础的 GC 算法。
它分为两个阶段:标记、清除。
- 标记出所有存活的对象,之后统一回收所有未被标记的对象
标记-清除算法的缺陷
执行效率不稳定
,如果Java堆中包含大量需要回收的对象,则标记、清除两个阶段的效率就会很低- 标记、清除之后,会
产生大量不连续的内存碎片
,会导致如果不够分配大对象,不得不提前触发下一次垃圾回收。
后续的垃圾回收算法,大多都是以标记-清除为基础,对其进行改进得到的。
3、标记-复制算法
1、最初的复制算法
为了解决清除算法在面对大量对象时效率低的问题,复制算法的思想是:
“半区复制”,将可用内存按容量划分为大小相等的两块,每次只使用其中的一块
当这块空间用完,就把这里面还存活的对象复制到另一块内存中,然后把已使用的内存空间全部清理掉
。
优缺点
优点:
- 如果内存中多数对象是要回收的,只需要复制少量的对象,效率很高
- 每次都是针对整个半区进行回收,
不会产生空间碎片
缺点:
- 如果内存中多数对象是需要存活的,就会发生大量的内存复制,效率低
- 可用内存变为了原先的一半,
空间浪费太大了
2、Eden与Survivor
大多数JVM都采用复制算法来回收新生代。
IBM公司发现,新生代中有98%的对象熬不过第一次垃圾收集,所以不需要“对半分配内存”。
具体做法是,把新生代分为一块较大的Eden空间,和两块较小的Survivor空间
。
每次分配内存,只使用Eden和其中一块Survivor。发生垃圾收集,将Eden和Survivor中仍然存活的对象复制到另一块Survivor,然后直接清理干净Eden和上次那块Survivor。
HotSpot默认Eden和Survivor的大小比例是8:1,即每次新生代可用内存空间占整个新生代空间的90%,冗余一个Survivor空间用于复制存活对象,这样的空间浪费是可以允许的。
如果存活对象很多,Survivor放不下,就需要其他内存区域(大多数是老年代)进行“分配担保”。
3、新生代垃圾回收的细节
- JVM触发了一次Minor GC,Eden区和Survivor from区的存活对象就会被复制到Survivor to区中
- 然后交换 from 和 to 指针,以保证下一次 Minor GC 时,to 指向的 Survivor 区还是空的
- Java 虚拟机会记录 Survivor 区中的对象一共被来回复制了几次。
- 如果一个对象被复制的次数为 15,那么该对象会晋升至老年代
- 另外,如果单个 Survivor 区已经被占用了 50%,那么较高复制次数的对象也会被提前晋升至老年代
4、分配担保机制
由于采用了较小的内存区域用来复制存活对象,如果这个区域装不下所有的存活对象,就会使用老年代的空间进行分配担保。
具体做法是,把一些存活对象直接晋升到老年代中。
- JDK 1.6之前:
- 在发生young GC之前,会先检查老年代的最大可用连续空间是否大于新生代所有对象的空间。如果大于,那么这次young GC肯定是安全的
- 如果不满足,就检查虚拟机“允许担保失败”参数是否开启
- 如果开启了,会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次 Minor GC
- 如果没开启,就进行一次Full GC
- JDK 1.6之后:
- 只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小,就会进行 Minor GC,否则将进行 Full GC
4、标记-整理算法
对于老年代,每次回收时对象的存活率比较高,所以不适合使用复制存活对象的算法。而且,没有内存空间能为老年代进行“担保”。
针对老年代,提出了“整理算法”。它也是先进行标记,但不是直接原地删除,而是把所有存活的对象都规整地拷贝到内存的一侧,达到整理内存空间的效果。
移动存活对象,优缺点并存:
缺点:
- 老年代在进行垃圾回收后,会有大量对象存活,移动这些数据很耗时
- 移动存活对象的内存地址意味着需要重新修改referance引用地址,这个更新操作也耗费很大
- 这种对象移动操作必须全程暂停用户应用程序才能进行(读屏障可以解决)
优点:
- 不会产生内存碎片
这种方式跟“标记-清除”的区别,仅仅是处理“被标记对象”的方式不同,一个原地删除,一个将它们移动。
移动更耗时,但访问起来很方便,而如果要针对碎片化的内存进行特殊设计,则比较麻烦
,显著降低访问效率,反而拉低了整体效率。
一种均衡的做法是,平时使用清除算法,允许碎片的存在,但不对它进行优化访问。如果内存碎片影响到了对象分配,就使用一次整理算法,获得规整的内存空间。CMS收集器就是这种策略。
5、总结
- 标记-清除:
- 标记存活的对象,然后删除未被标记的对象。
- 缺陷是会产生大量的内存碎片,以后需要分配较大的对象时,可能会提前触发下一次垃圾回收
- 标记-复制:
- 把整个堆分为两个部分,每次只使用其中的一块。
- 标记还存活的对象,然后将它们复制到另一半内存中,清空之前的整块内存。
- 好处是,不用考虑内存碎片,直接按顺序分配内存即可。
- 缺陷是内存的利用率不高。把堆对半分是因为,这样就不会出现,存活对象太多,另一半空间放不下的情况
- Enen区和suvivor区的思想是,如果suvivor区放不下存活对象,就使用老年代担保。这样suvivor区就可以设置得小一些,提高内存利用率
- 标记-整理:
- 标记出所有存活的对象,向内存的一端整体移动,清理掉边界外的内存
- 好处是,可以获得规整的内存空间
- 缺陷是,如果存活对象很多,移动过程比较耗时
6、新生代和老年代一般使用什么算法
新生代一般使用“标记复制”算法,老年代一般使用“标记清除”与“标记整理”算法
1、为什么新生代不使用清除算法
在新生代中,每次垃圾收集时都会有大批对象死去,只有少量存活。
所以,如果删除大量的死亡对象,效率肯定不如复制少量的存活对象更高
。
而且,清除算法会带来大量内存碎片
。新生代上会频繁为新的对象分配内存,碎片过多肯定会导致垃圾回收多次触发
。
而且复制算法经过改良,可以压缩Survivor区域,内存利用率也不低。如果复制时空间不足,则由老年代来担保。
2、为什么老年代不使用清除算法
老年代的特点是,存活对象比较多。如果去标记出死亡的对象,然后清除它们,效率其实也可以。
但是清除算法会带来大量的内存碎片,针对这种环境就必须设计一个空闲列表来分配内存,这样反而会降低平时分配内存的效率。
所以还是选择了在GC时效率较低的整理算法,它能带来规整的内存空间,平时用起来更方便。
3、为什么老年代不使用复制算法
一方面,绝对不可能让整个老年代划分成大小相等的两块,这样的内存空间浪费太大了。而且不能使用新生代的大Eden区小suvivor区的做法,因为没有额外空间对它进行分配担保。
另一方面,老年代中对象存活率高,复制起来效率不高。
所以选择使用“标记清除”或者“标记整理”算法来进行回收。
5、HotSpot的算法实现细节
1、根节点枚举与 OOPMap
虚拟机在GC之前,需要先通过可达性分析来标记存活对象,而可达性分析分为两个阶段:
- 根节点枚举,确定所有的GCRoots
- 顺着根节点查找引用链
根节点枚举需要STW
- 所有的垃圾收集器在根节点枚举时,都必须先暂停用户线程,这里的目的是为了保障一致性,做到准确垃圾收集
- 因为在用户进程运行过程中,根节点的引用链也是不断变化的,必须抽取一个确定时刻的状态,才能去保证分析的准确性。
- 而每次都查找它们很耗时,应该尽量减少暂停用户线程的时间。
如何缩短根节点枚举STW的时间
固定可作为GC Roots的节点,主要有两大类:
- 全局性的引用(常量、类的静态属性)
- 执行上下文(栈帧中的引用对象)
最笨的方法,需要完整把方法区和栈空间扫描一遍才能找到这些对象,造成STW的时间特别长
一个想法是以空间换时间,也就是OOPMap的思想。
OOPMap
把引用类型和它对应的位置信息用哈希表记录下来,这样 GC 的时候就可以直接读取这个哈希表,而不用一个区域一个区域地进行扫描了
HotSpot的做法是,使用一组称为“OopMap”的数据结构。
一旦类加载动作完成,HotSpot就把类中的每个数据类型在内存中的偏移量都记录下来。
这样GC时就可以借助OOPMap来快速完成根节点枚举,从而减少STW的时间。
2、安全点
在OopMap的帮助下,HotSpot可以快速完成根节点的枚举。
但是,在程序运行的过程中,经常有一些操作会导致引用发生变化,那么OopMap的内容就需要更新,否则拿着这个OOPMap去GC就会出现错误。
这类操作通常非常多,如果为每个变化的时刻都生成对应的OopMap,这个额外的空间代价就太高昂了。
HotSpot没有这么做,只是在“安全点”记录了对象引用的相关信息。
这就意味着,只有程序运行到了安全点,才能发生用户线程停顿,利用这个安全点对应的OopMap进行根节点枚举,开始垃圾收集。
当 Java 虚拟机收到 Stop-the-world 请求,它便会等待所有的线程都到达安全点,才允许请求 Stop-the-world 的线程 进行独占的工作。
在安全点上,没有字节码执行。
安全点出现在什么地方
- 安全点不能太稀疏,否则会导致垃圾收集不能及时开展
- 也不能太密集,否则额外维护OOPMap的代价很高,而且也没有必要进行这么频繁的GC
安全点的选定标准是:
- 是否具有让程序长时间执行的特征
- 比如指令的复用,包括方法调用、循环、异常处理等。只有具有这些功能的指令才会产生安全点
安全点是如何工作的
如何在 GC 发生时,让所有用户线程都执行到最近的安全点,然后停顿下来?
让用户线程停顿,有两种方式:
- 抢先式中断
- 在 GC 发生时,系统先把所有用户线程全部中断掉
- 如果发现有的用户线程中断位置不在安全点上,就让它继续运行,跑到安全点再重新中断
- 主动式中断
- 不会直接中断线程,而是全局设置一个标志位
- 用户线程会不断的轮询这个标志位,当发现标志位为真时,线程会在最近的一个安全点主动中断挂起
两种方式的比较:
- 抢占式中断的缺点是性能不稳定,造成让用户线程停顿这一动作的时间消耗不可控,而且涉及到重复中断,效率不高。没有虚拟机这样设计
- 主动式中断比较合理,虚拟机都是这样设计的
3、安全区域
1、为什么需要安全区域
用户线程一般是通过主动式中断的方式,自行抵达安全点后中断。
但有些不活跃的用户线程,GC时正在被阻塞或被挂起,它们不活跃的时候没有办法去轮询标志位,也就无法在安全点自行中断。
这样就导致一个问题,有可能在GC开始根节点枚举之后,这些线程恢复了运行,修改了引用,导致垃圾收集不准确。
这个问题需要引入安全区域来解决。
安全区域的思想是:确保在某一段代码中,引用关系不会发生变化,因此从这个区域中任意地方开始垃圾收集都是安全的。
也就是说,安全区域类似于被拉长的安全点
2、安全区域的工作方式
- 当用户线程执行到安全区域里面的代码时,首先会标识自己已经进入了安全区域
- 之后虚拟机要发起GC,设置了全局的中断标志位
- 如果线程要离开安全区域,它会先检查主动中断标志位
- 如果线程在安全区域内被阻塞或挂起,等它恢复运行之后,也会首先检查主动中断标志位
- 如果标志位为真,说明将要或正在发起GC,那么当前线程继续阻塞
- 如果标志位为假,那么恢复正常运行