likes
comments
collection
share

从实际案例聊聊Java应用因为Eden区设置不当导致的慢GC问题

作者站长头像
站长
· 阅读数 4

JVM的内存结构

在虚拟机中,不同的对象存活的时间是不一样的,为了区别出不同年龄的对象,更好的管理他们,目前主流的虚拟机采用都是分代算法。

在JVM中,会内存划分为三块,分别是新生代,老年代,以及持久代。如图:

从实际案例聊聊Java应用因为Eden区设置不当导致的慢GC问题

其中新生代又划分为Eden、Survivor0、Survivor1 三个区,为什么要划分为三个区呢?这要从GC算法讲起。

常见的GC算法

  • 标记-清除

    标记-清除是最基础的收集算法,标记清除分为两个阶段:首先是标记需要回收的对象,标记完成后,统一回收所有被标记的对象。标记清除的缺点是非常明显的,就是会产生大量的内存碎片,同时,标记清除的效率不高。如图: 从实际案例聊聊Java应用因为Eden区设置不当导致的慢GC问题

  • 复制算法

    为了解决效率问题,出现了复制算法。复制算法,顾名思义就是将没有有引用的对象复制到另外一块内存中去,再把使用过的内存进行回收,这样做的好处就是再也不用考虑内存碎片,坏处就是内存变成两块之后,每次只能用一块。如图:

    从实际案例聊聊Java应用因为Eden区设置不当导致的慢GC问题

    在商业实践中,人们发现新生代中的对象98%以上都是朝生夕死的,并不需要按照1:1来划分内存空间,一般是划分为一个较大的Eden区和两个较小的Survivor区,每次只使用Eden区和其中的一个Survivor区。当进行内存回收的时候,就将Eden区和其中一块Survivor存活的对象复制到另外一块Survivor上,最后清理完Eden区和刚才使用Survivor区。

    在目前Hotspot虚拟机中,Eden区和Survivor的比例是8:1,这样每次最多只有1/10的内存被浪费掉,避免1/2的内存处于空闲。

  • 标记整理

    复制算法虽然解决了内存碎片问题,但是如果对象不是朝生夕死的话,复制算法每次都要把存活的对象搬过来搬过去,效率会非常低。另外一点是,我们在新生代采用复制算法,但是并不是所有的对象都可以在新生代得到分配的,往往一些大对象可能需要直接分配在老年代。

    因此,在老年代中,一般不会使用复制算法,而是使用标记-整理算法。

    标记-整理和标记-清除算法是一样的,但是相比标记-清除多了一步,就是将存活的对象进行移动,放在一起,然后清除到其他的空间。如图

    从实际案例聊聊Java应用因为Eden区设置不当导致的慢GC问题

分代收集

目前,商业虚拟机都使用分代收集算法,根据对象的存活周期不一样将内存划分为几块,一般是老年代和新生代,根据每个不同的年代的特点就可以采用不同的收集算法。在实践中,一般新生代采用复制算法,老年代采用标记-整理算法,或者标记-清除。

ParNew 收集器

ParNew垃圾收集器是上面所说的复制算法的一种实现,他的前身是Serial,Serial看他的名字就知道单线程收集器,它工作原理如下:(图来源网络)

从实际案例聊聊Java应用因为Eden区设置不当导致的慢GC问题

Serial使用一个GC线程,特点就是简单,效率高,容易实现,目前Client模式上,Serial是默认的GC的收集器。

相对于Serial,ParnNew收集器就是多线程版本,使用多线程可以大大减少收集时间。如图:

从实际案例聊聊Java应用因为Eden区设置不当导致的慢GC问题

目前主流使用CMS收集器在新生代默认使用的就是ParnNew收集器,今天要讲的主要内容也是ParnNew在新生代调优注意的问题,在调整之前,先来看一个案例。

案例

在我们线上环境的HBase服务器中,在我没有插手的时候,运维工程师使用的默认配置,在GC日志出现了如下问题。

从实际案例聊聊Java应用因为Eden区设置不当导致的慢GC问题

日志有什么问题呢?

首先是GC时间过长,在[Times: user=110.02, sys=0.02 , real-3.59 secs] 可以看到,GC时间超过了3秒,对于一个数据库服务来说,特别是这种KV数据库,出现超过几秒是不应该的。再仔细看日志,在每一次yong gc 的时候,数据都直接进入到O区,一些对象本来应该再Young区就被GC掉,进去到了O区,导致Old区增加,触发FullGC, 严重影响了应用服务的性能。

原因分析

上面说过在新生代中,整个内存划分为Eden区和S0,S1三个部分,在ParNew收集器,默认比例是8:1:1,这个是通过参数–XX:SurvivorRatio这个参数的实现的。

每一次Minor gc 的时候,收集器会将Eden和其中一个S区中还存活的对象复制到另外一个S区当中。默认8的比例是OK的,超过98%的对象都是朝生夕死的。整个GC的过程如下:

从实际案例聊聊Java应用因为Eden区设置不当导致的慢GC问题

先来介绍一些对象年龄。

在JVM中,对象每经历一次Minor GC,年龄加1,达到“晋升年龄阈值”后,被放到老年代,这个过程也称为“晋升”。显然,“晋升年龄阈值”的大小直接影响着对象在新生代中的停留时间,在Serial和ParNewGC两种回收器中,“晋升年龄阈值”通过参数MaxTenuringThreshold设定,默认值为15。

影响晋升的年龄其他原因

从上面的日志看到,尽管是MaxTenuringThreshold是15,但是当对象是age=1的时候,对象也晋升到老年代,这是为什么呢?

原来是,在Hotspot里面,使用的是动态年龄来决定对象是否晋升到老年代。

动态年龄计算:Hotspot遍历所有对象时,按照年龄从小到大对其所占用的大小进行累积,当累积的某个年龄大小超过了survivor区的一半时,取这个年龄和MaxTenuringThreshold中更小的一个值,作为新的晋升年龄阈值。在本案例中,调优前:Survivor区 = 64M,desired survivor = 32M,此时Survivor区中age<=2的对象累计大小为41M,41M大于32M,所以晋升年龄阈值被设置为2,下次Minor GC时将年龄超过2的对象被晋升到老年代。

JVM引入动态年龄计算,主要基于如下两点考虑:

如果固定按照MaxTenuringThreshold设定的阈值作为晋升条件:

  • MaxTenuringThreshold设置的过大,原本应该晋升的对象一直停留在Survivor区,直到Survivor区溢出,一旦溢出发生,Eden+Svuvivor中对象将不再依据年龄全部提升到老年代,这样对象老化的机制就失效了。
  • MaxTenuringThreshold设置的过小,“过早晋升”即对象不能在新生代充分被回收,大量短期对象被晋升到老年代,老年代空间迅速增长,引起频繁的Major GC。分代回收失去了意义,严重影响GC性能。

相同应用在不同时间的表现不同:特殊任务的执行或者流量成分的变化,都会导致对象的生命周期分布发生波动,那么固定的阈值设定,因为无法动态适应变化,会造成和上面相同的问题。

总结来说,为了更好的适应不同程序的内存情况,虚拟机并不总是要求对象年龄必须达到Maxtenuringthreshhold再晋级老年代。

另外一个情况是当在Young区生成的对象过大的时候,我们可以选择直接进入老年代。Serial和ParNew提供一个-XX:PretenureSizeThreadhold参数,令大于这个参数值的对象直接在老年代中分配,避免在Eden区和两个Survivor区发生大量的内存拷贝。

回到案例

通过以上的分析,就知道日志中存在的问题,因为在新生代中age=1的内存超过S区的1/2,对象直接晋升到老年代,针对这个想象,可以通过调整SurvriorRatio的大小,来增加S区的空间,减少Eden的区,从而让对象熬过几次GC后再晋升老年代。除了调整SurvriorRatio的比例大小之后,新生代中也可以通过调整Young区的大小来改善GC情况。

新生性能调整方式

总结一些,新生代的性能优化方式,有以下几种:

  • Young大小的调整
  • SurvriorRatio的调整
  • 晋升年龄大小的设置
  • GC线程的数量的设置