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

Как правильно конвертировать String в byte[] и обратно?

Конвертация строки в байты и обратно — одна из самых частых операций при работе с файлами, сетью и базами данных.

Версии по языкам: English Russian Ukrainian

🟢 Junior Level

Конвертация строки в байты и обратно — одна из самых частых операций при работе с файлами, сетью и базами данных.

Главное правило: ВСЕГДА явно указывайте кодировку!

import java.nio.charset.StandardCharsets;

String str = "Hello, World!";

// String → byte[]
byte[] bytes = str.getBytes(StandardCharsets.UTF_8);

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

System.out.println(restored); // "Hello, World!"

Никогда не делайте так:

// ❌ ПЛОХО — использует кодировку ОС (разная на Windows и Linux!)
byte[] bytes = str.getBytes();
String restored = new String(bytes);

Почему: Кодировка по умолчанию зависит от операционной системы. На Windows это может быть Windows-1251, на Linux — UTF-8. Строка, сконвертированная на одной системе, превратится в «кракозябры» на другой.

**String → byte[] — как запись разговора на диктофон (текст → байты). byte[] → String — как воспроизведение записи (байты → текст). Если неправильно выбрать формат записи (кодировку), услышите шум вместо слов.


🟡 Middle Level

Правильные способы конвертации

String → byte[]:

// Java 7+ — рекомендуемый способ (константа, без аллокаций)
byte[] bytes = str.getBytes(StandardCharsets.UTF_8);

// Если кодировка определяется динамически
byte[] bytes = str.getBytes(Charset.forName("Windows-1251"));

byte[] → String:

// Java 7+ — рекомендуемый способ
String str = new String(bytes, StandardCharsets.UTF_8);

// Если кодировка динамическая
String str = new String(bytes, Charset.forName("Windows-1251"));

Потоковая работа с большими данными:

// Чтение файла с кодировкой
try (BufferedReader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) {
    String line = reader.readLine();
}

// Запись файла с кодировкой
try (BufferedWriter writer = Files.newBufferedWriter(path, StandardCharsets.UTF_8)) {
    writer.write("Hello");
}

Таблица типичных ошибок

Ошибка Последствия Решение
getBytes() без кодировки «Кракозябры» при смене ОС Всегда getBytes(StandardCharsets.UTF_8)
new String(bytes) без кодировки Невозможно восстановить исходный текст Всегда new String(bytes, StandardCharsets.UTF_8)
Путать str.length() с bytes.length Неправильная логика работы с данными "Привет".length() = 6, "Привет".getBytes(UTF_8).length = 12
Конвертация binary данных в String Потеря данных, corruption Для binary — используйте byte[]/ByteBuffer напрямую

Сравнение кодировок для конвертации

Кодировка Байт/символ (Latin) Байт/символ (кириллица) Когда использовать
UTF-8 1 2 Интернет, JSON, HTTP — стандарт де-факто
UTF-16 2 2 Внутренний формат Java, Windows API
Latin-1 1 N/A Western European only
Windows-1251 N/A 1 Legacy Windows-системы

Когда НЕ конвертировать String → byte[]

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

🔴 Senior Level

Internal Implementation

String.getBytes(Charset):

public byte[] getBytes(Charset charset) {
    if (charset == null) throw new NullPointerException();
    return StringCoding.encode(value, coder, charset);
}

StringCoding.encode — что происходит:

  1. Проверка coder (Latin-1 или UTF-16)
  2. Выбор алгоритма кодирования:
    • Latin-1 → UTF-8: 1 байт → 1 байт (ASCII), 1 байт → 2 байта (extended Latin-1, U+0080–U+00FF)
    • UTF-16 → UTF-8: 2 байта → 1–3 байта (зависит от code point)
  3. Для UTF-8: посимвольная конвертация через CharsetEncoder
  4. Аллокация нового byte[] с нужным размером

new String(byte[], Charset):

public String(byte[] bytes, Charset charset) {
    this(bytes, 0, bytes.length, charset);
}
// → StringCoding.decode → CharsetDecoder → byte[] value + coder
  1. CharsetDecoder декодирует байты в символы
  2. Определяется coder: если все символы в диапазоне U+0000–U+00FF → LATIN1, иначе → UTF16
  3. Невалидные байты → замена на \uFFFD (replacement character)
  4. Создаётся новый String с byte[] value + coder

Trade-offs кодировок

UTF-8:

  • Плюсы: Стандарт де-факто, ASCII-совместимая, variable-length (экономия для латиницы)
  • Минусы: Кириллица = 2 байта/символ, иероглифы = 3 байта, variable-length усложняет random access

UTF-16:

  • Плюсы: Фиксированный размер для BMP (2 bytes/char), внутренний формат Java (минимум конвертации)
  • Минусы: Endianness (LE vs BE), BOM, 2x размер для ASCII, не ASCII-совместимая

Latin-1 (ISO-8859-1):

  • Плюсы: 1 байт/символ, минимальный overhead, фиксированный размер
  • Минусы: Только 256 символов, кириллица в расширенном Latin-1, не Unicode

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

1. BOM (Byte Order Mark):

// UTF-8 файл с BOM: EF BB BF
byte[] bomUtf8 = {(byte)0xEF, (byte)0xBB, (byte)0xBF, 'H', 'i'};
String s = new String(bomUtf8, StandardCharsets.UTF_8);
// s.charAt(0) = '\uFEFF' — невидимый BOM символ!
// s.startsWith("Hi") → false!

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

if (bytes.length >= 3 && bytes[0] == (byte)0xEF && bytes[1] == (byte)0xBB && bytes[2] == (byte)0xBF) {
    s = new String(bytes, 3, bytes.length - 3, StandardCharsets.UTF_8);
}

2. Malformed input — невалидные UTF-8 байты:

byte[] malformed = {(byte) 0xFF, (byte) 0xFE, (byte) 0x80};
String s = new String(malformed, StandardCharsets.UTF_8);
// Невалидные последовательности → '\uFFFD' (replacement character)
// s = "\uFFFD\uFFFD\uFFFD"

Поведение управляется через CodingErrorAction:

CharsetDecoder decoder = StandardCharsets.UTF_8.newDecoder();
decoder.onMalformedInput(CodingErrorAction.REPORT);  // Бросает MalformedInputException
decoder.onMalformedInput(CodingErrorAction.IGNORE);  // Пропускает
decoder.onMalformedInput(CodingErrorAction.REPLACE); // По умолчанию → \uFFFD

3. Truncated multi-byte sequences:

// Кириллица 'А' = D0 90 в UTF-8
byte[] truncated = {(byte) 0xD0}; // Только первый байт
String s = new String(truncated, StandardCharsets.UTF_8);
// '\uFFFD' — неполная последовательность

Критично для streaming I/O: если буфер обрывается посередине multi-byte последовательности, нужно сохранить «хвост» и добавить к следующему буферу.

4. Security — encoding bypass:

// SQL injection через multi-byte encoding bypass
String input = "%C0%27 OR 1=1 --";
// В некоторых старых системах %C0%27 декодируется как одинарная кавычка
// Это обходит фильтры, которые проверяют input ДО декодирования
// Всегда валидируйте input ПОСЛЕ декодирования!

5. Surrogate pairs и длина байтов:

String emoji = "\uD83D\uDE00"; // "😀" — surrogate pair
emoji.length();                         // 2 (char)
emoji.getBytes(StandardCharsets.UTF_8).length; // 4 (bytes)
emoji.getBytes(StandardCharsets.UTF_16).length; // 6 (2 BOM + 4 data)
// StandardCharsets.UTF_16 включает BOM. UTF_16BE/UTF_16LE — без BOM, было бы 4 байта.
// Не используйте bytes.length для определения количества символов!

Производительность

Операция UTF-8 UTF-16 Latin-1
Encode 100 chars (Latin-1) ~100ns ~50ns ~20ns
Encode 100 chars (Cyrillic) ~200ns ~50ns N/A
Decode 200 bytes (UTF-8) ~150ns N/A N/A
Decode 200 bytes (UTF-16) N/A ~40ns N/A
Encode 10KB text (mixed) ~8μs ~3μs ~2μs

Аллокации:

  • getBytes(UTF_8): аллоцирует новый byte[] размера ~N–3N (зависит от содержимого)
  • new String(bytes, UTF_8): аллоцирует новый byte[] + объект String (~24 bytes overhead)
  • Для 1M конвертаций: ~100MB аллокаций → Young GC pressure

Thread Safety

  • StandardCharsets.UTF_8thread-safe (immutable singleton)
  • Charset.forName(...)thread-safe (кэширует результаты)
  • CharsetEncoder / CharsetDecoderNOT thread-safe! Один инстанс нельзя использовать из нескольких потоков одновременно
  • Решение: создавайте новый encoder/decoder на каждый вызов или используйте ThreadLocal<CharsetEncoder>

Production War Story

Сценарий 1: HTTP API — чтение request body (Spring Boot):

// Spring Boot — автоматически использует UTF-8
@PostMapping
public void handle(@RequestBody String body) { ... }

// Raw Servlet — нужно указать явно
request.setCharacterEncoding("UTF-8");
String body = request.getReader().readLine();

Проблема: клиент отправлял POST-запрос в Windows-1251, Spring читал как UTF-8 → «кракозябры». Fix: заголовок Content-Type: text/plain; charset=Windows-1251 или миграция клиента на UTF-8.

Сценарий 2: Kafka messages — сериализация/десериализация:

// Producer
byte[] bytes = jsonString.getBytes(StandardCharsets.UTF_8);
producer.send(new ProducerRecord<>(topic, keyBytes, bytes));

// Consumer
String message = new String(record.value(), StandardCharsets.UTF_8);

Проблема: один из продюсеров использовал getBytes() без кодировки (дефолт на Windows = Cp1251 (Windows, русская локаль)). Consumer на Linux читал как UTF-8 → corrupted messages. Fix: единый стандарт UTF-8 на уровне Kafka-контракта.

Сценарий 3: Highload парсер логов (500K lines/sec):

  • Конвертация каждой строки new String(bytes, UTF_8) → 500K аллокаций/sec
  • Young GC каждые 2 секунды, pause 15ms
  • Fix: zero-copy через ByteBuffer + кастомный parser, без конвертации в String
  • Результат: Young GC каждые 8 секунд, pause 5ms

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())"

# GC логи — аллокации от конвертации
java -Xlog:gc*:file=gc.log ...

# JFR — Object Allocation
java -XX:StartFlightRecording=filename=recording.jfr ...
# В JFR: Memory → Object Allocation → filter by java.lang.String

# JOL — размер String после конвертации
System.out.println(GraphLayout.parseInstance(s).toFootprint());
// Runtime-проверка
System.out.println(Charset.defaultCharset()); // Зависит от ОС!

// Бенчмарк конвертации
long start = System.nanoTime();
for (int i = 0; i < 1_000_000; i++) {
    byte[] b = str.getBytes(StandardCharsets.UTF_8);
}
long elapsed = System.nanoTime() - start;
System.out.println("1M encode: " + elapsed / 1_000_000 + "ms");

Best Practices для Highload

  • Всегда указывайте Charset — используйте StandardCharsets.UTF_8 (константа, без аллокаций на lookup)
  • Для JSON/XML/HTTP: UTF-8 — стандарт де-факто
  • Для binary protocols (Kafka, gRPC, TCP): работайте с byte[]/ByteBuf напрямую, без конвертации в String
  • Для streaming I/O: InputStreamReader/OutputStreamWriter с явным Charset — буферизация внутри
  • BOM handling: BOMInputStream (Apache Commons IO) или ручная проверка первых байт
  • Для ultra-low-latency: избегайте String — используйте ByteBuf (Netty), ByteBuffer, zero-copy подходы
  • Security: валидируйте input ПОСЛЕ декодирования, используйте constant-time сравнение для secrets
  • CharsetEncoder/CharsetDecoderне шарьте между потоками, используйте ThreadLocal или per-call создание
  • Для больших данных: streaming через Reader/Writer, не загружайте весь файл в byte[]

🎯 Шпаргалка для интервью

Обязательно знать:

  • str.getBytes(StandardCharsets.UTF_8) — правильный способ конвертации String → byte[]
  • new String(bytes, StandardCharsets.UTF_8) — правильный способ byte[] → String
  • getBytes() и new String(bytes) без кодировки — зависят от ОС, вызывают «кракозябры»
  • BOM в UTF-8 (3 байта EF BB BF) создаёт невидимый символ \uFEFF в начале строки
  • Невалидные UTF-8 байты заменяются на \uFFFD (replacement character)
  • CharsetEncoder/CharsetDecoder — NOT thread-safe, нужен ThreadLocal

Частые уточняющие вопросы:

  • Почему new String(bytes) без кодировки — это плохо? — Дефолтная кодировка зависит от ОС. String, закодированная на Windows (Cp1251), станет «кракозябрами» на Linux (UTF-8).
  • Как обработать BOM при чтении UTF-8 файла?BOMInputStream или проверка: если первые 3 байта EF BB BF, пропустить их.
  • Что будет при конвертации binary данных в String? — Потеря данных. Binary — работайте с byte[]/ByteBuffer напрямую.
  • Как ускорить конвертацию в highload? — Zero-copy: ByteBuffer, ByteBuf (Netty), streaming через Reader/Writer.

Красные флаги (НЕ говорить):

  • ❌ “getBytes() без кодировки — нормально” — зависит от ОС, ломается при миграции
  • ❌ “CharsetEncoder можно шарить между потоками” — NOT thread-safe
  • ❌ “BOM — это только проблема UTF-16” — UTF-8 тоже может иметь BOM
  • ❌ “Можно использовать bytes.length для определения количества символов” — для UTF-8 1 символ = 1-4 байта

Связанные темы:

  • [[17. Что такое String encoding (кодировка)]]
  • [[19. Что такое compact strings в Java 9+]]
  • [[20. Как узнать, сколько памяти занимает String]]