“String pool в Java” или почему не надо сравнивать строки при помощи “==”

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

Метод intern()

Внеся изменения в метод getRandomStr, мы сознательно добавили полученную “случайную” строку в пул при первом вызове этого метода:

String result = sb.toString().intern();

Так как пул ранее не содержал строки с таким значением, она была интернирована, а ссылка на новый объект возвращена в переменную result, которую мы присвоили в переменную temp метода main. При повторном вызове метода getRandomStr мы попытались интернировать ту же самую строку. В методе intern выполнилась проверка, что аналогичный объект уже имеется в пуле и возвращена ссылка на ранее интернированную строку. Именно поэтому temp == temp2 вернул значение true. Не лишним будет заметить, что строковый пул в Java участвует в сборке мусора, то есть объекты, не имеющие ссылок, будут удалены при следующей очистке.

Выводы

Возвращаясь к корням проблемы из начала статьи, могу предположить, что в старой реализации сервера приложений строки интернировались по умолчанию, поэтому сравнение ссылок возвращало корректные результаты. Тем не менее, самым очевидным способом сравнения строк является метод equals(), сравнивающий внутренние char[] посимвольно.

А еще метод equals может быть интринсифицирован, то есть заменён на intrinsic-функцию JIT-компилятором. Подробнее об интринсиках можно почитать в статье на хабре.

Обновление:

Интереснейшая статья Алексей Шипилёва, демонстрирующая аспекты производительности интернирования строк в OpenJDK (подсказка: все несколько сложнее, чем кажется на первый взгляд).