Класс 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 байта
Строковые литералы
Что такое строковый литерал? Это строка, записанная в двойных кавычках, например: "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() — проблемы с памятью давно исправлены
Что ещё почитать |
|---|
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ