😀大家好,我是白晨,一个不是很能熬夜😫,但是也想日更的人✈。如果喜欢这篇文章,点个赞👍,关注一下👀白晨吧!你的支持就是我最大的动力!💪💪💪
文章目录
- JVM调优实战
- 性能优化的步骤
- 第一步(发现问题):性能监控
- 第二步(排查问题):性能分析
- 第三步(解决问题):性能调优
- Jmeter
- 简介
- 使用流程
- 新增线程组
- 新增JMeter元组
- 新增监听器
- 运行并查看结果
- 案例1:调整堆大小提升服务的吞吐量
- 修改tomcatJVM配置
- 初始配置
- 优化配置
- 案例2:JVM优化之JIT优化
- 堆是分配对象的唯一选择吗?
- 传统认知的局限性
- JIT的优化突破:逃逸分析
- 总结
- 编译的开销
- 时间开销
- 空间开销
- 编译开销的平衡艺术
- 即时编译对代码的优化
- 逃逸分析
- 代码举例1:未逃逸对象
- 代码举例2:逃逸对象
- 参数设置
- 代码优化一:栈上分配
- 代码举例
- 代码优化二:同步省略(锁消除)
- 代码举例
- 代码优化三:标量替换
- 代码举例1:基本类型替换
- 参数设置
- 代码举例2:数组成员替换
- 逃逸分析小结
- 案例3:合理配置堆内存
- 推荐配置
- 如何计算老年代存活对象
- 方式1:查看GC日志(无侵入式)
- 方式2:强制触发Full GC(侵入式)
- 注意事项
- 方案对比
- 案例演示
- JVM参数配置
- 代码演示
- 数据分析
- 结论
- 如何估算GC频率
- 新生代与老年代的比例
- 参数设置:手动控制比例
- 参数AdaptiveSizePolicy:动态比例调优
- 验证方式
- 案例4:CPU占用很高的排查方案
- 示例代码
- 问题呈现
- 问题分析
- 解决方案
- 案例5:G1并发GC线程数对性能的影响
- 配置信息
- 硬件配置
- JVM参数设置
- 初始状态
- 优化后
- 总结
- 案例6:调整垃圾回收器对吞吐量的影响
- 初始配置:单核+SerialGC
- 优化配置1:ParallelGC
- 优化配置2:8核
- 优化配置3:G1
- 案例7:日均百万订单如何设置JVM参数
JVM调优实战
性能优化的步骤
首先,来回顾一下调优的基本步骤。
第一步(发现问题):性能监控
性能监控是整个调优过程的基础,必须通过监控工具实时获取系统运行状态。通过监控,可以发现哪些资源(如内存、CPU、磁盘、网络等)存在瓶颈,系统是否频繁发生GC停顿、线程阻塞等问题。 常见的问题如下:
- GC频繁
- CPU负载过高
- OOM
- 内存泄露
- 死锁
- 程序响应时间长
第二步(排查问题):性能分析
- 打印GC日志,通过
GCViewer
或GCeasy
来分析异常 - 灵活运用命令行工具,如
jstat
、jinfo
等 - 生成快照文件,使用内存分析工具分析文件
- 使用阿里
Arthas
、jconsole
以及JVisualVM
等GUI工具来实时查看JVM状态 jstack
查看堆栈信息
第三步(解决问题):性能调优
- 调优GC:根据分析结果,调整垃圾回收器和相关参数,减少GC停顿时间。例如,切换至G1 GC或使用ZGC(低延迟垃圾回收器)。
- 调优堆内存:根据应用的内存需求,调整堆大小,避免内存溢出或频繁的垃圾回收。
- 调优线程池:通过调整线程池的大小、线程优先级等,确保线程调度的高效性。
- 优化代码:针对代码中的性能瓶颈,进行相应的优化,减少内存泄漏和过多的锁竞争。
- 使用中间件:针对程序中原生的接口,观察是否可以替换为效率更高的中间件,如消息队列等。
Jmeter
简介
Apache JMeter是Apache组织开发的基于Java的压力测试工具。用于对软件做压力测试,它最初被设计用于Web应用测试,但后来扩展到其他测试领域。 它可以用于测试静态和动态资源,例如静态文件、CGI 脚本、Java 对象、数据库、FTP 服务器, 等等。JMeter 可以用于对服务器、网络或对象模拟巨大的负载,来自不同压力类别下测试它们的强度和分析整体性能。我们这里主要介绍与我们接下来的案例相关的部分。
使用流程
新增线程组
新增JMeter元组
创建各种默认元组及测试元组,填入目标测试静态资源请求和动态资源请求参数及数据。
新增http采样器,采样器用于对具体的请求进行性能数据的采样,如下图所示,这次案例添加HTTP请求的采样。
对请求的具体目标进行设置,比如目标服务器地址,端口号,路径等信息,如下图所示,Jmeter会按照设置对目标进行批量的请求。
新增监听器
创建各种形式的结果搜集元组,以便在运行过程及运行结束后搜集监控指标数据。
对于批量请求的访问结果,Jmeter可以以报告的形式展现出来,在监听器中,添加聚合报告,如下图所示:
运行并查看结果
调试运行,分析指标数据,挖掘性能瓶颈、评估系统性能状态
案例1:调整堆大小提升服务的吞吐量
修改tomcatJVM配置
生产环境下,Tomcat并不建议直接在catalina.sh里配置变量,而是写在与catalina同级目录(bin目录)下的setenv.sh里。
所以如果我们想要修改jvm的内存配置,那么我们就需要修改setenv.sh文件(默认没有,需新建一个setenv.sh)。
初始配置
setenv.sh文件中写入(大小根据自己情况修改):setenv.sh内容如下:
export CATALINA_OPTS="$CATALINA_OPTS -Xms30m"
export CATALINA_OPTS="$CATALINA_OPTS -XX:SurvivorRatio=8"
export CATALINA_OPTS="$CATALINA_OPTS -Xmx30m"
export CATALINA_OPTS="$CATALINA_OPTS -XX:+UseParallelGC"
export CATALINA_OPTS="$CATALINA_OPTS -XX:+PrintGCDetails"
export CATALINA_OPTS="$CATALINA_OPTS -XX:MetaspaceSize=64m"
export CATALINA_OPTS="$CATALINA_OPTS -XX:+PrintGCDateStamps"
export CATALINA_OPTS="$CATALINA_OPTS -Xloggc:/opt/tomcat8.5/logs/gc.log"
我们查看日志信息:
其中存在大量的Full GC日志,查看一下我们Jmeter汇总报告
优化配置
接下来我们测试另外一组数据,增加初始化和最大内存:
export CATALINA_OPTS="$CATALINA_OPTS -Xms120m"
export CATALINA_OPTS="$CATALINA_OPTS -XX:SurvivorRatio=8"
export CATALINA_OPTS="$CATALINA_OPTS -Xmx120m"
export CATALINA_OPTS="$CATALINA_OPTS -XX:+UseParallelGC"
export CATALINA_OPTS="$CATALINA_OPTS -XX:+PrintGCDetails"
export CATALINA_OPTS="$CATALINA_OPTS -XX:MetaspaceSize=64m"
export CATALINA_OPTS="$CATALINA_OPTS -XX:+PrintGCDateStamps"
export CATALINA_OPTS="$CATALINA_OPTS -Xloggc:/opt/tomcat8.5/logs/gc.log"
查找Full关键字,发现只有一处FullGC,如下图所示,我们可以看到,增大了初始化内存和最大内存之后,我们的Full次数有一个明显的减少。
查看Jmeter汇总报告,如下图所示:吞吐量变成了1142.1/sec,基本上是有一个明显的提升,这就说明,增加内存之后,服务器的性能有一个明显的提升。
案例2:JVM优化之JIT优化
堆是分配对象的唯一选择吗?
在Java开发者的普遍认知中,对象的实例化必须依赖堆内存分配,这一观点源自Java语言规范对对象存储的早期定义。然而,随着JVM即时编译(JIT)技术的发展,尤其是**逃逸分析(Escape Analysis)**的引入,这一结论正在被颠覆。
传统认知的局限性
- 堆分配的必要性: 对象的生命周期通常跨越方法调用边界,堆内存的动态性和线程共享特性使其成为默认选择。
- 性能代价: 频繁的堆分配会导致内存碎片化、垃圾回收(GC)压力增大,尤其是对短生命周期的小对象而言,GC暂停时间可能成为性能瓶颈。
JIT的优化突破:逃逸分析
JVM的即时编译器通过逃逸分析技术,能够在编译期推断对象的生命周期范围,从而为某些对象提供更高效的分配方式。
总结
虽然堆仍是大部分对象的最终归宿,但JIT通过逃逸分析打破了“堆是唯一选择”的绝对性。对于符合条件的作用域内对象,栈上分配和标量替换能显著减少GC压力,提升程序吞吐量。开发者可通过代码结构优化(如减少全局暴露、缩小对象作用域)主动适配这些特性,释放JVM性能潜力。
编译的开销
即时编译(JIT)技术通过将热点代码转换为本地机器码来提升程序性能,但这一过程并非零成本。编译操作本身会消耗 CPU时间 和 内存空间,理解这些开销是优化JVM性能的关键前提。
时间开销
- 分层编译的代价
JVM采用分层编译(Tiered Compilation) 策略,分为解释执行、C1编译(客户端编译器)、C2编译(服务端编译器)三个阶段:- 解释阶段:快速启动但执行效率低,适合短生命周期方法。
- C1编译(-XX:TieredStopAtLevel=1):生成简单优化代码,编译耗时约 1-10ms/方法。
- C2编译(-XX:TieredStopAtLevel=4):深度优化(如内联、循环展开),编译耗时可达 10-100ms/方法。
- 性能拐点
若热点方法频繁触发再编译(如因去优化[Deoptimization]),累积的编译时间可能抵消性能收益。典型场景:
// 动态类型变化导致去优化
Object val = Math.random() > 0.5 ? "string" : 123;
for (int i = 0; i < 1_000_000; i++) {
process(val); // 需多次编译多态签名
}
- 参数调优
- 调整触发编译的阈值:
-XX:CompileThreshold
(默认1500次调用) - 关闭非必要编译层级:
-XX:-TieredCompilation
(慎用,可能降低峰值性能)
- 调整触发编译的阈值:
空间开销
- 代码缓存(Code Cache)
JIT编译生成的本地机器码存储在固定大小的Code Cache中(默认约240MB):- 溢出风险:缓存满后停止编译,程序退回解释执行,性能断崖下降。
- 监控命令:
jinfo -flag CodeCacheSize <pid> # 查看当前容量
jstat -compiler <pid> # 查看编译方法占比
- 元数据内存占用
- 编译队列:待编译方法队列占用堆外内存(可通过
-XX:CICompilerCount
增加编译线程数缓解)。 - 分析数据结构:逃逸分析、内联决策等算法需额外存储方法调用图、控制流等中间数据。
- 编译队列:待编译方法队列占用堆外内存(可通过
- 空间优化策略
- 调整CodeCache大小:
-XX:ReservedCodeCacheSize=512m
(建议不超过512MB) - 清理无效编译结果:
-XX:+UseCodeCacheFlushing
(JDK 8+默认开启) - 限制激进优化:
-XX:InlineSmallCode=2000
(避免大方法内联占用缓存)
- 调整CodeCache大小:
编译开销的平衡艺术
JIT编译的 时间-空间-性能三角矛盾 要求开发者根据场景动态权衡:
- 短周期应用(如命令行工具):关闭C2编译(
-XX:TieredStopAtLevel=3
)减少启动延迟。 - 长周期服务(如Web服务器):扩大CodeCache并启用全量优化,追求吞吐量最大化。
通过-XX:+PrintCompilation
日志可观察编译耗时,结合-Xlog:codecache=info
监控缓存利用率,实现精准调优。
即时编译对代码的优化
JIT编译器通过**逃逸分析(Escape Analysis)**动态追踪对象作用域,触发以下三类关键优化,显著降低堆内存与同步操作的开销。
逃逸分析
原理:在编译期判断对象是否逃逸出当前线程/方法/类的作用域,决定优化策略。
代码举例1:未逃逸对象
public void renderImage() {
// 对象未逃逸出方法
ColorProfile profile = new ColorProfile(0.5, 0.2, 0.8);
applyFilter(profile); // 仅在方法内使用
}
优化:profile
对象满足栈上分配或标量替换条件。
代码举例2:逃逸对象
public class LoggerHolder {
private static Logger LOGGER; // 静态字段导致逃逸
public static void log(String msg) {
LOGGER = new Logger(msg); // 对象逃逸到类作用域外
LOGGER.flush();
}
}
结果:强制堆分配,无法触发优化。
参数设置
- 开启逃逸分析(JDK 7+默认启用):
-XX:+DoEscapeAnalysis
- 逃逸分析日志(调试用):
-XX:+PrintEscapeAnalysis
代码优化一:栈上分配
条件:对象未逃逸出方法作用域。
原理:将对象存储在栈帧中,随方法调用结束自动销毁。
代码举例
public String generateID() {
// UUID对象未逃逸
UUID uuid = UUID.randomUUID();
return uuid.toString(); // 仅返回字符串,UUID对象未暴露
}
效果:uuid
对象分配在栈上,无GC开销。
代码优化二:同步省略(锁消除)
条件:对象未逃逸出线程作用域。
原理:若锁对象仅被当前线程访问,JIT移除synchronized
字节码。
代码举例
public void threadLocalCounter() {
Object lock = new Object(); // 对象未逃逸出当前线程
synchronized(lock) { // 锁被消除
counter++;
}
}
反编译验证:用javap -v
查看字节码,无monitorenter/monitorexit
指令。
代码优化三:标量替换
条件:对象未逃逸且可拆解为独立标量(基本类型或极简结构)。
代码举例1:基本类型替换
public double calculateArea(Point p) {
// Point对象被拆解为x,y
int x = p.x;
int y = p.y;
return x * y; // 无需保留Point对象
}
参数设置
- 标量替换开关(JDK 7+默认开启):
-XX:+EliminateAllocations
代码举例2:数组成员替换
public void initMatrix() {
int[] matrix = new int[4]; // 未逃逸数组
matrix[0] = 1; // 替换为4个int变量
matrix[1] = 2;
// ...
}
逃逸分析小结
优化类型 | 触发条件 | 性能收益 |
---|---|---|
栈上分配 | 对象未逃逸出方法作用域 | 减少堆内存分配/GC停顿 |
同步省略 | 对象未逃逸出线程作用域 | 消除锁竞争开销 |
标量替换 | 对象可分解为独立标量 | 降低内存占用,提高CPU缓存命中率 |
注意事项:
- 逃逸分析依赖JIT编译阈值,高频热点方法才能触发优化。
- 过度封装(如滥用DTO对象)可能导致对象意外逃逸,抑制优化。
- 可通过
-XX:+JITDisableStackAllocation
强制关闭栈分配(调试用)。
案例3:合理配置堆内存
推荐配置
- Java整个堆大小设置,Xmx 和 Xms设置为老年代存活对象的3-4倍,即FullGC之后的老年代内存占用的3-4倍。
- 方法区(永久代 PermSize和MaxPermSize 或 元空间 MetaspaceSize 和 MaxMetaspaceSize)设置为老年代存活对象的1.2-1.5倍。
- 年轻代Xmn的设置为老年代存活对象的1-1.5倍。
- 老年代的内存大小设置为老年代存活对象的2-3倍。
如何计算老年代存活对象
准确计算老年代存活对象大小是合理设置堆内存、选择垃圾收集器的重要依据,以下提供两种实践方案:
方式1:查看GC日志(无侵入式)
原理:通过GC日志中的老年代容量变化推算长期存活对象。
- 启用详细GC日志
启动参数添加:
-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/path/to/gc.log
- 关键日志指标解读
观察Full GC后老年代占用(以G1收集器为例):
2024-01-01T12:00:00.123+0800: [GC pause (G1 Humongous Allocation)
...
[Eden: 200M(200M)->0B(200M)
Survivors: 24M->24M
**Old: 1800M->1500M** # 老年代回收后占用
]
计算逻辑:
存活对象 ≈ Old区回收后占用值(1500M) - 浮动垃圾(需多轮Full GC观察稳定值)
- 日志分析工具
- GCViewer:可视化分析
gc.log
中各区域趋势 - grep命令:快速提取老年代数据
- GCViewer:可视化分析
grep "Old: " gc.log | awk -F'Old: ' '{print $2}'
方式2:强制触发Full GC(侵入式)
原理:主动触发Full GC回收可回收对象,通过内存快照获取精确值。
- 手动触发Full GC
- jmap命令触发(立即执行Full GC):
jmap -histo:live <pid> # 触发Full GC并生成直方图(不推荐生产环境)
- **jcmd命令触发**(JDK 7+):
jcmd <pid> GC.run # 执行Full GC
- 查看老年代占用
Full GC完成后,使用jstat
获取实时数据:
jstat -gc <pid> 1000 5 # 每秒1次,共5次
输出列说明:
OC(Old Capacity) OU(Old Utilization)
6144.0(MB) 2048.0(MB) # 存活对象约2GB
- 内存dump验证
jmap -dump:live,file=heap.hprof <pid> # Full GC后dump(谨慎使用)
使用MAT工具分析heap.hprof
,过滤Old Gen对象:
OQL查询:SELECT * FROM INSTANCEOF java.lang.Object WHERE ${object}@gcInfo.gen == "old"
注意事项
- 生产环境风险:
- Full GC会导致所有线程暂停(STW),禁止在高峰期操作
- 建议在压测环境或维护窗口执行
- 数据误差控制:
- 多次采样取平均值(至少3次Full GC后数据)
- 排除元数据(Metaspace)、CodeCache等非堆影响
- 收集器差异:
- CMS收集器需配合
-XX:+UseCMSCompactAtFullCollection
避免碎片误差 - G1收集器优先观察
Region
分布(jstat -gccapacity
)
- CMS收集器需配合
方案对比
维度 | 日志分析 | 强制触发Full GC |
---|---|---|
实时性 | 依赖历史数据 | 即时获取 |
侵入性 | 无 | 高(STW暂停) |
精度 | 需排除浮动垃圾干扰 | 精确(需排除强引用误差) |
适用阶段 | 日常监控 | 容量规划、故障排查 |
案例演示
JVM参数配置
内存初始化为1024M。
-XX:+PrintGCDetails -XX:MetaspaceSize=64m -Xss512K
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=heap/heapdump3.hprof
-XX:SurvivorRatio=8 -XX:+PrintGCDateStamps -Xms1024M -Xmx1024M -Xloggc:log/gc-oom3.log
代码演示
- controller
@RequestMapping("/getData")
public List<People> getProduct(){
List<People> peopleList = peopleSevice.getPeopleList();
return peopleList;
}
- service
@Service
public class PeopleSevice {
@Autowired
PeopleMapper peopleMapper;
public List<People> getPeopleList(){
return peopleMapper.getPeopleList();
}
}
- mapper
@Repository
public interface PeopleMapper {
List<People> getPeopleList();
}
- bean
@Data
public class People {
private Integer id;
private String name;
private Integer age;
private String job;
private String sex;
}
- xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.atguiigu.jvmdemo.mapper.PeopleMapper">
<resultMap id="baseResultMap" type="com.atguiigu.jvmdemo.bean.People">
<result column="id" jdbcType="INTEGER" property="id" />
<result column="name" jdbcType="VARCHAR" property="name" />
<result column="age" jdbcType="VARCHAR" property="age" />
<result column="job" jdbcType="INTEGER" property="job" />
<result column="sex" jdbcType="VARCHAR" property="sex" />
</resultMap>
<select id="getPeopleList" resultMap="baseResultMap">
select id,name,job,age,sex from people
</select>
</mapper>
数据分析
项目启动,通过jmeter访问10000次(主要是看项目是否可以正常运行)之后,查看gc状态
jstat -gc pid
YGC平均耗时: 0.12s * 1000/7 = 17.14ms
FGC未产生
看起来似乎不错,YGC触发的频率不高,FGC也没有产生,但这样的内存设置是否还可以继续优化呢?是不是有一些空间是浪费的呢。
为了快速看数据,我们使用了方式2,通过命令 jmap -histo:live pid 产生几次FullGC,FullGC之后,使用的jmap -heap 来看的当前的堆内存情况。
观察老年代存活对象大小:
jmap -heap pid
或者直接查看GC日志
查看一次FullGC之后剩余的空间大小
可以看到存活对象占用内存空间大概13.36M,老年代的内存占用为683M左右。 按照整个堆大小是老年代(FullGC)之后的3-4倍计算的话,设置堆内存情况如下:
Xmx=14 * 3 = 42M 至 14 * 4 = 56M 之间
我们修改堆内存状态如下:
-XX:+PrintGCDetails -XX:MetaspaceSize=64m -Xss512K -XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=heap/heapdump.hprof -XX:SurvivorRatio=8 -XX:+PrintGCDateStamps
-Xms60M -Xmx60M -Xloggc:log/gc-oom.log
修改完之后,我们查看一下GC状态
请求之后
YGC平均耗时: 0.195s * 1000/68 = 2.87ms
FGC未产生
整体的GC耗时减少。但GC频率比之前的1024M时要多了一些。依然未产生FullGC,所以我们内存设置为60M 也是比较合理的,相对之前节省了很大一块内存空间,所以本次内存调整是比较合理的
依然手动触发Full ,查看堆内存结构
结论
在内存相对紧张的情况下,可以按照上述的方式来进行内存的调优, 找到一个在GC频率和GC耗时上都可接受的一个内存设置,可以用较小的内存满足当前的服务需要。
但当内存相对宽裕的时候,可以相对给服务多增加一点内存,可以减少GC的频率,GC的耗时相应会增加一些。 一般要求低延时的可以考虑多设置一点内存, 对延时要求不高的,可以按照上述方式设置较小内存。
如果在垃圾回收日志中观察到OutOfMemoryError,尝试把Java堆的大小扩大到物理内存的80%~90%。尤其需要注意的是堆空间导致的OutOfMemoryError以及一定要增加空间。
比如说,增加-Xms和-Xmx的值来解决old代的OutOfMemoryError
- 增加-XX:PermSize和-XX:MaxPermSize来解决permanent代引起的OutOfMemoryError(jdk7之前);
- 增加-XX:MetaspaceSize和-XX:MaxMetaspaceSize来解决Metaspace引起的OutOfMemoryError(jdk8之后)
记住一点Java堆能够使用的容量受限于硬件以及是否使用64位的JVM。在扩大了Java堆的大小之后,再检查垃圾回收日志,直到没有OutOfMemoryError为止。如果应用运行在稳定状态下没有OutOfMemoryError就可以进入下一步了,计算活动对象的大小。
如何估算GC频率
正常情况我们应该根据我们的系统来进行一个内存的估算,这个我们可以在测试环境进行测试,最开始可以将内存设置的大一些,比如4G这样,当然这也可以根据业务系统估算来的。
比如从数据库获取一条数据占用128个字节,需要获取1000条数据,那么一次读取到内存的大小就是(128 B/1024 Kb/1024M)* 1000 = 0.122M ,那么我们程序可能需要并发读取,比如每秒读取100次,那么内存占用就是0.122100 = 12.2M ,如果堆内存设置1个G,那么年轻代大小大约就是333M,那么333M80% / 12.2M =21.84s ,也就是说我们的程序几乎每分钟进行两到三次youngGC。这样可以让我们对系统有一个大致的估算。
0.122M * 100 = 12.2M /秒 ---Eden区
1024M * 1/3 * 80% = 273M
273 / 12.2M = 22.38s ---> YGC 每分钟2-3次YGC
新生代与老年代的比例
参数设置:手动控制比例
- 静态比例策略(-XX:NewRatio)
-XX:NewRatio=3 # 老年代:新生代=3:1(默认JDK8=2,JDK11+=2)
场景:对象晋升模式稳定(如批量处理系统),老年代长期占用70%+内存。
- 绝对值分配(-Xmn/-XX:NewSize)
-Xmn2g # 新生代固定2GB(优先级高于NewRatio)
-XX:NewSize=1g -XX:MaxNewSize=4g # 动态区间(需配合自适应策略)
风险:硬编码可能导致老年代溢出(尤其在突发大对象场景)。
- Survivor区调节(-XX:SurvivorRatio)
-XX:SurvivorRatio=8 # Eden:Survivor=8:1:1(默认JDK8=8)
调优依据:
- 对象存活率低(如Web请求):增大Eden区,减少Minor GC频率
- 对象存活率高(如缓存服务):缩小Eden区,避免Survivor溢出
参数AdaptiveSizePolicy:动态比例调优
JVM内置自适应内存调整算法,通过统计GC效率动态平衡各代容量。
- 启用与关闭
-XX:+UseAdaptiveSizePolicy # 默认开启(Parallel Scavenge生效)
-XX:-UseAdaptiveSizePolicy # 关闭后需手动配置
- 运作原理
- 监控指标:Minor GC耗时、对象晋升速率、Survivor溢出次数
- 动态响应:
- 若老年代GC耗时过高 → 扩大新生代,减少晋升压力
- 若Survivor频繁溢出 → 增大Survivor区比例
- 若Eden区GC频率过低 → 收缩Eden区,释放内存给老年代
- 冲突与规避
- 与CMS/G1的兼容性:
- CMS需配合
-XX:+UseCMSCompactAtFullCollection
避免碎片干扰 - G1收集器强制禁用AdaptiveSizePolicy(由Region机制替代)
- CMS需配合
- 调优矛盾:
- UseAdaptiveSizePolicy不要和SurvivorRatio参数显示设置搭配使用,一起使用会导致参数失效;
- 由于UseAdaptiveSizePolicy会动态调整 Eden、Survivor 的大小,有些情况存在Survivor 被自动调为很小,比如十几MB甚至几MB的可能,这个时候YGC回收掉 Eden区后,还存活的对象进入Survivor 装不下,就会直接晋升到老年代,导致老年代占用空间逐渐增加,从而触发FULL GC,如果一次FULL GC的耗时很长(比如到达几百毫秒),那么在要求高响应的系统就是不可取的。
- 与CMS/G1的兼容性:
关于堆内存的自适应调节有如下三个参数:调整堆是按照每次20%增长,按照每次5%收缩
young区增长量(默认20%):-XX:YoungGenerationSizeIncrement=<Y>
old区增长量(默认20%):-XX:TenuredGenerationSizeIncrement=<T>
收缩量(默认5%):-XX:AdaptiveSizeDecrementScaleFactor=<D>
验证方式
- jstat动态观测:
jstat -gc <pid> 1000 # 每秒输出各代容量变化
NGCMN(最小新生代) NGCMX(最大新生代) NGC(当前新生代)
4194304.0 6291456.0 5242880.0 # 动态扩容至5GB
- GC日志分析:
[PSYoungGen: 3584K->480K(4096K)] # 新生代调整后容量
[ParOldGen: 10240K->10752K(20480K)]
- 强制内存快照:
jmap -heap <pid> # 查看运行时各代实际容量
案例4:CPU占用很高的排查方案
示例代码
public class JstackDeadLockDemo {
/**
* 必须有两个可以被加锁的对象才能产生死锁,只有一个不会产生死锁问题
*/
private final Object obj1 = new Object();
private final Object obj2 = new Object();
public static void main(String[] args) {
new JstackDeadLockDemo().testDeadlock();
}
private void testDeadlock() {
Thread t1 = new Thread(() -> calLock_Obj1_First());
Thread t2 = new Thread(() -> calLock_Obj2_First());
t1.start();
t2.start();
}
/**
* 先synchronized obj1,再synchronized obj2
*/
private void calLock_Obj1_First() {
synchronized (obj1) {
sleep();
System.out.println("已经拿到obj1的对象锁,接下来等待obj2的对象锁");
synchronized (obj2) {
sleep();
}
}
}
/**
* 先synchronized obj2,再synchronized obj1
*/
private void calLock_Obj2_First() {
synchronized (obj2) {
sleep();
System.out.println("已经拿到obj2的对象锁,接下来等待obj1的对象锁");
synchronized (obj1) {
sleep();
}
}
}
/**
* 为了便于让两个线程分别锁住其中一个对象,
* 一个线程锁住obj1,然后一直等待obj2,
* 另一个线程锁住obj2,然后一直等待obj1,
* 然后就是一直等待,死锁产生
*/
private void sleep() {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
问题呈现
可以看到,程序依然处于运行状态。现在我们知道是线程死锁造成的问题。
问题分析
那么如果是生产环境的话,是怎么样才能发现目前程序有问题呢?我们可以推导一下,如果线程死锁,那么线程一直在占用CPU,这样就会导致CPU一直处于一个比较高的占用率。所示我们解决问题的思路应该是:
- 首先查看java进程ID
# 查看所有java进程 ID
jps -l
- 根据进程 ID 检查当前使用异常线程的pid
# 根据进程 ID 检查当前使用异常线程的pid
top -Hp 1456
- 把线程pid变为16进制
从上图可以看出来,当前占用cpu比较高的线程 ID 是1465
# 10 进制线程PId 转换为 16 进制
1465 -------> 5b9
# 5b9 在计算机中显示为
0x5b9
- jstack 进程的pid | grep -A20 0x… 得到相关进程的代码 (鉴于我们当前代码量比较小,线程也比较少,所以我们就把所有的信息全部导出来)
jstack 1456 > jstack.log
打开jstack.log文件,查找一下刚刚我们转换完的16进制ID是否存在。
jstack命令生成的thread dump信息包含了JVM中所有存活的线程,里面确实是存在我们定位到的线程 ID ,在thread dump中每个线程都有一个nid,在nid=0x5b9的线程调用栈中,我们发现两个线程在互相等待对方释放资源。
到此就可以检查对应的代码是否有问题,也就定位到我们的死锁问题。
解决方案
- 顺序加锁。
- 采用定时锁,一段时间后,如果还不能获取到锁就释放自身持有的所有锁。
案例5:G1并发GC线程数对性能的影响
配置信息
硬件配置
8核linux
JVM参数设置
export CATALINA_OPTS="$CATALINA_OPTS -XX:+UseG1GC"
export CATALINA_OPTS="$CATALINA_OPTS -Xms30m"
export CATALINA_OPTS="$CATALINA_OPTS -Xmx30m"
export CATALINA_OPTS="$CATALINA_OPTS -XX:+PrintGCDetails"
export CATALINA_OPTS="$CATALINA_OPTS -XX:MetaspaceSize=64m"
export CATALINA_OPTS="$CATALINA_OPTS -XX:+PrintGCDateStamps"
export CATALINA_OPTS="$CATALINA_OPTS -Xloggc:/opt/tomcat8.5/logs/gc.log"
export CATALINA_OPTS="$CATALINA_OPTS -XX:ConcGCThreads=1"
说明:最后一个参数可以在使用G1GC测试初始并发GCThreads之后再加上。
初始化内存和最大内存调整小一些,目的发生 FullGC,关注GC时间
关注点是:GC次数,GC时间,以及 Jmeter的平均响应时间
初始状态
启动tomcat,查看进程默认的并发线程数:
jinfo -flag ConcGCThreads pid
-XX:ConcGCThreads=1
没有配置的情况下:并发线程数是1。
查看线程状态:
jstat -gc pid
得出信息:
YGC:youngGC次数是1259次
FGC:Full GC次数是6次
GCT:GC总时间是5.556s
Jmeter压测之后的GC状态:
得出信息:
YGC:youngGC次数是1600次
FGC:Full GC次数是18次
GCT:GC总时间是7.919s
由此我们可以计算出来压测过程中,发生的GC次数和GC时间差。
压测过程GC状态:
YGC:youngGC次数是 1600 - 1259 = 341次
FGC:Full GC次数是 18 - 6 = 12次
GCT:GC总时间是 7.919 - 5.556 = 2.363s
Jmeter压测结果如下:
95%的请求响应时间为:16ms
99%的请求响应时间为:28ms
优化后
增加线程配置:
export CATALINA_OPTS="$CATALINA_OPTS -XX:ConcGCThreads=8"
观察GC状态:
jstat -gc pid
YGC:youngGC次数是 1134 次
FGC:Full GC次数是 5 次
GCT:GC总时间是 5.234s
Jmeter压测之后的GC状态:
YGC:youngGC次数是 1347 次
FGC:Full GC次数是 16 次
GCT:GC总时间是 7.149s
压测过程GC状态:
YGC:youngGC次数是 1347 - 1134 = 213次
FGC:Full GC次数是 16 - 5 = 13次
GCT:GC总时间是 7.149 - 5.234 = 1.915s 提供了线程数,使得用户响应时间降低了。
Jmeter压测结果如下:
95%的请求响应时间为:15ms
99%的请求响应时间为:22ms
总结
- 在CPU资源充足(空闲率 > 30%)时优先调高
ConcGCThreads
。 - 在容器/云环境中,严格限制CPU配额并预留GC线程预算。
- 始终通过A/B测试对比调整前后的吞吐量与P99延迟。
案例6:调整垃圾回收器对吞吐量的影响
初始配置:单核+SerialGC
单核+SerialGC
优化配置1:ParallelGC
export CATALINA_OPTS="$CATALINA_OPTS -Xms60m"
export CATALINA_OPTS="$CATALINA_OPTS -Xmx60m"
export CATALINA_OPTS="$CATALINA_OPTS -XX:+UseParallelGC"
export CATALINA_OPTS="$CATALINA_OPTS -XX:+PrintGCDetails"
export CATALINA_OPTS="$CATALINA_OPTS -XX:MetaspaceSize=64m"
export CATALINA_OPTS="$CATALINA_OPTS -XX:+PrintGCDateStamps"
export CATALINA_OPTS="$CATALINA_OPTS -Xloggc:/opt/tomcat8.5/logs/gc6.log"
查看GC状态:
3次FullGC。
查看吞吐量:
查看吞吐量,997.6/sec。
优化配置2:8核
8核状态下的性能表现如下,吞吐量大幅提升,甚至翻了一倍,这说明我们在多核机器上面采用并行收集器对于系统的吞吐量有一个显著的效果。
优化配置3:G1
export CATALINA_OPTS="$CATALINA_OPTS -XX:+UseG1GC"
export CATALINA_OPTS="$CATALINA_OPTS -Xms60m"
export CATALINA_OPTS="$CATALINA_OPTS -Xmx60m"
export CATALINA_OPTS="$CATALINA_OPTS -XX:+PrintGCDetails"
export CATALINA_OPTS="$CATALINA_OPTS -XX:MetaspaceSize=64m"
export CATALINA_OPTS="$CATALINA_OPTS -XX:+PrintGCDateStamps"
export CATALINA_OPTS="$CATALINA_OPTS -Xloggc:/opt/tomcat8.5/logs/gc6.log"
查看GC状态:
没有产生FullGC,效果较之前有提升。
查看压测效果,吞吐量也是比串行收集器效果更佳,而且没有了FullGC。此次优化较为成功。
案例7:日均百万订单如何设置JVM参数
一天百万级订单这个绝对是现在顶尖电商公司交易量级,百万订单一般在4个小时左右产生,我们计算一下每秒产生多少订单,3000000/3600/4 = 208.3单/s,我们大概按照每秒300单来计算。
这种系统我们一般至少要三四台机器去支撑,假设我们部署了三台机器,也就是每台每秒钟大概处理完成100单左右,也就是每秒大概有100个订单对象在堆空间的新生代内生成,一个订单对象的大小跟里面的字段多少及类型有关,比如int类型的订单id和用户id等字段,double类型的订单金额等,int类型占用4字节,double类型占用8字节,初略估计下一个订单对象大概1KB左右,也就是说每秒会有100KB的订单对象分配在新生代内。
真实的订单交易系统肯定还有大量的其他业务对象,比如购物车、优惠券、积分、用户信息、物流信息等等,实际每秒分配在新生代内的对象大小应该要再扩大几十倍,我们假设20倍,也就是每秒订单系统会往新生代内分配近2M的对象数据,这些数据一般在订单提交完的操作做完之后基本都会成为垃圾对象。
假设我们选择4核8G的服务器,就可以给JVM进程分配四五个G的内存空间,那么堆内存可以分到三四个G左右,于是可以给新生代至少分配1G,这样算下差不多需要10分钟左右才能把新生代放满触发minor gc,这样的GC频率我们是可以接受的。我们还可以继续调整young区大小。不一定是1:2,这样就可以降低GC频率。这样进入老年代的对象也会降低,减少Full GC频率。
如果系统业务量继续增长那么可以水平扩容增加更多的机器,比如五台甚至十台机器,这样每台机器的JVM处理请求可以保证在合适范围,不至于压力过大导致大量的gc。
假设业务量暴增几十倍,在不增加机器的前提下,整个系统每秒要生成几千个订单,之前每秒往新生代里分配的1M对象数据可能增长到几十M,而且因为系统压力骤增,一个订单的生成不一定能在1秒内完成,可能要几秒甚至几十秒,那么就有很多对象会在新生代里存活几十秒之后才会变为垃圾对象,如果新生代只分配了几百M,意味着一二十秒就会触发一次minor gc,那么很有可能部分对象就会被挪到老年代,这些对象到了老年代后因为对应的业务操作执行完毕,马上又变为了垃圾对象,随着系统不断运行,被挪到老年代的对象会越来越多,最终可能又会导致full gc,full gc对系统的性能影响还是比较大的。