记一次系统上线的JVM调优

前言

开发了大半年的数据中台系统,近期在测试环境上线压测,监控JVM时发现GC频繁,开启GC日志后发现隔几秒就要做一次Minor GC(对新生代内存进行回收),并且堆内存不到十分之一的时候就进行GC(测试-Xmx设置为10g,使用不到1g就进行了gc),于是从两个方面进行系统的优化(代码层面和jvm启动参数)。

1、代码层面

观察jvm内存使用的时候,发现堆内存在5秒内就迅速上升1g,此时HTTP访问的人数为1,那么在并发访问数目极少的情况下,内存上升快的原因应该就是后台的监控线程了(在后台的线程池中做服务进程、数据仓库、中间件kafka的监控),在这些监控线程中,有大量的RESTful API请求以及对服务器端口的检测(远程)。经过分析代码发现,因为监控线程定时运行,其中大部分的远程连接和api请求可以不断开,保存在内存中重复使用这些对象(加锁机制)。通过这部分的改造之后再观察jvm的使用情况,发现效果显著。

2、jvm参数层面

通过分析代码,总结出系统新生代的内存占用大,并且在GC的时候会回收大量的内存,前面也有说到过一共10G的堆内存,使用不到1G就进行了GC。因此从jvm的参数方面进行系统的调优。接下来会先介绍jvm的一些基础知识,并帮助大家进行参数调优理解。

2.1、JVM结构

file

各部分的主要功能:

类加载器 JVM启动,程序开始执行时,负责将class字节码加载到JVM内存区域中

执行引擎 负责执行class文件中包含的字节码指令

本地方法库

主要是调用C或C++实现的本地方法及返回结果

运行时数据区【重点关注】

方法区(Method Area) 用于存储类结构信息的地方,包括常量池、静态变量、构造函数等。

java堆(Heap)

存储java实例或者对象的地方。这块是GC的主要区域。方法区和堆是被所有java线程共享的。

java栈(Stack) java栈总是和线程关联在一起,每当创建一个线程时,JVM就会为这个线程创建一个对应的java栈。在这个java栈中又会包含多个栈帧,每运行一个方法就创建一个栈帧,用于存储局部变量表、操作栈、方法返回值等。每一个方法从调用直至执行完成的过程,就对应一个栈帧在java栈中入栈到出栈的过程。所以java栈是现成私有的。

程序计数器(PC Register) 程序计数器是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

本地方法栈(Native Method Stack) 和java栈的作用差不多,只不过是为JVM使用到的native方法服务的。

2.2、JVM内存分配及回收策略

虚拟机中的共划分为三个代:年轻代(Young Generation)、年老点(Old Generation)和持久代(Permanent Generation)。其中持久代主要存放的是Java类的类信息,与垃圾收集要收集的Java对象关系不大。年轻代和年老代的划分是对垃圾收集影响比较大的。

年轻代:

所有新生成的对象首先都是放在年轻代的。年轻代的目标就是尽可能快速的收集掉那些生命周期短的对象。年轻代分三个区。一个Eden区,两个Survivor区(一般而言)。大部分对象在Eden区中生成。当Eden区满时,还存活的对象将被复制到Survivor区(两个中的一个),当这个Survivor区满时,此区的存活对象将被复制到另外一个Survivor区,当这个Survivor去也满了的时候,从第一个Survivor区复制过来的并且此时还存活的对象,将被复制“年老区(Tenured)”。需要注意,Survivor的两个区是对称的,没先后关系,所以同一个区中可能同时存在从Eden复制过来 对象,和从前一个Survivor复制过来的对象,而复制到年老区的只有从第一个Survivor去过来的对象。而且,Survivor区总有一个是空的。同时,根据程序需要,Survivor区是可以配置为多个的(多于两个),这样可以增加对象在年轻代中的存在时间,减少被放到年老代的可能。

年老代:

在年轻代中经历了N次垃圾回收后仍然存活的对象,就会被放到年老代中。因此,可以认为年老代中存放的都是一些生命周期较长的对象。

持久代:

用于存放静态文件,如今Java类、方法等。持久代对垃圾回收没有显著影响,但是有些应用可能动态生成或者调用一些class,例如Hibernate等,在这种时候需要设置一个比较大的持久代空间来存放这些运行过程中新增的类。持久代大小通过-XX:MaxPermSize=进行设置。

新生代和老年代的梗说,JVM的内存分配也是和GC保持一致的,具体分配如图:

file

具体的回收策略如图:

file

file

总结策略就是:

对象优先在Eden分配
大对象直接进老年代
长期存活的对象将进入老年代
动态对象进行年龄判定再分代
GC类型

Scavenge GC 一般情况下,当新对象生成,并且在Eden申请空间失败时,就会触发Scavenge GC,对Eden区域进行GC,清除非存活对象,并且把尚且存活的对象移动到Survivor区。然后整理Survivor的两个区。这种方式的GC是对年轻代的Eden区进行,不会影响到年老代。因为大部分对象都是从Eden区开始的,同时Eden区不会分配的很大,所以Eden区的GC会频繁进行。因而,一般在这里需要使用速度快、效率高的算法,使Eden去能尽快空闲出来。

Full GC 对整个堆进行整理,包括Young、Tenured和Perm。Full GC因为需要对整个对进行回收,所以比Scavenge GC要慢,因此应该尽可能减少Full GC的次数。在对JVM调优的过程中,很大一部分工作就是对于FullGC的调节。有如下原因可能导致Full GC:· 年老代(Tenured)被写满 · 持久代(Perm)被写满 · System.gc()被显示调用 ·上一次GC之后Heap的各域分配策略动态变化

2.3 JVM内存泄露和溢出

2.3.1.定义

内存泄露

指程序中动态分配内存给一些临时对象,但是对象不会被GC所回收,它始终占用内存。即被分配的对象可达但已无用。

内存溢出 指程序运行过程中无法申请到足够的内存而导致的一种错误。内存溢出通常发生于OLD段或Perm段垃圾回收后,仍然无内存空间容纳新的Java对象的情况。

从定义上可以看出内存泄露是内存溢出的一种诱因,不是唯一因素。Java 堆内存的OutOfMemoryError异常是实际应用中最常见的内存溢出异常情况。出现Java 堆内存溢出时,异常堆栈信息“java.lang.OutOfMemoryError”会跟着进一步提示“Java heapspace”。

2.3.2.常见内存泄露的几种场景

1、长生命周期的对象持有短生命周期对象的引用

这是内存泄露最常见的场景,也是代码设计中经常出现的问题。例如:在全局静态map中缓存局部变量,且没有清空操作,随着时间的推移,这个map会越来越大,造成内存泄露。

2、修改hashset中对象的参数值,且参数是计算哈希值的字段

当一个对象被存储进HashSet集合中以后,就不能修改这个对象中的那些参与计算哈希值的字段,否则对象修改后的哈希值与最初存储进HashSet集合中时的哈希值就不同了,在这种情况下,即使在contains方法使用该对象的当前引用作为参数去HashSet集合中检索对象,也将返回找不到对象的结果,这也会导致无法从HashSet集合中删除当前对象,造成内存泄露。

3、机器的连接数和关闭时间设置

长时间开启非常耗费资源的连接,也会造成内存泄露。

2.3.3.内存溢出的几种情况

1、堆内存溢出(outOfMemoryError:Java heap space)

在jvm规范中,堆中的内存是用来生成对象实例和数组的。如果细分,堆内存还可以分为年轻代和年老代,年轻代包括一个eden区和两个survivor区。当生成新对象时,内存的申请过程如下:

a、jvm先尝试在eden区分配新建对象所需的内存;
b、如果内存大小足够,申请结束,否则下一步;
c、jvm启动youngGC,试图将eden区中不活跃的对象释放掉,释放后若Eden空间仍然不足以放入新对象,则试图将部分Eden中活跃对象放入Survivor区;
d、Survivor区被用来作为Eden及old的中间交换区域,当OLD区空间足够时,Survivor区的对象会被移到Old区,否则会被保留在Survivor区;
e、 当OLD区空间不够时,JVM会在OLD区进行full GC;
f、full GC后,若Survivor及OLD区仍然无法存放从Eden复制过来的部分对象,导致JVM无法在Eden区为新对象创建内存区域,则出现”out of memory错误”:outOfMemoryError:java heap space
2、方法区内存溢出(outOfMemoryError:permgem space)

在jvm规范中,方法区主要存放的是类信息、常量、静态变量等。所以如果程序加载的类过多,或者使用反射、gclib等这种动态代理生成类的技术,就可能导致该区发生内存溢出,一般该区发生内存溢出时的错误信息为:outOfMemoryError:permgem space

3、线程栈溢出(java.lang.StackOverflowError)

线程栈时线程独有的一块内存结构,所以线程栈发生问题必定是某个线程运行时产生的错误。一般线程栈溢出是由于递归太深或方法调用层级过多导致的。发生栈溢出的错误信息为:java.lang.StackOverflowError

4.发生了内存泄露或溢出怎么办?

要解决这个区域的异常,一般的手段是首先通过内存映像分析工具(如Eclipse Memory Analyzer)对dump 出来的堆转储快照进行分析,重点是确认内存中的对象是否是必要的,也就是要先分清楚到底是出现了内存泄漏(Memory Leak)还是内存溢出(Memory Overflow)。

如果是内存泄漏,可进一步通过工具查看泄漏对象到GC Roots 的引用链。于是就能找到泄漏对象是通过怎样的路径与GC Roots 相关联并导致垃圾收集器无法自动回收它们的。掌握了泄漏对象的类型信息,以及GC Roots 引用链的信息,就可以比较准确地定位出泄漏代码的位置。

如果不存在泄漏,换句话说就是内存中的对象确实都还必须存活着,那就应当检查虚拟机的堆参数(-Xmx 与-Xms),与机器物理内存对比看是否还可以调大,从代码上检查是否存在某些对象生命周期过长、持有状态时间过长的情况,尝试减少程序运行期的内存消耗。

5.怎么样避免发生内存泄露和溢出

a、尽早释放无用对象的引用
b、使用字符串处理,避免使用String,应大量使用StringBuffer,每一个String对象都得独立占用内存一块区域
c、尽量少用静态变量,因为静态变量存放在永久代(方法区),永久代基本不参与垃圾回收
d、避免在循环中创建对象
e、开启大型文件或从数据库一次拿了太多的数据很容易造成内存溢出,所以在这些地方要大概计算一下数据量的最大值是多少,并且设定所需最小及最大的内存空间值。
2.4 JVM参数详解

2.4.1 堆内存参数详解

-Xmx10g:设置JVM最大可用内存为10g。-Xms10g:设置JVM
促使内存为10g。此值可以设置与-Xmx相同,以避免每次垃圾回收完成后JVM
重新分配内存。-Xmn8g:设置年轻代大小为8G。整个堆大小=年轻代大小+年老代大小+
持久代大小。持久代一般固定大小为64m,所以增大年轻代后,将会减小年老代大小。此值对系统性能影响较大,
Sun官方推荐配置为整个堆的3/8。-Xss128k:设置每个线程的堆栈大小。JDK5.0以后每个线程堆栈大小为1M,以前每个线程堆栈大小为
256K。更具应用的线程所需内存大小进行调整。在相同物理内存下,减小这个值能生成更多的线程。
但是操作系统对一个进程内的线程数还是有限制的,不能无限生成,经验值在3000~5000左右。-XX:NewRatio=4
:设置年轻代(包括Eden和两个Survivor区)与年老代的比值(除去持久代)。设置为4,则年轻代与年老代所占比值为1:4
,年轻代占整个堆栈的1/5-XX:SurvivorRatio=4:设置年轻代中Eden区与Survivor区的大小比值。设置为4,则两个Survivor区与一个
Eden区的比值为2:4,一个Survivor区占整个年轻代的1/6-XX:
MaxPermSize=16m:设置持久代大小为
16m

XX
:
MaxTenuringThreshold
=
0
:设置垃圾最大年龄。如果设置为
0
的话,则年轻代对象不经过
Survivor
区,直接进入年老代。对于年老代比较多的应用,可以提高效率。如果将此值设置为一个较大值,则年轻代对象会在
Survivor
区进行多次复制,这样可以增加对象再年轻代的存活时间,增加在年轻代即被回收的概论。

-XX:+CMSClassUnloadingEnabled

这个参数表示在使用
CMS
垃圾回收机制的时候是否启用类卸载功能。默认这个是设置为不启用的
所以你想启用这个功能你需要在
Java
参数中明确的设置下面的参数:

XX
:+
CMSClassUnloadingEnabled
如果你启用了
CMSClassUnloadingEnabled

,垃圾回收会清理持久代,移除不再使用的
classes

这个参数只有在

UseConcMarkSweepGC

也启用的情况下才有用。参数如下:

XX
:+
UseConcMarkSweepGC

-XX:+CMSPermGenSweepingEnabled

这个参数表示是否会清理持久代。默认是不清理的,因此我们需要明确设置这个参数来调试持久代内存溢出问题。这个参数在
Java6
中被移除了,因此你需要使用

XX
:+
CMSClassUnloadingEnabled

如果你是使用
Java6
或者后面更高的版本。那么解决持久代内存大小问题的参数看起来会是下面这样子:

XX
:
MaxPermSize
=
128m

XX
:+
UseConcMarkSweepGC
XX
:+
CMSClassUnloadingEnabled

XX
:+
CMSParallelRemarkEnabled

降低标记停顿
2.4.2 回收器参数详解

JVM给了三种选择:串行收集器、并行收集器、并发收集器,但是串行收集器只适用于小数据量的情况,所以这里的选择主要针对并行收集器和并发收集器。默认情况下,JDK5.0以前都是使用串行收集器,如果想使用其他收集器需要在启动时加入相应参数。JDK5.0以后,JVM会根据当前系统配置进行判断。

XX
:+
UseSerialGC
:设置串行收集器

XX
:+
UseParallelGC
:设置并行收集器

XX
:+
UseParalledlOldGC
:设置并行年老代收集器

XX
:+
UseConcMarkSweepGC
:设置并发收集器

并行收集器设置

XX
:
ParallelGCThreads
=
n
:设置并行收集器收集时使用的
CPU
数。并行收集线程数。

XX
:
MaxGCPauseMillis
=
n
:设置并行收集最大暂停时间

XX
:
GCTimeRatio
=
n
:设置垃圾回收时间占程序运行时间的百分比。公式为
1
/(
1

n
)

并发收集器设置

XX
:+
CMSIncrementalMode
:设置为增量模式。适用于单
CPU
情况。

XX
:
ParallelGCThreads
=
n
:设置并发收集器年轻代收集方式为并行收集时,使用的
CPU
数。并行收集线程数。
吞吐量优先的并行收集器 如上文所述,并行收集器主要以到达一定的吞吐量为目标,适用于科学技术和后台处理等。典型配置:
java

Xmx3800m

Xms3800m

Xmn2g

Xss128k

XX
:+
UseParallelGC

XX
:
ParallelGCThreads
=
20

参数解释

XX
:+
UseParallelGC
:选择垃圾收集器为并行收集器。此配置仅对年轻代有效。即上述配置下,年轻代使用并发收集,而年老代仍旧使用串行收集。

XX
:
ParallelGCThreads
=
20
:配置并行收集器的线程数,即:同时多少个线程一起进行垃圾回收。此值最好配置与处理器数目相等。

java

Xmx3550m

Xms3550m

Xmn2g

Xss128k

XX
:+
UseParallelGC

XX
:
ParallelGCThreads
=
20

XX
:+
UseParallelOldGC

参数解释

XX
:+
UseParallelOldGC
:配置年老代垃圾收集方式为并行收集。
JDK6
.
0
支持对年老代并行收集。

java

Xmx3550m

Xms3550m

Xmn2g

Xss128k

XX
:+
UseParallelGC

XX
:
MaxGCPauseMillis
=
100

参数解释

XX
:
MaxGCPauseMillis
=
100
:设置每次年轻代垃圾回收的最长时间,如果无法满足此时间,
JVM
会自动调整年轻代大小,以满足此值。

java

Xmx3550m

Xms3550m

Xmn2g

Xss128k

XX
:+
UseParallelGC

XX
:
MaxGCPauseMillis
=
100

XX
:+
UseAdaptiveSizePolicy

参数解释

XX
:+
UseAdaptiveSizePolicy
:设置此选项后,并行收集器会自动选择年轻代区大小和相应的
Survivor
区比例,以达到目标系统规定的最低相应时间或者收集频率等,此值建议使用并行收集器时,一直打开。
响应时间优先的并发收集器 如上文所述,并发收集器主要是保证系统的响应时间,减少垃圾收集时的停顿时间。适用于应用服务器、电信领域等。典型配置:
java

Xmx3550m

Xms3550m

Xmn2g

Xss128k

XX
:
ParallelGCThreads
=
20

XX
:+
UseConcMarkSweepGC

XX
:+
UseParNewGC

参数解释

XX
:+
UseConcMarkSweepGC
:设置年老代为并发收集。测试中配置这个以后,-
XX
:
NewRatio
=
4
的配置失效了,原因不明。所以,此时年轻代大小最好用-
Xmn
设置。

XX
:+
UseParNewGC
:设置年轻代为并行收集。可与
CMS
收集同时使用。
JDK5
.
0
以上,
JVM
会根据系统配置自行设置,所以无需再设置此值。

java

Xmx3550m

Xms3550m

Xmn2g

Xss128k

XX
:+
UseConcMarkSweepGC

XX
:
CMSFullGCsBeforeCompaction
=
5

XX
:+
UseCMSCompactAtFullCollection

参数解释

XX
:
CMSFullGCsBeforeCompaction
:由于并发收集器不对内存空间进行压缩、整理,所以运行一段时间以后会产生“碎片”,使得运行效率降低。此值设置运行多少次
GC
以后对内存空间进行压缩、整理。

XX
:+
UseCMSCompactAtFullCollection
:打开对年老代的压缩。可能会影响性能,但是可以消除碎片
2.4.3 辅助信息参数详解

JVM提供了大量命令行参数,打印信息,供调试使用。主要有以下一些:

-XX:+
PrintGC
输出形式:[
GC
118250K
->
113543K
(
130112K
),

0.0094143
secs
]

[
Full
GC
121376K
->
10414K
(
130112K
),

0.0650971
secs
]

XX
:+
PrintGCDetails
输出形式:[
GC
[
DefNew
:

8614K
->
781K
(
9088K
),

0.0123035
secs
]

118250K
->
113543K
(
130112K
),

0.0124633
secs
]

[
GC
[
DefNew
:

8614K
->
8614K
(
9088K
),

0.0000665
secs
][
Tenured
:

112761K
->
10414K
(
121024K
),

0.0433488
secs
]

121376K
->
10414K
(
130112K
),

0.0436268
secs
]

XX
:+
PrintGCTimeStamps

XX
:+
PrintGC

PrintGCTimeStamps
可与上面两个混合使用
输出形式:
11.851
:

[
GC
98328K
->
93620K
(
130112K
),

0.0082960
secs
]

XX
:+
PrintGCApplicationConcurrentTime
:打印每次垃圾回收前,程序未中断的执行时间。可与上面混合使用
输出形式:
Application
time
:

0.5291524
seconds

XX
:+
PrintGCApplicationStoppedTime
:打印垃圾回收期间程序暂停的时间。可与上面混合使用
输出形式:
Total
time
for
which application threads were stopped
:

0.0468229
seconds

XX
:
PrintHeapAtGC
:打印
GC
前后的详细堆栈信息
输出形式:
34.702
:

[
GC
{
Heap
before gc invocations
=
7
:
def

new
generation total
55296K
,
used
52568K

[
0x1ebd0000
,

0x227d0000
,

0x227d0000
)
eden space
49152K
,

99
%
used
[
0x1ebd0000
,

0x21bce430
,

0x21bd0000
)
from
space
6144K
,

55
%
used
[
0x221d0000
,

0x22527e10
,

0x227d0000
)
to space
6144K
,

0
%
used
[
0x21bd0000
,

0x21bd0000
,

0x221d0000
)
tenured generation total
69632K
,
used
2696K

[
0x227d0000
,

0x26bd0000
,

0x26bd0000
)
the space
69632K
,

3
%
used
[
0x227d0000
,

0x22a720f8
,

0x22a72200
,

0x26bd0000
)
compacting perm gen total
8192K
,
used
2898K

[
0x26bd0000
,

0x273d0000
,

0x2abd0000
)
the space
8192K
,

35
%
used
[
0x26bd0000
,

0x26ea4ba8
,

0x26ea4c00
,

0x273d0000
)
ro space
8192K
,

66
%
used
[
0x2abd0000
,

0x2b12bcc0
,

0x2b12be00
,

0x2b3d0000
)
rw space
12288K
,

46
%
used
[
0x2b3d0000
,

0x2b972060
,

0x2b972200
,

0x2bfd0000
)
34.735
:

[
DefNew
:

52568K
->
3433K
(
55296K
),

0.0072126
secs
]

55264K
->
6615K
(
124928K
)
Heap
after gc invocations
=
8
:
def

new
generation total
55296K
,
used
3433K

[
0x1ebd0000
,

0x227d0000
,

0x227d0000
)
eden space
49152K
,

0
%
used
[
0x1ebd0000
,

0x1ebd0000
,

0x21bd0000
)
from
space
6144K
,

55
%
used
[
0x21bd0000
,

0x21f2a5e8
,

0x221d0000
)
to space
6144K
,

0
%
used
[
0x221d0000
,

0x221d0000
,

0x227d0000
)
tenured generation total
69632K
,
used
3182K

[
0x227d0000
,

0x26bd0000
,

0x26bd0000
)
the space
69632K
,

4
%
used
[
0x227d0000
,

0x22aeb958
,

0x22aeba00
,

0x26bd0000
)
compacting perm gen total
8192K
,
used
2898K

[
0x26bd0000
,

0x273d0000
,

0x2abd0000
)
the space
8192K
,

35
%
used
[
0x26bd0000
,

0x26ea4ba8
,

0x26ea4c00
,

0x273d0000
)
ro space
8192K
,

66
%
used
[
0x2abd0000
,

0x2b12bcc0
,

0x2b12be00
,

0x2b3d0000
)
rw space
12288K
,

46
%
used
[
0x2b3d0000
,

0x2b972060
,

0x2b972200
,

0x2bfd0000
)
}
,

0.0757599
secs
]

Xloggc
:
filename
:与上面几个配合使用,把相关日志信息记录到文件以便分析。
调优总结

1、年轻代大小选择

响应时间优先的应用:尽可能设大,直到接近系统的最低响应时间限制(根据实际情况选择)。在此种情况下,年轻代收集发生的频率也是最小的。同时,减少到达年老代的对象。吞吐量优先的应用:尽可能的设置大,可能到达Gbit的程度。因为对响应时间没有要求,垃圾收集可以并行进行,一般适合8CPU以上的应用。

2、年老代大小选择

响应时间优先的应用:年老代使用并发收集器,所以其大小需要小心设置,一般要考虑并发会话率和会话持续时间等一些参数。如果堆设置小了,可以会造成内存碎片、高回收频率以及应用暂停而使用传统的标记清除方式;如果堆大了,则需要较长的收集时间。最优化的方案,一般需要参考以下数据获得:

并发垃圾收集信息
持久代并发收集次数
传统GC信息
花在年轻代和年老代回收上的时间比例
减少年轻代和年老代花费的时间,一般会提高应用的效率
3、吞吐量优先的应用

一般吞吐量优先的应用都有一个很大的年轻代和一个较小的年老代。原因是,这样可以尽可能回收掉大部分短期对象,减少中期的对象,而年老代尽存放长期存活对象。

4、较小堆引起的碎片问题

因为年老代的并发收集器使用标记、清除算法,所以不会对堆进行压缩。当收集器回收时,他会把相邻的空间进行合并,这样可以分配给较大的对象。但是,当堆空间较小时,运行一段时间以后,就会出现“碎片”,如果并发收集器找不到足够的空间,那么并发收集器将会停止,然后使用传统的标记、清除方式进行回收。如果出现“碎片”,可能需要进行如下配置:-XX:+UseCMSCompactAtFullCollection:使用并发收集器时,开启对年老代的压缩。-XX:CMSFullGCsBeforeCompaction=0:上面配置开启的情况下,这里设置多少次Full GC后,对年老代进行压缩

参考链接:[https://blog.csdn.net/chizizhixin/article/details/87877265] [https://blog.csdn.net/shengmingqijiquan/article/details/77508471]

此次系统的调优让系统运行的速度有了很大的提升,同时学习到了JVM的内存管理和垃圾回收机制。总的来说收获很大。故整理出来共享给大家,希望能帮助到以后有接触的童鞋。如有错误的地方,烦请大神指导。创作不易,给个关注和赞哟~

本文由博客一文多发平台 OpenWrite 发布!

你可能感兴趣的:(记一次系统上线的JVM调优)