Содержание

понедельник, 16 мая 2011 г.

java.lang.String: вы уверены, что знаете про строки все?

Казалось бы, что может быть непонятно о таком часто используемом объекте как строки? Я тоже так думал долгие годы, однако, если почитать документацию и копнуть поглубже о некоторых свойствах строк, то можно найти много интересного. Например, я только недавно узнал, что время операции intern зависит от размера пула, что, на самом деле, созданную строку можно поменять стандартными средствами java, а в версии JVM 1.4 использование String, строго говоря, не является потокобезопасным.


Класс java.lang.String состоит из трех final полей: массив символов, длина строки и сдвиг в массиве, с помощью которого реализуется метод substring. Так же в этом классе есть поле, в котором хранится посчитанный хэшкод, после первого вызова метода hashCode.

Свойство Immutable и потокобезопасность
Так как все логические поля класса являются final, то модель памяти java 5.0 гарантирует, что после вызова конструктора все потоки, которые видят ссылку на экземпляр этого класса, увидят и все final поля. Однако до java 5 этого никто не гарантировал, поэтому поток, который данную строку не создавал, вполне мог получить ссылку на инконсистентный (с его точки зрения) объект.

Постойте, но, ведь, значение строки можно поменять через reflection:
Таким образом, получается, что строку можно поменять, а другой поток этих изменений и не увидит? Неправда - видимость измененных final полей через reflection тоже гарантируется моделью памяти java 5.0.

Методы, над реализацией которых, мало кто задумывается
Как я уже упоминал вначале, метод substring(int beginIndex, int endIndex) не копирует содержимое массива символов, а использует ссылку на оригинальный массив и выставляет соответствующим образом поля длины строки и сдвига. Таким образом, если вы считали большой файл в строку, потом взяли из него подстроку и удалили ссылку на первый объект, то память у вас нисколько не освободиться. Так же если вы сериализуете полученную подстроку стандартными средствами и передаете её по сети, то опять же ничего хорошего не выйдет, так как будет сериализован весь оригинальный массив и количество передаваемых байтов по сети вас неприятно удивит. Если это ваш случай, то после нахождения подстроки вы можете воспользоваться конструктором String(String original), который скопирует только реально используемую часть массива. Кстати, если этот конструктор вызывать на стоке длиной равной длине массива символов, то копирования в этом случае происходить не будет, а будет использоваться ссылка на оригинальный массив.

Методы String(byte[] bytes) и getBytes() кодируют и декодируют строку в байты и наоборот, используя кодировку по умолчанию, которую можно переопределить запустив JVM с параметром "Dfile.encoding=". Если вы кодировку не переопределяете, то используется UTF-8. Если её не присутствует в системе, то - ISO-8859-1. Если нет и её, то JVM завершается с кодом -1. Но когда я пытался выставить "Dfile.encoding=" в java 1.4, то она у меня не подцеплялась. Погуглив немного, я выяснил, что с данной проблемой сталкивался не только я, правда, почему этот параметр не хочет работать, я так и не нашел.

Важно не путать вышеупомянутые методы с String(byte[] ascii, int hibyte), String(byte[] ascii, int hibyte, int offset, int count) и getBytes(int srcBegin, int srcEnd, byte[] dst, int dstBegin), которые могут некорректно преобразовать символы в байты и обратно.

Переопределение операции "+"

Здесь важно понимать, что данный оператор переопределяется не на уровне байткода, а еще на этапе компиляции java файла в байткод. Если вы декомпилируете байткод, то уже не увидите никакого сложения,а вместо операции
вы уже увидите что-то похожее на
Т.е. смысла засорять ваш код и вместо простого знака "+" городить громоздкий и плохо читаемый код из StringBuilder смысла никакого нет. Даже скажу больше, те кто в java 1.4. вместо плюсов писали StringBuffer, то их код в пятой джаве теперь может работать даже медленнее, так как StringBuffer работает не быстрее чем StringBuilder.
Еще в последних версиях JVM появился замечательный оптимизационный флаг -XX:+OptimizeStringConcat, который при вызове метода StringBuilder.toString() не будет копировать внутренний массив StringBuilder в новый String, а будет использовать тот же массив, таким образом операция конкатенации будет проходить вообще без копирования массивов.

Однако стоить заметить, что в циклах JVM вам может и не заменить операцию "+" на StringBuilder. Вернее она может заменить её на создание нового StringBuilder на каждом шаге цикла, т.е. код
может быть скомпилирован в
Да, раз уж начал об этом писать, то, наверное, стоить упомянуть, что код String a = "b" + "c" скомпилируется в String a = "bc"
Сколько же String занимает места в памяти?
Казалось, бы чего тут сложного - строка это объект с тремя полями int и массивом char'ов, т.е. 

[8 bytes (заголовок объекта) + 3*4(int) + 4 (ссылка на массив)]{выравнивание по 8 байтам} + [8 bytes (заголовок объекта массива) + 4(int длины массива) + 2(char)*длина_строки]{выравнивание по 8 байтам} = 24 + [12 + 2*length]{выравнивание по 8 байтам} = [36 + 2*length]{выравнивание по 8 байтам}

Получается пустая строка в сумме будет весить 40 байтов.

Теперь посмотрим во что это обернется на 64 битной jvm:

[16 bytes (заголовок объекта) + 3*4(int) + 8 (ссылка на массив)]{выравнивание по 8 байтам} + [24 bytes (заголовок массива) + 2(char)*длина_строки]{выравнивание по 8 байтам} = 40 + [24 + 2*length]{выравнивание по 8 байтам} = 64 + 2*length{выравнивание по 8 байтам}
Таким образом пустая строка уже вести 64 байта.

Если же у нас включена опция (-XX:+UseCompressedOops), которая в последних JVM уже работает по умолчанию для куч размером меньше 32 гигов, то имеем следующую арифметику:

[16 bytes (заголовок объекта) + 3*4(int) + 4 (ссылка на массив)]{выравнивание по 8 байтам} + [16 bytes (заголовок массива) + 2(char)*длина_строки]{выравнивание по 8 байтам} = 32 + [16 + 2*length]{выравнивание по 8 байтам} = 48 + 2*length{выравнивание по 8 байтам}

Значит для пустой строки в этом случае нам надо 48 байтов.

Так же не стоит забывать о существовании параметра -XX:+UseCompressedStrings, при использовании которого, ANSII строки будут содержать внутри массив байтов, а не символов. Таким образом в формулах выше нужно длину строки умножать на один, а не на два. Т.е. данная опция может сократить размер строк до двух раз.
Пул строк
Как вы уже знаете в java есть пул строк. Туда неявно попадают все литералы (строки в коде оформленные через двойные кавычки "literal"). Так же есть возможность положить строку в пул явно с помощью метода intern(). Например, классы java reflection интенсивно используют этот метод, и все имена полей и методов класса попадают в пул. Соответственно, то же самое происходит при использовании стандартной java сериализации, которая неявно использует reflection. Так же обычно библиотеки работающие с XML кладут в пул названия элементов и атрибутов документов.

Пул располагается в PermGen и хранит строки с помощью слабых ссылок. Таким образом при вызове Full GC, если ваша куча больше не ссылается на строки, находящиеся в пуле, то сборщик мусора их очищает. Однако не стоит увлекаться складыванием всего попало в пул: чем больше в вашем пуле находится строк, тем дольше будет операция на проверку находится ли строка уже там или нет. Мои замеры показали, что зависимость примерно прямо пропорциональна (О(n)) и при размере пула 1 миллион строк, каждая такая операция у меня уже занимала несколько микросекунд.

Говоря о пуле строк, не могу не упомянуть почему некоторые библиотеки при объявлении констант используют вышеописанный метод intern:
Данная конструкция может показаться абсурдной не искушенному программисту. И правда, зачем класть эту строку в пул, если она и так константа и присутствует в единственном экземпляре. И вообще, это же литерал, он и так должен неявно попадать в пул. Однако цель, с которой это делается в библиотеках - совсем не пулинг. Дело в том, что если бы эта константа была объявлена в библиотеке просто как
и вы бы использовали её в своем коде, то при компилировании в байткод эта строка заинлайнилась. И если бы вы потом подложили jar с новой версией библиотеки, и не перекомпилировали ваши исходники, то ваш код изменения в данной константе просто бы не заметил.