太多相同的对象导致oom
背景:
-- 有一个项目在内存中缓存了全量用户数据,
-- 在搜索用户时可以直接从缓存中返回用户信息。
-- 但现在为了改善用户体验,
-- 需要实现输入用户名自动在下拉框提示不全用户名的功能(自动完成功能)。
分析:
-- 对于快速检索的需求,最好使用Map实现,比List搜索快的多;
-- HashMap存用户数据,Key时用户姓名索引,Value是索引下对应用户列表;
-- 如,两个用户aa和ab,那么Key有三个,分别是a、aa和ab。
-- 用户输入a时,就能从Value这个List中拿到所有字母a开头的用户。
例子:
-- 数据库一万用户,用户名a-j的6个字母随机构成;
-- 然后把每一个用户名的前1个字母、前2个字母依次类推知道完整用户名做为Key存入缓存中,
-- 缓存的Value时一个UserDTO的List,存放所有相同的用户名索引,及对应用户信息;
代码:
//自动完成的索引,Key是用户输入的部分用户名,Value是对应的用户数据
private ConcurrentHashMap> autoCompleteIndex = new ConcurrentHashMap<>();
@Autowired
private UserRepository userRepository;
@PostConstruct
public void wrong() {
//先保存10000个用户名随机的用户到数据库中
userRepository.saveAll(LongStream.rangeClosed(1, 10000).mapToObj(i -> new UserEntity(i, randomName())).collect(Collectors.toList()));
//从数据库加载所有用户
userRepository.findAll().forEach(userEntity -> {
int len = userEntity.getName().length();
//对于每一个用户,对其用户名的前N位进行索引,N可能是1~6六种长度类型
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()));
}
});
log.info("autoCompleteIndex size:{} count:{}", autoCompleteIndex.size(),
autoCompleteIndex.entrySet().stream().map(item -> item.getValue().size()).reduce(0, Integer::sum));
}
//模拟用户信息,10k左右数据
@Data
public class UserDTO {
private String name;
@EqualsAndHashCode.Exclude
private String payload;
public UserDTO(String name) {
this.name = name;
this.payload = IntStream.rangeClosed(1, 10_000)
.mapToObj(__ -> "a")
.collect(Collectors.joining(""));
}
}
输出:
//autoCompleteIndex size:26838 count:60000
例子总结:
-- 一共有 26838 个索引(也就是所有用户名的 1 位、2 位一直到 6 位有 26838 个组合),
-- HashMap 的 Value,也就是 List一共有 1 万个用户 *6=6 万个 UserDTO 对象。
-- 使用内存分析工具 MAT 打开堆 dump 发现,6 万个 UserDTO 占用了约 1.2GB 的内存
-- 虽然真正的用户只有 1 万个,但因为使用部分用户名作为索引的 Key,导致缓存的 Key 有 26838 个,缓存的用户信息多达 6 万个。
-- 如果我们的用户名不是 6 位而是 10 位、20 位,那么缓存的用户信息可能就是 10 万、20 万个,必然会产生堆 OOM。
解决:
-- 把所有 UserDTO 先加入 HashSet 中,因为 UserDTO 以 name 来标识唯一性,
-- 所以重复用户名会被过滤掉,最终加入 HashSet 的 UserDTO 就不足 1 万个
-- 同一个用户名前缀的不同组合(比如用户名为 abc 的用户,a、ab 和 abc 三个 Key)关联到 UserDTO 是同一份
代码:
@PostConstruct
public void right() {
...
HashSet cache = userRepository.findAll().stream()
.map(item -> new UserDTO(item.getName()))
.collect(Collectors.toCollection(HashSet::new));
cache.stream().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);
}
});
...
}
//UserDTO 只有 9945 份,总共占用的内存不到 200M
//修复后的程序,不仅相同的 UserDTO 只有一份,总副本数变为了原来的六分之一;
//而且因为 HashSet 的去重特性,双重节约了内存。
//值得注意的是,我们虽然清楚数据总量,但却忽略了每一份数据在内存中可能有多份。
案例:
-- 一个后台程序需要从数据库加载大量信息用于数据导出,
-- 这些数据在数据库中占用 100M 内存,
-- 但是 1GB 的 JVM 堆却无法完成导出操作。
分析:
-- 100M 的数据加载到程序内存中,变为 Java 的数据结构就已经占用了 200M 堆内存;
-- 这些数据经过 JDBC、MyBatis 等框架其实是加载了 2 份,然后领域模型、DTO 再进行转换可能又加载了 2 次
-- 最终,占用的内存达到了 200M*4=800M。
总结:在进行容量评估时,我们不能认为一份数据在程序内存中也是一份。
使用WeakHashMap出现OOM的情况
Java中引用类型和垃圾回收的关系:
-- 垃圾回收器不会回收有强引用的对象;
-- 在内存充足时,垃圾回收器不会回收具有软引用的对象;
-- 垃圾回收器只要扫描到了具有弱引用的对象就会回收,WeakHashMap 就是利用了这个特点。
WeakHashMap 的 Key 虽然是弱引用,但是其 Value 却持有 Key 中对象的强引用,Value 被 Entry 引用,Entry 被 WeakHashMap 引用,最终导致 Key 无法回收。
解决方案就是让 Value 变为弱引用,使用 WeakReference 来包装value
如:
private Map> cache2 = new WeakHashMap<>();
@GetMapping("right")
public void right() {
String userName = "zhuye";
//间隔1秒定时输出缓存中的条目数
Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate(
() -> log.info("cache size:{}", cache2.size()), 1, 1, TimeUnit.SECONDS);
LongStream.rangeClosed(1, 2000000).forEach(i -> {
User user = new User(userName + i);
//这次,我们使用弱引用来包装UserProfile
cache2.put(user, new WeakReference(new UserProfile(user, "location" + i)));
});
}
Tomcat参数配置不合理导致OOM
server.max-http-header-size=10000000
压测调整为合适的如:
server.max-http-header-size=20000
//一定要根据实际需求来修改参数配置,可以考虑预留 2 到 5 倍的量。
//容量类的参数背后往往代表了资源,
//设置超大的参数就有可能占用不必要的资源,
//在并发量大的时候因为资源大量分配导致 OOM。
总结:
1. 程序确实需要超出 JVM 配置的内存上限的内存。
//不管是程序实现的不合理,还是因为各种框架对数据的重复处理、加工和转换,相同的数据在内存中不一定只占用一份空间。
//针对内存量使用超大的业务逻辑,比如缓存逻辑、文件上传下载和导出逻辑,我们在做容量评估时,可能还需要实际做一下 Dump,
//而不是进行简单的假设。
2. 出现内存泄露,其实就是我们认为没有用的对象最终会被 GC,但却没有。
//GC 并不会回收强引用对象,我们可能经常在程序中定义一些容器作为缓存,但如果容器中的数据无限增长,要特别小心最终会导致 OOM。
//使用 WeakHashMap 是解决这个问题的好办法,但值得注意的是,如果强引用的 Value 有引用 Key,也无法回收 Entry。
3. 不合理的资源需求配置,在业务量小的时候可能不会出现问题,但业务量一大可能很快就会撑爆内存。
//比如,随意配置 Tomcat 的 max-http-header-size 参数,会导致一个请求使用过多的内存,请求量大的时候出现 OOM。
//在进行参数配置的时候,我们要认识到,很多限制类参数限制的是背后资源的使用,资源始终是有限的,需要根据实际需求来合理设置参数。
4. 出现OOM根据错误日志中的异常信息查看GC日志
//结合 jstat 等命令行工具观察内存使用情况,以及程序的 GC 日志,来大致定位出现 OOM 的内存区块和类型。
//其实,我们遇到的 90% 的 OOM 都是堆 OOM,对 JVM 进程进行堆内存 Dump,或使用 jmap 命令分析对象内存占用排行,
//一般都可以很容易定位到问题。
5. 生产系统的程序配置 JVM 参数启用详细的 GC 日志,方便观察垃圾收集器的行为,并开启 HeapDumpOnOutOfMemoryError,以便在出现 OOM 时能自动 Dump 留下第一问题现场。
//JDK8设置JVM启用GC日志参数
XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=. -XX:+PrintGCDateStamps -XX:+PrintGCDetails -Xloggc:gc.log -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=10 -XX:GCLogFileSize=100M
6. 应该用那种引用
//强引用:最常见的一种,只要该引用存在,就不会被GC。
//软引用:内存空间不足时,进行回收。
//弱引用:当JVM进行GC时,则进行回收,无论内存是否充足。
//引用和选择软引用。因为弱引用,被回收的频率更高。缓存,如果经常被回收的话,就达不到最大利用率。
//单说缓存设计,还要涉及其他的因素。包括缓存大小,缓存的过期时间等。让我来说的话,我可能会考虑使用现有的缓存实现,或者是redis。