背景: -- 有一个项目在内存中缓存了全量用户数据, -- 在搜索用户时可以直接从缓存中返回用户信息。 -- 但现在为了改善用户体验, -- 需要实现输入用户名自动在下拉框提示不全用户名的功能(自动完成功能)。 分析: -- 对于快速检索的需求,最好使用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。 总结:在进行容量评估时,我们不能认为一份数据在程序内存中也是一份。
Java中引用类型和垃圾回收的关系: -- 垃圾回收器不会回收有强引用的对象; -- 在内存充足时,垃圾回收器不会回收具有软引用的对象; -- 垃圾回收器只要扫描到了具有弱引用的对象就会回收,WeakHashMap 就是利用了这个特点。