Настолько ли «энергичен» Java 8 Stream API? Возможно ли «превращение» обработки сложных операций над коллекциями в простой и понятный код? Где та выгода от параллельных операций, и когда стоит остановиться? Это одни из многочисленных вопросов, встречающихся читателям. Попробуем разобрать подводные камни Stream API с Тагиром Валеевым aka @lany. Многие читатели уже знакомы с нашим собеседником по статьям, исследованиям в области Java, выразительным докладам на конференциях. Итак, без проволочек, начинаем обсуждение.
— Тагир, у вас отличные показатели на ресурсе StackOverflow (gold status в ветке «java-stream»). Как вы думаете, динамика применения Java 8 Stream API и сложность конструкций выросла (на основе вопросов и ответов на данном ресурсе)?
— Верно, одно время я много времени проводил на StackOverflow, постоянно отслеживая вопросы по Stream API. Сейчас заглядываю периодически, так как, на мой взгляд, на большинство интересных вопросов уже есть ответы. Безусловно, чувствуется, что люди распробовали Stream API, было бы странно, если бы это было не так. Первые вопросы по этой теме появились ещё до выпуска Java 8, когда люди экспериментировали с ранними сборками. Расцвет пришёлся на конец 2014 и 2015-й год.
Многие интересные вопросы связаны не только с тем, что можно сделать со Stream API, но и с тем, чего нормально сделать нельзя без сторонних библиотек. Пользователи, постоянно спрашивая и обсуждая, стремились раздвинуть рамки Stream API. Некоторые из этих вопросов послужили источниками идей для моей библиотеки StreamEx, расширяющей функциональность Java 8 Stream API.
— Вы упомянули про StreamEx. Расскажите, что побудило вас к созданию? Какие цели вы преследовали?
— Мотивы были сугубо практические. Когда на работе мы перешли на Java 8, первая эйфория от красоты и удобства довольно быстро сменилась чередой спотыканий: хотелось сделать с помощью Stream API определённые вещи, которые вроде делаться должны, но по факту не получались. Приходилось удлинять код или отступать от спецификации. Я начал добавлять в рабочие проекты вспомогательные классы и методы для решения данных проблем, но выглядело это некрасиво. Потом я догадался обернуть стандартные стримы в свои классы, которые предлагают ряд дополнительных операций, и работать стало существенно приятнее. Эти классы я выделил в отдельный открытый проект и начал развивать его.
— На ваш взгляд, какие виды расчетов и операций и над какими данными действительно стоит реализовать c использованием Stream API, а что не очень подходит для обработки?
— Stream API любит неизменяемые данные. Если вы хотите поменять существующие структуры данных, а не создать новые, вам нужно что-то другое. Посмотрите в сторону новых стандартных методов (например, List.replaceAll).
Stream API любит независимые данные. Если для получения результата вам нужно использовать одновременно несколько элементов из входного набора, без сторонних библиотек будет очень коряво. Но библиотеки вроде StreamEx часто решают эту проблему.
Stream API любит решать одну задачу за проход. Если вы хотите в один обход данных решить несколько разных задач, готовьтесь писать свои коллекторы. И не факт, что это вообще получится.
Stream API не любит проверяемые исключения. Вам будет не очень удобно кидать их из операций Stream API. Опять же есть библиотеки, которые пытаются это облегчить (скажем, jOO?), но я бы рекомендовал отказываться от проверяемых исключений.
В стандартном Stream API не хватает некоторых операций, которые очень нужны. Например, takeWhile, появится только в Java 9. Может оказаться, что вы хотите чего-то вполне разумного и несложного, но сделать это не получится. Опять же, стоит заметить, что библиотеки вроде jOO? и StreamEx решают большинство таких проблем.
— Как вы считаете, есть ли смысл использовать parallelStream всегда? Какие проблемы могут возникнуть при «переключении» методов из stream на parallelStream?
— Ни в коем случае не надо использовать parallelStream всегда. Его надо использовать исключительно редко, и у вас должен быть хороший повод для этого.
Во-первых, большинство задач, решаемых с помощью Stream API, слишком быстрые по сравнению с накладными расходами на распределение задач по ForkJoinPool и их синхронизацию. Известная статья Дага Ли (Doug Lea) "When to use parallel streams" приводит правило большого пальца: на современных машинах обычно распараллеливать имеет смысл задачи, время выполнения которых превышает 100 микросекунд. Мои тесты показывают, что иногда и 20-микросекундная задача ускоряется от распараллеливания, но это уже зависит от многих факторов.
Во-вторых, даже если ваша задача выполняется долго, не факт, что параллелизм её ускорит. Это зависит и от качества источника, и от промежуточных операций (например, limit для упорядоченного стрима может долго работать), и от терминальных операций (скажем, forEachOrdered может иногда свести на нет выгоду от параллелизма). Самые хорошие промежуточные операции — это операции без состояния (filter, map, flatMap и peek), а самые хорошие терминальные — это семейство reduce/collect, которые ассоциативны, то есть могут эффективно разбить задачу на подзадачи и потом объединить их результаты. И то процедура объединения иногда не очень оптимальна (к примеру, для сложных цепочек groupingBy).
В-третьих, многие люди используют Stream API неверно, нарушая спецификацию. Например, передавая лямбды с внутренним состоянием (stateful) в операции вроде filter и map. Или нарушая требования к единице и ассоциативности в reduce. Не говоря уж о том, сколько неправильных коллекторов пишут. Это часто простительно для последовательных стримов, но совершенно недопустимо для параллельных. Конечно, это не повод писать неправильно, но факт налицо: параллельными стримами пользоваться сложнее, это не просто дописать parallel() где-нибудь.
И, наконец, даже если у вас стрим выполняется долго, операции в нём легко параллелятся и вы всё делаете правильно, стоит задуматься, действительно ли у вас простаивают ядра процессора, что вы готовы их отдать параллельным стримам? Если у вас веб-сервис, который постоянно загружен запросами, вполне возможно, что обрабатывать каждый запрос отдельным потоком будет разумнее. Только если у вас ядер достаточно много, либо система не загружена полностью, можно задуматься о параллельных стримах. Возможно, кстати, стоит устанавливать java.util.concurrent.ForkJoinPool.common.parallelism для ограничения параллельных стримов.
Например, если у вас 16 ядер и обычно 12 загружено, попробуйте установить уровень параллелизма 4, чтобы занять стримами оставшиеся ядра. Общих советов, конечно, нет: надо всегда проверять.
— В продолжение разговора о параллелизации, можно ли говорить о том, что на производительность влияет объем и структура данных, количество ядер процессора? Какие источники данных (например, LinkedList) не стоит обрабатывать в параллель?
— LinkedList ещё не самый худший источник. Он, по крайней мере, свой размер знает, что позволяет Stream API удачнее дробить задачи. Хуже всего для параллельности источники, которые по сути последовательны (как LinkedList) и при этом не сообщают свой размер. Обычно это то, что создано через Spliterators.spliteratorUnknownSize(), либо через AbstractSpliterator без указания размера. Примеры из JDK — Stream.iterate(), Files.list(), Files.walk(), BufferedReader.lines(), Pattern.splitAsStream() и так далее. Я говорил об этом на докладе «Странности Stream API» на JPoint в этом году. Там очень плохая реализация, которая приводит, например, к тому, что если этот источник содержит 1024 элемента или менее, то он не параллелится вообще. И даже потом параллелится довольно плохо. Для более или менее нормального параллелизма вам нужно, чтобы в нём были десятки тысяч элементов. В StreamEx реализация лучше. Например, StreamEx.ofLines(reader) (аналог BufferedReader.lines()) будет параллелиться неплохо даже для небольших файлов. Если у вас плохой источник и вы хотите его распараллелить, часто эффективнее сперва последовательно его собрать в список (например, Stream.iterate(…).collect(toList()).parallelStream()…)
Большинство стандартных структур данных из JDK являются хорошими источниками. Опасайтесь структур и обёрток из сторонних библиотек, которые совместимы с Java 7. В них не может быть переопределён метод spliterator() (потому что в Java 7 нет сплитераторов), поэтому они будут использовать реализацию Collection.spliterator() или List.spliterator() по умолчанию, которая, конечно, плохо параллелится, потому что ничего не знает о вашей структуре данных и просто оборачивает итератор. В девятке это улучшится для списков со случайным доступом.
— При использовании промежуточных операций, на ваш взгляд, какое пороговое значение их в Stream — конвейере и как это определяется? Существуют ли ограничения (явные и неявные)?
— Я бы не сказал, что есть жёсткие ограничения. Большинство промежуточных операций Stream API увеличивают глубину стека на один вызов. С одной стороны, это может снижать эффективность инлайнинга (по умолчанию MaxInlineLevel = 9 в HotSport JVM). Но обычно это играет роль только в изолированных бенчмарках. В реальных приложениях вызовы всё равно слишком полиморфные, чтобы получить большую выгоду от инлайнинга. Длинные стектрейсы могут засорять логи, замедлять создание объектов исключений или просто пугать новичков. Но, в целом, они безобидны. Никаких проблем иметь несколько десятков промежуточных операций, если вам действительно надо. Разумеется, не следует наращивать стрим неконтролируемо, например, в цикле while(something) stream = stream.map(x -> ...). При последующем выполнении терминальной операции есть риск упасть со StackOverflowError.
Наличие методов упорядочивания коллекций во время обработки (промежуточная операция sorted()) или упорядоченного источника данных и последующая работа с ним с помощью map, filter и reduce операций могут привести к повышению производительности?
Нет, вряд ли. Только операция distinct() использует тот факт, что вход сортирован. Она меняет алгоритм, сравнивая элемент с предыдущим, а без сортировки приходится держать HashSet. Однако для этого источник должен сообщить, что он сортирован. Все сортированные источники из JDK (BitSet, TreeSet, IntStream.range) уже содержат уникальные элементы, поэтому для них distinct() бесполезен. Ну, теоретически операция filter может что-то выиграть из-за лучшего предсказания ветвлений в процессоре, если она на первой половине набора истинна, а на второй ложна. Но если данные уже отсортированы по предикату, эффективнее не использовать Stream API, а найти границу с помощью бинарного поиска. Причём сортировка сама по себе медленная, если данные на входе плохо сортированы. Поэтому, скажем, sorted().distinct() для случайных данных будет медленнее, чем просто distinct(), хотя сам distinct() ускорится.
— Необходимо затронуть важные вопросы, связанные с отладкой кода. Вы используете метод peek(), для получения промежуточных результатов? Возможно, что у вас есть свои секреты тестирования? Поделитесь, пожалуйста, ими с читателями.
— Я почему-то не пользуюсь peek() для отладки. Если стрим достаточно сложный и что-то непонятное происходит в процессе, можно разбить его на несколько (с промежуточным списком) и посмотреть на этот список. Вообще можно привыкнуть обходить стрим в обычном пошаговом отладчике в IDE. Поначалу это страшно, но потом привыкаешь.
Когда я разрабатываю новые сплитераторы и коллекторы, я использую вспомогательные методы в тестах, которые подвергают их всестороннему тестированию, проверяя различные инварианты и запуская в разных условиях. Скажем, я не только сравниваю, что результат параллельного и последовательного стрима совпадает, а могу в параллельный стрим вставить искусственный сплитератор, который наплодит пустых фрагментов при создании параллельных задач. Они не должны влиять на результат и помогают найти нетривиальные баги. Или при тестировании сплитераторов я случайным образом дроблю их на подзадачи, которые выполняю в случайном порядке (но в одном потоке) и сверяю результат с последовательным. Это стабильный воспроизводимый тест, который хотя и однопоточный, но позволяет отловить большинство ошибок в распараллеленных сплитераторах. Вообще, крутая система тестов, которая всесторонне проверяет каждый кирпичик кода и в случае ошибок выдаёт вменяемый отчёт, обычно вполне заменяет отладку.
— Какое развитие Stream API вы видите в будущем?
— Сложный вопрос, я не умею предсказывать будущее. Сейчас многое упирается в наличие четырёх специализаций Stream API (Stream, IntStream, LongStream, DoubleStream), поэтому многий код приходится дублировать четыре раза, чего мало кому хочется. Все с нетерпением ждут специализацию дженериков, которую, вероятно, доделают в Java 10. Тогда будет проще.
Также есть проблемы с расширением Stream API. Как известно, Stream — это интерфейс, а не какой-нибудь финальный класс. С одной стороны, это позволяет расширять Stream API сторонним разработчикам. С другой стороны, добавлять новые методы в Stream API теперь не так-то легко: надо не сломать все те классы, который уже в Java 8 реализовали этот интерфейс. Каждый новый метод должен предоставить реализацию по умолчанию, выраженную в терминах существующих методов, что не всегда возможно и легко. Поэтому взрывного роста функциональности вряд ли стоит ожидать.
Самое важное, что появится в Java 9, — это методы takeWhile и dropWhile. Будут мелкие приятные штуки — Stream.ofNullable, Optional.stream, iterate с тремя аргументами и несколько новых коллекторов — flatMapping, filtering. Но, в целом, многого всё ещё будет не хватать. Зато появятся дополнительные методы в JDK, которые создают стрим: новые API теперь разрабатывают с оглядкой на стримы, да и старые подтягивают.
— Многие запомнили ваше выступление в 2015 году с докладом «Что же мы измеряем?». В этом году вы планируете выступить с новой темой на Joker? О чем пойдет речь?
— Я решил делать новый доклад, который не очень творчески назову «Причуды Stream API». Это будет в некотором смысле продолжение доклада «Странности Stream API» с JPoint: я расскажу о неожиданных эффектах производительности и скользких местах Stream API, акцентируя внимание на том, что будет исправлено в Java 9.
— Спасибо большое за интересные и подробные ответы. С нетерпением ждем ваше новое выступление.
Прикоснуться к миру Stream API и другого Java-хардкора можно будет на конференции Joker 2016. Там же — вопросы спикерам, дискуссии вокруг докладов и бесконечный нетворкинг.
Комментарии (8)
vsb
18.08.2016 19:56+1Интересно, а во сколько раз код со стримами медленней аналогичного кода c циклами и тд. Мне концепция нравится, но применяю пока очень редко в заведомо некритичных для производительности местах, нет времени хорошо потестировать скорость. Навскидку кажется, что в худшем случае производительность будет ужасной, т.к. будет куча боксингов-анбоксингов, виртуальных вызовов и тд против нескольких машинных инструкций в случае обычного цикла.
lany
19.08.2016 03:20+5Если вы будете складывать с помощью Stream API подряд идущие числа, то можете потерять в скорости по сравнению с циклом раз в 10-15. Хотя зачем всё это, если можно воспользоваться формулой n*(n+1)/2, которая будет гораздо быстрее цикла.
Если вы делаете хоть что-то нетривиальное (хотя бы строки конкатенируете), то накладные расходы от стримов редко замедляют процесс вдвое. Для существенной части бизнес-логики вы не потеряете более 10-30%. Если у вас в соседней строке выполняется SQL-запрос, то вам должно быть совершенно наплевать на оверхед от стримов.
Боксингов там обычно нет, если правильно пользоваться стримами. Чаще всего дополнительных поэлементных выделений памяти вообще нет по сравнению с аналогичным циклом. Виртуальные вызовы есть, это проблема, из-за них основной оверхед и происходит.
lany
19.08.2016 11:28+3Вот, кстати, простенький бенчмарк, который показывает, что я даже недооцениваю стримы.
# JMH 1.13 (released 27 days ago) # VM version: JDK 1.8.0_91, VM 25.91-b14 # VM invoker: C:\Program Files\Java\jre1.8.0_91\bin\java.exe Benchmark (n) (pollute) Mode Cnt Score Error Units Concat.stream 10 true avgt 15 0,335 ± 0,008 us/op Concat.stream 10 false avgt 15 0,312 ± 0,006 us/op Concat.stream 1000 true avgt 15 26,733 ± 0,740 us/op Concat.stream 1000 false avgt 15 24,910 ± 0,477 us/op Concat.stream 100000 true avgt 15 3127,121 ± 299,316 us/op Concat.stream 100000 false avgt 15 2869,882 ± 86,572 us/op Concat.plain 10 true avgt 15 0,254 ± 0,011 us/op Concat.plain 10 false avgt 15 0,258 ± 0,013 us/op Concat.plain 1000 true avgt 15 25,366 ± 0,438 us/op Concat.plain 1000 false avgt 15 24,992 ± 0,705 us/op Concat.plain 100000 true avgt 15 2829,553 ± 162,468 us/op Concat.plain 100000 false avgt 15 2762,287 ± 122,223 us/op
С грязным профилем типов конкатенация 10 строчек проседает на 30% при использовании стримов, а конкатенация 1000 и 100000 строчек — всего процентов на 7-10. Причём строки короткие. Длинные медленнее конкатенируются и разница будет меньше. Я детально не вникал, но разница, вероятно, как раз из-за виртуальных вызовов, потому что на чистом профиле производительность вообще одинаковая для 1000 и 100000 строчек.
В общем, стоит задумываться, если у вас большое количество мелких стримов. А так провал в производительности не должен вас беспокоить. Стандартное правило — оптимизируйте то, что тормозит. Стримы можно использовать почти повсеместно в бизнес-логике. Самое медленное место вряд ли окажется именно в стримах.
burjui
20.08.2016 00:27+2Стандартное правило — оптимизируйте то, что тормозит.
И к нему ещё одно: тормозные места ищите профайлером, а не шестым чувством.
shishmakov
groupBy не будет в Java 9?
lany
Что вы имеете в виду? Приведите пример желаемого кода и желаемый эффект от него.
shishmakov
// { 1 = (1, 3), 0 = (2, 4) }
Seq.of(1, 2, 3, 4).groupBy(i -> i % 2);
// (tuple(1, (1, 3)), tuple(0, (2, 4)))
Seq.of(1, 2, 3, 4).grouped(i -> i % 2);
из jOO?
lany
А, вы хотите шорткат к
Stream.of(1, 2, 3, 4).collect(groupingBy(i -> i % 2))
? Нет, не будет, конечно. Стандартная библиотека выдерживается в минималистичном виде и экономию десятка символов уж точно никто не оправдает. В StreamEx у меня есть аналогичный шорткат (StreamEx.of(1, 2, 3, 4).groupingBy(i -> i % 2)
).Ну а туплов в Java вроде и не планируется, соответственно аналога
grouped
точно не ждите. Можно сделатьStream.of(1, 2, 3, 4).collect(groupingBy(i -> i % 2)).entrySet().stream()
— эффект аналогичный (получитеStream<Entry<Integer, Integer>>
). Можете приниматьEntry
за тупл.