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

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

Компьютер понимает только числа. Кодировка — это «словарь», который говорит: «Символу 'А' соответствует число 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. Что такое compact strings в Java 9+]]