Является ли Java 8 потоковая лень бесполезной на практике?

в последнее время я много читал о потоках Java 8 и несколько статей о ленивой загрузке с потоками Java 8:здесь и здесь. Я не могу избавиться от ощущения, что ленивая загрузка совершенно бесполезна (или, в лучшем случае, незначительное синтаксическое удобство, предлагающее нулевое значение производительности).

давайте возьмем этот код в качестве примера:

int[] myInts = new int[]{1,2,3,5,8,13,21};

IntStream myIntStream = IntStream.of(myInts);

int[] myChangedArray = myIntStream
                        .peek(n -> System.out.println("About to square: " + n))
                        .map(n -> (int)Math.pow(n, 2))
                        .peek(n -> System.out.println("Done squaring, result: " + n))
                        .toArray();

это войдет в консоль, потому что terminal operation в этом случае toArray(), is вызывается, и наш поток ленив и выполняется только тогда, когда вызывается операция терминала. Конечно, я тоже могу это сделать:

  IntStream myChangedInts = myIntStream
    .peek(n -> System.out.println("About to square: " + n))
    .map(n -> (int)Math.pow(n, 2))
    .peek(n -> System.out.println("Done squaring, result: " + n));

и ничего не будет напечатано, потому что карта не происходит, потому что мне не нужны данные. Пока я не назову это:

  int[] myChangedArray = myChangedInts.toArray();

и вуаля, я получаю свои сопоставленные данные и журналы консоли. Только я не вижу в этом никакой пользы. Я понимаю, что могу!--19-->определение длинный фильтр код до вызова к toArray(), и я могу пройти по этот " не-действительно-фильтрованный поток вокруг), но что с того? Это единственная выгода?

статьи, похоже, подразумевают, что есть прирост производительности, связанный с ленью, например:

в API потоков Java 8 промежуточные операции ленивы, и их внутренняя модель обработки оптимизирована, чтобы сделать ее способной обрабатывать большой объем данных с высокой производительностью.

и

Java 8 Streams API оптимизирует обработку потока с помощью операций короткого замыкания. Методы короткого замыкания завершают обработку потока, как только выполняются их условия. В нормальных словах деятельность короткого замыкания, как только условие удовлетворено как раз ломает все промежуточные деятельности, лежа перед в трубопроводе. Некоторые из промежуточных, а также терминальных операций имеют такое поведение.

это звучит буквально как вырваться из петли, а не ассоциируется с ленью вообще.

наконец, во второй статье есть эта озадачивающая строка:

ленивые деятельности достигают эффективности. Это способ не работать с устаревшими данными. Ленивые операции могут быть полезны в ситуациях, когда входные данные потребляются постепенно, а не с полным набором элементов заранее. Например, рассмотрим ситуации, когда бесконечный поток был создан с помощью Stream#generate (Supplier) и предоставленного Функция поставщика постепенно получает данные с удаленного сервера. В таких ситуациях вызов сервера будет производиться только в терминальной операции, когда это необходимо.

не работает над устаревшими данными? Что? Как ленивая загрузка удерживает кого-то от работы над устаревшими данными?


TLDR: есть ли какая-либо польза от ленивой загрузки, кроме возможности запуска фильтра / карты / уменьшения / любой операции в более позднее время (что обеспечивает нулевую производительность выгода)?

если да, то что такое реальный случай использования?

7 ответов


ваши работы терминала, toArray(), возможно, поддерживает ваш аргумент, учитывая, что он требует всех элементов потока.

некоторые терминальные операции не делают. И для них было бы пустой тратой, если бы потоки не выполнялись лениво. Два примера:

//example 1: print first element of 1000 after transformations
IntStream.range(0, 1000)
    .peek(System.out::println)
    .mapToObj(String::valueOf)
    .peek(System.out::println)
    .findFirst()
    .ifPresent(System.out::println);

//example 2: check if any value has an even key
boolean valid = records.
    .map(this::heavyConversion)
    .filter(this::checkWithWebService)
    .mapToInt(Record::getKey)
    .anyMatch(i -> i % 2 == 0)

первый поток будет печатать:

0
0
0

то есть промежуточные операции будут выполняться только на одном элементе. Это важная оптимизация. Если бы не лень, то все peek() звонков должны работать на всех элементах (абсолютно лишнее как вас интересует только один элемент). Промежуточные операции могут быть дорогостоящими (например, во втором примере)

операция короткого замыкания терминала (из которых toArray нет) сделать эту оптимизацию возможной.


у меня есть реальный пример из нашей базы кода, так как я собираюсь упростить его, не совсем уверен, что вам это понравится или полностью понять...

у нас есть служба, которая нуждается в List<CustomService>, Я должен назвать это. Теперь, чтобы вызвать его, я иду в базу данных (намного проще, чем реальность) и получаю List<DBObject>, чтобы получить List<CustomService> из этого есть некоторые тяжелые преобразования, которые необходимо сделать.

и вот мой выбор, преобразование на месте и передать список. Простой, но, вероятно, не оптимальным. Второй вариант, рефакторинг службы, чтобы принять List<DBObject> и Function<DBObject, CustomService>. И это звучит тривиально, но это позволяет лень (между прочим). Эта служба может иногда нуждаться только в нескольких элементах из этого списка, или иногда max некоторым свойством и т. д. - таким образом, мне не нужно делать тяжелую трансформацию для все элементы, где Stream API тяга на основе лени победитель.

до того, как потоки существовали, мы использовали guava. Это было Lists.transform( list, function) это тоже было лениво.

это не фундаментальная особенность потоков как таковых, это могло бы быть сделано даже без гуавы, но это намного проще. Пример здесь приведен с findFirst велик и простейший для понимания; в этом весь смысл лени, элементы тянутся только тогда, когда это необходимо, они не передаются от промежуточной операции к другой кусками, а проходят от одной стадии к другой за раз.


лень может быть очень полезно для пользователей вашего API, особенно когда конечный результат Stream оценка конвейера может быть очень большой!

простой пример -Files.lines метод в самом API Java. Если вы не хотите читать весь файл в память и вам нужен только первый N строки, то просто написать:

Stream<String> stream = Files.lines(path); // lazy operation

List<String> result = stream.limit(N).collect(Collectors.toList()); // read and collect

вы правы, что не будет пользы от map().reduce() или map().collect(), но есть довольно очевидное преимущество с findAny() findFirst(), anyMatch(), allMatch(), etc. В принципе, любая операция, которая может быть закорочена.


один интересный случай использования, который не был упомянут, - это произвольный состав операций над потоками, поступающими из разных частей базы кода, реагирующими на различные виды бизнеса или технические требования.

например, скажем, у вас есть приложение, где некоторые пользователи могут видеть все данные, но некоторые другие пользователи могут видеть только часть его. Часть кода, которая проверяет разрешения пользователя, может просто наложить фильтр на любой передаваемый поток о.

без ленивых потоков эта же часть кода может фильтровать уже реализованную полную коллекцию, но это может быть дорого, без реальной выгоды.

кроме того, та же часть кода может захотеть добавить свой фильтр к источнику данных, но теперь он должен знать, поступают ли данные из базы данных, поэтому он может наложить дополнительное предложение WHERE или какой-либо другой источник.

с ленивыми потоками, это фильтр, который может быть реализовано всегда так. Фильтры, наложенные на потоки из базы данных, могут преобразовываться в вышеупомянутое предложение WHERE с очевидным повышением производительности по сравнению с фильтрацией коллекций в памяти в результате чтения всей таблицы.

Итак, лучшая абстракция, лучшая производительность, лучшая читаемость кода и ремонтопригодность, звучит как победа для меня. :)


хороший вопрос.

предполагая, что вы пишете идеальный код учебника, разница в производительности между правильно оптимизирован for и stream не заметно (потоки, как правило, немного лучше загружаются классом, но в большинстве случаев разница не должна быть заметна).

рассмотрим следующий пример.

// Some lengthy computation
private static int doStuff(int i) {
    try { Thread.sleep(1000); } catch (InterruptedException e) { }
    return i;
}

public static OptionalInt findFirstGreaterThanStream(int value) {
    return IntStream
            .of(MY_INTS)
            .map(Main::doStuff)
            .filter(x -> x > value)
            .findFirst();
}

public static OptionalInt findFirstGreaterThanFor(int value) {
    for (int i = 0; i < MY_INTS.length; i++) {
        int mapped = Main.doStuff(MY_INTS[i]);
        if(mapped > value){
            return OptionalInt.of(mapped);
        }
    }
    return OptionalInt.empty();
}

учитывая вышеуказанные методы, следующий тест должен показать, что они выполняются примерно в том же время.

public static void main(String[] args) {
    long begin;
    long end;

    begin = System.currentTimeMillis();
    System.out.println(findFirstGreaterThanStream(5));
    end = System.currentTimeMillis();
    System.out.println(end-begin);

    begin = System.currentTimeMillis();
    System.out.println(findFirstGreaterThanFor(5));
    end = System.currentTimeMillis();
    System.out.println(end-begin);
}

OptionalInt[8]

5119

OptionalInt[8]

5001

в любом случае, мы проводим большую часть времени в doStuff метод. Допустим, мы хотим добавить больше нитей в смеси.

настройка метода stream тривиальна (учитывая, что ваши операции отвечают предварительным условиям параллельных потоков).

public static OptionalInt findFirstGreaterThanParallelStream(int value) {
    return IntStream
            .of(MY_INTS)
            .parallel()
            .map(Main::doStuff)
            .filter(x -> x > value)
            .findFirst();
}

достижение того же поведения без потоков может быть сложной.

public static OptionalInt findFirstGreaterThanParallelFor(int value, Executor executor) {
    AtomicInteger counter = new AtomicInteger(0);

    CompletableFuture<OptionalInt> cf = CompletableFuture.supplyAsync(() -> {
        while(counter.get() != MY_INTS.length-1);
        return OptionalInt.empty();
    });

    for (int i = 0; i < MY_INTS.length; i++) {
        final int current = MY_INTS[i];
        executor.execute(() -> {
            int mapped = Main.doStuff(current);
            if(mapped > value){
                cf.complete(OptionalInt.of(mapped));
            } else {
                counter.incrementAndGet();
            }
        });
    }

    try {
        return cf.get();
    } catch (InterruptedException | ExecutionException e) {
        e.printStackTrace();
        return OptionalInt.empty();
    }
}

тесты выполняются примерно в то же время снова.

public static void main(String[] args) {
    long begin;
    long end;

    begin = System.currentTimeMillis();
    System.out.println(findFirstGreaterThanParallelStream(5));
    end = System.currentTimeMillis();
    System.out.println(end-begin);

    ExecutorService executor = Executors.newFixedThreadPool(10);
    begin = System.currentTimeMillis();
    System.out.println(findFirstGreaterThanParallelFor(5678, executor));
    end = System.currentTimeMillis();
    System.out.println(end-begin);

    executor.shutdown();
    executor.awaitTermination(10, TimeUnit.SECONDS);
    executor.shutdownNow();
}

OptionalInt[8]

1004

OptionalInt[8]

1004

в заключение, хотя мы не выжимаем большое преимущество производительности из потоков (учитывая, что вы пишете отличный многопоточный код в своем for alternative), сам код имеет тенденцию быть больше ремонтопригодный.

a (немного не по теме) последнее примечание:

как и в языках программирования, абстракции более высокого уровня (streams по отношению к fors) сделать материал проще разрабатывать за счет производительности. Мы не перешли от ассемблера к процедурным языкам и к объектно-ориентированным языкам, поскольку более поздние предлагали более высокую производительность. Мы переехали, потому что это сделало нас более продуктивными (развивайте то же самое по более низкой цене). Если вы можете получить такая же производительность из потока, как и с for и правильно написал многопоточный код, я бы сказал, что это уже победа.


non-lazy реализация будет обрабатывать все входные данные и собирать выходные данные в новую коллекцию для каждой операции. Очевидно, что это невозможно для неограниченных или достаточно больших источников, потребляющих память в противном случае и излишне потребляющих память в случае сокращения и короткого замыкания, поэтому есть большие преимущества.