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


概述

  1. 引用计数算法
    • 判断对象是否存活的一种算法: 给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1,当引用失效时,计数器值就减1;任何适合计数器都为0的对象就是不可能带被使用的
    • Java语言没有选用引用计数算法来管理内存,因为此算法存在缺陷,它很难解决对象之间的互相循环引用问题
  2. 根搜索算法
    • 在主流的商用程序语言中(Java和C#,甚至古老的Lisp),都是使用跟搜索算法(GC Roots Trancing)判定对象是否存活的
    • 算法基本思路是通过一系列的名为”GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径被称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连(即图论中从GC Roots到这个对象不可达)时,则证明此对象先是不可用的
    • Java语言中,可作为GC Roots的对象包括下面几种:
      • 虚拟机栈(栈帧中的本地变量表)中的引用对象
      • 方法区中的类静态属性引用的对象
      • 方法区中的常量引用的对象
      • 本地方法栈中JNI(Native方法)的引用对象
  3. 引用分类
    • JDK1.2之后,Java对引用的概念进行了扩充,将引用分为强引用(Strong Reference)丶软引用(Soft Reference)丶弱引用(Weak Reference)丶虚引用(Phantom Reference) 四种
    • 强引用 就是指在程序代码中普遍存在的,类似”Object obj = new Objcet()”,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象
    • 软引用 用来描述一些还有用,但并非必须的对象,对于软引用关联的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中并进行第二次回收,如果这次回收还是没有足够的内存,才会抛出内存溢出异常,在JDK1.2之后,提供了SoftReference类来实现软引用
    • 弱引用 也是用来描述非必须对象的,但是他的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前,当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被若引用关联的对象,JDK1.2之后,提供了WeakReference 类实现若引用
    • 虚引用 也被称为幽灵引用或者幻影引用,它是最弱的一种引用关系,一个对象是否有虚引用存在,完全不会对其生存时间构成影响,也无法通过虚引用来取的一个对象实例,为一个对象设置虚引用关联的唯一目的就是希望能在这个对象被收集器回收时收到一个系统通知.JKD1.2之后,提供了PhantomReference 类来实现虚引用
  4. 对象回收过程
    • 在跟搜索算法中不可达的对象,也并非是”非死不可”的,这个时候他们暂时处于”唤醒”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程
      • 如果对象在进行根搜索后发现没有与GC Roots相连接的引用链,那它将会被第一次标记并且进行一次筛选
      • 筛选的条件是次对象是否有必要执行finalize()方法,当对象没有重写finalize()方法,或者finalize()方法已经被虚拟机调用过时,虚拟机将这两种情况都视为”没有必要执行”
      • 如果这个对象被判定为有必要执行finalize()方法,那么这个对象会被放置在一个名为F-Queue的对象中,并在稍后又一条虚拟机自动建立的丶低优先级的Finanlizer线程去执行(仅触发这个方法,不承诺会等待它运行结束)
      • 稍后GC将对F-Queue中的对象进行第二次小规模标记 ,在此之前finalize()方法执行可以拯救对象被回收的命运,只要重新与引用链上的任何一个对象建立关联即可(如this赋值给某个类变量或者对象的成员变量)
      • 不建议使用finalize()方法
  5. 回收方法区
    • 很多人认为方法区(或者HotSpot虚拟机中的永久代)是没有垃圾收集的,Java虚拟机规范中确实说过可以不要求虚拟机在方法区实现垃圾收集,而且在方法区进行垃圾手机的”性价比”一般比较低
    • 永久代的垃圾收集主要回收两部分内容: 废弃常量(如常量池中) 和 无用的类
    • 类需要同时满足下面三个条件才能算 无用的类
      • 该类所有的实例都已经被回收,也就是Java堆中不存在该类的任何实例
      • 加载该类的ClassLoader已经被回收
      • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法
    • 虚拟机可以对满足上述三个条件的无用类进行回收,这里说的仅仅是”可以”,但是是否对类进行回收,HotSpot虚拟机提供了-Xnoclassgc参数进行控制,还可以使用-verbose:class及-XX:+TraceClassLoading丶-XX:+TraceClassUnloading查看类的加载和卸载信息,-verbose:class和-XX:+TraceClassLoading可以在Product版的虚拟机中使用,但是-XX:+TranceClassUnLoading参数需要fastdebug版的虚拟机支持
    • 在大量使用反射丶动态代理丶CGlib等bytecode框架的场景,以及动态生成jsp和OSGi这类品频繁自定义ClassLoader的场景都需要虚拟机具备类卸载的功能,以保证永久代不会溢出

垃圾收集算法

  1. 标记-清除算法(Mark-Sweep)
    • 最基础的收集算法是”标记-清除”(Mark-Sweep)算法,算法分为”标记”和”清除”两个阶段,后续的收集算法都是基于这种思路的改进版本
    • 首先标记出所有需要回收的对象,在标记完成后统一回收掉所有被标记的对象
    • 它的主要缺点有两个: 一个是效率问题,标记和清除过程的效率都不高;另外一个是空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当程序在以后的运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作
  2. 复制算法(Copying)
    • 为了解决效率问题,一种称为”复制”(Copying)的收集算法出现了,它将可用内存按容量划分为大小相等的凉快,每次只使用其中一块,当这块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉
    • 优点: 不用在考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效
    • 缺点: 这种算法将内存缩小为原来的一半,代价太大
    • 现在的商业虚拟机都采用这种收集算法来回收新生代,IBM的专门研究表明,新生代中的对象98%都是朝生暮死的,所以并不需要按照1:1的比例来划分内存空间,而是将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中的一块Survivor,当回收时将Eden和Survivor中还存活着的对象一次性的拷贝到另外一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor空间(当Survivor空间不够用时,需要依赖其他内存(老年代)进行分配担保)
    • HotSpot虚拟机默认Eden和Survivor的大小比例为8:1
  3. 标记-整理算法(Mark-Compact)
    • 复制收集算法在对象存活率较高时需要执行较多的复制操作,效率将会变低,在老年代中一般不能直接选用这种算法
    • 标记整理算法的标记过程仍然与”标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存
  4. 分代收集算法(Generational Collection)
    • 当前商业虚拟机的垃圾收集都采用”分代收集”(Generational Collection)算法,根据对象的存活周期的不同将内存划分为几块,一般把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适合的收集算法
    • 新生代中,对象大多朝生暮死,因此选用复制算法
    • 老年代中,对象存活率高,因此选用”标记-清除”或者”标记-整理”算法

垃圾收集器

  1. 概述
    • Java虚拟机规范中对垃圾收集器应该如何实现并没有任何规定,因此不同厂商丶不同版本的虚拟机所提供的垃圾收集器都可能会有很大差别,并且一般都会提供参数供用户根据自己应用的特点和要求组合出各个年代所使用的收集器
    • 下面讨论的收集器是基于Sun HotSpot虚拟机1.6版Update22,这个虚拟机包含了7个垃圾收集器Serial丶ParNew丶paralle Scavenge丶G1丶CMS丶Parallel Old丶Serial Old(MSC)
    • 可从网络查找详细的搭配关系图(是否可以搭配使用),
  2. Serial收集器
    • Serial收集器是最基本丶历史最悠久的收集器,这个收集器是单线程收集器,当它进行垃圾收集时,必须暂停其他所有的工作线程(Stop The World),直到它收集结束,对比其他收集器而言,主要优点是简单高效
    • Serial收集器对于运行在Client模式下的虚拟机来说是一个很好的选择
  3. ParNew收集器
    • ParNew收集器其实就是Serial收集器的多线程版本,除了使用多线程进行垃圾收集之外,其余和Serial收集器完全相同
    • 它是许多运行在Server模式下的虚拟机中首选的新生代收集器,其中一个原因是目前除了Serial收集器外, 只有它能与CMS收集器(划时代)配合使用
  4. Parallel Scavenge收集器
    • Parallel Scavenge收集器也是一个新生代收集器,他也是使用复制算法的收集器,又是并行的多线程收集器
    • Parallel Scavenge收集器的目标是达到一个可控制的吞吐量(Throughput)
    • 停顿时间越短就越适合需要与用户交互的程序;而高吞吐量则可以最高效率的利用CPU时间,尽快的完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务
    • 参数-XX:MaxGCPauseMillis控制最大垃圾收集停顿时间,-XX:GCTimeRatio参数设置吞吐量大小
    • 参数-XX:+UseAdapttiveSizePolicy是一个开关参数,打开之后就不需要手工指定新生代的大小(-Xmn)丶Eden与Survivor区的比例(-XX:SurvivorRatio)丶晋升老年代对象年龄(-XX:PretenureSizeThreshold)等细节参数了,虚拟机会根据情况动态调整
  5. Serial Old收集器
    • Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器,使用”标记-整理”算法
    • 这个收集器主要意义是被Client模式下的虚拟机使用,在Server模式下,还有两大用途:一个是与Parallel Scavenge收集器搭配,另一个是作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure的时候使用
  6. Parallel Old收集器
    • Parallel Old是Parallel Scavenge收集器的老年代版本,使用多线程和”标记-整理”算法
    • 在注意吞吐量及CPU资源敏感的场合,都可以优先考虑Parallel Scavenge+Parallel Old收集器组合
  7. CMS收集器
    • CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器,目前B/S系统中,这类应用尤其重视服务的响应速度,CMS收集器就非常符合这类应用的需求
    • CMS收集器是基于”标记-清除”算法实现的,整个过程稍微复杂,分为4个步骤:
      • 初始标记(CMS initial mark)
      • 并发标记(CMS concurrent mark)
      • 重新标记(CMS remark)
      • 并发清除(CMS concurrent sweep)
    • 其中初始标记丶重新标记两个步骤仍然需要”Stop The World”,初始标记仅仅是标记一下GC Roots能直接关联到的对象,速度很快,并发标记阶段就是进行GC Roots Tracing的过程,而重新标记阶段则是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短
    • CMS主要有点就是并发收集丶低停顿 ,但同时也有三个显著缺点:
      • CMS收集器对CPU资源非常敏感,并发阶段默认启动的回收线程数是(CPU数量+3/4),当CPU越少时,垃圾收集线程占用的CPU资源就越大
      • CMS收集器无法处理浮动垃圾(Floating Garbage),即CMS清理阶段用户线程还在运行,在此过程产生的垃圾,CMS会留待下一次GC时清理掉,同时在垃圾收集阶段用户线程还需要运行,则需要预留足够的内存空间给用户线程使用,如果预留的内存无法满足程序需要,就会出现一次”Concurrent Mode Failure”失败,这时候虚拟机将启动后备预案:临时启动Serial Old收集器来重新进行老年代的垃圾收集,这样停顿时间就很长,因此参数-XX:CMSInitiatingOccupancyFraction设置的太高将很容易导致大量失败,性能反而降低
      • CMS采用”标记-清除”算法会产生大量空间碎片,当无法找到足够大的连续空间分配对象时,不得不提前触发一次Full GC,参数-XX:+UseCMSCompactAtFullCollection开关用于享受完”Full GC”之后额外附送一个碎片整理过程,参数-XX:CMSFullGCsBeforeCompaction用于设置在执行多少次不压缩的Full GC后,跟着来一次压缩
  8. G1收集器
    • G1收集器是基于”标记-整理”算法实现的,也就是说不会产生空间碎片,这对于长时间运行的应用系统来说非常重要
    • 第二个特点是它可以非常精确的控制停顿(用户可指定)
    • G1收集器可以实现在基本不牺牲吞吐量的前提下完成低停顿的内存回收
    • G1将整个Java堆(新生代丶老年代)划分为多个大小固定的独立区域(Region),并且跟踪这些区域里面的垃圾堆积程度,在后台维护一个优先列表,每次根据允许的收集时间,优先回收垃圾最多的区域(这就是Garbage First名称由来)
    • 区域划分及优先级的区域回收,保证了G1收集器在有限的时间内可以获得更高的收集效率

内存分配策略

  1. 对象优先在Eden分配
    • 大多数情况下,对象在新生代Eden区中分配,当Eden区没有足够的空间进行分配时,虚拟机将发起一次Minor GC(转移到Survivor空间,如果不够就会通过分配担保机制提前转移到老年代中去)
  2. 大对象直接进入老年代
    • 大对象指,需要大量连续内存空间的Java对象(如很长的字符串及数组)
    • 设置-XX:PretenureSizeThreshold参数,可以令大于这个设置值的对象直接在老年代中分配(只对Serial和ParNew收集器有效)
    • 大对象放入老年代的目的是避免在Eden区及两个Survivor区之间发生大量的内存拷贝
  3. 长期存活的对象将进入老年代
    • 虚拟机给每个对象定义了一个对象年龄(Age)计数器,如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并将对象年龄+1,当它的年龄增加到一定程度(默认15)时,就会被晋升到老年代中,年龄阈值,可以通过参数-XX:MaxTenuringThreshold设置
  4. 动态对象年龄判定
    • 为了更好适应不同程序的内存状况,虚拟机并不总是要求对象的年龄必须达到MaxTenuringThreshold才能晋升老年代
    • 如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进去老年代
  5. 空间分配担保
    • 在发生Minor GC(清理新生代)时,虚拟机会检测之前每次晋升到老年代的平均大小是否大于老年代的剩余空间大小,如果大于,则改为直接进行一次Full GC(清理整个堆),如果小于,则查看HandlePromotionFailure设置是否允许担保失败;如果允许,那只会进行Minor GC;如果不允许,则也要改为进行一次Full GC
    • 老年代进行分配担保,大部分情况下都会将HandlePromotionFailur(担保失败)开关打开,避免Full GC过于频繁

文章作者: Bryson
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 Bryson !
评论
 上一篇
下一篇 
  目录