Читаемость кода, производительность и ограничения Streams
Выпуск Java 8 стал знаменательным событием в истории Java. Были представлены потоки и лямбда-выражения, и теперь они широко используются. Если вы не знаете о Streams или никогда о нем не слышали, ничего страшного. В большинстве случаев циклы удовлетворят ваши потребности, и у вас не возникнет проблем без потоков.
Тогда зачем нам Streams? Могут ли они заменить или иметь преимущества перед циклами? В этой статье мы рассмотрим код, сравним производительность и посмотрим, насколько хороши потоки в качестве замены циклов.
Сравнение кодов
Потоки увеличивают сложность кода, поскольку им нужны классы, интерфейсы и импорт; петли, напротив, встроены по своей природе. Это верно в некоторых моментах, но не обязательно. Сложность кода намного больше, чем то количество вещей, которые вам нужно знать. Это больше о том, насколько читабелен код. Давайте посмотрим на некоторые примеры.
Список имен элементов с типом
Допустим, у нас есть список элементов и нам нужен список имен определенных типов элементов. Используя циклы, вы напишете следующее:
List<String> getItemNamesOfType(List<Item> items, Item.Type type) { List<String> itemNames = new ArrayList<>(); for (Item item : items) { if (item.type() == type) { itemNames.add(item.name()); } } return itemNames; }
Читая код, вы увидите, что должен быть создан новый экземпляр ArrayList
, а проверка типов и вызов add()
должны выполняться в каждом цикле. С другой стороны, вот потоковая версия того же результата:
List<String> getItemNamesOfTypeStream(List<Item> items, Item.Type type) { return items.stream() .filter(item -> item.type() == type) .map(item -> item.name()) .toList(); }
С помощью Lambda можно сразу уловить, что мы сначала выбираем элементы с заданным типом, а затем получаем список имен отфильтрованных элементов. В таком коде построчный поток хорошо согласуется с логическим потоком.
Создать случайный список
Давайте посмотрим на другой пример. В разделе «Сравнение времени» мы рассмотрим ключевые методы Streams и сравним время их выполнения с циклами. Для этого нам нужен случайный список Item
s. Вот фрагмент со статическим методом, который выдает случайное Item
:
public record Item(Type type, String name) { public enum Type { WEAPON, ARMOR, HELMET, GLOVES, BOOTS, } private static final Random random = new Random(); private static final String[] NAMES = { "beginner", "knight", "king", "dragon", }; public static Item random() { return new Item( Type.values()[random.nextInt(Type.values().length)], NAMES[random.nextInt(NAMES.length)]); } }
Теперь давайте создадим список случайных Item
, используя циклы. Код будет выглядеть так:
List<Item> items = new ArrayList<>(100); for (int i = 0; i < 100; i++) { items.add(Item.random()); }
Код с Streams выглядит так:
List<Item> items = Stream.generate(Item::random).limit(length).toList();
Замечательный и легко читаемый код. Кроме того, List
, возвращаемый методом toList()
, не поддается изменению, что дает вам неизменность, поэтому вы можете использовать его в любом месте кода, не беспокоясь о побочных эффектах. Это делает код менее подверженным ошибкам, и читатели легче понимают ваш код.
Потоки предоставляют множество полезных методов, позволяющих писать краткие коды. Самые популярные из них:
allMatch()
anyMatch()
count()
filter()
findFirst()
forEach()
map()
reduce()
sorted()
limit()
- И многое другое в Stream Javadoc
Производительность
Потоки ведут себя как циклы в обычных обстоятельствах и практически не влияют на время выполнения. Давайте сравним некоторые основные варианты поведения в Streams с реализациями цикла.
Повторить элементы
Когда у вас есть коллекция элементов, существует множество случаев, когда вы перебираете все элементы внутри коллекции. В потоках такие методы, как forEach()
, map()
, reduce()
и filter()
, выполняют такую итерацию всего элемента.
Давайте подумаем о случае, когда мы хотим подсчитать каждый тип элемента в списке. Код с циклом for будет выглядеть так:
public Map<Item.Type, Integer> loop(List<Item> items) { Map<Item.Type, Integer> map = new HashMap<>(); for (Item item : items) { map.compute(item.type(), (key, value) -> { if (value == null) return 1; return value + 1; }); } return map; }
Код с Streams выглядит так:
public Map<Item.Type, Integer> stream(List<Item> items) { return items.stream().collect(Collectors.toMap( Item::type, value -> 1, Integer::sum)); }
Они выглядят совсем иначе, но как они будут работать? Ниже приведена таблица среднего времени выполнения из 100 попыток:
Как видно из приведенной выше сравнительной таблицы, потоки и циклы показывают небольшую разницу во времени выполнения при повторении всего списка. В большинстве случаев это то же самое для других методов Stream, таких как map()
, forEach()
, reduce()
и т. д.
Оптимизация с параллельным потоком
Итак, мы обнаружили, что потоки не работают лучше или хуже циклов при повторении списка. Однако в потоках есть удивительная вещь, которой нет у циклов: мы можем легко выполнять многопоточные вычисления с помощью потоков. Все, что вам нужно сделать, это использовать parallelStream()
вместо stream()
.
Чтобы увидеть, какое влияние мы можем получить от этого, давайте посмотрим на следующий пример, где мы имитируем долговременную задачу следующим образом:
private void longTask() { // Mock long task. try { Thread.sleep(1); } catch (InterruptedException e) { throw new RuntimeException(e); } }
Перебор списка будет выглядеть так:
protected void loop(List<Item> items) { for (Item item : items) { longTask(); } }
Потоки будут выглядеть так:
protected void stream(List<Item> items) { items.stream().forEach(item -> longTask()); }
И, наконец, параллельные потоки будут выглядеть так:
protected void parallel(List<Item> items) { items.parallelStream().forEach(item -> longTask()); }
Обратите внимание, что только stream()
изменилось на parallelStream()
.
Вот сравнение:
Как и ожидалось, циклы и потоки мало чем отличаются друг от друга. Тогда как насчет параллельных потоков? Сенсационно! Это экономит более 80% времени выполнения по сравнению с другими реализациями! Как это возможно?
Что касается задач, которые занимают много времени и должны выполняться для каждого элемента в списке независимо, то они могут выполняться одновременно, и мы можем ожидать значительного улучшения. Это то, что делают параллельные потоки. Они распределяют их по нескольким потокам и заставляют выполняться одновременно.
Параллельные потоки лишь иногда выигрывают, если их можно использовать везде вместо циклов или потоков. Это полезно только тогда, когда задачи независимы. Если задачи не являются независимыми и должны совместно использовать одни и те же ресурсы, вам придется защитить их с помощью блокировки, в основном с помощью ключевого слова synchronized
в Java, и заставить их выполняться медленнее, чем обычные итерации.
Ограничения
Однако потоки также имеют ограничения. Один случай — условные циклы, а другой — повторения. Давайте посмотрим, что они означают.
Условные циклы
Когда мы хотим повторять, пока условие не станет истинным, но не уверены, сколько итераций это займет, мы обычно используем цикл while
.
boolean condition = true; while (condition) { ... condition = doSomething(); }
Код, который ведет себя так же, используя Streams, выглядит так:
Stream.iterate(true, condition -> condition, condition -> doSomething()) .forEach(unused -> ...);
Вы можете видеть, что некоторые стандартные части мешают чтению, например, condition -> condition
, который проверяет истинность условия, и параметр unused
внутри файла forEach()
. Учитывая это, условные циклы лучше писать в while
циклах.
Повторение
Повторение — одна из основных причин существования петли for
. Допустим, мы хотим повторить процесс десять раз. С циклом for
его можно легко записать так:
for (int i = 0; i < 10; i++) { ... }
В Streams один из способов добиться этого — создать IntStream
, содержащий [0, 1, 2, ... , 9]
, и повторить его.
IntStream.range(0, 10).forEach(i -> ...);
Хотя код может выглядеть кратким и правильным, он выглядит более сфокусированным на значениях диапазона от 0 до 10 (исключительно), где код цикла for
можно прочитать, повторив десять раз, поскольку более общим является запись повтора таким образом: начиная с 0 и заканчивая числом повторений.
Краткое содержание
Мы провели несколько сравнений между потоками и циклами. Итак… могут ли потоки заменить циклы? Ну, как всегда, все зависит от ситуации! Однако потоки обычно могут предоставить вам более лаконичный, легко читаемый код и оптимизации.
И так, чего же ты ждешь? Идите вперед и начните писать свои коды с помощью Streams!
Коды, написанные для этой статьи, можно найти на моем GitHub.