使用 WeakHashMap 仍然出现 OutOfMemoryException 异常

10
如果不调用 System.gc(),系统会抛出 OutOfMemoryException 异常。我不知道为什么需要显式地调用 System.gc();JVM 应该自己调用 gc(),对吗?请给予建议。
以下是我的测试代码:
public static void main(String[] args) throws InterruptedException {
    WeakHashMap<String, int[]> hm = new WeakHashMap<>();
    int i  = 0;
    while(true) {
        Thread.sleep(1000);
        i++;
        String key = new String(new Integer(i).toString());
        System.out.println(String.format("add new element %d", i));
        hm.put(key, new int[1024 * 10000]);
        key = null;
        //System.gc();
    }
}

接下来,添加-XX:+PrintGCDetails以打印GC信息;正如您所看到的,实际上JVM尝试运行完整的GC,但失败了;我仍然不知道原因。非常奇怪的是,如果我取消注释System.gc();这一行,结果是积极的:

add new element 1
add new element 2
add new element 3
add new element 4
add new element 5
[GC (Allocation Failure) --[PSYoungGen: 48344K->48344K(59904K)] 168344K->168352K(196608K), 0.0090913 secs] [Times: user=0.02 sys=0.00, real=0.01 secs] 
[Full GC (Ergonomics) [PSYoungGen: 48344K->41377K(59904K)] [ParOldGen: 120008K->120002K(136704K)] 168352K->161380K(196608K), [Metaspace: 5382K->5382K(1056768K)], 0.0380767 secs] [Times: user=0.09 sys=0.03, real=0.04 secs] 
[GC (Allocation Failure) --[PSYoungGen: 41377K->41377K(59904K)] 161380K->161380K(196608K), 0.0040596 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (Allocation Failure) [PSYoungGen: 41377K->41314K(59904K)] [ParOldGen: 120002K->120002K(136704K)] 161380K->161317K(196608K), [Metaspace: 5382K->5378K(1056768K)], 0.0118884 secs] [Times: user=0.02 sys=0.00, real=0.01 secs] 
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
    at test.DeadLock.main(DeadLock.java:23)
Heap
 PSYoungGen      total 59904K, used 42866K [0x00000000fbd80000, 0x0000000100000000, 0x0000000100000000)
  eden space 51712K, 82% used [0x00000000fbd80000,0x00000000fe75c870,0x00000000ff000000)
  from space 8192K, 0% used [0x00000000ff800000,0x00000000ff800000,0x0000000100000000)
  to   space 8192K, 0% used [0x00000000ff000000,0x00000000ff000000,0x00000000ff800000)
 ParOldGen       total 136704K, used 120002K [0x00000000f3800000, 0x00000000fbd80000, 0x00000000fbd80000)
  object space 136704K, 87% used [0x00000000f3800000,0x00000000fad30b90,0x00000000fbd80000)
 Metaspace       used 5409K, capacity 5590K, committed 5760K, reserved 1056768K
  class space    used 576K, capacity 626K, committed 640K, reserved 1048576K

你使用的JDK版本是什么?你是否使用了-Xms和-Xmx参数?在哪个步骤中出现了OOM? - Vladislav Kysliy
1
我在我的系统上无法重现这个问题。在调试模式下,我可以看到垃圾回收正在发挥作用。你能否在调试模式下检查一下Map是否被清除了? - magicmn
jre 1.8.0_212-b10 -Xmx200m 您可以从我附加的gc日志中查看更多详细信息;谢谢 - Dominic Peng
2个回答

8

JVM会自动调用垃圾回收,但在这种情况下,它可能太少了太晚了。在这种情况下,不仅垃圾回收负责清除内存。当对Map执行某些操作时,Map的值是强可及的,并且由Map本身清除。

如果启用GC事件(XX:+PrintGC),可以看到以下输出:

add new element 1
add new element 2
add new element 3
add new element 4
add new element 5
add new element 6
add new element 7
[GC (Allocation Failure)  2407753K->2400920K(2801664K), 0.0123285 secs]
[GC (Allocation Failure)  2400920K->2400856K(2801664K), 0.0090720 secs]
[Full GC (Allocation Failure)  2400856K->2400805K(2590720K), 0.0302800 secs]
[GC (Allocation Failure)  2400805K->2400805K(2801664K), 0.0069942 secs]
[Full GC (Allocation Failure)  2400805K->2400753K(2620928K), 0.0146932 secs]
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space

仅在最后一次尝试将值放入映射中时,GC才会被触发。

WeakHashMap无法清除陈旧的条目,直到映射键出现在引用队列中。 并且,直到它们被垃圾收集之后,映射键才会出现在引用队列中。 新映射值的内存分配在映射有任何机会自我清理之前就被触发。 当内存分配失败并触发GC时,映射键确实被收集。但为了分配新的映射值,释放的内存太少了。 如果减少有效载荷,可能会获得足够的内存来分配新的映射值,并且过期的条目将被删除。

另一种解决方案是将值本身包装成WeakReference。这将允许GC清除资源,而无需等待映射自行进行清理。 以下是输出:

add new element 1
add new element 2
add new element 3
add new element 4
add new element 5
add new element 6
add new element 7
[GC (Allocation Failure)  2407753K->2400920K(2801664K), 0.0133492 secs]
[GC (Allocation Failure)  2400920K->2400888K(2801664K), 0.0090964 secs]
[Full GC (Allocation Failure)  2400888K->806K(190976K), 0.1053405 secs]
add new element 8
add new element 9
add new element 10
add new element 11
add new element 12
add new element 13
[GC (Allocation Failure)  2402096K->2400902K(2801664K), 0.0108237 secs]
[GC (Allocation Failure)  2400902K->2400838K(2865664K), 0.0058837 secs]
[Full GC (Allocation Failure)  2400838K->1024K(255488K), 0.0863236 secs]
add new element 14
add new element 15
...
(and counting)

更好了。


谢谢你的回答,看起来你的结论是正确的;当我尝试将负载从1024 * 10000减少到1024 * 1000时,代码可以正常工作;但我仍然不太理解你的解释;根据你的意思,如果需要释放WeakHashMap中的空间,应该至少进行两次gc;第一次是从映射中收集键,并将它们添加到引用队列中;第二次是收集值?但从你提供的第一个日志中,实际上JVM已经进行了两次完整的gc; - Dominic Peng
你是在说,"映射值是强可达的,并且在执行特定操作时,由映射本身清除。" 它们从哪里可达? - Andronicus
1
在您的情况下,仅进行两次GC运行是不够的。首先,您需要进行一次GC运行,这是正确的。但是下一步将需要与地图本身进行一些交互。 您应该寻找的是java.util.WeakHashMap.expungeStaleEntries方法,它会读取引用队列并从地图中删除条目,从而使值无法访问并且可以进行收集。只有在完成此操作后,第二次GC才会释放一些内存。 expungeStaleEntries在许多情况下都会被调用,例如get/put/size或几乎您通常使用地图的所有操作。这就是问题所在。 - tentacle
1
@Andronicus,这是WeakHashMap中最令人困惑的部分。 已经多次涉及。https://dev59.com/wW035IYBdhLWcg3wSN-r - tentacle
2
@Andronicus,这个答案(https://dev59.com/Jqvka4cB1Zd3GeqP0enL#50701443),特别是后半部分,可能也会有所帮助。还有这个问答(https://dev59.com/8rLma4cB1Zd3GeqPXjyc)... - Holger
显示剩余3条评论

5
另一个答案是正确的,我已经编辑了我的回答。作为一个小附加说明,G1GC 不会表现出这种行为,不像 ParallelGC;后者是在 java-8 下的默认设置。

如果我稍微更改您的程序(在 jdk-8 下使用 -Xmx20m 运行),你认为会发生什么?

public static void main(String[] args) throws InterruptedException {
    WeakHashMap<String, int[]> hm = new WeakHashMap<>();
    int i = 0;
    while (true) {
        Thread.sleep(200);
        i++;
        String key = "" + i;
        System.out.println(String.format("add new element %d", i));
        hm.put(key, new int[512 * 1024 * 1]); // <--- allocate 1/2 MB
    }
}

它将正常工作。为什么呢?因为它为程序提供了足够的空间进行新分配,然后WeakHashMap清除其条目。另一个答案已经解释了这是如何发生的。
现在,在G1GC中,情况会有所不同。当分配这样大的对象(通常超过1/2 MB)时,这将被称为巨大分配。当发生这种情况时,将触发并发GC。作为该周期的一部分:将触发young收集,并启动Cleanup phase,以负责将事件发布到ReferenceQueue,以便WeakHashMap清除其条目。
所以对于这段代码:
public static void main(String[] args) throws InterruptedException {
    Map<String, int[]> hm = new WeakHashMap<>();
    int i = 0;
    while (true) {
        Thread.sleep(1000);
        i++;
        String key = "" + i;
        System.out.println(String.format("add new element %d", i));
        hm.put(key, new int[1024 * 1024 * 1]); // <--- 1 MB allocation
    }
}

我使用jdk-13运行(其中G1GC是默认值)

java -Xmx20m "-Xlog:gc*=debug" gc.WeakHashMapTest

这是一部分日志记录:
[2.082s][debug][gc,ergo] Request concurrent cycle initiation (requested by GC cause). GC cause: G1 Humongous Allocation

这已经有所不同了。它启动了一个“并发周期”(在您的应用程序运行时完成),因为有一个“G1巨大分配”。作为此并发周期的一部分,它执行了一个年轻的GC循环(在运行时“停止”了您的应用程序)。
 [2.082s][info ][gc,start] GC(0) Pause Young (Concurrent Start) (G1 Humongous Allocation)

作为年轻的GC的一部分,它还会清除巨大的区域,这里是缺陷
您现在可以看到,jdk-13 在分配非常大的对象时不会等待垃圾堆积在旧区域中,而是触发了一个并发GC周期,这挽救了局面;与 jdk-8 不同。
您可能想了解DisableExplicitGC和/或ExplicitGCInvokesConcurrent的含义,以及与System.gc相结合,了解为什么调用System.gc实际上有助于这里。

1
Java 8 默认不使用 G1GC。OP 的 GC 日志也清楚地显示它正在使用旧一代的并行 GC。对于这样一种非并发收集器,就像 这个答案 中所描述的那样简单。 - Holger
@Holger,今天早上我正在审核这个答案,才意识到它确实是“ParalleGC”,我已经进行了编辑,很抱歉(也感谢您)证明我错了。 - Eugene
1
“巨大的分配”仍然是一个正确的提示。对于一个非并发的收集器,这意味着当老年代满了时,第一次GC将运行,所以无法回收足够的空间将导致致命错误。相反,当你减小数组大小时,当老年代还有内存时,将会触发Young GC,因此收集器可以晋升对象并继续进行。然而,对于并发收集器,触发GC在堆耗尽之前是很正常的,因此-XX:+UseG1GC让它在Java 8中工作,就像-XX:+UseParallelOldGC在新版JVM中失败一样。 - Holger

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接