JavaRush /Java блог /Архив info.javarush /Ах, эти строки...
articles
15 уровень

Ах, эти строки...

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

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

Класс java.lang.String содержит в себе три поля:

/**
 * NOTE: This is just a partial API
 */
public final class String{

    private final char value[];
    private final int offset;
    private final int count;

}
На самом деле там содержатся и другие поля, например, hash-код, но сейчас это неважно. Основные – эти. Итак, в основе строки лежит массив символов (char). При хранении символов в памяти используется кодировка Unicode UTF-16BE. Подробнее о ней можно почитать тут. Начиная с версии Java 5.0 введена поддержка Unicode версии выше 2 и, соответственно, символов с кодами больше 0xFFFF. Для этих символов используются уже не один char, а два, подробнее о кодировке этих символов в той же статье. Хоть поддержка этих символов и введена, да вот незадача – отобразить их не получится. Я нашел набор музыкальных символов (U1D100) и попробовал вывести хоть куда-нибудь скрипичный ключ (символ с кодом 1D120). Перевел код в два char, как и положено – '\uD834' и '\uDD20'. Декодер на них не ругается, честно распознает как один символ. Вот только шрифта нет, в котором этот символ существует. А потому – квадратик. И судя по всему – это надолго. Так что введение поддержки Unicode 4 можно рассматривать исключительно через призму задела на будущее. Пойдем дальше. Я прошу обратить пристальное внимание на второе и третье поля – offset и count. Казалось бы, массив полностью определяет строку, если используются ВСЕ символы. Если же существуют такие поля – символы в массиве могут использоваться не все. Так оно и есть, об этом мы поговорим в части выборка подстроки и копирующий конструктор.

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

Что такое строковый литерал? Это строка, записаная в двойных кавычках, например, такая: "abc". Такие выражения используются в коде сплошь и рядом. Строка эта может содержать escape-последовательности unicode, например, \u0410, что будет соответствовать русской букве 'А'. Однако, эта строка НЕ МОЖЕТ содержать последовательностей \u000A и \u000D, соответствующие символам LF и CR соответственно. Дело в том, что последовательности обрабатываются на самой ранней стадии компиляции, и символы эти будут заменены на реальные LF и CR (как если бы в редакторе просто нажали "Enter"). Для вставки в строку этих символов следует использовать последовательности \n и \r, соответственно. Строковые литералы сохраняются в пуле строк. Я упоминал о пуле в статье о сравнении на практике, но повторюсь. Виртуальная машина Java поддерживает пул строк. В него кладутся все строковые литералы, объявленные в коде. При совпадении литералов (с точки зрения equals, см. тут) используется один и тот же объект, находящийся в пуле. Это позволяет сильно экономить память, а в некоторых случаях и повышать производительность. Дело в том, что строку в пул можно поместить принудительно, с помощью метода String.intern(). Этот метод возвращает из пула строку, равную той, у которой был вызван этот метод. Если же такой строки нет – в пул кладется та, у которой вызван метод, после чего возвращается ссылка на нее же. Таким образом, при грамотном использовании пула появляется возможность сравнивать строки не по значению, через equals, а по ссылке, что значительно, на порядки, быстрее. Так реализован, например, класс java.util.Locale, который имеет дело с кучей маленьких, в основном двухсимвольных, строк – кодами стран, языков и т.п. См. также тут: Сравнение объектов: практика – метод String.intern. Очень часто я вижу в различной литературе конструкции следующего вида:

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 в этом случае не нужна. На очереди у нас...

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

Строки – единственный объект, для которого определена операция сложения ссылок. Во всяком случае, так было до версии Java 5.0, в которой появился autoboxing/unboxing, но речь сейчас не об этом. Общее описание принципа работы оператора конкатенации можно найти в статье о ссылках, а именно – тут. Я же хочу затронуть более глубокий уровень. Представьте себе, представьте себе... Прямо как в песенке про кузнечика. :) Так вот, представьте себе, что нам надо сложить две строки, вернее, к одной прибавить другую:

String str1 = "abc";
str1 += "def";
Как происходит сложение? Поскольку объект класса строки неизменяем, то результатом сложения будет новый объект. Итак. Сначала выделяется память, достаточная для того, чтобы вместить туда содержимое обеих строк. В эту память копируется содержимое сначала первой строки, потом второй. Далее переменной str1 присваивается ссылка на новую строку, а старая строка отбрасывается. Усложним задачу. Пусть у нас есть файл из четырех строк:

abc
def
ghi
jkl
Нам надо прочитать эти строки и собрать их в одну. Поступаем по той же схеме.

BufferedReader br = new BufferedReader(new FileReader("... filename ..."));
String result = "";
while(true){
    String line = br.readLine();
    if (line == null) break;
    result += line;
}
Вроде пока все хорошо и логично. Давайте разберем, что происходит на нижнем уровне. Первый проход цикла. result="", line="abc". Выделяется память на 3 символа, туда копируется содержимое line"abc". Переменной result присваивается ссылка на новую строку, старая отбрасывается. Второй проход цикла. result="abc", line="def". Выделяется память на 6 символов, туда копируется содержимое result"abc", затем line"def". Переменной result присваивается ссылка на новую строку, старая отбрасывается. Третий проход цикла. result="abcdef", line="ghi". Выделяется память на 9 символов, туда копируется содержимое result"abcdef", затем line"ghi". Переменной result присваивается ссылка на новую строку, старая отбрасывается. Четвертый проход цикла. result="abcdefghi", line="jkl". Выделяется память на 12 символов, туда копируется содержимое result"abcdefghi", затем line"jkl". Переменной result присваивается ссылка на новую строку, старая отбрасывается. Пятый проход цикла. 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 Мб памяти. Ни много ни мало – в 1000 раз больше, чем полезный объем. Какой из этого следует сделать вывод? Очень простой. Никогда, запомните – НИКОГДА не используйте прямую конкатенацию строк, особенно в циклах. Даже в каком-нибудь методе toString, если он вызывается достаточно часто, имеет смысл использовать StringBuffer вместо конкатенации. Собственно, компилятор при оптимизации чаще всего так и делает – прямые сложения он выполняет через StringBuffer. Однако в случаях, подобных тому, что привел я, оптимизацию компилятор сделать не в состоянии. Что и приводит к весьма печальным последствиям, описаным чуть ниже. К сожалению, подобные конструкции встречаются слишком часто. Потому я и счел необходитмым заострить на этом внимание. Собственный опыт Не могу не вспомнить один эпизод из собственной практики. Один из программистов, работавших со мной, как-то пожаловался, что у него очень медленно работает его код. Он читал достаточно большой файл в HTML формате, после чего производил какие-то манипуляции. И действительно, работало все с черепашьей скоростью. Я взял посмотреть исходник, и обнаружил, что он... использует конкатенацию строк. У него было по 200-250 строк в каждом файле, и при чтении файла около 200Кб через память проходило более 40Мб! В итоге я переписал немного код, заменив операции со строками на операции со StringBuffer-ом. Честно сказать, когда я запустил переписаный код, я подумал, что он просто где-то "упал". Обработка занимала доли секунды. Скорость выросла в 300-800 раз. После этого я коренным образом пересмотрел свое отношение к строковым операциям. Следующий акт марлезонского балета –

Выборка подстроки и копирующий конструктор

Представим, что у нас есть строка, из которой надо вырезать подстроку. Вопроса "как это сделать" не стоит – и так понятно. Вопрос в другом – что при этом происходит?

String str = "abcdefghijklmnopqrstuvwxyz";
str = str.substring(5,10);
Вроде тривиальный код. И первая мысль такая – выбирается подстрока "efghi", переменной str присваивается ссылка на новую строку, а старый объект отбрасывается. Так? Почти. Дело в том, что для увеличения скорости при выборке подстроки используется ТОТ ЖЕ МАССИВ, что и в исходной строке. Иначе говоря, мы получим не объект, в котором массив value (cм. устройство строки) имеет длину 5 и содержит в себе символы 'e', 'f', 'g', 'h' и 'i', count=5 и offset=0. Нет, длина массива будет по-прежнему 26, count=5 и offset=5. И при отбрасывании старой строки массив НЕ ОТБРОСИТСЯ, а по-прежнему будет находиться в памяти, ибо на него есть ссылка из новой строки. И существовать в памяти он будет до того момента, как будет отброшена уже новая строка. Это совсем неочевидный момент, который может привести к проблемам с памятью. Возникает вопрос – как этого избежать? Ответ – с помощью копирующего конструктора String(String). Дело в том, что в этом конструкторе в явном виде выделяется память под новую строку, и в эту память копируется содержимое исходной. Таким образом, если мы перепишем код так:

String str = "abcdefghijklmnopqrstuvwxyz";
str = new String(str.substring(5,10));
..., то длина массива value у объекта str будет действительно 5, count=5 и offset=0. И это – единственный случай, где оправдано применение копирующего конструктора для строки. И как финальный аккорд –

Изменение строки

Это к строке как таковой относится слабо. Я лишь хочу показать тот факт, что строка является неизменяемой только до известной степени. Итак, код.

package tests;

import java.lang.reflect.Field;
import java.lang.reflect.Modifier;

/**
 * This application demonstrates how to modify java.lang.String object
 * through reflection API.
 *
 * @version 1.0
 * @author Eugene Matyushkin
 */
public class StringReverseTest {

    /**
     * final static string that should be modified.
     */
    public static final String testString = "abcde";

    public static void main(String[] args) {
        try{
            System.out.println("Initial static final string:  "+testString);
            Field[] fields = testString.getClass().getDeclaredFields();
            Field value = null;
            for(int i=0; i
Что тут происходит? Сначала я ищу поле типа char[]. Я мог бы искать и по имени. Однако имя может измениться, а вот тип – сильно сомневаюсь. Далее, я у найденого поля вызываю метод setAccessible(true). Это ключевой момент – я отключаю проверку уровня доступа к полю (иначе я просто не смогу изменить значение, ибо поле private). В этом месте я могу получить по голове от менеджера безопасности, который проверяет, разрешено ли такое действие (через вызов checkPermission(new ReflectPermission("suppressAccessChecks"))). Если разрешено (а по умолчанию для обычных приложений так и есть) – я могу получить доступ к private-полю. Остальное, как говорится, дело техники. В результате я получаю вывод:

Initial static final string:  abcde
Reversed static final string: edcba
Что и требовалось доказать. А потому – в реальных приложениях я советую более тщательно подходить к настройке политики безопасности. Иначе может оказаться, что объекты, которые вы считаете гарантированно неизменяемыми, таковыми не являются. * * * Наверное, это все, что я хочу рассказать о строках на данный момент. Спасибо за внимание! Ссылка на первоисточник: Ах, эти строки...
Комментарии (10)
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ
UnknownReboot Уровень 11
17 декабря 2019
Очень познавательно, спасибо!
Илья Уровень 17
13 марта 2019
Код последнего примера сильно обрезан, остается читать первоисточник) На конкатенацию строк в цикле даже сама IDE ругается, кстати говоря. Конечно, компилируется при этом, но помечает как бы намекая, что ты написал говнокод))
Natalya Grigoreva Уровень 16
12 августа 2018
"Я нашел набор музыкальных символов (U1D100) и попробовал вывести хоть куда-нибудь скрипичный ключ (символ с кодом 1D120). Перевел код в два char, как и положено – '\uD834' и '\uDD20'." Добрый день! Мне не очень понятно почему именно для char использовались коды '\uD834' и '\uDD20'.
Игорь Уровень 32
22 июля 2018
String str = "abcdefghijklmnopqrstuvwxyz"; str = str.substring(5,10); System.out.println(str); Выводит на печать: fghij потому, что считается с нуля и пятый символ это "f", а не "e".................
Костя Демчук Уровень 13
17 июля 2018
хидыщ
Wertual Уровень 9
11 июля 2018
Последний пример вообще о чем?
AlexSav7 Уровень 33
16 декабря 2017
Очень отрезвляюще написано про конкатенацию строк)
alexeydemin Уровень 6
22 марта 2017
Неужели за 10 лет это говнище не было пофикшено?