Java“for”语句实现可防止垃圾回收

UPD 21.11.2017:该错误已在JDK中修复,请参阅Vicente Romero的评论

概要:

如果for语句用于任何Iterable实现,则集合将保留在堆内存中,直到当前作用域(方法,语句体)结束,即使您没有对集合的任何其他引用,也不会进行垃圾回收。应用程序需要分配新内存。

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 

在运行时我有:

在第一次内存分配之前:最大176640k的1251k

第一次内存分配后:最大176640k的131426k

迭代后:最大176640k的131426k

在方法体中强制GC后:最大176640k的110682k(几乎没有收集)

尝试再次在方法体中分配内存:

  !!!! Out of memory error !!!!: 168948k of max 176640k 

方法体后的GC:最大176640k的459k(收集垃圾!)

方法外的第三次分配总是成功:最大​​163840k的117740k

所以,在gcInMethod()里面我尝试分配列表,迭代它,丢弃对列表的引用,(可选)强制垃圾收集并再次分配类似的列表。 但由于内存不足,我无法分配第二个数组。

同时,在函数体之外我可以成功强制垃圾收集(可选)并再次分配相同的数组大小!

为了避免函数体内部出现这种OutOfMemoryError ,只需删除/注释这一行:

for (String string : strings); < – 这是邪恶的!

然后输出看起来像这样:

在第一次内存分配之前:最大176640k的1251k

第一次内存分配后:最大176640k的131409k

迭代后:最大176640k的131409k

在方法体中强制GC后:最大176640k的497k(垃圾被收集!)

尝试再次在方法体中分配内存:

二次内存分配后:最大163840k的115541k

GC方法体后:最大163840k 493k(收集垃圾!)

方法外的第三次分配总是成功的:最大163840k的121300k

因此,无需在丢弃对字符串的引用后成功收集垃圾,并分配第二次(在函数体内)并分配第三次(在方法之外)。

我的假设:

用于编译语法构造

 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)

谢谢你的bug报告。 我们已修复此错误,请参阅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 $保存对数组数据的引用,该数组阻止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。 该错误已在repo JDK repo中修复

这里,增强的for语句的唯一相关部分是对象的额外本地引用。

你的例子可以简化为

 public class Example { private static final int length = (int) (Runtime.getRuntime().maxMemory() * 0.8); public static void main(String[] args) { byte[] data = new byte[length]; Object ref = data; // this is the effect of your "foreach loop" data = null; // ref = null; // uncommenting this also makes this complete successfully byte[] data2 = new byte[length]; } } 

该程序也会因OutOfMemoryError失败。 如果删除ref声明(及其初始化),它将成功完成。

您需要了解的第一件事是范围与垃圾收集无关。 Scope是一个编译时概念,它定义程序源代码中的标识符和名称可用于引用程序实体的位置。

垃圾收集由可达性驱动。 如果JVM可以确定任何活动线程的任何潜在持续计算都无法访问对象,那么它将认为它有资格进行垃圾回收。 此外, System.gc()是无用的,因为如果JVM找不到分配新对象的空间,它将执行主要集合。

所以问题就变成了: 如果我们将它存储在第二个局部变量中,为什么JVM不能确定不再访问byte[]对象

我没有答案。 在这方面,不同的垃圾收集算法(和JVM)可能表现不同。 当局部变量表中的第二个条目具有对该对象的引用时,似乎此JVM不会将该对象标记为无法访问。


这是一个不同的场景,其中JVM的行为与您在迁移时对垃圾收集的预期完全不同:

  • 当看似无关的代码块注释掉时OutOfMemoryError

所以这实际上是一个有趣的问题,可以从略有不同的措辞中受益。 更具体地说,专注于生成的字节码反而会清除很多混乱。 所以,让我们这样做。

鉴于此代码:

 List foo = 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等价物生成完全相同的字节码,如下所示:

 List foo = new ArrayList<>(); for (Iterator i = foo.iterator(); i.hasNext(); ) { Integer val = i.next(); } 

而且还没有清理。 为什么?

好吧,这里我们在猜测领域,除非它实际上是在JVM规范中指定的(尚未检查)。 无论如何,要进行清理,编译器必须为超出范围的每个变量生成额外的字节码(2条指令, aconst_nullastore_ )。 这意味着代码运行速度较慢; 为了避免这种情况,可能需要将复杂的优化添加到JIT中。

那么,为什么你的代码失败了?

你最终处于与上述类似的情况。 迭代器被分配并存储在局部变量1中。然后您的代码尝试分配新的字符串数组,并且由于局部变量1不再使用,它​​将存储在相同的局部变量中(检查字节码)。 但是分配发生在赋值之前,所以仍然有对迭代器的引用,所以没有内存。

如果你在try块之前添加这一行,即使你删除了System.gc()调用,事情仍然有效:

 int i = 0; 

因此,似乎JVM开发人员做出了选择(生成更小/更高效的字节码而不是显式地使变量超出范围),并且您碰巧编写的代码在他们对人们如何做出的假设下表现不佳写代码。 鉴于我在实际应用中从未见过这个问题,对我来说似乎是件小事。

正如在其他答案中已经说明的那样,变量范围的概念在运行时是未知的。 在已编译的类文件中,局部变量仅位于堆栈帧(由索引寻址)内,执行写入和读取。 如果多个变量具有析取范围,则它们可以使用相同的索引,但是没有正式声明它们。 只有写入新值才会丢弃旧值。

因此,有三种方法,如何将局部变量存储中的引用视为未使用:

  1. 存储位置将被新值覆盖
  2. 该方法退出
  3. 没有后续代码读取该值

显而易见的是,第三点是最难检查的,因此,它并不总是适用,但是当优化器开始工作时,它可能会导致另一个方向的意外,如“ Java可以在最终确定对象时所解释的那样” 它还在范围内? “和” finalize()在Java 8中调用强可达对象 “。

在您的情况下,应用程序很快就会运行并且可能非优化,这可能导致由于第3点和第1点和第2点不适用而导致引用未被识别为未使用。

您可以轻松validation是否是这种情况。 当你改变线

 ArrayList strings2 = generateLargeStringsArray(N); 

 ArrayList strings2 = null; strings2 = generateLargeStringsArray(N); 

OutOfMemoryError消失了。 原因是此时没有覆盖保存前一个for循环中使用的Iterator的存储位置。 新的局部变量strings2将重用存储,但这仅在实际写入新值时显示。 因此, 调用generateLargeStringsArray(N) 之前使用null初始化将覆盖Iterator引用并允许收集旧列表。

或者,您可以使用选项-Xcomp以原始格式运行程序。 这迫使所有方法的编译。 在我的机器上,它有一个明显的启动减速,但由于变量使用分析, OutOfMemoryError也消失了。

让应用程序在初始化期间分配大量内存(与最大堆大小相比),即大多数方法运行时解释,是一个不寻常的极端情况。 通常,在内存消耗很高之前,大多数热方法都已充分编译。 如果您在现实应用程序中反复遇到此角落案例,那么-Xcomp可能对您-Xcomp

最后,Oracle / Open JKD错误被接受,批准和修复:

http://bugs.java.com/bugdatabase/view_bug.do?bug_id=JDK-8175883

https://bugs.openjdk.java.net/browse/JDK-8175883

引用线程中的注释:

这是一个在8和9都可重现的问题

存在一些问题,程序保持它自己的内存块的隐式自动生成引用,直到下一次隐式使用并且其内存被锁定而导致OOM

(这certificate了@vanza的期望 ,请参阅JDK开发人员的这个例子 )

根据规范,这不应该发生

(这是我的问题的答案: 如果编译器创建了一些实例,它应该关心在使用后将它们从范围中丢弃

UPD 21.11.2017:该错误已在JDK中修复,请参阅Vicente Romero的评论

只是总结一下答案:

正如@ sotirios-delimanolis 在他关于增强for语句的 评论中提到的那样 – 我的假设是明确定义的: for sugar语句使用hasNext()编译为Iteratornext()调用:

#i是一个自动生成的标识符,它与发生增强for语句时的范围(第6.3节 )中的任何其他标识符(自动生成的或其他标识符)不同。

当时@vanza 在他的回答中表示 :这个自动生成的标识符可能会或者可能不会在以后被覆盖。 如果被覆盖 – 内存可能被释放,如果没有 – 内存不再被释放。

仍然(对我来说)是一个悬而未决的问题: 如果Java编译器或JVM创建了一些隐式引用,那么稍后它是否应该关注丢弃这些引用呢? 是否可以保证在下一次内存分配之前的下一次调用中将重用相同的自动生成的迭代器引用 ? 不应该是一个规则:那些分配记忆然后关心释放它的人? 我会说 – 它必须关心这一点。 否则行为是未定义的(它可能会落到OutOfMemoryError,或者可能不会 – 谁知道……)

是的,我的例子是一个极端情况(没有在迭代器和下一个内存分配之间初始化),但这并不意味着它是不可能的情况。 这并不意味着这种情况很难实现 – 很可能在有限的内存环境中使用一些大数据并立即重新分配内存。 我在我的工作应用程序中找到了这种情况,我解析了一个大的XML,它“占用”超过一半的内存。

(问题不仅仅是关于迭代器和for循环,猜测它是常见问题:编译器或JVM有时不会清理自己的隐式引用)。