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

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

总结:

如果 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

在运行时,我有:

首次分配内存之前:1251k,最大 176640k

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

迭代后:131426k,最大176640k

方法主体中的力GC后:最大176640k的110682k(几乎没有收集任何内容)

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

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

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

方法外的第三次分配始终成功:117740k,最大163840k

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

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

为了避免函数体中的这种 OutOfMemoryError,只需删除/注释以下一行就足够了:

for (字符串字符串 : 字符串);<——这就是邪恶!!!

然后输出如下所示:

首次分配内存之前:1251k,最大 176640k

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

迭代后:131409k,最大176640k

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

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

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

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

方法外的第三次分配始终成功:121300k,最大 163840k

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

我的假设:

用于语法构造编译为

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

(我检查了这个反编译javap -c IteratorAndGc.class)

看起来这个迭代器引用一直停留在范围内直到最后。您无权访问引用以使其无效,并且 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平台的效率。

  • 问题不在于行为(您可以从示例中删除所有gc调用) - 在第二个字符串分配期间,JVM必须释放双卡内存。System.gc()

引用测试java类在线编译器进行测试(但此资源只有50 Mb的堆,因此使用N = 5000)


答案 1

感谢您的错误报告。我们已经修复了这个错误,请参阅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在方法中不再引用数组,就会阻止GC收集数组。此错误已通过生成以下代码得到修复:

String[] arr$ = data;
String s;
for (int len$ = arr$.length, i$ = 0; i$ < len$; ++i$) {
    s = arr$[i$];
}
arr$ = null;
s = null;

这个想法是设置为 null 由 javac 创建的任何引用类型的合成变量来转换循环。如果我们谈论的是基元类型的数组,那么编译器不会生成对 null 的最后一次赋值。该错误已在存储库 JDK 存储库中修复


答案 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条指令和)。这意味着代码运行速度较慢;为了避免这种情况,必须将可能复杂的优化添加到JIT中。aconst_nullastore_<n>

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

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

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

int i = 0;

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