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

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

Комп'ютер розуміє тільки числа. Кодування — це «словник», який каже: «Символу 'А' відповідає число 1040, символу 'B' — число 66» тощо.

Мовні версії: English Russian Ukrainian

🟢 Junior Level

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

Комп’ютер розуміє тільки числа. Кодування — це «словник», який каже: «Символу ‘А’ відповідає число 1040, символу ‘B’ — число 66» тощо.

Проста аналогія: Кодування — як мова перекладу. Ви кажете перекладачу: «Переклади цю фразу німецькою» (encode), а потім: «Переклади назад російською» (decode). Якщо обидва перекладачі використовують однаковий словник — все зрозуміють правильно.

Приклад:

String s = "Привіт";

// Encode: String → byte[]
byte[] bytes = s.getBytes(StandardCharsets.UTF_8);

// Decode: byte[] → String
String restored = new String(bytes, StandardCharsets.UTF_8);

Найважливіші кодування: | Кодування | Для чого | Розмір “Привіт” | | —————- | ———————————————————————- | —————- | | UTF-8 | Інтернет, файли, бази даних | 12 байт | | UTF-16 | Внутрішній формат Java 8 і раніше / для нелатинських символів в Java 9+| 12 байт | | Windows-1251 | Старі Windows-системи | 6 байт |

Головне правило: ЗАВЖДИ явно вказуйте кодування! Ніколи не покладайтеся на кодування за замовчуванням — воно різне на Windows і Linux.


🟡 Middle Level

Як Java зберігає рядки всередині

До Java 9: Рядки зберігалися як char[] у кодуванні UTF-16 (2 байти на символ завжди).

Java 9+ (Compact Strings): Java автоматично обирає оптимальний формат:

  • Тільки латинські символи (U+0000–U+00FF) → Latin-1 (1 байт/символ)
  • Є нелатинські символи → UTF-16 (2 байти/символ)
  • Рішення зберігається в полі coder (0 = Latin-1, 1 = UTF-16)

Конвертація при I/O

Коли рядок виходить за межі JVM (запис у файл, відправка по мережі, запит до БД), він має бути перетворений на байти з вказаним кодуванням:

// ✅ ГАРАЗД — явне кодування
byte[] bytes = str.getBytes(StandardCharsets.UTF_8);
String str = new String(bytes, StandardCharsets.UTF_8);

// ❌ ПОГАНО — кодування за замовчуванням залежить від ОС!
byte[] bytes = str.getBytes();           // Windows-1251 на Windows, UTF-8 на Linux
String str = new String(bytes);          // Може дати «кракозябри»

Таблиця типових помилок

Помилка Наслідки Рішення
new String(bytes) без кодування «Кракозябри» при зміні ОС Завжди new String(bytes, StandardCharsets.UTF_8)
getBytes() без кодування Неможливо декодувати на іншій системі Завжди getBytes(StandardCharsets.UTF_8)
Плутати str.length() і bytes.length Неправильна логіка роботи з даними "Привіт".length() = 6, "Привіт".getBytes(UTF_8).length = 12

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

Кодування Байт/символ Підтримка Сумісність
UTF-8 1–4 (variable) Всі Unicode ASCII-сумісна
UTF-16 LE/BE 2–4 Всі Unicode Потребує BOM або угоди
Latin-1 1 Western European Не підтримує кирилицю
Windows-1251 1 Кирилиця Тільки Windows
ASCII 1 128 символів Не підтримує кирилицю

Коли НЕ використовувати конвертацію в String

  • Binary data (зображення, protobuf, архіви) — працюйте з byte[] напряму
  • Ultra-low-latency системи — конвертація додає 50–200ns overhead
  • Потокові дані невідомого кодування — використовуйте BOMInputStream або автодетект

🔴 Senior Level

Internal Implementation — Compact Strings і кодування

public final class String {
    @Stable
    private final byte[] value;  // byte[] замість char[] (Java 9+)
    private final byte coder;    // LATIN1=0 або UTF16=1
}

При виклику getBytes(Charset) відбувається:

  1. Перевірка coder поточного String
  2. Вибір алгоритму кодування в StringCoding.encode(value, coder, charset)
  3. Для UTF-8: посимвольна конвертація через CharsetEncoder
    • Latin-1 → UTF-8: 1 байт → 1 байт (ASCII), 1 байт → 2 байти (extended Latin-1)
    • UTF-16 → UTF-8: 2 байти → 1–3 байти (залежить від code point)

При new String(byte[], Charset):

  1. StringCoding.decode(bytes, charset)byte[] value + coder
  2. Якщо декодер виявив невалідні байти → заміна на \uFFFD (replacement character)

Edge Cases (мінімум 3)

1. BOM (Byte Order Mark):

byte[] withBom = {(byte)0xEF, (byte)0xBB, (byte)0xBF, (byte)'H', (byte)'i'};
String s = new String(withBom, StandardCharsets.UTF_8);
// s.charAt(0) = '\uFEFF' — невидимий символ!
// s.startsWith("Hi") → false!

Рішення: BOMInputStream (Apache Commons IO) або ручна перевірка перших 3 байт.

2. Malformed input — невалідні UTF-8 послідовності:

byte[] bad = {(byte) 0xFF, (byte) 0xFE};
String s = new String(bad, StandardCharsets.UTF_8);
// Результат: "\uFFFD\uFFFD" — заміна на replacement character

Поведінка залежить від CodingErrorAction (REPLACE за замовчуванням, можна змінити на REPORT або IGNORE).

3. Charset encoding roundtrip — втрата даних:

String s = "Привіт";
byte[] ascii = s.getBytes(StandardCharsets.US_ASCII);
// "??????" — кирилиця не підтримується в ASCII, замінена на '?'
// Зворотна конвертація вже неможлива — дані втрачені!

4. Security — encoding bypass:

// SQL injection через multi-byte encoding bypass
String input = "%C0%27 OR 1=1 --";
// Деякі старі системи декодують %C0%27 як одинарну лапку
// Завжди валідуйте input ПІСЛЯ декодування, а не до!

Продуктивність

Операція UTF-8 UTF-16 Latin-1
Encode “Hello” ~5ns ~3ns ~2ns
Encode “Привіт” ~15ns ~5ns N/A
Decode 12 bytes ~10ns ~5ns ~3ns
Encode 10KB text ~500ns ~150ns ~80ns

// UTF-8 для кирилиці = 2 байти/символ з multi-byte encoding логікою. // UTF-16 = прямий 1:1 copy для BMP символів — швидше.

Пам’ять (Java 9+):

  • Latin-1 рядок: 24 bytes (об’єкт) + 16 + N (byte[]) → ~40+N bytes
  • UTF-16 рядок: 24 bytes (об’єкт) + 16 + 2N (byte[]) → ~40+2N bytes
  • Економія для ASCII/Latin-1: ~50% пам’яті

Thread Safety

Класи StandardCharsets, Charset, CharsetEncoder, CharsetDecoder:

  • StandardCharsets.UTF_8 — thread-safe (immutable singleton)
  • CharsetEncoder / CharsetDecoderNOT thread-safe! Один інстанс не можна використовувати з кількох потоків одночасно
  • Рішення: створюйте новий encoder/decoder на кожен потік або використовуйте ThreadLocal

Production War Story

Сценарій: Мікросервіс на Linux (Spring Boot) читає дані з legacy Windows-системи.

Windows-система надсилала дані в Windows-1251. Linux-сервіс читав байти як UTF-8 (дефолтне кодування Linux) → «кракозябри» в логах. Клієнти скаржилися на некоректні відповіді.

Діагностика: Charset.defaultCharset() на Linux = UTF-8, на Windows = Windows-1251 (або Cp1252).

Fix:

// Явне вказання кодування на рівні протоколу
String text = new String(bytes, Charset.forName("Windows-1251"));

Довгострокове рішення: домовитися про UTF-8 на рівні API-контракту між всіма сервісами.

Сценарій 2: HTTP API response з кирилицею — без Content-Type: application/json; charset=UTF-8 браузер інтерпретував відповідь як ISO-8859-1, кирилиця перетворювалася на «кракозябри».

Monitoring

# Перевірити дефолтне кодування
java -XX:+PrintFlagsFinal -version 2>&1 | grep file.encoding
# java.nio.file.DefaultCharset = UTF-8

# Перевірити доступні кодування
jrunscript -e "print(java.nio.charset.Charset.availableCharsets().keySet())"

# JFR — можна відстежувати I/O операції з кодуваннями
java -XX:StartFlightRecording=filename=recording.jfr ...

# GC логи — опосередковано: менший розмір рядків → менший heap
java -Xlog:gc*:file=gc.log ...
// Runtime-перевірка кодування
System.out.println(Charset.defaultCharset());

// JOL — реальний розмір String після конвертації
String latin = "Hello";
String cyrillic = "Привіт";
System.out.println(GraphLayout.parseInstance(latin).toFootprint());  // ~34 bytes (Latin-1)
System.out.println(GraphLayout.parseInstance(cyrillic).toFootprint()); // ~42 bytes (UTF-16)

Best Practices для Highload

  • Завжди вказуйте Charset — використовуйте StandardCharsets.UTF_8 (константа, без алокацій)
  • Для JSON/XML/HTTP: UTF-8 — стандарт де-факто
  • Для binary data: працюйте з byte[]/ByteBuffer напряму, не конвертуйте в String
  • CharsetEncoder/CharsetDecoderне шарте між потоками, створюйте per-thread або використовуйте ThreadLocal
  • Для ultra-low-latency: уникайте String для I/O — використовуйте ByteBuf (Netty), zero-copy підходи
  • BOM handling: BOMInputStream або ручна перевірка перших байт перед декодуванням
  • Security: валідуйте input ПІСЛЯ декодування, використовуйте constant-time порівняння для secrets

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

Обов’язково знати:

  • Кодування — правила перетворення символів на байти і навпаки
  • UTF-8 — стандарт де-факто для інтернету, JSON, HTTP (ASCII-сумісна, 1-4 байти/символ)
  • Java 9+: Compact Strings — Latin-1 (1 байт/символ) або UTF-16 (2 байти/символ) автоматично
  • getBytes() без кодування залежить від ОС — на Windows і Linux буде різний результат
  • CharsetEncoder/CharsetDecoder — NOT thread-safe, не можна шарити між потоками
  • BOM (Byte Order Mark) — невидимий символ \uFEFF на початку UTF-8 файла

Часті уточнюючі запитання:

  • Чому не можна використовувати getBytes() без кодування? — Кодування за замовчуванням залежить від ОС. На Windows може бути Cp1251, на Linux — UTF-8.
  • Як обробляти BOM?BOMInputStream (Apache Commons IO) або ручна перевірка перших 3 байтів (EF BB BF).
  • Що буде при декодуванні невалідних UTF-8 байтів? — Заміна на \uFFFD (replacement character). Можна налаштувати через CodingErrorAction.
  • Як Java 9+ зберігає рядки?byte[] + coder: Latin-1 для U+0000–U+00FF, UTF-16 для решти.

Червоні прапорці (НЕ говорити):

  • ❌ “Кодування за замовчуванням — завжди UTF-8” — залежить від ОС і локалі
  • ❌ “CharsetEncoder thread-safe” — НЕ thread-safe, потрібен ThreadLocal або per-call створення
  • ❌ “Можна конвертувати binary дані в String” — втрата даних, працюйте з byte[] напряму
  • ❌ “BOM — це проблема тільки UTF-16” — UTF-8 теж може мати BOM

Пов’язані теми:

  • [[18. Як правильно перетворити String в byte[] і назад]]
  • [[19. Що таке компактні рядки в Java 9+]]