GC 调优决策
OOM 与 GC 调优决策
一、OOM 类型深度解析
1.1 OOM 类型总览
┌────────────────────────────────────────────────────────────────────────────┐
│ OOM 类型对比 │
├────────────────┬────────────┬────────────────┬────────────────────────────┤
│ OOM 类型 │ 发生位置 │ 关键参数 │ 典型场景 │
├────────────────┼────────────┼────────────────┼────────────────────────────┤
│ Java heap │ JVM 堆 │ -Xms, -Xmx │ 内存泄漏、大对象 │
│ Metaspace │ 本地内存 │ MaxMetaspaceSize│ 类加载过多、动态代理 │
│ StackOverflow │ 线程栈 │ -Xss │ 递归过深、栈帧过大 │
│ Direct buffer │ 本地内存 │ MaxDirectMemory│ NIO、Netty 泄漏 │
│ Thread │ 系统 │ -Xss, ulimit │ 线程泄漏、系统限制 │
│ GC overhead │ JVM 堆 │ UseGCOverhead │ GC 过于频繁 │
└────────────────┴────────────┴────────────────┴────────────────────────────┘OOM 分类理解:
| 分类 | 含义 | 代表类型 |
|---|---|---|
| 堆内存 OOM | JVM 堆空间不足 | Java heap space、GC overhead |
| 非堆内存 OOM | JVM 管理的非堆区域不足 | Metaspace、StackOverflow |
| 本地内存 OOM | 操作系统本地内存不足 | Direct buffer、Thread |
1.2 Java heap space —— 堆内存溢出
错误现象
java.lang.OutOfMemoryError: Java heap space这是最常见的 OOM 类型,表示 JVM 堆内存不足以分配新对象。
根本原因分析
1. 内存泄漏(Memory Leak)
内存泄漏是最常见的原因,指程序中存在某些对象不再被使用,但垃圾收集器无法回收它们。
| 场景 | 代码示例 | 问题分析 |
|---|---|---|
| 静态集合类 | static Map<String, Object> cache = new HashMap<>(); | 静态变量生命周期与类相同,添加的对象永远不会被回收 |
| 监听器未注销 | button.addActionListener(listener); 但从未移除 | 事件源持有监听器引用,导致监听器对象无法回收 |
| ThreadLocal 未清理 | threadLocal.set(value); | ThreadLocal 的 value 在线程池场景下会持续累积 |
| 数据库连接未关闭 | Connection conn = dataSource.getConnection(); | 连接对象持有资源,未关闭导致泄漏 |
2. 缓存无限增长
// 危险示例:无界缓存
public class UserCache {
private static Map<Long, User> userCache = new HashMap<>();
public void addUser(User user) {
userCache.put(user.getId(), user);
}
// 缺少清理机制,缓存会无限增长
}
// 正确做法:使用 LRU 缓存
public class UserCache {
private static final int MAX_SIZE = 10000;
private static LinkedHashMap<Long, User> userCache = new LinkedHashMap<Long, User>(16, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<Long, User> eldest) {
return size() > MAX_SIZE;
}
};
}
// 推荐做法:使用 Caffeine 缓存
public class UserCache {
private static final Cache<Long, User> cache = Caffeine.newBuilder()
.maximumSize(10000) // 最大容量
.expireAfterWrite(Duration.ofHours(1)) // 写入后 1 小时过期
.expireAfterAccess(Duration.ofMinutes(30)) // 访问后 30 分钟过期
.recordStats() // 记录统计信息
.build();
}3. 大对象过多
大对象(Large Object)一般指需要大量连续内存空间的对象:
// 大对象示例
byte[] data = new byte[100 * 1024 * 1024]; // 100MB 数组
List<Order> orders = orderDao.findAll(); // 百万级数据一次性加载
String content = Files.readString(path); // 超大文件一次性读取大对象的问题:
- 直接进入老年代(或 Humongous Region),占用老年代空间
- 触发提前的 Full GC
- 内存碎片化严重,难以分配新的大对象
解决方案:
// 错误:一次性加载大文件
String content = Files.readString(Paths.get("large-file.txt"));
// 正确:流式处理
try (Stream<String> lines = Files.lines(Paths.get("large-file.txt"))) {
lines.forEach(line -> processLine(line));
}
// 错误:一次性查询所有数据
List<Order> orders = orderDao.findAll();
// 正确:分页查询
Page<Order> page = orderDao.findAll(PageRequest.of(0, 1000));排查方法详解
步骤一:确认问题
## 1. 查看错误日志
grep "OutOfMemoryError" /var/log/app.log
## 2. 检查 JVM 参数
jinfo -flags <pid> | grep -E "Xms|Xmx"
## 3. 监控堆内存使用
jstat -gc <pid> 1000
## 关注列:
## - EU: Eden 使用量
## - OU: 老年代使用量
## - FGC: Full GC 次数
## - FGCT: Full GC 总时间步骤二:获取堆转储文件
## 方式一:启动时配置自动生成(推荐)
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/path/to/dump/heap.hprof
## 方式二:运行时使用 jmap
jmap -dump:format=b,file=heap.hprof <pid>
## 方式三:使用 jcmd
jcmd <pid> GC.heap_dump /path/to/heap.hprof
## 方式四:JMX 远程获取(需要 JMX 配置)
jconsole → MBeans → com.sun.management → HotSpotDiagnostic → dumpHeap步骤三:分析堆转储文件
MAT 分析步骤:
-
打开
.hprof文件 -
查看 Leak Suspects Report(泄漏嫌疑报告)
plaintextProblem Suspect 1: The class java.util.HashMap$Node, loaded by <system class loader>, occupies 1.5GB (45%) of the heap. -
查看 Dominator Tree(支配树)
- 找到占用内存最大的对象
- 右键 → Path to GC Roots → exclude weak/soft references
-
使用 Histogram(直方图)
- 按类统计对象数量
- 找到异常的对象数量
-
分析引用链
plaintextOrderCache (静态变量) └── ConcurrentHashMap$Node[1200000] └── Order 对象(120万个,每个 2KB) ├── orderId: Long ├── items: List<OrderItem> └── customer: Customer
步骤四:定位代码位置
// 在 MAT 中查看对象的引用链后,定位到代码
public class OrderCache {
// 问题根源:静态 Map 无限增长
private static final ConcurrentHashMap<Long, Order> cache =
new ConcurrentHashMap<>();
public static void addOrder(Order order) {
cache.put(order.getId(), order);
// × 缺少移除逻辑
}
public static Order getOrder(Long orderId) {
return cache.get(orderId);
}
}
// 修复方案:添加移除逻辑
public static void removeOrder(Long orderId) {
cache.remove(orderId);
}
// 或使用 Caffeine 缓存替代
private static final Cache<Long, Order> cache = Caffeine.newBuilder()
.maximumSize(10000)
.expireAfterWrite(Duration.ofHours(1))
.build();完整解决方案
| 问题类型 | 解决方案 | 具体措施 |
|---|---|---|
| 内存泄漏 | 修复代码,释放无用引用 | 移除静态集合中的无用元素、注销监听器、清理 ThreadLocal |
| 缓存失控 | 使用有界缓存或弱引用 | WeakHashMap、Caffeine、Guava Cache |
| 大对象 | 优化数据结构,分块处理 | 流式读取文件、分页查询数据库、避免一次性加载 |
| 堆大小不足 | 合理调整 -Xms 和 -Xmx | 根据监控数据调整,建议 Xms = Xmx |
| 对象创建过多 | 优化代码,减少临时对象 | 使用对象池、StringBuilder、复用对象 |
代码优化示例:
// 问题 1:字符串拼接产生大量临时对象
public String buildMessage(List<String> items) {
String result = "";
for (String item : items) {
result += item + ","; // 每次拼接创建新 String
}
return result;
}
// 优化:使用 StringBuilder
public String buildMessage(List<String> items) {
StringBuilder sb = new StringBuilder(items.size() * 20);
for (String item : items) {
sb.append(item).append(",");
}
return sb.toString();
}
// 问题 2:频繁创建日期格式化对象
public String formatDate(Date date) {
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
return sdf.format(date); // 每次创建新对象
}
// 优化:使用 ThreadLocal 或 DateTimeFormatter
private static final DateTimeFormatter FORMATTER =
DateTimeFormatter.ofPattern("yyyy-MM-dd");
public String formatDate(LocalDate date) {
return date.format(FORMATTER);
}
// 问题 3:线程池泄漏
public void processTask() {
ExecutorService executor = Executors.newFixedThreadPool(10);
executor.submit(() -> { /* 任务 */ });
// × 忘记关闭
}
// 优化:使用 try-with-resources 或单例
public void processTask() {
executorService.submit(() -> { /* 任务 */ });
}
// 或
public void processTask() {
try (ExecutorService executor = Executors.newFixedThreadPool(10)) {
executor.submit(() -> { /* 任务 */ });
}
}1.3 Metaspace —— 元空间溢出
错误现象
java.lang.OutOfMemoryError: Metaspace元空间是 JDK 8 引入的概念,用于替代永久代(PermGen),存储类的元数据。元空间使用本地内存,不占用堆内存。
元空间存储内容
Metaspace 内存结构:
├── Klass 结构(类元数据)
│ ├── 方法信息(方法名、描述符、字节码)
│ ├── 字段信息
│ ├── 常量池
│ └── 父类、接口信息
├── 方法区(Method Area)
│ ├── 运行时常量池
│ └── 静态变量(JDK 7 后移到堆)
├── JIT 编译产物
│ └── 编译后的本地代码
└── 类加载器数据
└── 已加载类列表Metaspace vs PermGen:
| 特性 | PermGen(JDK 7-) | Metaspace(JDK 8+) |
|---|---|---|
| 位置 | JVM 堆内存 | 本地内存(Native Memory) |
| 大小限制 | 固定大小(容易 OOM) | 默认无限制(受系统内存限制) |
| 配置参数 | -XX:PermSize, -XX:MaxPermSize | -XX:MetaspaceSize, -XX:MaxMetaspaceSize |
| GC 回收 | Full GC 时回收 | 类卸载时回收 |
根本原因分析
1. 动态代理类无限生成
// 问题代码示例
public class ProxyFactory {
public static Object createProxy(Object target) {
return Proxy.newProxyInstance(
target.getClass().getClassLoader(),
target.getClass().getInterfaces(),
(proxy, method, args) -> method.invoke(target, args)
);
}
}
// 如果每次请求都创建新代理,会生成大量代理类
for (int i = 0; i < 1000000; i++) {
Object proxy = ProxyFactory.createProxy(new UserServiceImpl());
// 每次调用都生成新的代理类 → Metaspace 膨胀
}
// 正确做法:缓存代理对象
public class ProxyFactory {
private static final ConcurrentHashMap<Class<?>, Object> proxyCache =
new ConcurrentHashMap<>();
public static <T> T getProxy(Class<T> interfaceClass, T target) {
return (T) proxyCache.computeIfAbsent(interfaceClass, cls -> {
return Proxy.newProxyInstance(
cls.getClassLoader(),
new Class<?>[] { cls },
(proxy, method, args) -> method.invoke(target, args)
);
});
}
}2. CGLIB 字节码增强
// Spring AOP 使用 CGLIB
// 如果配置不当,可能生成大量子类
@EnableAspectJAutoProxy(proxyTargetClass = true)
public class AppConfig {
// 大量的 @Transactional 注解会生成代理子类
}
// 问题:动态生成的类无法卸载
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(UserService.class);
enhancer.setCallback(new MethodInterceptor() { ... });
UserService proxy = (UserService) enhancer.create();
// 每次调用 create() 都生成新的子类3. 类加载器泄漏
这是 Metaspace OOM 最常见的原因:
应用服务器(Tomcat/WebLogic)热部署场景:
WebAppClassLoader
├── 加载应用类 A、B、C...
└── 被某个线程持有引用
热部署时:
├── 创建新的 WebAppClassLoader
├── 旧的 ClassLoader 应该被回收
└── 但如果有线程仍持有旧 ClassLoader 引用
└── 旧 ClassLoader 无法回收
└── 其加载的所有类都在 Metaspace 中无法释放
└── 多次热部署后 Metaspace 耗尽常见泄漏场景:
// 场景 1:ThreadLocal 持有类加载器
public class ContextHolder {
private static final ThreadLocal<ClassLoader> context =
new ThreadLocal<>();
public static void setClassLoader(ClassLoader cl) {
context.set(cl); // 线程池线程未清理
}
}
// 场景 2:静态变量持有类加载器
public class PluginManager {
private static final Map<String, ClassLoader> loaders =
new HashMap<>();
public static void register(String name, ClassLoader loader) {
loaders.put(name, loader); // 永不释放
}
}
// 场景 3:线程池线程未清理 ThreadLocal
ExecutorService pool = Executors.newFixedThreadPool(10);
pool.submit(() -> {
threadLocal.set(bigObject);
// × 未清理
});排查方法
步骤一:检查 JVM 参数
## 查看当前 Metaspace 配置
jinfo -flags <pid> | grep -i meta
## 关键参数
-XX:MetaspaceSize=128m # 初始元空间大小(初始水位线)
-XX:MaxMetaspaceSize=256m # 最大元空间大小(限制)步骤二:监控 Metaspace 使用情况
## 使用 jstat 查看
jstat -gcmetacapacity <pid> 1000
## 输出列说明:
## MCMN: 最小 Metaspace 容量
## MCMX: 最大 Metaspace 容量
## MC: 当前 Metaspace 容量
## CCSMN: 最小压缩类空间容量
## CCSMX: 最大压缩类空间容量
## CCSC: 当前压缩类空间容量
## 使用 jcmd 查看详细信息
jcmd <pid> VM.metaspace
## 输出示例:
## Total space: 512 MB (100% committed)
## Used: 508 MB
## MaxMetaspaceSize: 512 MB步骤三:获取类加载信息
## 查看已加载类数量
jcmd <pid> GC.class_stats | head -50
## 查看类加载器层次
jcmd <pid> VM.classloaders
## 使用 jmap 查看类直方图
jmap -histo <pid> | head -50
## 发现大量代理类:
## 124565 com.example.service.$Proxy123
## 124565 com.example.service.$Proxy124步骤四:分析堆转储
## 生成堆转储(包含类信息)
jmap -dump:format=b,file=heap.hprof <pid>
## 在 MAT 中分析:
## 1. 查看 Class Loader Explorer
## 2. 找到重复的 ClassLoader
## 3. 分析类加载器引用链解决方案
方案一:调整 Metaspace 大小
## 根据应用规模设置合理的上限
-XX:MetaspaceSize=256m # 初始大小,避免频繁 Full GC
-XX:MaxMetaspaceSize=512m # 最大限制,防止无限制增长
## 启用类卸载(G1)
-XX:+ClassUnloadingWithConcurrentMark
## 启用压缩类指针(减少 Metaspace 使用)
-XX:+UseCompressedClassPointers
-XX:CompressedClassSpaceSize=1g方案二:解决类加载器泄漏
// 1. 确保关闭自定义 ClassLoader
GroovyClassLoader loader = new GroovyClassLoader();
try {
// 使用 loader
} finally {
loader.close(); // JDK 7+ 支持自动清理
}
// 2. 清理 ThreadLocal
public class RequestContext {
private static final ThreadLocal<Map<String, Object>> context =
new ThreadLocal<>();
public static void clear() {
context.remove(); // 必须调用
}
}
// 使用 try-finally
try {
RequestContext.set("user", user);
// 业务逻辑
} finally {
RequestContext.clear();
}
// 3. 缓存代理对象,避免重复创建
private static final Map<Class<?>, Object> proxyCache = new ConcurrentHashMap<>();
public static <T> T getProxy(Class<T> interfaceClass) {
return (T) proxyCache.computeIfAbsent(interfaceClass, cls -> {
return Proxy.newProxyInstance(
cls.getClassLoader(),
new Class<?>[] { cls },
new InvocationHandler() { ... }
);
});
}
// 4. 使用 WeakReference 避免强引用
public class ClassLoaderCache {
private static final Map<String, WeakReference<ClassLoader>> cache =
new ConcurrentHashMap<>();
public static ClassLoader getLoader(String name) {
WeakReference<ClassLoader> ref = cache.get(name);
return ref != null ? ref.get() : null;
}
}方案三:监控和预警
// 使用 JMX 监控 Metaspace
import java.lang.management.ManagementFactory;
import java.lang.management.MemoryPoolMXBean;
for (MemoryPoolMXBean pool : ManagementFactory.getMemoryPoolMXBeans()) {
if (pool.getName().contains("Metaspace")) {
long used = pool.getUsage().getUsed();
long max = pool.getUsage().getMax();
double usage = (double) used / max * 100;
if (usage > 80) {
log.warn("Metaspace usage is high: {}%", usage);
}
}
}1.4 StackOverflowError —— 栈溢出
错误现象
java.lang.StackOverflowError栈溢出不属于 OOM,但经常与之混淆。它表示线程栈空间耗尽,通常由递归调用过深或栈帧过大引起。
根本原因分析
1. 无限递归
// 典型的无限递归
public int factorial(int n) {
return n * factorial(n - 1); // 缺少终止条件 n <= 1
}
// 正确实现
public int factorial(int n) {
if (n <= 1) return 1;
return n * factorial(n - 1);
}
// 问题:递归深度过大
public int fibonacci(int n) {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2); // 指数级递归
}
// 优化:使用迭代
public int fibonacci(int n) {
if (n <= 1) return n;
int a = 0, b = 1;
for (int i = 2; i <= n; i++) {
int sum = a + b;
a = b;
b = sum;
}
return b;
}2. 相互调用死循环
public class DeadLoop {
public void methodA() {
methodB();
}
public void methodB() {
methodA(); // 相互调用,栈无限增长
}
}
// 常见场景:循环依赖
public class OrderService {
@Autowired
private UserService userService;
public Order getOrder(Long id) {
return userService.getUser(id).getOrder(); // 循环调用
}
}
public class UserService {
@Autowired
private OrderService orderService;
public User getUser(Long id) {
return orderService.getOrder(id).getUser(); // 循环调用
}
}3. 复杂数据结构递归
// 树形结构深度遍历
public void traverse(TreeNode node) {
if (node == null) return;
traverse(node.left); // 如果树深度过大,栈溢出
traverse(node.right);
}
// 解决方案:改为迭代方式
public void traverse(TreeNode root) {
Stack<TreeNode> stack = new Stack<>();
TreeNode node = root;
while (node != null || !stack.isEmpty()) {
while (node != null) {
stack.push(node);
node = node.left;
}
node = stack.pop();
// 处理 node
node = node.right;
}
}4. 栈帧过大
// 问题:局部变量过多或过大
public void processData() {
int[] array1 = new int[1000000]; // 大数组在栈上
int[] array2 = new int[1000000];
int[] array3 = new int[1000000];
// ...
}
// 解决:使用堆存储
public void processData() {
int[] array1 = new int[1000000]; // 数组对象在堆上
int[] array2 = new int[1000000];
// 栈上只存储引用,占用很小
}排查方法
步骤一:查看异常堆栈
## 异常堆栈会显示递归调用的方法
Exception in thread "main" java.lang.StackOverflowError
at com.example.Factorial.factorial(Factorial.java:10)
at com.example.Factorial.factorial(Factorial.java:10)
at com.example.Factorial.factorial(Factorial.java:10)
... (重复数百次)
## 从堆栈可以看出是 factorial 方法递归调用步骤二:使用 jstack 查看线程栈
## 打印线程栈
jstack <pid>
## 查看特定线程
jstack <pid> | grep -A 100 "thread-name"步骤三:调整栈大小
## 查看当前栈大小
jinfo -flag ThreadStackSize <pid>
## 调整栈大小
-Xss512k # 每个线程栈 512KB(默认 1MB)
-Xss2m # 每个线程栈 2MB解决方案
| 场景 | 解决方案 | 具体措施 |
|---|---|---|
| 无限递归 | 添加正确的终止条件 | 检查递归退出条件 |
| 递归深度过大 | 改为迭代实现或尾递归优化 | 使用循环、栈模拟递归 |
| 相互调用 | 重构代码,消除循环依赖 | 使用中介者模式、依赖注入 |
| 栈帧过大 | 减少局部变量,使用堆存储 | 大数组改为堆分配 |
| 栈空间不足 | 适当增大 -Xss | 权衡线程数和内存占用 |
代码优化示例:
// 问题:深度递归
public int sumTree(TreeNode node) {
if (node == null) return 0;
return node.val + sumTree(node.left) + sumTree(node.right);
}
// 优化 1:迭代实现
public int sumTree(TreeNode root) {
if (root == null) return 0;
int sum = 0;
Stack<TreeNode> stack = new Stack<>();
stack.push(root);
while (!stack.isEmpty()) {
TreeNode node = stack.pop();
sum += node.val;
if (node.left != null) stack.push(node.left);
if (node.right != null) stack.push(node.right);
}
return sum;
}
// 优化 2:尾递归(某些 JVM 支持优化)
public int sumTreeTail(TreeNode node, int acc) {
if (node == null) return acc;
acc = acc + node.val;
return sumTreeTail(node.right, sumTreeTail(node.left, acc));
}1.5 Direct buffer memory —— 直接内存溢出
错误现象
java.lang.OutOfMemoryError: Direct buffer memory直接内存(Direct Memory)是堆外内存,不受 JVM 堆大小限制。当使用 NIO 或 Netty 等框架时,大量使用直接内存可能耗尽本地内存。
直接内存原理
Java NIO 直接内存模型:
┌─────────────────────────────────────┐
│ JVM 堆内存 │
│ ┌──────────────────────────────┐ │
│ │ HeapByteBuffer (引用) │ │
│ │ - 持有 DirectByteBuffer 引用 │ │
│ └──────────────────────────────┘ │
└─────────────────────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ 本地内存(堆外) │
│ ┌──────────────────────────────┐ │
│ │ DirectByteBuffer │ │
│ │ - address: 内存地址 │ │
│ │ - capacity: 容量 │ │
│ │ - 实际数据存储区域 │ │
│ └──────────────────────────────┘ │
└─────────────────────────────────────┘直接内存优势:
- 减少内存复制:避免 JVM 堆与操作系统堆之间的数据拷贝
- 提高 IO 性能:适合大文件读写、网络传输
- 不受 GC 影响:生命周期独立于 GC
直接内存劣势:
- 分配和释放成本高
- 不受 JVM 管理,难以监控
- 泄漏后难以排查
根本原因分析
1. 直接内存未正确释放
// 问题代码:ByteBuffer 未释放
public void processData() {
ByteBuffer buffer = ByteBuffer.allocateDirect(10 * 1024 * 1024); // 10MB
// 使用 buffer...
// 没有显式释放,依赖 GC 回收 Cleaner
// 如果 GC 不及时,直接内存会累积
}
// 正确做法:显式释放
public void processData() {
ByteBuffer buffer = ByteBuffer.allocateDirect(10 * 1024 * 1024);
try {
// 使用 buffer...
} finally {
if (buffer instanceof sun.nio.ch.DirectBuffer) {
((sun.nio.ch.DirectBuffer) buffer).cleaner().clean();
}
}
}
// 或使用 try-with-resources(Java 9+)
public void processData() {
try (ByteBuffer buffer = ByteBuffer.allocateDirect(10 * 1024 * 1024)) {
// 使用 buffer...
}
}2. Netty ByteBuf 泄漏
// Netty ByteBuf 泄漏
public void handleRequest(ChannelHandlerContext ctx, ByteBuf msg) {
ByteBuf response = ctx.alloc().directBuffer(1024);
// 写入数据...
ctx.writeAndFlush(response);
// 如果 writeAndFlush 失败,response 可能泄漏
}
// 正确做法:使用 try-finally 或 ReferenceCountUtil
public void handleRequest(ChannelHandlerContext ctx, ByteBuf msg) {
ByteBuf response = ctx.alloc().directBuffer(1024);
boolean released = false;
try {
// 写入数据...
ctx.writeAndFlush(response);
released = true;
} finally {
if (!released) {
ReferenceCountUtil.release(response);
}
}
}
// 开启泄漏检测(生产环境用 DISABLE 或 PARANOID)
-Dio.netty.leakDetection.level=ADVANCED3. 直接内存限制过低
## 默认直接内存限制 = Xmx
## 如果同时使用堆内存和直接内存,可能不足
## 示例:
-Xmx4g # 堆内存 4GB
-XX:MaxDirectMemorySize=2g # 直接内存限制 2GB
## 总内存占用:堆 + 直接内存 + Metaspace + 线程栈 ≈ 7GB+排查方法
步骤一:监控直接内存使用
// 使用 JMX 监控
import java.lang.management.BufferPoolMXBean;
import java.lang.management.ManagementFactory;
List<BufferPoolMXBean> pools = ManagementFactory.getPlatformMXBeans(BufferPoolMXBean.class);
for (BufferPoolMXBean pool : pools) {
System.out.println(pool.getName());
System.out.println(" Count: " + pool.getCount());
System.out.println(" Used: " + pool.getMemoryUsed() / 1024 / 1024 + " MB");
System.out.println(" Capacity: " + pool.getTotalCapacity() / 1024 / 1024 + " MB");
}
// 输出示例:
// direct
// Count: 150
// Used: 1500 MB
// Capacity: 1500 MB步骤二:检测 ByteBuf 泄漏
## 启用 Netty 泄漏检测
-Dio.netty.leakDetection.level=PARANOID
## 日志会输出泄漏报告
LEAK: ByteBuf.release() was not called before it's garbage-collected.
Recent access records:
#0: io.netty.handler.codec.ByteToMessageDecoder.channelRead(...)
...步骤三:使用 Native Memory Tracking
## 启用 NMT
-XX:NativeMemoryTracking=summary
## 查看本地内存使用
jcmd <pid> VM.native_memory summary
## 输出示例:
## Total: reserved=8192MB, committed=6144MB
## - Java Heap (reserved=4096MB, committed=4096MB)
## - Class (reserved=1024MB, committed=512MB)
## - Thread (reserved=1024MB, committed=1024MB)
## - Code (reserved=256MB, committed=128MB)
## - GC (reserved=128MB, committed=128MB)
## - Internal (reserved=1664MB, committed=1280MB)
## (malloc=1280MB #256)解决方案
| 问题 | 解决方案 | 具体措施 |
|---|---|---|
| 直接内存不足 | 增大 -XX:MaxDirectMemorySize | 根据业务需求设置合理上限 |
| ByteBuf 泄漏 | 使用 ReferenceCountUtil,开启泄漏检测 | try-finally 确保释放 |
| 分配开销大 | 使用 Netty 池化分配器 | -Dio.netty.allocator.type=pooled |
| 难以监控 | 使用 JMX BufferPoolMXBean 监控 | 定期检查使用量 |
Netty 最佳实践:
// 1. 使用池化分配器
ByteBufAllocator allocator = new PooledByteBufAllocator(true); // true = direct
// 2. 使用 SimpleChannelInboundHandler 自动释放
public class MyHandler extends SimpleChannelInboundHandler<ByteBuf> {
@Override
protected void channelRead0(ChannelHandlerContext ctx, ByteBuf msg) {
// msg 会在处理完成后自动释放
}
}
// 3. 手动管理生命周期
public void handleRequest(ChannelHandlerContext ctx, ByteBuf msg) {
ByteBuf response = ctx.alloc().directBuffer();
boolean success = false;
try {
response.writeBytes("Hello".getBytes());
ctx.writeAndFlush(response).addListener(future -> {
if (!future.isSuccess()) {
ReferenceCountUtil.release(response);
}
});
success = true;
} finally {
if (!success) {
ReferenceCountUtil.release(response);
}
}
}1.6 unable to create new native thread —— 线程资源耗尽
错误现象
java.lang.OutOfMemoryError: unable to create new native thread这类问题不一定是"堆太小",而是系统资源耗尽:线程数达到上限、进程数限制、内存不足等。
根本原因分析
1. 线程泄漏
// 问题代码:线程池未正确关闭
public class ThreadPoolLeak {
public void process() {
ExecutorService executor = Executors.newCachedThreadPool();
executor.submit(() -> { /* 任务 */ });
// 忘记调用 executor.shutdown()
// 每次调用创建新线程池,线程累积
}
}
// 正确做法
public class ThreadPoolManager {
private static final ExecutorService executor =
new ThreadPoolExecutor(10, 100, 60L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(1000));
// 使用单例线程池
}
// 或使用 try-with-resources
public void process() {
try (ExecutorService executor = Executors.newFixedThreadPool(10)) {
executor.submit(() -> { /* 任务 */ });
}
}2. 线程数配置不当
// 问题:无界线程池
ExecutorService executor = Executors.newCachedThreadPool();
// 可创建 Integer.MAX_VALUE 个线程
// 正确:有界线程池
ExecutorService executor = new ThreadPoolExecutor(
10, // 核心线程数
100, // 最大线程数
60L, TimeUnit.SECONDS, // 空闲线程存活时间
new LinkedBlockingQueue<>(1000), // 工作队列
new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略
);3. 系统资源限制
## Linux 系统线程限制
ulimit -u # 用户最大进程/线程数
cat /proc/sys/kernel/pid_max # 系统 PID 上限
cat /proc/sys/vm/max_map_count # 最大内存映射区域数
## 查看当前进程线程数
cat /proc/<pid>/status | grep Threads
## 查看系统总线程数
ps -eLf | wc -l
## 容器环境(Docker/K8s)可能有限制
## pids.max 限制了容器内最大进程/线程数
cat /sys/fs/cgroup/pids/pids.max4. 每个线程占用栈内存
计算线程内存占用:
线程数 × 栈大小 = 总栈内存
例如:
- 1000 线程 × 1MB 栈 = 1GB 本地内存
- 10000 线程 × 1MB 栈 = 10GB 本地内存
这会与 JVM 堆、Metaspace、直接内存竞争系统内存。排查方法
步骤一:统计线程数量
## 使用 jstack 统计线程数
jstack <pid> | grep "java.lang.Thread.State" | wc -l
## 使用 jconsole 或 jvisualvm 查看线程
## 使用 jcmd
jcmd <pid> Thread.print步骤二:分析线程栈
## 查看线程详细信息
jstack <pid> > thread_dump.txt
## 查看线程状态分布
grep "java.lang.Thread.State" thread_dump.txt | sort | uniq -c
## 示例输出:
## 50 RUNNABLE
## 30 WAITING (on object monitor)
## 20 TIMED_WAITING (sleeping)步骤三:检查系统限制
## 检查用户进程限制
ulimit -a
## 检查系统资源
cat /proc/sys/kernel/pid_max
cat /proc/sys/kernel/threads-max
## 检查当前进程打开的文件描述符
lsof -p <pid> | wc -l解决方案
| 问题 | 解决方案 | 具体措施 |
|---|---|---|
| 线程泄漏 | 正确关闭线程池,使用单例模式 | shutdown()、shutdownNow() |
| 线程数过多 | 使用有界线程池,合理配置核心/最大线程数 | ThreadPoolExecutor 参数优化 |
| 系统限制过低 | 调整 ulimit、pid_max、容器配置 | 增大系统限制 |
| 栈内存占用大 | 减小 -Xss | 注意可能触发 StackOverflowError |
| 任务处理不过来 | 使用异步非阻塞模型 | Netty、Reactor、CompletableFuture |
线程池最佳实践:
// 合理配置线程池
public class ThreadPoolConfig {
// CPU 密集型任务:线程数 = CPU 核数 + 1
private static final int CPU_COUNT = Runtime.getRuntime().availableProcessors();
// IO 密集型任务:线程数 = CPU 核数 * 2
private static final int IO_COUNT = CPU_COUNT * 2;
// 混合型任务:根据 IO 等待时间调整
// 线程数 = CPU 核数 * (1 + 等待时间/计算时间)
public static ExecutorService createThreadPool() {
return new ThreadPoolExecutor(
CPU_COUNT, // 核心线程数
IO_COUNT, // 最大线程数
60L, TimeUnit.SECONDS, // 空闲线程存活时间
new LinkedBlockingQueue<>(1000), // 工作队列
new ThreadFactoryBuilder()
.setNameFormat("worker-%d")
.setDaemon(true)
.build(),
new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略
);
}
}
// 使用虚拟线程(Java 21+)
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
executor.submit(() -> { /* 任务 */ });
}1.7 GC overhead limit exceeded —— GC 开销超限
错误现象
java.lang.OutOfMemoryError: GC overhead limit exceeded这是 JVM 的一种自我保护机制。当 JVM 发现花费过多的时间在 GC 上(默认 98%),且回收的内存很少(默认 2%),就会抛出此错误。
触发条件详解
触发条件(连续多次 GC 满足):
1. GC 时间占比 > 98%
2. GC 回收内存 < 堆的 2%
3. 这两个条件在连续 5 次 Full GC 中都满足(可配置)
触发机制:
┌─────────────────────────────────────┐
│ 应用运行时间: 2% │
│ GC 时间: 98% │
│ ──────────────────────── │
│ 几乎所有时间都在做 GC,但收效甚微 │
│ JVM 判断继续运行已无意义,抛出错误 │
└─────────────────────────────────────┘这个错误通常是 Java heap space 的前兆,表明堆即将耗尽。
与 Java heap space 的关系
Java heap space vs GC overhead limit exceeded:
场景 1:内存泄漏
┌──────────────────────────────────────┐
│ 堆几乎满了,GC 无法回收 │
│ → GC overhead limit exceeded │
│ → 如果继续分配,会触发 heap space │
└──────────────────────────────────────┘
场景 2:对象分配速率过快
┌──────────────────────────────────────┐
│ GC 来不及回收,跟不上分配速度 │
│ → GC overhead limit exceeded │
│ → 需要优化代码或增大堆 │
└──────────────────────────────────────┘
场景 3:堆太小
┌──────────────────────────────────────┐
│ 堆配置过小,GC 频繁 │
│ → GC overhead limit exceeded │
│ → 增大堆内存 │
└──────────────────────────────────────┘排查方法
步骤一:查看 GC 日志
## 查看最近的 GC 日志
grep "GC overhead" gc.log
## 分析 GC 频率和耗时
grep "Full GC" gc.log | tail -100步骤二:分析堆使用情况
## 使用 jstat 查看 GC 统计
jstat -gcutil <pid> 1000
## 关注列:
## FGC: Full GC 次数
## FGCT: Full GC 总时间
## GCT: GC 总时间
## 如果 GCT/运行时间 > 20%,说明 GC 开销过大步骤三:生成堆转储分析
## 生成堆转储
jmap -dump:format=b,file=heap.hprof <pid>
## 使用 MAT 分析
## 查看是否有内存泄漏或对象过多解决方案
方案一:分析根本原因
## 可以禁用此限制(不推荐)
-XX:-UseGCOverheadLimit
## 更好的做法:解决根本问题
## 1. 分析堆转储文件,查找泄漏
## 2. 优化代码,减少对象创建
## 3. 调整堆大小
## 4. 更换垃圾收集器方案二:具体优化措施
## 1. 增大堆内存
-Xmx8g
## 2. 调整新生代比例
-Xmn3g # 或
-XX:NewRatio=2 # 老年代:新生代 = 2:1
## 3. 更换垃圾收集器
## 如果使用 Parallel GC,考虑换成 G1
-XX:+UseG1GC
-XX:MaxGCPauseMillis=100
## 4. 调整 GC 触发时机
-XX:InitiatingHeapOccupancyPercent=35 # G1:堆占用 35% 触发并发标记代码优化示例:
// 问题:对象创建过多
public List<String> process(List<Data> dataList) {
List<String> results = new ArrayList<>();
for (Data data : dataList) {
String temp = data.toString(); // 创建临时字符串
String processed = temp.toUpperCase(); // 又创建新字符串
results.add(processed);
}
return results;
}
// 优化:减少临时对象
public List<String> process(List<Data> dataList) {
List<String> results = new ArrayList<>(dataList.size()); // 预分配容量
StringBuilder sb = new StringBuilder();
for (Data data : dataList) {
sb.setLength(0); // 复用 StringBuilder
sb.append(data.toString().toUpperCase());
results.add(sb.toString());
}
return results;
}二、GC 调优核心概念
2.1 GC 调优的本质
GC 调优本质上是在平衡三个目标:
吞吐量(Throughput)
▲
╱ ╲
╱ ╲
╱ ╲
╱ ╲
╱ ╲
╱ ╲
╱ ╲
▼───────────────▼
延迟(Latency) ←→ 内存占用(Footprint)三者关系:
| 优化方向 | 可能的代价 |
|---|---|
| 提高吞吐量 | 可能增加停顿时间 |
| 降低延迟 | 可能降低吞吐量,增加内存占用 |
| 减少内存占用 | 可能增加 GC 频率,降低吞吐量 |
不存在银弹:没有一种配置能让三者同时达到最优。需要根据业务场景选择优先级:
- 批处理:吞吐量优先
- 交互式应用:延迟优先
- 资源受限:内存占用优先
2.2 核心指标详解
2.2.1 吞吐量(Throughput)
定义: 应用程序运行时间占总时间的比例。
吞吐量 = 应用运行时间 / (应用运行时间 + GC 时间) × 100%
示例:
总时间:100 秒
GC 时间:5 秒
应用运行时间:95 秒
吞吐量 = 95 / 100 = 95%适用场景:
- 批处理任务
- 后台计算
- 不关心响应延迟的服务
优化方向:
- 增大堆内存,减少 GC 频率
- 使用 Parallel GC
- 减少对象创建
2.2.2 延迟(Latency / Pause Time)
定义: GC 造成的应用程序停顿时间(Stop-The-World, STW)。
延迟指标:
- 平均停顿时间:所有 GC 停顿的平均值
- 最大停顿时间:单次 GC 最长停顿
- 停顿频率:单位时间内 GC 次数
- P99 停顿:99% 的 GC 停顿时间上限适用场景:
- 交互式应用
- 实时交易系统
- 在线游戏
- 高频交易
优化方向:
- 使用 G1、ZGC 等低延迟收集器
- 减小堆内存或调整分代比例
- 优化对象晋升策略
2.2.3 内存占用(Footprint)
定义: JVM 使用的物理内存总量。
JVM 内存组成:
├── 堆内存(Heap)
│ ├── 新生代
│ └── 老年代
├── 非堆内存(Non-Heap)
│ ├── Metaspace
│ ├── Code Cache
│ └── Thread Stacks
└── 直接内存(Direct Memory)权衡:
- 更大的堆 → GC 频率降低,但停顿时间可能增加
- 更小的堆 → 内存占用少,但 GC 频繁
2.3 分代假说与 GC 设计
弱分代假说(Weak Generational Hypothesis):
大多数对象都是朝生夕灭的。
强分代假说(Strong Generational Hypothesis):
熬过越多次 GC 的对象越难以消亡。
基于这两个假说,JVM 采用分代收集策略:
┌────────────────────────────────────────────────────────┐
│ JVM 堆 │
│ ┌─────────────────────────────────┐ ┌──────────────┐ │
│ │ 新生代 │ │ 老年代 │ │
│ │ ┌────────┬────────┬────────┐ │ │ │ │
│ │ │ Eden │ S0 │ S1 │ │ │ 长期存活对象 │ │
│ │ │ 新对象│ Survivor│Survivor│ │ │ │ │
│ │ └────────┴────────┴────────┘ │ │ │ │
│ │ ↑ Minor GC 优先回收 │ │ ↑ Major GC │ │
│ └─────────────────────────────────┘ └──────────────┘ │
│ │
│ 特点: │
│ - 新生代对象生命周期短,回收效率高 │
│ - 老年代对象生命周期长,回收成本高 │
│ - 分代回收可以针对不同区域使用不同算法 │
└────────────────────────────────────────────────────────┘三、垃圾收集器与调优参数
3.1 收集器演进历程
时间线:
JDK 1.3 ──→ Serial GC
└── 单线程,简单高效,适合客户端
JDK 1.4 ──→ Parallel GC (吞吐量优先)
└── 多线程并行收集,适合批处理
JDK 1.5 ──→ CMS (Concurrent Mark Sweep)
└── 低停顿,标记-清除,有碎片问题
JDK 7u4 ──→ G1 GC (Garbage First)
└── 区域化收集,可预测停顿,服务端默认
JDK 11 ──→ ZGC (Z Garbage Collector)
└── 并发整理,停顿 < 10ms(目标 < 1ms)
JDK 21 ──→ 分代 ZGC(Generational ZGC)
└── 分代收集的 ZGC,性能更优3.3 G1 GC —— Garbage First 收集器
G1 是目前最主流的垃圾收集器,JDK 9+ 的默认收集器。
工作原理
G1 GC 核心概念:
堆内存划分(Region):
┌────┬────┬────┬────┬────┬────┬────┬────┐
│ E │ E │ S │ │ O │ O │ O │ H │
├────┼────┼────┼────┼────┼────┼────┼────┤
│ O │ O │ │ E │ E │ S │ O │ H │
└────┴────┴────┴────┴────┴────┴────┴────┘
E: Eden Region
S: Survivor Region
O: Old Region
H: Humongous Region(大对象)
空白: 空闲 Region
特点:
- 每个 Region 大小相等(1-32MB,2 的幂)
- 动态分配 Eden、Survivor、Old 区域
- 优先收集垃圾最多的 Region(Garbage First)
- 可预测停顿时间收集过程
G1 收集过程(Young GC + Mixed GC):
1. Young GC
└── 回收所有 Eden 和 Survivor Region
└── 存活对象复制到新的 Survivor 或 Old Region
└── [STW] 但可控(根据停顿目标选择回收 Region)
2. 并发标记周期
├── 初始标记 [STW](借道 Young GC)
├── 并发标记
├── 最终标记 [STW]
└── 清点(识别垃圾最多的 Region)
3. Mixed GC
└── 回收所有年轻代 + 部分老年代 Region
└── 根据停顿目标选择老年代 Region
└── [STW] 复制整理
4. Full GC(退化为 Serial Old)
└── 当 Mixed GC 无法跟上分配速度时触发
└── 性能急剧下降关键参数
## 启用 G1
-XX:+UseG1GC # JDK 9+ 默认
## 停顿时间目标
-XX:MaxGCPauseMillis=200 # 目标最大停顿 200ms(默认)
## Region 大小
-XX:G1HeapRegionSize=4m # Region 大小(1-32MB,2 的幂)
# 不设置则自动计算(堆/2048)
## 新生代大小
-Xmn # 不建议设置,让 G1 自动调整
-XX:G1NewSizePercent=5 # 新生代最小占比(默认 5%)
-XX:G1MaxNewSizePercent=60 # 新生代最大占比(默认 60%)
## 并发标记触发
-XX:InitiatingHeapOccupancyPercent=45 # 堆占用 45% 触发并发标记(默认)
## Mixed GC 控制
-XX:G1MixedGCCountTarget=8 # Mixed GC 次数目标(默认 8)G1 调优建议
## 1. 设置合理的停顿目标
-XX:MaxGCPauseMillis=100-300 # 根据业务需求调整
## 2. 不要固定新生代大小
## × 不要设置 -Xmn,会禁用 G1 的自适应调节
## 3. 调整并发标记触发时机
## 如果经常 Full GC,提前触发并发标记
-XX:InitiatingHeapOccupancyPercent=35
## 4. 增加 Mixed GC 频率
## 更积极地回收老年代
-XX:G1MixedGCCountTarget=4
## 5. 处理大对象
## 如果大量大对象导致问题
-XX:G1HeapRegionSize=16m # 增大 Region3.4 ZGC —— Z Garbage Collector
ZGC 是 JDK 15+ 推出的新一代垃圾收集器,目标是实现极低延迟。
工作原理
ZGC 核心技术:
1. 着色指针(Colored Pointers)
┌─────────────────────────────────────────┐
│ 64位指针 │
│ ├── 42位:对象地址 │
│ ├── 4位:标记信息(Marked0/1/Remapped等)│
│ └── 18位:保留 │
└─────────────────────────────────────────┘
通过指针的颜色标记对象状态,实现并发标记。
2. 读屏障(Load Barrier)
在读取对象引用时检查指针颜色:
if (pointer.color != expected) {
修正指针,确保访问正确的对象副本
}
允许并发整理,无需 STW。
收集阶段:
1. 初始标记 [STW, < 1ms]
2. 并发标记
3. 重新标记 [STW, < 1ms]
4. 并发转移准备
5. 初始转移 [STW, < 1ms]
6. 并发转移适用场景
- 超大堆(TB 级别)
- 极低延迟要求(< 10ms)
- JDK 15+ 生产可用
- JDK 21+ 分代 ZGC 更优
关键参数
## 启用 ZGC
-XX:+UseZGC # JDK 15+
## 分代 ZGC(JDK 21+)
-XX:+UseZGC -XX:+ZGenerational
## 并发线程
-XX:ConcGCThreads=4 # 并发线程数
## 堆大小
-Xms16g -Xmx16g # 建议设置固定大小
## 启用大页面
-XX:+UseLargePages # 使用大页内存,提升性能四、GC 日志分析
4.1 启用 GC 日志
JDK 8 及以下
## 基础配置
-XX:+PrintGCDetails # 打印详细 GC 信息
-XX:+PrintGCDateStamps # 打印时间戳
-Xloggc:/path/to/gc.log # 输出到文件
## 日志轮转
-XX:+UseGCLogFileRotation
-XX:NumberOfGCLogFiles=5
-XX:GCLogFileSize=20MJDK 9+(统一日志框架)
## 统一配置格式
-Xlog[:[what][:[output][:[decorators][:output-options]]]]
## 常用配置
-Xlog:gc*:file=/path/to/gc.log:time,level,tags:filecount=5,filesize=50m
## 详细配置
-Xlog:gc*,gc+heap=debug:file=gc.log:time,level:filecount=5,filesize=50m4.2 GC 日志格式解析
G1 GC 日志
// Young GC
[GC pause (G1 Evacuation Pause) (young), 0.0123456 secs]
[Parallel Time: 10.2 ms, GC Workers: 8]
[Eden: 1024.0M(1024.0M)->0.0B(768.0M) Survivors: 128.0M->256.0M Heap: 12.5G(16.0G)->11.8G(16.0G)]
[Times: user=0.08 sys=0.00, real=0.01 secs]
解析:
- (G1 Evacuation Pause) (young) - Young GC
- Parallel Time: 并行阶段耗时
- Eden/Survivors/Heap: 内存变化
- real=0.01 secs: 实际停顿时间
// Full GC(退化)
[Full GC (Allocation Failure) 12G->10G(16G), 1.234567 secs]
[Times: user=4.56 sys=0.12, real=1.23 secs]4.3 GC 日志分析工具
在线分析工具
| 工具 | 地址 | 特点 |
|---|---|---|
| GC Easy | https://gceasy.io/ | 免费在线分析,生成详细报告 |
| GC Viewer | https://github.com/chewiebug/GC-Viewer | 开源,本地运行 |
GC Easy 分析示例
上传 GC 日志后,报告包含:
关键指标:
├── 吞吐量:98.5%(目标 > 90%)
├── 平均 GC 停顿:15ms
├── 最大 GC 停顿:250ms
├── GC 频率:每分钟 5 次
└── 内存分配速率:150 MB/s
问题检测:
├── Full GC 过于频繁(每 5 分钟)
├── 老年代使用率高(85%)
├── 无内存泄漏迹象
└── GC 停顿在可接受范围
优化建议:
├── 增大堆内存到 8GB
├── 调整 -XX:InitiatingHeapOccupancyPercent=40
└── 考虑使用 G1 GC 替代 Parallel GC五、实战调优案例
案例 1:电商系统 OOM 排查
问题现象
生产环境错误日志:
java.lang.OutOfMemoryError: Java heap space
系统环境:
- JVM: JDK 8, 4GB 堆
- 应用:电商订单服务
- 现象:每天高峰期(10:00-12:00)触发 OOM排查过程
步骤一:查看 GC 日志
## 发现 Full GC 频繁且无法回收内存
[Full GC (Allocation Failure) [Tenured: 3584K->3584K(3584K), 0.123456 secs]
## 关键发现:Full GC 后内存没有明显下降
## 3584K -> 3584K(无变化)
## 怀疑内存泄漏步骤二:生成堆转储
## 配置自动生成
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/heap.hprof
## 或手动生成
jmap -dump:format=b,file=/tmp/heap.hprof <pid>步骤三:MAT 分析
MAT 分析结果:
1. 内存泄漏报告
- 泄漏对象:OrderCache
- 泄漏大小:2.8 GB
- 对象数量:1,200,000 个 Order 对象
2. 引用链分析
OrderCache (静态变量)
└── ConcurrentHashMap<Long, Order>
└── Order 对象(120万个)
3. 问题定位
- OrderCache 是静态全局缓存
- 订单完成后未从缓存移除
- 每天新增约 50 万订单,累积导致 OOM步骤四:代码分析
// 问题代码
public class OrderCache {
private static final ConcurrentHashMap<Long, Order> cache = new ConcurrentHashMap<>();
public static void addOrder(Order order) {
cache.put(order.getId(), order);
// 缺少移除逻辑
}
public static Order getOrder(Long orderId) {
return cache.get(orderId);
}
// 应该添加的方法
public static void removeOrder(Long orderId) {
cache.remove(orderId);
}
}解决方案
// 方案一:使用有界缓存(推荐)
public class OrderCache {
private static final int MAX_SIZE = 10000;
private static final Cache<Long, Order> cache = Caffeine.newBuilder()
.maximumSize(MAX_SIZE)
.expireAfterWrite(Duration.ofHours(1)) // 1小时过期
.build();
public static void addOrder(Order order) {
cache.put(order.getId(), order);
}
}
// 方案二:使用弱引用
public class OrderCache {
private static final Map<Long, WeakReference<Order>> cache =
new ConcurrentHashMap<>();
// GC 时自动回收无引用的 Order
}调优效果
优化前后对比:
优化前:
- 堆使用:持续上升,直到 OOM
- Full GC:每天 10+ 次,无法回收
- 服务:每天重启一次
优化后:
- 堆使用:稳定在 1.5GB 左右
- Full GC:每周 1-2 次,正常回收
- 服务:稳定运行,无重启案例 2:社交平台响应慢排查
问题现象
问题描述:
- 社交平台首页加载慢,平均响应时间 2s+
- 高峰期 RT 偶尔超过 5s
环境信息:
- JVM: JDK 11, 8GB 堆, G1 GC
- 配置:默认 G1 参数排查过程
步骤一:监控 GC 情况
## 查看 GC 日志
grep "GC pause" gc.log | tail -100
## 发现问题:Young GC 停顿过长
[GC pause (G1 Evacuation Pause) (young), 0.234567 secs] # 234ms
[GC pause (G1 Evacuation Pause) (young), 0.312456 secs] # 312ms
## 分析:Young GC 平均停顿 300ms+,超出预期步骤二:分析原因
## 使用 jstat 查看堆使用
jstat -gc <pid> 1000
## 发现问题:老年代占用高
OU(老年代使用) 接近 OC(老年代容量)
## 导致对象快速晋升,Young GC 时复制大量对象步骤三:分析对象分配
## 使用 JProfiler 分析对象分配
## 发现:
## 1. 短时间内创建大量临时字符串
## 2. JSON 序列化/反序列化产生大量对象
## 3. 数据库查询返回大量对象步骤四:定位热点代码
// 问题代码 1:字符串拼接
public String buildResponse(List<Post> posts) {
String result = "";
for (Post post : posts) {
result += post.toString(); // 每次创建新 String
}
return result;
}
// 问题代码 2:JSON 序列化
public List<String> getPosts() {
List<Post> posts = postRepository.findAll();
return posts.stream()
.map(post -> objectMapper.writeValueAsString(post)) // 每个对象都序列化
.collect(Collectors.toList());
}解决方案
// 优化 1:使用 StringBuilder
public String buildResponse(List<Post> posts) {
StringBuilder sb = new StringBuilder(1024);
for (Post post : posts) {
sb.append(post.toString());
}
return sb.toString();
}
// 优化 2:复用 ObjectMapper
private static final ObjectMapper mapper = new ObjectMapper();
// 优化 3:分页查询
public Page<Post> getFeed(Long userId, int page, int size) {
return postRepository.findAllByUserId(userId, PageRequest.of(page, size));
}JVM 参数优化:
## 原配置
-Xmx8g -XX:+UseG1GC
## 优化后配置
-Xmx8g -XX:+UseG1GC
-XX:MaxGCPauseMillis=100 # 目标停顿 100ms
-XX:InitiatingHeapOccupancyPercent=40 # 提前触发并发标记
-XX:G1NewSizePercent=30 # 增大新生代最小占比
-XX:G1MaxNewSizePercent=50 # 增大新生代最大占比调优效果
优化前后对比:
指标 优化前 优化后
─────────────────────────────────────────
平均响应时间 2.3s 0.4s
P99 响应时间 5.6s 0.8s
Young GC 停顿 300ms 50ms
Full GC 频率 每天 2 次 每周 1 次
吞吐量 1200 QPS 3500 QPS案例 3:微服务 Metaspace OOM
问题现象
问题描述:
- 微服务部署后 2-3 天崩溃
- 错误信息:java.lang.OutOfMemoryError: Metaspace
- 重启后恢复,但问题重复出现
环境信息:
- JVM: JDK 11, 2GB 堆, G1 GC
- 应用:Spring Boot 微服务
- 框架:使用动态代理、反射排查过程
## 查看 Metaspace 使用
jcmd <pid> VM.metaspace
## 输出:
## Total space: 512 MB (100% committed)
## Used: 508 MB
## MaxMetaspaceSize: 512 MB
## Metaspace 几乎满了分析已加载类:
## 查看类直方图
jcmd <pid> GC.class_histogram | head -50
## 发现大量代理类:
## 124565 com.example.service.$Proxy123
## 124565 com.example.service.$Proxy124
## ...
## 生成了 12 万+ 代理类!代码分析:
// 问题代码:每次请求创建新代理
@RestController
public class ApiController {
@GetMapping("/process")
public Result process(@RequestParam String type) {
// 每次请求都创建新的动态代理
Object proxy = Proxy.newProxyInstance(
getClass().getClassLoader(),
new Class<?>[] { getServiceInterface(type) },
new ServiceInvocationHandler(getServiceBean(type))
);
return ((ServiceInterface) proxy).execute();
}
}解决方案
// 优化:缓存代理对象
@RestController
public class ApiController {
private static final ConcurrentHashMap<String, Object> proxyCache =
new ConcurrentHashMap<>();
@GetMapping("/process")
public Result process(@RequestParam String type) {
Object proxy = proxyCache.computeIfAbsent(type, t -> {
return Proxy.newProxyInstance(
getClass().getClassLoader(),
new Class<?>[] { getServiceInterface(t) },
new ServiceInvocationHandler(getServiceBean(t))
);
});
return ((ServiceInterface) proxy).execute();
}
}JVM 参数优化:
## 原配置
-Xmx2g -XX:+UseG1GC
## 优化后配置
-Xmx2g -XX:+UseG1GC
-XX:MetaspaceSize=128m # 初始大小
-XX:MaxMetaspaceSize=512m # 最大限制
-XX:+ClassUnloadingWithConcurrentMark # G1 并发类卸载调优效果
优化前后对比:
指标 优化前 优化后
─────────────────────────────────────────
运行时间 2-3 天崩溃 稳定运行
已加载类数 12 万+ 8000
Metaspace 使用 508 MB 128 MB
类加载速率 持续增长 稳定六、调优决策流程
6.1 调优前必问的问题
在开始调优之前,必须回答以下问题:
1. 问题定义
├── 具体症状是什么?(OOM、慢、崩溃)
├── 何时发生?(高峰期、特定操作)
├── 影响范围?(单服务、集群、用户感知)
└── 频率如何?(持续、偶发、定时)
2. 性能目标
├── 吞吐量要求?(QPS、TPS)
├── 延迟要求?(平均、P99、P999)
├── 资源限制?(CPU、内存、磁盘)
└── 成本考量?(硬件、人力)
3. 当前状态
├── JVM 版本和配置?
├── GC 收集器类型?
├── 堆大小设置?
└── 业务特点?(请求量大、对象生命周期)6.2 调优决策树
开始调优
│
▼
是否有 OOM 错误?
│
├── 是 ──→ 确定类型
│ ├── heap space → 分析堆转储
│ ├── Metaspace → 分析类加载
│ ├── Direct buffer → 分析 NIO/Netty
│ └── Thread → 分析线程泄漏
│
└── 否 ──→ 性能是否达标?
│
├── 达标 → 无需调优
│
└── 不达标 → 问题类型?
│
├── 响应慢 → 分析 GC 停顿
│ │
│ ├── Young GC 停顿长
│ │ → 减小新生代或换收集器
│ │
│ └── Full GC 频繁
│ → 增大堆或优化代码
│
├── 吞吐低 → 分析 GC 时间占比
│ │
│ ├── GC 时间 > 10%
│ │ → 换 Parallel GC 或增大堆
│ │
│ └── GC 时间 < 10%
│ → 优化业务代码
│
└── 资源高 → 分析内存使用
│
├── 堆使用高 → 检查泄漏
│
└── CPU 高 → 分析 GC 线程6.3 调优原则
核心原则:
1. 先分析,后调优
不要一上来就改参数,先收集数据、分析原因
2. 代码优化优先
很多 JVM 问题根因在代码层面
3. 单一变量原则
每次只改一个参数,观察效果
4. 增量调整
小步快跑,逐步优化
5. 监控驱动
持续监控,数据说话
6. 回归测试
每次调整后验证功能和性能
7. 记录变更
记录每次调整和效果,便于回溯七、常见误区
7.1 误区一:一看到 OOM 就调大堆
错误做法:
## 看到 OOM 就加大堆
-Xmx4g → -Xmx8g → -Xmx16g
问题:
1. 可能是内存泄漏,调大堆只是延缓问题
2. 更大的堆意味着更长的 GC 停顿
3. 可能掩盖真正的代码问题
正确做法:
1. 分析堆转储,确认是否泄漏
2. 如果是泄漏,修复代码
3. 如果确实需要更多内存,再调整7.2 误区二:GC 频繁只怪 JVM
错误认知:
"GC 太频繁了,JVM 需要调优"
可能的真实原因:
1. 对象创建过多(代码问题)
2. 内存泄漏(代码问题)
3. 堆大小配置不合理(配置问题)
4. 业务流量突增(容量问题)
正确做法:
1. 分析对象分配速率
2. 检查是否有泄漏
3. 确认业务流量是否合理
4. 最后才考虑 JVM 参数7.3 误区三:把调优等同于改参数
错误认知:
"调优就是改 JVM 启动参数"
调优的层次:
Level 1: 架构调优(影响最大)
├── 分布式架构优化
├── 数据库分库分表
└── 缓存策略优化
Level 2: 代码调优
├── 算法优化
├── 数据结构选择
├── 减少对象创建
└── 并发优化
Level 3: JVM 调优
├── 收集器选择
├── 堆大小设置
└── GC 参数调整
Level 4: OS/硬件调优
├── 内存配置
├── CPU 绑核
└── 磁盘 I/O
正确做法:
按照从高到低的顺序,优先考虑高层次的优化7.4 误区四:容器内存 = JVM 堆
错误配置:
Docker 容器限制 4GB 内存
JVM 配置:-Xmx4g
问题:
JVM 使用的内存 ≠ 堆内存
实际内存 = 堆 + Metaspace + 线程栈 + 直接内存 + Code Cache + ...
容器 OOM → 进程被 kill
正确配置:
容器限制 4GB
JVM 配置:-Xmx3g(预留 ~25% 给其他内存)
推荐配置:
-Xmx${CONTAINER_MEMORY:-4096}m
-XX:MaxMetaspaceSize=256m
-XX:MaxDirectMemorySize=512m8.1 基础问题
Q1:常见 OOM 类型有哪些,排查方向分别是什么?
1. Java heap space
- 排查:堆转储分析,MAT 查找泄漏对象
- 原因:内存泄漏、缓存失控、大对象
2. Metaspace
- 排查:jcmd 查看类加载,分析代理/反射
- 原因:动态代理过多、类加载器泄漏
3. StackOverflowError
- 排查:jstack 查看线程栈
- 原因:递归过深、相互调用
4. Direct buffer memory
- 排查:JMX BufferPoolMXBean,Netty 泄漏检测
- 原因:NIO/Netty 未释放
5. unable to create new native thread
- 排查:jstack 统计线程,检查系统限制
- 原因:线程泄漏、系统资源限制
6. GC overhead limit exceeded
- 排查:分析 GC 日志,检查堆使用情况
- 原因:GC 频繁但收效甚微,通常是内存泄漏前兆Q2:为什么 GC 调优本质上是吞吐、延迟、内存占用的权衡?
三者相互制约:
吞吐量 ↑ → 需要更少的 GC 次数 → 可能需要更大的堆 → 内存占用 ↑
延迟 ↓ → 需要 GC 快速完成 → 可能需要更小的堆 → GC 频繁 → 吞吐量 ↓
内存占用 ↓ → 堆变小 → GC 频繁 → 停顿增加 → 吞吐量 ↓
不存在三者同时最优的方案,需要根据业务场景选择优先级:
- 批处理:吞吐量优先
- 交互式应用:延迟优先
- 资源受限:内存占用优先Q3:内存泄漏和内存溢出的区别?
内存泄漏(Memory Leak):
- 定义:程序中存在某些对象不再被使用,但无法被回收
- 特点:内存逐渐累积,最终导致 OOM
- 原因:静态集合、监听器未注销、ThreadLocal 未清理等
- 解决:修复代码,释放无用引用
内存溢出(OutOfMemory):
- 定义:内存不足,无法分配新对象
- 特点:可能是泄漏导致,也可能是配置不合理
- 原因:堆太小、对象过多、泄漏等
- 解决:增大内存或修复泄漏
关系:
内存泄漏 → 内存逐渐耗尽 → 内存溢出(OOM)8.2 进阶问题
Q4:如何选择合适的垃圾收集器?
决策依据:
1. 堆大小
- < 4GB:Serial、Parallel
- 4-32GB:G1
- > 32GB:G1、ZGC、Shenandoah
2. 延迟要求
- 无要求:Parallel(吞吐优先)
- < 200ms:G1
- < 10ms:ZGC、Shenandoah
3. JDK 版本
- JDK 8:Parallel、CMS(已弃用)、G1
- JDK 11:G1
- JDK 15+:G1、ZGC
- JDK 21+:分代 ZGC
4. 业务类型
- 批处理:Parallel
- 在线服务:G1
- 实时交易:ZGC
推荐:
- JDK 8:默认 Parallel,服务端可考虑 G1
- JDK 9+:默认 G1,大堆低延迟用 ZGCQ5:什么情况下需要调大新生代?什么情况下需要调小?
调大新生代的场景:
1. Young GC 频繁(每秒多次)
2. 对象存活率低(大多数对象朝生夕灭)
3. 吞吐量要求高(减少 GC 次数)
调小新生代的场景:
1. Young GC 停顿过长
2. 单次 Young GC 时间超过可接受范围
3. 对象存活率高(需要快速晋升)
注意:G1 不建议手动设置新生代大小
G1 会根据停顿目标自动调整Q6:G1 GC 为什么会退化为 Full GC?如何避免?
退化原因:
1. 对象分配速度 > 回收速度
2. 老年代空间不足,Mixed GC 无法跟上
3. 大对象过多,无法在 Young GC 中处理
避免方法:
1. 提前触发并发标记
-XX:InitiatingHeapOccupancyPercent=35
2. 增加 Mixed GC 频率
-XX:G1MixedGCCountTarget=4
3. 增大 Region 大小(处理大对象)
-XX:G1HeapRegionSize=16m
4. 增大堆内存
5. 优化代码,减少对象创建Q7:如何排查 Metaspace OOM?
排查步骤:
1. 查看 Metaspace 使用情况
jcmd <pid> VM.metaspace
2. 查看已加载类数量
jcmd <pid> GC.class_stats
jmap -histo <pid> | head -50
3. 分析类加载器
jcmd <pid> VM.classloaders
4. 检查是否有大量代理类
jmap -histo <pid> | grep Proxy
5. 分析堆转储,查看类加载器引用链
MAT → Class Loader Explorer
常见原因:
- 动态代理无限生成
- 类加载器泄漏(热部署)
- CGLIB 字节码增强过多8.3 实战问题
Q8:生产环境出现 OOM,如何快速定位问题?
快速定位流程:
1. 确认 OOM 类型
查看错误日志,区分是哪种 OOM
2. 保留现场
- 配置 -XX:+HeapDumpOnOutOfMemoryError 自动生成堆转储
- 或手动生成:jmap -dump:format=b,file=heap.hprof <pid>
- 保存 GC 日志
3. 分析堆转储
- 使用 MAT 打开 .hprof 文件
- 查看 Leak Suspects Report
- 找到占用内存最大的对象
- 分析引用链,定位代码位置
4. 分析 GC 日志
- 使用 GC Easy 在线分析
- 查看 GC 频率、停顿时间
- 判断是泄漏还是容量不足
5. 验证假设
- 根据分析结果,定位代码问题
- 在测试环境复现
- 修复并验证
6. 监控验证
- 部署修复后,持续监控
- 确认问题已解决Q9:如何监控和预防 OOM?
监控手段:
1. JVM 监控
- JMX 监控堆使用率、GC 频率
- Prometheus + Grafana 可视化
- Arthas 实时监控
2. 日志监控
- GC 日志分析
- 错误日志告警
- 使用 ELK 收集和分析
3. 系统监控
- CPU、内存、磁盘使用率
- 线程数、网络连接数
- 容器资源限制
预防措施:
1. 代码审查
- 检查静态集合、缓存使用
- 检查资源关闭(Connection、Stream)
- 检查 ThreadLocal 清理
2. 压力测试
- 模拟高并发场景
- 长时间稳定性测试
- 内存泄漏检测
3. 设置合理的 JVM 参数
- 根据业务需求设置堆大小
- 启用 GC 日志
- 配置堆转储自动生成
4. 定期巡检
- 分析 GC 日志
- 检查内存使用趋势
- 及时发现潜在问题Q10:谈谈你对 JVM 调优的理解?
核心理解:
1. 调优是最后手段
- 架构优化 > 代码优化 > JVM 调优
- 大多数问题可以通过代码优化解决
- 不要一上来就调 JVM 参数
2. 数据驱动
- 先收集数据,再分析,最后调优
- 使用监控工具,避免凭感觉调优
- 记录每次调整和效果
3. 权衡取舍
- 吞吐量、延迟、内存占用无法同时最优
- 根据业务场景选择优先级
- 没有银弹,只有最适合的方案
4. 持续监控
- 调优不是一次性工作
- 业务变化可能需要重新调优
- 建立完善的监控体系
5. 预防胜于治疗
- 代码质量是根本
- 压力测试提前发现问题
- 合理的容量规划
最佳实践:
- 理解业务需求,明确性能目标
- 选择合适的垃圾收集器
- 设置合理的堆大小
- 启用 GC 日志和监控
- 代码优化优先,JVM 调优为辅9.1 核心要点回顾
OOM 排查:
1. 先确定类型(heap/Metaspace/Stack/Direct/Thread/GC overhead)
2. 收集现场(堆转储、GC 日志)
3. 分析原因(泄漏 vs 容量不足)
4. 对症下药(代码修复 vs 参数调整)
GC 调优:
1. 明确目标(吞吐 vs 延迟 vs 资源)
2. 选择收集器(Parallel/G1/ZGC)
3. 调整参数(堆大小、停顿目标)
4. 持续监控(GC 日志、性能指标)
核心原则:
1. 分析优于调优
2. 代码优化优先
3. 监控驱动决策
4. 单一变量调整9.2 示例参数配置
## 小型应用(客户端/测试)
-Xms512m -Xmx512m
-XX:+UseSerialGC
-XX:+HeapDumpOnOutOfMemoryError
## 中型应用(服务端,JDK 8)
-Xms4g -Xmx4g
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/heap.hprof
-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/var/log/gc.log
## 大型应用(服务端,JDK 11+)
-Xms8g -Xmx8g
-XX:+UseG1GC
-XX:MaxGCPauseMillis=100
-XX:InitiatingHeapOccupancyPercent=40
-XX:+HeapDumpOnOutOfMemoryError
-Xlog:gc*:file=/var/log/gc.log:time,level:filecount=10,filesize=50m
## 超低延迟(JDK 15+)
-Xms16g -Xmx16g
-XX:+UseZGC
-XX:+UseLargePages
-XX:ConcGCThreads=4
## 容器环境(4GB 限制)
-Xmx3g
-XX:MaxMetaspaceSize=256m
-XX:MaxDirectMemorySize=512m
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
-XX:+HeapDumpOnOutOfMemoryError
-Xlog:gc*:file=/var/log/gc.log:time,level:filecount=5,filesize=20m9.3 调优工具清单
| 工具 | 用途 | 使用场景 |
|---|---|---|
| jstat | 查看 GC 统计 | 实时监控 GC 情况 |
| jmap | 生成堆转储 | OOM 排查 |
| jstack | 查看线程栈 | 线程问题排查 |
| jcmd | JVM 诊断工具 | 综合诊断 |
| MAT | 堆转储分析 | 内存泄漏分析 |
| GC Easy | GC 日志分析 | 在线分析 GC 日志 |
| JProfiler | 性能分析 | 对象分配、热点分析 |
| Arthas | 在线诊断 | 生产环境实时诊断 |
版本差异(旧版 → Java 21)
| 特性 | 旧版(JDK 8/11) | Java 21 |
|---|---|---|
| 默认收集器 | G1(9+) | G1(不变) |
| CMS | 可用 | 已移除(JDK 14,JEP 363) |
| ZGC | 非分代(15 正式) | 分代 ZGC(JEP 439),默认推荐 |
| Shenandoah | 可选 | 可选;低延迟备选 |
| GC 日志 | -Xloggc(8) | -Xlog:gc*(9+,不变) |
| 调优指标 | 吞吐量/延迟/内存 | 不变;新增分代 ZGC 的 young/old 调参 |
参考资料
- Oracle Java Documentation - Garbage Collection Tuning
- OpenJDK ZGC Documentation
- G1 Garbage Collector in Java
- 《Java Performance》- Scott Oaks
- 《深入理解 Java 虚拟机》- 周志明