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

Введение

В современном цифровом мире приложениями автоматизированной банковской системы часто обрабатываются и анализируются огромные объемы данных. При управлении большими наборами данных особое внимание требуется уделять производительности, расходованию памяти и масштабируемости. Для этого в Java Spring предлагаются надежные решения, благодаря которым разработчики создают эффективные приложения, способные справляться со значительными загрузками данных.

Рекомендации по управлению большими наборами данных в Java Spring

1. Оптимизация циклов

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

Пример:

// Неэффективный цикл
for (int i = 0; i < list.size(); i++) {
process(list.get(i));
}

// Оптимизированный цикл
int size = list.size();
for (int i = 0; i < size; i++) {
process(list.get(i));
}

Пояснение:

В первом примере list.size() вызывался с каждым телом цикла, а это недешево. Во втором примере эта проблема решается вынесением размера объекта за пределы цикла.

2. Осторожное применение Optional

  • Optional используется для обработки значений null, но не рекомендуется в коллекциях или в качестве полей в классах данных, поскольку из-за этого увеличиваются накладные расходы.
// Не рекомендуется
private Optional<String> name; // Создаются лишние оберточные объекты.// Оптимизируется
// Вместо этого выполняются проверки наличия значений «null» или инициализация значениями по умолчанию.
private String name;

3. Пакетная обработка

  • Большие наборы данных, например при импорте/ экспорте, подвергаются пакетной обработке  —  так сокращается потребление памяти, очень просто это делается при добавлении SpringBatch:
@Transactional
public void importEmployees(List<Employee> employees) {
int size = employees.size();
for (int i = 0; i < size ; i += 100) {
//здо́рово, пакет делается очень просто
List<Employee> batch = employees.subList(i, Math.min(i + 100, employees.size()));
employeeRepository.saveAll(batch);
employeeRepository.flush(); // Чтобы освободить память, очищается контекст длительного сохранения.
}
}

4. Рекомендации Stream API

  • При использовании Java Streams не создаются большие промежуточные коллекции, а отложенные вычисления и терминальные операции применяются с умом:
// Не рекомендуется
List<String> names = employees.stream()
.filter(e -> e.getAge() > 30)
.map(Employee::getName)
.collect(Collectors.toList());
// Промежуточные результаты собираются в новый «List»// Оптимизируется
employees.stream()
.filter(e -> e.getAge() > 30)
.map(Employee::getName)
.forEach(System.out::println); // Чтобы избежать дополнительного выделения памяти, напрямую используется «forEach».

5. Эффективные структуры данных

Структура данных выбирается, исходя из потребностей. Например:

  • Если требуется произвольный доступ, вместо LinkedList используется ArrayList: им расходуется меньше памяти на элемент.
  • Чтобы избежать изменения размера во время выполнения, используется HashMap с корректным исходным размером.
  • По возможности используются примитивные типы данных вместо их классов-оберток, например int вместо Integer.

Пример:

// Не рекомендуется
List<Integer> list = new ArrayList<>();
for (int i = 0; i < 10000; i++) {
list.add(i); // При автоматической упаковке добавляются лишние накладные расходы.
}
// Используются примитивные массивы
int[] arr = new int[10000]; // Автоупаковка избегается
for (int i = 0; i < arr.length; i++) {
arr[i] = i;
}
// Исходная емкость оценивается по ожидаемому количеству записей
int expectedEntries = 10000;
float loadFactor = 0.75f;
int initialCapacity = (int) (expectedEntries / loadFactor + 1);
// Создается «HashMap» с вычисляемой исходной емкостью
HashMap<String, Integer> accountBalances = new HashMap<>(initialCapacity);

6. Недопущение создания лишних объектов

  • Вместо многократного создания новых экземпляров объекты, где возможно, переиспользуются, например new внутри циклов избегаются:
// Не рекомендуется
for (int i = 0; i < 1000; i++) {
String result = new String("Result"); // На каждой итерации создается новый объект «String».
}
// Оптимизируется
String result = "Result"; // Переиспользуется тот же объект, в Java строковые литералы интернируются.
for (int i = 0; i < 1000; i++) {
// Используется «result» без создания нового экземпляра.
}

7. Параллельные потоки и фреймворк Fork/Join

Реализацией Fork/Join и параллельными потоками в Java для процессорного выполнения процесс ускоряется за счет использования нескольких имеющихся ядер.

Пример:

// Последовательный поток
list.stream().forEach(this::process);
// Параллельный поток
list.parallelStream().forEach(this::process);

Пояснение:

Параллельным потоком при обработке данных используется несколько потоков, отчего выполнение на многоядерных процессорах убыстряется. Но здесь не стоит переусердствовать с параллельными потоками. Особенно в том, что касается накладных расходов, связанных с управлением потоками.

8. Настройка сборщика мусора JVM

Сборка мусора в Java хороша, но в меру. Элементы тщательно настраиваются, иначе снижается производительность. Начальный и максимальный размеры кучи определяются соответствующим набором параметров виртуальной машины Java, таких как –xms иxmx , с его же помощью развертывается сборщик мусора вроде G1GC или ZGC для приложения.

Пример:

# Параметры виртуальной машины Java для настройки сборщика мусора
java -Xms1g -Xmx2g -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -jar myapp.jar

Пояснение:

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

9. Логи сборки мусора

  • Чтобы отслеживать использование памяти и определять, часто ли потребляется или освобождается избыточная память, на продакшене включаются логи сборки мусора.
-XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+PrintGCDateStamps

10. Утечки памяти в объектах-«долгожителях»

  • Статические поля или объекты с длительным жизненным циклом, в которых содержатся большие ссылки, используются осторожно. Эти объекты не удаляются сборщиком мусора, что чревато утечками памяти.
// Не рекомендуется
public class Cache {
private static List<Employee> employeeCache = new ArrayList<>();
}
// Оптимизируется
public class Cache {
private static WeakHashMap<String, Employee> employeeCache = new WeakHashMap<>(); // Использование слабых ссылок
}

11. Полная сериализация объектов

  • При сериализации объектов, например для длительного сохранения сеанса или кэширования, не рекомендуется сериализовывать лишние поля, их помечают как transient.
public class Employee implements Serializable {
private String name;
private transient int salary; // «salary» не сериализуется
}

Заключение

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

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

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

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

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


Перевод статьи kiarash shamaii: Best Practices for Working with Large Datasets in Java

Предыдущая статьяКак создать собственную библиотеку на Kotlin Multiplatform
Следующая статья10 полезных приемов работы со строками JavaScript