Spring Boot 内存占用暴涨排查与堆外内存泄漏分析
大家好,今天我们来聊聊 Spring Boot 应用中内存占用暴涨以及堆外内存泄漏的排查技巧。这个问题在生产环境中非常常见,也比较棘手,因为它可能导致应用性能下降,甚至崩溃。我们将从常见的内存占用模式入手,逐步深入到堆外内存泄漏的排查和定位。
常见内存占用模式:问题与应对
首先,我们需要了解 Spring Boot 应用中内存占用的一些常见模式。这些模式并不一定都是问题,但了解它们有助于我们更快地定位真正的瓶颈。
1. 大对象分配:
应用需要处理大量数据,例如读取大文件、处理大型数据库查询结果等,导致 JVM 堆中分配大量大对象。
应对策略:
- 流式处理: 避免一次性加载所有数据到内存。使用流式处理(例如
java.util.stream)逐行或分块处理数据。 - 分页查询: 对于数据库查询,使用分页查询限制每次加载的数据量。
- 对象池: 对于频繁创建和销毁的大对象,考虑使用对象池来复用对象,减少垃圾回收的压力。
代码示例 (流式处理):
try (BufferedReader reader = new BufferedReader(new FileReader("large_file.txt"))) {
reader.lines()
.forEach(line -> {
// 处理每一行数据
processLine(line);
});
} catch (IOException e) {
e.printStackTrace();
}
private void processLine(String line) {
// 实际处理逻辑
System.out.println("Processing line: " + line);
}
代码示例 (分页查询):
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
Page<User> findAll(Pageable pageable);
}
@Service
public class UserService {
@Autowired
private UserRepository userRepository;
public Page<User> getUsers(int page, int size) {
Pageable pageable = PageRequest.of(page, size);
return userRepository.findAll(pageable);
}
}
2. 缓存未有效管理:
使用缓存(例如 Redis, Caffeine)来提高应用性能,但缓存的容量没有限制或者过期策略不合理,导致缓存无限增长,最终耗尽内存。
应对策略:
- 设置最大容量: 为缓存设置最大容量,防止无限增长。
- 设置过期时间: 为缓存中的数据设置合理的过期时间,自动清理过期数据。
- 淘汰策略: 选择合适的淘汰策略(例如 LRU, LFU)来清理缓存中的数据。
- 监控缓存命中率: 监控缓存命中率,根据实际情况调整缓存配置。
代码示例 (Caffeine缓存配置):
@Configuration
public class CacheConfig {
@Bean
public CaffeineCacheManager caffeineCacheManager() {
CaffeineCacheManager cacheManager = new CaffeineCacheManager();
cacheManager.setCaffeine(
Caffeine.newBuilder()
.maximumSize(1000) // 设置最大容量为 1000
.expireAfterWrite(60, TimeUnit.MINUTES) // 设置过期时间为 60 分钟
);
return cacheManager;
}
}
3. 字符串拼接不当:
在循环中频繁使用 + 或 StringBuilder 进行字符串拼接,导致创建大量的临时字符串对象。
应对策略:
- 使用
StringBuilder或StringBuffer: 在循环中进行字符串拼接时,使用StringBuilder(单线程)或StringBuffer(多线程)来避免创建大量的临时字符串对象。 - 避免在循环中创建字符串对象: 尽量在循环外部创建字符串对象,然后在循环中修改其内容。
代码示例:
// 不好的代码
String result = "";
for (int i = 0; i < 1000; i++) {
result += "item" + i; // 每次循环都会创建一个新的字符串对象
}
// 好的代码
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 1000; i++) {
sb.append("item").append(i);
}
String result = sb.toString();
4. 集合类使用不当:
使用 ArrayList 或 HashMap 等集合类时,没有预估集合的大小,导致集合在扩容时频繁分配内存。
应对策略:
- 预估集合大小: 在创建集合时,预估集合的大小,并设置初始容量,避免频繁扩容。
- 选择合适的集合类: 根据实际需求选择合适的集合类。例如,如果需要线程安全,可以使用
ConcurrentHashMap或CopyOnWriteArrayList。
代码示例:
// 预估集合大小
List<String> list = new ArrayList<>(1000); // 预估集合大小为 1000
Map<String, String> map = new HashMap<>(1000); // 预估集合大小为 1000
5. 日志级别设置过高:
将日志级别设置为 DEBUG 或 TRACE,导致输出大量的日志信息,占用大量的内存。
应对策略:
- 调整日志级别: 根据实际需求调整日志级别。在生产环境中,通常建议将日志级别设置为
INFO或WARN。 - 控制日志输出: 控制日志输出的内容,避免输出不必要的日志信息。
代码示例:
// application.properties
logging.level.root=INFO // 设置根日志级别为 INFO
6. 数据库连接池配置不合理:
数据库连接池配置过大,导致创建大量的数据库连接,占用大量的内存。
应对策略:
- 调整连接池大小: 根据实际并发量调整连接池大小。
- 设置最大连接数: 设置连接池的最大连接数,防止连接池无限增长。
- 设置连接超时时间: 设置连接超时时间,避免长时间占用连接。
代码示例 (HikariCP 配置):
spring.datasource.hikari.maximum-pool-size=10 // 设置最大连接数为 10
spring.datasource.hikari.connection-timeout=30000 // 设置连接超时时间为 30 秒
以上是一些常见的内存占用模式。在实际排查问题时,需要结合应用的具体情况进行分析。接下来,我们将重点介绍堆外内存泄漏的排查技巧。
堆外内存泄漏:原因与排查
堆外内存是指 JVM 堆之外的内存,例如 Direct Memory、Native Memory 等。堆外内存泄漏通常比堆内内存泄漏更难排查,因为它不容易被垃圾回收器管理。
1. 堆外内存泄漏的常见原因:
- Direct Memory 泄漏:
ByteBuffer.allocateDirect()分配的 Direct Memory 没有被及时释放。 - NIO Buffer 泄漏: 使用 NIO 进行 IO 操作时,如果没有正确释放 Buffer,会导致内存泄漏。
- JNI 代码泄漏: 使用 JNI 调用 Native 代码时,如果 Native 代码没有正确释放内存,会导致内存泄漏。
- 第三方库泄漏: 使用的第三方库可能存在内存泄漏问题。
2. 堆外内存泄漏的排查工具:
jcmd: JVM 自带的命令行工具,可以用于获取 JVM 的各种信息,包括内存使用情况。jmap: JVM 自带的命令行工具,可以用于生成 Heap Dump 文件,分析堆内存使用情况。虽然不能直接分析堆外内存,但可以辅助分析。NMT (Native Memory Tracking): JVM 自带的 Native Memory Tracking 工具,可以用于跟踪 Native Memory 的使用情况。VisualVM: 一个图形化的 JVM 监控工具,可以用于监控 JVM 的各种指标,包括内存使用情况。MAT (Memory Analyzer Tool): 一个强大的 Heap Dump 分析工具,可以用于分析堆内存泄漏。
3. 使用 NMT 排查 Direct Memory 泄漏:
NMT (Native Memory Tracking) 是一个非常有用的工具,可以帮助我们定位 Native Memory 泄漏。
步骤:
-
启用 NMT:
在 JVM 启动参数中添加
-XX:NativeMemoryTracking=summary或-XX:NativeMemoryTracking=detail。summary模式提供概要信息,detail模式提供更详细的信息。建议在排查问题时使用detail模式。 -
获取 NMT 数据:
使用
jcmd <pid> VM.native_memory summary或jcmd <pid> VM.native_memory detail命令获取 NMT 数据。其中<pid>是 JVM 进程的 ID。 -
分析 NMT 数据:
分析 NMT 数据,找到内存占用增长最快的区域。通常,Direct Memory 泄漏会导致
Direct Buffers区域的内存占用快速增长。 -
定位泄漏代码:
根据 NMT 数据中的线程 ID 和分配大小,结合代码分析,定位泄漏的代码。
代码示例 (Direct Memory 泄漏):
import java.nio.ByteBuffer;
public class DirectMemoryLeak {
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 100000; i++) {
ByteBuffer buffer = ByteBuffer.allocateDirect(1024 * 1024); // 分配 1MB 的 Direct Memory
// 没有释放 buffer
Thread.sleep(1);
}
}
}
分析 NMT 数据示例:
假设我们运行了上面的代码,并使用 jcmd <pid> VM.native_memory detail 命令获取了 NMT 数据。我们可能会看到类似以下的输出:
Native Memory Tracking:
Total: reserved=1055335KB, committed=1055335KB
- Java Heap (reserved=262144KB, committed=262144KB)
(mmap: reserved=262144KB, committed=262144KB)
- Class (reserved=10657KB, committed=10657KB)
(classes #425 total, #425 loaded)
(malloc=857KB #6812)
(mmap: reserved=9800KB, committed=9800KB)
- Thread (reserved=15360KB, committed=15360KB)
(thread #16 total, #0 daemon)
(malloc=14848KB #282)
(stack: reserved=512KB, committed=512KB)
- Code (reserved=24900KB, committed=24900KB)
(malloc=1536KB #3063)
(mmap: reserved=23364KB, committed=23364KB)
- GC (reserved=7567KB, committed=7567KB)
(malloc=4067KB #89)
(mmap: reserved=3500KB, committed=3500KB)
- Compiler (reserved=131KB, committed=131KB)
(malloc=131KB #635)
- Internal (reserved=1266KB, committed=1266KB)
(malloc=1266KB #2465)
- Other (reserved=132KB, committed=132KB)
(malloc=132KB #25)
- Direct Buffers (reserved=754976KB, committed=754976KB) <-- 注意这里
(malloc: reserved=754976KB, committed=754976KB)
- Shared memory (reserved=12288KB, committed=12288KB)
(mmap: reserved=12288KB, committed=12288KB)
- Arena Chunk (reserved=117KB, committed=117KB)
(malloc=117KB)
- Symbol Table (reserved=1100KB, committed=1100KB)
(malloc=1100KB)
- Synchronizer (reserved=102KB, committed=102KB)
(malloc=102KB)
- Metaspace (reserved=38796KB, committed=38796KB)
(mmap: reserved=38796KB, committed=38796KB)
- String Deduplication (reserved=0KB, committed=0KB)
(malloc=0KB)
从输出中可以看到,Direct Buffers 区域的内存占用非常大,并且还在不断增长。这说明可能存在 Direct Memory 泄漏。
接下来,我们可以进一步分析 NMT 数据,找到分配 Direct Memory 的线程 ID,然后结合代码分析,定位泄漏的代码。
4. 使用 MAT 分析 Heap Dump 文件:
虽然 MAT 主要用于分析堆内存,但它也可以辅助分析堆外内存泄漏。例如,如果 Direct Memory 泄漏导致大量的 DirectByteBuffer 对象无法被回收,MAT 可以帮助我们找到这些对象,并分析它们的引用链,从而定位泄漏的代码。
步骤:
-
生成 Heap Dump 文件:
使用
jmap -dump:live,format=b,file=heapdump.bin <pid>命令生成 Heap Dump 文件。其中<pid>是 JVM 进程的 ID。 -
使用 MAT 打开 Heap Dump 文件:
使用 MAT 打开 Heap Dump 文件。
-
查找
DirectByteBuffer对象:在 MAT 中,使用 OQL 查询语句
SELECT * FROM java.nio.DirectByteBuffer查找所有的DirectByteBuffer对象。 -
分析引用链:
分析
DirectByteBuffer对象的引用链,找到持有这些对象的代码,并分析是否存在泄漏。
5. 其他排查技巧:
- 代码审查: 仔细审查代码,特别是涉及 Direct Memory、NIO、JNI 等操作的代码,检查是否存在内存泄漏的风险。
- 监控: 监控 JVM 的内存使用情况,包括堆内存和堆外内存,及时发现内存泄漏的迹象。
- 压力测试: 使用压力测试工具模拟高并发场景,加速内存泄漏的发生,方便排查问题。
案例分析:解决一起实际的堆外内存泄漏问题
假设我们的 Spring Boot 应用使用了 Netty 框架进行网络通信,并且出现了堆外内存泄漏的问题。经过排查,我们发现是由于 Netty 的 ByteBuf 没有被及时释放导致的。
代码示例 (Netty ByteBuf 泄漏):
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
public class NettyByteBufLeak {
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 100000; i++) {
ByteBuf buffer = Unpooled.buffer(1024); // 分配 1KB 的 ByteBuf
buffer.writeBytes("Hello, Netty!".getBytes());
// 没有释放 buffer
Thread.sleep(1);
}
}
}
解决方案:
在使用完 ByteBuf 后,必须调用 release() 方法释放它。Netty 使用引用计数来管理 ByteBuf 的生命周期,如果忘记释放 ByteBuf,会导致内存泄漏。
修复后的代码:
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
public class NettyByteBufLeakFixed {
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 100000; i++) {
ByteBuf buffer = Unpooled.buffer(1024); // 分配 1KB 的 ByteBuf
try {
buffer.writeBytes("Hello, Netty!".getBytes());
} finally {
buffer.release(); // 释放 ByteBuf
}
Thread.sleep(1);
}
}
}
通过添加 finally 块,确保 ByteBuf 在任何情况下都能被释放,避免内存泄漏。
总结
内存占用暴涨和堆外内存泄漏是 Spring Boot 应用中常见的问题。通过了解常见的内存占用模式,使用合适的排查工具,并结合代码分析,我们可以有效地定位和解决这些问题。掌握这些技巧,可以帮助我们构建更稳定、更高效的 Spring Boot 应用。