Java 的for语句实现防止垃圾回收。
Java 的for语句实现防止垃圾回收。
更新 2017年11月21日: JDK中的错误已经修复,请参考Vicente Romero的评论
概述:
如果对于任何Iterable
实现使用for
语句,那么该集合将保留在堆内存中直到当前范围(方法、语句体)的结尾,并且即使您没有对集合进行任何其他引用,当应用程序需要分配新内存时也不会被垃圾收集。
http://bugs.java.com/bugdatabase/view_bug.do?bug_id=JDK-8175883
https://bugs.openjdk.java.net/browse/JDK-8175883
示例:
如果我有下面这段代码,它会分配一个带有随机内容的大字符串列表:
import java.util.ArrayList; public class IteratorAndGc { // number of strings and the size of every string static final int N = 7500; public static void main(String[] args) { System.gc(); gcInMethod(); System.gc(); showMemoryUsage("GC after the method body"); ArrayList strings2 = generateLargeStringsArray(N); showMemoryUsage("Third allocation outside the method is always successful"); } // main testable method public static void gcInMethod() { showMemoryUsage("Before first memory allocating"); ArrayList strings = generateLargeStringsArray(N); showMemoryUsage("After first memory allocation"); // this is only one difference - after the iterator created, memory won't be collected till end of this function for (String string : strings); showMemoryUsage("After iteration"); strings = null; // discard the reference to the array // one says this doesn't guarantee garbage collection, // Oracle says "the Java Virtual Machine has made a best effort to reclaim space from all discarded objects". // but no matter - the program behavior remains the same with or without this line. You may skip it and test. System.gc(); showMemoryUsage("After force GC in the method body"); try { System.out.println("Try to allocate memory in the method body again:"); ArrayList strings2 = generateLargeStringsArray(N); showMemoryUsage("After secondary memory allocation"); } catch (OutOfMemoryError e) { showMemoryUsage("!!!! Out of memory error !!!!"); System.out.println(); } } // function to allocate and return a reference to a lot of memory private static ArrayList generateLargeStringsArray(int N) { ArrayList strings = new ArrayList<>(N); for (int i = 0; i < N; i++) { StringBuilder sb = new StringBuilder(N); for (int j = 0; j < N; j++) { sb.append((char)Math.round(Math.random() * 0xFFFF)); } strings.add(sb.toString()); } return strings; } // helper method to display current memory status public static void showMemoryUsage(String action) { long free = Runtime.getRuntime().freeMemory(); long total = Runtime.getRuntime().totalMemory(); long max = Runtime.getRuntime().maxMemory(); long used = total - free; System.out.printf("\t%40s: %10dk of max %10dk%n", action, used / 1024, max / 1024); } }
使用有限的内存编译和运行,例如(180MB):
javac IteratorAndGc.java && java -Xms180m -Xmx180m IteratorAndGc
并且在运行时我得到了以下结果:
在第一次内存分配之前:1251k of max 176640k
第一次内存分配之后:131426k of max 176640k
迭代结束之后:131426k of max 176640k
在方法体中的强制垃圾回收之后:110682k of max 176640k(几乎没有回收)
再次尝试在方法体中分配内存:
!!!! Out of memory error !!!!: 168948k of max 176640k
方法体之后的垃圾回收:459k of max 176640k(Garbage被收集了!)
方法外的第三次分配始终成功:117740k of max 163840k
在gcInMethod()函数内部,我尝试分配一个列表,对它进行迭代,丢弃对该列表的引用,(可选地)强制进行垃圾回收,并再次分配相似的列表。但由于内存不足,我无法分配第二个数组。
同时,在函数体外部,我可以成功地强制进行垃圾回收(可选),并再次分配相同的数组大小!
为了避免函数体内的OutOfMemoryError,只需删除或注释掉这一行:
for (String string : strings);
<--这是罪魁祸首!!!
然后输出就会像这样:
在第一个内存分配之前:1251k最大176640k
第一次内存分配后:131409k最大176640k
迭代后:131409k最大176640k
在方法体内部强制GC后:497k最大176640k(垃圾被收集!)
尝试在方法体内部再次分配内存:
在二次内存分配后:115541k最大163840k
在方法体后GC:493k最大163840k(垃圾被收集!)
方法外的第三次分配总是成功的:121300k最大163840k
因此,在迭代丢弃strings的引用后,成功收集垃圾,并在函数体内部分配第二次()和第三次分配(方法外)。
我的假设:
加利福尼亚州语法结构被编译为
Iterator iter = strings.iterator(); while(iter.hasNext()){ iter.next() }
(我通过反编译 javap -c IteratorAndGc.class
进行了检查)
而且看起来这个 iter 引用一直在作用域中,直到结束。您无法访问引用以将其置为空值,GC 也无法执行收集。
也许这是正常行为(甚至可能在 javac 中指定),但我认为,如果编译器创建了一些实例,它应该在使用后将它们弹出作用域。
这就是我期望实现 for
语句的方式:
Iterator iter = strings.iterator(); while(iter.hasNext()){ iter.next() } iter = null; // <--- flush the water!
使用的 Java 编译器和运行时版本:
javac 1.8.0_111 java version "1.8.0_111" Java(TM) SE Runtime Environment (build 1.8.0_111-b14) Java HotSpot(TM) 64-Bit Server VM (build 25.111-b14, mixed mode)
注:
- 问题并不涉及编程风格、最佳实践、约定等等,它关注的是 Java 平台的效率。
- 问题不涉及
System.gc()
的行为(您可以从示例中删除所有 gc 调用)- 在第二个字符串分配期间,JVM 必须释放已丢弃的内存。
测试 Java 类的参考,在线编译器进行测试(但此资源只有 50 MB 的堆,因此请使用 N = 5000)
所以这实际上是一个有趣的问题,可能会受益于稍微改变措辞。更具体地说,如果重点放在生成的字节码上,将会消除许多混淆。所以让我们这样做。
在给定的代码中:
Listfoo = new ArrayList<>(); for (Integer i : foo) { // nothing }
这是生成的字节码:
0: new #2 // class java/util/ArrayList 3: dup 4: invokespecial #3 // Method java/util/ArrayList."":()V 7: astore_1 8: aload_1 9: invokeinterface #4, 1 // InterfaceMethod java/util/List.iterator:()Ljava/util/Iterator; 14: astore_2 15: aload_2 16: invokeinterface #5, 1 // InterfaceMethod java/util/Iterator.hasNext:()Z 21: ifeq 37 24: aload_2 25: invokeinterface #6, 1 // InterfaceMethod java/util/Iterator.next:()Ljava/lang/Object; 30: checkcast #7 // class java/lang/Integer 33: astore_3 34: goto 15
详细说明如下:
- 将新列表存储在本地变量1(“foo”)中
- 将迭代器存储在本地变量2中
- 对于每个元素,将元素存储在本地变量3中
请注意,在循环之后,没有清理循环中使用的任何内容。这不仅限于迭代器:尽管代码中没有对其的引用,但最后一个元素仍存储在本地变量3中。
因此,在您继续“那是错误的,错误的”之前,让我们看看在上面的代码之后添加以下代码会发生什么:
byte[] bar = new byte[0];
循环之后,您会得到以下字节码:
37: iconst_0 38: newarray byte 40: astore_2
哦,看那个。新声明的本地变量被存储在与迭代器相同的“本地变量”中。因此现在迭代器的引用已经消失了。
请注意,这与您认为等价的Java代码是不同的。实际的Java等价物生成完全相同的字节码:
Listfoo = new ArrayList<>(); for (Iterator i = foo.iterator(); i.hasNext(); ) { Integer val = i.next(); }
仍然没有清理。为什么呢?
然后,我们就在猜测的领域了,除非在JVM规范中明确指定(没有检查)。无论如何,要进行清理,编译器都必须为每个超出范围的变量生成额外的字节码(2条指令,aconst_null
和astore_
)。这意味着代码运行会变慢;为了避免这种情况,可能需要为JIT添加复杂的优化。
那么,为什么您的代码失败了呢?
您最终会遇到与上述类似的情况。迭代器被分配并存储在本地变量1中。然后您的代码尝试分配新的字符串数组,并且由于本地变量1不再使用,它将存储在相同的本地变量中(检查字节码)。但是分配发生在赋值之前,因此仍然存在对迭代器的引用,因此没有内存。
如果在try
块之前添加此行,则即使删除System.gc()
调用,也可以正常工作:
int i = 0;
所以,似乎JVM开发人员做出了选择(生成更小/更高效的字节码,而不是明确地将超出范围的变量设置为null),而您编写的代码不适合他们对人们如何编写代码的假设。鉴于我从未在实际应用程序中遇到过这个问题,对我来说似乎是一件小事。
感谢您提供这个错误报告。我们已经修复了这个错误,请查看JDK-8175883。如在增强型 for 循环的情况下所述,javac 正在生成合成变量,例如:
void foo(String[] data) { for (String s : data); }
javac 大致生成:
for (String[] arr$ = data, len$ = arr$.length, i$ = 0; i$ < len$; ++i$) { String s = arr$[i$]; }
如上所述的翻译方法意味着合成变量arr$持有对数组data的引用,这妨碍了 GC 在该方法中不再引用数组时收集该数组。通过生成以下代码解决了这个问题:
String[] arr$ = data; String s; for (int len$ = arr$.length, i$ = 0; i$ < len$; ++i$) { s = arr$[i$]; } arr$ = null; s = null;
这个想法是将 javac 创建的任何引用类型的合成变量设置为 null 以翻译循环。如果涉及到一个原始类型的数组,那么编译器不会生成最后的赋值为 null。这个错误已经在JDK repo 中得到修复。