JVM

深入理解Java虚拟机总结-垃圾回收和内存分配策略

Posted by AlstonWilliams on February 17, 2019

注:此文是我在读完周志明老师的深入理解Java虚拟机之后总结的一篇文章,请阅读此书获取更加详细的信息.

判断对象是否存活的算法

(1)引用计数算法:

每当一个地方引用一个对象时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为0的对象就是不可能再被使用的对象.

(2)可达性分析算法:

从”GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的.

可作为GC Roots的对象包括:

  • 在虚拟机栈中引用的对象
  • 本地方法栈中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象

引用

  • 强引用:我们普遍使用的引用,形如Object obj = new Object()这类,只要强引用还存在,垃圾回收器永远不会回收掉被引用的对象
  • 软引用:用来描述一些还有用但并非必须的对象.对于软引用关联着的对象,在系统将要发出内存溢出异常之前,将会把这些对象列进回收范围内进行第二次回收,如果回收后内存依旧不足才会抛出内存溢出错误
  • 弱引用:被弱引用引用的对象,在进行下一次垃圾回收时,无论内存是否充足,都会被回收掉
  • 虚引用:虚引用不会对对象的生存时间构成影响,也无法通过虚引用来获取一个对象实例.为一个对象设置虚引用关联的唯一目的就是能在这个对象被垃圾回收器回收时收到一个系统通知.

在可达性分析算法中,不可达的对象一定会死亡吗?

不一定会死亡,对象还是有机会进行自我拯救的,采用如下算法:

if(对象在可达性分析算法中不可达) {
  
  第一次标记并筛选此对象

  if(此对象被判定为有必要执行finalize()方法) {
  
    将对象放置在一个叫做F-Queue的队列中

    由虚拟机自动建立的,低优先级的Finalizer线程去执行它

    if(对象在finalize()方法中重新与引用链中的对象建立关联) {
    
      在第二次标记时,被移除出**即将回收**的队列

    } else {

      被回收

    }

  }

}

对象满足什么条件才被判定为没有必要执行finalize()方法?

  • ①对象并没有覆盖finalize()方法
  • ②finalize()方法已被执行过(所以对象只能进行一次自救)

另外,Finalizer线程并不会保证等待finalize()执行结束,是为了防止finalizer()方法中有死循环等阻塞此线程.

方法区的回收

在HotSpot中,方法区指的是永久带.

垃圾回收不仅发生在堆上,方法区也会进行垃圾回收,但是方法区的回收率一般比较低.

方法区进行垃圾回收时,主要回收两部分内容:废弃常亮和无用类

那么什么样的类才是无用类呢?

  • ①该类的所有实例已被回收
  • ②该类的类加载器已被回收
  • ③该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法

垃圾收集算法

(1).标记-清除算法:

缺点是:

  • ①内存碎片化
  • ②标记和清除的效率都不高

(2).复制算法:

将新生代按照8:1:1的比例划分为一块Eden区和两块Survivor区,每次进行垃圾收集时,都是将Eden区和Survivor区中的存活对象复制到另一块Survivor区中,然后清空Edent区和第一块Survivor区.

缺点是:

  • ①复制过程开销大
  • ②会改变对象的内存地址

    优点是:

  • 不会产生内存碎片

所以适用于对象存活比率较低的情况.

另外,Survivor空间并不总是足够的,当不够时,需要让老年代为其进行分配担保.

(3).标记-整理算法:

与标记-清除算法基本相同,但是在标记过程完成后,是将存活对象向一侧移动,然后清除另一侧的内存.

(4).分代算法:

即不同的代采用上面提到的不同算法.

对于新生代这种存活对象少,回收效率高的代,采用复制算法,而对于老年代这种对象存活率少的代,则采用标记-清除算法或者标记-整理算法.

垃圾收集器

Serial收集器

单线程垃圾收集器,只会使用一条线程完成GC,并且会Stop The World(即停止所有用户线程).

是Client模式下JVM的默认新生代收集器

ParNew收集器

跟Serial收集器有异同点.

相同点在于,都会Stop The World, 差别在于,ParNew收集器在进行垃圾收集时,会采用多线程.

ParNew收集器是Server模式下JVM的首选新生代收集器.

若老年代采用CMS收集器,则新生代技能采用Serial或者ParNew收集器.

当CPU少时,性能并不一定比Serial收集器好,因为存在线程切换的开销.

Parallel Scavenge收集器

与ParNew相同,都是采用复制算法以及多线程的新生代垃圾收集器.

它的关注点与其他收集器不同,CMS等收集器的关注点是尽可能地缩短垃圾收集时,用户线程的停顿时间,而Parallel Scavenge收集器的目的则是达到一个可控制的吞吐量.

吞吐量=(用户代码的执行时间) / (用户代码的执行时间 + 垃圾收集时间)

适合在后台运算而不需要太多交互的任务.

提供用于控制吞吐量的参数,设置的吞吐量越高,它就越有可能自动将新生代的空间缩小.

还提供了开启自适应调节策略的参数,开启之后就无需手工指定新生代的大小,Eden区与Survivor区的比例,晋升老年代对象大小等细节参数了,虚拟机会根据收集到的监控信息自动为我们调节.

Serial Old收集器

单线程老年代收集器,给Client模式下的虚拟机使用.

Parallel Old收集器

只能与Parallel Scanvage配合使用,在注重吞吐量以及CPU资源敏感的场合,可以考虑Parallel Scanvage + Parallel Old收集器.

CMS收集器

采用标记-清除算法的老年代收集器.

经历下面的四个步骤:

  • ①初始标记:标记一下GC Roots能直接关联到的对象,会Stop The World.
  • ②并发标记:采用可达性分析算法找出不可达对象
  • ③重新标记:修正并发标记期间由于用户线程而导致变动的那一部分记录,也会Stop The World
  • ④并发清理:清楚不可达对象

缺点:

  • CMS收集器对CPU资源非常敏感,在并发阶段,启用的线程数为(CPU数量+3)/4,所以可能会导致应用程序变慢
  • CMS无法处理浮动垃圾,所以需要预留足够的内存空间给用户线程使用
  • 会产生内存碎片,但是CMS中已经提供了对应的参数来优化这个问题.

G1收集器

G1收集器的处理过程跟CMS收集器差不多,还是下面的四个阶段:

  • 初始标记:仅仅标记一下GC Roots能直接关联到的对象,并且修改TAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可用的Region中创建对象,这阶段需要停顿线程,但是耗时很短.
  • 并发标记:从GC Root开始对堆中对象进行可达性分析,找出存活对象,这阶段耗时最长,但可与用户程序并发执行
  • 最终标记:修正在并发标记阶段因用户程序继续运行而导致标记产生变动的那一部分标记记录,可并行执行
  • 筛选回收:对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间来制定回收计划

G1和CMS的区别在哪里呢?

G1可以不依靠其他的垃圾回收器就独立管理整个堆.在G1之前的其他收集器进行收集的范围都是整个新生代或者老年代,而G1不再是这样.使用G1收集器时,Java堆的内存布局就与其他收集器有很大区别,它将整个Java堆划分为多个大小相等的独立区域,虽然还保留着新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分Region的集合.

G1和CMS的”标记-清理算法”不同,G1从整体上来看是基于”标记-整理算法”,从局部来看是基于”复制算法”,这意味着G1运行时不会产生内存碎片.

G1能够预测进行垃圾回收时停顿的时间,还能够让开发者明确指定垃圾回收的时间,消耗在垃圾回收上的时间不得超过这个时间.

G1能够实现这点是因为能够有计划的避免在整个Java堆中进行全区域的垃圾收集.G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需要的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region.这种使用Region划分内存空间以及有优先级的区域回收方式,保证了G1收集器在有限的时间内可以获取尽可能高的收集效率.

垃圾回收器参数总结

  • UseSerialGC:虚拟机运行在Client模式下的默认值,打开此开关后,使用Serial+Serial Old的收集器组合进行内存回收.
  • UseParNewGC:打开此开关后,使用ParNew + Serial Old的收集器组合进行内存回收
  • UseConcMarkSweepGC:打开此开关后,使用ParNew + CMS + Serial Old的收集器组合进行内存回收.Serial Old收集器将作为CMS收集器出现Concurrent Mode Failure失败后的后备收集器使用
  • UseParallelGC:虚拟机运行在Server模式下的默认值,打开此开关后,使用Parallel Scavenge + Serial Old(PS MarkSweep)的收集器组合进行内存回收
  • UseParallelOldGC:打开此开关后,使用Parallel Scavenge + Parallel Old的收集器组合进行内存回收
  • SurvivorRatio:新生代中Eden区与Survivor区的容量比值,默认为8,代表Eden:Survivor=8:1
  • PretenureSizeThreshold:直接晋升到老年代的对象大小,设置这个参数后,大于这个参数的对象将直接在老年代进行分配
  • MaxTenuringThreshold:晋升到老年代的对象年龄.每个对象在坚持过一次MinorGC之后,年龄就增加1,当超过这个数值时,就进入老年代
  • UseAdaptiveSizePolicy:动态调整Java堆中各个区域的大小以及进入老年代的年龄
  • HandlePromotionFailure:动态允许分配担保失败,即老年代的剩余空间不足以应付新生代的整个Eden和Survivor区的所有对象都存活的极端情况
  • ParalleleGCThreads:设置并行GC时进行垃圾回收的线程数
  • GCTimeRatio:GC时间占总时间的比率,默认值为99,即允许1%的GC时间.仅在使用Parallel Scavenge收集器时生效
  • MaxGCPauseMillis:设置GC的最大停顿时间.仅在使用Parallel Scavenge收集器时生效.
  • CMSInitiatingOccupancyFraction:设置CMS收集器在老年代空间被使用多少后触发垃圾收集.默认值为68%,仅在使用CMS收集器时生效
  • UseCMSCompactAtFullCollection:设置CMS收集器在完成垃圾收集后是否要进行一次内存碎片整理.仅在使用CMS收集器时生效
  • CMSFullGCsBeforeCompaction:设置CMS收集器在进行若干次垃圾回收后再启动一次内存碎片整理.仅在使用CMS收集器时生效.

内存分配与回收策略

对象主要分配在新生代的Eden区中,如果启动了本地线程分配缓冲,将按线程优先在TLAB上分配.少数情况下也可能直接分配在老年代中,分配的规则并不是百分之百固定的,其细节取决于当前使用的是哪一种垃圾回收器组合,还有虚拟机中与内存相关的参数的设置.

新生代的可用空间为Eden区+1个Survivor区的总容量.

大小超过-XX:PretenureSizeThreshold参数设定的尺寸的对象,将直接在老年代中分配.

如果对象在Eden出生并经过第一次MinorGC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并且对象年龄设为1.对象在Survivor区中每”熬过”一次Minor GC,年龄就增加1岁,当他的年龄增加到一定程度(默认为15岁),就将会被晋升到老年代中.对象晋升老年代的年龄阈值,可以通过-XX:MaxTenuringThreshold参数设置.

为了能更好的适应不同程序的内存状况,虚拟机并不是永远要求对象的年龄必须达到MaxTenuringThreshold才能晋级入老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到MaxTenuringThreshold所要求的年龄.

在发生Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么Minor GC可以确保是安全的.如果不成立,则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败.如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次Minor GC,尽管这次Minor GC是有风险的;如果小于,或者HandlePromotionFailure设置为不允许冒险,那这时也要改为进行一次Full GC.