Руководство по String pool в Java

Введение
Класс String (строка) является наиболее часто используемым классом в языке программирования Java. Исследования показывают, что до 25% объектов, находящихся в heap'е, являются объектами типа String. При этом половина из этих объектов имеют дубликаты. Существует ряд механизмов, направленных на оптимизацию памяти, занимаемую строками, которые мы рассмотрим в этой статье, а именно интернирование в пул строк и дедупликацию.
Для начала сделаем краткое введение в класс String.
1. Класс String
Класс String отвечает за создание строк, состоящих из символов. А если быть точнее, заглянув в реализацию и посмотрев способ их хранения, то строки представляют собой массив символов (так было до Java 9):
private final char value[];
Начиная с Java 9 строки хранятся как массив байт:
private final byte[] value;
Причину смены используемого типа вы можете узнать из статьи «Компактные строки в Java 9».
Строки в Java являются immutable, т. е. неизменяемыми.
Создать объект класса String можно двумя способами: при помощи строкового литерала и конструктора.
Первый способ, а он является рекомендуемым, удобен и прост. Под строковым литералом понимается последовательность символов, заключенных в двойные кавычки:
String stringLiteral = "TopJava";
Класс String имеет в своем распоряжении множество конструкторов, которые могут принимать на вход данные разного типа. Например, строковый литерал:
String stringViaConstructor = new String("TopJava");
или массив символов:
char[] chars = { 'T', 'o', 'p', 'J', 'a', 'v', 'a' };
String str = new String(chars);
Рассмотрим механизм создания и хранения строк более подробно.
2. Интернирование строк
Экземпляр класса String хранится в памяти, именуемой куча (heap), но есть некоторые нюансы. Если строка, созданная при помощи конструктора хранится непосредственно в куче, то строка, созданная как строковый литерал, уже хранится в специальном месте кучи — в так называемом пуле строк (string pool). В нем сохраняются исключительно уникальные значения строковых литералов, а не все строки подряд. Процесс помещения строк в пул называется интернирование (от англ. interning).
Когда мы объявляем переменную типа String и присваиваем ей строковый литерал, то JVM обращается в пул строк и ищет там такое же значение. Если пул содержит необходимое значение, то компилятор просто возвращает ссылку на соответствующий адрес строки без выделения дополнительной памяти. Если значение не найдено, то новая строка будет интернирована, а ссылка на нее возвращена и присвоена переменной.
Пример:
public class StringExampleOne {

    public static void main(String[] args) {
        String str1 = "TopJava";
        String str2 = "TopJava";

        System.out.println("Строка 1 равна строке 2? " + (str1 == str2));
    }
}
Результат выполнения программы:
Напомним, что знак «==» сравнивает ссылки на объекты, а не их значения. Результат выполнения программы подтверждает, что строки str1 и str2 ссылаются на одно и то же место в памяти в пуле строк.
Иллюстративно это выглядит так:
В следующем примере попробуем «склеить» строковые литералы и посмотрим, влияет ли конкатенация на результат:
public class StringExampleTwo {

    public static void main(String[] args) {
        String str1 = "TopJava";
        String str2 = "Top" + "Java";

        System.out.println("Строка 1 равна строке 2? " + (str1 == str2));
    }
}
Результат выполнения программы:
В строке «Top» + «Java» создаются два строковых объекта со значениями «Top» и «Java», которые помещаются в пул. «Склеенные» строки образуют еще одну строку со значением «TopJava», ссылка на которую берется из пула строк (а не создается заново), т.к. она была интернирована в него ранее.
Значения всех строковых литералов из данного примера известно на этапе компиляции.
Иллюстративно итоговый результат выглядит так:
А теперь давайте рассмотрим еще один пример, который выдаст неожиданный результат:
public class StringExampleThree {

    public static void main(String[] args) {
        String str1 = "TopJava";
        String str2 = "Java";
        String str3 = "Top" + str2;

        System.out.println("Строка 1 равна строке 3? " + (str1 == str3));
    }
}
Результат выполнения программы:
Схематично это выглядит примерно так:
Причиной получения false является то, что интернирование происходит не во время работы приложения (runtime), а во время компиляции. А т.к. значение строки str3 вычисляется во время выполнения приложения, то на этапе компиляции оно не известно и потому, не добавляется в пул строк.
3. Создание строк с помощью конструктора
Теперь давайте рассмотрим детальнее процесс создания объекта String при помощи конструктора.
Когда мы создаем экземпляр класса String с помощью оператора new, компилятор размещает строки в куче. При этом каждая строка, созданная таким способом, помещается в кучу (и имеет свою ссылку), даже если такое же значение уже есть в куче или в пуле строк.
Создадим строки через интернирование и с помощью конструктора, а затем сравним их ссылки:
public class StringExampleFour {

    public static void main(String[] args) {
        String str1 = "TopJava";
        String str2 = "TopJava";
        String str3 = new String("TopJava");
        String str4 = new String("TopJava");

        System.out.println("Строка 1 равна строке 2? " + (str1 == str2));
        System.out.println("Строка 2 равна строке 3? " + (str2 == str3));
        System.out.println("Строка 3 равна строке 4? " + (str3 == str4));
    }
}
Результат выполнения программы:
Иллюстративно это выглядит так:
Таким образом, создав четыре одинаковых строки, в памяти зафиксируются только три объекта. Согласитесь, что это нерационально.
4. Ручное интернирование
В Java существует возможность вручную выполнить интернирование строки в пул путем вызова метода intern() у объекта типа String. Видоизменим приведенный ранее пример, добавив метод intern() к созданным при помощи конструктора строкам:
public class StringExampleFive {

    public static void main(String[] args) {
        String str1 = "TopJava";
        String str2 = "TopJava";
        String str3 = (new String("TopJava")).intern();
        String str4 = (new String("TopJava")).intern();

        System.out.println("Строка 1 равна строке 2? " + (str1 == str2));
        System.out.println("Строка 2 равна строке 3? " + (str2 == str3));
        System.out.println("Строка 3 равна строке 4? " + (str3 == str4));
    }
}
Результат выполнения программы:
Иллюстративно это выглядит так:
Рассмотрим еще один интересный пример:
public class StringExampleSix {

    public static void main(String[] args) {
        String str1 = "interned TopJava";
        String str2 = "TopJava";
        String str3 = ("interned " + str2).intern();

        System.out.println("Строка 1 равна строке 3? " + (str1 == str3));
    }
}
Результат выполнения программы:
Соответственно, иллюстративно это выглядит так:
Поясним результат. Строки str1 и str2 добавлены в пул строк на этапе компиляции. Во время выполнения программы происходит конкатенация строки «interned» со значением строки str2, с последующим интернированием получившейся строки в пул строк (благодаря методу intern()). Но, так как пул строк уже содержит строку «interned TopJava», объекту String str3 будет присвоена ссылка на строку в пуле строк и, соответственно, выражение равенства ссылок «==» будет истинным.
Принимая во внимание всё вышесказанное, вы можете спросить: «Почему бы все строки сразу после их создания не добавлять в пул строк? Ведь это приведет к экономии памяти…». Да, среди достаточно большого количества программистов такое заблуждение присутствует. Именно заблуждение, поскольку не все учитывают дополнительные затраты виртуальной машины на процесс интернирования, а также падение производительности, в целом. Тесты и наглядное подтверждение этого приводятся в видео докладе Алексея Шипилёва — «Катехизис java.lang.String». Финализируя доклад Алексея можно сказать, что интернирование (в виде применения метода intern ()) рекомендуется вообще не использовать. Вместо интернирования необходимо использовать дедупликацию (рассматривается далее).
5. Собственный пул строк
Что же тогда делать, если мы создаем много объектов класса String? Нам ничего не мешает написать свой собственный пул строк, доступ к которому может быть быстрее, чем к пулу виртуальной машины. После того, как он справится со своей работой, его можно легко уничтожить.
Рассмотрим пример (источник: доклад Алексея Шипилёва — «Катехизис java.lang.String», код доработан):
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

public class CHMInterner <T> {
    private final Map<T, T> map;

    public CHMInterner() {
        map = new ConcurrentHashMap<>();
    }

    public T intern(T t) {
        T exist = map.putIfAbsent(t, t);
        return (exist == null) ? t : exist;
    }

    public int internSize() {
        return map.size();
    }
}
public class Main {
    public static void main(String[] args) {
        CHMInterner chmInterner = new CHMInterner();

        chmInterner.intern("TopJava_1");
        chmInterner.intern("TopJava_2");
        chmInterner.intern("TopJava_2");
        chmInterner.intern("TopJava_3");
        chmInterner.intern("TopJava_3");
        chmInterner.intern("TopJava_3");
        chmInterner.intern("TopJava_4");
        chmInterner.intern("TopJava_4");
        chmInterner.intern("TopJava_4");
        chmInterner.intern("TopJava_4");

        System.out.println("Размер пула строк равен: " + chmInterner.internSize());
    }
}
Результат выполнения программы:
Таким образом, на основе ConcurrentHashMap был создан пул строк, в который мы пытались добавить 10 повторяющихся строк. В итоге были добавлены только 4 уникальных строки.
6. Сборщик мусора
До Java версии 7 виртуальная машина размещала пул строк в области памяти под названием PermGen, которая имеет фиксированный размер и не может быть расширена во время выполнения приложения. Также следует отметить, что на эту область памяти не распространяется действие сборщика мусора.
Риск интернирования строк в область PermGen (вместо кучи) заключается в том, что мы можем получить от JVM ошибку OutOfMemoryError, если будем интернировать слишком много строк (PermGen имеет фиксированный размер).
Учтите, что однажды интернированную строку в версии Java ниже 7й нельзя деинтернировать: она будет занимать память программы даже тогда, когда перестанет быть нужна. Из этого следует, что чрезмерное интернирование строк может оказать негативный эффект, связанный с утечками памяти!
Начиная с Java 7, пул строк размещается в куче, на которую распространяется процесс сборки мусора. Преимуществом данного подхода является снижение вероятности появления ошибки OutOfMemoryError, так как строки, на которые не будет ссылаться ни одна переменная в выполняемой программе, будут удалены сборщиком мусора из пула, что приведет к освобождению памяти.
7. Производительность и оптимизация
В Java 6 единственной оптимизацией, которую мы могли сделать — это увеличить размер PermGen во время запуска программы, используя опцию JVM — MaxPermSize:
-XX:MaxPermSize=1G
В Java 7 разработчикам предоставили более гибкую возможность настройки (увеличение/уменьшение) размера пула строк. Существуют две возможности посмотреть размер пула:
-XX:+PrintStringTableStatistics
и
-XX:+PrintFlagsFinal
В Java 6 и Java 7 (до Java7u40) значение по умолчанию для параметра -XX:StringTableSize равняется 1009. С Java7u40 размер увеличен до 60 013 (такое же значение используется и в Java 8). В Java 11, 13 и 15 это значение уже составляет 65 536.
Рассмотрим при помощи команды -XX:+PrintStringTableStatistics размер пула строк и другие данные:
SymbolTable statistics:
Number of buckets   	   : 	32768 = 262144 bytes, each 8
Number of entries   	   :    517 =   8272 bytes, each 16
Number of literals  	   :    517 =  19712 bytes, avg  38,000
Total footprint     	   :        = 290128 bytes
Average bucket size 	   :	0,016
Variance of bucket size    :  0,016
Std. dev. of bucket size   :  0,125
Maximum bucket size        :      2
StringTable statistics:
Number of buckets   	   : 	65536 = 524288 bytes, each 8
Number of entries   	   :     68 =   1088 bytes, each 16
Number of literals  	   :     68 =   4648 bytes, avg  68,000
Total footprint     	   :        = 530024 bytes
Average bucket size 	   :  0,001
Variance of bucket size    :  0,001
Std. dev. of bucket size   :  0,032
Maximum bucket size        :      1
Если мы хотим увеличить размер пула, то для этого необходимо воспользоваться опцией StringTableSize:
-XX:StringTableSize=4901
Следует обратить внимание на то, что увеличение размера пула приведет к тому, что будет задействован больший объем памяти, но при этом сократится время, необходимое для добавления строки непосредственно в пул.
8. Пара слов о Java 9
До Java 8 строка внутренне представлялась, как массив символов char[] в кодировке UTF-16, каждый из которых занимал по два байта в памяти.
В Java 9 было внедрено новое представление для типа String, получившее название компактные строки (Compact Strings). Благодаря новому формату хранения строк (в зависимости от контента) делается выбор между массивом символов char[] и массивом байт byte[].
Поскольку новый способ хранения объектов типа String использует кодировку UTF-16 лишь в том случае, когда в этом есть необходимость, объем памяти, занимаемый пулом строк в куче, будет значительно ниже, что в, свою очередь, уменьшит издержки работы сборщика мусора.
Ключевые моменты:
  • Строки в Java представляют собой константы, которые не могут быть изменены
  • Создать объект класса String можно двумя способами: при помощи строкового литерала и конструктора
  • Строковый литерал сохраняется в пул строк, если до этого он там отсутствовал
  • Строка, созданная при помощи конструктора, сохраняется в heap, а не в пул строк
  • Java 6: Пул строк хранится в памяти фиксированного размера, именуемого PermGen.
  • Java 7, 8: Пул строк хранится в heap и, соответственно, для пула строк можно использовать всю память приложения
  • При помощи параметра -XX:StringTableSize=N, где N — размер HashMap, можно изменить размер пула строк. Его размер является фиксированным, поскольку он реализован, как HashMap со списками в корзинах
  • Инженеры по оптимизации Java компании Oracle настоятельно не рекомендуют самостоятельно интернировать строки, поскольку это приводит к замедлению работы приложения. Их рекомендация — дедупликация.
9. Дедупликация
Как мы написали в самом начале, класс String представляет собой массив байт:
private final byte[] value;
А т.к. созданный экземпляр класса String нельзя модифицировать, т. е. содержимое массива value[] нельзя изменить, то его значение может быть безопасно использовано одновременно несколькими объектами String.
Дедупликация представляет собой не что иное, как переприсваивание виртуальной машиной адресов поля value. Т. е. мы выполняем дедупликацию не объектов String, а массивов их байт. Поля value нескольких объектов типа String с одинаковым значением текста изначально ссылаются на разные участки памяти (разные массивы байт), а после дедупликации будут ссылаться на один и тот же участок памяти, содержащий массив байт.
Кроме того, у нас все еще остаются накладные расходы в виде заголовка объекта, полей и др. Такие накладные расходы зависят от платформы/конфигурации и варьируются в пределах от 24 до 32 байт. Однако, для средней длины объекта String в 45 символов (90 байт + заголовок массива), это все еще значительные цифры. Принимая во внимание вышеперечисленное, актуальный выигрыш в экономии памяти может быть около 10%.
9.1. Как работает дедупликация
Во время сборки мусора GC проверяет живые (имеющие рабочие ссылки) объекты в куче на возможность провести их дедупликацию. Ссылки на подходящие объекты вставляются в очередь для последующей обработки. Далее происходит попытка дедупликации каждого объекта String из очереди, а затем удаление из нее ссылок на объекты, на которые они ссылаются. Также для отслеживания всех уникальных массивов байт, используемых объектами String, используется хеш-таблица. При дедупликации в этой хеш-таблице выполняется поиск идентичных массивов байт (символов).
При положительном результате значение поля value объекта String переприсваивается так, чтобы указывать на этот существующий массив байт. Соответственно, предыдущий массив байт value становится ненужным — на него ничего не ссылается и впоследствии он попадает под сборку мусора.
При отрицательном результате, массив байт, соответствующий value, вставляется в хеш-таблицу, чтобы впоследствии быть использованным совместно с новым объектом String в какой-то другой момент в будущем.
Давайте поэкспериментируем, запустив следующую программу:
import java.lang.reflect.Field;

public class DeduplicationDemo {

    public static void main(String[] args) throws InterruptedException, NoSuchFieldException, IllegalAccessException {
        char[] chars = {'T', 'o', 'p', 'J', 'a', 'v', 'a'};
        String[] strings = {new String(chars), new String(chars)};
        Field value = String.class.getDeclaredField("value");
        value.setAccessible(true);

        System.out.println("Хеш первого объекта: " + value.get(strings[0]));
        System.out.println("Хеш второго объекта: " + value.get(strings[1]));

        System.gc();
        System.out.println("Запустили сборщик мусора");
        Thread.sleep(100);

        System.out.println("Хеш первого объекта: " + value.get(strings[0]));
        System.out.println("Хеш второго объекта: " + value.get(strings[1]));
    }
}
Результат выполнения программы:
Как видим, дедупликация не сработала. Для ее активации необходимо в параметрах виртуальной машины указать -XX:+UseStringDeduplication, а также активировать сборщик мусора G1 (если он не используется по умолчанию), указав также -XX:+UseG1GC.
В этом случае имеем правильный результат выполнения программы:
Результат говорит о следующем: создав два объекта с помощью new, мы получили два разных объекта с разными идентификационными хешами для массивов байт. Запустив сборщик мусора и подождав некоторое время (дедупликация не происходит мгновенно), мы видим, что хеши для двух объектов стали одинаковы (ссылаются на один и тот же массив).
Иллюстративно это выглядит так:
Видоизменим немного код, добавив в массив строковый литерал:
import java.lang.reflect.Field;

public class DeduplicationDemo {

    public static void main(String[] args) throws InterruptedException, NoSuchFieldException, IllegalAccessException {
        char[] chars = {'T', 'o', 'p', 'J', 'a', 'v', 'a'};
        String[] strings = {new String(chars), new String(chars), "TopJava"};
        Field value = String.class.getDeclaredField("value");
        value.setAccessible(true);

        System.out.println("Хеш первого объекта: " + value.get(strings[0]));
        System.out.println("Хеш второго объекта: " + value.get(strings[1]));
        System.out.println("Хеш третьего объекта: " + value.get(strings[2]));

        System.gc();
        System.out.println("Запустили сборщик мусора");
        Thread.sleep(100);

        System.out.println("Хеш первого объекта: " + value.get(strings[0]));
        System.out.println("Хеш второго объекта: " + value.get(strings[1]));
        System.out.println("Хеш третьего объекта: " + value.get(strings[2]));
    }
}
Результат выполнения программы:
Иллюстративно это выглядит так:
Создав строковый литерал str3, мы, тем самым, строку «TopJava» добавили в пул строк. Во время дедупликации виртуальная машина увидев, что в пуле строк уже содержится такой массив байт, изменила адрес массива byte[] для созданных через конструктор строковых объектов на адрес массива byte[] строкового литерала, находящегося в пуле строк.
Чтобы убедиться, что этот результат был получен благодаря дедупликации, попробуйте отключить функционал дедупликации строк в виртуальной машине.
Ключевые моменты:
  • Дедупликация строк доступна с Java 8 Update 20
  • Она активируется параметром для виртуальной машины: -XX:+UseStringDeduplication
  • Дедупликация строк работает только со сборщиком мусора G1. Для его активации в Java 8 необходимо указать параметр для виртуальной машины -XX:+UseG1GC. Начиная с Java 9, G1 является сборщиком мусора по умолчанию
  • Опыты показывают, что применение дедупликации строк сокращает расходы кучи на примерно 10%, что, в принципе, неплохо, учитывая, что нам не нужно вносить изменение в код
  • Дедупликация строк работает в фоновом режиме без приостановления работы приложения
  • В отличие от пула строк, который применим только для строк, интернированных командой intern(), или строковых литералов, но не применим для строк, созданных динамически во время жизни приложения, дедупликация строк применима для строк, созданных всеми этими способами
Автор: Малянов Игорь
Технический редактор: Чимаев Максим
Оцените статью, если она вам понравилась!