{T}

GC 调优决策

OOM 与 GC 调优决策

一、OOM 类型深度解析

1.1 OOM 类型总览

code
┌────────────────────────────────────────────────────────────────────────────┐
│                        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 分类理解:

分类含义代表类型
堆内存 OOMJVM 堆空间不足Java heap space、GC overhead
非堆内存 OOMJVM 管理的非堆区域不足Metaspace、StackOverflow
本地内存 OOM操作系统本地内存不足Direct buffer、Thread

1.2 Java heap space —— 堆内存溢出

错误现象
code
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. 缓存无限增长

java
// 危险示例:无界缓存
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)一般指需要大量连续内存空间的对象:

java
// 大对象示例
byte[] data = new byte[100 * 1024 * 1024];  // 100MB 数组
List<Order> orders = orderDao.findAll();    // 百万级数据一次性加载
String content = Files.readString(path);     // 超大文件一次性读取

大对象的问题:

  • 直接进入老年代(或 Humongous Region),占用老年代空间
  • 触发提前的 Full GC
  • 内存碎片化严重,难以分配新的大对象

解决方案:

java
// 错误:一次性加载大文件
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));
排查方法详解

步骤一:确认问题

bash
## 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 总时间

步骤二:获取堆转储文件

bash
## 方式一:启动时配置自动生成(推荐)
-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 分析步骤:

  1. 打开 .hprof 文件

  2. 查看 Leak Suspects Report(泄漏嫌疑报告)

    code
    Problem Suspect 1:
    The class java.util.HashMap$Node, loaded by <system class loader>, 
    occupies 1.5GB (45%) of the heap.
  3. 查看 Dominator Tree(支配树)

    • 找到占用内存最大的对象
    • 右键 → Path to GC Roots → exclude weak/soft references
  4. 使用 Histogram(直方图)

    • 按类统计对象数量
    • 找到异常的对象数量
  5. 分析引用链

    code
    OrderCache (静态变量)
    └── ConcurrentHashMap$Node[1200000]
        └── Order 对象(120万个,每个 2KB)
            ├── orderId: Long
            ├── items: List<OrderItem>
            └── customer: Customer

步骤四:定位代码位置

java
// 在 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、复用对象

代码优化示例:

java
// 问题 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 —— 元空间溢出

错误现象
code
java.lang.OutOfMemoryError: Metaspace

元空间是 JDK 8 引入的概念,用于替代永久代(PermGen),存储类的元数据。元空间使用本地内存,不占用堆内存。

元空间存储内容
code
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. 动态代理类无限生成

java
// 问题代码示例
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 字节码增强

java
// 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 最常见的原因:

code
应用服务器(Tomcat/WebLogic)热部署场景:

WebAppClassLoader
    ├── 加载应用类 A、B、C...
    └── 被某个线程持有引用

热部署时:
    ├── 创建新的 WebAppClassLoader
    ├── 旧的 ClassLoader 应该被回收
    └── 但如果有线程仍持有旧 ClassLoader 引用
        └── 旧 ClassLoader 无法回收
            └── 其加载的所有类都在 Metaspace 中无法释放
                └── 多次热部署后 Metaspace 耗尽

常见泄漏场景:

java
// 场景 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 参数

bash
## 查看当前 Metaspace 配置
jinfo -flags <pid> | grep -i meta

## 关键参数
-XX:MetaspaceSize=128m      # 初始元空间大小(初始水位线)
-XX:MaxMetaspaceSize=256m   # 最大元空间大小(限制)

步骤二:监控 Metaspace 使用情况

bash
## 使用 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

步骤三:获取类加载信息

bash
## 查看已加载类数量
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

步骤四:分析堆转储

bash
## 生成堆转储(包含类信息)
jmap -dump:format=b,file=heap.hprof <pid>

## 在 MAT 中分析:
## 1. 查看 Class Loader Explorer
## 2. 找到重复的 ClassLoader
## 3. 分析类加载器引用链
解决方案

方案一:调整 Metaspace 大小

bash
## 根据应用规模设置合理的上限
-XX:MetaspaceSize=256m      # 初始大小,避免频繁 Full GC
-XX:MaxMetaspaceSize=512m   # 最大限制,防止无限制增长

## 启用类卸载(G1)
-XX:+ClassUnloadingWithConcurrentMark

## 启用压缩类指针(减少 Metaspace 使用)
-XX:+UseCompressedClassPointers
-XX:CompressedClassSpaceSize=1g

方案二:解决类加载器泄漏

java
// 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;
    }
}

方案三:监控和预警

java
// 使用 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 —— 栈溢出

错误现象
code
java.lang.StackOverflowError

栈溢出不属于 OOM,但经常与之混淆。它表示线程栈空间耗尽,通常由递归调用过深或栈帧过大引起。

根本原因分析

1. 无限递归

java
// 典型的无限递归
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. 相互调用死循环

java
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. 复杂数据结构递归

java
// 树形结构深度遍历
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. 栈帧过大

java
// 问题:局部变量过多或过大
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];
    // 栈上只存储引用,占用很小
}
排查方法

步骤一:查看异常堆栈

bash
## 异常堆栈会显示递归调用的方法
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 查看线程栈

bash
## 打印线程栈
jstack <pid>

## 查看特定线程
jstack <pid> | grep -A 100 "thread-name"

步骤三:调整栈大小

bash
## 查看当前栈大小
jinfo -flag ThreadStackSize <pid>

## 调整栈大小
-Xss512k   # 每个线程栈 512KB(默认 1MB)
-Xss2m     # 每个线程栈 2MB
解决方案
场景解决方案具体措施
无限递归添加正确的终止条件检查递归退出条件
递归深度过大改为迭代实现或尾递归优化使用循环、栈模拟递归
相互调用重构代码,消除循环依赖使用中介者模式、依赖注入
栈帧过大减少局部变量,使用堆存储大数组改为堆分配
栈空间不足适当增大 -Xss权衡线程数和内存占用

代码优化示例:

java
// 问题:深度递归
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 —— 直接内存溢出

错误现象
code
java.lang.OutOfMemoryError: Direct buffer memory

直接内存(Direct Memory)是堆外内存,不受 JVM 堆大小限制。当使用 NIO 或 Netty 等框架时,大量使用直接内存可能耗尽本地内存。

直接内存原理
code
Java NIO 直接内存模型:

┌─────────────────────────────────────┐
│         JVM 堆内存                    │
│  ┌──────────────────────────────┐   │
│  │  HeapByteBuffer (引用)        │   │
│  │  - 持有 DirectByteBuffer 引用  │   │
│  └──────────────────────────────┘   │
└─────────────────────────────────────┘
                 │
                 ▼
┌─────────────────────────────────────┐
│         本地内存(堆外)              │
│  ┌──────────────────────────────┐   │
│  │  DirectByteBuffer            │   │
│  │  - address: 内存地址          │   │
│  │  - capacity: 容量             │   │
│  │  - 实际数据存储区域            │   │
│  └──────────────────────────────┘   │
└─────────────────────────────────────┘

直接内存优势:

  • 减少内存复制:避免 JVM 堆与操作系统堆之间的数据拷贝
  • 提高 IO 性能:适合大文件读写、网络传输
  • 不受 GC 影响:生命周期独立于 GC

直接内存劣势:

  • 分配和释放成本高
  • 不受 JVM 管理,难以监控
  • 泄漏后难以排查
根本原因分析

1. 直接内存未正确释放

java
// 问题代码: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 泄漏

java
// 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=ADVANCED

3. 直接内存限制过低

bash
## 默认直接内存限制 = Xmx
## 如果同时使用堆内存和直接内存,可能不足

## 示例:
-Xmx4g                    # 堆内存 4GB
-XX:MaxDirectMemorySize=2g  # 直接内存限制 2GB
## 总内存占用:堆 + 直接内存 + Metaspace + 线程栈 ≈ 7GB+
排查方法

步骤一:监控直接内存使用

java
// 使用 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 泄漏

bash
## 启用 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

bash
## 启用 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 最佳实践:

java
// 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 —— 线程资源耗尽

错误现象
code
java.lang.OutOfMemoryError: unable to create new native thread

这类问题不一定是"堆太小",而是系统资源耗尽:线程数达到上限、进程数限制、内存不足等。

根本原因分析

1. 线程泄漏

java
// 问题代码:线程池未正确关闭
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. 线程数配置不当

java
// 问题:无界线程池
ExecutorService executor = Executors.newCachedThreadPool();
// 可创建 Integer.MAX_VALUE 个线程

// 正确:有界线程池
ExecutorService executor = new ThreadPoolExecutor(
    10,                                 // 核心线程数
    100,                                // 最大线程数
    60L, TimeUnit.SECONDS,              // 空闲线程存活时间
    new LinkedBlockingQueue<>(1000),    // 工作队列
    new ThreadPoolExecutor.CallerRunsPolicy()  // 拒绝策略
);

3. 系统资源限制

bash
## 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.max

4. 每个线程占用栈内存

code
计算线程内存占用:
线程数 × 栈大小 = 总栈内存

例如:
- 1000 线程 × 1MB 栈 = 1GB 本地内存
- 10000 线程 × 1MB 栈 = 10GB 本地内存

这会与 JVM 堆、Metaspace、直接内存竞争系统内存。
排查方法

步骤一:统计线程数量

bash
## 使用 jstack 统计线程数
jstack <pid> | grep "java.lang.Thread.State" | wc -l

## 使用 jconsole 或 jvisualvm 查看线程

## 使用 jcmd
jcmd <pid> Thread.print

步骤二:分析线程栈

bash
## 查看线程详细信息
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)

步骤三:检查系统限制

bash
## 检查用户进程限制
ulimit -a

## 检查系统资源
cat /proc/sys/kernel/pid_max
cat /proc/sys/kernel/threads-max

## 检查当前进程打开的文件描述符
lsof -p <pid> | wc -l
解决方案
问题解决方案具体措施
线程泄漏正确关闭线程池,使用单例模式shutdown()、shutdownNow()
线程数过多使用有界线程池,合理配置核心/最大线程数ThreadPoolExecutor 参数优化
系统限制过低调整 ulimitpid_max、容器配置增大系统限制
栈内存占用大减小 -Xss注意可能触发 StackOverflowError
任务处理不过来使用异步非阻塞模型Netty、Reactor、CompletableFuture

线程池最佳实践:

java
// 合理配置线程池
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 开销超限

错误现象
code
java.lang.OutOfMemoryError: GC overhead limit exceeded

这是 JVM 的一种自我保护机制。当 JVM 发现花费过多的时间在 GC 上(默认 98%),且回收的内存很少(默认 2%),就会抛出此错误。

触发条件详解
code
触发条件(连续多次 GC 满足):
1. GC 时间占比 > 98%
2. GC 回收内存 < 堆的 2%
3. 这两个条件在连续 5 次 Full GC 中都满足(可配置)

触发机制:
┌─────────────────────────────────────┐
│  应用运行时间: 2%                   │
│  GC 时间: 98%                       │
│  ────────────────────────           │
│  几乎所有时间都在做 GC,但收效甚微  │
│  JVM 判断继续运行已无意义,抛出错误 │
└─────────────────────────────────────┘

这个错误通常是 Java heap space 的前兆,表明堆即将耗尽。

与 Java heap space 的关系
code
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 日志

bash
## 查看最近的 GC 日志
grep "GC overhead" gc.log

## 分析 GC 频率和耗时
grep "Full GC" gc.log | tail -100

步骤二:分析堆使用情况

bash
## 使用 jstat 查看 GC 统计
jstat -gcutil <pid> 1000

## 关注列:
## FGC: Full GC 次数
## FGCT: Full GC 总时间
## GCT: GC 总时间
## 如果 GCT/运行时间 > 20%,说明 GC 开销过大

步骤三:生成堆转储分析

bash
## 生成堆转储
jmap -dump:format=b,file=heap.hprof <pid>

## 使用 MAT 分析
## 查看是否有内存泄漏或对象过多
解决方案

方案一:分析根本原因

bash
## 可以禁用此限制(不推荐)
-XX:-UseGCOverheadLimit

## 更好的做法:解决根本问题
## 1. 分析堆转储文件,查找泄漏
## 2. 优化代码,减少对象创建
## 3. 调整堆大小
## 4. 更换垃圾收集器

方案二:具体优化措施

bash
## 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% 触发并发标记

代码优化示例:

java
// 问题:对象创建过多
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 调优本质上是在平衡三个目标:

code
        吞吐量(Throughput)
             ▲
            ╱ ╲
           ╱   ╲
          ╱     ╲
         ╱       ╲
        ╱         ╲
       ╱           ╲
      ╱             ╲
     ▼───────────────▼
延迟(Latency) ←→ 内存占用(Footprint)

三者关系:

优化方向可能的代价
提高吞吐量可能增加停顿时间
降低延迟可能降低吞吐量,增加内存占用
减少内存占用可能增加 GC 频率,降低吞吐量
重要原则

不存在银弹:没有一种配置能让三者同时达到最优。需要根据业务场景选择优先级:

  • 批处理:吞吐量优先
  • 交互式应用:延迟优先
  • 资源受限:内存占用优先

2.2 核心指标详解

2.2.1 吞吐量(Throughput)

定义: 应用程序运行时间占总时间的比例。

code
吞吐量 = 应用运行时间 / (应用运行时间 + GC 时间) × 100%

示例:
总时间:100 秒
GC 时间:5 秒
应用运行时间:95 秒
吞吐量 = 95 / 100 = 95%

适用场景:

  • 批处理任务
  • 后台计算
  • 不关心响应延迟的服务

优化方向:

  • 增大堆内存,减少 GC 频率
  • 使用 Parallel GC
  • 减少对象创建
2.2.2 延迟(Latency / Pause Time)

定义: GC 造成的应用程序停顿时间(Stop-The-World, STW)。

code
延迟指标:
- 平均停顿时间:所有 GC 停顿的平均值
- 最大停顿时间:单次 GC 最长停顿
- 停顿频率:单位时间内 GC 次数
- P99 停顿:99% 的 GC 停顿时间上限

适用场景:

  • 交互式应用
  • 实时交易系统
  • 在线游戏
  • 高频交易

优化方向:

  • 使用 G1、ZGC 等低延迟收集器
  • 减小堆内存或调整分代比例
  • 优化对象晋升策略
2.2.3 内存占用(Footprint)

定义: JVM 使用的物理内存总量。

code
JVM 内存组成:
├── 堆内存(Heap)
│   ├── 新生代
│   └── 老年代
├── 非堆内存(Non-Heap)
│   ├── Metaspace
│   ├── Code Cache
│   └── Thread Stacks
└── 直接内存(Direct Memory)

权衡:

  • 更大的堆 → GC 频率降低,但停顿时间可能增加
  • 更小的堆 → 内存占用少,但 GC 频繁

2.3 分代假说与 GC 设计

弱分代假说(Weak Generational Hypothesis):

大多数对象都是朝生夕灭的。

强分代假说(Strong Generational Hypothesis):

熬过越多次 GC 的对象越难以消亡。

基于这两个假说,JVM 采用分代收集策略:

code
┌────────────────────────────────────────────────────────┐
│                     JVM 堆                              │
│  ┌─────────────────────────────────┐  ┌──────────────┐ │
│  │           新生代                 │  │    老年代     │ │
│  │  ┌────────┬────────┬────────┐  │  │              │ │
│  │  │   Eden │  S0    │   S1   │  │  │  长期存活对象 │ │
│  │  │  新对象│ Survivor│Survivor│  │  │              │ │
│  │  └────────┴────────┴────────┘  │  │              │ │
│  │      ↑ Minor GC 优先回收        │  │  ↑ Major GC  │ │
│  └─────────────────────────────────┘  └──────────────┘ │
│                                                        │
│  特点:                                               │
│  - 新生代对象生命周期短,回收效率高                   │
│  - 老年代对象生命周期长,回收成本高                   │
│  - 分代回收可以针对不同区域使用不同算法               │
└────────────────────────────────────────────────────────┘

三、垃圾收集器与调优参数

3.1 收集器演进历程

code
时间线:

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+ 的默认收集器。

工作原理
code
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)
- 可预测停顿时间
收集过程
code
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 无法跟上分配速度时触发
   └── 性能急剧下降
关键参数
bash
## 启用 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 调优建议
bash
## 1. 设置合理的停顿目标
-XX:MaxGCPauseMillis=100-300  # 根据业务需求调整

## 2. 不要固定新生代大小
## × 不要设置 -Xmn,会禁用 G1 的自适应调节

## 3. 调整并发标记触发时机
## 如果经常 Full GC,提前触发并发标记
-XX:InitiatingHeapOccupancyPercent=35

## 4. 增加 Mixed GC 频率
## 更积极地回收老年代
-XX:G1MixedGCCountTarget=4

## 5. 处理大对象
## 如果大量大对象导致问题
-XX:G1HeapRegionSize=16m  # 增大 Region

3.4 ZGC —— Z Garbage Collector

ZGC 是 JDK 15+ 推出的新一代垃圾收集器,目标是实现极低延迟。

工作原理
code
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 更优
关键参数
bash
## 启用 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 及以下
bash
## 基础配置
-XX:+PrintGCDetails         # 打印详细 GC 信息
-XX:+PrintGCDateStamps      # 打印时间戳
-Xloggc:/path/to/gc.log     # 输出到文件

## 日志轮转
-XX:+UseGCLogFileRotation
-XX:NumberOfGCLogFiles=5
-XX:GCLogFileSize=20M
JDK 9+(统一日志框架)
bash
## 统一配置格式
-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=50m

4.2 GC 日志格式解析

G1 GC 日志
code
// 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 Easyhttps://gceasy.io/免费在线分析,生成详细报告
GC Viewerhttps://github.com/chewiebug/GC-Viewer开源,本地运行
GC Easy 分析示例

上传 GC 日志后,报告包含:

code
关键指标:
├── 吞吐量: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 排查

问题现象
code
生产环境错误日志:
java.lang.OutOfMemoryError: Java heap space

系统环境:
- JVM: JDK 8, 4GB 堆
- 应用:电商订单服务
- 现象:每天高峰期(10:00-12:00)触发 OOM
排查过程

步骤一:查看 GC 日志

bash
## 发现 Full GC 频繁且无法回收内存
[Full GC (Allocation Failure) [Tenured: 3584K->3584K(3584K), 0.123456 secs]

## 关键发现:Full GC 后内存没有明显下降
## 3584K -> 3584K(无变化)
## 怀疑内存泄漏

步骤二:生成堆转储

bash
## 配置自动生成
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/heap.hprof

## 或手动生成
jmap -dump:format=b,file=/tmp/heap.hprof <pid>

步骤三:MAT 分析

code
MAT 分析结果:

1. 内存泄漏报告
   - 泄漏对象:OrderCache
   - 泄漏大小:2.8 GB
   - 对象数量:1,200,000 个 Order 对象

2. 引用链分析
   OrderCache (静态变量)
   └── ConcurrentHashMap<Long, Order>
       └── Order 对象(120万个)

3. 问题定位
   - OrderCache 是静态全局缓存
   - 订单完成后未从缓存移除
   - 每天新增约 50 万订单,累积导致 OOM

步骤四:代码分析

java
// 问题代码
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);
    }
}
解决方案
java
// 方案一:使用有界缓存(推荐)
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
}
调优效果
code
优化前后对比:

优化前:
- 堆使用:持续上升,直到 OOM
- Full GC:每天 10+ 次,无法回收
- 服务:每天重启一次

优化后:
- 堆使用:稳定在 1.5GB 左右
- Full GC:每周 1-2 次,正常回收
- 服务:稳定运行,无重启

案例 2:社交平台响应慢排查

问题现象
code
问题描述:
- 社交平台首页加载慢,平均响应时间 2s+
- 高峰期 RT 偶尔超过 5s

环境信息:
- JVM: JDK 11, 8GB 堆, G1 GC
- 配置:默认 G1 参数
排查过程

步骤一:监控 GC 情况

bash
## 查看 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+,超出预期

步骤二:分析原因

bash
## 使用 jstat 查看堆使用
jstat -gc <pid> 1000

## 发现问题:老年代占用高
OU(老年代使用) 接近 OC(老年代容量)
## 导致对象快速晋升,Young GC 时复制大量对象

步骤三:分析对象分配

bash
## 使用 JProfiler 分析对象分配
## 发现:
## 1. 短时间内创建大量临时字符串
## 2. JSON 序列化/反序列化产生大量对象
## 3. 数据库查询返回大量对象

步骤四:定位热点代码

java
// 问题代码 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());
}
解决方案
java
// 优化 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 参数优化:

bash
## 原配置
-Xmx8g -XX:+UseG1GC

## 优化后配置
-Xmx8g -XX:+UseG1GC 
-XX:MaxGCPauseMillis=100           # 目标停顿 100ms
-XX:InitiatingHeapOccupancyPercent=40  # 提前触发并发标记
-XX:G1NewSizePercent=30            # 增大新生代最小占比
-XX:G1MaxNewSizePercent=50         # 增大新生代最大占比
调优效果
code
优化前后对比:

指标                  优化前        优化后
─────────────────────────────────────────
平均响应时间          2.3s         0.4s
P99 响应时间          5.6s         0.8s
Young GC 停顿         300ms        50ms
Full GC 频率          每天 2 次     每周 1 次
吞吐量                1200 QPS     3500 QPS

案例 3:微服务 Metaspace OOM

问题现象
code
问题描述:
- 微服务部署后 2-3 天崩溃
- 错误信息:java.lang.OutOfMemoryError: Metaspace
- 重启后恢复,但问题重复出现

环境信息:
- JVM: JDK 11, 2GB 堆, G1 GC
- 应用:Spring Boot 微服务
- 框架:使用动态代理、反射
排查过程
bash
## 查看 Metaspace 使用
jcmd <pid> VM.metaspace

## 输出:
## Total space: 512 MB (100% committed)
## Used: 508 MB
## MaxMetaspaceSize: 512 MB

## Metaspace 几乎满了

分析已加载类:

bash
## 查看类直方图
jcmd <pid> GC.class_histogram | head -50

## 发现大量代理类:
##  124565  com.example.service.$Proxy123
##  124565  com.example.service.$Proxy124
##  ...

## 生成了 12 万+ 代理类!

代码分析:

java
// 问题代码:每次请求创建新代理
@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();
    }
}
解决方案
java
// 优化:缓存代理对象
@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 参数优化:

bash
## 原配置
-Xmx2g -XX:+UseG1GC

## 优化后配置
-Xmx2g -XX:+UseG1GC
-XX:MetaspaceSize=128m              # 初始大小
-XX:MaxMetaspaceSize=512m           # 最大限制
-XX:+ClassUnloadingWithConcurrentMark  # G1 并发类卸载
调优效果
code
优化前后对比:

指标                  优化前        优化后
─────────────────────────────────────────
运行时间              2-3 天崩溃    稳定运行
已加载类数            12 万+        8000
Metaspace 使用        508 MB        128 MB
类加载速率            持续增长       稳定

六、调优决策流程

6.1 调优前必问的问题

在开始调优之前,必须回答以下问题:

code
1. 问题定义
   ├── 具体症状是什么?(OOM、慢、崩溃)
   ├── 何时发生?(高峰期、特定操作)
   ├── 影响范围?(单服务、集群、用户感知)
   └── 频率如何?(持续、偶发、定时)

2. 性能目标
   ├── 吞吐量要求?(QPS、TPS)
   ├── 延迟要求?(平均、P99、P999)
   ├── 资源限制?(CPU、内存、磁盘)
   └── 成本考量?(硬件、人力)

3. 当前状态
   ├── JVM 版本和配置?
   ├── GC 收集器类型?
   ├── 堆大小设置?
   └── 业务特点?(请求量大、对象生命周期)

6.2 调优决策树

code
开始调优
    │
    ▼
是否有 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 调优原则

code
核心原则:

1. 先分析,后调优
   不要一上来就改参数,先收集数据、分析原因

2. 代码优化优先
   很多 JVM 问题根因在代码层面

3. 单一变量原则
   每次只改一个参数,观察效果

4. 增量调整
   小步快跑,逐步优化

5. 监控驱动
   持续监控,数据说话

6. 回归测试
   每次调整后验证功能和性能

7. 记录变更
   记录每次调整和效果,便于回溯

七、常见误区

7.1 误区一:一看到 OOM 就调大堆

code
错误做法:
## 看到 OOM 就加大堆
-Xmx4g → -Xmx8g → -Xmx16g

问题:
1. 可能是内存泄漏,调大堆只是延缓问题
2. 更大的堆意味着更长的 GC 停顿
3. 可能掩盖真正的代码问题

正确做法:
1. 分析堆转储,确认是否泄漏
2. 如果是泄漏,修复代码
3. 如果确实需要更多内存,再调整

7.2 误区二:GC 频繁只怪 JVM

code
错误认知:
"GC 太频繁了,JVM 需要调优"

可能的真实原因:
1. 对象创建过多(代码问题)
2. 内存泄漏(代码问题)
3. 堆大小配置不合理(配置问题)
4. 业务流量突增(容量问题)

正确做法:
1. 分析对象分配速率
2. 检查是否有泄漏
3. 确认业务流量是否合理
4. 最后才考虑 JVM 参数

7.3 误区三:把调优等同于改参数

code
错误认知:
"调优就是改 JVM 启动参数"

调优的层次:

Level 1: 架构调优(影响最大)
         ├── 分布式架构优化
         ├── 数据库分库分表
         └── 缓存策略优化

Level 2: 代码调优
         ├── 算法优化
         ├── 数据结构选择
         ├── 减少对象创建
         └── 并发优化

Level 3: JVM 调优
         ├── 收集器选择
         ├── 堆大小设置
         └── GC 参数调整

Level 4: OS/硬件调优
         ├── 内存配置
         ├── CPU 绑核
         └── 磁盘 I/O

正确做法:
按照从高到低的顺序,优先考虑高层次的优化

7.4 误区四:容器内存 = JVM 堆

code
错误配置:
Docker 容器限制 4GB 内存
JVM 配置:-Xmx4g

问题:
JVM 使用的内存 ≠ 堆内存
实际内存 = 堆 + Metaspace + 线程栈 + 直接内存 + Code Cache + ...

容器 OOM → 进程被 kill

正确配置:
容器限制 4GB
JVM 配置:-Xmx3g(预留 ~25% 给其他内存)

推荐配置:
-Xmx${CONTAINER_MEMORY:-4096}m
-XX:MaxMetaspaceSize=256m
-XX:MaxDirectMemorySize=512m

8.1 基础问题

Q1:常见 OOM 类型有哪些,排查方向分别是什么?

code
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 调优本质上是吞吐、延迟、内存占用的权衡?

code
三者相互制约:

吞吐量 ↑ → 需要更少的 GC 次数 → 可能需要更大的堆 → 内存占用 ↑
延迟 ↓   → 需要 GC 快速完成 → 可能需要更小的堆 → GC 频繁 → 吞吐量 ↓
内存占用 ↓ → 堆变小 → GC 频繁 → 停顿增加 → 吞吐量 ↓

不存在三者同时最优的方案,需要根据业务场景选择优先级:
- 批处理:吞吐量优先
- 交互式应用:延迟优先
- 资源受限:内存占用优先

Q3:内存泄漏和内存溢出的区别?

code
内存泄漏(Memory Leak):
- 定义:程序中存在某些对象不再被使用,但无法被回收
- 特点:内存逐渐累积,最终导致 OOM
- 原因:静态集合、监听器未注销、ThreadLocal 未清理等
- 解决:修复代码,释放无用引用

内存溢出(OutOfMemory):
- 定义:内存不足,无法分配新对象
- 特点:可能是泄漏导致,也可能是配置不合理
- 原因:堆太小、对象过多、泄漏等
- 解决:增大内存或修复泄漏

关系:
内存泄漏 → 内存逐渐耗尽 → 内存溢出(OOM)

8.2 进阶问题

Q4:如何选择合适的垃圾收集器?

code
决策依据:

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,大堆低延迟用 ZGC

Q5:什么情况下需要调大新生代?什么情况下需要调小?

code
调大新生代的场景:
1. Young GC 频繁(每秒多次)
2. 对象存活率低(大多数对象朝生夕灭)
3. 吞吐量要求高(减少 GC 次数)

调小新生代的场景:
1. Young GC 停顿过长
2. 单次 Young GC 时间超过可接受范围
3. 对象存活率高(需要快速晋升)

注意:G1 不建议手动设置新生代大小
G1 会根据停顿目标自动调整

Q6:G1 GC 为什么会退化为 Full GC?如何避免?

code
退化原因:
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?

code
排查步骤:

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,如何快速定位问题?

code
快速定位流程:

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?

code
监控手段:

1. JVM 监控
   - JMX 监控堆使用率、GC 频率
   - Prometheus + Grafana 可视化
   - Arthas 实时监控

2. 日志监控
   - GC 日志分析
   - 错误日志告警
   - 使用 ELK 收集和分析

3. 系统监控
   - CPU、内存、磁盘使用率
   - 线程数、网络连接数
   - 容器资源限制

预防措施:

1. 代码审查
   - 检查静态集合、缓存使用
   - 检查资源关闭(Connection、Stream)
   - 检查 ThreadLocal 清理

2. 压力测试
   - 模拟高并发场景
   - 长时间稳定性测试
   - 内存泄漏检测

3. 设置合理的 JVM 参数
   - 根据业务需求设置堆大小
   - 启用 GC 日志
   - 配置堆转储自动生成

4. 定期巡检
   - 分析 GC 日志
   - 检查内存使用趋势
   - 及时发现潜在问题

Q10:谈谈你对 JVM 调优的理解?

code
核心理解:

1. 调优是最后手段
   - 架构优化 > 代码优化 > JVM 调优
   - 大多数问题可以通过代码优化解决
   - 不要一上来就调 JVM 参数

2. 数据驱动
   - 先收集数据,再分析,最后调优
   - 使用监控工具,避免凭感觉调优
   - 记录每次调整和效果

3. 权衡取舍
   - 吞吐量、延迟、内存占用无法同时最优
   - 根据业务场景选择优先级
   - 没有银弹,只有最适合的方案

4. 持续监控
   - 调优不是一次性工作
   - 业务变化可能需要重新调优
   - 建立完善的监控体系

5. 预防胜于治疗
   - 代码质量是根本
   - 压力测试提前发现问题
   - 合理的容量规划

最佳实践:
- 理解业务需求,明确性能目标
- 选择合适的垃圾收集器
- 设置合理的堆大小
- 启用 GC 日志和监控
- 代码优化优先,JVM 调优为辅

9.1 核心要点回顾

code
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 示例参数配置

bash
## 小型应用(客户端/测试)
-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=20m

9.3 调优工具清单

工具用途使用场景
jstat查看 GC 统计实时监控 GC 情况
jmap生成堆转储OOM 排查
jstack查看线程栈线程问题排查
jcmdJVM 诊断工具综合诊断
MAT堆转储分析内存泄漏分析
GC EasyGC 日志分析在线分析 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 调参

参考资料