Java自动垃圾收集器常见出现oom的情况

太多相同的对象导致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<String, List<UserDTO>> 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<UserDTO> 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 就是利用了这个特点。

 

上一篇:Soul API 网关源码解析 02


下一篇:Java集合排序