JVM垃圾回收(二)
垃圾回收调优
1. 触发时机
1.1 Young GC的触发时机
其实之前几周的文章里,我们已经分析的很清楚了,Young GC其实一般就是在新生代的Eden区域满了之后就会触发,采用复制算法来回收新生代的垃圾
1.2 Old GC和Full GC的触发时机
- 发生Young GC之前进行检查,如果老年代可用的连续内存空间” < “新生代历次Young GC后升入老年代的对象总和的平均大小,说明本次Young GC后可能升入老年代的对象大小,可能超过了老年代当前可用内存空间此时必须先触发一次Old GC给老年代腾出更多的空间,然后再执行Young GC
- 执行Young GC之后有一批对象需要放入老年代,此时老年代就是没有足够的内存空间存放这些对象了,此时必须立即触发一次Old GC
- 老年代内存使用率超过了92%,也要直接触发Old GC,当然这个比例是可以通过参数调整的
1.3 对象进入老年代区域中几种情况
- 一个对象在年轻代里躲过15次垃圾回收,年龄太大了,寿终正寝,进入老年代
- 对象太大了,超过了一定的阈值,直接进入老年代,不走年轻代
- 一次Young GC过后存活对象太多了,导致Survivor区域放不下了,这批对象会进入老年代
- 可能几次Young GC过后,Surviovr区域中的对象占用了超过50%的内存,此时会判断如果年龄1+年龄2+年龄N的对象总和超过了Survivor区域的50%,此时年龄N以及之上的对象都进入老年代,这是动态年龄判定规则
1.4 常见的频繁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,必然是第四种和第五种,此时对应的进行优化即可。
1.5 手动dump文件生成
ps -ef | grep list-app | grep -v grep
jmap -dump:file=test.hprof,format=b 3307
1.6 试验一:自己模拟出JVM Metaspace内存溢出
<dependency>
<groupId>cglib</groupId>
<artifactId>cglib</artifactId>
<version>3.3.0</version>
</dependency>
接着我们就可以使用CGLIB来动态生成类了,大家看下面的代码:
接着我们可以设置一下这个程序的JVM参数,限制他的Metaspace区域比较小一点,如下所示,我们把这个程序的JVM中的Metaspace区域设置为仅仅10m:
-XX:MetaspaceSize=10m -XX:MaxMetaspaceSize=10m
1.7 试验二:模拟出JVM栈内存溢出的场景
- 设置这个程序的JVM参数如下:-XX:ThreadStackSize=1m,通过这个参数设置JVM的栈内存为1MB。
- 接着大家运行这段代码,会看到如下所示的打印输出:目前是第5675次调用方法
java.lang.StackOverflowError,也就是说,当这个线程调用了5675次方法之后,他的栈里压入了5675个栈桢,最终把1MB的栈内存给塞满了,引发了栈内存的溢出。大家看到StackOverflowError,就知道是线程栈内存溢出了。
1.8 试验三:模拟出堆内存溢出的场景
- 代码很简单,就是在一个while循环里不停的创建对象,而且对象全部都是放在List里面被引用的,也就是不能被回收。大家试想一下,如果你不停的创建对象,Eden区满了,他们全部存活会全部转移到老年代,反复几次之后老年代满了。
- 然后Eden区再次满了,ygc后存活对象再次进入老年代,此时老年代先full gc,但是回收不了任何对象,因此ygc后的存活对象就一定是无法进入老年代的。
- 所以我们用下面的JVM参数来运行一下代码:-Xms10m -Xmx10m,我们限制了堆内存大小总共就只有10m,这样可以尽快触发堆内存的溢出。
- 我们在控制台打印的信息中可以看到如下的信息:
当前创建了第360145个对象
Exception in thread “main” java.lang.OutOfMemoryError: Java heap space
所以从这里就可以看到,在10M的堆内存中,用最简单的Object对象搞到老年代被塞满大概需要36万个对象。然后堆内存实在放不下任何其他对象,此时就会OutOfMemory了,而且告诉了你是Java heap space,也就是堆空间发生了内存溢出的。
1.9 案例一:中小公司级别的JVM参数模板
- 大部分系统套用这个模板,基本保证JVM性能别太差,避免很多初中级工程师直接使用默认的JVM参数,可能一台8G内存的机器上,JVM堆内存就分配了几百MB。下面为生产正在用的参数:
-Xms4096M -Xmx4096M -Xmn3072M -Xss1M -XX:+DisableExplicitGC
-XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M
-XX:SurvivorRatio=8 -XX:+UseParNewGC -XX:+UseConcMarkSweepGC
-XX:CMSInitiatingOccupancyFaction=92 -XX:+UseCMSCompactAtFullCollection
-XX:CMSFullGCsBeforeCompaction=0 -XX:+CMSParallelInitialMarkEnabled
-XX:+CMSScavengeBeforeRemark -XX:+PrintGCDetails -XX:+PrintGCTimeStamps
-XX:+PrintGCDateStamps -XX:+PrintHeapAtGC
-XX:+PrintGCApplicationStoppedTime
-Xloggc:/data/jars/proclamation-service/log/gc.log
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/data/jars/proclamation-service/log-jar
- 为什么如此定制JVM参数模板呢?
- 首先,8G的机器上给JVM堆内存分配 -Xms4096M -Xmx4096M(4G)就差不多了,毕竟可能还有其他进程会使用内存,一般别让JVM堆内存把机器内存给占满。
- 然后年轻代给到-Xmn3072M(3G),之所以给到3G的内存空间,就是因为让年轻代尽量大一些,进而让每个Survivor区域都达到300MB左右(-XX:SurvivorRatio=8默认值,eden:from:to=8:1:1)。
- 假设用默认的JVM参数,可能年轻代就几百MB的内存,Survivor区域就几十MB的内存那么每次垃圾回收过后存活对象可能会有几十MB,这是因为在垃圾回收的一瞬间可能有部分请求没处理完毕,此时会有几十MB对象是存活的,所以很容易触发动态年龄判定规则,让部分对象进入老年代。
- 不同的系统运行时的情况略有不同,但是基本上都是在每次Young GC过后存活几MB~几十MB的对象,所以此时在这个参数模板下,都可以抗住。
- -XX:CMSInitiatingOccupancyFaction=92 老年代内存达到92%时候开始出发老年代回收
- UseCMSCompactAtFullCollection 与 CMSFullGCsBeforeCompaction 是搭配使用的;前者目前默认就是true了,也就是关键在后者上
- CMSFullGCsBeforeCompaction 说的是,在上一次CMS并发GC执行过后,到底还要再执行多少次full GC才会做压缩。默认是0,也就是在默认配置下每次CMS GC顶不住了而要转入full GC的时候都会做压缩。 把CMSFullGCsBeforeCompaction配置为10,就会让上面说的第一个条件变成每隔10次真正的full GC才做一次压缩(而不是每10次CMS并发GC就做一次压缩,目前VM里没有这样的参数)。这会使full GC更少做压缩,也就更容易使CMS的old gen受碎片化问题的困扰。 本来这个参数就是用来配置降低full GC压缩的频率,以期减少某些full GC的暂停时间。CMS回退到full GC时用的算法是mark-sweep-compact,但compaction是可选的,不做的话碎片化会严重些但这次full GC的暂停时间会短些;这是个取舍。
- -XX:+CMSParallelInitialMarkEnabled,这个参数会在CMS垃圾回收器的初始标记阶段开启多线程并发执行。大家应该还记得初始标记阶段,是会进行Stop the World的,会导致系统停顿,所以这个阶段开启多线程并发之后,可以尽可能优化这个阶段的性能,减少Stop the World的时间。
- -XX:+CMSScavengeBeforeRemark,这个参数会在CMS的重新标记阶段之前,先尽量执行一次Young GC。CMS的重新标记也是会Stop the World的,所以所以如果在重新标记之前,先执行一次Young GC,就会回收掉一些年轻代里没有人引用的对象。所以如果先提前回收掉一些对象,那么在CMS的重新标记阶段就可以少扫描一些对象,此时就可以提升CMS的重新标记阶段的性能,减少他的耗时。
- -XX:+PrintGCDetails 输出GC的详细日志
- -XX:+PrintGCTimeStamps 输出GC的时间戳(以基准时间的形式)
- -XX:+PrintGCDateStamps 输出GC的时间戳(以日期的形式,如 2013-05-04T21:53:59.234+0800)
- -XX:+PrintHeapAtGC 在进行GC的前后打印出堆的信息
- -XX:+PrintGCApplicationStoppedTime // 输出GC造成应用暂停的时间
- -XX:PrintGCApplicationConcurrentTime //应用程序在不停止的情况下工作的时间
- 内存溢出时打印快照
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/data/jars/proclamation-service/log-jar - JDK8中将-XX:PermSize=200m;-XX:MaxPermSize=256m;修改为:-XX:MetaspaceSize=200m;-XX:MaxMetaspaceSize=256m;
- -XX:+DisableExplicitGC 禁止代码中显示调用GC
- 总结
- 通过jstat观察,基本上发现各个系统的Full GC都变成了几天才会发生一次,每次耗时在几百毫秒。
- 基本上各个系统的JVM达到这个性能,就对线上系统没多大影响了。哪怕是不太懂JVM优化的普通工程师只要套用这个模板,对一些普通的业务系统,都能保证其JVM性能不会出现大的问题,比如频繁的Young GC和Full GC导致的系统频繁卡顿。不过没有适合任何系统的jvm参数,还是要根据每个系统的访问量推测、压测等结果具体情况具体设置
- 参数总结
-XX:+CMSParallelInitialMarkEnabled表示在初始标记的多线程执行,减少STW;
-XX:+CMSScavengeBeforeRemark:在重新标记之前执行minorGC减少重新标记时间;
-XX:+CMSParallelRemarkEnabled:在重新标记的时候多线程执行,降低STW;
-XX:CMSInitiatingOccupancyFraction=92和-XX:+UseCMSInitiatingOccupancyOnly配套使用,如果不设置后者,jvm第一
次会采用92%但是后续jvm会根据运行时采集的数据来进行GC周期,如果设置后者则jvm每次都会在92%的时候进行gc;
-XX:+PrintHeapAtGC:在每次GC前都要GC堆的概况输出
1.10 案例二:线上频繁full gc
-
中大型公司都是接入类似Zabbix、OpenFalcon或者公司自研的一些监控系统的,监控系统一般都做的很好,可以让你的系统直接接入进去,然后在上面可以看到每台机器的CPU、磁盘、内存、网络的一些负载。
-
这个场景的发生大致如下过程:某天团队里一个新手工程师大概是心血来潮,觉得自己网上看到了某个JVM参数,以为学会了绝世武功秘籍,于是就在当天上线一个系统的时候,自作主张设置了一个JVM参数直接导致线上频繁接到JVM的Full GC的报警,大家就很奇怪了,于是就开始排查那个系统了。
-
此时我们看到在GC日志中有大量的Full GC的记录。那么是为什么导致的Full GC呢?
在日志里,看到了一个“Metadata GC Threshold”的字样,类似于如下日志:
【Full GC(Metadata GC Threshold)xxxxx, xxxxx】
从这里就知道,这频繁的Full GC,实际上是JDK 1.8以后的Metadata元数据区导致的,也就是类似我们之前说的永久代。这个Metadata区域一般是放一些加载到JVM里去的类的。
所以此时就很奇怪了,为什么会因为Metadata区域频繁的被塞满,进而触发Full GC?而且Full GC大家都知道,会带动CMS回收老年代,还会回收Metadata区域本身。 -
很明显是系统在运行过程中,不停的有新的类产生被加载到Metaspace区域里去,然后不停的把Metaspace区域占满,接着触发一次Full GC回收掉Metaspace区域中的部分类。然后这个过程反复的不断的循环,进而造成Metaspace区域反复被占满,然后反复导致Full GC的发生。
-
这个时候就需要在JVM启动参数中加入如下两个参数了:
“-XX:TraceClassLoading -XX:TraceClassUnloading”
这两个参数,顾名思义,就是追踪类加载和类卸载的情况,他会通过日志打印出来JVM中加载了哪些类,卸载了哪些类。
加入这两个参数之后,我们就可以看到在Tomcat的catalina.out日志文件中,输出了一堆日志,里面显示类似如下的内容:
【Loaded sun.reflect.GeneratedSerializationConstructorAccessor from __JVM_Defined_Class】
明显可以看到,JVM在运行期间不停的加载了大量的所谓“GeneratedSerializationConstructorAccessor”类到了Metaspace区域里
去 -
那么接下来我们就很奇怪一件事情,就是JVM为什么要不停的创建那些奇怪的类然后放入Metaspace中去?
- 其实这就要从一个点入手来分析一下了,因为上面说的那种JVM自己创建的奇怪的类,他们的Class对象都是SoftReference,也就是软引用的。所以我们这里所说的Class对象,就是JVM在反射过程中动态生成的类的Class对象,他们都是SoftReference软引用的。
- 所谓的软引用,正常情况下不会回收,但是如果内存比较紧张的时候就会回收这些对象。那么SoftReference对象到底在GC的时候要不要回收是通过什么公式来判断的呢?
是如下的一个公式:clock - timestamp <= freespace * SoftRefLRUPolicyMSPerMB。 - 这个公式的意思就是说,“clock - timestamp”代表了一个软引用对象他有多久没被访问过了,freespace代表JVM中的空闲内存空间,SoftRefLRUPolicyMSPerMB代表每一MB空闲内存空间可以允许SoftReference对象存活多久。
- 举个例子,假如说现在JVM创建了一大堆的奇怪的类出来,这些类本身的Class对象都是被SoftReference软引用的。然后现在JVM里的空间内存空间有3000MB,SoftRefLRUPolicyMSPerMB的默认值是1000毫秒,那么就意味着,此时那些奇怪的SoftReference软引用的Class对象,可以存活3000 * 1000 = 3000秒,就是50分钟左右。
- 当然上面都是举例而已,大家都知道,一般来说发生GC时,其实JVM内部或多或少总有一些空间内存的,所以基本上如果不是快要发生OOM内存溢出了,一般软引用也不会被回收。所以大家就知道了,按理说JVM应该会随着反射代码的执行,动态的创建一些奇怪的类,他们的Class对象都是软引用的,正常情况下不会被回收,但是也不应该快速增长才对。
-
为什么JVM创建的奇怪的类会不停的变多?
- 原因很简单,因为文章开头那个新手工程师不知道从哪里扒出来了SoftRefLRUPolicyMSPerMB这个JVM启动参数,他直接把这个参数设置为0了。
他想的是,一旦这个参数设置为0,任何软引用对象就可以尽快释放掉,不用留存,尽量给内存释放空间出来,这样不就可以提高内存利用效率了么? - 实际上一旦这个参数设置为0之后,直接导致clock - timestamp <= freespace * SoftRefLRUPolicyMSPerMB这个公式的右半边是0,就导致所有的软引用对象,比如JVM生成的那些奇怪的Class对象,刚创建出来就可能被一次Young GC给带着立马回收掉一些。
- 比如JVM好不容易给你弄出来100个奇怪的类,结果因为你瞎设置软引用的参数,导致突然一次GC就给你回收掉几十个类接着JVM在反射代码执行的过程中,就会继续创建这种奇怪的类,在JVM的机制之下,会导致这种奇怪类越来越多。
也许下一次gc又会回收掉一些奇怪的类,但是马上JVM还会继续生成这种类,最终就会导致Metaspace区域被放满了,一旦Metaspace区域被占满了,就会触发Full GC,然后回收掉很多类,接着再次重复上述循环
- 原因很简单,因为文章开头那个新手工程师不知道从哪里扒出来了SoftRefLRUPolicyMSPerMB这个JVM启动参数,他直接把这个参数设置为0了。
1.11 案例三
- 通过监控平台+jstat工具分析,直接得出当时没优化过的系统的JVM性能表现大致如下:
- 机器配置:2核4G
- JVM堆内存大小:2G
- 系统运行时间:6天
- 系统运行6天内发生的Full GC次数和耗时:250次,70多秒
- 系统运行6天内发生的Young GC次数和耗时:2.6万次,1400秒
- 综合分析一下,就可以知道,大致来说每天会发生40多次Full GC,平均每小时2次,每次Full GC在300毫秒左右;
- 每天会发生4000多次Young GC,每分钟会发生3次,每次Young GC在50毫秒左右。
上述数据对任何一个线上系统,用jstat可以轻松看出来,因为jstat显示出来的Full GC和Young GC的次数都是系统启动以来的总次数,耗时都是所有GC加起来的总耗时,所以直接可以拿到上述数据,略微分析一下就知道具体情况了。 - 基本看起来,这个系统的性能是相当差了,每分钟3次Young GC,每小时2次Full GC,这种系统是必须得进行优化的。
- 未优化前的线上JVM参数
-Xms1536M -Xmx1536M -Xmn512M -Xss256K
-XX:SurvivorRatio=5 -XX:+UseParNewGC -XX:+UseConcMarkSweepGC
-XX:CMSInitiatingOccupancyFraction=68 -XX:+CMSParallelRemarkEnabled
-XX:+UseCMSInitiatingOccupancyOnly
-XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+PrintHeapAtGC
-
其实基本上跟我们之前看到的参数没多大的不同,一个4G的机器上,给JVM的堆内存是设置了1.5G的大小,其中新生代是给了512M,比较关键的是“-XX:SurvivorRatio”设置为了5,也就是说,Eden:Survivor1:Survivor2的比例是5:1:1所以此时Eden区域大致为365M,每个Survivor区域大致为70MB。而且这里有一个非常关键的参数,那就是“-XX:CMSInitiatingOccupancyFraction”参数设置为了68所以一旦老年代内存占用达到68%,也就是大概有680MB左右的对象时,就会触发一次Full GC。
-
首先我们知道每分钟会发生3次Young GC,说明系统运行20秒就会让Eden区满,也就是产生300多MB的对象,平均下来系统每秒钟会产生15~20MB的对象,所以20秒左右就会导致Eden区满,然后触发一次Young GC。接着我们根据每小时2次Full GC推断出,30分钟会触发一次Full GC,根据“-XX:CMSInitiatingOccupancyFraction=68”参数的设置,应该是在老年代有600多MB左右的对象时大概就会触发一次FullGC,因为1GB的老年代有68%空间占满了就会触发CMS的GC了。所以系统运行30分钟就会导致老年代里有600多MB的对象。
-
所以基本上我们就能根据推导出的运行内存模型得出一个结论:
每隔20秒会让300多MB的Eden区满触发一次Young GC,一次Young GC耗时50毫秒左右。
每隔30分钟会让老年代里600多MB空间占满,进而触发一次CMS的GC,一次Full GC耗时300毫秒左右。 -
但是到这里大家先暂停一下,有的朋友可能立马会推断了,他会说,是不是因为Survivor区域太小了,导致Young GC后的存活对象太多放不下,就一直有对象流入老年代,进而导致30分钟后触发Full GC?实际上仅仅只是分析到这里,绝对不能草率下这个判断的。因为老年代里为什么有那么多的对象?有可能是每次Young GC后的存活对象较多,Survivor区域太小,放不下了,也有可能是有很多长时间存活的对象太多了,都积累在老年代里,始终回收不掉,进而导致老年代很容易就达到68%的占比触发GC。
-
以用jstat在高峰期观察一下JVM实际运行的情况,通过jstat的观察,我们当时可以明确看到,每次Young GC过后升入老年代里的对象很少,一般来说,每次Young GC过后大概就存活几十MB而已,那么Survivor区域因为就70MB,所以经常会触发动态年龄判断规则,导致偶尔一次Young GC过后有几十MB对象进入老年代。偶尔一次Young GC才
会有几十MB对象进入老年代,记住,是偶尔一次!所以正常来说,应该不至于30分钟就导致老年代占用空间达到68%。 -
这个时候我们通过jstat运行的时候就观察到一个现象,就是老年代里的内存占用在系统运行的时候,不知道为什么系统运行着运行着,就会突然有几百MB的对象占据在里面,大概有五六百MB的对象,一直占据在老年代中正是因为系统运行的时候,不知道为什么突然有有几百MB对象进入老年代中,所以才导致Young GC偶尔一次让几十MB对象升入老年代,平均30分钟左右就会触发一次Full GC!!!那么我们就很奇怪了,为什么系统运行着会突然有几百MB的对象进入老年代?答案已经呼之欲出了,大对象!
-
分析到这里,就很简单了,我们只需要采用之前给大家介绍的jmap工具,通过后台jstat工具观察系统,什么时候发现老年代里突然进入了几百MB的大对象,就立马用jmap工具导出一份dump内存快照。
-
通过内存快照的分析,直接定位出来那个几百MB的大对象,就是几个Map之类的数据结构,这是什么东西?直接让负责写那个系统代码的几个同学分析了一下,明显是从数据库里查出来的!
-
然后这个时候也没太好的办法了,直接笨办法,几个人地毯式排查这个系统的所有SQL语句,结果还真的有一个人发现,自己的一个SQL居然在某种特殊的场景下,会类似如下所示:
select * from tbl。
这是啥意思?就是没有where条件!没有where条件,就代表这个SQL可能会把表中几十万条数据直接全部查出来!
正是因为这个代码层面的bug,导致了每隔一段时间系统会搞出几个上百MB的大对象,这些对象是会全部直接进入老年代的!
然后过一会儿随着偶尔几次Young GC有几十MB对象进入老年代,所以平均几十分钟就会触发一次Full GC!!! -
针对本案例的JVM和代码优化
其实分析到这里,这个案例如何优化已经呼之欲出了
(1)第一步,让开发同学解决代码中的bug,避免一些极端情况下SQL语句里不拼接where条件,务必要拼接上where条件,不允许查询表中全部数据。彻底解决那个时不时有几百MB对象进入老年代的问题。
(2)第二步,年轻代明显过小,Survivor区域空间不够,因为每次Young GC后存活对象在几十MB左右,如果Survivor就70MB很容易触发动态年龄判定,让对象进入老年代中。所以直接调整JVM参数如下:
-Xms1536M -Xmx1536M -Xmn1024M -Xss256K -XX:SurvivorRatio=5
-XX:PermSize=256M -XX:MaxPermSize=256M
-XX:+UseParNewGC -XX:+UseConcMarkSweepGC
-XX:CMSInitiatingOccupancyFraction=92 -
XX:+CMSParallelRemarkEnabled -XX:+UseCMSInitiatingOccupancyOnly
-XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+PrintHeapAtGC
- 直接把年轻代空间调整为700MB左右,每个Surivor是150MB左右,此时Young GC过后就几十MG存活对象,一般不会进入老年代。反之老年代就留500MB左右就足够了,因为一般不会有对象进入老年代。
- 而且调整了参数“-XX:CMSInitiatingOccupancyFraction=92”,避免老年代仅仅占用68%就触发GC,现在必须要占用到92%才会触发GC。
- 最后,就是主动设置了永久代大小为256MB,因为如果不主动设置会导致默认永久代就在几十MB的样子,很容易导致万一系统运行时候采用了反射之类的机制,可能一旦动态加载的类过多,就会频繁触发Full GC。
- 这几个步骤优化完毕之后,线上系统基本上表现就非常好了,基本上每分钟大概发生一次Young GC,一次在几十毫秒;Full GC几乎就很少,大概可能要运行至少10天才会发生一次,一次就耗时几百毫秒而已,频率很低。
马士兵老师jvm笔记
JVM笔记-GC常用参数设置
Jvm线上调优实战(1)
JVM线上调优实战(2)
一次线上JVM调优实践
三天两夜肝完这篇万字长文,终于拿下了 TCP/IP
使用 JMeter 进行压力测试
jvm实用调优参数(G1)
JVM笔记(黑马+尚硅谷+张龙整合笔记)
JVisualVM工具查看死锁、溢出、堆
java虚拟机(十一)–GC日志分析