Решая задачу подсчета текущих остатков по всем счетам, в каждом из которых содержалось более 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, соответствующего начального размера избегаются лишние накладные расходы во время выполнения.
Реализовав эти рекомендации, вы подготовите приложения к эффективной обработке больших объемов данных.
Читайте также:
- Почему служебные классы не желательны в проектах
- Функциональное программирование Java: элегантное применение Predicate и Function
- Карьерные пути в Java: от младшего разработчика до эксперта
Читайте нас в Telegram, VK и Дзен
Перевод статьи kiarash shamaii: Best Practices for Working with Large Datasets in Java





