Разбирая недавно старый код, доставшийся мне по наследству на одном из проектов, я наткнулся на дефект, который отнял у меня добрую часть рабочего дня. Ошибка была самым настоящим heisenbug‘ом: несколько лет этот код успешно работал в продукционной среде и дефект вышел из тени только при миграции на новую версию сервера приложений.
Метод, в котором возникала ошибка, очевидно нуждался в рефакторинге. Он использовал не самые прозрачные конструкции и был плохо читаем. Метод принимал два параметра типа String: первый – ключ, второй – значение. В блоках if осуществлялась проверка ключа на равенство некоторому строковому литералу, в случае совпадения компилировался экземпляр java.util.regex.Pattern соответствующего регулярного выражения, затем при помощи java.util.regex.Matcher.find() проверялось наличие подстроки в полученном значении. Ниже похожий метод, который я набросал “по памяти”:
public static boolean isOk(String paramName, String paramValue) {
// TEST.12345
String pattern1 = "TEST.\\d{5}";
// TEST.123
String pattern2 = "TEST.\\d{3}";
Pattern pattern = null;
if (paramName == "TEST1") {
pattern = Pattern.compile(pattern1);
} else if (paramName == "TEST2") {
pattern = Pattern.compile(pattern2);
} else {
throw new IllegalArgumentException("Wrong parameter " + paramName);
}
return pattern.matcher(paramValue).find();
}
Обратите внимание, как осуществляется проверка строк на эквивалентность – сравниваются не значения, а ссылки на объекты в куче. В Java литературе такой подход считается bad practice, ведь совершенно необязательно, что две одинаковые (по содержанию) строки являются одним и тем же объектом. Но если вы запустите этот код в своей IDE, он может оказаться вполне работоспособным и вернёт ожидаемый результат.
Пул строк в Java
Java-компилятор и JIT-интерпретатор значительно оптимизируют выполнение кода, а объекты String вполне пригодны для оптимизации в контексте уменьшения потребляемой памяти (дедупликации или интернирования). Так называемый string pool в Java – это некоторое пространство, выделяемое в куче (до Java 7 – в отдельном пространстве PermGen, выделяемом в той же heap, будьте осторожны – в конечном счёте все зависит от конкретной реализации виртуальной машины!), которое используется для оптимизации потребления памяти при хранении строк. Рассмотрим простой пример:
public static void main(String[] args) {
String a = "abc";
String b = "abc";
System.out.println(a == b);
}

Мы создали два объекта типа String путём присваивания им одинаковых строковых литералов "abc". Результат сравнения ссылок a и b возвращает нам true, отсюда следует, что обе ссылки ведут к одному и тому же объекту в куче. Значения обоих литералов известны ещё на этапе компиляции, это позволяет оптимизировать использование памяти, внеся литерал "abc" в строковый пул, а при создании новой строки с аналогичным значением, возвращая ссылку на уникальный объект из пула. Эта операция полностью безопасна, так как строки в Java являются неизменяемыми (immutable). Теперь обновим условие:
public static void main(String[] args) {
String a = "abc";
String b = "abc";
// Создадим еще одну строку
String c = new String("abc");
System.out.println(a == b);
System.out.println(a == c);
}

Ссылки a и b по-прежнему указывают на один и тот же объект, в то время как ссылка c указывает на новый объект, о чём свидетельствует false в выводе консоли. То есть использование оператора new при создании новой строки приводит к созданию нового объекта (<sarcasm>не может быть!</sarcasm>), даже если аналогичная по значению строка уже создавалась ранее и сохранена в пуле строк.
Вероятнее всего компилятор способен на такую оптимизацию для всех строковых литералов и констант, доступных на момент компиляции.
Напишем метод, который будет генерировать случайную строку некоторой длины:
public static String getRandomStr(int length) {
StringBuilder sb = new StringBuilder();
Random rnd = new Random();
for (int i = 0; i < length; i++) {
sb.append(Character.toChars(rnd.nextInt(255)));
}
return sb.toString();
}
Модифицируем метод main:
public static void main(String[] args) {
String temp = getRandomStr(5);
String temp2 = getRandomStr(5);
System.out.println("temp= " + temp);
System.out.println("temp2= " + temp2);
System.out.println(temp == temp2);
}

Мы создаем две случайные строки длиной 5 символов и получаем false при сравнении их ссылок, что вполне логично. Теперь “сломаем” генератор псевдослучайных чисел в методе getRandomStr. Передадим ему в конструкторе фиксированное значение seed. Теперь при каждом вызове этого метода будет возвращаться одинаковая строка.
public static String getRandomStr(int length) {
StringBuilder sb = new StringBuilder();
Random rnd = new Random(1);
for (int i = 0; i < length; i++) {
sb.append(Character.toChars(rnd.nextInt(255)));
}
return sb.toString();
}
Снова вызовем метод main и посмотрим в консоль:

На этот раз псевдослучайные строки в обоих случаях посимвольно равны, но ссылаются на разные объекты кучи. Почему? Думаю, что в этом случае добавление строки в пул не произошло. Давайте исправим это, вызвав метод intern перед возвратом из методаgetRandomStr:
public static String getRandomStr(int length) {
StringBuilder sb = new StringBuilder();
Random rnd = new Random(1);
for (int i = 0; i < length; i++) {
sb.append(Character.toChars(rnd.nextInt(255)));
}
String result = sb.toString().intern();
return result;
}

На этот раз ссылки указывают на один и тот же объект. Если копнуть исходный код класса String, то видно, что метод intern реализован как native, то есть использует JNI:

Но native не значит “быстрый”. Стоимость вызова intern может варьироваться в зависимости от используемой JVM, поэтому относится к его использованию нужно с осторожностью.
Интернирование строк
Давайте рассмотрим метод intern подробнее. Его описание из javadoc для Java 8:

Внеся изменения в метод getRandomStr, мы сознательно добавили полученную “случайную” строку в пул при первом вызове этого метода:
String result = sb.toString().intern();
Так как пул ранее не содержал строки с таким значением, она была интернирована, а ссылка на новый объект возвращена в переменную result, которую мы присвоили в переменную temp метода main. При повторном вызове метода getRandomStr мы попытались интернировать ту же самую строку. В методе intern выполнилась проверка, что аналогичный объект уже имеется в пуле и возвращена ссылка на ранее интернированную строку. Именно поэтому temp == temp2 вернул значение true. Не лишним будет заметить, что строковый пул в Java участвует в сборке мусора, то есть объекты, не имеющие ссылок, будут удалены при следующей очистке.
Выводы
Возвращаясь к корням проблемы из начала статьи, могу предположить, что в старой реализации сервера приложений строки интернировались по умолчанию, поэтому сравнение ссылок возвращало корректные результаты. Тем не менее, самым очевидным способом сравнения строк является метод equals(), сравнивающий внутренние char[] посимвольно.
А еще метод equals может быть интринсифицирован, то есть заменён на intrinsic-функцию JIT-компилятором. Подробнее об интринсиках можно почитать в статье на хабре.
Обновление:
Интереснейшая статья Алексей Шипилёва, демонстрирующая аспекты производительности интернирования строк в OpenJDK (подсказка: все несколько сложнее, чем кажется на первый взгляд).