Блог Томаша Фяялковского о программировании
Большинство статей по информатике описывают, как их автор узнал то, что кто‑то другой уже знал. Эта заметка — краткая история о том, как благие намерения могут привести к катастрофе, если забыть про внутренние механизмы JVM, и о том, как снова себя оправдал подход Кента Бека «Make it work, Make it right, Make it fast». Недавно я наткнулся на довольно нечитаемый код. В исходном варианте методы были не так отделены друг от друга, класс занимал несколько сотен строк и всё было гораздо запутаннее. После нескольких шагов рефакторинга я заменил Map<String, Set<String>> на Map<String, String>, в результате чего структура выглядела проще. Чтобы не вдаваться в доменные детали, в примере я именую переменные a, b, c и т. п. Удовлетворённый результатом, я развернул изменение. Через несколько минут я посмотрел метрики сервиса и увидел резкий скачок использования памяти. К счастью, деплой был не в продакшн‑окружении. Оказалось, новая структура данных потребляла в несколько раз больше памяти. И это не была небольшая коллекция — в ней хранились миллионы элементов. Коллекция в исходной версии весила примерно 400 МБ, а в «улучшенной» — около 1100 МБ.
Увеличение потребления памяти в моём случае вызвано механизмом создания и хранения строк в JVM. В Java есть ряд оптимизаций для строк. В частности, строковые литералы попадают в пул строк в памяти и могут многократно переиспользоваться. Это не относится к строкам, которые не являются литералами. Можно принудительно добавить строку в пул с помощью метода intern(), но у этого решения есть нюансы и, по моему мнению, оно может привести к ошибкам и другим проблемам с памятью. Описанное поведение строк делает реализацию с Map заметно более эффективной по памяти в моём случае. Map держит ссылку на ключ, что даёт ощутимую экономию, когда много записей имеют одинаковый ключ. Кроме того, в моём примере строки были довольно длинными — по нескольку десятков символов.
Чтобы лучше понять, что происходит в JVM, рассмотрим упрощённый пример. После выполнения первой строки создаются следующие объекты строк:
- «a» — литерал, который может быть в пуле и подлежать сборке мусора в зависимости от реализации;
- «b1» — литерал;
- new String(«a») — объект, на который будет ссылаться ключ в мапе;
- new String(«b1») — объект, который попадёт в сет как значение, и на который будет ссылаться мап через этот сет.
После выполнения второй строки появятся:
- «b2» — литерал;
- new String(«a») — новый объект, который может быть собран сборщиком мусора, потому что при добавлении в мап будет вызван equals, и такой ключ уже существует;
- new String(«b2») — объект для значения в сете.
В результате в памяти остаются только три строки без дубликатов — нет повторного хранения same‑data.
Для сравнения рассмотрим версию с сетом и агрегирующим объектом (например, объект Key, который хранит строковые поля). После первой операции создаются:
- «b1» — литерал;
- new String(«a») — значение в объекте Key; Key хранит ссылку на этот объект, и сет хранит ссылки на Key;
- new String(«b1») — тоже значение в объекте Key; Key хранит ссылку на этот объект, и сет хранит ссылки на Key.
После второй операции создаётся:
- new String(«b2») — значение в другом объекте Key; Key хранит ссылку на этот объект, и сет хранит ссылки на Key.
В результате в памяти остаются четыре строковых объекта, которых нельзя удалить сборщиком мусора — экземпляр new String(«a») оказывается сохранённым дважды.
С учётом соображений производительности я решил оставить карту карт. При этом я инкапсулировал всю «уродливость» вида set в map в отдельный класс, чтобы дать чистый, понятный API для добавления и проверки наличия элемента в структуре. Также, для повышения читаемости кода, можно отказаться от привычки везде писать строки как простые String — практики, известной как String typing — и оборачивать строки в отдельные классы или record‑типы, заменяя прямое использование String соответствующими типами. В моём случае такое решение дало примерно десятипроцентное увеличение использования памяти для рассматриваемой коллекции по сравнению с исходной картой карт. Все измерения были выполнены на OpenJDK версии 17.0.1.
Вывод: достаточно знать внутренние механизмы JVM — нужно ещё помнить о них в нужный момент, особенно при повседневной поддержке кода. Программирование — это не только идеи и успехи, но и ошибки и неочевидные падения производительности, о которых стоит рассказывать, чтобы учиться на чужих и собственных промахах.




