Разбирая недавно старый код, доставшийся мне по наследству на одном из проектов, я наткнулся на дефект, который отнял у меня добрую часть рабочего дня. Ошибка была самым настоящим 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 (подсказка: все несколько сложнее, чем кажется на первый взгляд).