Java Challengers #2: Сравнение строк
У нас как всегда много опаздывающих к началу курса, так что только вчера провели второе занятие среди нового потока "Разработчик Java". Но это так, мелочи жизни, а пока что мы продолжаем публикацию серии статей Java Challengers, перевод которых подготовили для вас.
В Java класс String
инкапсулирует массив char
(прим. переводчика — с java 9 это уже массив byte
, см. Компактные строки в Java 9). Говоря по простому, String
— это массив символов, используемый для составления слов, предложений или других конструкций.
Инкапсуляция — это одна из самых мощных концепций объектно — ориентированного программирования. Благодаря инкапсуляции вам не нужно знать как работает класс String
. Вам достаточно знать методы его интерфейса.
Когда вы смотрите на класс String
в Java, вы можете увидеть как инкапсулирован массив char
:
public String(char value[]) {
this(value, 0, value.length, null);
}
Чтобы лучше понять инкапсуляцию, представьте физический объект: машину. Нужно ли вам знать, как работает автомобиль под капотом, чтобы управлять им? Конечно, нет, но вы должны знать, что делают интерфейсы автомобиля: педаль газа, тормоза и рулевое колесо. Каждый из этих интерфейсов поддерживает определенные действия: ускорение, торможение, поворот налево, поворот направо. То же самое и в объектно — ориентированном программировании.
Первая статья в серии Java Challengers была про перегрузку методов, которая широко используется в классе String
. Перегрузка может сделать ваши классы действительно гибкими:
public String(String original) {}
public String(char value[], int offset, int count) {}
public String(int[] codePoints, int offset, int count) {}
public String(byte bytes[], int offset, int length, String charsetName) {}
// И так далее ...
Вместо того, чтобы пытаться понять, как работает класс String
, эта статья поможет вам понять что он делает и как использовать его в вашем коде.
Что такое пул строк (String pool)
Класс String
, возможно, наиболее часто используемый класс в Java. Если новый объект создавать в динамической памяти (memory heap) каждый раз, когда мы используем String
, то мы потратим впустую много памяти. Пул строк (String pool) решает эту проблему, сохраняя только один объект для каждого значения строки.
Строки в пуле строк
Хотя мы создали несколько переменных String
со значениями Duke
и Juggy
, но в динамической памяти (куче) создаётся и храниться только два объекта. Для доказательства посмотрите следующий пример кода. (Напомним, что в Java оператор "==
" используется для сравнения двух объектов и определения того один и тот же это объект или нет.)
String juggy = "Juggy";
String anotherJuggy = "Juggy";
System.out.println(juggy == anotherJuggy);
Этот код вернет true
, потому что две переменные String
указывают на один и тот же объект в пуле строк. Их значения одинаковые.
Исключение — оператор new
Теперь посмотрите на этот код — он выглядит похожим на предыдущий пример, но здесь есть отличие.
String duke = new String("duke");
String anotherDuke = new String("duke");
System.out.println(duke == anotherDuke);
На основе предыдущего примера можно подумать, что этот код вернёт true
, но это не так. Добавление оператора new
приводит к созданию нового объекта String
в памяти. Таким образом, JVM создаст два разных объекта.
Native — методы
Native-методы в Java — это методы, которые будут компилироваться с использованием языка C, обычно с целью управления памятью и оптимизации производительности.
Пулы строк и метод intern()
Для хранения строк в пуле используется способ, называемый "интернирование строк" (String interning).
Вот, что Javadoc говорит нам о методе intern()
:
/**
* Возвращает каноническое представление для строкового объекта.
*
* Пул строк (первоначально пустой) управляется классом {@code String}.
*
* Когда вызывается метод intern, если пул уже содержит строку,
* равную этому объекту {@code String}, определяемому через
* метод {@link #equals(Object)}, тогда возвращается строка из пула.
* Иначе, этот объект {@code String} добавляется к
* пулу и возвращается ссылка на этот объект {@code String}.
*
* Из этого следует, что для любых двух строк {@code s} и {@code t},
* {@code s.intern() == t.intern()} будет {@code true}
* тогда и только тогда, когда {@code s.equals(t)} равно {@code true}.
*
* Все литеральные строки и строковые константы интернируются.
* Строковые литералы определяются в разделе 3.10.5 The Java™ Language Specification.
*
* @returns строка, которая имеет то же самое содержание как эта строка,
* но, гарантируется, что она будет из пула уникальных строк.
*
* @jls 3.10.5 String Literals
*/ public native String intern();
Метод intern()
используется для хранения строк в пуле строк. Во-первых, он проверяет, существует ли уже созданная строка в пуле. Если нет, то создает новую строку в пуле. Логика пула строк основана на паттерне Flyweight.
Теперь, обратите внимание, что происходит, когда мы используем new
для создания двух строк:
String duke = new String("duke");
String duke2 = new String("duke");
System.out.println(duke == duke2); // Здесь результат будет false
System.out.println(duke.intern() == duke2.intern()); // Здесь результат будет true
В отличие от предыдущего примера с ключевым словом new
, в данном случае сравнение вернёт true
. Это потому, что использование метода intern()
гарантирует, что строка будет в пуле.
Метод equals
в классе String
Метод equals()
используется для того, чтобы проверить одинаковое или нет состояние двух классов. Поскольку equals()
находится к классе Object
, то каждый Java — класс наследует его. Но метод equals()
должен быть переопределен, чтобы он работал правильно. Конечно, String
переопределяет equals()
.
Взгляните:
public boolean equals(Object anObject) {
if (this == anObject) {
return true;
}
if (anObject instanceof String) {
String aString = (String)anObject;
if (coder() == aString.coder()) {
return isLatin1() ? StringLatin1.equals(value, aString.value)
: StringUTF16.equals(value, aString.value);
}
}
return false;
}
Как вы видите, значение класса String
сравнивается через equals()
, а не через ссылку на объект. Не имеет значения, если ссылки на объекты разные; будут сравниваться состояния.
Наиболее распространенные методы String
Есть ещё одна вещь, которую вам нужно знать, прежде чем решить задачку на сравнение строк.
Рассмотрим наиболее распространённые методы класса String
:
// Удаляет пробелы в начале и в конце строки
trim()
// Получает подстроку по индексам
substring(int beginIndex, int endIndex)
// Возвращает длину строки
length()
// Заменяет строку, можно использовать регулярное выражение
replaceAll(String regex, String replacement)
// Проверяет, есть ли указанная последовательность CharSequence в строке
contains(CharSequences)
Решите задачку на сравнение строк
Давайте проверим, что вы узнали о классе String
, решив небольшую задачку.
В этой задаче вы сравните несколько строк, используя изученные концепции. Глядя на код ниже, можете ли вы определить значение каждой переменной result
?
public class ComparisonStringChallenge {
public static void main(String... doYourBest) {
String result = "";
result += " powerfulCode ".trim() == "powerfulCode" ? "0" : "1";
result += "flexibleCode" == "flexibleCode" ? "2" : "3";
result += new String("doYourBest")
== new String("doYourBest") ? "4" : "5";
result += new String("noBugsProject")
.equals("noBugsProject") ? "6" : "7";
result += new String("breakYourLimits").intern()
== new String("breakYourLimits").intern() ? "8" : "9";
System.out.println(result);
}
}
Какой будет вывод?
- A: 02468
- B: 12469
- C: 12579
- D: 12568
Правильный ответ приведён в конце статьи.
Что сейчас произошло? Понимание поведения String
В первой строке мы видим:
result += " powerfulCode ".trim() == "powerfulCode" ? "0" : "1";
В этом случае результат false
, потому что, когда метод trim()
удаляет пробелы он создаёт новый String
с помощью оператора new
.
Далее мы видим:
result += "flexibleCode" == "flexibleCode" ? "2" : "3";
Здесь нет никакой тайны, строки одинаковы в пуле строк. Это сравнение возвращает true
.
Затем, мы имеем:
result += new String("doYourBest")
== new String("doYourBest") ? "4" : "5";
Использование new
приводит к созданию двух новых строк и не важно равны их значения или нет. В этом случае сравнение будет false
даже если значения одинаковые.
Далее:
result += new String("noBugsProject")
.equals("noBugsProject") ? "6" : "7";
Поскольку мы использовали метод equals()
, будет сравниваться значение строки, а не экземпляр объекта.
В этом случае, не имеет значение разные объекты или нет, поскольку сравнивается значение. Результат true
.
Окончательно, мы имеем:
result += new String("breakYourLimits").intern()
== new String("breakYourLimits").intern() ? "8" : "9";
Как вы видели ранее, метод intern()
помещает строку в пул строк. Обе строки указывают на один и тот же объект, поэтому в этом случае true
.
Распространенные ошибки со строками
Бывает трудно определить, указывают ли две строки на один и тот же объект или нет, особенно когда строки содержат одно и то же значение. Полезно помнить, что использование new
всегда приводит к созданию нового объекта в памяти, даже если значения строк одинаковые.
Использование методов класса String
для сравнения ссылок на объекты также может быть сложным. Особенность в том, что если метод изменяет что-то в строке, то будут разные ссылки на объекты.
Несколько примеров, которые помогут прояснить:
System.out.println("duke".trim() == "duke".trim());
Это сравнение будет истинным, потому что метод trim()
не создает новую строку.
System.out.println(" duke".trim() == "duke".trim());
В этом случае первый метод trim()
генерирует новую строку, так как метод будет выполнять свою работу и поэтому ссылки будут разные.
Наконец, когда trim()
выполнит свою работу, он создает новую строку:
// Реализация метода trim в классе String
new String(Arrays.copyOfRange(val, index, index + len), LATIN1);
Что нужно помнить о строках
Строки не изменяемые, поэтому состояние строки изменить нельзя.
Для экономии памяти JVM хранит строки в пуле строк. При создании новой строки JVM проверяет ее значение и указывает на существующий объект. Если в пуле нет строки с этим значением, то JVM создаёт новую строку.
Оператор "
==
" сравнивает ссылки на объект. Методequals()
сравнивает значения строк. То же правило будет применяться ко всем объектам.
При использовании оператора
new
будет создана новая строка в хипе (Прим. переводчика — в оригинале написано, что в пуле, но это не так, спасибо zagayevskiy), даже если есть строка с тем же значением.
Ответ
Ответ на эту задачу — D. Вывод будет 12568.
Продолжение следует...
Комментарии (23)
BugM
07.11.2018 23:03+1Всегда делайте
А не наоборот."некая константа".equals(someVar)
И никогда не используйте == для строк. За исключение каких-то совершенно невероятных и очень подробно прокомментированных случаев.
zagayevskiy
08.11.2018 00:26При использовании оператора new будет создана новая строка в пуле строк, даже если есть строка с тем же значением.
Нет, строка будет создана не в пуле, а в обычном хипе. В пул в рантайме строку можно засунуть как раз вызовом intern(). Строки из пула никогда не собираются GC, так что лучше не надо.
spv32 Автор
08.11.2018 12:00Спасибо за уточнение про хип. Но GC может удалять строки из пула.
https://stackoverflow.com/a/2433076
http://java-performance.info/string-intern-in-java-6-7-8/vladimir_dolzhenko
08.11.2018 12:25Некогда пытался убедить о вреде использования `intern()` в jason и что из этого вышло (спойлер — ничего хорошего)
https://github.com/FasterXML/jackson-core/issues/332
SlavniyTeo
08.11.2018 09:06Кому статья показалась интересной, будет интересно и выступление Алексея Шипилева на эту тему.
Видео
vladimir_dolzhenko
Не делайте
String#intern()
. Н И К О Г Д А.poxvuibr
Немного дополню. Хотите сэкономить память, когда у вас есть много одинаковых строк — сделайте мапу и для каждой новой строки проверяйте, нет ли её в мапе. Если есть, то просто используйте ту строку, которая в мапе. Если нет — положите новую строку в мапу. Ну или можно использовать Set, но там под капотом всё равно HashMap.
Когд все строки будут обработаны — можно смело отдать мапу сборщику мусора, а строки дальше будут жить в приложении и не занимать дополнительное место в памяти.
Danik-ik
Вероятно, этому есть причина и Вы её знаете?
Честно, я сам с большой настороженностью отношусь к подобным «весьма затейливым штучкам из-под капота, за которые можно дёрнуть на ходу», но не менее настороженно я отношусь к безапелляционным заявлениям без внятной аргументации. Вот Вы что-то знаете, а я чего-то не знаю. Вы мне помогли? Скорее нет, чем да. Ваше предложение в таком виде воспринимается как религиозный принцип (вероятно, правильный по последствиям, но тщательно обессмысленный). «Trust me, I'm таки an engиneer!» — и хватит с Вас, убогие…
Впрочем, надеюсь, что Вы просто спешили, но не могли не предупредить.
vladimir_dolzhenko
Данная тема,
intern()
, является настолько избитой, что кажется странно о ней уже говорить снова и снова.Как минимум странно говорить о ней, когда это уже 2ая часть подобной статьи, и в 1ой части не могли не сослаться (пусть и в комментариях) на доклад Алексея Шипилёва
The Lord of the Strings https://www.youtube.com/watch?v=HWkVJkoo1_Q
и, конечно же, Катехизис: java.lang.String https://youtu.be/SZFe3m1DV1A?t=1913 — здесь я специально привёл момент, где подробно и в деталях рассказывают почему
intern()
это зло.+ чуть ниже я привёл ссылку на проблему с которой можно столкнуться при использовании jackson (а если у вас есть REST API или вы как-то работаете со json — то скорее всего вы столкнётесь именно с ним): https://github.com/FasterXML/jackson-core/issues/332
Почитайте и посмотрите какие проблемы (на пустом месте) и лишние доп. расходы всплывают при этом (и не у меня одного — этот prod профиль фееричен), но автору jackson эти доводы не зашли (сразу).
+ нельзя не упоминуть JVM Anatomy Park (всё того же А. Шипилёва) и в частности, что касается
intern()
: JVM Anatomy Park #10: String.intern()Можно ещё добавить про то, что intern hash table, которая реализована на уровне VM не расширяется. Ещё в бытность java 6 проводил эксперимент с intern и пересборкой java из сорсов, но это меркнет на фоне докладов и статей Шипилёва.
Надеюсь, что, теперь у вас есть более развёрнутый ответ на данную тематику и вы будете нести свет другим.
turbanoff
Это ещё актуально для java 11? Вроде были подвижки, чтобы улучшить String.intern.
vladimir_dolzhenko
@Deprecated
так на него и не навесили.turbanoff
Я имею в виду performance проблемы в ней пофиксили или нет?
Если она стала расширяемой и масштабируемой, то
@Deprecated
нет нужды вешать.vladimir_dolzhenko
Оно не сломано, просто вы его не правильно готовите.
Это торчащая наружу ручка от самой JVM и Вам туда не надо — если сильно надо — то можно подкрутить размер hash table. Точка.
turbanoff
Я не понимаю вас. Я не утверждал что оно сломано, или нет.
Я интересоваkся пофикшены ли performance проблемы или нет. Так и не получил ответ(
vladimir_dolzhenko
Окей — какие перформанс проблемы есть у intern с точки зрения его использования самой jvm? никаких — раньше был один размер таблицы — с увеличением внутренних нужд эту внутреннюю кухню чуть расширили.
А т.к. она не предназначена для работы вне jvm — то и проблемы индейцев инженеров jvm тоже не волнуют.
turbanoff
Она была не расширяема и использовала блокировки. Поэтому активное её использование, даже внутри JVM всегда было ограничено.
Поэтому мне было интересно узнать, сделали ли её более быстрой?
Далее. Метод String.intern() описан в javadoc последней версии Java — docs.oracle.com/en/java/javase/11/docs/api/java.base/java/lang/String.html#intern()
В этом javadoc нет ни слова, о том, что её можно применять только в JVM. Метод не задепрекейчен, не помечен, какой-либо хитрой аннотацией.
Можно много обсуждать то, какие были идеи у отцов-основателей по поводу использования этого метода, но в ближайшее время этот метод никуда не исчезнет, как и приложения его использующие.
Это часть java specification и точка.
Всё что я хотел услышать от вас — остались ли с ним такие же performance проблемы в JDK 11, по сравнению с более ранними JDK. Так и не получил ответа(
turbanoff
Как я и подозревал, товарищи из Oracle не сидели сложа руки, а заиспользовали быструю расширяемую concurrent hash table для реализации пула строк. См JDK-8195097
Поэтому большинство страшилок, на которые дал ссылки vladimir_dolzhenko неактуальны для JDK11+.
vladimir_dolzhenko
Спасибо за ссылку. Берём jmh benchmark из JVM Anatomy Park #10: String.intern() и java 11 (build 11+28) запускаем — смотрим
для случая, когда нужна дедупликация
intern()
(а ведь именно на этот use-case по сути указывал автор статьи) — плохой выбор.Danik-ik
Вот теперь большое спасибо.
Что же касается этого:
Пока жизнь продолжается, пока в программирование (или в программирование на конкретном языке) приходят новые люди — странно будет как раз, если не будут снова и снова говорить об одном и том же. "Не долбить дятел не может. Если дятел не долбит, он спит, либо умер". То же относится и к любому языку программирования — если не говорят вновь и вновь о том, что "все уже знают", то этот язык или спит, или…
poxvuibr
Да, причина есть. Если коротко, то строки, прошедшие через intern, хранятся в hash map который переполнен, а его размер увеличить нельзя. Поэтому всё это добро тормозит. Воркараунд можете посмотреть в моём комментарии выше.
Если короткое объяснение показалось вам слишком коротким, то вот ссылка на душераздирающие подробности.