JavaRush/Java блог/Random UA/Ах, ці рядки...
articles
15 рівень

Ах, ці рядки...

Стаття з групи Random UA
учасників
Клас 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, наприклад, що відповідатиме російській букві 'А'. Однак, цей рядок НЕ МОЖЕмістити послідовностей \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 (див. пристрій рядка) має довжину 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
Що й потрібно було довести. А тому – у реальних додатках я раджу ретельніше підходити до настроювання політики безпеки. Інакше може виявитися, що об'єкти, які ви вважаєте гарантовано незмінними, не є такими. * * * Напевно, це все, що я хочу розповісти про рядки на даний момент. Дякую за увагу! Посилання на першоджерело: Ах, ці рядки...
Коментарі
  • популярні
  • нові
  • старі
Щоб залишити коментар, потрібно ввійти в систему
Для цієї сторінки немає коментарів.