Java "for"语句实现防止垃圾收集

22

更新于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<String> 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<String> 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<String> 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<String> generateLargeStringsArray(int N) {
        ArrayList<String> 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

在运行时我有:

Before first memory allocating: 1251k of max 176640k

After first memory allocation: 131426k of max 176640k

After iteration: 131426k of max 176640k

After force GC in the method body: 110682k of max 176640k (almost nothing collected)

Try to allocate memory in the method body again:

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

GC after the method body: 459k of max 176640k (the garbage is collected!)

Third allocation outside the method is always successful: 117740k of max 163840k

因此,在gcInMethod()内部,我尝试分配列表,迭代它,丢弃对列表的引用,(可选)强制垃圾回收并再次分配类似的列表。但是由于缺乏内存,我无法分配第二个数组。

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

为了避免函数体内的OutOfMemoryError,只需删除/注释掉这一行:

for (String string : strings); <-- 这就是罪魁祸首!!!

然后输出如下:

在第一次内存分配之前:1251k of max 176640k

第一次内存分配后:131409k of max 176640k

迭代后:131409k of max 176640k

在方法体中强制GC后:497k of max 176640k(垃圾已被清除!)

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

在第二次内存分配之后:115541k of max 163840k

方法体后的GC:493k of max 163840k(垃圾已被清除!)

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

因此,如果没有for迭代,在丢弃对字符串的引用后,垃圾可以成功收集,并在第二次(函数体内)和第三次(方法外部)分配。

我的假设:

for语法结构编译为

Iterator iter = strings.iterator();
while(iter.hasNext()){
    iter.next()
}

(and i checked this decompiling javap -c IteratorAndGc.class)
并且我通过反编译 javap -c IteratorAndGc.class 进行了检查。
而且看起来这个 iter 引用一直保持在作用域内,你无法访问引用以将其置为null,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)


2
您错误地理解了GC的工作方式。调用一次后,没有任何保证GC会收集任何内容。 - Andremoniy
2
你认为迭代器和相关的类加载应该占用150Mb的内存吗?我猜不会。而且,当我离开函数体时,这些迭代器类并没有被移除,但是内存被释放了!此外,类被加载到自己的内存中,而不是堆中。 - radistao
1
无论如何:使用“-XX:+TraceClassLoading”进行测试,所有类都在第一次内存分配之前加载。 - radistao
3
与什么相比?无论你如何遍历集合,都必须保留对它的引用。问题在于你正在遍历集合,而不是任何实现上。注意:“在第二个字符串分配期间JVM必须释放被丢弃的内存”是错误的。请不要使用粗体字,它会伤害我的眼睛。 - user207421
2
@EJP并且它运行时内存不足!我在问题中还描述了编译器如何编译“for”语句——编译为迭代器-while结构。 - radistao
显示剩余9条评论
6个回答

15

感谢您的bug报告。我们已经修复了这个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$持有对数组data的引用,这会阻止GC在方法内不再引用该数组时收集该数组。通过生成以下代码来修复此错误:

正如上述所提到的,这种翻译方法意味着合成变量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赋值。该bug已在repo JDK repo中修复。


没问题 @radistao,不用客气 - Vicente Romero
那不是一个修复,而是一轮打“鼹鼠”的游戏。如果任何超出作用域的引用都被置空,将不需要这个hack,开发人员也不必担心其他类似情况。多余的清空操作(后续该位置会重新赋值)可以轻松地进行微观优化,因此不应该有任何性能影响。 - RFST
不幸的是,当抛出异常时,无论是将所有引用作废还是使用这种方法,都无法解决问题。或者可以吗?为什么?顺便说一句,对于C++程序员来说,这些问题都是“扇脸”领域(RAII规则!)... - RFST
1
@RFST 管理代码和垃圾回收的原则是内存不是 RAII 意义上的资源。我理解你的印象,但这是由于“问题”的过度强调所造成的。实际上,局部变量并不会阻止其引用对象的回收。这些悬空引用只在处理大数据且 JIT 未启动时才会出现问题(因为只有在解释执行时 JVM 才无法识别这些引用未被使用)。即使在这种循环情况下,也可以通过利用堆栈映射表在 JVM 方面进行修复。 - Holger

5

这实际上是一个有趣的问题,但需要稍微改一下措辞。更具体地说,如果将焦点放在生成的字节码上,就可以消除很多困惑。因此,让我们来看看这个。

给定这段代码:

List<Integer> foo = new ArrayList<>();
for (Integer i : foo) {
  // nothing
}

这是生成的字节码:

   0: new           #2                  // class java/util/ArrayList
   3: dup           
   4: invokespecial #3                  // Method java/util/ArrayList."<init>":()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<Integer> foo = new ArrayList<>();
for (Iterator<Integer> i = foo.iterator(); i.hasNext(); ) {
  Integer val = i.next();
}

为什么还没有清理呢?

嗯,这里我们就要猜测了,除非在JVM规范中有明确说明(我还没有检查)。无论如何,要进行清理,编译器都必须为每个超出作用域的变量生成额外的字节码(2条指令:aconst_nullastore_<n>)。这意味着代码运行速度会变慢;为了避免这种情况,可能需要向JIT添加复杂的优化。

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

你最终会遇到与上述类似的情况。迭代器被分配并存储在本地变量1中。然后你的代码尝试分配新的字符串数组,由于本地变量1不再使用,它将被存储在同一本地变量中(检查字节码)。但是分配发生在赋值之前,因此仍然存在对迭代器的引用,因此没有内存可用。

如果在try块之前添加以下行,即使删除System.gc()调用,也可以正常工作:

int i = 0;

所以,看起来JVM开发人员做出了一个选择(生成更小/更高效的字节码而不是显式地清空超出范围的变量),而你恰好编写了一些在他们对人们如何编写代码的假设下不能很好地运行的代码。考虑到我从未在实际应用程序中看到过这个问题,对我来说似乎是一个小问题。


这对我来说似乎是最适用的答案:迭代器引用的“自动生成标识符”会保留内存,直到下次使用该标识符。 - radistao
但是即使:1)如果它是一个边缘情况-它必须被考虑(好的开发人员会跳过边缘情况吗?);2)平台(编译器+ VM)不应以未定义的方式工作,当变量可能被重用或可能不被重用时,因此内存是否释放;3)为了“更有效率”而牺牲稳定性和确定性似乎不是一个好选择;4)我在我的工作应用程序中发现了这个“边缘情况”,在大型XML解析和迭代之后,我进入了内存不足状态。 - radistao

4
这里增强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声明(以及它的初始化),程序将成功完成。
首先需要明白的是,作用域与垃圾回收没有任何关系。作用域是一个编译时概念,定义了源代码中标识符和名称可以用于引用程序实体的位置。 垃圾回收由可达性驱动。如果JVM能够确定对象不能被任何活动线程的潜在继续计算访问,那么它将考虑将其视为可回收的。此外,System.gc()是无用的,因为如果JVM找不到空间来分配新对象,它将执行一次主要的收集。
因此问题变成了:为什么如果我们将byte[]对象存储在第二个本地变量中,JVM无法确定它不再被访问
我对此没有答案。不同的垃圾回收算法(和JVM)在这方面可能会有不同的行为。似乎这个JVM在本地变量表中有第二个引用指向该对象时,并不将该对象标记为不可达。

以下是一个不同的情况,其中JVM在垃圾回收方面的行为并不完全符合您的预期:


1
@radistao,你不必阅读字节码。JLS定义了如何编译增强型for语句。有一个额外的引用到Iterator(引用你的对象),就像你建议的那样。 - Sotirios Delimanolis
现在第二个问题是:(对我来说听起来是“是的”)在使用后是否合理丢弃“自动生成的标识符”? - radistao
@radistao 关于标识符,不存在“丢弃”这种情况。Java将局部变量的使用编译成局部变量表(在.class文件中),该表定义了字节码中给定点处活动的变量。 - Sotirios Delimanolis
让我们在聊天中继续这个讨论 - radistao
2
@Sotirios Delimanolis:不,局部变量表是一个可选的调试属性,与执行无关。局部变量的存在纯粹由对堆栈帧位置的读写决定。这就是为什么在for循环之后迭代器引用会失效的原因:没有其他变量导致其位置被新值覆盖。实际上,这将是无关紧要的,因为一旦优化器启动,它将进行自己的使用分析,有时会比您预期的更早地释放对象。但在这里,它可能会运行解释。 - Holger
显示剩余4条评论

4
如其他答案中已经提到的那样,变量作用域的概念在运行时是未知的。在编译后的类文件中,局部变量只是堆栈帧内的位置(由索引表示),对其进行写入和读取。如果多个变量具有不相交的作用域,则它们可以使用相同的索引,但没有正式声明它们。只有写入新值才会丢弃旧值。

因此,本地变量存储中保存的引用被认为未使用有三种方式:

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

显然,第三点是最难检查的,因此并不总是适用,但当优化器开始工作时,它可能会导致出乎意料的结果,如“Java是否可以在对象仍在范围内时完成对象的finalize操作?”和“Java 8中在强可达对象上调用finalize()”所解释的那样。

在您的情况下,应用程序运行时间很短,可能是非优化的,这可能会导致由于点3而未被识别为未使用的引用,当点1和2不适用时。
您可以轻松验证这一点。当您更改以下行时:
ArrayList<String> strings2 = generateLargeStringsArray(N);

ArrayList<String> strings2 = null;
strings2 = generateLargeStringsArray(N);
OutOfMemoryError 消失的原因是在前面的 for 循环中使用的 Iterator 所占用的存储位置此时还没有被覆盖。新的局部变量 strings2 将重用该存储,但只有在实际写入新值时才会体现出来。因此,在调用 generateLargeStringsArray(N) 之前使用 null 进行初始化将覆盖 Iterator 引用并允许旧列表被收集。

或者,您可以使用选项 -Xcomp 在原始形式下运行程序。这将强制编译所有方法。在我的机器上,它启动速度有所减慢,但由于变量使用分析,OutOfMemoryError 也消失了。

在初始化期间(与最大堆大小相比),即大多数方法运行解释时分配那么多内存的应用程序是一个不寻常的边角情况。通常,在内存消耗达到如此之高之前,大多数热门方法都已经足够编译。如果您在实际应用程序中反复遇到这种边缘情况,那么 -Xcomp 可能适合您。


1

最终,Oracle/Open JDK 的错误已被接受、批准并修复:

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

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

引用帖子中的评论:

这是8和9都能够复现的问题

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

(这证明了@vanza的期望,参见来自JDK开发人员的示例)

根据规范,这不应该发生

(这是对我的问题的回答:如果编译器创建了一些实例,则应在使用后将其从作用域中丢弃)

更新 2017年11月21日: JDK中已修复此错误,请参见Vicente Romero的评论


0

简单总结一下答案:

正如@sotirios-delimanolis在他的评论中提到},关于{{link2:增强型for语句 - 我的假设是明确定义的:编译for语法糖语句为Iterator,并调用hasNext()-next()

#i是一个自动生成的标识符,在增强型for语句发生的地方,它与任何其他标识符(自动生成的或其他)不同,并且处于范围内(§6.3)。

正如@vanza在他的回答中所示:

这个自动生成的标识符可能会被覆盖,也可能不会。如果它被覆盖了-内存可能会被释放,如果没有-内存就不再释放。

对我来说,仍然存在一个问题:如果Java编译器或JVM创建了一些隐式引用,那么它随后不应该关心丢弃这些引用吗?有没有保证在下一次内存分配之前将重用相同的自动生成的迭代器引用?难道不应该有一个规则:那些分配内存的人负责释放它吗?我想说-它必须关心这个。否则行为是未定义的(它可能会导致OutOfMemoryError,也可能不会-谁知道...)

是的,我的例子是一个特殊情况(在for迭代器和下一个内存分配之间没有初始化任何内容),但这并不意味着它是不可能的情况。这也不意味着这种情况很难实现——在有限的内存环境中使用一些大型数据并立即重新分配内存是相当可能的。

我在我的工作应用程序中发现了这种情况,其中我解析了一个大型XML文件,它“吃掉”了超过一半的内存。

(问题不仅涉及迭代器和for循环,我猜这是一个常见问题:编译器或JVM有时不清理自己的隐式引用)。


1
好的,就像我的回答中所述,如果您真的遇到了这种不寻常的情况并且有内存压力,那么有一个选项可以让您交换一些CPU周期来解决此问题。您的情况也很不寻常,因为它创建了一个大列表,对其进行了一次迭代,然后将其丢弃。通常,列表的生命周期比迭代器长得多,而不是迭代器的存储造成问题... - Holger
1
很抱歉,如果您认为“Java应用程序应该以可预测的方式工作”,并且我们仅谈论内存消耗和性能方面,那么Java可能不适合您。您不能假设内存会立即释放,您无法预测最大递归深度,并且性能也没有保障。规范中没有提供任何被违反的保证。此外,如前所述,如果使用“-Xcomp”选项运行代码,它将按预期工作。 - Holger
-Xcomp 是“非标准选项”(http://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html),因此不同 JVM 实现可能有不同的解释(这里已经被弃用:https://www.ibm.com/support/knowledgecenter/SSYKE2_8.0.0/com.ibm.java.zos.80.doc/diag/appendixes/cmdline/xcomp.html)。 - radistao
1
释放未使用内存的承诺仅基于最佳努力原则。现在,想一想:如果Java从未成功释放该内存,那将是可预测的行为。但某些JVM在特定条件下可以释放该内存,那么这会使情况变得更好还是更糟呢?此外,无论如何都没有定义的内存消耗量。不同的JVM可能总是能够释放该内存,但通常每个对象的内存消耗量可能会增加一倍,因此仍然无法通过该代码。这会使情况变得更好吗?您需要多少内存实现特定的。 - Holger
1
但是如果您认为这是一个错误,您可以提交错误报告,而不是在Stackoverflow上讨论它... - Holger
显示剩余4条评论

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