3.1. 对象存在与否
3.1.1. 引用计数算法
3.1.2. 可达性分析算法
既然可达性分析判定一个对象是否应该回收取决于根节点是否可达,那么根节点的选取就变得尤为重要。在 Java 中,根节点(GC Roots)可以为如下几种:
- 虚拟机栈引用的对象,比如各个线程调用的方法堆栈中的方法参数,局部变量,临时变量。
- 方法区中类静态属性引用的对象。
- 方法区中常量引用的对象。
- 本地方法栈中引用的对象。
- JVM 内部的引用,比如基本数据类型对应的 Class 对象,常驻的异常对象。
- 所有被同步锁持有的对象。
- 反应 JVM 内部情况的 JMXBean,JVMTI 中注册的回调,本地代码缓存等。
其实可以看到,所谓 GC Roots 更像是活跃对象集合 , 哪些对象是存活的,就可以作为 GC Roots 。
3.1.3. 再谈引用
它们分别是:
- 强引用就是最传统的定义,就是最基本的 new 对象然后赋值。任何时候,只要存在强引用,对象就不会被回收。
- 软引用就是那些非必需的对象,在内存即将用尽时,会把这些对象进行第二次回收,如果还不够,则抛异常。
- 弱引用更弱,它撑不过垃圾回收,任何只被弱引用关联的对象都会在垃圾回收时被回收( ThreadLocalMap 的值就是这种类型,为了防止内存泄漏而引入)。
- 虚引用只能用来在对象被回收时得到一个通知,仅此而已。
3.1.4. 回收方法区
对于类型的回收,需要判断一个类型是否属于不再使用的类,条件会比较苛刻:
- 该类及其子类的所有实例都被回收。
- 加载该 类的类加载器 已经被回收。
- 该类的 Class 对象没有在任何地方被引用 。
在使用了大量的动态代理,反射,CGLib 的地方,类型回收就显得比较重要。
3.2. 垃圾收集算法
3.2.1. 分代收集理论
收集器根据这两个假说把 Java 堆划分成了不同区域,根据对象的年龄(指对象熬过垃圾回收的次数)划分出了两个主要的区域: 新生代 和 老年代 。
新生代关注的更多是如何保留少量存活,使用弱分代假说;而老年代则可以使用更低的频率来回收,使用强分代假说。新生代回收之后剩下的对象会逐步晋升代老年代。
因为跨代引用假说的存在,所以可以把老年代划分出不同区域,这样在进行 Minor GC 时,仅仅把这些区域内的老年代加入到 GC Roots,以它们为根节点进行扫描。
3.2.2. 标记-清除算法
这个算法足够简单,但是它有两个缺点:
- 执行效率不稳定,它的执行效率随着堆中需要回收的对象数量增加而下降。
- 第二个是 碎片化问题 ,过多的碎片会导致没有足够大的空间分配对象进而触发进一步垃圾收集。
3.2.3. 标记-复制算法
为了解决标记清除算法的执行效率不稳定问题,引入了标记-复制算法。
此算法会把堆分为两个区域,其中一个保留,另一个存放对象;每次垃圾回收就把需要存活的对象复制到另一半内存,然后清空整个内存半区,这样这一半就完全空闲,而且所有的存活对象都会整齐地排列在另一半中。下次同样这样操作。
这种算法的缺点显而易见:每次只有一半内存得以使用,未免有点太浪费空间了。
为了解决这个问题,可以把内存划分比例换一下,因为统计发现,绝大多数新生代对象撑不过第一轮垃圾回收。
目前有一种更好的解决方案:把内存划分成两个较小的 Survivor(以下简称 S)和一个较大的 Eden(以下简称 E)。 每次只使用一个 S 和一个 E 来分配内存 。垃圾回收时,把这个 E 和 S 上的存活对象移动到另一个 S 上,然后清空 E 和刚刚那个 S。
凡事总有个例外,如果 S 不够容纳一次 GC 之后的存活对象,就需要老年代的部分区域来存放。这部分实现的安全性由 JVM 担保。
由此可见这个算法主要用于新生代的 GC。
3.2.4. 标记-整理算法
这个算法如果用在老年代上的话,估计不是很理想,因为老年代存活对象太多了。而且这个算法在移动对象时,必须暂停用户程序,就像 JOJO 里 Dio 喊出:The World!全世界冻结一样。然后搬运它需要搬运的对象。
是否移动对象有弊有利。移动的好处在于内存空间整齐,方便新空间的申请,且加大系统吞吐量;弊端就是 会因为移动对象而产生过多的停顿 。
还有一种中和式,就是在内存碎片多到无法容忍时进行整理,CMS 便是这样的原理。
3.3. HotSpot 算法细节
3.3.1. 根节点枚举
因为在 类加载时会确定对象偏移量多少的位置上,数据是什么类型 ,加上即时编译时,也会记录栈和寄存器里哪些保存的是引用类型,所以最终可以 直接得到所有引用的位置 。然后把它们加入到 OOPMap,进而选出根节点。
JIT 记录 OOPMap 具体过程:一个线程意味着一个栈,一个栈由多个栈帧组成,一个栈帧对应着一个方法,一个方法里面可能有多个安全点(见下面)。 GC 发生时,程序首先运行到最近的一个安全点停下来,然后更新自己的 OOPMap ,记下栈上哪些位置代表着引用。枚举根节点时,递归遍历每个栈帧的 OOPMap,通过栈中记录的被引用对象的内存地址,即可找到所有的 GC Roots。
OOPMap 还可用作准确式 GC(以下内容为 转载 )。
- 保守式 GC 在进行 GC 的时候,会从一些已知的位置(GC Roots)开始扫描内存,扫描到一个数字就判断它是不是可能是指向 GC 堆中的一个指针(这里会涉及上下边界检查(GC 堆得上下界是已知的)、对齐检查(通常分配空间的时候会有对齐要求,假如说是 4 字节对齐,那么不能被 4 整除的数字就肯定不是指针),之类的)。然后一直递归的扫描下去,最后完成可达性分析。这种模糊的判断方法因为无法准确判断一个位置上是否是真的指向 GC 堆中的指针,所以被命名为保守式 GC。这种可达性分析的方式因为不需要准确的判断出一个指针,所以效率快,但是也正因为这种特点,它存在下面两个明显的缺点:
- 准确式 GC
与保守式 GC 相对的就是准确式 GC,何为准确式 GC?就是我们准确的知道,某个位置上面是否是指针,对于 Java 来说,就是知道对于某个位置上的数据是什么类型的,这样就可以判断出所有的位置上的数据是不是指向 GC 堆的引用,包括栈和寄存器里的数据。
上看了下说是实现这种要求的方法有好几种,但是在 java 中实现的方式是:从我外部记录下类型信息,存成映射表,在 HotSpot 中把这种映射表称之为 OOPMap,不同的虚拟机名称可能不一样。
实现这种功能,需要虚拟机的解释器和 JIT 编译器支持,由他们来生成 OOPMap。生成这样的映射表一般有两种方式:
3.3.2. 安全点
安全点的选择基本遵循“是否具有让程序长时间执行的特征”来选择的,比如循环,异常抛出,方法调用等。只有具有这些特征的指令才会被放置安全点。说白了就是 如果一段代码会长时间执行 (比如循环,或者方法调用),那么我们 不能等到这段代码执行完再添加安全点 , 只能把安全点插入到这段代码内部 ,以此防止它长时间执行影响 GC;比如插入在循环块内最后的位置,方法返回的位置(相当于在方法与方法之间插入)。
另一个问题是,怎么让程序在安全点停下来?有两个方案,抢占式中断和主动式中断。前者基本不再使用,来看看后者。
主动式中断比较简单,在安全点前面添加一个 test 汇编指令,当希望线程停下时,就通过 JVM 把 test 后面的地址设置为不可读,这样就可以触发线程产生自陷异常,被预先注册好的处理程序挂起。
在这里可以看出,所谓的枚举 GC Roots 时发动 The World 能力是通过让线程发生中断来实现的。
线程中断的位置是离它最近的安全点,所以 GC 开始枚举时必须等待所有线程全部跑到最近的安全点才行(此时忽略那些非运行的线程)。因为安全点有很多,所以当 JVM 发动能力后,很快啊!所有还在运行的线程基本马上立刻就停了。
然后更新 OOPMap,进行枚举 GC Roots。
3.3.3. 安全区域
安全区域具体过程:
3.3.4. 记忆集
记忆集一般有这三种:
- 😮ne:字长精度:精确代机器字长,就是处理器寻址位数,查看该字是否包含跨代指针。
- :two:对象精度:精确到每个对象,查看对象是否含有跨代指针。
- :three:卡精度:精确到某个内存区域,查看这个内存区域是否含有跨代指针。
其中,卡精度是使用最多的,它有点像分页,把内存划分成固定大小的区域,然后 总 内 存 大 小 / 每 个 内 存 区 域 大 小 = 卡 表 长 度
如果这块内存区域含有跨代指针,则卡表对应元素为 1,否则为 0。
3.3.5. 写屏障
首先,写屏障会对引用赋值代码进行 AOP 切入,使用的是环形通知(Around)。赋值代码之前的部分是 写前屏障 ,而之后的成为 写后屏障 。
在应用了写屏障技术后,JVM 就会为赋值指令生成相应的更新卡表指令。
但是还有一个问题
声明:本站部分文章内容及图片转载于互联 、内容不代表本站观点,如有内容涉及侵权,请您立即联系本站处理,非常感谢!