【问题标题】:OutOfMemoryException despite using WeakHashMap尽管使用了 WeakHashMap,但 OutOfMemoryException
【发布时间】:2020-03-08 05:40:58
【问题描述】:

如果不调用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?
  • 我无法在我的系统上重现它。在调试模式下,我可以看到 GC 正在完成它的工作。如果地图实际上正在被清除,您可以在调试模式下检查吗?
  • jre 1.8.0_212-b10 -Xmx200m 您可以从我附加的 gc 日志中查看更多详细信息;谢谢

标签: java java-8 garbage-collection out-of-memory weak-references


【解决方案1】:

JVM 会自己调用 GC,但在这种情况下,为时已晚。 在这种情况下,不仅 GC 负责清除内存。 映射值是强可达的,并且在对其调用某些操作时由映射本身清除。

如果开启 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 清除资源,而无需等待 map 自行完成。 这是输出:

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;第一次是从map中收集keys,并将它们添加到引用队列中;第二次是收集价值?但是从您提供的第一个日志来看,实际上 JVM 已经进行了两次完整的 gc;
  • 您的意思是,“地图值是高度可访问的,并且在对其调用某些操作时由地图本身清除。”。从哪里可以到达?
  • 仅运行两次 GC 是不够的。首先,您需要运行一次 GC,这是正确的。但下一步将需要与地图本身进行一些交互。您应该寻找的是方法java.util.WeakHashMap.expungeStaleEntries,它读取引用队列并从映射中删除条目,从而使值无法访问并被收集。只有在那之后,第二次 GC 才会释放一些内存。 expungeStaleEntries 在许多情况下都会被调用,例如 get/put/size 或几乎所有您通常使用地图执行的操作。这就是问题所在。
  • @Andronicus,这是迄今为止 WeakHashMap 中最令人困惑的部分。它被多次覆盖。 stackoverflow.com/questions/5511279/…
  • @Andronicus this answer,尤其是后半部分,可能也会有所帮助。还有this Q&A
【解决方案2】:

另一个答案确实是正确的,我编辑了我的。作为一个小附录,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),这将被称为humongous allocation。当发生这种情况时,将触发 并发 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

这已经做了一些不同的事情。它启动一个concurrent cycle(在您的应用程序运行时完成),因为有一个G1 Humongous Allocation。作为这个并发周期的一部分,它会执行一个年轻的 GC 周期(在运行时停止您的应用程序)

 [2.082s][info ][gc,start] GC(0) Pause Young (Concurrent Start) (G1 Humongous Allocation)

作为 young GC 的一部分,它还清除了 巨大的区域here is the defect


您现在可以看到jdk-13 在分配非常大的对象时不会等待垃圾堆积在旧区域,而是触发并发 GC 循环,从而节省了时间;不像jdk-8。

您可能想了解DisableExplicitGC 和/或ExplicitGCInvokesConcurrent 的含义以及System.gc 的含义,并了解为什么调用System.gc 在这里实际上会有所帮助。

【讨论】:

  • Java 8 默认不使用 G1GC。并且 OP 的 GC 日志也清楚地表明它正在为老年代使用并行 GC。而对于这样的非并发收集器,就如this answer中描述的一样简单
  • @Holger 今天早上我正在查看这个答案,却发现它确实是ParalleGC,我已经编辑并抱歉(并感谢你)证明我错了。
  • “巨大的分配”仍然是一个正确的提示。使用非并发收集器,意味着第一次 GC 将在老年代满时运行,因此未能回收足够的空间将使其致命。相比之下,当你减小数组大小时,当老年代还有剩余内存时,就会触发年轻 GC,因此收集器可以提升对象并继续。另一方面,对于并发收集器,在堆耗尽之前触发 gc 是正常的,因此 -XX:+UseG1GC 使其在 Java 8 中工作,就像 -XX:+UseParallelOldGC 在新的 JVM 中使其失败一样。
猜你喜欢
  • 2016-01-10
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2021-04-15
  • 2019-02-07
  • 1970-01-01
相关资源
最近更新 更多