【问题标题】:Java 8 stream objects significant memory usageJava 8 流对象显着的内存使用
【发布时间】:2016-12-19 20:51:31
【问题描述】:

在查看一些分析结果时,我注意到在紧密循环中使用流(使用而不是另一个嵌套循环)会导致 java.util.stream.ReferencePipelinejava.util.ArrayList$ArrayListSpliterator 类型的对象的显着内存开销。我将有问题的流转换为 foreach 循环,内存消耗显着减少。

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

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

【问题讨论】:

  • 是的,不,这完全可以预料。对于这么小的输入,流的开销肯定会很大。
  • 不完全相关,但不等于getIndexOfNothingManualImplreturn nums.stream().anyMatch(predicate)
  • 我很有信心,for 循环在底层创建了一个Iterator 实现。不知何故,您的分析器错过了……
  • 有趣的是,使用“手动”实现(阅读:基于Iterator 的实现)运行应该创建了更多Random 实例,但是显着更少的ArrayLists。你怎么能相信这样的数字?
  • 顺便说一句,JVM 检测到您的blackHole 变量始终为false 是没有问题的。由于它没有声明volatile,优化器不必考虑来自其他线程的更新,并且在您的顺序代码路径中,它不可能转到true

标签: java memory java-8 java-stream


【解决方案1】:

使用 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)

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

这里的另一个有趣的事情是内联。 JIT 足够聪明,可以内联整个getIndexOfNothingManualImpl(通过java -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 迭代器对象没有转义,所以它只是被标量化了。如果Iterator 实际分配,它会额外占用 32 个字节:

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

请注意,JIT 也可以完全删除迭代。您的 blackhole 默认为 false,因此无论 value 是什么,blackhole = blackhole &amp;&amp; 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 次迭代时,Stream 版本应该比手动版本多分配 10^8*200/2^30 = 18.62Gb,这与您的结果接近。我认为,AtomicLong 内部 Random 也是标量化的,但是在热身迭代期间IteratorAtomicLong 都存在(直到 JIT 实际上创建了最优化的版本)。这可以解释数字上的细微差异。

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

【讨论】:

  • 很棒的答案。
  • 澄清一点,当你说流API内部有太多相互依赖的对象时,你是指一般情况下还是这种情况下?
  • @BryanJ,两者都有。逃逸分析是一件非常脆弱的事情。除了一些已知的模式之外,它只是放弃了“或者,我不确定这些对象是否不会逃避该方法,所以最好分配它们”。例如。如果您分配两个对象并将它们相互链接,那么当前的 EA 实现肯定会失​​败,即使它们没有逃脱。
【解决方案2】:

不仅由于构建 Stream API 所需的基础设施需要更多内存。但是,它可能在速度方面会更慢(至少对于这么小的输入)。

来自 Oracle 的一位开发人员的 this 演示文稿(它是俄语,但这不是重点)展示了一个执行速度为 30% 的简单示例(并不比你的复杂多少)在 Streams vs Loops 的情况下更糟。他说这很正常。

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

尝试运行您的示例:

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

并查看您的代码和 Streams 需要的代码(通过 ASM)会创建多少额外的类

【讨论】:

  • 从技术上讲,在理想情况下,数组列表的流也可以更快,因为它们的拆分器只需执行一次并发修改检查。
  • dumpProxyClasses 与流无关,它是 lambdas 运行时表示的内部实现。如果你使用没有流的 lambdas(就像 OP 一样),你也会拥有它们。
  • @TagirValeev 当然你仍然会使用代理类,我应该说更清楚,谢谢评论,将编辑。
猜你喜欢
  • 2015-07-13
  • 1970-01-01
  • 2016-09-21
  • 2015-10-24
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2020-05-16
相关资源
最近更新 更多