Питання 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]]
  • [[19. Що таке компактні рядки в Java 9+]]
  • [[20. Як дізнатися, скільки пам’яті займає String]]