Day641.JavaOOM问题 -Java业务开发常见错误

Posted 阿昌喜欢吃黄桃

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Day641.JavaOOM问题 -Java业务开发常见错误相关的知识,希望对你有一定的参考价值。

JavaOOM问题

Hi,阿昌来也,今天学习记录的是JavaOOM问题的学习

Java 是 自动垃圾收集,针对Java,经过这么多年的发展,Java 的垃圾收集器已经非常成熟了。有了自动垃圾收集器,绝大多数情况下我们写程序时可以专注于业务逻辑,无需过多考虑对象的分配和释放,一般也不会出现 OOM。但,内存空间始终是有限的,Java 的几大内存区域始终都有 OOM 的可能。相应地,Java 程序的常见 OOM 类型,可以分为堆内存的 OOM、栈 OOM、元空间 OOM、直接内存 OOM 等。

几乎每一种 OOM 都可以使用几行代码模拟,市面上也有很多资料在堆、元空间、直接内存中分配超大对象或是无限分配对象,尝试创建无限个线程或是进行方法无限递归调用来模拟。


一、太多份相同的对象导致 OOM

有一个项目在内存中缓存了全量用户数据,在搜索用户时可以直接从缓存中返回用户信息。现在为了改善用户体验,需要实现输入部分用户名自动在下拉框提示补全用户名的功能(也就是所谓的自动完成功能)。

在记录集合时,我提到对于这种快速检索的需求,最好使用 Map 来实现,会比直接从 List 搜索快得多。为实现这个功能,我们需要一个 HashMap 来存放这些用户数据,Key 是用户姓名索引,Value 是索引下对应的用户列表。举一个例子,如果有两个用户 aa 和 ab,那么 Key 就有三个,分别是 a、aa 和 ab。用户输入字母 a 时,就能从 Value 这个 List 中拿到所有字母 a 开头的用户,即 aa 和 ab。

在代码中,在数据库中存入 1 万个测试用户,用户名由 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));

对于每一个用户对象 UserDTO,除了有用户名,我们还加入了 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(""));
    

运行程序后,日志输出如下:

[11:11:22.982] [main] [INFO ] [.t.c.o.d.UsernameAutoCompleteService:37  ] - 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。尝试调大用户名的最大长度,重启程序可以看到类似如下的错误:

[17:30:29.858] [main] [ERROR] [ringframework.boot.SpringApplication:826 ] - Application run failed
org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'usernameAutoCompleteService': Invocation of init method failed; nested exception is java.lang.OutOfMemoryError: Java heap space

我们可能会想当然地认为,数据库中有 1 万个用户,内存中也应该只有 1 万个 UserDTO 对象,但实现的时候每次都会 new 出来 UserDTO 加入缓存,当然在内存中都是新对象。

在实际的项目中,用户信息的缓存可能是随着用户输入增量缓存的,而不是像这个案例一样在程序初始化的时候全量缓存,所以问题暴露得不会这么早。知道原因后,解决起来就比较简单了。

把所有 UserDTO 先加入 HashSet 中,因为 UserDTO 以 name 来标识唯一性,所以重复用户名会被过滤掉,最终加入 HashSet 的 UserDTO 就不足 1 万个。

有了 HashSet 来缓存所有可能的 UserDTO 信息,我们再构建自动完成索引 autoCompleteIndex 这个 HashMap 时,就可以直接从 HashSet 获取所有用户信息来构建了。这样一来,同一个用户名前缀的不同组合(比如用户名为 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

对于上一节实现快速检索的案例,为了防止缓存中堆积大量数据导致 OOM,一些同学可能会想到使用 WeakHashMap 作为缓存容器。

WeakHashMap 的特点是 Key 在哈希表内部是弱引用的,当没有强引用指向这个 Key 之后,Entry 会被 GC,即使我们无限往 WeakHashMap 加入数据,只要 Key 不再使用,也就不会 OOM。

说到了强引用和弱引用,我先和你回顾下 Java 中引用类型和垃圾回收的关系:

  • 垃圾回收器不会回收有强引用的对象;
  • 在内存充足时,垃圾回收器不会回收具有软引用的对象;
  • 垃圾回收器只要扫描到了具有弱引用的对象就会回收,WeakHashMap 就是利用了这个特点。

不过,我要和你分享的第二个案例,恰巧就是不久前我遇到的一个使用 WeakHashMap 却最终 OOM 的案例。我们暂且不论使用 WeakHashMap 作为缓存是否合适,先分析一下这个 OOM 问题。

声明一个 Key 是 User 类型、Value 是 UserProfile 类型的 WeakHashMap,作为用户数据缓存,往其中添加 200 万个 Entry,然后使用 ScheduledThreadPoolExecutor 发起一个定时任务,每隔 1 秒输出缓存中的 Entry 个数:

private Map<User, UserProfile> cache = new WeakHashMap<>();

@GetMapping("wrong")
public void wrong() 
    String userName = "zhuye";
    //间隔1秒定时输出缓存中的条目数
    Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate(
            () -> log.info("cache size:", cache.size()), 1, 1, TimeUnit.SECONDS);
    LongStream.rangeClosed(1, 2000000).forEach(i -> 
        User user = new User(userName + i);
        cache.put(user, new UserProfile(user, "location" + i));
    );

执行程序后日志如下:

[10:30:28.509] [pool-3-thread-1] [INFO ] [t.c.o.demo3.WeakHashMapOOMController:29  ] - cache size:2000000
[10:30:29.507] [pool-3-thread-1] [INFO ] [t.c.o.demo3.WeakHashMapOOMController:29  ] - cache size:2000000
[10:30:30.509] [pool-3-thread-1] [INFO ] [t.c.o.demo3.WeakHashMapOOMController:29  ] - cache size:2000000

可以看到,输出的 cache size 始终是 200 万,即使我们通过 jvisualvm 进行手动 GC 还是这样。这就说明,这些 Entry 无法通过 GC 回收。如果你把 200 万改为 1000 万,就可以在日志中看到如下的 OOM 错误:

Exception in thread "http-nio-45678-exec-1" java.lang.OutOfMemoryError: GC overhead limit exceeded
Exception in thread "Catalina-utility-2" java.lang.OutOfMemoryError: GC overhead limit exceeded

我们来分析一下这个问题。进行堆转储后可以看到,堆内存中有 200 万个 UserProfie 和 User:

如下是 User 和 UserProfile 类的定义,需要注意的是,WeakHashMap 的 Key 是 User 对象,而其 Value 是 UserProfile 对象,持有了 User 的引用:

@Data
@AllArgsConstructor
@NoArgsConstructor
class User 
    private String name;



@Data
@AllArgsConstructor
@NoArgsConstructor
public class UserProfile 
    private User user;
    private String location;

没错,这就是问题的所在。分析一下 WeakHashMap 的源码,你会发现 WeakHashMap 和 HashMap 的最大区别,是 Entry 对象的实现。接下来,我们暂且忽略 HashMap 的实现,来看下 Entry 对象:

private static class Entry<K,V> extends WeakReference<Object> ...
/**
 * Creates new entry.
 */
Entry(Object key, V value,
      ReferenceQueue<Object> queue,
      int hash, Entry<K,V> next) 
    super(key, queue);
    this.value = value;
    this.hash  = hash;
    this.next  = next;

Entry 对象继承了 WeakReference,Entry 的构造函数调用了 super (key,queue),这是父类的构造函数。其中,key 是我们执行 put 方法时的 key;queue 是一个 ReferenceQueue。如果你了解 Java 的引用就会知道,被 GC 的对象会被丢进这个 queue 里面。

再来看看对象被丢进 queue 后是如何被销毁的:

public V get(Object key) 
    Object k = maskNull(key);
    int h = hash(k);
    Entry<K,V>[] tab = getTable();
    int index = indexFor(h, tab.length);
    Entry<K,V> e = tab[index];
    while (e != null) 
        if (e.hash == h && eq(k, e.get()))
            return e.value;
        e = e.next;
    
    return null;


private Entry<K,V>[] getTable() 
    expungeStaleEntries();
    return table;


/**
 * Expunges stale entries from the table.
 */
private void expungeStaleEntries() 
    for (Object x; (x = queue.poll()) != null; ) 
        synchronized (queue) 
            @SuppressWarnings("unchecked")
                Entry<K,V> e = (Entry<K,V>) x;
            int i = indexFor(e.hash, table.length);

            Entry<K,V> prev = table[i];
            Entry<K,V> p = prev;
            while (p != null) 
                Entry<K,V> next = p.next;
                if (p == e) 
                    if (prev == e)
                        table[i] = next;
                    else
                        prev.next = next;
                    // Must not null out e.next;
                    // stale entries may be in use by a HashIterator
                    e.value = null; // Help GC
                    size--;
                    break;
                
                prev = p;
    ``            p = next;
            
        
    

从源码中可以看到,每次调用 get、put、size 等方法时,都会从 queue 里拿出所有已经被 GC 掉的 key 并删除对应的 Entry 对象。我们再来回顾下这个逻辑:

  • put 一个对象进 Map 时,它的 key 会被封装成弱引用对象;
  • 发生 GC 时,弱引用的 key 被发现并放入 queue;
  • 调用 get 等方法时,扫描 queue 删除 key,以及包含 key 和 value 的 Entry 对象。

WeakHashMap 的 Key 虽然是弱引用,但是其 Value 却持有 Key 中对象的强引用,Value 被 Entry 引用,Entry 被 WeakHashMap 引用,最终导致 Key 无法回收

解决方案就是让 Value 变为弱引用,使用 WeakReference 来包装 UserProfile 即可:

private Map<User, WeakReference<UserProfile>> 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)));
    );

重新运行程序,从日志中观察到 cache size 不再是固定的 200 万,而是在不断减少,甚至在手动 GC 后所有的 Entry 都被回收了:

[10:40:05.792] [pool-3-thread-1] [INFO ] [t.c.o.demo3.WeakHashMapOOMController:40  ] - cache size:1367402
[10:40:05.795] [pool-3-thread-1] [INFO ] [t.c.o.demo3.WeakHashMapOOMController:40  ] - cache size:1367846
[10:40:06.773] [pool-3-thread-1] [INFO ] [t.c.o.demo3.WeakHashMapOOMController:40  ] - cache size:549551
...
[10:40:20.742] [pool-3-thread-1] [INFO ] [t.c.o.demo3.WeakHashMapOOMController:40  ] - cache size:549551
[10:40:22.862] [pool-3-thread-1] [INFO ] [t.c.o.demo3.WeakHashMapOOMController:40  ] - cache size:547937
[10:40:22.865] [pool-3-thread-1] [INFO ] [t.c.o.demo3.WeakHashMapOOMController:40  ] - cache size:542134
[10:40:23.779] [pool-3-thread-1] [INFO ] 
//手动进行GC
[t.c以上是关于Day641.JavaOOM问题 -Java业务开发常见错误的主要内容,如果未能解决你的问题,请参考以下文章

C语言猴子吃桃问题递归法

day29-day34 面向对象程序设计

Mysql高级-day01

day12-day15集合

ABAP相关问题。

Oracle - LAST_DAY 和 TRUNC - 执行顺序