🔤 Розділ 12 · Питання #17

Що таке кодування String

Комп'ютерна пам'ять оперує виключно бітами та байтами (числами від 0 до 255). Кодування слугує «словником-перекладачем»:


🟢 Junior Level

Кодування (Character Encoding) — це стандартизована система правил і таблиць відповідності, яка визначає, як абстрактні текстові символи (літери, цифри, розділові знаки, емодзі) перетворюються на послідовність фізичних байтів (чисел) для збереження на диску або передачі мережею, і навпаки.

Комп’ютерна пам’ять оперує виключно бітами та байтами (числами від 0 до 255). Кодування слугує «словником-перекладачем»:

  • Кодування (Encoding): Текст (String) $\to$ масив байтів (byte[]).
  • Декодування (Decoding): Масив байтів (byte[]) $\to$ текст (String).

Суть у 30 секундах

Головне правило професійної розробки на Java: ніколи не покладайтеся на неявне кодування за замовчуванням. При будь-яких операціях введення-виведення (I/O), серіалізації, читанні файлів чи роботі з мережевими сокетами завжди явно передавайте константу StandardCharsets.UTF_8.

String text = "Привіт, Java 21! 🔥";

// 1. Кодування: явно вказуємо кодування UTF-8
byte[] bytes = text.getBytes(StandardCharsets.UTF_8);

// 2. Декодування: відновлюємо рядок у тому самому кодуванні
String restored = new String(bytes, StandardCharsets.UTF_8);

System.out.println(restored.equals(text)); // true

Якщо закодувати рядок в одному кодуванні (наприклад, Windows-1251), а прочитати в іншому (наприклад, UTF-8 або ISO-8859-1), виникнуть знамениті «кракозябри» (Mojibake) з нечитабельних знаків питання ??? або символів \uFFFD.


🟡 Middle Level

Unicode: Кодові точки (Code Points) та Кодові одиниці (Code Units)

Щоб глибоко розуміти кодування в Java, необхідно розрізняти два поняття:

  1. Unicode Code Point (Кодова точка): Абстрактний унікальний номер, присвоєний символу міжнародним стандартом Unicode (позначається як U+XXXX, наприклад, літера 'A' — це U+0041, а емодзі '🔥' — U+1F525). Загалом стандарт охоплює понад 1.1 мільйона кодових точок.
  2. Code Unit (Кодова одиниця): Мінімальний блок пам’яті, що використовується конкретним кодуванням для представлення символу.
    • В UTF-8 кодова одиниця дорівнює 1 байту (символ кодується від 1 до 4 байтів).
    • В UTF-16 кодова одиниця дорівнює 2 байтам (char у Java).

Пастка сурогатних пар (Surrogate Pairs) у Java

Історично тип char у Java було спроєктовано як 16-бітний (2 байти), оскільки автори сподівалися, що 65 536 символів вистачить для всіх мов світу (Basic Multilingual Plane, BMP: U+0000 .. U+FFFF). Коли стандарт розширився до U+10FFFF (емодзі, рідкісні історичні символи), у кодуванні UTF-16 символи за межами BMP почали кодуватися двома char поспіль — сурогатною парою (High Surrogate + Low Surrogate):

String emoji = "🔥";

// Довжина рядка повертає кількість 16-бітних code units, а НЕ символів!
System.out.println(emoji.length()); // 2 ! (сурогатна пара)

// Коректний підрахунок реальних символів (кодових точок Unicode):
System.out.println(emoji.codePointCount(0, emoji.length())); // 1

Порівняння популярних кодувань

| Кодування | Розмір символу | Сумісність з ASCII | Особливості та застосування | | :— | :— | :— | :— | | UTF-8 | 1–4 байти (змінний) | ✅ 100% сумісне (1 байт) | Світовий стандарт Інтернету, JSON, REST API, баз даних. | | UTF-16 | 2 або 4 байти | ❌ Несумісне (мінімум 2 байти) | Внутрішнє представлення рядків у Java, Windows API. Буває LE (Little Endian) та BE (Big Endian). | | ISO-8859-1 (Latin-1) | Рівно 1 байт | ✅ Сумісне в нижніх 128 | Західноєвропейські мови. Кирилицю не підтримує. Використовується в Compact Strings. | | Windows-1251 | Рівно 1 байт | ✅ Сумісне в нижніх 128 | Застарілий стандарт для кирилиці у Windows. Несумісне з UTF-8. | | US-ASCII | Рівно 7 бітів (1 байт) | — | Базові 128 символів англійського алфавіту та керуючих кодів. |

JEP 400 (Java 18+): UTF-8 by Default

До Java 18 методи на кшталт str.getBytes() або new FileReader("file.txt") без явного вказання кодування використовували системне кодування операційної системи (file.encoding):

  • На Linux це майже завжди був UTF-8.
  • На україномовній або російськомовній Windows це був Windows-1251. Через це код, що чудово працював на машині розробника під Windows, падав або спотворював дані під час розгортання в Linux-контейнері. Починаючи з Java 18 (JEP 400), значення Charset.defaultCharset() за замовчуванням жорстко зафіксоване як UTF-8 на всіх операційних системах. Проте навіть у Java 21 покладатися на системний дефолт вважається поганою практикою, оскільки прапорець -Dfile.encoding може бути перевизначений адміністратором системи.

🔴 Senior Level

Внутрішня будова StringCoding та Compact Strings (Java 9+)

Починаючи з Java 9, клас String зберігає масив byte[] value та однобайтне поле coder:

public final class String {
    @Stable
    private final byte[] value;
    private final byte coder; // 0 = LATIN1, 1 = UTF16
}

При виклику text.getBytes(StandardCharsets.UTF_8) HotSpot задіює клас java.lang.StringCoding:

  1. Якщо рядок уже має coder = LATIN1:
    • HotSpot C2 застосовує SIMD-інструкції (AVX-512 / AVX2): процесор перевіряє старший біт усіх байтів масиву векторними операціями.
    • Якщо всі байти $< 128$ (чистий ASCII), конвертація в UTF-8 не вимагає перетворення символів — дані копіюються безпосередньо блоками по 64 байти за машинний такт.
  2. Якщо рядок має coder = UTF16:
    • Запускається векторизований транскодер StringUTF16.toUtf8, що конвертує 2-байтові UTF-16 значення в 1, 2 або 3 байти UTF-8.

Проблема маркера порядку байтів (BOM — Byte Order Mark)

При читанні файлів у кодуванні UTF-8 або UTF-16 на початку потоку байтів часто міститься сигнатура порядку байтів:

  • В UTF-8 BOM — це 3 байти: 0xEF, 0xBB, 0xBF.
  • У Java при декодуванні new String(bytes, StandardCharsets.UTF_8) ці три байти перетворяться на невидимий символ \uFEFF (Zero Width No-Break Space): ```java byte[] bomBytes = {(byte) 0xEF, (byte) 0xBB, (byte) 0xBF, ‘O’, ‘K’}; String str = new String(bomBytes, StandardCharsets.UTF_8);

System.out.println(str.equals(“OK”)); // false! На початку рядка невидимий \uFEFF System.out.println(str.startsWith(“OK”)); // false! System.out.println(str.length()); // 3 (а не 2!)

**Рішення:** Використання `org.apache.commons.io.input.BOMInputStream` або ручна фільтрація перших байтів.

### Обробка пошкоджених байтів: `CodingErrorAction`
Коли ви викликаєте стандартний конструктор `new String(byte[] bytes, Charset charset)`, він приховано маскує помилки:
- Невалідні послідовності байтів автоматично замінюються на символ заміни Unicode: `'\uFFFD'` (`REPLACE`).
- У високонадійних системах (банківський сектор, криптографія, валідація протоколів) така поведінка є неприпустимою, оскільки вона призводить до непомітної втрати даних.
- **Strict Decoding:** Для гарантованого виявлення помилок використовується явний екземпляр `CharsetDecoder`:
```java
CharsetDecoder decoder = StandardCharsets.UTF_8.newDecoder()
    .onMalformedInput(CodingErrorAction.REPORT)     // Викине MalformedInputException
    .onUnmappableCharacter(CodingErrorAction.REPORT);

ByteBuffer buffer = ByteBuffer.wrap(corruptedBytes);
CharBuffer result = decoder.decode(buffer); // Викине виняток при першій же помилці

4 Tricky Questions

1. Чому "👋".length() у Java повертає 2, а не 1, і як правильно обійти рядок посимвольно?

Відповідь: Метод length() класу String історично повертає кількість 16-бітних кодових одиниць (char), а не кількість реальних символів стандарту Unicode. Емодзі "👋" (U+1F44B) лежить у площині Supplemental Multilingual Plane (вище ніж U+FFFF) і не вміщується в один char. У кодуванні UTF-16 воно кодується сурогатною парою: High Surrogate (\uD83D) та Low Surrogate (\uDC4B). Для коректної ітерації за символами Unicode необхідно використовувати Code Points:

String str = "Hi 👋!";
str.codePoints().forEach(cp -> {
    System.out.println(Character.toString(cp));
});

2. Що відбудеться, якщо розрізати рядок посередині сурогатної пари методом substring()?

Відповідь: Відбудеться створення невалідного рядка з відірваним сурогатом (Unpaired Surrogate):

  • Перша частина отримає лише High Surrogate (0xD800..0xDBFF), а друга — лише Low Surrogate (0xDC00..0xDFFF).
  • Виняток StringIndexOutOfBoundsException викинуто не буде, оскільки з точки зору Java substring() працює за індексами char.
  • Проте при будь-якій спробі передати такий рядок мережею, зберегти в БД або серіалізувати через str.getBytes(StandardCharsets.UTF_8) конвертер виявить відірваний сурогат і замінить його на символ \uFFFD (Replacement Character). Дані будуть безповоротно втрачені.

3. Чому категорично заборонено конвертувати довільні бінарні дані (хеші, ключі, зображення) у String через new String(bytes) і назад?

Відповідь: Тому що текстові кодування не є бієктивним (взаємно однозначним) відображенням усіх 256 можливих значень байта:

  1. В UTF-8 існують заборонені послідовності байтів (наприклад, байти 0xFF, 0xFE або некоректні продовження багатобайтових символів). При спробі декодувати довільні бінарні байти декодер замінить їх на символ \uFFFD (3 байти 0xEF, 0xBB, 0xBF).
  2. При зворотному виклику getBytes() ці байти не відновляться. Масив буде безповоротно спотворено.
  3. Бінарні дані мають передаватися або у вигляді byte[], або у вигляді безпечного текстового представлення: Base64 (Base64.getEncoder().encodeToString(bytes)) або Hex.

4. Чи є потокобезпечними класи CharsetEncoder та CharsetDecoder у Java?

Відповідь: Ні, категорично не потокобезпечні (Not Thread-Safe). На відміну від самого класу Charset та констант StandardCharsets.UTF_8 (які є незмінними й абсолютно потокобезпечними), об’єкти CharsetEncoder та CharsetDecoder зберігають внутрішній мутабельний стан проміжного кодування, буфери залишку байтів та прапорці скидання стану. Одночасний виклик методу .encode() або .decode() в одного екземпляра з кількох потоків призведе до стану гонитви (Race Condition), генерації IllegalStateException та пошкодження даних. Екземпляри енкодерів необхідно створювати наново на кожен виклик або ізолювати в ThreadLocal.


🎯 Шпаргалка для інтерв’ю

30-секундна відповідь

«Кодування — це схема відображення символів Unicode у байти. У Java внутрішнє представлення рядків оптимізовано через Compact Strings (Latin-1 по 1 байту на символ для латиниці, або UTF-16 по 2 байти на символ за наявності кирилиці чи емодзі). Для зовнішнього I/O стандартом є UTF-8. Головні правила: завжди явно передавати StandardCharsets.UTF_8, ніколи не конвертувати сирі бінарні дані у String (використовувати Base64), пам’ятати, що length() рахує char (сурогатні пари для емодзі дають довжину 2), а CharsetDecoder не є потокобезпечним».

Ключові тези для Senior-інтерв’ю

  1. JEP 400 (Java 18+): UTF-8 став кодуванням за замовчуванням для всієї платформи, але явне вказання StandardCharsets.UTF_8 залишається обов’язковим.
  2. Surrogate Pairs: Символи за межами BMP (U+10000+) займають 2 char. Для роботи з символами Unicode використовуються методи codePoints(), codePointCount().
  3. BOM Danger: Невидимий маркер \uFEFF на початку потоку UTF-8 ламає перевірку startsWith() та equals().
  4. Data Corruption: Конструктор new String(bytes, UTF_8) мовчки підміняє пошкоджені байти на \uFFFD. Для суворої валідації використовується CharsetDecoder з CodingErrorAction.REPORT.
  5. Thread Safety: Charset та StandardCharsets є thread-safe; CharsetEncoder/CharsetDecoder — NOT thread-safe.

Червоні прапорці (Чого говорити категорично не можна)

  • ❌ Викликати str.getBytes() або new String(bytes) без передачі об’єкта Charset.
  • ❌ Стверджувати, що «1 символ завжди дорівнює 1 char або 1 байту» (емодзі = 2 char = 4 байти в UTF-8).
  • ❌ Пропонувати зберігати зашифровані криптографічні байти або картинки в типі String без Base64.
  • ❌ Поділяти один екземпляр CharsetEncoder між потоками без синхронізації.

Пов’язані питання