Советы по производительности Java
Практические советы по производительности Java — сначала измерение, избегание преждевременной оптимизации, распространённые микрооптимизации.
Java достаточно быстра почти для всего, но медленный код всё равно случается — как правило, из-за лишней работы, избыточного выделения памяти или неправильного выбора структуры данных. Важнейший совет по производительности — вовсе не трюк: измеряйте прежде, чем что-либо менять. В этой главе рассказывается, как честно проводить измерения, какие оптимизации чаще всего окупаются (построение строк, выбор структур данных, избегание лишнего выделения памяти), а также о подводных камнях, из-за которых наивный «быстрый» код на самом деле оказывается медленным.
Сначала измеряйте, потом оптимизируйте
Догадки о производительности — верный способ потратить часы на ускорение кода, который никогда не был медленным. Профилируйте реальную нагрузку, найдите горячий путь (тот небольшой участок кода, где тратится большая часть времени), и только после этого оптимизируйте его. Для быстрых экспериментов System.nanoTime() даёт разницу по «настенным часам»; для серьёзных бенчмарков используйте инструмент вроде JMH (Java Microbenchmark Harness), который прогревает JIT и учитывает погрешности измерений.
long start = System.nanoTime();
doWork();
long elapsedMs = (System.nanoTime() - start) / 1_000_000;
System.out.println("Took " + elapsedMs + " ms");К этому прилагаются два правила. Во-первых, избегайте преждевременной оптимизации — понятный код, который достаточно быстр, лучше хитрого кода, который трудно читать. Во-вторых, JIT-компилятор JVM оптимизирует горячий код во время выполнения, поэтому метод выходит на полную скорость лишь после многократного вызова; одиночный замер мало о чём говорит.
Строим строки с помощью StringBuilder
Поскольку строки неизменяемы, s += x внутри цикла создаёт новую строку и копирует все предыдущие символы на каждой итерации — это работа O(n²). StringBuilder хранит растущий буфер и дополняет его на месте, превращая ту же задачу в O(n).
// Slow: a new String allocated every iteration
String csv = "";
for (String field : fields) {
csv += field + ",";
}
// Fast: one buffer, appended in place
StringBuilder sb = new StringBuilder();
for (String field : fields) {
sb.append(field).append(',');
}
String csv2 = sb.toString();Одиночная конкатенация a + b + c вне цикла не проблема — компилятор уже преобразует её в один StringBuilder (смотрите конкатенацию строк, чтобы понять, что делает компилятор). Проблема — конкатенация внутри цикла, где каждый проход добавляет ещё одну полную копию.
Выбирайте правильную структуру данных
Наибольший выигрыш обычно даёт алгоритмический выбор, а не микрооптимизации. Поиск значения в ArrayList перебирает все элементы (O(n)); HashMap или HashSet делают это за примерно постоянное время (O(1)). Выбирайте коллекцию, которая соответствует тому, как вы на самом деле обращаетесь к данным.
| Потребность | Используйте | Стоимость поиска |
|---|---|---|
| Доступ по индексу, добавление в конец | ArrayList | O(1) по индексу, O(n) по значению |
| Поиск по ключу/значению | HashMap | O(1) в среднем |
| Проверка вхождения, без дубликатов | HashSet | O(1) в среднем |
| Отсортированные ключи | TreeMap | O(log n) |
| Частые вставки/удаления с концов | ArrayDeque | O(1) на концах |
Если вы знаете финальный размер, передайте его в конструктор: new ArrayList<>(10_000) или new HashMap<>(capacity). Это позволяет избежать повторного перераспределения памяти и копирования при росте коллекции.
Избегайте лишнего создания объектов
Каждый выделенный объект впоследствии нужно собрать, а сборка мусора не бесплатна. Переиспользуйте неизменяемые значения, предпочитайте примитивы их упакованным обёрткам в горячих циклах и не создавайте объекты, которые тут же выбрасываются.
// Autoboxing: every += boxes a new Integer
Long total = 0L;
for (int i = 0; i < n; i++) total += i; // slow, allocates boxes
// Primitive: no allocation at all
long sum = 0L;
for (int i = 0; i < n; i++) sum += i; // fastДругие простые выигрыши: кешируйте скомпилированные объекты Pattern вместо вызова String.matches() в цикле, переиспользуйте DateTimeFormatter (он потокобезопасен и неизменяем), а в самых горячих внутренних циклах, где важно выделение памяти, предпочитайте расширенный for потокам.
Что можно вынести из запуска:
Same result? trueдоказывает, чтоStringBuilderпроизводит ровно ту же строку, что и+=, поэтому замена влияет только на скорость, но не на корректность.- Напечатанное соотношение «x slower» показывает, что конкатенация в цикле обходится во много раз дороже добавления в буфер, поскольку каждый
+=копирует всю строку целиком. Both lists size 100000: trueподтверждает, чтоArrayListс предварительно заданным размером в итоге идентичен тому, что рос динамически — подсказка конструктору влияет на выделение памяти, а не на содержимое.Pre-sized faster? trueпоказывает, что указаниеArrayListёмкости заранее позволяет избежать повторных шагов изменения размера и копирования.Map lookups found: 50000 in ... msдемонстрирует, что 50 000 обращений кHashMapзавершаются примерно за миллисекунду — вот цена выбора доступа O(1) вместо сканирования списка O(n).
Распространённые ловушки
Несколько ошибок превращают «очевидно быстрый» код в противоположность:
- Доверие единственному замеру. JIT ещё не прогрелся, а ОС могла переключиться на другую задачу прямо в момент измерения. Повторите работу тысячи раз или используйте JMH, прежде чем доверять числу.
- Микрооптимизация холодного кода. Метод, запускаемый один раз при старте, ничего не выиграет от более компактного цикла. Тратьте усилия только на горячий путь, на который указывает профайлер.
- Построение строк через
+в цикле. Самое распространённое устранимое замедление — используйтеStringBuilderвсякий раз, когда конкатенируете строки внутри цикла. - Скрытый автобоксинг.
List<Integer>,Map<Integer, Integer>или случайный аккумулятор типаLongупаковывает каждое значение. В горячем числовом цикле предпочитайте примитивы и массивы примитивов. - Оптимизация до того, как код работает. Сначала правильность, потом скорость. Понятный код, который можно профилировать, лучше хитрого кода, о котором невозможно рассуждать.
Итоги
- Измеряйте прежде, чем что-либо менять — профилируйте реальную нагрузку и оптимизируйте только горячий путь.
- Стройте строки с помощью
StringBuilder, а не+=, внутри циклов. - Подбирайте коллекцию под паттерн доступа:
HashMap/HashSetдля поиска,ArrayListдля индексного доступа; задавайте размер заранее, если количество элементов известно. - Избегайте лишнего выделения памяти: предпочитайте примитивы, переиспользуйте неизменяемые объекты и помните, что каждый объект добавляет работу сборщику мусора.