Java内存溢出(OOM)避坑指南:三个典型案例深度解析
自动垃圾收集器不是万能的,这些隐蔽的OOM陷阱你遇到过吗?
Java的自动垃圾收集器(GC)让我们能专注于业务逻辑,但内存溢出(OOM)依然是生产环境中常见的"隐形杀手"。本文通过三个真实案例,揭示业务代码中容易忽略的OOM场景,并给出解决方案。我们将使用关键代码和Mermaid图直观展示问题本质,帮助你从根本上避免类似问题。
案例一:太多份相同的对象导致OOM
场景描述
某项目需要实现用户名的自动补全功能(类似搜索框的联想提示)。开发同学设计了一个内存缓存:以用户名的所有前缀作为Key,Value是对应前缀的用户列表。例如用户"aa"和"ab"会生成Key"a"、"aa"、"ab",输入"a"时就能返回两个用户。
问题代码
java
private ConcurrentHashMap<String, List<UserDTO>> autoCompleteIndex = new ConcurrentHashMap<>();
@PostConstruct
public void wrong() {
// 从数据库加载所有用户(假设1万个)
userRepository.findAll().forEach(userEntity -> {
int len = userEntity.getName().length();
// 为每个用户名的前1~N位创建索引
for (int i = 0; i < len; i++) {
String key = userEntity.getName().substring(0, i + 1);
autoCompleteIndex.computeIfAbsent(key, s -> new ArrayList<>())
.add(new UserDTO(userEntity.getName())); // 每次都new对象
}
});
}
每个UserDTO除了用户名还包含10KB的模拟数据。运行后,1万个用户却生成了6万个UserDTO对象,占用约1.2GB内存,远超预期。
问题分析
虽然只有1万个真实用户,但每个用户名平均长度6位,因此产生了6万个索引条目,每个条目都创建了新的UserDTO对象,导致内存中对象数量膨胀6倍。
原始方案的对象关系图:
用户1: abc
Key: a
List
UserDTO 副本1
Key: ab
List
UserDTO 副本2
Key: abc
List
UserDTO 副本3
用户2: abd
Key: a
List
UserDTO 副本4
Key: ab
List
UserDTO 副本5
Key: abd
List
UserDTO 副本6
解决方案
使用HashSet去重,确保每个用户只保留一份UserDTO,所有索引Key的List都引用同一份对象。
java
@PostConstruct
public void right() {
// 先构建去重的用户缓存
HashSet<UserDTO> cache = userRepository.findAll().stream()
.map(item -> new UserDTO(item.getName()))
.collect(Collectors.toCollection(HashSet::new));
// 构建索引时共享对象
cache.forEach(userDTO -> {
int len = userDTO.getName().length();
for (int i = 0; i < len; i++) {
String key = userDTO.getName().substring(0, i + 1);
autoCompleteIndex.computeIfAbsent(key, s -> new ArrayList<>())
.add(userDTO); // 共享同一对象
}
});
}
优化后的对象关系图:
用户1: abc
HashSet
UserDTO 唯一对象
用户2: abd
UserDTO 唯一对象
Key: a
List
Key: ab
List
Key: abc
List
Key: a
List
Key: ab
List
Key: abd
List
优化后,内存占用降至不足200MB,对象数量从6万减至约1万。
教训
- 容量评估时不能想当然地认为"一份数据在内存中也是一份"。经过框架转换、多次复制,内存占用可能成倍增长。
- 使用集合缓存时,务必考虑对象复用,避免重复创建。
案例二:使用WeakHashMap不等于不会OOM
场景描述
开发者想用WeakHashMap作为缓存,认为当Key不再被外部引用时,Entry会自动被GC回收,避免内存堆积。于是实现了如下代码,缓存200万个用户资料。
java
private Map<User, UserProfile> cache = new WeakHashMap<>();
@GetMapping("wrong")
public void wrong() {
String userName = "zhuye";
LongStream.rangeClosed(1, 2000000).forEach(i -> {
User user = new User(userName + i);
cache.put(user, new UserProfile(user, "location" + i));
});
}
运行后却发现cache.size()始终是200万,最终导致OOM。
问题分析
WeakHashMap的Key是弱引用,但ValueUserProfile却持有User对象的强引用(通过其user字段)。这导致即使外部的user变量不再使用,User对象仍然被UserProfile引用,无法被GC回收。
引用关系图(问题版):
WeakHashMap
弱引用
强引用
强引用
Entry
WeakReference
User对象
UserProfile对象
当GC发生时,Key(User)只有弱引用,本应被回收,但因Value中的强引用,整个Entry无法从ReferenceQueue中移除,导致内存泄漏。
解决方案
让Value也使用弱引用包装,切断Value对Key的强引用链。
java
private Map<User, WeakReference<UserProfile>> cache2 = new WeakHashMap<>();
@GetMapping("right")
public void right() {
String userName = "zhuye";
LongStream.rangeClosed(1, 2000000).forEach(i -> {
User user = new User(userName + i);
cache2.put(user, new WeakReference<>(new UserProfile(user, "location" + i)));
});
}
或者重新创建User对象,使UserProfile不再引用原来的Key:
java
cache.put(user, new UserProfile(new User(user.getName()), "location" + i));
优化后的引用关系:
WeakHashMap
弱引用
强引用
弱引用
不再引用key
Entry
WeakReference
User对象
WeakReference<UserProfile>
UserProfile对象
现在,当Key只被弱引用时,GC可以回收Key,同时WeakReference<UserProfile>也会被回收,Entry最终被清除。
补充
Spring提供的ConcurrentReferenceHashMap支持Key和Value同时使用软引用或弱引用,线程安全且性能更好,是更优的选择。
案例三:Tomcat参数配置不合理导致OOM
场景描述
某应用在业务高峰期频繁出现OOM,堆Dump显示有大量1.7GB的byte数组,占满了2GB的堆内存。分析发现,这些数组来自Tomcat的工作线程。
问题代码
查看项目配置,发现有人修改了Tomcat的max-http-header-size参数:
properties
server.max-http-header-size=10000000
起因是开发遇到了java.lang.IllegalArgumentException: Request header is too large异常,搜索后简单地将该参数改为一个超大值(10MB),期望永远不再报错。
问题分析
Tomcat的Http11InputBuffer和Http11OutputBuffer会根据max-http-header-size分配固定大小的缓冲区。该配置导致每个请求的Request和Response各占用约10MB内存(实际InputBuffer稍大)。假设有100个工作线程,仅缓冲区就占用近2GB,加上业务对象,很容易OOM。
请求处理内存分配示意图:
Tomcat线程池
每个请求
Http11InputBuffer
ByteBuffer 约10MB
Http11OutputBuffer
ByteBuffer 10MB
线程1
线程2
Http11InputBuffer
10MB
Http11OutputBuffer
10MB
...
解决方案
将参数改为合理值,例如20000(20KB),并压测验证:
properties
server.max-http-header-size=20000
教训
- 修改参数前要理解其含义,容量类参数背后往往代表资源占用,不能随意设置超大值。
- 建议预留2~5倍余量,但必须结合实际需求。
总结与建议
- 对象复用意识 :相同数据可能因多次转换、索引等原因在内存中存在多份,使用
HashSet等去重可大幅降低内存占用。 - 引用类型陷阱 :
WeakHashMap的Value若持有Key的强引用,会导致Key无法回收。使用弱引用包装Value或切断引用链。 - 合理配置资源:Tomcat等中间件的容量参数需谨慎设置,过大会直接导致内存暴涨。
- OOM排查手段 :
-
启用GC日志和HeapDumpOnOutOfMemoryError:
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=. -XX:+PrintGCDateStamps -XX:+PrintGCDetails -Xloggc:gc.log -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=10 -XX:GCLogFileSize=100M -
使用MAT、jvisualvm等工具分析堆Dump,定位大对象和引用链。
-
思考与讨论
- Spring的
ConcurrentReferenceHashMap支持Key和Value使用软引用或弱引用。你觉得哪种方式更适合做缓存?为什么? - 动态执行Groovy脚本时,每次
new GroovyShell()会生成大量类,容易导致Metaspace OOM。你知道如何避免吗?
欢迎在评论区分享你的观点和遇到的OOM案例,一起交流进步!