Що таке кодування 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, необхідно розрізняти два поняття:
- Unicode Code Point (Кодова точка): Абстрактний унікальний номер, присвоєний символу міжнародним стандартом Unicode (позначається як
U+XXXX, наприклад, літера'A'— цеU+0041, а емодзі'🔥'—U+1F525). Загалом стандарт охоплює понад 1.1 мільйона кодових точок. - 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:
- Якщо рядок уже має
coder = LATIN1:- HotSpot C2 застосовує SIMD-інструкції (AVX-512 / AVX2): процесор перевіряє старший біт усіх байтів масиву векторними операціями.
- Якщо всі байти $< 128$ (чистий ASCII), конвертація в UTF-8 не вимагає перетворення символів — дані копіюються безпосередньо блоками по 64 байти за машинний такт.
- Якщо рядок має
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викинуто не буде, оскільки з точки зору Javasubstring()працює за індексамиchar. - Проте при будь-якій спробі передати такий рядок мережею, зберегти в БД або серіалізувати через
str.getBytes(StandardCharsets.UTF_8)конвертер виявить відірваний сурогат і замінить його на символ\uFFFD(Replacement Character). Дані будуть безповоротно втрачені.
3. Чому категорично заборонено конвертувати довільні бінарні дані (хеші, ключі, зображення) у String через new String(bytes) і назад?
Відповідь: Тому що текстові кодування не є бієктивним (взаємно однозначним) відображенням усіх 256 можливих значень байта:
- В UTF-8 існують заборонені послідовності байтів (наприклад, байти
0xFF,0xFEабо некоректні продовження багатобайтових символів). При спробі декодувати довільні бінарні байти декодер замінить їх на символ\uFFFD(3 байти0xEF, 0xBB, 0xBF). - При зворотному виклику
getBytes()ці байти не відновляться. Масив буде безповоротно спотворено. - Бінарні дані мають передаватися або у вигляді
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-інтерв’ю
- JEP 400 (Java 18+): UTF-8 став кодуванням за замовчуванням для всієї платформи, але явне вказання
StandardCharsets.UTF_8залишається обов’язковим. - Surrogate Pairs: Символи за межами BMP (
U+10000+) займають 2char. Для роботи з символами Unicode використовуються методиcodePoints(),codePointCount(). - BOM Danger: Невидимий маркер
\uFEFFна початку потоку UTF-8 ламає перевіркуstartsWith()таequals(). - Data Corruption: Конструктор
new String(bytes, UTF_8)мовчки підміняє пошкоджені байти на\uFFFD. Для суворої валідації використовуєтьсяCharsetDecoderзCodingErrorAction.REPORT. - Thread Safety:
CharsetтаStandardCharsetsє thread-safe;CharsetEncoder/CharsetDecoder— NOT thread-safe.
Червоні прапорці (Чого говорити категорично не можна)
- ❌ Викликати
str.getBytes()абоnew String(bytes)без передачі об’єктаCharset. - ❌ Стверджувати, що «1 символ завжди дорівнює 1
charабо 1 байту» (емодзі = 2char= 4 байти в UTF-8). - ❌ Пропонувати зберігати зашифровані криптографічні байти або картинки в типі
Stringбез Base64. - ❌ Поділяти один екземпляр
CharsetEncoderміж потоками без синхронізації.