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) решает эту проблему, сохраняя только один объект для каждого значения строки.


strings-in-the-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)


  1. vladimir_dolzhenko
    07.11.2018 22:53
    +2

    Не делайте String#intern(). Н И К О Г Д А.


    1. poxvuibr
      08.11.2018 09:51

      Немного дополню. Хотите сэкономить память, когда у вас есть много одинаковых строк — сделайте мапу и для каждой новой строки проверяйте, нет ли её в мапе. Если есть, то просто используйте ту строку, которая в мапе. Если нет — положите новую строку в мапу. Ну или можно использовать Set, но там под капотом всё равно HashMap.


      Когд все строки будут обработаны — можно смело отдать мапу сборщику мусора, а строки дальше будут жить в приложении и не занимать дополнительное место в памяти.


    1. Danik-ik
      08.11.2018 16:02
      +1

      Вероятно, этому есть причина и Вы её знаете?
      Честно, я сам с большой настороженностью отношусь к подобным «весьма затейливым штучкам из-под капота, за которые можно дёрнуть на ходу», но не менее настороженно я отношусь к безапелляционным заявлениям без внятной аргументации. Вот Вы что-то знаете, а я чего-то не знаю. Вы мне помогли? Скорее нет, чем да. Ваше предложение в таком виде воспринимается как религиозный принцип (вероятно, правильный по последствиям, но тщательно обессмысленный). «Trust me, I'm таки an engиneer!» — и хватит с Вас, убогие…
      Впрочем, надеюсь, что Вы просто спешили, но не могли не предупредить.


      1. vladimir_dolzhenko
        08.11.2018 17:59
        +1

        Данная тема, 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 из сорсов, но это меркнет на фоне докладов и статей Шипилёва.


        Надеюсь, что, теперь у вас есть более развёрнутый ответ на данную тематику и вы будете нести свет другим.


        1. turbanoff
          09.11.2018 10:00

          Это ещё актуально для java 11? Вроде были подвижки, чтобы улучшить String.intern.


          1. vladimir_dolzhenko
            09.11.2018 10:35

            @Deprecated так на него и не навесили.


            1. turbanoff
              09.11.2018 16:34
              -1

              Я имею в виду performance проблемы в ней пофиксили или нет?
              Если она стала расширяемой и масштабируемой, то @Deprecated нет нужды вешать.


              1. vladimir_dolzhenko
                09.11.2018 16:57

                Оно не сломано, просто вы его не правильно готовите.

                Это торчащая наружу ручка от самой JVM и Вам туда не надо — если сильно надо — то можно подкрутить размер hash table. Точка.


                1. turbanoff
                  09.11.2018 17:24

                  Я не понимаю вас. Я не утверждал что оно сломано, или нет.
                  Я интересоваkся пофикшены ли performance проблемы или нет. Так и не получил ответ(


                  1. vladimir_dolzhenko
                    09.11.2018 17:27

                    Окей — какие перформанс проблемы есть у intern с точки зрения его использования самой jvm? никаких — раньше был один размер таблицы — с увеличением внутренних нужд эту внутреннюю кухню чуть расширили.
                    А т.к. она не предназначена для работы вне jvm — то и проблемы индейцев инженеров jvm тоже не волнуют.


                    1. turbanoff
                      09.11.2018 18:07

                      Она была не расширяема и использовала блокировки. Поэтому активное её использование, даже внутри 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. Так и не получил ответа(


                      1. turbanoff
                        09.11.2018 22:11

                        Как я и подозревал, товарищи из Oracle не сидели сложа руки, а заиспользовали быструю расширяемую concurrent hash table для реализации пула строк. См JDK-8195097
                        Поэтому большинство страшилок, на которые дал ссылки vladimir_dolzhenko неактуальны для JDK11+.


                        1. vladimir_dolzhenko
                          09.11.2018 23:10

                          Спасибо за ссылку. Берём jmh benchmark из JVM Anatomy Park #10: String.intern() и java 11 (build 11+28) запускаем — смотрим


                          Benchmark               (size)    Score     Error   Units
                          StrInt.chm               10000  2000,237 ± 73,646   ops/s
                          StrInt.chm:·gc.time      10000   100,000               ms
                          StrInt.intern            10000   448,418 ± 12,227   ops/s
                          StrInt.intern:·gc.time   10000    30,000               ms
                          StrInt.chm             1000000    10,792 ±  1,219   ops/s
                          StrInt.chm:·gc.time    1000000    33,000               ms
                          StrInt.intern          1000000     2,433 ±  0,218   ops/s
                          StrInt.intern:·gc.time 1000000    47,000               ms
                          

                          для случая, когда нужна дедупликация intern() (а ведь именно на этот use-case по сути указывал автор статьи) — плохой выбор.


        1. Danik-ik
          10.11.2018 19:37

          Вот теперь большое спасибо.
          Что же касается этого:


          является настолько избитой, что кажется странно о ней уже говорить снова и снова.

          Пока жизнь продолжается, пока в программирование (или в программирование на конкретном языке) приходят новые люди — странно будет как раз, если не будут снова и снова говорить об одном и том же. "Не долбить дятел не может. Если дятел не долбит, он спит, либо умер". То же относится и к любому языку программирования — если не говорят вновь и вновь о том, что "все уже знают", то этот язык или спит, или…


      1. poxvuibr
        08.11.2018 18:07
        +1

        Вероятно, этому есть причина и Вы её знаете?

        Да, причина есть. Если коротко, то строки, прошедшие через intern, хранятся в hash map который переполнен, а его размер увеличить нельзя. Поэтому всё это добро тормозит. Воркараунд можете посмотреть в моём комментарии выше.


        Если короткое объяснение показалось вам слишком коротким, то вот ссылка на душераздирающие подробности.


  1. BugM
    07.11.2018 23:03
    +1

    Всегда делайте

    "некая константа".equals(someVar)
    А не наоборот.

    И никогда не используйте == для строк. За исключение каких-то совершенно невероятных и очень подробно прокомментированных случаев.


  1. zagayevskiy
    08.11.2018 00:26

    При использовании оператора new будет создана новая строка в пуле строк, даже если есть строка с тем же значением.

    Нет, строка будет создана не в пуле, а в обычном хипе. В пул в рантайме строку можно засунуть как раз вызовом intern(). Строки из пула никогда не собираются GC, так что лучше не надо.


    1. spv32 Автор
      08.11.2018 12:00

      Спасибо за уточнение про хип. Но GC может удалять строки из пула.
      https://stackoverflow.com/a/2433076
      http://java-performance.info/string-intern-in-java-6-7-8/


      1. vladimir_dolzhenko
        08.11.2018 12:25

        Некогда пытался убедить о вреде использования `intern()` в jason и что из этого вышло (спойлер — ничего хорошего)

        https://github.com/FasterXML/jackson-core/issues/332


  1. ssurrokk
    08.11.2018 07:19

    сия статья была полезна для меня


  1. SlavniyTeo
    08.11.2018 09:06

    Кому статья показалась интересной, будет интересно и выступление Алексея Шипилева на эту тему.

    Видео


  1. keekkenen
    08.11.2018 09:49

    В этом случае результат false, потому что, когда метод trim() удаляет пробелы он создаёт новый String с помощью оператора new.

    Это сравнение будет истинным, потому что метод trim() не создает новую строку.


    вот, тут я не понял, как это работает


    1. spv32 Автор
      08.11.2018 09:52

      result += " powerfulCode ".trim() == "powerfulCode" ? "0" : "1";
      В этом случае результат false, потому что, когда метод trim() удаляет пробелы, он создаёт новый String с помощью оператора new.

      Для строки с пробелами " powerfulCode " метод trim() создаст новый объект String.


      System.out.println("duke".trim() == "duke".trim());
      Это сравнение будет истинным, потому что метод trim() не создает новую строку.

      Если пробелов нет "duke", то метод trim() не создает новую строку, а возвращает ту же строку.


      javadoc trim():


      returns — A string whose value is this string, with any leading and trailing white space removed, or this string if it has no leading or trailing white space.
      Строка, значение которой равно этой строке с удаленными пробелами в начале и в конце или эту строку, если в ней нет пробельных символов

      Из исходников trim():


      return ((st > 0) || (len < value.length)) ? substring(st, len) : this;