🔤 Раздел 12 · Вопрос #17

Что такое String encoding (кодировка)

Компьютерная память оперирует исключительно битами и байтами (числами от 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 между потоками без синхронизации.

Связанные вопросы