Комментарии 85
К сожалению в Яве не существует встроенных механизмов, чтобы напрямую сократить потребление памяти при работе со строками
Существуют. Вы можете работать с массивом байт вместо класса String. Как, например, это делают в проекте Netty (смотреть класс AsciiString). Так же в Java 9 на подходе JEP 254.
1) Массив байт даёт выигрыш в 1 байт на символ (из пяти!). Просто по той причине, что если вы хотите использовать .hashCode и .equals, вам придётся положить массив в объект-контейнер, который будет отвечать за хэширование и сравнение.
2) Это ни разу не встроенный механизм, а свой велосипед.
3) JEP 254 — это хотя бы намёк на то, что разработчики знают о существовании проблемы. Но это опять же экономия в 1 байт для строк с латиницей. Для национальных языков выигрыш отсутствует.
Основной расход памяти здесь не на сами символы (в тесте их просто нет), а на дорогущие обвязки объектов и неспособность Java хранить объектные поля рядом с самим экземпляром. Есть подстольные решения для последнего, но это ещё более велосипед.
Если использовать решение со складыванием содержимого всех строк в один большой массив — там уже начинаются варианты. Но это государство в государстве, по сути своя модель управления памятью.
Цель статьи ни в коем случае не в критике Явы — она такая, какая есть и тому свои причины. Цель — наглядно показать, что строки стоят не 2 байта на символ, а существенно дороже в случае работы с короткими строками.
Соответственно очень-очень хочется этого избежать и каждый сценарий анализируешь вдоль и поперёк, а нельзя ли его решить внутри платформы.
Не согласен.
Не согласны с тем что вместо класса String можно использовать массив байт? Ну ок…
Массив байт даёт выигрыш в 1 байт на символ (из пяти!).
Это лишь одна из возможных оптимизаций. Можно банально все в один массив сложить, или в коллекцию. Тут уже можно сэкономить в 2-3 раза. Совсем не обазательно массив байт оборачивать в класс. А если у вас все строки уникальны, то Вам даже и строки хранить не надо, тут уже от задачи зависит.
2) Это ни разу не встроенный механизм, а свой велосипед.
Масисив байтов не встроенный механизм? Ну ок…
Основной расход памяти здесь не на сами символы (в тесте их просто нет), а на дорогущие обвязки объектов и неспособность Java хранить объектные поля рядом с самим экземпляром.
Спс, кэп.
Все оптимизации в данном случае — это в той или иной степени свой механизм управления памятью. Работает? Конечно! Удобно? Ни разу.
Это будет бесценный материал для принятия взвешенного решения, какую платформу использовать в похожих сценариях (если есть такой выбор).
extern crate heapsize;
use heapsize::heap_size_of;
use std::os::raw::c_void;
use std::mem::size_of_val;
fn main() {
let n = 100_000_000;
let mut vec: Vec<String> = Vec::with_capacity(n);
for _ in 0..n {
vec.push(String::from(""));
}
let self_size = unsafe { heap_size_of(vec.as_ptr() as *const c_void) };
let string = String::from("");
let string_size = size_of_val::<String>(&string);
print!("Heap size: one string: {}B, vec: {}B\n", string_size, self_size);
}
Heap size: one string: 24B, vec: 2684354560B
Итого размер строки по умолчанию 24 байта. + 271МБ(~10%) рискну предположить что пропало в недрах jemalloc. Независимо от количества элементов эти 10% остаются десятью процентами.
const util = require('util');
const process = require('process');
var A=new Array();
for (var i=0; i<10000000; i++)
A.push(new String("/u0000"));
console.log(util.inspect(process.memoryUsage()));
~$ nodejs x.js
{ rss: 496259072, heapTotal: 474601056, heapUsed: 470572400 }
До кучи:
...
for (var i=0; i<10000000; i++)
A.push(new String((10000000+i).toString())); // Длинна строки всегда 8 символов
...
{ rss: 821313536, heapTotal: 794501216, heapUsed: 790837096 }
...
for (var i=0; i<10000000; i++)
A.push(new String("\u0000"));
...
{ rss: 496185344, heapTotal: 474601056, heapUsed: 470573552 }
Принципиально картинка не поменялась. И да, тут 10 млн строк.
Для большого количества дублирующихся строк можно использовать интернирование (string interning). Суть механизма такая: поскольку строки в Яве неизменяемые, то можно хранить их в отдельном пуле и при повторе ссылаться на существующий объект вместо создания новой строки. Такой подход не бесплатен — он стоит и памяти и процессорного времени для хранения структуры пула и поиска в нём.
Но строки в джаве и так хранятся в отдельном пуле и, если такая строка существует в пуле, то будет ссылаться на нее
Остальные строки можно сложить в пул (интернировать) применив к ним метод .intern(). Попробуйте запустить следующий код:
package ru.habrahabr.experiment;
public class StringEqualityExperiment {
public static void main(String[] args) {
String x = "abc";
String y = "abc";
System.out.println(x == y);
x = new String("abc");
y = new String("abc");
System.out.println(x == y);
x = new String("abc").intern();
y = new String("abc").intern();
System.out.println(x == y);
x = new String("abc").intern();
y = new String("abc");
System.out.println(x == y);
}
}
Он выдаст: true false true false.
Пул, начиная с Java 7, можно использовать (до этого были серьёзные архитектурные проблемы). Но стоит подкручивать его под конкретный сценарий настроечкой -XX:StringTableSize и заранее оценивать, стоит ли в принципе овчинка выделки. В нашем случае, при работе с уникальными строками, использование пула начисто лишено всякого смысла.
К сожалению в Яве не существует встроенных механизмов, чтобы напрямую сократить потребление памяти при работе со строками.
и далее расказывается про пул строк и интернирование, поэтому я и уточнил данный момент
Пул, начиная с Java 7, можно использовать
Вот как вы можете ссылаться на презентацию Шипилёва и тут же говорить, что интернирование можно использовать? Шипилёв кучу раз со свойственной ему выразительностью говорил, что интернирование использовать нельзя. Вот в том самом видео, на которое вы ссылку вставили, с 32-й минуты про это же и говорит. Это низкоуровневая штука, нужная самой JVM и библиотекам, использующим JNI. Это не для пользователей. Если вам нужна дедупликация, напишите свой собственный пул, это 15 строчек кода. Не пользуйтесь String.intern(), если вы просто хотите снизить расход памяти! Он для других целей.
Тут, конечно, всё зависит от того, как вы потом эти фразы используете.
По теме — Алексей немного лукавит. Если использовать подстроечный параметр -XX:StringTableSize, то просадка по скорости не такая ужасающая получается. Но сам посыл очень грамотный: если используешь встроенную магию в приложениях чувствительных к производительности, будь добр разобраться как она работает.
Особенно это касается встроенного интернирования, с которым ещё в шестёрке были адовы проблемы с забиванием PermGen.
Может быть, проверите на реальных данных, как влияет простота размера таблицы со строками на количество коллизий в HashMap-е?
На данный момет, при таком масштабе — 100 млн строк — будет ещё просадка и от GC — обход такого графа не дешёвое удовольствие (и когда они в молодом поколении, и когда будут перенесены в старое). В общем-то схожие проблемы возникают при подобных масштабах при любых объектах — и пока один выход — уходить в offheap.
int[] textCodes;
.Вот только надо подумать об устройстве достаточно осмысленной и при этом быстрой хэш-функции…
Более того ваше направление мысли абсолютно верное. Именно так мы и поступили, но об этом отдельная статья.
может в том, что он выполняется не как "очисть мне сейчас", а "по возможности очисть раньше чем планировал"
Можно использовать всё что угодно и как угодно, просто в этом случае вы берёте на себя ответственность за последствия.
Которая дёргает тот же самый метод. И не зря есть подстроечный параметр -XX:+DisableExplicitGC, который защищает ваш боевой код от отчаянных разработчиков сторонних библиотек, которые вопреки всем советам дёргают рубильник.
java version «1.8.0_102»
Java(TM) SE Runtime Environment (build 1.8.0_102-b14)
Java HotSpot(TM) 64-Bit Server VM (build 25.102-b14, mixed mode)
Поделитесь вашими экспериментами.
System.gc() не вызывает метод finalize(), а может добавить ваш объект в очередь финализации (если на него не осталось ссылок), которая разгребается отдельным потоком, не имеющим отношения к сборке мусора. В зависимости от того, что делает этот поток, финалайзер может не вызываться очень долго или никогда. Вообще выглядит так, будто вы какой-то магией занимаетесь без понимания происходящего. Код показать можете?
public void unload() {
try {
if (driverManager != null) {
DriverManagerProxy dmp = driverManager;
this.driverManager = null;
dmp.deregisterDriver(driver);
} else {
DriverManager.deregisterDriver(driver);
}
} catch (SQLException e) {
e.printStackTrace();
}
this.driver = null;
if (classLoader != null) {
ResourceBundle.clearCache(classLoader);
try {
cleanupThreadLocals(this.classLoader);
} catch (ReflectiveOperationException e1) {
e1.printStackTrace();
}
try {
this.classLoader.close();
} catch (IOException e) {
e.printStackTrace();
}
this.classLoader = null;
System.gc();
System.runFinalization();
System.gc();
System.runFinalization();
}
if (!delete(dir, false)) {
dumpHeap();
delete(dir, false);
}
}
Сурово, спасибо. Яркий пример кода, который пошёл метастазами. Я честно с этой проблемой не сталкивался и, возможно, лучше действительно ничего не придумаешь (я сомневаюсь, но всякое бывает).
Впрочем, речь была о другом: при настройках по умолчанию System.gc точно запускает сборку; проблема в чём-то ином. Есть ещё несколько способов вызвать GC: через DiagnosticCommandMBean или через JVMTI. Но дамп хипа — это имхо перебор.
Что у меня получилось в итоге:
Библиотека выгружается вместе с класслоадером.
Класслоадер выгружается после полной сборки мусора, если нет ни одного живого объекта из загруженных им классов.
После первой полной сборки мусора все объекты очищаются, но пустой класслоадер с библиотекой остается. Поэтому приходится 2 раза подряд вызывать System.gc().
Сам класслоадер удаляется после 2-го System.gc() в Java 7 и не удаляется в Java 8.
А вот dumpHeap делает то, что требуется, и объекты подчищает, и класслоадер, и библиотеку выгружает, и файл освобождает. Поскольку это дорого, то к этому лекарству я прибегаю только тогда, когда больше ничего не помогло.
Здесь он никак не мешает (и не помогает), потому что Eclipse MemoryAnalyzer практически во всех вьюшках показывает только достижимые объекты. То есть если в куче есть недостижимый, то неважно, собрал ли его GC или нет — в MemoryAnalyzer'е увидим одно и то же. А насчёт верят — ну разработчики JDK вон тоже верят. Тоже дураки?
$ java -XX:+UnlockDiagnosticVMOptions -XX:+PrintFlagsFinal -version | egrep "UseStringDe|UseG1GC"
bool UseG1GC = false {product}
bool UseStringDeduplication = false {product}
java version "1.8.0_74"
Java(TM) SE Runtime Environment (build 1.8.0_74-b02)
Java HotSpot(TM) 64-Bit Server VM (build 25.74-b02, mixed mode)
На восьмёрке у меня -XX:+UseG1GC -XX:-UseStringDeduplication отвратительно себя ведёт — зажирает процессор и в целом в реальных приложениях проседает производительность. Но я не разбирался с ним досконально, просто ушёл обратно на CMS.
- заголовок объекта
- ссылка на массив char-ов
- hashcode (кэшируется)
- дырки (паддинги) для выравнивания полей
Это все можно было и без подобного теста узнать
java.lang.Class
по мнению MAT.40 байт вместо реальных 96! Или ещё:
java.lang.invoke.MemberName
якобы занимает 32 байта, хотя на самом деле 56. И это пример не с потолка: я сталкивался с реальными утечками, связанными с MemberName: JDK-8152271.А статья без какого-либо анализа основывается исключительно на инструменте, который в некоторых случаях врёт в 2 раза!
Ну ладно уж тебе :-) Для строк MAT обычно не врёт, ему эвристик хватает, чтобы разобраться. Хотя, конечно, если вопрос стоит не "куда у меня десять гигабайт кучи делось", а "сколько точно байт занимает X", то, конечно, JOL использовать логичнее.
Сколько места в куче занимают 100 миллионов строк в Java?