Java 8流对象占用内存显著

26

在查看一些分析结果时,我注意到在一个紧密循环中使用流(而不是另一个嵌套的循环)会产生大量类型为java.util.stream.ReferencePipelinejava.util.ArrayList$ArrayListSpliterator的对象的内存开销。我将有问题的流转换为for-each循环后,内存消耗显著降低。

我知道流不能保证比普通循环执行得更好,但我本以为差别微不足道。在这种情况下,似乎增加了40%。

以下是我编写的测试类,用于隔离问题。我使用JFR监视内存消耗和对象分配:

import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.Random;
import java.util.function.Predicate;

public class StreamMemoryTest {

    private static boolean blackHole = false;

    public static List<Integer> getRandListOfSize(int size) {
        ArrayList<Integer> randList = new ArrayList<>(size);
        Random rnGen = new Random();
        for (int i = 0; i < size; i++) {
            randList.add(rnGen.nextInt(100));
        }
        return randList;
    }

    public static boolean getIndexOfNothingManualImpl(List<Integer> nums, Predicate<Integer> predicate) {

        for (Integer num : nums) {
            // Impossible condition
            if (predicate.test(num)) {
                return true;
            }
        }
        return false;
    }

    public static boolean getIndexOfNothingStreamImpl(List<Integer> nums, Predicate<Integer> predicate) {
        Optional<Integer> first = nums.stream().filter(predicate).findFirst();
        return first.isPresent();
    }

    public static void consume(boolean value) {
        blackHole = blackHole && value;
    }

    public static boolean result() {
        return blackHole;
    }

    public static void main(String[] args) {
        // 100 million trials
        int numTrials = 100000000;
        System.out.println("Beginning test");
        for (int i = 0; i < numTrials; i++) {
            List<Integer> randomNums = StreamMemoryTest.getRandListOfSize(100);
            consume(StreamMemoryTest.getIndexOfNothingStreamImpl(randomNums, x -> x < 0));
            // or ...
            // consume(StreamMemoryTest.getIndexOfNothingManualImpl(randomNums, x -> x < 0));
            if (randomNums == null) {
                break;
            }
        }
        System.out.print(StreamMemoryTest.result());
    }
}

流实现:

为TLAB分配的内存为64.62 GB

Class   Average Object Size(bytes)  Total Object Size(bytes)    TLABs   Average TLAB Size(bytes)    Total TLAB Size(bytes)  Pressure(%)
java.lang.Object[]                          415.974 6,226,712   14,969  2,999,696.432   44,902,455,888  64.711
java.util.stream.ReferencePipeline$2        64      131,264     2,051   2,902,510.795   5,953,049,640   8.579
java.util.stream.ReferencePipeline$Head     56      72,744      1,299   3,070,768.043   3,988,927,688   5.749
java.util.stream.ReferencePipeline$2$1      24      25,128      1,047   3,195,726.449   3,345,925,592   4.822
java.util.Random                            32      30,976      968     3,041,212.372   2,943,893,576   4.243
java.util.ArrayList                         24      24,576      1,024   2,720,615.594   2,785,910,368   4.015
java.util.stream.FindOps$FindSink$OfRef     24      18,864      786     3,369,412.295   2,648,358,064   3.817
java.util.ArrayList$ArrayListSpliterator    32      14,720      460     3,080,696.209   1,417,120,256   2.042

手动实现:

为TLAB分配的内存46.06 GB

Class   Average Object Size(bytes)  Total Object Size(bytes)    TLABs   Average TLAB Size(bytes)    Total TLAB Size(bytes)  Pressure(%)
java.lang.Object[]      415.961     4,190,392       10,074      4,042,267.769       40,721,805,504  82.33
java.util.Random        32          32,064          1,002       4,367,131.521       4,375,865,784   8.847
java.util.ArrayList     24          14,976          624         3,530,601.038       2,203,095,048   4.454

还有其他人遇到过流对象本身消耗内存的问题吗?/这是已知的问题吗?


6
是的,这完全可以预料到。对于这么小的输入,流的开销肯定会很大。 - Louis Wasserman
4
虽然不是完全相关的问题,但是 getIndexOfNothingManualImpl 的等价实现可以是 return nums.stream().anyMatch(predicate) 吗? - Zircon
2
我非常有信心,for循环在幕后创建了一个Iterator实现。不知何故,您的分析工具错过了这一点... - Holger
2
有趣的是,使用“手动”实现(即基于Iterator的实现)运行时,应该创建了更多的Random实例,但却创建了显著较少的ArrayList。你怎么能相信这样的数字呢? - Holger
3
顺便提一下,JVM 没有问题地检测到您的“blackHole”变量始终为“false”。由于它未声明为“volatile”,因此优化器不必考虑来自其他线程的更新,在您的顺序代码路径中也没有机会变为“true”。 - Holger
显示剩余2条评论
2个回答

25

使用Stream API确实会分配更多的内存,尽管您的实验设置有些值得质疑。我从未使用过JFR,但我使用JOL的发现与您的相当相似。

请注意,您测量的不仅是在查询ArrayList期间分配的堆,还包括创建和填充期间的分配。单个ArrayList的分配和填充应该如下所示(64位,压缩OOP,通过JOL):

 COUNT       AVG       SUM   DESCRIPTION
     1       416       416   [Ljava.lang.Object;
     1        24        24   java.util.ArrayList
     1        32        32   java.util.Random
     1        24        24   java.util.concurrent.atomic.AtomicLong
     4                 496   (total)

所以分配最多内存的是ArrayList内部使用的Object[]数组来存储数据。AtomicLong是Random类实现的一部分。如果您执行100_000_000次,则这两个测试中至少应该分配496*10^8/2^30 = 46.2 Gb。尽管这部分可以跳过,因为对于两个测试来说它应该是相同的。

另一个有趣的事情是内联。JIT足够聪明,可以通过java -XX:+UnlockDiagnosticVMOptions -XX:+PrintCompilation -XX:+PrintInlining StreamMemoryTest来内联整个getIndexOfNothingManualImpl

  StreamMemoryTest::main @ 13 (59 bytes)
     ...
     @ 30   StreamMemoryTest::getIndexOfNothingManualImpl (43 bytes)   inline (hot)
       @ 1   java.util.ArrayList::iterator (10 bytes)   inline (hot)
        \-> TypeProfile (2132/2132 counts) = java/util/ArrayList
         @ 6   java.util.ArrayList$Itr::<init> (6 bytes)   inline (hot)
           @ 2   java.util.ArrayList$Itr::<init> (26 bytes)   inline (hot)
             @ 6   java.lang.Object::<init> (1 bytes)   inline (hot)
       @ 8   java.util.ArrayList$Itr::hasNext (20 bytes)   inline (hot)
        \-> TypeProfile (215332/215332 counts) = java/util/ArrayList$Itr
         @ 8   java.util.ArrayList::access$100 (5 bytes)   accessor
       @ 17   java.util.ArrayList$Itr::next (66 bytes)   inline (hot)
         @ 1   java.util.ArrayList$Itr::checkForComodification (23 bytes)   inline (hot)
         @ 14   java.util.ArrayList::access$100 (5 bytes)   accessor
       @ 28   StreamMemoryTest$$Lambda$1/791452441::test (8 bytes)   inline (hot)
        \-> TypeProfile (213200/213200 counts) = StreamMemoryTest$$Lambda$1
         @ 4   StreamMemoryTest::lambda$main$0 (13 bytes)   inline (hot)
           @ 1   java.lang.Integer::intValue (5 bytes)   accessor
       @ 8   java.util.ArrayList$Itr::hasNext (20 bytes)   inline (hot)
         @ 8   java.util.ArrayList::access$100 (5 bytes)   accessor
     @ 33   StreamMemoryTest::consume (19 bytes)   inline (hot)

反汇编实际上显示,在预热之后没有迭代器的分配。因为逃逸分析成功地告诉JIT迭代器对象不会逃逸,它被标量化了。如果 Iterator 实际上被分配,那么还需要额外32个字节:

 COUNT       AVG       SUM   DESCRIPTION
     1        32        32   java.util.ArrayList$Itr
     1                  32   (total)

请注意,即时编译器(JIT)也可以完全删除迭代。默认情况下,您的blackhole为false,因此执行blackhole = blackhole && value无论value如何都不会改变它,并且可以完全排除value计算,因为它没有任何副作用。我不确定它是否实际上这样做了(对我来说阅读反汇编很难),但这是可能的。

然而,虽然getIndexOfNothingStreamImpl似乎也内联了其中的所有内容,但由于流API中有太多相互依赖的对象,逃逸分析失败,因此实际发生了分配。因此,它确实添加了五个额外的对象(该表手动从JOL输出组成):

 COUNT       AVG       SUM   DESCRIPTION
     1        32        32   java.util.ArrayList$ArrayListSpliterator
     1        24        24   java.util.stream.FindOps$FindSink$OfRef
     1        64        64   java.util.stream.ReferencePipeline$2
     1        24        24   java.util.stream.ReferencePipeline$2$1
     1        56        56   java.util.stream.ReferencePipeline$Head
     5                 200   (total)
每次调用这个特定流实际上会分配200个额外的字节。当您执行100_000_000次迭代时,总的来说,流版本应该比手动版本多分配10^8*200/2^30 = 18.62Gb,接近您的结果。我认为,在Random内部的AtomicLong也被标量化了,但是在热身迭代期间(直到JIT实际创建了最优化版本),Iterator和AtomicLong都存在。这将解释数字上的小差异。
这200个额外字节的分配不取决于流大小,而取决于中间流操作的数量(特别是每个额外的filter步骤会增加64 + 24 = 88个字节)。然而请注意,这些对象通常是短暂的,快速分配并且可以通过小型GC进行收集。在大多数实际应用程序中,您可能不必担心这个问题。

1
哇,非常棒的答案。 - Bryan J
作为澄清,当您说流API内存在过多相互依赖的对象时,是指一般情况还是特指此场景? - Bryan J
1
@BryanJ,两者都可以。逃逸分析是非常脆弱的东西。只要稍微偏离一些已知的模式,它就会放弃,比如说“我不确定这些对象是否没有逃逸出该方法,所以最好还是分配它们”。例如,如果您分配了两个对象并将它们链接在一起,当前EA的实现肯定会失败,即使它们没有逃逸。 - Tagir Valeev

7
不仅因为需要构建Stream API的基础设施而需要更多内存,而且在速度方面(至少对于这些小输入)可能会更慢。这里有来自Oracle开发人员之一的演示文稿this(它是俄语的,但这不是重点),展示了一个简单的例子(比你的复杂性不大),其中Streams与Loops相比执行速度慢30%。他说这很正常。
我注意到的一件事是,使用Streams(更准确地说是lambda和方法引用)还将创建(潜在地)许多您不知道的类。
尝试使用以下内容运行您的示例:
  -Djdk.internal.lambda.dumpProxyClasses=/Some/Path/Of/Yours

请查看您的代码以及Streams需要的代码(通过ASM),看看会创建多少额外的类。


1
从技术上讲,在理想情况下,流对于数组列表来说也可以更快,因为它们的分裂器只需要进行一次并发修改检查。 - the8472
dumpProxyClasses与流没有任何关系,它是lambda运行时表示的内部实现。如果您像OP一样在没有流的情况下使用lambda,那么您也会有它们。 - Tagir Valeev
@TagirValeev 当然你仍然会使用代理类,我应该更清楚地表达,谢谢评论,我会进行编辑。 - Eugene

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