Класс java.lang.String, пожалуй, является одним из самых используемых в Java. И очень часто его используют неграмотно, что порождает кучу проблем — прежде всего с производительностью. В этой статье я хочу рассказать о строках, о тонкостях при их использовании, об источниках проблем и о том, как же все-таки работать с ними правильно. Вот о чем мы поговорим:
  • Устройство строки
  • Строковые литералы
  • Сравнение строк
  • Сложение строк
  • Выборка подстроки
  • Современные методы работы со строками
Начнем с основ.Ах, эти строки... - 1

Устройство строки

До Java 9 класс String хранил данные в массиве char[]. Это было просто, но неэффективно — каждый символ занимал 2 байта, даже если это была обычная латиница или цифра. В большинстве приложений строки содержат именно такие символы, и половина памяти тратилась впустую. В Java 9 разработчики внедрили оптимизацию под названием Compact Strings. Теперь внутреннее устройство выглядит так:
public final class String {
    private final byte[] value;
    private final byte coder;
    private int hash;
    
    // ... методы
}
Обрати внимание — вместо char[] теперь byte[]! А поле coder указывает, какая кодировка используется:
  • LATIN1 (0) — если строка содержит только символы Latin-1 (коды 0-255), каждый символ занимает 1 байт
  • UTF16 (1) — если есть символы за пределами Latin-1, используется UTF-16, каждый символ занимает 2 байта
JVM автоматически выбирает оптимальную кодировку при создании строки. Если твоя строка содержит только английские буквы, цифры и базовые символы — она займет вдвое меньше памяти. Как только появится хотя бы один эмодзи или иероглиф — переключится на UTF-16. Это называется оптимизацией "на лету", и она работает прозрачно для программиста. Ты просто пишешь код, а JVM сама решает, как эффективнее хранить данные. Кстати, начиная с версии Java 5 (да-да, это было давно) введена поддержка Unicode версии выше 2, и символов с кодами больше 0xFFFF. Для таких символов — например, различных эмодзи или древних письменностей — используются уже не один char, а два (суррогатная пара). Подробнее об этом можно почитать в документации по Unicode. Я как-то пробовал вывести музыкальный символ — скрипичный ключ с кодом U+1D120. Технически Java его поддерживает, но вот незадача — шрифтов толковых нет, и вместо красивого символа получается квадратик. Так что Unicode выше базовой плоскости — это пока больше задел на будущее, чем практический инструмент.

Строковые литералы

Что такое строковый литерал? Это строка, записанная в двойных кавычках, например: "abc". Такие выражения мы используем в коде постоянно. Строка может содержать escape-последовательности Unicode, например, \u0410 — это русская буква 'А'. Однако она не может содержать последовательности \u000A и \u000D, соответствующие символам LF и CR. Дело в том, что эти последовательности обрабатываются на самой ранней стадии компиляции и превращаются в реальные переносы строк (как будто ты нажал Enter в редакторе). Для вставки переноса строки используй \n и \r. А вот начиная с Java 15 появились text blocks — многострочные литералы. Если раньше приходилось писать что-то вроде:
String json = "{\n" +
              "  \"name\": \"John\",\n" +
              "  \"age\": 30\n" +
              "}";
То теперь можно просто:
String json = """
              {
                "name": "John",
                "age": 30
              }
              """;
Намного читабельнее, правда? Text blocks автоматически убирают лишние отступы и позволяют не экранировать кавычки внутри строки. Очень удобно для SQL-запросов, HTML, JSON и прочих многострочных текстов. Теперь о пуле строк. Виртуальная машина Java поддерживает специальный пул (string pool), куда кладутся все строковые литералы из кода. При совпадении литералов используется один и тот же объект. Это экономит память и в некоторых случаях повышает производительность. Строку можно поместить в пул принудительно с помощью метода String.intern(). Этот метод возвращает из пула строку, равную той, у которой был вызван. Если такой строки нет — она добавляется в пул, после чего возвращается ссылка на нее. При грамотном использовании это позволяет сравнивать строки не через equals, а по ссылке, что значительно быстрее. Так реализован, например, класс java.util.Locale, который работает с массой маленьких строк — кодами стран, языков и т.п. Очень часто я вижу в коде конструкции вроде:
public static final String SOME_STRING = new String("abc");
Вот эта штука — new String("abc") — безграмотна. В Java строковый литерал "abc" уже является объектом класса String. Использование конструктора приводит к копированию строки. Поскольку литерал уже хранится в пуле и никуда не денется, созданный новый объект — пустая трата памяти. Правильно писать вот так:
public static final String SOME_STRING = "abc";
С точки зрения работы это абсолютно то же самое, но эффективнее.

Сравнение строк

Строки надо сравнивать по значению, используя метод equals. Сравнение по ссылке через == можно использовать, но очень аккуратно и только если точно знаешь, что делаешь. В этом помогает метод String.intern(), о котором я писал выше. Один момент, который хочется упомянуть особо — сравнение с литералами. Часто вижу конструкции типа str.equals("abc"). Тут есть грабли — перед этим сравнением правильно было бы проверить str на null, чтобы не получить NullPointerException. То есть правильная конструкция: str != null && str.equals("abc"). Между тем, ее можно упростить. Достаточно написать "abc".equals(str). Проверка на null в этом случае не нужна — метод equals просто вернет false, если str окажется null. Еще полезные методы для сравнения:
  • equalsIgnoreCase() — сравнение без учета регистра
  • compareTo() — лексикографическое сравнение
  • startsWith() и endsWith() — проверка префикса и суффикса
  • contains() — проверка на вхождение подстроки

Сложение строк

Строки — единственный объект в Java, для которого определена операция сложения ссылок через оператор +. Но как это работает на самом деле? Представь себе... Прямо как в песенке про кузнечика :) Так вот, представь, что нам надо сложить две строки:
String str1 = "abc";
str1 += "def";
Как происходит сложение? Поскольку объект класса String неизменяем, результатом будет новый объект. Сначала выделяется память, достаточная для обеих строк. В эту память копируется содержимое сначала первой строки, потом второй. Далее переменной str1 присваивается ссылка на новую строку, а старая отбрасывается. Усложним задачу. Пусть есть файл из четырех строк:
abc
def
ghi
jkl
Нам надо прочитать эти строки и собрать в одну. Можно сделать так:
BufferedReader br = new BufferedReader(new FileReader("filename.txt"));
String result = "";
String line;
while ((line = br.readLine()) != null) {
    result += line;
}
Вроде логично. Давай разберем, что происходит на нижнем уровне. Первый проход цикла. result="", line="abc". Выделяется память на 3 символа, туда копируется "abc". Переменной result присваивается ссылка на новую строку, старая отбрасывается. Второй проход. result="abc", line="def". Выделяется память на 6 символов, копируется "abc", затем "def". Переменной result присваивается новая ссылка, старая строка отбрасывается. Третий проход. result="abcdef", line="ghi". Выделяется память на 9 символов, копируется "abcdef", затем "ghi". Новая ссылка, старая отбрасывается. Четвертый проход. result="abcdefghi", line="jkl". Выделяется память на 12 символов, копируется "abcdefghi", затем "jkl". Пятый проход. result="abcdefghijkl", line=null. Цикл завершен. Итак. Три символа "abc" копировались в памяти 4 раза, "def" — 3 раза, "ghi" — 2 раза, "jkl" — один раз. Страшно? Не особо? А теперь представь файл с длиной строки 80 символов, в котором 1000 строк. Всего-то 80 килобайт. Что будет? Первая строка скопируется в памяти 1000 раз, вторая — 999, и так далее. При средней длине 80 символов через память пройдет ((1000 + 1) × 1000 / 2) × 80 = 40 040 000 символов, что составляет около 80 мегабайт памяти! Каков итог? Чтение 80-килобайтного файла вызвало выделение 80 Мб памяти. В тысячу раз больше, чем полезный объем. Какой вывод? Очень простой. Никогда — запомни, НИКОГДА — не используй прямую конкатенацию строк в циклах. Даже в методе toString(), если он вызывается часто, имеет смысл использовать StringBuilder. Правильный вариант того же кода:
BufferedReader br = new BufferedReader(new FileReader("filename.txt"));
StringBuilder result = new StringBuilder();
String line;
while ((line = br.readLine()) != null) {
    result.append(line);
}
String finalResult = result.toString();
StringBuilder — это изменяемый буфер символов. Он выделяет память с запасом и просто дописывает новые символы в конец, без создания новых объектов на каждом шаге. Когда нужно получить итоговую строку — вызывается toString(). Есть еще StringBuffer — его старший брат, который умеет работать в многопоточной среде (все методы синхронизированы). Но в 99% случаев нужен именно StringBuilder — он быстрее. Компилятор, кстати, при оптимизации сам заменяет простые конкатенации на StringBuilder. Например, код:
String s = "Hello, " + name + "!";
Компилятор превратит примерно в:
String s = new StringBuilder()
    .append("Hello, ")
    .append(name)
    .append("!")
    .toString();
Но в циклах компилятор не может провернуть такой трюк, и приходится делать это вручную.

Собственный опыт

Не могу не вспомнить один случай из практики. Один программист, с которым я работал, пожаловался, что у него жутко медленно работает код. Он читал большой HTML-файл, после чего производил какие-то операции. И правда, работало все с черепашьей скоростью. Я взял посмотреть исходник и... о ужас... он использовал конкатенацию строк в цикле. У него было по 200-250 строк в каждом файле, и при чтении 200 Кб через память проходило больше 40 Мб! Я переписал код, заменив операции со строками на StringBuilder. Честно говоря, когда запустил переписанный вариант, я подумал, что программа просто где-то "упала" — так быстро все отработало. Обработка занимала доли секунды. Скорость выросла в 300-800 раз. После этого я навсегда запомнил: строковые операции в циклах — это серьезно.

Современные методы работы со строками

Начиная с Java 8 и далее, в класс String добавили кучу удобных методов. Вот самые полезные:

String.join()

Если нужно соединить несколько строк через разделитель:
String result = String.join(", ", "Java", "Python", "C++");
// result = "Java, Python, C++"

List words = Arrays.asList("Hello", "world");
String sentence = String.join(" ", words);
// sentence = "Hello world"
Это гораздо удобнее, чем крутить цикл со StringBuilder.

String.format()

Форматирование строк в стиле printf:
String name = "John";
int age = 30;
String message = String.format("Привет, %s! Тебе %d лет.", name, age);
// message = "Привет, John! Тебе 30 лет."

String price = String.format("Цена: %.2f руб.", 123.456);
// price = "Цена: 123.46 руб."
Спецификаторы формата:
  • %s — строка
  • %d — целое число
  • %f — число с плавающей точкой
  • %n — перенос строки (зависит от ОС)

repeat()

Начиная с Java 11, можно повторять строку нужное количество раз:
String line = "-".repeat(50);
// line = "--------------------------------------------------"

String indent = "  ".repeat(3);
// indent = "      " (6 пробелов)
Раньше для этого приходилось писать циклы или использовать хитрые трюки.

isBlank()

Тоже Java 11. Проверяет, что строка пустая или состоит только из пробелов:
"".isBlank()        // true
"   ".isBlank()     // true
"  \n  ".isBlank()  // true
"abc".isBlank()     // false
Метод isEmpty() проверяет только длину (length == 0), а isBlank() учитывает еще и пробельные символы.

strip(), stripLeading(), stripTrailing()

Улучшенные версии trim() из Java 11. Они корректно работают с Unicode-пробелами:
String text = "  Hello  ";
text.trim()           // "Hello"
text.strip()          // "Hello"
text.stripLeading()   // "Hello  "
text.stripTrailing()  // "  Hello"

lines()

Java 11 добавила метод для разбивки строки на строки (да-да, звучит забавно):
String multiline = """
    Первая строка
    Вторая строка
    Третья строка
    """;
    
multiline.lines()
    .forEach(System.out::println);
Очень удобно в сочетании с text blocks и Stream API.

Выборка подстроки

Представим, что у нас есть строка, из которой надо вырезать подстроку:
String str = "abcdefghijklmnopqrstuvwxyz";
str = str.substring(5, 10);
Что произойдет? Ты получишь подстроку "fghij" (символы с индексами 5, 6, 7, 8, 9 — помни, что индексация с нуля). В старых версиях Java (до Java 7) метод substring() работал хитро — он не создавал новый массив символов, а просто ссылался на тот же массив исходной строки, указывая смещение и длину. Это было быстро, но приводило к утечкам памяти. Если ты вырезал маленькую подстроку из огромной строки, вся исходная строка оставалась в памяти. Начиная с Java 7u6 эта проблема решена. Теперь substring() всегда создает новый массив и копирует в него нужные символы. Старая строка может быть спокойно удалена сборщиком мусора. Так что можешь использовать substring() без опасений — JVM сама позаботится о памяти. Единственное, стоит помнить: если ты делаешь много операций с подстроками в цикле, копирование может влиять на производительность. В таких случаях стоит подумать об использовании StringBuilder или работе с массивами char[] напрямую.

Заключение

Итак, что нужно запомнить о строках:
  • В Java 9+ строки стали компактнее благодаря автоматическому выбору кодировки
  • Никогда не используй конкатенацию в циклах — только StringBuilder
  • Сравнивай строки через equals(), а не через ==
  • Используй String.join(), String.format() и другие современные методы
  • Text blocks (с Java 15) делают работу с многострочным текстом намного проще
  • Не бойся substring() — проблемы с памятью давно исправлены
Класс String — один из самых проработанных в Java. Разработчики постоянно его оптимизируют и добавляют удобные методы. Используй современные возможности языка, и твой код будет не только быстрым, но и читаемым. Спасибо за внимание! Надеюсь, эта статья помогла тебе лучше понять, как устроены и работают строки в Java.