Java 8 流对象大量内存使用量

2022-09-01 17:32:47

在查看一些性能分析结果时,我注意到在紧循环中使用流(用于代替另一个嵌套循环)会产生大量类型和 .我将有问题的流转换为 foreach 循环,内存消耗显著降低。java.util.stream.ReferencePipelinejava.util.ArrayList$ArrayListSpliterator

我知道流没有承诺比普通循环表现得更好,但我的印象是,这种差异可以忽略不计。在这种情况下,它似乎增加了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());
    }
}

流实现:

Memory Allocated for TLABs 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

手动实现:

Memory Allocated for TLABs 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

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


答案 1

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

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

 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)

因此,分配的内存最多是内部用于存储数据的数组。 是随机类实现的一部分。如果执行此 100_000_000 次,则至少应该在两个测试中都已分配。但是,可以跳过此部分,因为它对于两个测试应该是相同的。Object[]ArrayListAtomicLong496*10^8/2^30 = 46.2 Gb

这里另一个有趣的事情是内联。JIT足够智能,可以内联整个(通过):getIndexOfNothingManualImpljava -XX:+UnlockDiagnosticVMOptions -XX:+PrintCompilation -XX:+PrintInlining StreamMemoryTest

  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 迭代器对象不转义,因此它只是标量化。如果实际分配,它将额外需要32个字节:Iterator

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

请注意,JIT 还可以删除迭代。默认情况下,您的是假的,因此无论 ,都不会更改它,并且计算可能根本不被排除,因为它没有任何副作用。我不确定它是否真的做到了这一点(阅读反汇编对我来说很难),但这是可能的。blackholeblackhole = blackhole && valuevaluevalue

但是,虽然似乎也内联了内部的所有内容,但转义分析失败了,因为流 API 中存在太多相互依赖的对象,因此会发生实际分配。因此,它确实添加了五个额外的对象(该表是从JOL输出手动编写的):getIndexOfNothingStreamImpl

 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。我认为,内部也是标量化的,但两者都在预热迭代期间存在(直到JIT实际创建最优化的版本)。这可以解释数字中的微小差异。AtomicLongRandomIteratorAtomicLong

这额外的 200 字节分配不取决于流大小,而是取决于中间流操作的数量(特别是,每个额外的筛选步骤都会增加 64+24=88 个字节)。但请注意,这些对象通常是短暂的,分配速度很快,可以由次要GC收集。在大多数实际应用程序中,您可能不必担心这一点。


答案 2

不仅由于构建流 API 所需的基础结构而增加了内存。但是,就速度而言,它可能更慢(至少对于这个小输入)。

来自Oracle的一位开发人员的演示(它是俄语的,但这不是重点),它显示了一个微不足道的示例(不会比你的复杂得多),其中执行速度在Streams与Loops的情况下差30%。他说这很正常。

我注意到的一件事是,没有多少人意识到,使用Streams(lambda和方法引用更精确)也会创建(可能)很多你不会知道的类。

尝试使用以下命令运行您的示例:

  -Djdk.internal.lambda.dumpProxyClasses=/Some/Path/Of/Yours

并查看您的代码和 Streams 所需的代码将创建多少个其他类(通过 ASM)