基于JVM系统运行的过程剖析
首先我们还是通过一步一图的方式,将我们整个系统基于JVM跑起来后所涉及到的一些核心知识进行串联以及总结回顾,这样大家在头脑里先有一个整体的思维过程,加深印象,然后我们再结合一些案例来逐步讲解如何针对一些系统进行优化。
①对象进年轻代
系统启动后,肯定会创建对象,对象肯定存在我们的JVM内存,而且会分配到年轻代的Eden区中:
②年轻代装满触发GC
当系统不断运行,对象越来越多,导致Eden无法装下,那么就会触发年轻代垃圾回收,年轻代主要通过复制算法进行回收。
那么如何进行复制呢?首先会通过可达性分析算法来标记哪些对象是GC Roots的,能被当做GC ROOTS对象的一般两种:
- 方法内的局部变量
- 类的静态变量
JVM会将存活的对象统一拷贝到幸存者区域也就是Survivor,占年轻代内存的10%,如下图:
注意:新生代一旦发生GC,那么必定会导致“Stop the World”,以后简称 STW,让系统停止运行。
③年轻代垃圾回收
此时垃圾回收线程开始工作,这里我们默认使用并发收集器 ParNew
④思考:年轻代GC回收对系统影响大不?
年轻代的GC回收基于复制算法,一般这个过程都会非常的快,比如几十ms即可回收完毕,但是大家要注意的一点就是,年轻代的回收是会触发STW,让系统停止的,不过一般我们实际线上部署,机器配置都是2核4G或4核8G,只要给堆内存分配的空间足够,一般来讲不需要对年轻代的gc进行调优。大多数情况下的系统,一般也就几分钟或者几十分钟才会进行一次新生代的GC,卡顿时间也较少,对于用户而言几乎没有太大的感觉。
那么什么时候需要关注新生代的GC调优呢?
当我们系统部署在大内存机器上的时候,比如32核64G的机器,此时可能年轻代的内存就会分配到40G!比如像Kafka、Elasticsearch之类的大数据系统,都是部署在这种大内存的机器,而且并发访问量相当高,那么有可能我们的Eden区1分钟就会装满,然后触发一次MinorGC,而我们的年轻代基于复制算法,这个过程由于对象太庞大,标记复制也是需要耗费一定的时间,像这样大的内存产生的对象可能回收就得要几秒钟,那么就会导致一个可怕的现象:系统每运行1分钟就会导致卡死几秒,而我们的前端请求一般可能在2秒内无响应就直接反馈给用户了,这种体验肯定是相当不好的。
如何解决大内存机器的年轻代GC过慢问题?
答案就是:使用G1垃圾回收器
由于G1收集器可以设置一个停顿的时间,比如默认200ms,那么基于G1收集器回收的特点,我们就可以让系统每隔200ms就进行一次垃圾回收,这样可以让我们的系统几乎在不受影响的情况下,边执行边回收垃圾,同时也不会给用户带来卡顿的使用体验。
因此小结下:对于年轻代的垃圾回收,一般不存在太大的问题,对于大内存机器我们使用G1回收期即可,而我们更多需要关注和调优的地方在于老年代!
⑤对象进老年代
首先 我们重新梳理下对象要进入老年代的几个条件:
对象在新生代中熬过15次GC,年龄达15岁进入老年代,这种对象一般较少一般都是系统中确实需要长期存在的核心组件。
动态年龄判断规则,比如一次GC过后,Survivor区中的几个年龄对象加起来超过Survivor区内存的50%,比如年龄1 + 年龄2 + 年龄3的对象总和超过50%Survivor区,此时就会把年龄3以上的对象放入老年代。
这里补充说明下动态年龄规则判断算法:Survivor区中的对象年龄从小到大进行累加,当累加到X年龄时的总和大于50%(可以通过参数 -XX:TargetSurvivorRatio=?来设置保留多少空间空间,默认值是50),那么比X大的都会进入老年代
大对象直接进老年代,可以通过参数-XX: PretenureSizeThreshold参数 设置
新生代垃圾回中存活对象太多无法放入Survivor中,通过空间担保原则进入老年代
如果Survivor区空间太小,就会导致我们以上的条件2和4频繁发生,然后导致大量对象进入老年代,从而频繁触发老年代的垃圾回收。
当然CMS的老年代回收,包括:初始标记、并发标记、重新标记、并发清理、碎片整理等环节,如果有同学已经忘记了请查看之前的CMS收集器内容进行复习。
这里重点想说的是,一旦触发老年代GC那么耗时至少比新生代GC慢10倍以上,因此一旦JVM内存分配不合理,导致频繁触发老年代CG对于系统用户来说就是糟糕的体验。
因此我们常说的JVM优化,到底在优化什么??
你的代码编写是否有问题,内存分配,参数设置是否合理,有没有导致对象频繁的进入老年代,然后频繁触发老年代GC,导致系统频繁的每隔几分钟就要卡死几秒!那么JVM性能优化也就是优化这些东西,尽量减少触发老年代的GC这就是我们的目标。
⑥Yong GC/Minor GC 触发时机
新生代的GC触发相信大家应该很熟悉了,一旦当Eden区满了后就会触发,并且采用复制算法来回收。
⑦OldGC和Full GC触发时机
第一种情况:
老年代连续空间小于新生代历次YongGC晋升到老年代的对象的平均值
之前针对老年代的空间担保原则时我们画过一张详细的图非常清晰,我们再次拿来回顾下:
JVM在Minor GC之前,当判断到老年代的可用内存已经小于新生代的全部对象大小,会看一个参数:“-XX: HandlePromotionFailure”是否设置了。如果有该参数的设置,那会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小。当判断到历次平均大小是小于老年代可用内存空间的,将尝试进行一次Minor GC,尽管这次Minor GC是有风险的;如果小于,或者-XX: HandlePromotionFailure没有设置,那这时就要改为进行一次Full GC。
注意这里为什么执行的是Full GC呢而不是Old GC,理论上来说就是Old GC,只不过当Old GC执行完了后肯定还会执行一次Yong GC,所以我们直接用了Full GC进行表示。
而且我们之前也明确说明了是在发生Minor GC之前对上述流程的一个判断,因此其实不管最终走不走Old GC,最后都会执行一次Minor GC,前提是没有发生OOM异常。
第二种情况:
执行YongGC之后,有一批对象需要放入老年代,而此时老年代没有足够的内存空间存放,此时必须触发一次Old GC。
就像上图中所示一样,假如JVM开启冒险模式进行一次YongGC,但是执行完后了后发现老年代空间还是不足与存放,那么此时必然触发Old GC的执行。
第三种情况:
当老年代内存使用超过了92%,也要直接触发Old GC,当然这个比例是可以通过参数调整的。
总结一下就是:当老年代空间不足,没法放入更多的对象时,就会触发Old GC进行垃圾回收。
其实大家也发现了,我们上述在分析老年代垃圾回收的触发时机时,也能知晓,要么先进行新生代回收,再触发老年代回收,要么老年代回收后也会触发一次MinorGC。其实当上述条件达到触发的就是Full GC了,而不是单纯的Old GC。在后续的实操案例中我们也可以通过日志观察到,GC日志中显示的就是Full GC,而不是单纯的Old GC,Full GC的触发是包含Yong GC,Old GC,永久代的GC。
一般来讲永久代的空间都是足够的,不会存在太多的垃圾,里面都是存放的一些常量池之类的数据,并且StringTable从JDK7开始也移动到了堆内存,因此永久代一般来说可回收的垃圾很少,如果真的出现了永久代满了,那么系统也就直接OOM了。
如果大家能将以上内容完全掌握理解,那么我相信大家的JVM理论核心知识点已经掌握的非常不错了!
后续我们结合一些工具和实操案例,或者结合各位自己当下公司的系统来进行JVM调优,分析,大家一定能彻底的将JVM给熟练于心,并且不会轻易忘记,如果每次问到你JVM相关内容,你还需要去看看笔记或者博客,说明当时理解清楚了,并没有实际运用到公司中。因此,后续的一些实际案例希望大家能结合自己的系统多去思考,实操练习!