6 рекомендаций по устранению типичных проблем производительности Java

Андрей Шагин
NOP::Nuances of Programming
11 min readJun 18, 2024

В прошлом году один из наших проектов сильно разросся. Я не жалуюсь, масштабирование — это хорошо. Но процесс расширения, как в случае с любым программным продуктом, сопровождался трудностями, особенно с точки зрения производительности.

Каким бы ни было захватывающим масштабирование этого проекта, оно сопряжено с рядом проблем производительности, которые нельзя оставлять без внимания: трудности использования ресурсов, случайные утечки памяти, неэффективный доступ к базе данных и другие.

Для решения этих проблем и инструментирования мы воспользовались Digma. Имеются также инструменты оптимизации вроде JMH для замера производительности и профилировщики для выявления «горячих точек». Надеюсь, наши наработки пригодятся вам, чтобы эффективнее справляться с подобными трудностями.

Краткий обзор проблем производительности Java

Java ― отличный язык программирования, которому, как и любому другому языку, присущи определенные проблемы. Они не умаляют ценности Java, но знать о потенциальных подводных камнях разработчикам необходимо.

Эти проблемы, часто проявляющиеся в снижении производительности, требуется тщательно изучать, разрабатывать для них упреждающие стратегии оптимизации. Решение проблем производительности ― ключ к полному использованию возможностей Java: платформонезависимости, надежных библиотек, обширной экосистемы.

Кроме тщательного изучения этих проблем, необходимо учитывать преимущества оптимизации Java-приложений. Оптимизация производительности — не разовый процесс: начинается во время разработки, продолжается при тестировании и развертывании, сохраняется на протяжении всего жизненного цикла приложения.

Вот краткий перечень рекомендаций из поста на Reddit Мартийна Вербурга, главного руководителя инженерной группы Java и Golang в Microsoft. Важно учитывать их при оптимизировании Java-приложений.

Методология

Выберите модель диагностики производительности Java Кирка Пеппердайна, метод USE Брендана Грегга или модель Моники Беквит «Сверху вниз». Без структурированного подхода усилиям по оптимизации не хватает направленности.

Цели производительности SLA/SLO

Четко определите цели производительности, например достижение минимум 1000 транзакций в секунду с задержкой не более 500 миллисекунд на P999 в виртуальной машине Standard_DS_v4.

Архитектура приложений

Развивайте комплексное понимание логической и физической архитектуры приложений.

Ограничение по времени/бюджетирование/наблюдаемость

Реализуйте стратегии ограничения по времени или бюджетирования, например с учетом разбивки по времени приема-передачи: ~200 мс в JavaScript, 400 мс в виртуальной машине Java и 300 мс в базе данных. Подключайте наблюдаемость и получайте полезные данные.

Математические и статистические методы

Чтобы разбираться в нюансах метрик производительности, применяйте математические и статистические методы: кривые задержки P99, базовая линия, семплирование, сглаживание.

Технологический стек

Познакомьтесь с тем, как взаимодействуют все уровни технологического стека: виртуальная машина Java, процессор, память, операционные системы, гипервизоры, Docker, K8s. Разберитесь, как ключевые метрики производительности на каждом уровне соотносятся со следующим уровнем.

Виртуальная машина Java

Изучите внутреннее устройство виртуальной машины Java: сборку мусора, JIT-компиляцию, модель памяти Java и соответствующие концепции.

Инструменты и тактики

Следите за инструментами и тактиками, эта сфера развивается. Изучайте нагрузочное тестирование, инструменты наблюдаемости для ограничения по времени, мониторинг ресурсов, средства диагностики. Для эффективного анализа производительности применяйте такие инструменты, как JFR/JMC, GCToolkit и другие.

6 типичных проблем производительности Java

1. Утечки памяти

Но как они возможны в Java с ее автоматическим управлением памятью и сборщиком мусора? Действительно, сборщик мусора Java — это мощный инструмент для автоматического выделения и высвобождения памяти. Однако одним автоматическим управлением памятью защита от проблем с производительностью не гарантируется.

Сборщиком мусора автоматически находится и высвобождается неиспользуемая память, это важный аспект надежной системы управления памятью Java.

Но, имея такие продвинутые механизмы, даже самые умелые программисты порой сталкиваются с утечками памяти, сохраняя в ней объекты и таким образом предотвращая высвобождение сборщиком мусора связанной с ними памяти. Со временем это чревато увеличением расхода памяти и снижением производительности приложения.

Утечки памяти сложно обнаружить и устранить в основном из-за их перекрывающихся симптомов. В нашем случае симптом был очевиднейший: ошибка динамической памяти OutOfMemoryError, повлекшая снижение производительности за определенное время.

В Java имеется много возможных причин утечки памяти. Чтобы определить, обычная ли это нехватка памяти из-за плохого проектирования или утечка, мы проанализировали сообщение об ошибке.

Сначала проверили статические поля, коллекции, большие объекты, объявленные как статические и потенциально блокирующие жизненно важную память все время существования приложения.

Например, когда в этом коде при инициализации списка удаляется ключевое слово static, расходование памяти резко сокращается:

public class StaticFieldsMemoryTestExample {
public static List<Double> list = new ArrayList<>();
public void addToList() {
for (int i = 0; i < 10000000; i++) {
list.add(Math.random());
}
Log.info("Debug Point 2");
}
public static void main(String[] args) {
new StaticFieldsDemo().addToList();
}
}

Затем проверили наличие открытых ресурсов или подключений, блокирующих память и поэтому недоступных для сборщика мусора. В некорректных реализациях методов equals() и hashCode() написали соответствующие переопределенные методы, в частности в HashMap и HashSet.

Вот пример корректной реализации методов equals() и hashCode():

public class Person {
public String name;

public Person(String name) {
this.name = name;
}

@Override
public boolean equals(Object o) {
if (o == this) return true;
if (!(o instanceof Person)) {
return false;
}
Person person = (Person) o;
return person.name.equals(name);
}

@Override
public int hashCode() {
int result = 17;
result = 31 * result + name.hashCode();
return result;
}
}

Рекомендации по предотвращению утечек памяти

  • Если используемые в коде внешние ресурсы — дескрипторы файлов, подключения баз данных или сетевые сокеты — больше не нужны, обязательно высвобождайте их явно.
  • Чтобы анализировать и выявлять потенциальные утечки памяти в приложении, применяйте такие инструменты профилирования, как VisualVM и YourKit.
  • Во избежание выделения лишних ресурсов до тех пор, пока синглтоны не понадобятся, применяйте вместо безотложной загрузки отложенную.

2. Взаимоблокировки потоков

Java — многопоточный язык, особенно хорош он в разработке корпоративных приложений для одновременного выполнения задач.

Каждый из потоков — минимальная, независимая единица выполнения с отдельным путем выполнения: исключение в одном потоке не сказывается на другом.

Но что, если потоки попытаются получить доступ сразу ко всем ресурсам-блокировкам? Случится взаимоблокировка, как в системе обработки финансовых данных в реальном времени. В этом проекте потоки занимались выборкой данных из внешних API, выполнением сложных вычислений, обновлением общей базы данных в памяти.

По мере применения этого инструмента стали поступать сообщения о периодических зависаниях. В дампах потоков обнаружилось, что потоки застряли в состоянии ожидания, сформировав у блокировок циклическую зависимость.

В этом примере два потока thread1 и thread2 пытаются захватить две блокировки lock1 и lock2 в разном порядке, образуется циклическое ожидание, увеличивается вероятность взаимоблокировки:

public class DeadlockExample {
private static final Object lock1 = new Object();
private static final Object lock2 = new Object();
public static void main(String[] args) {
Thread thread1 = new Thread(() -> {
synchronized (lock1) {
System.out.println("Thread 1: Holding lock 1");
// Для увеличения вероятности взаимоблокировки вводится задержка
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (lock2) {
System.out.println("Thread 1: Holding lock 1 and lock 2");
}
}
});
Thread thread2 = new Thread(() -> {
synchronized (lock2) {
System.out.println("Thread 2: Holding lock 2");
// Для увеличения вероятности взаимоблокировки вводится задержка
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (lock1) {
System.out.println("Thread 2: Holding lock 2 and lock 1");
}
}
});
thread1.start();
thread2.start();
}
}

Проблема решается рефакторингом кода. Чтобы блокировки всегда гарантированно захватывались потоками в определенном порядке, вводим для всех потоков глобальный порядок блокировок:

public class DeadlockSolution {
private static final Object lock1 = new Object();
private static final Object lock2 = new Object();
public static void main(String[] args) {
Thread thread1 = new Thread(() -> {
synchronized (lock1) {
System.out.println("Thread 1: Holding lock 1");
// Для увеличения вероятности взаимоблокировки вводится задержка
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (lock2) {
System.out.println("Thread 1: Holding lock 2");
}
}
});
Thread thread2 = new Thread(() -> {
synchronized (lock1) {
System.out.println("Thread 2: Holding lock 1");
// Для увеличения вероятности взаимоблокировки вводится задержка
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (lock2) {
System.out.println("Thread 2: Holding lock 2");
}
}
});
thread1.start();
thread2.start();
}
}

Рекомендации по предотвращению взаимоблокировки потоков

  • Порядок захвата блокировок — один для всех потоков, чтобы предотвратить циклическое ожидание.
  • Реализуется тайм-аут блокировки: если за указанное время блокировка потоком не захватывается, все захваченные блокировки освобождаются и попытка повторяется.
  • Вложенные блокировки — захват блокировок в важных частях кода, где уже захвачены другие блокировки, — избегаются. Со вложенной блокировкой увеличивается риск взаимоблокировок.

3. Избыточная сборка мусора

Сборка мусора в Java — это незаметный герой, управляющий за нас памятью. Им автоматически удаляются ненужные объекты, благодаря этому жизнь разработчиков значительно облегчается, хоть и за счет циклов процессора, уделяемых сборке мусора. А это сказывается на производительности приложения.

Кроме типичной ошибки нехватки памяти, встречаются периодические задержки, зависания или аварийные завершения приложений. В облаке вычислительные затраты значительно сокращаются оптимизацией процесса сборки мусора. Пример — компания Uber, которой благодаря высокоэффективному, неопасному, крупномасштабному полуавтоматическому механизму настройки сборки мусора Go удалось сэкономить 70 тыс. ядер в 30 критически важных сервисах.

Рекомендации по предотвращению избыточной сборки мусора

  • Анализ и настройка журналов — выявляйте закономерности, такие как полные циклы сборки мусора или длительные паузы.
  • Оценивайте и пробуйте различные алгоритмы сборки мусора, например такие алгоритмы JDK, как последовательный, параллельный, G1, Z GC и другие.
  • Выбирайте алгоритм, исходя из рабочей нагрузки и характеристик производительности приложения. С переходом на более подходящий алгоритм сборки мусора потребление ресурсов процессора снижается.
  • Избыточно создаваемые объекты сокращаются оптимизацией кода. Чтобы выявлять области избыточного генерирования объектов, применяйте инструменты профилирования памяти HeapHero или YourKit. Чтобы переиспользовать объекты и снизить затраты на их выделение, реализуйте объектный пул.
  • На потреблении ресурсов процессора во время сборки мусора сказывается изменение размера кучи: для снижения частоты циклов сборки размер кучи увеличивают, для приложений с небольшим объемом занимаемой памяти — уменьшают.
  • В облаке распределяйте рабочую нагрузку между экземплярами. Увеличивайте количество экземпляров контейнера или EC2, так оптимизируется использование ресурсов и снижается нагрузка на отдельные экземпляры.

4. Раздутые библиотеки и зависимости

Инструменты сборки вроде Maven и Gradle — это революция в управлении зависимостями, простой способ подключить внешние библиотеки и облегчить процесс настройки проекта Java. Но такое удобство оборачивается раздуванием библиотек и зависимостей.

В 2021 году даже опубликовали целое исследование о зависимостях, включенных в скомпилированный код приложения, для сборки и запуска которого они на самом деле не нужны.

За счет нового функционала, зависимостей, исправления багов программные проекты обычно быстро разрастаются. Иногда непропорционально, так что разработчики не в состоянии их эффективно сопровождать и непреднамеренно создают уязвимости безопасности, а также дополнительные накладные расходы производительности.

В такой ситуации неиспользуемые зависимости и библиотеки из приложения удаляют.

Самые распространенные в экосистеме Java инструменты управления зависимостями — плагины Maven и Gradle, они хорошо справляются с обнаружением неиспользуемых зависимостей, используемых транзитивных зависимостей — объявляемых напрямую — и зависимостей, объявленных в неправильной конфигурации: API против реализации против compileOnly и т. д.

Имеются и другие инструменты вроде Sonarqube и JArchitect, а также современные интегрированные среды разработки вроде Intellij с неплохими возможностями анализа зависимостей.

Рекомендации по предотвращению раздувания зависимостей в Java

  • Проверки зависимостей — проводите регулярно, выявляя неиспользуемые или устаревшие библиотеки. При анализе зависимостей пригодятся инструменты вроде плагина зависимостей Maven или dependencyInsight от Gradle.
  • Контроль версий — поддерживайте зависимости в актуальном состоянии. Систематически отслеживайте изменения в зависимостях и управляйте обновлениями с помощью систем контроля версий.
  • Области зависимостей — compile, runtime, test и другие используйте эффективно. Чтобы уменьшить размер конечного артефакта, минимизируйте зависимости в области compile.

5. Неэффективный код

Ни один разработчик не пишет неэффективный или неоптимальный код намеренно. Тем не менее такой код в продакшене оказывается. Причины разные: сжатые сроки проекта, ограниченное понимание базовых технологий или меняющиеся требования, из-за которых разработчикам приходится отдавать приоритет не оптимизации, а функциональности.

Неэффективный пример:

String result = "";
for (int i = 0; i < 1000; i++) {
result += "example";
}

Доработанный пример:

StringBuilder sb = new StringBuilder();
for (int i = 0; i < 1000; i++) {
sb.append("example");
}
String result = sb.toString();

Неэффективный код чреват увеличением расхода памяти, замедлением отклика, снижением эффективности системы в целом, а в итоге — ухудшением пользовательского взаимодействия, увеличением эксплуатационных затрат и ограничением масштабирования приложений при работе с возросшими нагрузками.

Избавление от неэффективного кода начинается с выявления указывающих на неэффективность закономерностей: вложенных циклов без корректных условий выхода, создания и инстанцирования ненужных объектов, чрезмерной синхронизации, неэффективных запросов к базе данных и многого другого.

Рекомендации по написанию эффективного кода Java

  • Чтобы избежать дублирования, выполняйте рефакторинг и разбивайте код на модули.
  • Оптимизируйте операции ввода-вывода асинхронными или параллельными операциями ввода-вывода предотвращайте блокировку основного потока.
  • Избегайте создания ненужных объектов, особенно в важных для производительности частях кода. По возможности переиспользуйте объекты, применяйте приемы объектного пула.
  • При построении строк вместо их конкатенации оператором + задействуйте StringBuilder — так не создаются ненужные объекты.
  • Эффективные алгоритмы и структуры данных — выбирайте соответствующие задаче для оптимальной производительности.

6. Проблемы конкурентности

Проблемы конкурентности случаются, когда потоки обращаются к общим ресурсам одновременно, обычно это чревато неожиданным поведением.

Если вы давно занимаетесь программированием, наверняка расстраивались из-за проблем, возникающих на протяжении всего цикла разработки. Выявить и эффективно устранить их ― непростая задача.

Если нет четкого представления о реальной производительности, такие проблемы обычно задерживаются в приложении надолго. В сложных распределенных системах проблема усугубляется. Без должного понимания трудно принимать обоснованные проектные решения или оценивать влияние изменений в коде.

Здесь приходится кстати непрерывная обратная связь посредством плагина Digma для постоянного выявления различных проблем во всем цикле разработки, получения ценной информации в режиме реального времени.

Так, недавно мы заметили неожиданное поведение и столкнулись с проблемой производительности одной из серверных служб. Решили выявить первопричину с помощью Digma.

В традиционном сценарии для такого расследования создается, приоритизируется, назначается и… из-за срочных дел откладывается в сторону элемент бэклога. Благодаря имеющимся данным наблюдаемости, с Digma удалось очень конкретно проанализировать эту проблему конкурентности.

Результаты Digma
Дашборд Digma

В ходе анализа выявлена не только проблема, но и ее точная первопричина ― проблема масштабирования.

На графике первопричина представлена оранжевой линией, параллельной общему времени выполнения, и соответствует вызову запроса, активированному во время обработки запросов в определенной конечной точке.

Обе линии похожи, то есть проблема масштабирования особенным образом сказывается на этом конкретном запросе, который затем распространяется на конечную точку. Выявив первопричину, мы обнаружили в проблемном диапазоне неэффективный план выполнения запроса.

Основную проблему решили перестроением запроса и добавлением недостающих индексов, таким образом быстро решилась проблема масштабирования.

Рекомендации по предотвращению проблем конкурентности в Java

  • Используйте атомарные переменные — в пакете java.util.concurrent.atomic имеются классы вроде AtomicInteger и AtomicLong для выполнения атомарных операций без явной синхронизации.
  • Избегайте совместного использования изменяемых объектов — по возможности проектируйте классы неизменяемыми, избавляясь от необходимости в синхронизации и обеспечивая потокобезопасность.
  • Минимизируйте конфликт при блокировках: чтобы уменьшить конкуренцию за одну и ту же блокировку, используйте детализированную блокировку или такие приемы, как разбиение одной блокировки на несколько.
  • Чтобы обеспечить единовременный доступ к синхронизированному блоку кода только для одного потока, с помощью ключевого слова synchronized создавайте синхронизированные блоки или методы.
  • Используйте потокобезопасные структуры данных, такие как ConcurrentHashMap, CopyOnWriteArrayList, BlockingQueue из пакета java.util.concurrent, для конкурентного доступа без дополнительной синхронизации.

Заключение: решение проблем производительности Java

Надеемся, статья была полезной. Не становитесь жертвой преждевременной оптимизации. Также важно понимать: не все части кода одинаково влияют на производительность приложения, важно выявить и приоритизировать самые «влиятельные». Digma удается сделать усилия по оптимизации более целенаправленными и менее случайными. Другими словами, с помощью анализа данных времени выполнения в Digma выявляются фрагменты кода с наибольшим влиянием на масштабируемость системы.

Скачать Digma бесплатно.

Читайте также:

Читайте нас в Telegram, VK и Дзен

--

--