JVM实战笔记

1. 可达性分析法中的GC Roots:方法的局部变量、类的静态变量。

2. 对象引用类型:

(1)强引用(即最普通的对象引用)对象:在垃圾回收的时候是绝对不会被回收的;

(2)软引用(SoftReference)对象:正常情况下垃圾回收是不会回收软引用对象的,但是如果进行垃圾回收之后,发现内存空间还是不够存放新的对象,内存都快溢出了,此时就会把这些软引用对象给回收掉,哪怕他被变量引用了,但是因为它是软引用,所以还是要回收;

(3)弱引用(WeakReference)对象:弱引用对象跟没引用似的,只要发生垃圾回收就会被回收掉;

3. 对象进入老年代的几种情况:

(1)躲过15次年轻代GC之后进入老年代:对象每次在新生代里躲过一次GC被转移到一块Survivor区域中,它的年龄就会增长一岁。默认的设置下,当对象的年龄达到15岁的时候,也就是躲过15次Young GC的时候,它就会被转移到老年代里面去。

(2)动态对象年龄判断:年龄1+年龄2+年龄n(年龄从小到大进行累加)的多个年龄对象总和超过了Survivor区域的50%,此时就会把年龄n及以上的对象都放入老年代。无论15岁的那个规则,还是动态年龄判断的规则,都是希望那些可能是长期存活的对象,尽早进入老年代。

(3)大对象直接进入老年代:之所以这么做,就是为了避免新生代里出现那种大对象,然后屡次躲过GC,还得把它在两个Survivor区域里来回复制多次之后才会进入老年代。

(4)Young GC后的对象太多无法放入Survivor区:这个时候就必须得把这些对象直接转移到老年代去。

4. 老年代空间分配担保机制

如果Young GC后新生代里有大量对象存活下来,确实是自己的Survivor区放不下了,必须转移到老年代去,那么如果老年代里空间也不够放这些对象呢?

首先,在执行任何一次Young GC之前,JVM会先检查一下老年代里可用的内存空间(最大可用连续内存空间),是否大于新生代所有对象的总大小。为啥检查这个呢?因为最极端的情况下,可能新生代Young GC过后,所有对象都存活下来了,那岂不是新生代所有对象全部要进入老年代?

如果发现老年代的可用内存大小是大于新生代所有对象的,此时就可以放心大胆的对新生代发起一次Young GC了,因为即使Young GC之后所有对象都存活,Survivor区放不下了,也可以转移到老年代里去。

但是假如执行Young GC之前,发现老年代的可用内存已经小于新生代的全部对象大小了,那么这个时候是不是有可能在Young GC之后新生代的对象全部存活下来,然后全部需要转移到老年代去,但是老年代空间又不够?理论上,是有这种可能的。

如果设置了虚拟机参数HandlePromotionFailure,则会继续尝试进行下一步判断。

下一步判断,就是看看老年代的可用内存大小,是否大于之前每一次Young GC后进入老年代的对象的平均大小。

如果上面那个步骤判断失败了,或者是"-XX:-HandlePromotionFailure"参数没设置,此时就会直接触发一次"Full GC",就是对老年代进行垃圾回收,尽量腾出来一些内存空间,然后再执行Young GC。如果上面两个步骤都判断成功了,那么就是说可以冒点风险尝试一下Young GC。此时进行Young GC有几种可能结果:

第一种可能,Young GC过后,剩余的存活对象的大小,是小于Survivor区域的大小的,那么此时存活对象进入Survivor区域即可。

第二种可能,Young GC过后,剩余的存活对象的大小,是大于Survivor区域的大小的,但是是小于老年代可用内存大小的,此时就直接进入老年代即可。

第三种可能,很不幸,Young GC过后,剩余的存活对象的大小,大于了Survivor区域的大小,也大于了老年代可用内存的大小。此时老年代都放不下这些存活对象了,就会发生"Handle Promotion Failure"的情况,这个时候就会触发一次"Full GC"。

Full GC就是对老年代进行垃圾回收,同时也一般会对新生代进行垃圾回收。因为这个时候必须得把老年代里的没人引用的对象给回收掉,然后才可能让Young GC过后剩余的存活对象进入老年代里面。

如果Full GC过后,老年代还是没有足够的空间存放Young GC过后的剩余存活对象,那么此时就会导致OOM内存溢出了,因为内存实在是不够了,你还要不停的往里面放对象,当然就崩溃了。

5. CMS如何实现系统一边工作的同时一边进行垃圾回收的?

CMS执行一次垃圾回收的过程一共分为4个阶段:初始标记、并发标记、重新标记、并发清理。

首先,CMS要进行垃圾回收时,会先执行初始标记阶段,这个阶段会让系统的工作线程全部停止,进入"Stop the World"状态。所谓的"初始标记"是说标记出来所有GC Roots直接引用的对象,即方法的局部变量和类的静态变量,而类的实例变量不是GC Roots。初始标记虽然要"Stop the World"暂停一切工作线程,但其实影响不大,因为它的速度很快,仅仅标记GC Roots直接引用的那些对象即可。

接着第二个阶段,是并发标记,这个阶段会让系统线程可以随意创建各种新对象,继续运行。在运行期间可能会创建新的存活对象,也可能会让部分存活对象失去引用,变成垃圾对象。在这个过程中,垃圾回收线程,会尽可能的对已有的对象进行GC Roots追踪。并发标记会对老年代所有对象进行GC Roots追踪,其实是最耗时的,它需要追踪所有对象是否从根源上被GC Roots引用了,但是这个最耗时的阶段,是跟系统程序并发运行的,所以这个阶段不会对系统运行造成影响的。

接着会进入第三个阶段,重新标记阶段。因为在第二阶段里,你一边标记存活对象和垃圾对象,一边系统在不停运行创建新对象,或者让部分老对象变成垃圾对象,所以第二阶段结束之后,绝对会有很多存活对象和垃圾对象,是之前第二阶段没标记出来的。所以此时进入第三阶段,要继续让系统程序停下来,再次进入"Stop the World"状态。这个重新标记的阶段,是速度很快的,它其实就是对在第二阶段中被系统程序运行所变动过的少数对象进行标记,所以运行速度很快。

接着重新恢复系统程序的运行,进入第四阶段:并发清理。这个阶段就是让系统程序随意运行,然后它来清理掉之前标记为垃圾的对象即可。这个阶段其实也是很耗时的,因为需要进行对象的清理,但是它也是跟系统程序并发运行的,所以其实也不影响系统程序的运行。

CMS的垃圾回收机制已经尽可能的对垃圾回收进行了性能优化。因为最耗时的,其实就是对老年代全部对象进行GC Roots追踪,标记出来到底哪些对象可以回收;然后是把各种垃圾对象从内存里清理掉,这两个过程是最耗时的。但是它的第二阶段和第四阶段,都是和系统程序并发执行的,所以基本这两个最耗时的阶段对性能影响不大。只有第一个阶段和第三个阶段是需要"Stop the World"的,但是这两个阶段都是简单的标记而已,速度非常的快,所以基本上对系统运行影响也不大。

6. CMS收集器的缺点

(1)CMS比较吃CPU资源

CMS虽然能在垃圾回收的同时让系统同时工作,但是在并发标记和并发清理这两个最耗时的阶段,垃圾回收线程和系统工作线程同时工作,会导致有限的CPU资源被垃圾回收线程占用一部分。并发标记的时候,需要对GC Roots进行深度追踪,看老年代所有对象里面到底有多少是存活的,但是因为老年代里存活对象是比较多的,这个过程会追踪大量的对象,所以耗时较高。并发清理,又需要把垃圾对象从各种随机的内存位置清理掉,也是比较耗时的。因此在这两个阶段,CMS的垃圾回收线程是比较耗费CPU资源的。CMS默认启动的垃圾回收线程的数量是(CPU核数+3)/ 4。

(2)Concurrent Mode Failure问题

在并发清理阶段,CMS只不过是回收之前标记好的垃圾对象,但是这个阶段系统一直在运行,伴随系统运行可能会产生新的垃圾对象,这种垃圾对象是"浮动垃圾"。虽然成了垃圾对象,但是CMS只能回收之前标记出来的垃圾对象,不会回收它们,需要等到下一次GC的时候才会回收它们。所以为了保证在CMS垃圾回收期间,还有一定的内存空间让一些新对象可以进入老年代,需要预留一些内存空间。

CMS垃圾回收的触发时机,其中有一个就是当老年代内存占用达到一定比例了,就自动执行GC。"-XX:CMSInitiatingOccupancyFaction"参数可以用来设置老年代占用多少比例的时候触发CMS垃圾回收,JDK 1.6里面默认的值是92%。也就是说,老年代占用了92%空间了,就自动进行CMS垃圾回收,预留8%的空间给并发回收期间,让系统程序把产生的一些新对象放入老年代中。那么如果CMS垃圾回收期间,系统程序要放入老年代的对象大于了可用内存空间,此时会如何?这个时候,就会发生所谓的Concurrent Mode Failure失败,意思是并发垃圾回收失败了,我一边回收,你一边把对象放入老年代中,内存已经不够用,系统线程运行产生的新对象已经放不下老年代了。

此时就会自动用"Serial Old"垃圾回收器替代CMS,就是直接强行把系统程序"Stop the World",重新进行长时间的GC Roots追踪,标记出来全部垃圾对象,不允许新的对象产生,然后一次性把垃圾对象都回收掉,完事之后才恢复系统线程。所以在生产实践中,这个自动触发CMS垃圾回收的比例需要合理优化一下,避免"Concurrent Mode Failure"问题。

(3)内存碎片问题

CMS是一款基于"标记-清理"算法实现的老年代垃圾收集器,每次都是标记出来垃圾对象,然后一次性回收掉,这样会导致大量的内存碎片产生。如果内存碎片太多,就会导致后续对象进入老年代找不到可用的连续内存空间,然后不得不触发Full GC。

所以CMS不是完全就仅仅用"标记-清理"算法的,因为太多的内存碎片实际上会导致更加频繁的Full GC。CMS有一个参数是"-XX:+UseCMSCompactAtFullCollection",默认就打开了,意思是在Full GC之后要再次进行"Stop the World",停止工作线程,然后进行内存碎片整理,把存活对象挪到一起,空出来大片连续内存空间,避免内存碎片。还有一个参数是"-XX:CMSFullGCsBeforeCompaction",这个意思是执行多少次Full GC之后再执行一次内存碎片整理的工作,默认是0,意思是每次Full GC之后都会进行一次内存碎片整理。

7. 为什么老年代的Full GC要比新生代的Minor GC慢很多倍,一般在10倍以上?

其实原因很简单,只要分析一下它们俩的执行过程就行了。新生代存活对象是很少的,从GC Roots出发不需要追踪多少对象就行了,所以速度是很快的,然后直接把存活对象放入Survivor中,就一次性直接回收到Eden区和之前使用的Survivor区。

但是CMS的Full GC呢?在并发标记阶段,它需要去追踪老年代里所有存活对象,而老年代存活对象很多,这个过程就会很慢;其次并发清理阶段,它不是一次性回收一大片内存,而是找到零零散散在各个地方的垃圾对象,速度也很慢;最后还得执行一次内存碎片整理,把大量的存活对象给挪到一起,空出来连续内存空间,这个过程还得"Stop the World",那就更慢了。万一并发清理期间,剩余内存空间不足以存放要进入老年代的对象,引发了"Concurrent Mode Failure"问题,那更是麻烦,还得立马用"Serial Old"垃圾回收器,"Stop the World"之后慢慢重新来一遍回收的过程,这更是耗时。所以综上所述,老年代的垃圾回收,就是一个字:慢。

8. 触发老年代GC的时机

第一是老年代可用内存小于新生代全部对象的大小,如果没开启空间担保参数,会直接触发Full GC,所以一般空间担保参数都会打开;

第二是老年代可用内存小于历次新生代GC后进入老年代的平均对象大小,此时会提前Full GC;

第三是新生代Minor GC后的存活对象大于Survivor,那么就会进入老年代,此时如果老年代内存不足会触发Full GC;

第四是老年代可用内存大于历次新生代GC后进入老年代的平均对象大小,但是老年代已经使用的内存空间超过了"-XX:CMSInitiatingOccupancyFaction"参数指定的比例,也会自动触发Full GC。

G1

9. G1垃圾回收器

G1垃圾回收器是可以同时回收新生代和老年代的对象的,不需要两个垃圾回收器配合起来运作,它一个人就可以搞定所有的垃圾回收。

G1的一个显著特点,是把Java堆内存拆分成多个大小相等的Region。虽然它也有新生代和老年代的概念,但是只不过是逻辑上的概念,也就是说,新生代可能包含了某些Region,老年代可能包含了某些Region。

G1另外一个显著的特点就是可以让我们设置一个垃圾回收的预期停顿时间。很多JVM优化的思路其实就是对内存合理分配,优化一些参数,尽可能减少Minor GC和Full GC的次数,减少GC带来的系统停顿,避免影响系统处理请求。但是现在我们直接可以给G1指定,在一段时间内,垃圾回收导致的系统停顿时间不能超过多久,然后G1全权给你负责,保证达到这个目标,这就相当于我们可以直接控制垃圾回收对系统性能的影响了。

10. G1是如何做到对垃圾回收导致的系统停顿可控的?

G1要做到垃圾回收对系统停顿可控,它就必须要追踪每个Region里的回收价值。啥叫做回收价值呢?G1必须搞清楚每个Region里的对象有多少是垃圾,如果对这个Region进行垃圾回收,需要耗费多长时间,可以回收掉多少垃圾。

总结来说,G1垃圾回收器的设计思想,主要是把内存拆分为很多个小的Region,然后新生代和老年代各自对应一些Region,追踪每个Region中可以回收的对象大小和预估时间,回收的时候尽可能挑选回收效率最高的Region,尽可能保证达到我们指定的垃圾回收时的系统停顿时间。

11. Region可能属于新生代也可能属于老年代

在G1中,每一个Region是可能属于新生代,但是也可能属于老年代的。刚开始Region可能谁都不属于,然后接着就被分配给了新生代,然后放了很多属于新生代的对象,接着触发了GC回收了这个Region;然后下一次同一个Region可能又被分配给了老年代了,用来放老年代的长生存周期的对象。所以在G1对应的内存模型中,Region随时会属于新生代或老年代,没有所谓的新生代给多少内存,老年代给多少内存这一说。新生代和老年代各自的内存区域是不停在变动的,由G1自动控制。

12. 如何设定G1对应的内存大小

首先思考两个问题:G1到底划分多少个Region?每个Region的大小是多大?

其实这个默认情况下是自动计算和设置的,我们可以给整个堆内存设置一个大小,比如说用"-Xms"和"-Xmx"来设置堆内存的大小。然后JVM启动的时候一旦发现你使用的是G1垃圾回收器(可以使用"-XX:+UseG1GC"来指定使用G1垃圾回收器),此时会自动用堆大小除以2048,因为JVM最多可以有2048个Region,然后Region的大小必须是2的倍数,比如说1MB、2MB、4MB之类的。比如说堆大小是4G,那么就是4096MB,此时除以2048个Region,每个Region的大小就是2MB。大概就是这样子来决定Region的数量和大小的,一般保持默认的计算方式就可以。

刚开始的时候,默认新生代对堆内存的占比是5%,也就是占据200MB左右的内存,对应大概是100个Region,这个是可以通过"-XX:G1NewSizePercent"来设置新生代初始占比的,维持这个默认值即可。因为在系统运行中,JVM其实会不停的给新生代增加更多的Region,但是最多新生代的占比不会超过60%(可以通过"-XX:G1MaxNewSizePercent"设置)。而且一旦Region进行了垃圾回收,此时新生代的Region数量会减少,这些都是动态的。

13. 新生代还有Eden和Survivor的概念吗?

G1虽然把内存划分成了很多的Region,但是其实还是有新生代、老年代的区分的,而且新生代里还是有Eden和Survivor的划分的,它们会各自占据不同的Region,随着对象不停的在新生代里分配,属于新生代的Region会不断增加,Eden和Survivor对应的Region也会不断增加。

14. G1的新生代垃圾回收

既然G1的新生代也有Eden和Survivor的区分,那么触发垃圾回收的机制都是类似的。随着不停的在新生代的Eden对应的Region中放对象,JVM就会不停的给新生代加入更多的Region,直到新生代占据堆大小的最大比例60%。一旦新生代达到了设定的占据堆内存的最大大小60%,比如都有1200个Region了,里面的Eden可能占据了1000个Region,每个Survivor是100个Region,而且Eden区还占满了对象,这个时候还是会触发新生代的GC的,G1会用复制算法来进行垃圾回收,进入一个"Stop the World"状态,然后把Eden对应的Region中的存活对象放入S1对应的Region中,接着回收掉Eden对应的Region中的垃圾对象。但是这个过程跟其他垃圾回收器是有区别的,因为G1是可以设定目标GC停顿时间的,也就是可以指定G1执行GC的时候最多可以让系统停顿多长时间,可以通过"-XX:MaxGCPauseMills"参数来设定,默认值是200ms。那么G1就会通过对每个Region追踪回收它需要多长时间,可以回收多少对象来选择回收一部分的Region,保证GC停顿时间控制在指定范围内,尽可能多的回收掉一些对象。

15. 对象什么时候进入老年代?

在G1的内存模型下,新生代和老年代各自都会占据一定的Region,老年代也会有自己的Region,按照默认新生代最多只能占据堆内存60%的Region来推算,老年代最多可以占据40%的Region,大概就是800个左右的Region。

那么对象什么时候从新生代进入老年代呢?

(1)对象在新生代躲过了很多次的垃圾回收,达到了一定的年龄了,"-XX:MaxTenuringThreshold"参数可以设置这个年龄,他就会进入老年代;

(2)动态年龄判定规则,如果一旦发现某次新生代GC过后,存活对象超过了Survivor的50%。比如年龄1岁、2岁、3岁、4岁的对象的大小总和超过了Survivor的50%,此时4岁以上的对象全部会进入老年代,这就是动态年龄判定规则。

经过一段时间的新生代使用和垃圾回收之后,总有一些对象会进入老年代中。

16. 大对象Region

G1提供了专门的Region来存放大对象,而不是让大对象进入老年代的Region中。

在G1中,大对象的判定规则是一个大对象超过了一个Region大小的50%,比如每个Region是2MB,只要一个大对象超过了1MB,就会被放入大对象专门的Region中,而且一个大对象如果太大,可能会横跨多个Region来存放。

那堆内存里哪些Region用来存放大对象呢?不是说60%的给新生代,40%的给老年代吗,那还有Region给大对象?很简单,在G1里,新生代和老年代的Region是不停的变化的,比如新生代现在占据了1200个Region,但是一次垃圾回收之后,就让里面1000个Region都空了,此时那1000个Region就可以不属于新生代了,里面很多Region可以用来存放大对象。

大对象既然不属于新生代和老年代,那什么时候会触发垃圾回收呢?也很简单,新生代、老年代在回收的时候,会顺带着大对象Region一起回收,所以这就是在G1内存模型下对大对象的分配和回收的策略。

17. 什么时候触发新生代+老年代的混合垃圾回收?

G1有一个参数,是"-XX:InitiatingHeapOccupancyPercent",它的默认值是45%,意思是如果老年代占据了堆内存的45%的Region的时候,就会尝试触发一个新生代+老年代一起回收的混合回收阶段。比如堆内存有2048个Region,如果老年代占据了其中45%的Region,也就是接近1000个Region的时候,就会触发一个混合回收。

18. G1垃圾回收的过程

首先会触发一个"初始标记"的操作,这个过程是需要进入"Stop the World"的,仅仅只是标记一下GC Roots直接能引用的对象,这个过程速度是很快的。先停止系统程序的运行,然后对各个线程栈内存中的局部变量代表的GC Roots,以及方法区中的类静态变量代表的GC Roots,进行扫描,标记出来它们直接引用的那些对象。

接着会进入"并发标记"的阶段,这个阶段会允许系统程序的运行,同时进行GC Roots追踪,从GC Roots开始追踪所有的存活对象。这个并发标记阶段是很耗时的,因为要追踪全部的存活对象。但是这个阶段是可以跟系统程序并发运行的,所以对系统程序的影响不太大。而且JVM会把并发标记阶段对对象做出的修改操作记录下来,比如哪个对象被新建了,哪个对象失去了引用。

接着是下一个阶段,最终标记阶段,这个阶段会进入"Stop the World",系统程序是禁止运行的,但是会根据并发标记阶段的对象修改操作记录,最终标记一下有哪些存活对象,哪些垃圾对象。

最后一个阶段,就是"混合回收"阶段,这个阶段会计算老年代中每个Region中的存活对象数量,存活对象的占比,还有执行垃圾回收的预期性能和效率。接着会停止系统程序,然后全力以赴尽快进行垃圾回收,此时会选择部分Region进行回收,因为必须让垃圾回收的停顿时间控制在我们指定的范围内。比如老年代此时有1000个Region都满了,但是因为根据预定目标,本次垃圾回收可能只能停顿200毫秒,那么通过之前的计算得知,可能回收其中800个Region刚好需要200ms,那么就只会回收800个Region,把GC导致的停顿时间控制在我们指定的范围内。

其实老年代对堆内存占比达到45%时触发的所谓"混合回收"不仅仅回收老年代,还会回收新生代和大对象。那么,到底是回收这些区域中的哪些Region呢?那就要看情况了,因为我们设定了对GC停顿时间的目标,所以它会从新生代、老年代、大对象里各自挑选一些Region,保证用指定的时间(比如200ms)回收尽可能多的垃圾对象。

19. G1垃圾回收器的一些参数

G1在老年代的Region占据了堆内存的Region的45%之后,会触发一个混合回收的过程,也就是Mixed GC,分为了好几个阶段。其中最后一个阶段是执行混合回收,从新生代和老年代里都回收一些Region,但在最后一个阶段混合回收的时候,它其实会停止所有程序运行的,所以G1是允许执行多次混合回收的。比如先停止工作,执行一次混合回收回收掉一些Region,接着恢复系统运行,然后再次停止系统运行,再执行一次混合回收回收掉一些Region。

有一些参数可以控制其中的一些细节。比如"-XX:G1MixedGCCountTarget"参数,意思是在一次混合回收的过程中,最后一个阶段执行几次混合回收,默认值是8次。意味着最后一个阶段,先停止系统运行,混合回收一些Region,再恢复系统运行,接着再次禁止系统 运行,混合回收一些Region,反复8次。假设一次混合回收预期要回收掉一共有160个Region,第一次混合回收回收掉了20个Region,接着恢复系统运行一会儿,然后再执行一次"混合回收",再次回收掉20个Region,如此反复执行8次混合回收阶段,把预定的160个Region都回收掉,而且还把系统停顿时间控制在指定范围内。

那么为什么要反复回收多次呢?因为停止系统一会儿,回收掉一些Region,再让系统运行一会儿,然后再次停止系统一会儿,再次回收掉一些Region,这样可以尽可能让系统不要停顿时间过长,可以在多次回收的间隙,也运行一下。

还有一个参数,"-XX:G1HeapWasterPercent",默认值是5%,意思是说,在混合回收的时候,对Region回收都是基于复制算法进行的,都是把要回收的Region里的存活对象放入其他Region,然后这个Region中的垃圾对象全部清理掉。这样的话在回收过程中就会不断空出来新的Region,一旦空闲出来的Region数量达到了堆内存的5%,就会立即停止混合回收,本次垃圾回收结束。从这里也能看出来G1整体是基于复制算法进行Region垃圾回收的,不会出现内存碎片的问题,不需要像CMS那样标记-清理之后,再进行内存碎片的整理。

还有一个参数,"-XX:G1MixedGCLiveThresholdPercent",默认值是85%,意思是确定要回收的Region的时候,必须是存活对象低于85%的Region才可以进行回收。否则要是一个Region的存活对象多余85%,你还要回收它干什么?这个时候要把85%的对象都拷贝到别的Region,成本是很高的。

20. 回收失败时的Full GC

如果在进行Mixed GC的时候,无论是年轻代还是老年代都基于复制算法进行回收,都要把各个Region的存活对象拷贝到别的Region里去,此时万一出现拷贝的过程中发现没有空闲的Region可以承载自己的存活对象了,就会触发一次失败。一旦失败,立马就会切换为停止系统程序,然后采用单线程进行标记、清理和压缩整理,空闲出来一批Region,这个过程是极慢极慢的。

21. 学了不练,学了不复习,学了不总结,基本等于白学。

22. 什么时候新生代gc对系统影响很大?

当你的系统部署在大内存机器上的时候,比如说你的机器是32核64G的机器,此时你分配给系统的内存有几十个G,新生代的Eden区可能30G~40G的内存。类似Kafka、Elasticsearch之类的大数据相关系统,都是部署在大内存的机器上的,此时如果你的系统负载非常的高,很可能每秒几万的访问请求到Kafka、Elasticsearch上去,导致Eden区的几十个G内存频繁塞满要触发垃圾回收,假设1分钟会塞满一次。然后每次垃圾回收要停顿掉Kafka、Elasticsearch的运行,然后执行垃圾回收大概需要几秒钟,此时你发现,可能每过一分钟,你的系统就要卡顿几秒钟,有的请求一旦卡死几秒钟就会超时报错,导致你的系统频繁出错。

23. 如何解决大内存机器的新生代gc过慢的问题?

用G1垃圾回收器。G1垃圾回收器可以设置一个期望的每次GC停顿时间,G1基于它的Region内存划分原理,就可以在运行一段时间之后,只针对其中一部分的Region进行垃圾回收,腾出来部分内存,接着还可以继续让系统运行。G1天生就适合这种大内存机器的JVM运行,可以完美解决大内存垃圾回收时间过长的问题。

24. 要命的频繁老年代gc问题

新生代gc一般问题不会太大,但是真正问题最大的地方,在于频繁触发老年代的GC。对象进入老年代的几个条件:年龄太大了、动态年龄判定规则、新生代gc后存活对象太多无法放入Survivor中。下面重新分析一下这几个条件:

第一个,对象年龄太大了,这种对象一般很少,都是系统中确实需要长期存在的核心组件,它们一般不需要被回收掉,所以在新生代熬过默认15次垃圾回收之后就会进入老年代。

第二个,动态年龄判定规则,如果一次新生代gc过后,发现Survivor区域中的几个年龄的对象加起来超过了Survivor区域的50%,比如说年龄1+年龄2+年龄3的对象大小总和,超过了Survivor区域的50%,此时就会把年龄3以上的对象都放入老年代。

第三个,新生代垃圾回收过后,存活对象太多了,无法放入Survivor中,此时直接进入老年代。

其实在上述条件中,第二个和第三个都是很关键的,通常如果你的新生代中的Survivor区域内存过小,就会导致上述第二个和第三个条件频繁发生,然后导致大量对象快速进入老年代,进而频繁触发老年代的gc。

老年代gc通常来说都很耗费时间,无论是CMS垃圾回收器还是G1垃圾回收器,因为比如说CMS就要经历初始标记、并发标记、重新标记、并发清理、碎片整理几个环节,过程非常的复杂,G1同样也是如此。通常来说,老年代gc至少比新生代gc慢10倍以上,比如新生代gc每次耗费200ms,其实对用户影响不大,但是老年代每次gc耗费2s,那可能就会导致老年代gc的时候用户发现页面上卡顿2s,影响就很大了。

所以一旦你因为jvm内存分配不合理,导致频繁进行老年代gc,比如几分钟就有一次老年代gc,每次gc系统都停顿几秒钟,那简直对你的系统就是致命的打击。此时用户会发现页面上或者APP上经常性的出现点击按钮之后卡顿几秒钟。

25. JVM性能优化到底在优化什么?

系统真正最大的问题,就是因为内存分配、参数设置不合理,导致你的对象频繁的进入老年代,然后频繁触发老年代gc,导致系统频繁的每隔几分钟就要卡死几秒钟。这就是所谓JVM的性能问题,也是JVM性能优化需要优化的东西。

26. Old GC和Full GC的触发时机

(1)发生Young GC之前进行检查,如果"老年代可用的连续内存空间"<"新生代历次Young GC后升入老年代的对象总和的平均大小",说明可能本次Young GC后升入老年代的对象大小,超过了老年代当前可用内存空间。此时必须先触发一次Old GC给老年代腾出更多的空间,然后再执行Young GC。

(2)执行Young GC之后有一批对象需要放入老年代,此时老年代没有足够的内存空间存放这些对象了,此时必须立即触发一次Old GC。

(3)老年代内存使用率超过了92%,也要直接触发Old GC,当然这个比例是可以通过参数调整的。

综上所述,总结成一句话,就是老年代空间不够了,没法放入更多对象了,这个时候必须执行Old GC对老年代进行垃圾回收。

Old GC执行的时候一般都会带上一次Young GC,一般Old GC很可能就是在Young GC之前触发或者在Young GC之后触发的,所以自然Old GC一般都会跟一次Young GC连带关联在一起。另外一点,在很多JVM的实现机制里,其实在老年代达到GC条件的时候,它触发的实际上就是Full GC,这个Full GC会包含Young GC、Old GC和永久代的GC,所以很多时候我们笼统的概括为当上述条件满足时就会触发Full GC。

jstat、jmap

27. 功能强大的jstat

平时我们对运行中的系统,如果要检查它的JVM整体运行情况,比较常用的工具之一就是jstat,它可以轻易的让你看到当前运行中的系统,它的JVM内的Eden、Survivor、老年代的内存使用情况,还有Young GC和Full GC的执行次数以及耗时。通过这些指标我们可以轻松的分析出当前系统的运行情况,判断当前系统的内存使用压力以及GC压力,还有就是内存分配是否合理。

28. jstat -gc PID

针对我们的Java进程执行:jstat -gc PID,就可以看到这个Java进程(其实本质就是一个JVM)的内存和GC情况了,最完整、最常用、最实用还是jstat -gc命令。运行这个命令之后会看到如下列:

S0C:这是From Survivor的大小

S1C:这是To Survivor区的大小

S0U:这是From Survivor区当前使用的内存大小

S1U:这是To Survivor区当前使用的内存大小

EC:这是Eden区的大小

EU:这是Eden区当前使用的内存大小

OC:这是老年代的大小

OU:这是老年代当前使用的内存大小

MC:这是方法区(元数据区)的大小

MU:这是方法区(元数据区)的当前使用的内存大小

YGC:这是系统运行迄今为止的Young GC次数

YGCT:这是Young GC的耗时

FGC:这是系统运行迄今为止的Full GC次数

FGCT:这是Full GC的耗时

GCT:这是所有GC的总耗时

29. 到底该如何使用jstat工具?

我们分析线上的JVM进程,最想要知道的信息有哪些?

包括如下:

新生代对象增长的速率,Young GC的触发频率,Young GC的耗时,每次Young GC后有多少对象是存活下来的,每次Young GC过后有多少对象进入了老年代,老年代对象增长的速率,Full GC的增长频率,Full GC的耗时。

只要知道了这些信息,结合不同的垃圾回收器优化参数,合理分配内存空间,尽可能让对象留在年轻代不进入老年代,避免发生频繁的Full GC。这就是对JVM最好的性能优化了!

30. 新生代对象增长的速率

我们平时对jvm第一个要了解的事儿,就是随着系统运行,每秒钟会在年轻代的Eden区分配多少对象。要分析这东西,你只要在线上linux机器上运行如下命令:jstat -gc PID 1000 10。这行命令,它的意思是每隔1秒钟更新出来最新的一行jstat统计信息,一共执行10次jstat统计。通过这个命令,你可以非常灵活的对线上机器通过固定频率输出统计信息,观察每隔一段时间的jvm中的Eden区对象占用变化。

举个例子,执行这个命令之后,第一秒先显示出来Eden区使用了200MB内存,第二秒显示出来的那行统计信息里,发现Eden区使用了205MB内存,第三秒显示出来的那行统计信息里,发现Eden区使用了209MB内存,以此类推。此时你可以轻易的推断出来,这个系统大概每秒钟会新增5MB左右的对象。而且这里可以根据自己系统的情况灵活多变的使用,比如你的系统负载很低,不一定每秒都有请求,那么可以把上面的1秒钟调整为1分钟,甚至10分钟,去看你们系统每隔1分钟或者10分钟大概增长多少对象。

还有就是一般系统都有高峰和日常两种状态,比如系统高峰期用的人很多,此时你就应该在系统高峰期去用上述命令看看高峰期的对象增长速率,然后再在非高峰的日常时间段内看看对象的增长速率。按照上述思路,基本上你可以对线上系统的高峰和日常两个时间段内的对象增长速率有很清晰的了解。

31. Young GC的触发频率和每次耗时

接着下一步我们就想知道大概多久会触发一次Young GC,以及每次Young GC的耗时了。其实多久触发一次Young GC很容易推测出来,因为系统高峰和日常时候的对象增长速率你都知道了,那么非常简单就可以推测出来高峰期多久发生一次Young GC,日常期多久发生一次Young GC。

比如你的Eden区有800MB内存,那么发现高峰期每秒新增5MB对象,大概高峰期就是3分钟会触发一次Young GC。日常期每秒新增0.5MB对象,那么日常期大概需要半个小时才会触发一次Young GC。

那么每次Young GC的平均耗时呢?

简单,jstat会告诉你迄今为止系统已经发生了多少次Young GC以及这些Young GC的总耗时。比如系统运行24小时后共发生了260次Young GC,总耗时为20s,那么平均下来每次Young GC大概就耗时几十毫秒的时间,你大概就知道每次Young GC的时候会导致系统停顿几十毫秒。

32. 每次Young GC后有多少对象存活和进入老年代

接着我们想要知道,每次Young GC后有多少对象会存活下来,以及有多少对象会进入老年代。

其实每次Young GC过后有多少对象会存活下来,这个没法直接看出来,但是有办法可以大致推测出来。之前我们已经推算出来高峰期的时候多久发生一次Young GC,比如3分钟会有一次Young GC,那么此时我们可以执行下述jstat命令:jstat -gc PID 180000 10。这就相当于是让它每隔三分钟执行一次统计,连续执行10次。此时可以观察一下,每隔三分钟之后发生了一次Young GC,Eden、Survivor、老年代的对象变化。

正常来说,Eden区肯定会在几乎放满之后重新变得里面对象很少,比如800MB的空间就使用了几十MB。Survivor区肯定会放入一些存活对象,老年代可能会增长一些对象占用。所以这里的关键,就是观察老年代的对象增长速率。

从一个正常的角度来看,老年代的对象是不太可能不停的快速增长的,因为普通的系统其实没那么多长期存活的对象。如果你发现每次Young GC过后,老年代对象都要增长几十MB,那很有可能就是你一次Young GC过后存活对象太多了。

存活对象太多,可能导致放入Survivor区域之后触发了动态年龄判定规则进入老年代,也可能是Survivor区域放不下了,所以大部分存活对象进入老年代,最常见的就是这两种情况。如果你的老年代每次在Young GC过后就新增几百KB,或者几MB的对象,这个还算情有可原,但是如果老年代对象快速增长,那一定是不正常的。所以通过上述观察策略,你就可以知道每次Young GC过后多少对象是存活的,实际上Survivor区域里的和进入老年代的对象,都是存活的。

你也就可以知道老年代对象的增长速率了,比如每隔3分钟一次Young GC,每次会有50MB对象进入老年代,这就是老年代对象的增长速率,每隔3分钟增长50MB。

33. Full GC的触发时机和耗时

只要知道了老年代对象的增长速率,那么Full GC的触发时机就很清晰了,比如老年代总共有800MB的内存,每隔3分钟新增50MB对象,那么大概每小时就会触发一次Full GC。然后可以看到jstat打印出来的系统运行迄今为止的Full GC次数以及总耗时,比如一共执行了10次Full GC,共耗时30s,每次Full GC大概就是需要耗费3s左右。

34. 使用jmap了解系统运行时的内存区域

如果单单只是要了解JVM的运行状况,然后去进行JVM GC优化,通常来说jstat就完全够用了。但是有的时候可能我们会发现JVM新增对象的速度很快,然后就想要去看看,到底什么对象占据了那么多的内存。如果发现有的对象在代码中可以优化一下创建的时机,避免那种对象对内存占用过大,那么也许甚至可以去反过来优化一下代码。当然,其实如果不是出现OOM那种极端情况,也并没有那么大的必要去着急优化代码。

35. 使用jmap了解系统运行时的对象分布

jmap -histo PID

这个命令会打印当前jvm中的对象对内存占用的情况,让你可以快速了解当前内存里到底是哪个对象占用了大量的内存空间。它会按照各种对象占用内存空间的大小降序排列,把占用内存最多的对象放在最上面。

36. 使用jmap生成堆内存转储快照

jmap -dump:live,format=b,file=dump.hprof PID

这个命令会在当前目录下生成一个dump.hprof文件,这里是二进制的格式,是不能直接打开看的,它会把这一时刻JVM堆内存里所有对象的快照放到文件里去,供你后续去分析。

37. 使用jhat在浏览器中分析堆转储快照

jhat dump.hprof -port 7000

接着就可以使用jhat去分析堆快照了,jhat内置了web服务器,它支持你通过浏览器以图形化的方式分析堆转储快照,可以指定自己想要的http端口号,默认是7000端口。接着你就可以在浏览器上访问当前这台机器的7000端口,通过图形化的方式去分析堆内存里的对象分布情况了。

38. 开发好系统之后的预估性优化

大家平时在开发一个新系统的时候,一般完成开发之后,要经历测试以及上线的过程。在系统开发完毕之后,实际上可以对系统进行预估性的优化。

那什么叫做预估性的优化呢?就是自己估算系统每秒大概多少请求,没个请求会创建多少对象,占用多少内存,机器应该选用什么样的配置,年轻代应该给多少内存,Young GC触发的频率,对象进入老年代的速率,老年代应该给多少内存,Full GC触发的频率。这些东西其实是可以根据你自己写的代码,大致合理的预估一下的。在预估完成之后,就可以结合各种优化思路,先给自己的系统设置一些初始化的JVM参数,比如堆内存大小,年轻代大小,Eden和Survivor的比例,老年代的大小,大对象的阀值,大对象进入老年代的阀值,等等。

优化思路其实简单来说就一句话:尽量让每次Young GC后的存活对象小于Survivor区域的50%,都留存在年轻代里。尽量别让对象进入老年代。尽量减少Full GC的频率,避免频繁Full GC对JVM性能的影响。

39. 系统压测时的JVM优化

通常一个新系统开发完毕之后,就会经过一连串的测试。从本地的单元测试,到系统集成测试,再到测试环境的功能测试,预发布环境的压力测试,要保证系统的功能全部正常,而且在一定压力下性能、稳定性和并发能力都正常,最后才会部署到生产环境运行。

这里非常关键的一个环节就是预发布环境的压力测试,通常在这个环节,会使用一些压力测试工具模拟比如1000个用户同时访问系统造成每秒500个请求的压力,然后看系统能否支撑柱每秒500请求的压力。同时看系统各个接口的响应延时是否在比如200ms之内,也就是接口性能不能太慢,或者是在数据库中模拟出来百万级单表数据,然后看系统是否还能稳定运行。很多开源的压力测试工具都可以轻松模拟出N个用户同时访问你系统的场景,还能给你一份压力测试报告,告诉你系统可以支撑每秒多少请求,包括系统接口的响应延时。在这个环节,通常压测工具会对系统发起持续不断的请求,持续很长时间,比如几个小时,甚至几天时间。

所以此时,大家完全就可以在这个环节,对测试机器运行的系统,采用jstat工具来分析在模拟真实环境的压力下,JVM的整体运行状态。具体如何使用jstat来进行分析,之前都讲的很详细了,包括如何借助jstat的各种功能分析出来以下JVM的关键运行指标:新生代对象增长的速率,Young GC的触发频率,Young GC的耗时,每次Young GC后有多少是存活下来的,每次Young GC过后有多少对象进入了老年代,老年代对象增长的速率,Full GC的触发频率,Full GC的耗时。

然后根据压测环境中的JVM运行状况,如果发现对象过快进入老年代,可能是因为年轻代太小导致频繁Young GC,然后Young GC的时候很多对象还是存活的,结果Survivor也太小,导致很多对象频繁进入老年代。当然也可能是别的什么原因。此时就需要结合各种优化思路,合理调整新生代、老年代、Eden、Survivor各个区域的内存大小,保证对象尽量留在年轻代,不要过快进入老年代中。

不要去网上胡乱搜索JVM优化的博客,看到里面人家怎么优化,你就怎么优化,比如很多博客说年轻代和老年代的占比一般是3:8,其实完全是片面的。每个系统都是不一样的,特点不同,复杂度不同。记住一点:真正的优化,必须是你根据自己的系统,实际观察之后,然后合理调整内存分布,根本没什么固定的JVM优化模板。当你对压测环境下的系统优化好JVM参数之后,观察Young GC和Full GC频率都很低,此时就可以部署系统上线了。

40. 对线上系统进行JVM监控

当你的系统上线之后,你就需要对线上系统的JVM进行监控,这个监控通常来说有两种办法。

第一种方法会low一点,其实就是每天在高峰期和低峰期都用jstat、jmap、jhat等工具去看看线上系统的JVM运行是否正常,有没有频繁Full GC的问题。如果有就优化,没有的话,平时每天都定时去看看,或者每周都去看看即可。

第二种方法在中大型公司里会多一些,很多中大型公司都会部署专门的监控系统,比较常见的有Zabbix、OpenFalcon、Ganglia,等等。然后你部署的系统都可以把JVM统计项发送到这些监控系统里去。此时你就可以在这些监控系统可视化的界面里,看到你需要的所有指标,包括你的各个内存区域的对象占用变化曲线,直接可以看到Eden区的对象增速,还会告诉你Young GC发生的频率以及耗时,包括老年代的对象增速以及Full GC的频率和耗时。而且这些工具还允许你设置监控。也就是说,你可以指定一个监控规则,比如线上系统的JVM,如果10分钟之内发生5次以上Full GC,就需要发送报警给你。比如发生到你的邮箱、短信或钉钉里,这样你就不用自己每天去看看了。

简单一句话总结:对线上运行的系统,要不然用命令行工具手动监控,发现问题就优化,要不然就是依托公司的监控系统进行自动监控,可视化查看日常系统的运行状态。

41. 正常情况下的系统

正常情况下的系统,会有一定频率的Young GC,一般在几分钟一次Young GC,或者几十分钟一次Young GC,一次耗时在几毫秒到几十毫秒的样子,都是正常的。

正常的Full GC频率在几十分钟一次,或者几个小时一次,这个范围内都是正常的,一次耗时应该在几百毫秒的样子。

所以大家如果观察自己线上系统就是这个性能表现,基本上问题都不太大。当然,实际线上系统很多时候会遇到一些JVM性能问题,就是Full GC过于频繁,每次还耗时很多的情况,此时就需要一些优化了。

42. 线上频繁Full GC的几种表现

一旦系统发生频繁Full GC,大概看到的一些表象如下:

机器CPU负载过高;

频繁Full GC报警;

系统无法处理请求或者处理过慢;

所以一旦发生上述几个情况,大家第一时间得想到是不是发生了频繁Full GC。

43. 频繁Full GC的几种常见原因

第一种,系统承载高并发请求,或者处理数据量过大,导致Young GC很频繁,而且每次Young GC过后存活对象太多,内存分配不合理,Survivor区域过小,导致对象频繁进入老年代,频繁触发Full GC。

第二种,系统一次性加载过多数据进内存,搞出来很多大对象,导致频繁有大对象进入老年代,必然频繁触发Full GC。

第三种,系统发生了内存泄漏,莫名其妙创建了大量的对象,始终无法回收,一直占用在老年代里,必然频繁触发Full GC。

第四种,MetaSpace(永久代)因为加载类过多触发Full GC。

第五种,误调用System.gc()触发Full GC。

其实常见的频繁Full GC原因无非就上述那几种,所以大家在线上处理Full GC的时候,就从这几个角度入手去分析即可,核心利器就是jstat。

如果jstat分析发现Full GC原因是第一种,那么就合理分配内存,调大Survivor区域即可。

如果jstat分析发现是第二种或第三种原因,也就是老年代一直有大量对象无法回收掉,年轻代升入老年代的对象并不多,那么就dump出来内存快照,然后用MAT工具进行分析即可。通过分析,找出来什么对象占用内存过多,然后通过一些对象的引用和线程执行堆栈的分析,找到哪块代码弄出来那么多的对象的,接着优化代码即可。

通过jstat分析发现内存使用不多,还频繁触发Full GC,必然是第四种和第五种,此时对应的进行优化即可。

44. 一个统一的JVM参数模板

为了简化JVM的参数设置和优化,建议各个公司和团队leader做一份JVM参数模板出来,设置一些常见参数即可。核心就是一些内存区域的分配、垃圾回收器的指定、CMS性能优化的一些参数(比如压缩、并发,等等),常见的一些参数,包括禁止System.gc(),打印出来gc日志,等等。

45. 高并发场景下导致ygc后存活对象太多

因为各种各样的情况,一旦出现了高并发场景,导致ygc后很多请求还没处理完毕,存活对象太多,可能就在Survivor区域放不下了,此时就只能进入到老年代里去了,老年代很快就会放满,一旦老年代放满了就会触发Full GC。

我们假设ygc过后有一批存活对象,Survivor放不下,此时就等着要进入老年代里,然而老年代也满了,那么就得等着老年代进行CMS GC回收掉一些对象,才能让年轻代里存活下来的对象放进去,但是这时不幸的事情发生了,老年代GC过后依然存活下来了很多的对象,没有足够的剩余空间来存放年轻代中的存活对象。这时候会发生什么?那就是内存溢出了!因为老年代都已经塞满了,你还要往里面放东西,而且触发了Full GC回收了老年代还是没有足够内存空间,你坚持要放?那只能给你一个内存溢出的异常了!JVM跑不动了,崩溃掉。这就是堆内存实在放不下过多对象导致内存溢出的典型范例。

46. 什么时候会发生堆内存的溢出?

发生堆内存溢出的原因其实总结下来,就一句话:

有限的内存中放了过多的对象,而且大多数都是存活的,此时即使GC过后还是大部分都存活,所以要继续放入更多对象已经不可能了,只能引发内存溢出问题。

所以一般来说发生内存溢出有两种主要的场景:

系统承载高并发请求,因为请求量过大,导致大量对象都是存活的,所以要继续放入新的对象实在是不行了,此时就会引发OOM系统崩溃。

系统有内存泄漏的问题,就是莫名其妙弄了很多的对象,结果对象都是存活的,没有及时取消对他们的引用,导致触发GC还是无法回收,此时只能引发内存溢出,因为内存实在放不下更多对象了。

因此总结起来,一般引发OOM,要不然是系统负载过高,要不然就是有内存泄漏的问题。

这个OOM问题,一旦你的代码写的不太好,或者设计有缺陷,还是比较容易引发的。

原文 

https://segmentfault.com/a/1190000022098302

本站部分文章源于互联网,本着传播知识、有益学习和研究的目的进行的转载,为网友免费提供。如有著作权人或出版方提出异议,本站将立即删除。如果您对文章转载有任何疑问请告之我们,以便我们及时纠正。

PS:推荐一个微信公众号: askHarries 或者qq群:474807195,里面会分享一些资深架构师录制的视频录像:有Spring,MyBatis,Netty源码分析,高并发、高性能、分布式、微服务架构的原理,JVM性能优化这些成为架构师必备的知识体系。还能领取免费的学习资源,目前受益良多

转载请注明原文出处:Harries Blog™ » JVM实战笔记

赞 (0)
分享到:更多 ()

评论 0

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址