«…преждевременная оптимизация — корень всех зол…»
Дональд Эрвин Кнут
Введение
Большинство разработчиков хорошо знают эту цитату, которую приписывают Дональду Кнуту — автору книги «Искусство программирования» и одному из наиболее влиятельных учёных в области информатики. Многие усвоили и вытекающий из неё практический вывод: «Сначала заставим программу работать, а производительностью займёмся потом». В конце концов, добавить ещё один экземпляр EC2 проще, чем искать первопричину проблемы.
Но вот что Кнут написал на самом деле:
«Нам следует забыть о незначительном повышении эффективности, скажем, в 97% случаев: преждевременная оптимизация — корень всех зол. Однако мы не должны упускать возможности, которые открываются в оставшихся критически важных 3%».
Согласитесь, звучит несколько иначе? Второе предложение практически никогда не цитируют — и это очень удобно, поскольку позволяет превратить взвешенное утверждение в простое оправдание. Иногда — собственной лени. Иногда — убеждения, что оптимизация обязательно требует жертвовать читаемостью кода: использовать непонятные битовые операции, хитроумные трюки и конструкции, в которых способен разобраться только их автор, да и то в два часа ночи.
Полагаю, Кнут действительно предостерегал именно от такой оптимизации. Однако представление о том, что производительность и читаемость несовместимы, ошибочно гораздо чаще, чем принято считать. Хороший, чистый код нередко оказывается одновременно и эффективным. И это не случайность: выбор подходящего инструмента для конкретной задачи зачастую делает решение и понятнее, и быстрее. Примеры из этой статьи служат тому подтверждением.
Область рассмотрения
В этой статье мы сосредоточимся на простых, малозатратных и практически безошибочных приёмах оптимизации, которые можно применять повсеместно — независимо от архитектуры, используемого фреймворка или предметной области. По моему опыту, риск ухудшить ситуацию при их использовании практически отсутствует.
Вопросы архитектуры, проектирования, сетевого взаимодействия, подключения к базам данных и многопоточности мы намеренно оставим за рамками статьи. Не потому, что они не важны, а потому, что всё здесь зависит от контекста. Правильное решение определяется особенностями конкретной системы, и каждая из этих тем заслуживает отдельного материала.
Примеры
Операции со строками
Все мы хорошо знакомы со стандартными методами JDK для работы со строками: equals(), startsWith(), endsWith(), contains().
s1.equals(s2); s1.startsWith(s2); s1.endsWith(s2); s1.contains(s2);
К сожалению, JDK предоставляет только один метод для сравнения строк без учёта регистра:
s1.equalsIgnoreCase(s2);
Аналогов startsWith(), endsWith() и contains(), которые игнорировали бы регистр символов, в JDK нет.
Поэтому разработчики часто комбинируют toLowerCase() или toUpperCase() с методами startsWith(), endsWith() и contains():
s1.toLowerCase().startsWith(s2.toLowerCase()); s1.toLowerCase().endsWith(s2.toLowerCase()); s1.toLowerCase().contains(s2.toLowerCase());
Получается несколько многословно, к тому же такой код уязвим к null, но если он не находится на критическом пути выполнения, особых проблем нет. Однако подобный подход может негативно сказаться на производительности.
Не стоит забывать, что String — неизменяемый класс. Поэтому вместо простого посимвольного сравнения двух строк мы создаём две дополнительные строки, которые впоследствии придётся удалять сборщику мусора. Учитывая, что String представляет собой обёртку над массивом символов, выделение памяти может оказаться затратной операцией.
Решение — использовать методы сравнения без учёта регистра из сторонних библиотек, например Apache Commons Lang 3:
startsWithIgnoreCase(s1, s2); endsWithIgnoreCase(s1, s2); containsIgnoreCase(s1, s2);
Или, начиная с версии 3.18.0:
Strings.CI.startsWith(s1, s2); Strings.CS.startsWith(s1, s2);
Здесь CI предоставляет методы, игнорирующие регистр, а CS — методы, учитывающие регистр символов.
Многие разработчики любят регулярные выражения и порой используют класс java.util.Pattern даже там, где в нём нет особой необходимости. Например:
Pattern.compile("^prefix.+suffix$").matcher(s).find()
Вместо:
s.startsWith("prefix") && s.endsWith("suffix")
Или даже:
Pattern.compile("^prefix").matcher(s).find()
Вместо:
s.startsWith("prefix")
А также:
Pattern.compile("suffix$").matcher(s).find()
Вместо:
s.endsWith("suffix")
Сопоставление с шаблоном работает значительно медленнее, чем простая проверка подстроки.
В следующей таблице приведено время выполнения одного миллиона операций.
Операция (1 млн выполнений) | Время, мс |
| 7 |
| 6 |
| 11 |
| 24 |
| 65 |
| 5 |
| 238 |
| 31 |
Какие выводы можно сделать из этих результатов?
Производительность
equals()иstartsWith()примерно одинакова.Метод
endsWith()обходится примерно вдвое дороже.Метод
contains()требует в четыре раза больше времени, чемequals().Преобразование регистра с последующим вызовом
startsWith()обходится в десять раз (!) дороже.Методы сравнения без учёта регистра не создают дополнительных накладных расходов.
Поиск подстроки с помощью заранее скомпилированного регулярного выражения примерно на 20% медленнее, чем использование обычного метода
contains().Компиляция регулярного выражения с последующим его применением обходится почти в десять раз дороже, чем простой вызов
contains().
Поэтому в следующий раз, когда вы соберётесь использовать Pattern.compile(), стоит на секунду задуматься: действительно ли здесь необходимо регулярное выражение или обычный строковый метод окажется одновременно проще и быстрее?
Если регулярное выражение всё‑таки нужно, по крайней мере компилируйте его заранее. А ещё лучше — объявите его как закрытое статическое финальное поле класса (private static final).
Коллекции
Предположим, нам нужно выяснить, содержит ли заданный список определённый элемент:
list.contains("red");
Фактически этот вызов приводит к выполнению кода примерно следующего вида:
int n = list.size(); for (int i = 0; i < n; i++) { if ("red".equals(list.get(i))) { return true; } }
Начиная с Java 8 в нашем распоряжении есть Stream API, который просто скрывает от нас все эти технические подробности:
list.stream().anyMatch("red"::equals);
Такой подход вполне оправдан, если список невелик, часто изменяется или поиск выполняется лишь изредка. Но если список большой, его содержимое стабильно, а поиск выполняется многократно, правильным выбором станет HashSet. Эта структура данных обеспечивает поиск в среднем за O(1) вместо O(n).
Если изменить исходную структуру данных невозможно, практически всегда имеет смысл один раз преобразовать список в Set на этапе инициализации и в дальнейшем выполнять поиск уже в нём.
Если необходимо одновременно сохранить гарантированный порядок элементов и обеспечить быстрый поиск, можно либо поддерживать две структуры данных — список для сохранения порядка и множество для поиска, — либо просто использовать LinkedHashSet, который решает обе задачи.
Ещё один распространённый сценарий — поиск без учёта регистра. Выше мы уже убедились, что сочетание toLowerCase() или toUpperCase() с операцией сравнения существенно снижает производительность.
Решить эту проблему можно с помощью TreeSet и пользовательского компаратора, например String.CASE_INSENSITIVE_ORDER:
Set<String> set = new TreeSet<>(String.CASE_INSENSITIVE_ORDER);
В результате мы получаем отсортированное множество с поиском без учёта регистра и без дополнительных выделений памяти. Тот же подход работает с TreeMap, когда данные представлены парами «ключ — значение».
Поиск элементов перечислений (enum)
Всем известно, что элемент перечисления можно найти по имени с помощью встроенного метода valueOf(s).
Но что делать, если входная строка записана в нижнем регистре, а имена элементов перечисления, согласно принятым соглашениям, состоят из заглавных букв?
Некоторые разработчики используют комбинацию toUpperCase() и valueOf(). Она прекрасно работает, но создаёт дополнительные накладные расходы, о которых мы говорили выше.
Однако довольно часто разработчики предпочитают добавлять специальное поле, содержащее «пользовательское» имя. В результате простое перечисление:
enum Color { RED, GREEN, BLUE }
Превращается в:
enum Color { RED("red"), GREEN("green"), BLUE("blue"), … }
У такого решения есть как минимум два недостатка:
Дублирование данных. Пользовательское имя совпадает со встроенным, отличаясь лишь регистром символов. Эту задачу можно решить гораздо проще.
Возможность использовать действительно произвольные имена. По моему опыту, в большинстве случаев они не нужны и лишь создают так называемые пограничные случаи. А те, в свою очередь, чаще всего свидетельствуют о неудачном проектировании и способны приводить к множеству «глупых» ошибок.
Но продолжим. Как обычно используют такое пользовательское имя?
public static Color ofColor(String color) { return Arrays.stream(values()) .filter(c -> c.color.equals(color)) .findFirst() .orElseThrow(() -> new IllegalArgumentException("No enum constant %s.%s".formatted(Color.class.getName(), color))); }
Реализация выглядит вполне элегантно. Однако такой подход означает, что при каждом вызове ofColor() происходит перебор списка.
Да, в большинстве случаев перечисления невелики, поэтому и список получается коротким. Но зачем вообще выполнять перебор, если можно один раз при инициализации создать отображение пользовательских имён на элементы перечисления, а затем выполнять поиск со сложностью O(1)?
Следующий пример решает сразу обе проблемы: при инициализации мы создаём отображение без учёта регистра, используя в качестве ключа стандартное имя элемента перечисления, возвращаемое методом name().
private static final Map<String, Color> colors = Arrays.stream(values()).collect(toMap(Enum::name, e -> e, (existing, replacement) -> replacement, () -> new TreeMap<>(CASE_INSENSITIVE_ORDER)));
Теперь метод ofColor() становится предельно простым:
public static Color ofColor(String color) { return Optional.ofNullable(colors.get(color)) .orElseThrow(() -> new IllegalArgumentException("No enum constant for " + color)); }
Можно возразить, что реализация на основе Map применима не всегда: иногда критерии поиска слишком сложны, чтобы свести их к простому ключу.
В целом я с этим согласен, но, в свою очередь, могу сказать, что во многих — если не в большинстве — случаев это всё‑таки возможно.
До сих пор ключом поиска служила обычная строка. Но что, если искать нужно не по точному значению, а по диапазону?
Рассмотрим более точную с физической точки зрения модель цветов, в которой они представлены диапазонами длин электромагнитных волн.
public enum Color { BLUE(450, 495), GREEN(495, 570), RED(620, 750); …}
Как реализовать метод ofWaveLength(int waveLength)?
Самый очевидный способ — перебрать все значения перечисления и для каждого элемента проверить, попадает ли заданная длина волны в соответствующий диапазон. Иными словами, реализовать поиск со сложностью O(n).
Но можно поступить эффективнее, воспользовавшись NavigableMap, который как раз предназначен для подобных запросов по диапазонам:
private static final NavigableMap<Integer, Color> wavelengthMap = Arrays.stream(values()) .collect(Collectors.toMap( color -> color.minNm, color -> color, (existing, replacement) -> existing, TreeMap::new ));
К сожалению, метод поиска получается не таким тривиальным, как в предыдущем примере, но всё равно остаётся очень простым и быстрым:
public static Color ofWaveLength(int nm) { return Optional.ofNullable(wavelengthMap.floorEntry(nm)) .map(Entry::getValue) .filter(value -> nm <= value.maxNm) .orElseThrow(() -> new IllegalArgumentException("No enum constant for wavelength: " + nm + " nm")); }
Теперь сравним производительность.
Операция (1 млн выполнений) | Время, мс |
| 34 |
| 78 |
Перебор с использованием | 40 |
| 166 |
| 20 |
| 32 |
Из таблицы следует:
Как и ожидалось, использование
toUpperCase()снижает производительность примерно вдвое.Перебор элементов с вызовом
equals()немного медленнее, чемvalueOf(), хотя перечисление содержит всего три элемента. При этом время перебора линейно увеличивается с ростом размера перечисления. Чем больше элементов, тем дольше выполняется поиск.Реализация на основе Map оказывается даже быстрее решения, использующего встроенный метод
valueOf().Перебор с использованием Stream API (вариант
ofColor()с перебором) работает на удивление медленно. Накладные расходы на создание и настройку потока — упаковку значений, вызовы лямбда‑выражений и инициализацию сплитератора — весьма существенны для небольших коллекций.
Предварительная инициализация
Принцип здесь прост: не делайте несколько раз то, что можно сделать один раз.
Самый тривиальный пример — строковые и числовые константы:
private static final String FILE_NAME = "config.json"; private static final int MAX_VALUE = 10_000;
Однако тот же принцип применим и к более тяжёлым объектам — и именно здесь его значение становится особенно заметным.
Рассмотрим логирование. Большинство разработчиков привыкли писать следующую «магическую» строку в начале каждого класса (если, конечно, не используется аннотация Lombok @Slf4j):
private static final Logger logger = LoggerFactory.getLogger(MyClass.class);
Действительно ли нужны все эти модификаторы (private static final)?
Некоторые разработчики пытаются сэкономить время на наборе кода:
private final Logger logger = LoggerFactory.getLogger(MyClass.class);
Более того, если логер не является статическим, можно пойти ещё дальше:
private final Logger logger = LoggerFactory.getLogger(getClass());
Такая строка выглядит даже лучше, поскольку исключает определённый класс ошибок: имя класса здесь не задано жёстко. Следовательно, эту строку можно без изменений копировать из одного класса в другой или наследовать от базового класса.
Так в чём же проблема?
Проблема в том, что получение нужного логера потенциально может быть дорогостоящей операцией из‑за синхронизированного поиска в реестре. И если выполнять её при каждом создании экземпляра класса, накладные расходы накапливаются.
Один мой знакомый рассказывал, что в компании, где он работал, подобное изменение на одном из критических путей выполнения настолько повысило производительность, что им удалось сократить кластер AWS примерно на сотню крупных экземпляров EC2.
То же правило относится к компиляции регулярных выражений.
Как показали результаты тестирования, компиляция шаблона при каждом вызове метода почти в десять раз медленнее повторного использования заранее скомпилированного шаблона.
Результат вызова Pattern.compile() всегда следует сохранять в поле static final.
Единственное исключение — ситуации, когда регулярное выражение генерируется динамически. Но проектирования систем, требующих такого подхода, по возможности следует избегать.
Очень часто нам приходится форматировать или разбирать даты.
Традиционно я использовал SimpleDateFormat. Что может быть очевиднее такого решения?
private static final String FORMAT = "yyyy-MM-dd HH:mm:ss"; private static final DateFormat format = new SimpleDateFormat(FORMAT);
Признаться, я сам много раз поступал именно так, руководствуясь сформулированным выше принципом: нет никакого смысла каждый раз создавать новый экземпляр объекта, если можно создать его единожды.
Проблема в том, что SimpleDateFormat не является потокобезопасным классом. Поэтому совместное использование одного экземпляра несколькими потоками может привести к ошибкам.
И что ещё хуже, с такой ошибкой можно жить годами, даже не подозревая о её существовании. Ведь она проявляется лишь при высокой нагрузке, а иногда просто приводит к появлению слегка некорректных результатов, которые легко теряются в огромном массиве правильных данных.
Так неужели придётся создавать новый экземпляр SimpleDateFormat каждый раз, когда он нам понадобится, заставляя процессор и сборщик мусора выполнять дополнительную работу?
К счастью, начиная с Java 8 мы можем использовать вместо него DateTimeFormatter:
private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern(DATE_FORMAT);
Этот класс потокобезопасен, поэтому один его экземпляр можно совместно использовать в разных потоках, получая стабильные и корректные результаты.
Заключение
Мы начали с цитаты, которую почти всегда приводят в усечённом виде.
Кнут никогда не призывал игнорировать производительность. Он говорил о том, что не стоит жертвовать ясностью кода ради гипотетического ускорения, но одновременно напоминал: нельзя упускать возможности для оптимизации в тех самых критически важных 3% случаев.
Именно к этим 3% относятся примеры, рассмотренные в статье.
Ни одна из описанных проблем с производительностью вообще не должна возникать в промышленном коде. Избежать их несложно — для этого не нужны ни профилировщик, ни фреймворк для бенчмаркинга, ни обсуждение архитектуры.
Нужна лишь привычка выбирать подходящий инструмент.
И эта привычка окупается.
Использование equalsIgnoreCase() вместо toLowerCase().equals() делает код чище и быстрее.
Статический финальный логер (static final) проще и требует меньше ресурсов.
Заранее созданное отображение элементов перечисления (enum) улучшает читаемость и обеспечивает поиск за O(1).
В данном случае качественный и эффективный код не противоречат друг другу — это один и тот же код.
Всё, что требуется, — выработать привычку на секунду останавливаться и задавать себе вопрос:
Не выполняю ли я эту операцию n раз, хотя достаточно было бы одного?
Все примеры кода из этой статьи доступны на Gist.

Присоединяйтесь к русскоязычному сообществу разработчиков на Spring Boot в телеграм — Spring АйО, чтобы быть в курсе последних новостей из мира разработки на Spring Boot и всего, что с ним связано.

