Неизменяемость строк в Java
Почему класс String в Java неизменяем — влияние на безопасность, кэширование, хэширование и потокобезопасность.
String в Java нельзя изменить после создания. Как только "hello" существует, ни один метод, ни один трюк с рефлексией, ни одно хитрое присваивание не сможет перезаписать символы этого конкретного объекта. Каждая операция, которая «изменяет» строку, на самом деле возвращает новый String. Класс обеспечивает это: поле, хранящее байты, объявлено как private final, сам класс — final, и нет ни одного публичного сеттера, метода append или clear.
Этот выбор — неизменяемость — не стилистическое предпочтение. Это основополагающее решение, которое делает пул строк безопасным, хэширование надёжным, многопоточное совместное использование бесплатным и ряд тонких гарантий безопасности вообще возможными.
Что на самом деле означает «неизменяемый»
String s = "hello";
s.toUpperCase(); // returns "HELLO" — the return value is dropped
System.out.println(s); // prints "hello"
s = s.toUpperCase(); // s now *points at* a different String
System.out.println(s); // prints "HELLO"Переменная s может быть переприсвоена — это свойство переменной, а не объекта. Объект, изначально созданный с "hello", нигде и никогда не изменяется, независимо от того, на что s указывает в дальнейшем. Если другая переменная всё ещё ссылается на него, она по-прежнему видит "hello".
String a = "hello";
String b = a;
a = a.toUpperCase();
System.out.println(a); // "HELLO"
System.out.println(b); // "hello" — still the originalИменно это имеют в виду, когда говорят, что строки похожи на значения: содержимое ссылки на String так же стабильно, как содержимое int.
Почему разработчики JVM выбрали неизменяемость
Из неизменяемости вытекает ряд свойств, каждое из которых даёт реальный прирост производительности или реальную безопасность.
Пул строк безопасен. Если бы "hello" можно было изменить на месте, совместное использование одного экземпляра из пула по всей программе было бы катастрофой: изменение в одном месте незаметно изменяло бы его везде. Неизменяемость — это то, что делает пул строк возможным вообще.
hashCode() можно кэшировать. String вычисляет хэш при первом вызове и сохраняет его в приватном поле. Это кэшированное значение было бы ложным, если бы символы могли измениться позже, что сломало бы каждую HashMap<String, ?>, ключом которой является эта строка. Поскольку содержимое стабильно, кэш постоянен.
Конкурентное чтение не требует синхронизации. Два потока, читающих одну и ту же ссылку на String, никогда не увидят наполовину изменённое значение. Нет ни synchronized, ни volatile, ни танцев с барьерами памяти — нет ничего, что могло бы измениться. Сравните это с изменяемым буфером, где пришлось бы копировать, блокировать или ограничивать владение.
Загрузка классов, рефлексия и проверки безопасности могут доверять строковым аргументам. ClassLoader разрешает имена классов из String-значений, переданных вызывающим кодом. Если бы строка могла быть изменена другим потоком между проверкой безопасности и открытием файла, возникла бы уязвимость гонки — классическая ошибка time-of-check / time-of-use. С неизменяемыми строками значение, прошедшее проверку, идентично значению, которое используется.
Аргументы методов не нуждаются в защитных копиях. Когда вы передаёте String в метод, вам не нужно беспокоиться о том, что он будет изменён и удивит вас при возврате. Получатель может хранить ссылку напрямую; вызывающий код тоже может продолжать использовать свою ссылку.
Цена: массовая мутация дорого обходится
За это приходится платить. Построение строки из 10 000 символов по одному символу с помощью += выделяет абсолютно новый String на каждом шаге, копируя все уже имеющиеся символы плюс новый. Это квадратичная работа — O(n²) для задачи O(n).
// Don't do this for large n
String s = "";
for (int i = 0; i < n; i++) {
s += i + ",";
}Ответом стандартной библиотеки являются изменяемые буферы — StringBuilder для однопоточного кода и StringBuffer для редких случаев совместного использования. Они хранят динамический массив, добавляют элементы за O(1) амортизированно и производят единственный неизменяемый String в конце с помощью toString(). Это канонический паттерн для сборки строк.
StringBuilder sb = new StringBuilder();
for (int i = 0; i < n; i++) {
sb.append(i).append(',');
}
String s = sb.toString();Современные JDK оптимизируют короткие, статически сформированные цепочки + через StringConcatFactory, поэтому "hello, " + name + "!" вполне приемлемо. Случай, которого следует избегать, — это += внутри цикла с неизвестным числом итераций.
Попытки нарушить неизменяемость
Рефлексия технически может достичь приватного поля value и заменить его. Это является неопределённым поведением с точки зрения JVM: JIT предполагает, что строки неизменяемы, и встроит кэшированный hashCode, будет совместно использовать ссылки в пуле и пропускать барьеры чтения, основываясь на этом обещании. Мутирование String через рефлексию может незаметно испортить несвязанный код, который держит ссылку на тот же объект. Не делайте этого. Если вам нужна изменяемость, для этого есть StringBuilder.
Последствия для безопасности
Два конкретных случая, где неизменяемость важна для безопасности:
- Пути к файлам и имена классов. Передаются в API, которые выполняют проверку доступа перед открытием или загрузкой. Если бы путь мог измениться между проверкой и использованием, песочницы можно было бы обойти.
- Ключи
ClassLoaderи ключиString-карт. Стабильные хэш-коды означают, что злоумышленник не может создать ключ, который «подходит» в одном месте и незаметно перемещается в другое.
Обратная сторона: хранить пароли в String — плохая практика по противоположной причине. Как только пароль находится в String, вы не можете обнулить его — байты остаются в памяти кучи до тех пор, пока GC не освободит их, возможно, уже после записи дампа кучи. Для паролей используйте char[] (который можно вручную заполнить нулями) или — лучше — javax.crypto.SecretKey и сопутствующие классы. Метод Console.readPassword() из JDK возвращает char[] именно по этой причине.
Практический пример
Эта программа создаёт строку, передаёт её нескольким вызывающим функциям, каждая из которых «мутирует» её, а затем выводит, что видит каждая переменная после этого. Исходный объект посещается четырьмя ссылками и остаётся неизменным. Единственный изменяемый буфер в конце — это канонический вариант, когда вам действительно нужно построить строку по частям.
Обратите внимание на два сравнения ==. original и alias буквально являются одним и тем же объектом, поэтому тождественность сохраняется. original и upper имеют связанное содержимое, но upper — это свежий объект — нет никакой возможности для upperCase изменить тот, который был передан ему. Это гарантия, на которую каждый Java-разработчик полагается, не задумываясь об этом.
Что дальше
Когда вам действительно нужна строка, которую можно изменить, стандартная библиотека предоставляет изменяемого родственника String. Это рабочая лошадка за каждой цепочкой +, которую оптимизирует компилятор, и правильный ответ всякий раз, когда иначе пришлось бы использовать += в цикле. Продолжайте изучение с Java StringBuilder.