JVM读书笔记之第三章

垃圾收集器与内存分配策略

引用计数算法

给对象中添加一个引用计数器,每当一个地方引用它,计数器值加1;当引用失效时,计数器值减1;任何时候计数器值为0的对象就是不可能再被使用的。

python使用的引用计数算法来进行内存管理,但java虚拟机没有使用,主要原因是它很难解决对象之间相互循环引用的问题。

可达性分析算法

基本思想是通过一系列的称为“GC ROOT”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连时(即从GC ROOT到这个对象不可达),则证明对象是不可用的,会被判定为可回收的对象。

在JAVA语言中,可作为GC ROOT对象的包括以下几种:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象。
  • 方法区中类静态变量属性引用的对象。
  • 方法区中常量引用的对象。
  • 本地方法栈中JNI(Native方法)引用的对象。

即使被判定为不可达对象,也并非一定会被清理掉。要真正判定一个对象死亡,至少要经历两次标记过程:

  1. 通过可达性分析算法判定,对不可达对象进行标记,并且进行一次筛选,条件时此对象是否有必要执行finalize方法:
    • 当对象没有覆盖该方法,或者该方法已经被虚拟机调用过一次,都会视为没有必要执行。
    • 若有必要执行,那么这个对象会被放置在一个叫做F-Queue的队列中,并在稍后由一个虚拟机自动建立的、低优先级的Finalizer线程去触发它的finalize方法。但并不承诺会等待它运行结束。
  2. 稍后GC将对F-Queue中的对象进行第二次小规模的标记,如果对象在finalize方法中成功重新与引用链上的任何一个对象建立关联,则会被移出即将回收的集合。如果该对象此时还没有逃脱,那么基本上它真的被回收了。

引用

  • 强引用:代码中普遍存在的,类似于Object obj = new Object()这类的引用,只要强引用还存在(即obj存在),垃圾收集器永远不会回收被引用的对象。
  • 软引用:指一些还有用但并非必需的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。如果这次回收还没有足够内存,才会抛出内存溢出异常。可用SoftReference实现。
  • 弱引用:也是用来描述非必需对象,但强度比软引用更弱一些,被弱引用引用的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。可用WeakReference实现。
  • 虚引用(幽灵引用、幻影引用):最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。使用该引用的唯一目的是能在这个对象被垃圾收集器回收时收到一个系统通知。可用PhantomReference来实现。

回收方法区(永久代)

永久代的回收主要回收两部分的内容:

  • 废弃常量:与回收java堆中的对象类似,当没有其他地方引用到常量,如果这时发生内存回收,而且必要的话,这个常量就会被系统清理出常量池。
  • 无用的类:要同时满足以下3个条件才能算是无用的类,无用的类不是必然会被回收,HotSpot虚拟机提供了很多参数进行控制。在大量使用反射、动态代理、CGLIB等ByteCode框架、动态生成JSP以及OSGI这类频繁自定义ClassLoader的场景都需要虚拟机具备类卸载的功能,以保证永久代不会溢出。
    • 该类所有的实例都已经被回收,即java堆中不存在该类的任何实例
    • 加载该类的classloader已经被回收
    • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

垃圾收集算法

  • 标记-清除算法(Mark-Sweep):首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。不足:1.效率问题,标记和清除两个过程的效率都不高;2.空间问题,标记清除之后会产生大量的不连续内存碎片,空间碎片太多可能导致以后程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另外一次垃圾收集动作。

  • 复制算法:将可用内存划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将存活着的对象复制到另外一块上面,然后再把已经使用过的内存空间一次性清理掉。无内存碎片、简单高效。

    目前商业虚拟机都是用这种收集算法来回收新生代,但由于新生代的98%对象都是很快消失,所以不需要1:1的比例来划分。而是将内存划分为一块较大的Eden空间和两块较小的survivor空间,每次使用Eden和其中一块survivor。当回收时,将Eden和survivor中还存活的对象一次性复制到另外一块survivor空间上,最后清理掉Eden和刚才用过的survivor空间。Hotspot虚拟机默认的Eden和survivor大小比例是8:1。

    当另外一块survivor空间不足以存放上一次新生代收集下来的存活对象时,需要依赖其他内存(这里指老年代)进行分配担保(handle promotion)。这些对象可以直接通过分配担保机制进入老年代。

    复制收集算法在对象存活率较高时就要进行较多的复制操作,效率将会变低。所以不适用于老年代。需要用到下面这种算法。

  • 标记-整理算法(Mark-Compact):标记过程与之前的一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。

分代收集算法

将内存划分为新生代和老年代,其中新生代分为Eden和survivor。对于新生代这种每次垃圾收集都会有大批对象死去的区域采用复制算法,对于对象存活率高、没有额外空间对它进行分配担保的老年代就必须采用标记-清理和标记整理算法来回收。

HotSpot的算法实现

  • 枚举根节点:必须在一个能确保一致性的快照中执行,不可以出现在分析过程中对象引用关系还在不断变化的情况,否则分析结果将无法得到保证。这点是导致GC进行时,必须停顿所有JAVA执行线程(STOP THE WORLD)的其中一个重要原因。在类加载完成的时候,HotSpot就把对象内什么偏移量上是什么类型的数据计算出来,在JIT编译过程中,也会在特定的位置记录下栈和寄存器中哪些位置是引用。这样GC在扫描时就可以直接得知这些信息了。HotSpot的实现中,是使用一组称为OopMap的数据结构来达到这个目的。
  • 安全点:
    • HotSpot没有为每条指令都生成OopMap,只有在特定的位置记录了这些信息,这些位置称为安全点,即程序执行时并非在所有地方都能停顿下来开始GC,只有在到达安全点时才能暂停。例如方法调用、循环跳转、异常跳转等序列复用的功能指令才会产生Safepoint。
    • 另外一个需要考虑的问题是如何在GC发生时,让所有线程(不包括执行JNI调用的线程)都跑到最近的安全点上再停顿下来。有两种方式可供选择:
      • 抢先式中断:不需要线程的执行代码主动去配合,在GC发生时,首先先把所有线程全部中断,如果发现有线程中断的地方不在安全点上,就恢复线程,让它跑到安全点上。几乎没有虚拟机采用抢先式中断来暂停线程从而响应GC事件。
      • 主动式中断:当GC需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志,各个线程执行时主动去轮询这个标志,发现中断标志为真时就自己中断挂起,轮询标志的地方和安全点重合,另外再加上创建对象需要分配内存的地方。
  • 安全区域:Safepoint机制保证了程序执行时,在不太长时间内就会遇到可进入GC的safepoint。但程序不执行的时候,就是没有分配cpu时间,典型的例子是线程处于sleep状态或者blocked状态,这时线程无法响应JVM的重点请求,走到安全的地方去中断挂起,JVM显然也不太可能等待线程重新被分配cpu时间。对于这种情况,就需要安全区域(safe region)来解决。
    • 安全区域是指在一段代码片段中,引用关系不会再发生变化。在这个区域中的任意地方开始GC都是安全的,可以视为扩展了的safepoint。
    • 当线程执行到safe region中的代码时,首先标识自己已经进入了safe region,当在这段时间中jvm要发起gc时,就不用管标识自己为safe region状态的线程。在线程要离开safe region时,线程首先检查系统是否完成了根节点枚举(或者整个gc过程),如果完成了 ,那线程就继续执行,否则它必须等待直到收到可以安全离开safe region的信号为止。
  • 垃圾收集器:内存回收的具体实现
    • Serial收集器:单线程、采用复制算法的新生代收集器。在进行垃圾收集时,必须暂停所有其他的工作线程,直到它收集结束。现在依然是虚拟机运行在client模式下的默认新生代收集器,简单高效,适用于分配给虚拟机管理的内存比较小的场景下。
    • ParNew收集器:Serial收集器的多线程版本。是许多运行在Server模式下的虚拟机中首选的新生代收集器,其中有个与性能无关的原因但很重要的原因是,除了serial收集器外,目前只有它能与CMS收集器配合工作。
    • Parallel Scavenger收集器:新生代收集器,使用复制算法的收集器,并行的多线程收集器(使用多条垃圾收集线程工作,但此时用户线程仍然处于等待状态)。
      • 特点:关注吞吐量:cpu用于运行用户代码的时间与cpu总消耗时间的比值。
      • GC停顿时间越短越适合需要与用户交互的程序,良好的响应速度能提升用户体验,而高吞吐量可以高效率地利用CPU时间,尽快完成程序的运算任务,适合在后台运算而不需要太多交互的任务。
      • 自适应调节策略
  • Serial Old收集器:是serial收集器的老年代版本,单线程收集器,使用标记-整理算法。主要意义也是在于给client模式下的虚拟机使用,如果在server模式下,那么它主要还有两大用途:
    • 在JDK1.5以及之前的版本中与parallel scavenge收集器搭配使用。
    • 作为CMS收集器的后备预案,在并发收集发生Councurrent Mode Failure时使用。
  • Parallel Old收集器:parallel scavenger老年代收集器版本,多线程,使用标记-整理算法。
  • CMS(concurrent mark sweep)收集器:以获取最短回收停顿时间为目标的收集器。基于标记-清除算法实现。
    • 整个过程分为4个步骤:
      • 初始标记(会停顿):仅仅只是标记一下GC ROOTs能直接关联到的对象,速度很快,需要stop the world,即暂停其他工作线程。
      • 并发标记:与用户程序线程并发执行,耗时较长。
      • 重新标记(会停顿):修正并发标记期间因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一点,但远比并发标记的时间短。
      • 并发清除:与用户程序线程并发执行,耗时较长。
    • 缺点:
      • 对CPU资源非常敏感
      • 无法处理浮动垃圾:在cms并发清理阶段用户线程还在执行,伴随着程序运行自然会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,cms无法在当前收集中处理掉它们,只好留到下一次gc时再清理。这一部分称为浮动垃圾。
      • 基于标记-清除算法实现,会产生大量空间碎片,提供了合并整理的开关参数,内存整理的过程是无法并发的,停顿时间会变长。还提供了一个参数设置执行多少次不压缩的full gc后,跟着再来一次带压缩的full gc。
  • G1收集器

内存分配与回收策略

对象的内存分配,往大方向讲,就是在堆上分配(除了JIT编译后被拆散为标量类型并间接地在栈上分配)。对象主要分配在新生代的eden区上,如果启动了本地线程分配缓冲,将按线程优先在TLAB上分配。少数情况下也可能直接在老年代中分配,分配的规则不是固定的,取决于垃圾收集器组合和虚拟机的参数设置。

有以下几种内存分配策略:

  • 对象优先在Eden分配:大多数情况下,对象在新生代Eden区中分配,当eden区没有足够空间进行分配时,虚拟机会进行一次Minor GC(新生代的垃圾收集动作,频繁发生,回收速度也比较快)。注:Major GC/Full GC:老年代的GC,一般会比Minor GC慢10倍左右。
  • 大对象直接进入老年代:虚拟机提供了一个参数-XX:PretenureSizeThreshold,令大于这个设置值的对象直接在老年代分配,这样做的目的是避免在Eden区及两个Survivor区之间发生大量的内存复制。
  • 长期存活的对象将进入老年代:虚拟机给每个对象定义了一个对象年龄计数器。如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被survivor容纳的话,将被移动到Survivor空间中,并且对象年龄设为1。对象每在survivor去中熬过一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁),将会晋升到老年代中。可通过-XX:MaxTenuringThreshold设置。
  • 动态对象年龄判定:为了适应不同程序的内存状况,虚拟机并不是永远地要求对象的年龄必须达到了上述阈值才能晋升到老年代,如果在survivor空间中相同年龄所有对象大小的总和大于survivor空间一半,年龄大于等于该年龄的对象可以直接进入老年代,无需等到阈值中的年龄。
  • 空间分配担保:在发生minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于等于新生代所有对象总空间,如果这个条件成立,那么Minor GC可以确保是安全的。如果不成立,则虚拟机会查看Handlepromotionfailure设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小。如果大于,将尝试进行一次Minor GC,有可能会失败。如果小于,或者Handlepromotionfailure设置为否,那么这是要改为进行一次Full GC。大部分情况下都会打开这个开关。