При работе с естественным языком и лингвистическом анализе текстов нам часто приходится оперировать огромным количеством уникальных коротких строк. Счёт идёт на десятки и сотни миллионов — именно столько в языке существует, к примеру, осмысленных сочетаний из двух слов. Основной платформой для нас является Java и мы не понаслышке знаем о её прожорливости при работе с таким большим количеством мелких объектов.
Чтобы оценить масштаб бедствия, мы решили провести простой эксперимент — создать 100 миллионов пустых строк в Яве и посмотреть, сколько придётся заплатить за них оперативной памяти.
Внимание: В конце статьи приведён опрос. Будет интересно, если вы попробуете ответить на него до прочтения статьи, для самоконтроля.
Правилом хорошего тона при проведении любых замеров считается опубликовать версию виртуальной машины и параметры запуска теста:
Сжатие указателей включено (читай: размер кучи меньше 32 Гб):
Сжатие указателей выключено (читай: размер кучи больше 32 Гб):
Исходный код самого теста:
Процесс
Ищем идентификатор процесса с помощью утилиты jps и делаем снимок кучи (heap dump) с помощью jmap:
Анализируем снимок кучи, используя Eclipse Memory Analyzer (MAT):
Для второго теста с выключенным сжатием указателей снимки не приводим, но мы честно провели эксперимент и просим поверить на слово (оптимально: воспроизвести тест и убедиться самим).
Выводы
Если вы работаете с размером кучи больше 32Гб (сжатие указателей выключено), то указатели будут стоить ещё дороже. Соответственно будут такие результаты:
Итого, за каждую строку вы дополнительно к размеру массива символов платите 44 байта (64 байта без сжатия указателей). Если средняя длина строк составляет 15 символов, то получается почти 5 байт на каждый символ. Запретительно дорого, если речь идёт о домашнем железе.
Как бороться
Существуют две основные стратегии для экономии ресурсов:
К сожалению встроенных механизмов, чтобы более компактно хранить каждую отдельную строку, в Яве нет. В будущем ситуация может немного улучшиться для отдельных сценариев: см. JEP 254.
На посмотреть
Горячо рекомендуем посмотреть доклад Алексея Шипилёва из Oracle под громким названием «Катехизис java.lang.String» (спасибо periskop за наводку). Там он говорит по проблеме статьи на 4:26 и про интернирование/дедупликацию строк, начиная с 31:52.
В заключение
Решение любой проблемы начинается с оценки её масштабов. Теперь вы эти масштабы знаете и можете учитывать накладные расходы при работе с большим количеством строк в своих проектах.
Чтобы оценить масштаб бедствия, мы решили провести простой эксперимент — создать 100 миллионов пустых строк в Яве и посмотреть, сколько придётся заплатить за них оперативной памяти.
Внимание: В конце статьи приведён опрос. Будет интересно, если вы попробуете ответить на него до прочтения статьи, для самоконтроля.
Правилом хорошего тона при проведении любых замеров считается опубликовать версию виртуальной машины и параметры запуска теста:
> java -version
java version "1.8.0_101"
Java(TM) SE Runtime Environment (build 1.8.0_101-b13)
Java HotSpot(TM) 64-Bit Server VM (build 25.101-b13, mixed mode)
Сжатие указателей включено (читай: размер кучи меньше 32 Гб):
java -Xmx12g -Xms12g -XX:+UseConcMarkSweepGC -XX:NewSize=4g -XX:+UseCompressedOops ... ru.habrahabr.experiment.HundredMillionEmptyStringsExperiment
Сжатие указателей выключено (читай: размер кучи больше 32 Гб):
java -Xmx12g -Xms12g -XX:+UseConcMarkSweepGC -XX:NewSize=4g -XX:-UseCompressedOops ... ru.habrahabr.experiment.HundredMillionEmptyStringsExperiment
Исходный код самого теста:
package ru.habrahabr.experiment;
import org.apache.commons.lang3.time.StopWatch;
import java.util.ArrayList;
import java.util.List;
public class HundredMillionEmptyStringsExperiment {
public static void main(String[] args) throws InterruptedException {
List<String> lines = new ArrayList<>();
StopWatch sw = new StopWatch();
sw.start();
for (int i = 0; i < 100_000_000L; i++) {
lines.add(new String(new char[0]));
}
sw.stop();
System.out.println("Created 100M empty strings: " + sw.getTime() + " millis");
// чтобы не сохранять лишнего и было проще анализировать снимок кучи
System.gc();
// защита от оптимизаций
while (true) {
System.out.println("Line count: " + lines.size());
Thread.sleep(10000);
}
}
}
Процесс
Ищем идентификатор процесса с помощью утилиты jps и делаем снимок кучи (heap dump) с помощью jmap:
> jps
12777 HundredMillionEmptyStringsExperiment
> jmap -dump:format=b,file=HundredMillionEmptyStringsExperiment.bin 12777
Dumping heap to E:\jdump\HundredMillionEmptyStringsExperiment.bin ...
Heap dump file created
Анализируем снимок кучи, используя Eclipse Memory Analyzer (MAT):
Для второго теста с выключенным сжатием указателей снимки не приводим, но мы честно провели эксперимент и просим поверить на слово (оптимально: воспроизвести тест и убедиться самим).
Выводы
- 2.4 Гб занимает обвязка объектов класса String + указатели на массивы символов + хэши.
- 1.6 Гб занимает обвязка массивов символов.
- 400 Мб занимают указатели на строки.
Если вы работаете с размером кучи больше 32Гб (сжатие указателей выключено), то указатели будут стоить ещё дороже. Соответственно будут такие результаты:
- 3.2 Гб занимает обвязка объектов класса String + указатели на массивы символов + хэши.
- 2.4 Гб занимает обвязка массивов символов.
- 800 Мб занимают указатели на строки.
Итого, за каждую строку вы дополнительно к размеру массива символов платите 44 байта (64 байта без сжатия указателей). Если средняя длина строк составляет 15 символов, то получается почти 5 байт на каждый символ. Запретительно дорого, если речь идёт о домашнем железе.
Как бороться
Существуют две основные стратегии для экономии ресурсов:
- Для большого количества дублирующихся строк можно использовать интернирование (string interning) или дедупликацию (string deduplication). Суть механизма такая: поскольку строки в Яве неизменяемые, то можно хранить их в отдельном пуле и при повторе ссылаться на существующий объект вместо создания новой строки. Такой подход не бесплатен — он стоит и памяти и процессорного времени для хранения структуры пула и поиска в нём.
Чем отличается интернирование от дедупликации, какие есть вариации последней, и чем чревато использование метода String.intern() смотрите в замечательном докладе Алексея Шипилёва (ссылка), начиная с 31:52.
- Если, как в нашем случае, строки уникальные — не остаётся ничего другого как использовать различные алгоритмические трюки. Мини-анонс: как мы работаем с сотней миллионов биграмм (читай: слово + слово или 15 символов) в наших задачах расскажем в самое ближайшее время.
К сожалению встроенных механизмов, чтобы более компактно хранить каждую отдельную строку, в Яве нет. В будущем ситуация может немного улучшиться для отдельных сценариев: см. JEP 254.
На посмотреть
Горячо рекомендуем посмотреть доклад Алексея Шипилёва из Oracle под громким названием «Катехизис java.lang.String» (спасибо periskop за наводку). Там он говорит по проблеме статьи на 4:26 и про интернирование/дедупликацию строк, начиная с 31:52.
В заключение
Решение любой проблемы начинается с оценки её масштабов. Теперь вы эти масштабы знаете и можете учитывать накладные расходы при работе с большим количеством строк в своих проектах.
Only registered users can participate in poll. Log in, please.
Сколько места в куче занимают 100 миллионов пустых строк в Java? (Java 8, сжатие указателей включено, считаем только сами объекты, без учёта указателей на них)
28.97% нисколько, они ведь пустые168
24.83% 1.6 Гб144
18.28% 2.4 Гб106
12.76% 3.2 Гб74
15.17% 4 Гб88
580 users voted. 456 users abstained.