Читаемость кода, производительность и ограничения 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 и сравним время их выполнения с циклами. Для этого нам нужен случайный список Items. Вот фрагмент со статическим методом, который выдает случайное 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.