《深入理解Java虚拟机》读书笔记(三)–垃圾收集器与内存分配策略(上)

时间:2021-2-20 作者:admin

一、垃圾回收

1.1 判断对象是否可用

判断对象是否可用主要有两种方法:引用计数法和可达性分析。

  • 引用计数法:给对象中添加一个引用计数器,每当有一个地方引用它,计数器就加1;当引用失效时,计数器就减1;任何时刻计数器为0的对象就是不再被使用的。python、Squirrel等使用这种算法。

优点:实现简单,效率高

缺点:可能出现循环引用(A引用B,B引用A,除此之外再没有任何地方引用A和B,由于两者相互引用,计数不为0)

  • 可达性分析:通过一系列的被称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索走过的路径成为引用链。当一个对象到GC Roots没有任何引用链相连时,则证明此对象不可用。

选取GC Roots的标准是,明确GC Roots对象自己当前是可用的。在Java中,其主要是全局性的引用(比如常量或类静态属性)和执行上下文(比如栈帧中的本地变量表)。可作为GC Roots的对象包括下面几种:

1. 虚拟机栈(栈帧中的本地变量表)中引用的对象

2. 方法区中静态属性引用的对象

3. 方法区中常量引用的对象

4. 本地方法中JNI引用的对象(JNI可以参考Java深入JVM源码核心探秘Unsafe(含JNI完整使用流程)

1.2 四种引用

  • 强引用:在程序代码中普遍存在,类似“Object obj = new Object()”这类的引用,只要强引用还存在,垃圾收集器永远不会回收被引用的对象。

  • 软引用:描述一些还有用,但非必须的对象。对于软引用的对象,在内存溢出之前,将会把这些对象列进回收范围之中进行二次回收,如果这次回收还是没有获得足够的内存,才会抛出内存溢出异常。

  • 弱引用:描述非必须的对象,强度比软引用更弱一些。弱引用的对象只能生存到下一次GC之前。GC时,无论当前内存是否足够,都会回收掉只被弱引用的对象。可使用WeakReference类实现弱引用。

  • 虚引用:最弱的一种引用关系,无法通过虚引用获得对象实例。其必须和引用队列一起使用:当GC准备回收一个对象时,如果发现它还有虚引用,就会在GC后将这个虚引用加入引用队列,在其关联的虚引用出队前,不会彻底销毁该对象。 所以可以通过检查引用队列中是否有相应的虚引用来判断对象是否已经被回收了,以达到个跟踪垃圾回收的目的。可使用PhantomReference类实现虚引用。

如果一个对象没有强引用和软引用,对于垃圾回收器而言便是可以被清除的。

1.3 多次标记

如果一个对象在经过可达性分析后认为不可用,那它将会被第一次标记,并且判断其是否需要执行finalize()方法,判断的标准是:没有覆盖finalize()方法,或者方法已经被执行过,那么就认为不需要执行。如果这个对象需要执行finalize()方法,那么其会被放在一个叫做F-Queue的队列中,并稍后由一个由虚拟机创建的、低优先级的Finalizer线程去执行:只是触发执行,并不会等待其结束(防止finalize()方法执行缓慢导致F-Queue队列中其它对象一直等待,进而导致GC系统崩溃)。

GC将会对F-Queue中的对象进行第二次标记。对象可以在finalize()方法中将自己与引用链上的任何一个对象建立关联,以实现自救。重新关联后,第二次标记时它将被移出即将回收的集合;如果没有重新建立关联,那么就真的被回收了。

二、垃圾收集算法

2.1 标记-清除算法

有标记和清除两个阶段,首先标记出需要回收的对象,之后再统一回收被标记的对象。除了效率问题,此算法最大的不足是清除之后可能会产生大量不连续的内存碎片。如果内存碎片太多,可能会导致后续需要分配较大对象时,即使空闲内存总量足够,但是由于无法找到连续的内存空间而触发另外一次GC操作。

回收前状态
回收后状态

2.2 标记-整理算法

分为标记和整理两个阶段,标记过程和标记-清除算法一样,整理过程即是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。所以,以此算法清理内存之后,不存在内存碎片。

回收前状态
回收后状态

2.3 复制算法

将可用的内存按照容量划分为大小相同的两块,每次只使用其中一块。当某一块内存使用告急时,将该内存块中所有存活的对象全部复制到另外一块中,然后一次性清理掉该内存块中所有的对象,不会产生内存碎片。内存再次分配时,只需要移动堆顶指针顺序分配即可(指针碰撞)。虽然简单高效,但是可用内存空间只有原来的一半。

回收前状态
回收后状态

2.4 分代收集

根据对象不同的生命周期将内存划分为不同的几块,比如堆分为新生代老年代,新创建对象都在新生代,对象在新生代存活过”一段时间”则进入老年代(通常情况)。这样可以根据不同分代中对象的特点,采用适合的收集算法。

比如,在新生代中,绝大多数的对象都是“短命”的,只有少量会进入老年代,那么可以采取复制算法,并且不需要按照1:1的比例来划分内存;而老年代的存活率则比较高,并且没有其它空间对其进行“分配担保”,则可以采取标记-清除或标记-整理算法。

2.4.1 新生代垃圾收集

采用复制算法收集回收新生代。IBM研究表明,新生代中98%的对象都是朝生夕死的,所以并不需要按照1:1的比例划分内存。而将内存划分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor。详情可参考Hot Spot虚拟机新生代为什么是一个eden+2个survivor

2.5 回收方法区

方法区中进行GC的性价比通常很低,其主要回收两部分内容:废弃常量无用的类

回收废弃常量与回收Java堆中的对象类似,判断一个常量是否为废弃常量的标准是:以常量池中字面量的回收为例,对于一个常量池中的字符串,如果没有任何String对象引用它,也没有其它任何地方引用了这个字面量,那么可以认为是废弃常量,GC时可以被回收。常量池中的类(接口)、方法、字段的符号引用等也与此类似。

判断一个类是否为无用的类需要同时满足一下3个条件:

  • 该类所有实例都已被回收,即Java堆中不存在该类的实例。
  • 加载该类的ClassLoader已经被回收。
  • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

三、 HotSpot枚举GC Roots实现

枚举根节点的时候,对象的引用关系不能发生变化,不然结果可能不准确,所以GC进行时必须停顿所有的Java执行线程,这就是“Stop The World”。

由于GC Roots的节点主要是全局性的引用和执行上下文,而方法区通常会很大,如果逐个检查里面所有的引用,那会消耗很多的时间。由于目前主流Java虚拟机都使用的准确式GC,所以当执行系统停下来之后,虚拟机有办法知道哪些地方存放着对象引用,而不需要一个不漏的检查完所有执行上下文和全局引用。

HotSpot使用一组称为OopMap的数据结构来达到这个目的。在类加载完成之后,HotSpot就把对象内什么偏移量上是什么类型的数据计算出来,在JIT编译过程中也会在特定的位置记录下栈和寄存器中哪些位置是引用。这样GC在扫描时能直接得知这些信息。

3.1 安全点

由于可能导致OopMap内容变化的指令很多,如果为每条指令都生成对应的OopMap,那会消耗大量的额外空间,所以HotSpot没有为每条指令都生成OopMap。在上面提到的特定的位置称之为“安全点”,程序执行时只有在达到安全点时才能暂停。安全点基本上以程序“是否具有让程序长时间执行的特征”为标准进行选定,比如方法调用、循环跳转、异常跳转等。

让线程到安全点停顿下来有两种方案:

抢先试中断:GC发生时,首先把所有线程全部中断,如果发现有线程中断的地方不在安全点上,则恢复线程,让它“跑”到安全点。几乎没有虚拟机采取这个方案。

主动式中断:GC需要中断线程时,设置一个标志,各个线程执行时主动去轮询这个标志,发现中断标志为真时就自己中断挂起。轮询标志的地方和安全点是重合的。HotSpot的做法是:需要暂停线程时,将0x160100的内存页设置为不可读,线程执行test指令(轮询指令)时就会产生一个自陷异常信号,然后在预先注册的异常处理器中暂停线程实现等待。

3.2 安全域

另外,如果线程处于Sleep或者Blocked状态,无法响应JVM的中断请求,“跑”到安全点去中断挂起,JVM也不会等待线程重新被分配CPU时间,这就需要安全域来解决。安全域是指在一段代码片段中,引用关系不会发生变化,在这个区域中任意位置开始GC都是安全的。线程执行到安全域时,首先标识自己进入了安全域,在这离开安全域之前,JVM发起GC时,就不用再管它了。而在线程离开安全域时,它要检查系统是否已经完成了根节点枚举或者整个GC过程,如果完成了则继续执行,否则等待,直到收到可以离开安全域的信号为止。

声明:本文内容由互联网用户自发贡献自行上传,本网站不拥有所有权,未作人工编辑处理,也不承担相关法律责任。如果您发现有涉嫌版权的内容,欢迎进行举报,并提供相关证据,工作人员会在5个工作日内联系你,一经查实,本站将立刻删除涉嫌侵权内容。