Java 8 потоков объектов значительный объем использования памяти

При рассмотрении некоторых профилирующих результатов я заметил, что использование потоков в узком цикле (вместо другого вложенного цикла) вызвало значительные издержки памяти для объектов типов java.util.stream.ReferencePipeline и java.util.ArrayList$ArrayListSpliterator. Я преобразовал прерывающие потоки в циклы foreach, и потребление памяти значительно уменьшилось.

Я знаю, что потоки не делают promises для выполнения каких-либо лучше обычных циклов, но у меня создалось впечатление, что разница будет незначительной. В этом случае казалось, что это на 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 очень похожи на ваши.

Обратите внимание, что вы измеряете не только кучу, выделенную во время запроса ArrayList, но и во время ее создания и совокупности. Распределение во время распределения и совокупности одиночного ArrayList должно выглядеть следующим образом (64 бит, сжатые ООП, через 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)

Разборка на самом деле показывает, что после разминки не выполняется выделение итератора. Поскольку анализ escape успешно сообщает JIT, что объект итератора не исчезает, он просто сканируется. Если бы на самом деле было выделено Iterator, то дополнительно 32 байта:

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

Обратите внимание, что JIT также может удалить итерацию вообще. По умолчанию ваш blackhole является ложным, поэтому blackhole = blackhole && value не меняет его независимо от value, а вычисление value вообще может быть исключено, так как оно не имеет побочных эффектов. Я не уверен, действительно ли это произошло (чтение разборки для меня довольно сложно), но это возможно.

Однако в то время как getIndexOfNothingStreamImpl также, кажется, встроен во внутреннее пространство, анализ escape не выполняется, поскольку в потоковом 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 также сканируется, но как теги Iterator, так и AtomicLong присутствуют во время итераций разминки (пока JIT фактически не создаст самую оптимизированную версию). Это объясняет незначительные расхождения в числах.

Это дополнительное 200-байтовое распределение не зависит от размера потока, но зависит от количества операций промежуточного потока (в частности, каждый дополнительный шаг фильтра добавит 64 + 24 = 88 байт). Однако обратите внимание, что эти объекты обычно недолговечны, быстро распределяются и могут быть собраны небольшим GC. В большинстве реальных приложений вам, вероятно, не стоит беспокоиться об этом.

Ответ 2

Не только больше памяти из-за инфраструктуры, необходимой для создания Stream API. Но также это может быть медленнее с точки зрения скорости (по крайней мере, для этих небольших входов).

Существует эта презентация от одного из разработчиков от Oracle (это на русском языке, но это не так), который показывает тривиальный пример (не намного более сложный, чем ваш), где скорость выполнения на 30% хуже в случае потоков против циклов. Он говорит, что это довольно нормально.

Одна вещь, которую я заметил, что многие люди не понимают, заключается в том, что использование Streams (точнее, ссылок на lambda и методы) также создаст (потенциально) множество классов, о которых вы не узнаете.

Попробуйте запустить ваш пример с помощью

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

И посмотрите, сколько дополнительных классов будет создано вашим кодом и кодом, который требуется потокам (через ASM)