Последнее изменение: 7 мая 2008г.
Ах, эти строки...
Класс java.lang.String
, пожалуй, является одним из самых используемых в Java. И очень часто его используют
неграмотно, что порождает множество проблем, прежде всего с производительностью. В этой статье я хочу рассказать о строках,
о тонкостях при их использовании, об источниках проблем и т.п.
Вот о чем мы поговорим:
- Устройство строки
- Строковые литералы
- Сравнение строк
- Сложение строк
- Выборка подстроки и копирующий конструктор
- Изменение строки
Начнем с основ.
Устройство строки
Класс 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<fields.length; i++){ Field field = fields[i]; if (field.getType().equals(char[].class)){ value = field; break; } } if (value == null){ System.err.println("value wasn't found!"); return; } value.setAccessible(true); // 1. char[] charValue = (char[])value.get(testString); for(int i=0; i<charValue.length/2; i++ ){ char tmp=charValue[i]; charValue[i] = charValue[charValue.length-1-i]; charValue[charValue.length-1-i] = tmp; } value.set(testString, charValue); System.out.print("Reversed static final string: "); System.out.println(testString); }catch (Throwable th){ System.err.println("Exception: "+th); th.printStackTrace(); } } }
Что тут происходит? Сначала я ищу поле типа char[]
. Я мог бы искать и по имени. Однако имя может измениться,
а вот тип – сильно сомневаюсь. Далее, я у найденого поля вызываю метод setAccessible(true)
. Это ключевой
момент – я отключаю проверку уровня доступа к полю (иначе я просто не смогу изменить значение, ибо поле
private
). В этом месте я могу получить по голове от менеджера безопасности, который проверяет, разрешено ли такое
действие (через вызов checkPermission(new ReflectPermission("suppressAccessChecks"))
). Если разрешено (а по
умолчанию для обычных приложений так и есть) – я могу получить доступ к private
-полю. Остальное, как говорится,
дело техники. В результате я получаю вывод:
Initial static final string: abcde Reversed static final string: edcba
Что и требовалось доказать. А потому – в реальных приложениях я советую более тщательно подходить к настройке политики безопасности. Иначе может оказаться, что объекты, которые вы считаете гарантированно неизменяемыми, таковыми не являются.
* * *
Наверное, это все, что я хочу рассказать о строках на данный момент. Спасибо за внимание!