Klasa
java.lang.String jest prawdopodobnie jedną z najczęściej używanych w Javie. I bardzo często jest używany niepiśmiennie, co rodzi wiele problemów, przede wszystkim z wydajnością. W tym artykule chcę porozmawiać o ciągach znaków, zawiłościach ich używania, źródłach problemów itp.
Oto o czym będziemy rozmawiać:
- Urządzenie liniowe
- Literały łańcuchowe
- Porównanie ciągów
- Dodanie ciągu
- Konstruktor pobierania i kopiowania podciągów
- Zmiana linii
- Zacznijmy od podstaw.
Urządzenie liniowe
Klasa
java.lang.String zawiera trzy pola:
public final class String{
private final char value[];
private final int offset;
private final int count;
}
Tak naprawdę zawiera także inne pola, na przykład kod skrótu, ale to nie jest teraz istotne. Najważniejsze z nich to te. Zatem podstawą łańcucha jest tablica znaków (
char ). Podczas przechowywania znaków w pamięci używane jest kodowanie Unicode
UTF-16BE . Więcej na ten temat możesz przeczytać
tutaj . Począwszy od wersji Java 5.0 wprowadzono obsługę wersji Unicode powyżej 2 i odpowiednio znaków o kodach większych niż
0xFFFF . W przypadku tych znaków używany jest nie jeden
znak , ale dwa; więcej szczegółów na temat kodowania tych znaków
znajduje się w tym samym artykule . Chociaż wprowadzono obsługę tych symboli, problem polega na tym, że nie będą one wyświetlane. Znalazłem zestaw symboli muzycznych (
U1D100 ) i próbowałem gdzieś wyświetlić klucz wiolinowy (symbol o kodzie 1D120). Zgodnie z oczekiwaniami przekonwertowałem kod na dwa
znaki - „\uD834” i „\uDD20”. Dekoder nie narzeka na nie, uczciwie rozpoznaje je jako jeden znak. Ale nie ma czcionki, w której ten symbol występuje. A zatem - kwadrat. I najwyraźniej taki stan potrwa długo. Zatem na wprowadzenie obsługi Unicode 4 można patrzeć wyłącznie przez pryzmat podstaw na przyszłość. Idźmy dalej. Proszę o zwrócenie szczególnej uwagi na drugie i trzecie pole –
offset i
count . Wygląda na to, że tablica całkowicie definiuje ciąg, jeśli użyte zostaną
WSZYSTKIE znaki. Jeśli takie pola istnieją, nie wszystkie znaki w tablicy mogą zostać użyte. Tak jest, porozmawiamy o tym w sekcji wyboru podciągu i konstruktora kopiującego.
Literały łańcuchowe
Co такое строковый литерал? Это строка, записаная в двойных кавычках, например, такая: "abc". Такие выражения используются в kodе сплошь и рядом. Строка эта может содержать escape-последовательности unicode, например, \u0410, что будет соответствовать русской букве 'А'. Однако, эта строка
НЕ МОЖЕТ содержать последовательностей \u000A и \u000D, соответствующие символам LF и CR соответственно. Дело в том, что последовательности обрабатываются на самой ранней стадии компиляции, и символы эти будут заменены на реальные LF и CR (Jak если бы в редакторе просто нажали "Enter"). Для вставки в строку этих символов следует использовать последовательности \n и \r, соответственно. Строковые литералы сохраняются в пуле строк. Я упоминал о пуле в статье о сравнении на практике, но повторюсь. Виртуальная машина Java поддерживает пул строк. В него кладутся все строковые литералы, объявленные в kodе. При совпадении литералов (с точки зрения equals, см.
тут) используется один и тот же obiekt, находящийся в пуле. Это позволяет сильно экономить память, а в некоторых случаях и повышать производительность. Дело в том, что строку в пул можно поместить принудительно, с помощью метода
String.intern(). Этот метод возвращает из пула строку, равную той, у которой был вызван этот метод. Если же такой строки нет – в пул кладется та, у которой вызван метод, после чего возвращается połączyć на нее же. Таким образом, при грамотном использовании пула появляется возможность сравнивать строки не по значению, через equals, а по ссылке, что значительно, на порядки, быстрее. Так реализован, например, класс
java.util.Locale, который имеет дело с кучей маленьких, в основном двухсимвольных, строк – kodами стран, языков и т.п. См. также тут:
Сравнение obiektов: практика – метод String.intern. Очень часто я вижу в различной литературе конструкции следующего вида:
public static final String SOME_STRING = new String("abc");
Если говорить еще точнее, нарекания у меня вызывает
new String("abc"). Дело в том, что конструкция эта – безграмотна. В Java строковый литерал – "abc" –
УЖЕ является obiektом класса
String. А потому, использование еще и конструктора приводит к
КОПИРОВАНИЮ строки. Поскольку строковый литерал уже хранится в пуле, и никуда из него не денется, то созданный
НОВЫЙ obiekt – ничто иное Jak пустая трата памяти. Эту конструкцию с чистой совестью можно переписать вот так:
public static final String SOME_STRING = "abc";
С точки зрения kodа это будет абсолютно то же самое, но несколько эффективнее. Переходим к следующему вопросу –
Сравнение строк
Собственно, все об этом вопросе я уже писал в статье
Сравнение obiektов: практика. И добавить больше нечего. Резюмируя сказаное там – строки надо сравнивать по значению, с использованием метода
equals. По ссылке их можно сравнивать, но аккуратно, только если точно знаешь, что делаешь. В этом помогает метод
String.intern. Единственный момент, который хотелось бы упомянуть – сравнение с литералами. Я часто вижу конструкции типа
str.equals("abc"). И тут есть небольшие грабли – перед этим сравнением правильно бы было сравнить
str с
null, чтобы не получить
NullPointerException. Т.е. правильной будет конструкция
str != null && str.equals("abc"). Между тем – ее можно упростить. Достаточно написать всего лишь
"abc".equals(str). Проверка на
null в этом случае не нужна. На очереди у нас...
Сложение строк
Строки – единственный obiekt, для которого определена операция сложения ссылок. Во всяком случае, так было до версии Java 5.0, в которой появился autoboxing/unboxing, но речь сейчас не об этом. Общее описание принципа работы оператора конкатенации можно найти в статье о połączyćх, а именно –
тут. Я же хочу затронуть более глубокий уровень. Представьте себе, представьте себе... Прямо Jak в песенке про кузнечика. :) Так вот, представьте себе, что нам надо сложить две строки, вернее, к одной прибавить другую:
String str1 = "abc";
str1 += "def";
Как происходит сложение? Поскольку obiekt класса строки неизменяем, то результатом сложения будет новый obiekt. Итак. Сначала выделяется память, достаточная для того, чтобы вместить туда содержимое обеих строк. В эту память копируется содержимое сначала первой строки, потом второй. Далее переменной str1 присваивается połączyć на новую строку, а старая строка отбрасывается. Усложним задачу. Пусть у нас есть файл из четырех строк:
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 присваивается połączyć на новую строку, старая отбрасывается. Второй проход цикла.
result="abc",
line="def". Выделяется память на 6 символов, туда копируется содержимое
result –
"abc", затем
line –
"def". Переменной
result присваивается połączyć на новую строку, старая отбрасывается. Третий проход цикла.
result="abcdef",
line="ghi". Выделяется память на 9 символов, туда копируется содержимое
result –
"abcdef", затем
line –
"ghi". Переменной
result присваивается połączyć на новую строку, старая отбрасывается. Четвертый проход цикла.
result="abcdefghi",
line="jkl". Выделяется память на 12 символов, туда копируется содержимое
result –
"abcdefghi", затем
line –
"jkl". Переменной
result присваивается połączyć на новую строку, старая отбрасывается. Пятый проход цикла.
result="abcdefghijkl",
line=null. Цикл закончен. Итак. Три символа "abc" копировались в памяти 4 раза, "def" – 3 раза, "ghi" – 2 раза, "jkl" – один раз. Страшно? Не особо? А вот теперь представьте себе файл с длиной строки 80 символов, в котором где-то 1000 строк. Всего-навсего 80кб. ПредставLub? Co будет в этом случае? первая строка, Jak нетрудно подсчитать, будет скопирована в памяти 1000 раз, вторая – 999 и т.д. И при средней длине 80 символов через память пройдет ((1000 + 1) * 1000 / 2) * 80 = ... барабанная дробь... 40 040 000 символов, что составляет около 80 Мб (!!!) памяти. Каков же итог
ТАКОГО цикла? Чтение 80-килоbajtного plik вызвало выделение 80 Мб памяти. Ни много ни мало – в 1000 раз больше, чем полезный объем. Какой из этого следует сделать вывод? Очень простой. Никогда, запомните –
НИКОГДА не используйте прямую конкатенацию строк, особенно в циклах. Даже в Jakом-нибудь методе
toString, если он вызывается достаточно часто, имеет смысл использовать
StringBuffer zamiast конкатенации. Собственно, компилятор при оптимизации чаще всего так и делает – прямые сложения он выполняет через
StringBuffer. Однако в случаях, подобных тому, что привел я, оптимизацию компилятор сделать не в состоянии. Co и приводит к весьма печальным последствиям, описаным чуть ниже. К сожалению, подобные конструкции встречаются слишком часто. Потому я и счел необходитмым заострить на этом внимание.
Собственный опыт Не могу не вспомнить один эпизод из собственной практики. Один из программистов, работавших со мной, Jak-то пожаловался, что у него очень медленно работает его kod. Он читал достаточно большой файл в HTML формате, после чего производил Jakие-то манипуляции. И действительно, работало все с черепашьей prędkośćю. Я взял посмотреть исходник, и обнаружил, что он... использует конкатенацию строк. У него было по 200-250 строк в каждом файле, и при чтении plik около 200Кб через память проходило более 40Мб! В итоге я переписал немного kod, заменив операции со строками на операции со StringBuffer-ом. Честно сказать, когда я запустил переписаный kod, я подумал, что он просто где-то "упал". Обработка занимала доли секунды. Скорость выросла в 300-800 раз. После этого я коренным образом пересмотрел свое отношение к строковым операциям. Следующий акт марлезонского балета –
Выборка подстроки и копирующий конструктор
Представим, что у нас есть строка, из которой надо вырезать подстроку. Вопроса "Jak это сделать" не стоит – и так понятно. Вопрос в другом – что при этом происходит?
String str = "abcdefghijklmnopqrstuvwxyz";
str = str.substring(5,10);
Вроде тривиальный kod. И первая мысль такая – выбирается подстрока "efghi", переменной str присваивается połączyć на новую строку, а старый obiekt отбрасывается. Так? Почти. Дело в том, что для увеличения скорости при выборке подстроки используется ТОТ ЖЕ МАССИВ, что и в исходной строке. Иначе говоря, мы получим не obiekt, в котором массив
value (cм.
устройство строки) имеет длину 5 и содержит в себе символы 'e', 'f', 'g', 'h' и 'i', count=5 и offset=0. Нет, длина массива будет по-прежнему 26, count=5 и offset=5. И при отбрасывании старой строки массив НЕ ОТБРОСИТСЯ, а по-прежнему будет находиться в памяти, ибо на него есть połączyć из новой строки. И существовать в памяти он будет до того момента, Jak будет отброшена уже новая строка. Это совсем неочевидный момент, который может привести к проблемам с памятью. Возникает вопрос – Jak этого избежать? Ответ – с помощью копирующего конструктора
String(String). Дело в том, что в этом конструкторе в явном виде выделяется память под новую строку, и в эту память копируется содержимое исходной. Таким образом, если мы перепишем kod так:
String str = "abcdefghijklmnopqrstuvwxyz";
str = new String(str.substring(5,10));
..., то длина массива
value у obiektа
str будет действительно 5, count=5 и offset=0. И это – единственный случай, где оправдано применение копирующего конструктора для строки. И Jak финальный аккорд –
Изменение строки
Nie ma to wiele wspólnego z linią jako taką. Chcę tylko podkreślić fakt, że ciąg znaków jest niezmienny tylko w pewnym stopniu. Oto kod.
package tests;
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
public class StringReverseTest {
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
Co tu się dzieje? Najpierw szukam pola typu
char[] . Mógłbym też wyszukiwać po nazwie. Nazwa może się jednak zmienić, ale bardzo wątpię w typ. Następnie wywołuję metodę
setAccessible(true) na znalezionym polu . To kluczowa kwestia - wyłączam sprawdzanie poziomu dostępu do pola (w przeciwnym razie po prostu nie będę mógł zmienić wartości, bo pole jest
prywatne ). W tym momencie mogę zostać uderzony w głowę przez menedżera bezpieczeństwa, który sprawdza, czy taka akcja jest dozwolona (poprzez wywołanie metody
checkPermission(new ReflectPermission("suppressAccessChecks")) ). Jeśli jest to dozwolone (a jest to ustawienie domyślne w przypadku zwykłych aplikacji), mogę uzyskać dostęp do pola
prywatnego . Reszta, jak mówią, jest kwestią techniki. W rezultacie otrzymuję dane wyjściowe:
Initial static final string: abcde
Reversed static final string: edcba
co było do okazania Dlatego w rzeczywistych zastosowaniach radzę ostrożniej podchodzić do konfigurowania polityk bezpieczeństwa. W przeciwnym razie może się okazać, że obiekty, o których myślisz, że są niezmienne, takie nie są. * * * To chyba wszystko, co chcę na razie powiedzieć na temat ciągów znaków. Dziękuję za uwagę!
Link do oryginalnego źródła: Ach, te wersety...
GO TO FULL VERSION