【问题标题】:Java "for" statement implementation prevents garbage collectingJava“for”语句实现防止垃圾收集
【发布时间】:2017-07-13 04:51:18
【问题描述】:

UPD 21.11.2017:该错误已在 JDK 中修复,请参阅 comment from 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<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

在运行时我有:

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

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

迭代后:131426k of max 176640k

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

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

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

方法体后的GC:459k of max 176640k(垃圾被收集了!)

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

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

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

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

for (String string : strings);

然后输出如下:

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

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

迭代后: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()
}

(我检查了这个反编译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 必须 释放丢弃的内存。

Reference to the test java class, Online compiler to test(但是这个资源只有 50 Mb 的堆,所以使用 N = 5000)

【问题讨论】:

  • 你错误地理解了 GC 是如何工作的。不能保证 GC 会在一次调用后收集任何东西。
  • 该死的,请阅读说明!不是关于 GC 调用的问题!!! “问题不在于 System.gc() 行为(您可以从示例中删除所有 gc 调用) - 在第二个字符串分配期间,JVM 必须释放被丢弃的内存。”问题是关于“for”实现。
  • 您认为迭代器和相关的类加载应该占用 150Mb 的内存?猜不。此外,当我离开函数体时,这些迭代器类不会被删除,但内存会被释放!此外,类被加载到自己的内存中,而不是堆中。
  • 相比什么? 以任何方式 遍历集合,您必须保留对它的引用。 “问题”在于您正在遍历集合,而不是任何东西的“实现”。注意“在第二次字符串分配期间,JVM 必须释放丢弃的内存”是错误的。请放下大胆的脸。它伤害了我的眼睛。
  • @EJP 内存不足!我还在问题中描述了编译器如何编译“for”语句 - 它编译为 iterator-while 结构。

标签: java for-loop memory-management garbage-collection iterator


【解决方案1】:

感谢您的错误报告。我们已修复此错误,请参阅JDK-8175883。正如这里在 enhanced 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 的赋值不是由编译器生成的。该错误已在 repo 中修复 JDK repo

【讨论】:

  • 确定@radistao,np
  • 这不是解决办法,而是一轮打地鼠。相反,如果任何超出范围的引用都将被取消,则不需要这种 hack,开发人员也不必担心其他类似情况。多余的无效(位置随后被重新分配)可以很容易地被微优化掉(对吗?),因此不应该对性能产生任何影响。
  • 不幸的是,当抛出异常时,两种解决方案(当它们超出范围时使所有引用无效,或者这个 hack)都不会起作用。或者会,为什么?顺便说一句,这些问题对于 C++ 程序员来说是必须面对的领域(RAII 规则!)...
  • @RFST 带有垃圾收集的托管代码的原则是内存不是 RAII 意义上的资源。我理解你的印象,但这是过分强调“问题”造成的。实际上,局部变量不会阻止收集它们的所指对象。这些悬空引用只是在没有 JIT 启动的情况下处理大数据的罕见极端情况下的问题(因为只有在解释执行时,JVM 才不会识别这些引用是未使用的)。即使这样,这种循环情况也可以通过利用堆栈映射表在 JVM 端修复。
【解决方案2】:

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

鉴于此代码:

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_&lt;n&gt;)。这意味着代码运行速度较慢;并且为避免这种情况,可能必须将复杂的优化添加到 JIT。

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

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

如果您在 try 块之前添加此行,即使您删除了 System.gc() 调用,一切都会正常工作:

int i = 0;

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

【讨论】:

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

在这里,增强的 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与垃圾回收无关。 Scope is a compile time concept that defines where identifiers and names in a program's source code can be used to refer to program entities.

Garbage collection is driven by reachability. 如果 JVM 可以确定一个对象不能被来自任何活动线程的任何潜在的持续计算访问,那么它将认为它有资格进行垃圾回收。此外,System.gc() 是无用的,因为如果 JVM 无法找到分配新对象的空间,它将执行一次主要收集。

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

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


这是一个不同的场景,即 JVM 在垃圾收集方面的行为与您预期的不完全一样:

【讨论】:

  • 在示例中,您在 ref 对象中保留对字节数组的引用,不是吗?这就是它无法收集的原因。如果我删除 ref 声明或设置为 null - 内存可能会被释放(正如你所说的“成功完成”。但我认为编译器或 JVM 不应该创建任何“死”引用,这些引用会锁定内存,但不再使用。
  • 感谢参考“当看似无关的代码块被注释掉时出现 OutOfMemoryError” - 我也会尝试在 for 循环的字节码引用中找到(但在阅读反编译代码方面没有太多经验) )
  • @radistao 您不必读取字节码。 JLS 定义了enhanced for statement 的编译方式。就像您建议的那样,有一个对 Iterator 的额外引用(它引用了您的对象)。
  • #i is an automatically generated identifier that is distinct from any other identifiers (automatically generated or otherwise) that are in scope (§6.3) at the point where the enhanced for statement occurs. 这就是我要找的!谢谢!你能把它作为一个单独的答案 - 我会标记它
  • @Sotirios Delimanolis:不,局部变量表是与执行无关的可选调试属性。局部变量的存在完全取决于对堆栈帧位置的读取和写入。这就是为什么迭代器引用在for 循环之后悬空的原因:没有其他变量导致其位置被新值覆盖。在实践中,这将是无关紧要的,因为一旦优化器启动,它会进行自己的使用分析,有时会导致对象比您预期的更早被释放。但在这里,它可能会运行解释
【解决方案4】:

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

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

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

很明显,第三点是最难检查的,因此,它并不总是适用,但是当优化器开始工作时,它可能会导致另一个方向的意外,如“Can java finalize an object when it is still in scope?”中所述”和“finalize() called on strongly reachable object in Java 8”。

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

您可以轻松验证是否属于这种情况。当你换行时

ArrayList<String> strings2 = generateLargeStringsArray(N);

ArrayList<String> strings2 = null;
strings2 = generateLargeStringsArray(N);

OutOfMemoryError 消失了。原因是保存在前面for 循环中使用的Iterator 的存储位置此时尚未被覆盖。新的局部变量strings2 将重用存储空间,但这仅在实际写入新值时才会出现。所以null 的初始化之前 调用generateLargeStringsArray(N) 将覆盖Iterator 引用并允许收集旧列表。

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

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

【讨论】:

    【解决方案5】:

    最后,Oracle/Open JKD bug 被接受、批准并修复:

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

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

    从线程中引用 cmets:

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

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

    (这证明了@vanza's expectation,参见this example from the JDK developer

    根据规范,这不应该发生

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

    UPD 21.11.2017:该错误已在 JDK 中修复,请参阅 comment from Vicente Romero

    【讨论】:

      【解决方案6】:

      只是总结一下答案:

      正如@sotirios-delimanolis 提到的in his comment 关于The enhanced for statement - 我的假设是明确定义的:for 糖语句编译为IteratorhasNext()-next() 调用:

      #i 是一个自动生成的标识符,在增强的 for 语句发生时与范围 (§6.3) 内的任何其他标识符(自动生成或以其他方式生成)不同。

      当时@vanza showed in his answer: 这个自动生成的标识符以后可能会或可能不会被覆盖。如果它被覆盖 - 内存可能会被释放,如果没有 - 内存不会再被释放。

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

      是的,我的示例是一个极端情况(在 for 迭代器和下一次内存分配之间没有初始化),但这并不意味着它是 不可能 的情况。但这并不意味着这种情况很难实现——它很可能在有限的内存环境中工作,并且有一些大数据,并立即重新分配它已经使用过的内存。 我在我的工作应用程序中发现了这种情况,我在其中解析一个大型 XML,它“吃掉”了一半以上的内存。

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

      【讨论】:

      • 好吧,正如我在回答中所说,如果您真的遇到这种不寻常的情况并且有内存压力,那么有一个选项可以让您交换一些 CPU 周期来解决这个问题。您的案例也很不寻常,因为它创建了一个大列表,对其进行了一次迭代,然后将其删除。通常,列表的生命周期比迭代器长得多,而且伤害的不是迭代器的存储……
      • 即使是极端情况 - 也应该正确处理。当你创建一个应用程序时,你会跳过极端情况的处理和测试吗?即使在极少数情况下,Java 应用程序之类的东西也应该以可预测的方式工作。并且不应该中继“引用可能会或可能不会被其他调用重用”。还有不能保证自动生成的引用会被重用并且内存被释放!我在实践中发现了这种情况,这就是我在这里发布问题的原因。
      • 恐怕,如果您假设“Java 应用程序应该以可预测的方式工作”而我们只讨论内存消耗和性能,那么 Java 可能不适合您。你不能假设内存的瞬时释放,你不能预测最大递归深度,也不能保证性能。规范中没有任何保证会在此处被违反。除此之外,如前所述,如果您使用 -Xcomp 选项运行它,您的代码将按预期工作。
      • -Xcomp 是“非标准选项”(docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html),因此它可能对不同的 JVM 实现有不同的解释(这里不推荐使用:ibm.com/support/knowledgecenter/SSYKE2_8.0.0/…
      • 释放未使用内存的承诺仅在尽力而为的基础上作出。现在,想一想:如果 Java 从未设法释放该内存,那将是一种可预测的行为。但是一些 JVM 设法在某些条件下释放该内存,那么这会使情况变得更好还是更糟?除此之外,无论如何都没有定义的内存消耗。不同的 JVM 总是可以设法释放该内存,但通常每个对象消耗两倍的内存,因此仍然无法使用该代码。这会让情况变得更好吗?您需要多少内存实现特定的。
      猜你喜欢
      • 2011-12-20
      • 1970-01-01
      • 1970-01-01
      • 2021-01-18
      • 1970-01-01
      • 2011-10-29
      • 2010-12-13
      • 1970-01-01
      • 1970-01-01
      相关资源
      最近更新 更多