Что такое 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, необходимо различать два понятия:
- 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иStandardCharsetsthread-safe;CharsetEncoder/CharsetDecoder— NOT thread-safe.
Красные флаги (Чего говорить категорически нельзя)
- ❌ Вызывать
str.getBytes()илиnew String(bytes)без передачи объектаCharset. - ❌ Утверждать, что «1 символ всегда равен 1
charили 1 байту» (эмодзи = 2char= 4 байта в UTF-8). - ❌ Предлагать хранить зашифрованные криптографические байты или картинки в типе
Stringбез Base64. - ❌ Шэрить один экземпляр
CharsetEncoderмежду потоками без синхронизации.