🔤 Розділ 12 · Питання #18

Як правильно перетворити String в byte[] і назад

Конвертація рядка в масив байтів (String $\to$ byte[]) та відновлення рядка з байтів (byte[] $\to$ String) — базова операція при роботі з мережею, базами даних, шифруванням та ф...


🟢 Junior Level

Конвертація рядка в масив байтів (String $\to$ byte[]) та відновлення рядка з байтів (byte[] $\to$ String) — базова операція при роботі з мережею, базами даних, шифруванням та файловою системою.

Головне правило

ЗАВЖДИ явно вказуйте кодування через константи класу java.nio.charset.StandardCharsets! Ніколи не використовуйте методи без параметрів кодування.

import java.nio.charset.StandardCharsets;

String original = "Привіт, світ! 🚀";

// 1. Правильна конвертація String -> byte[]
byte[] bytes = original.getBytes(StandardCharsets.UTF_8);

// 2. Правильна конвертація byte[] -> String
String restored = new String(bytes, StandardCharsets.UTF_8);

System.out.println(restored); // "Привіт, світ! 🚀"

Чому не можна викликати str.getBytes() без аргументів?

Методи без аргументів використовують системне кодування за замовчуванням. До Java 18 на Windows це було кодування Windows-1251 (або Cp1252), а на Linux — UTF-8. У результаті файл або мережевий пакет, сформований на Windows, при читанні на сервері під Linux перетворювався на нечитабельні символи («кракозябри»).


🟡 Middle Level

Способи вказання кодування: чому StandardCharsets краще

У Java існує три способи передачі кодування, але тільки один із них вважається еталонним:

// ❌ 1. Застарілий і небезпечний спосіб (строковий літерал):
// Вимагає перехоплення перевірного винятку UnsupportedEncodingException!
// При кожному виклику виконує пошук Charset за строковим ім'ям у Map.
byte[] b1 = str.getBytes("UTF-8"); 

// 🟡 2. Динамічний пошук:
// Не кидає перевірних винятків, але витрачає такти CPU на lookup у кеші
byte[] b2 = str.getBytes(Charset.forName("UTF-8"));

// ✅ 3. Рекомендований стандарт (StandardCharsets):
// Статична константа-синглтон. Нуль алокацій, нуль пошуків, 100% compile-time безпека.
byte[] b3 = str.getBytes(StandardCharsets.UTF_8);
String s3 = new String(b3, StandardCharsets.UTF_8);

Різниця між довжиною рядка та довжиною масиву байтів

Критична помилка інженерів-початківців — припускати, що довжина рядка дорівнює кількості байтів:

String text = "Привіт";

System.out.println(text.length());                               // 6 (символів)
System.out.println(text.getBytes(StandardCharsets.UTF_8).length); // 12 (байтів!)

У кодуванні UTF-8 кириличні символи займають по 2 байти кожен, а емодзі — по 4 байти. Тому розмір мережевого буфера або стовпця в СУБД завжди потрібно розраховувати за довжиною масиву byte[], а не за str.length().

Конвертація бінарних даних: чому String заборонено

Якщо у вас є сирі бінарні дані (криптографічний хеш SHA-256, масив байтів зображення, зашифрований ключ AES), їх категорично не можна перетворювати у String через new String(bytes, StandardCharsets.UTF_8):

  • В UTF-8 є невалідні послідовності байтів, які декодер мовчки перетворить на символ заміни \uFFFD.
  • Зворотний виклик getBytes() поверне вже спотворені дані, і файл або ключ буде зіпсовано.

Правильні способи представлення бінарних даних у вигляді тексту:

  1. Base64 (для мережевих протоколів та JSON):
    String base64 = Base64.getEncoder().encodeToString(binaryBytes);
    byte[] restored = Base64.getDecoder().decode(base64);
    
  2. Шістнадцятковий рядок (Hex Format, Java 17+):
    String hex = HexFormat.of().formatHex(binaryBytes);
    byte[] restored = HexFormat.of().parseHex(hex);
    

🔴 Senior Level

Навантаження на GC та продуктивність при конвертації

Кожна операція str.getBytes() та new String(bytes) виконує:

  1. Валідацію кодування та підрахунок результуючого розміру.
  2. Виділення нового масиву в Heap: getBytes() завжди алокує масив байтів точної довжини, а new String() алокує сам заголовок String та внутрішній масив byte[] value.
  3. Побайтове копіювання даних із транскодуванням.

У високонавантажених сервісах (Highload, 100 000 RPS) безперервні виклики конвертації рядків у байти і назад створюють гігантський тиск на Garbage Collector (Young Gen Pressure), призводячи до частих збирань сміття.

Рішення для Highload та Zero-Copy:

  • Використання Netty ByteBuf або Java NIO ByteBuffer: парсинг протоколів безпосередньо в байтах без створення проміжних об’єктів String.
  • Використання потокового введення-виведення (Reader / Writer) замість завантаження файлу цілком у пам’ять:
    // Читання без проміжного створення byte[] усього файлу
    try (BufferedReader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) {
        String line;
        while ((line = reader.readLine()) != null) { ... }
    }
    

Потокове декодування та розрив багатобайтових послідовностей

У мережевому програмуванні (TCP/Sockets) дані надходять фрагментами (чанками). Якщо 2-байтовий символ кирилиці (наприклад, літера 'Д' = 0xD0, 0x94) випадково опиниться на межі двох TCP-пакетів (перший пакет закінчується байтом 0xD0, а другий починається байтом 0x94):

  • Виклик new String(packet1, UTF_8) завершиться помилкою: байт 0xD0 перетвориться на символ заміни \uFFFD.
  • Виклик new String(packet2, UTF_8) аналогічно зіпсує 0x94.

Рішення: Використання CharsetDecoder зі збереженням стану:

CharsetDecoder decoder = StandardCharsets.UTF_8.newDecoder()
    .onMalformedInput(CodingErrorAction.REPORT)
    .onUnmappableCharacter(CodingErrorAction.REPORT);

ByteBuffer in = ByteBuffer.allocate(1024);
CharBuffer out = CharBuffer.allocate(1024);

// Метод decode зберігає незавершені байти всередині свого стану
// до надходження наступної порції даних (endOfInput = false)
decoder.decode(in, out, false);

Безпека: паролі та вразливості в пам’яті

Конвертація паролів із мережевого запиту в String — серйозна вразливість безпеки:

  • Рядок String є імутабельним і не може бути примусово стертий із пам’яті. Він залишається в Java Heap доти, доки збирач сміття не видалить його, і може бути прочитаний зловмисником через Heap Dump.
  • Best Practice: Паролі та секретні ключі повинні зберігатися виключно у вигляді char[] або byte[]. Одразу після перевірки хешу масив зобов’язаний затиратися нулями:
    byte[] passwordBytes = readPasswordFromNetwork();
    try {
        authenticate(passwordBytes);
    } finally {
        Arrays.fill(passwordBytes, (byte) 0); // Гарантоване знищення в пам'яті
    }
    

4 Tricky Questions

1. Чому виклик StandardCharsets.UTF_8 кращий за str.getBytes("UTF-8") та Charset.forName("UTF-8")?

Відповідь:

  1. Відсутність перевірних винятків: Метод str.getBytes("UTF-8") вимагає обов’язкового try-catch для UnsupportedEncodingException, хоча UTF-8 гарантовано підтримується будь-якою JVM.
  2. Продуктивність: Передача рядка "UTF-8" змушує JVM при кожному виклику звертатися до внутрішньої хеш-таблиці кодувань (Charset.availableCharsets()) та виконувати регістронезалежний пошук імені.
  3. Безпека типів: StandardCharsets.UTF_8 — це готова статична константа типу Charset. Вона передає посилання на вже створений синглтон напряму за 1 машинний такт без алокацій і пошуку.

2. Що поверне new String(new byte[] {(byte) 0xD0}, StandardCharsets.UTF_8) і як налаштувати JVM, щоб викидався виняток замість підміни даних?

Відповідь: Конструктор new String(bytes, charset) за замовчуванням використовує політику CodingErrorAction.REPLACE. Оскільки байт 0xD0 є першим байтом 2-байтової послідовності UTF-8, а другого байта немає, метод розцінить його як помилку і поверне рядок, що містить один символ заміни \uFFFD (символ ). Жодного винятку викинуто не буде. Щоб у разі некоректних байтів викидався виняток java.nio.charset.MalformedInputException, необхідно використовувати CharsetDecoder:

CharsetDecoder decoder = StandardCharsets.UTF_8.newDecoder()
    .onMalformedInput(CodingErrorAction.REPORT);
CharBuffer result = decoder.decode(ByteBuffer.wrap(bytes));

3. Чому для конвертації масиву байтів хешу SHA-256 у рядок не можна використовувати new String(hashBytes, StandardCharsets.UTF_8)?

Відповідь: Тому що хеш SHA-256 — це псевдовипадковий набір із 32 байтів, де кожен байт може приймати будь-яке значення від 0x00 до 0xFF. Стандарт UTF-8 суворо регламентує допустимі послідовності: наприклад, байти з 0xF5 по 0xFF взагалі заборонені, а байти від 0x80 до 0xBF можуть бути лише продовженням багатобайтових символів. При декодуванні через UTF-8 декодер вважатиме такі байти сміттям і замінить їх на \uFFFD. Відновити вихідний масив байтів хешу через str.getBytes() стане неможливо. Бінарні дані повинні конвертуватися у String виключно через Base64 або Hex (HexFormat).

4. У чому перевага класу java.util.HexFormat (що з’явився в Java 17) перед String.format("%02x") при конвертації байтів у рядок?

Відповідь:

  • Метод String.format("%02x", b) всередині парсить рядок формату, використовує рефлексію, створює об’єкт Formatter та виконує автобоксинг примітивного байта в Byte, що робить його вкрай повільним (мікросекунди на операцію).
  • Клас HexFormat (Java 17+) спроєктований спеціально для високошвидкісної конвертації: він імутабельний, потокобезпечний, використовує прямі бітові зсуви (>> 4) та внутрішні таблиці символів, працюючи в десятки разів швидше і створюючи мінімальну кількість сміття в купі.

🎯 Шпаргалка для інтерв’ю

30-секундна відповідь

«Для конвертації String у byte[] використовується str.getBytes(StandardCharsets.UTF_8), а назад — new String(bytes, StandardCharsets.UTF_8). Виклики без явного вказання кодування суворо заборонені, оскільки залежать від платформи. Для бінарних даних (хеші, зашифровані дані) конвертація у String через текстові кодування неприпустима — слід використовувати Base64 або HexFormat (Java 17+). Паролі не варто переводити у String з міркувань безпеки (їх тримають у byte[] або char[] з наступним обнуленням через Arrays.fill())».

Ключові тези для Senior-інтерв’ю

  1. StandardCharsets: Завжди передавати готові константи StandardCharsets.UTF_8 (нуль оверхеду на lookup).
  2. Довжина: Довжина в char (str.length()) не дорівнює довжині масиву byte[] (кирилиця в UTF-8 = 2 байти, емодзі = 4 байти).
  3. Бінарні дані: Використовувати Base64 або HexFormat.of().formatHex(bytes), ніколи не використовувати new String(bytes).
  4. Потокове декодування: При читанні чанків із сокетів використовувати CharsetDecoder з обробкою незавершених багатобайтових послідовностей.
  5. Безпека паролів: Уникати String через неможливість затирання в купі; затирати масиви нулями одразу після перевірки.

Червоні прапорці (Чого говорити категорично не можна)

  • ❌ Використовувати str.getBytes() без аргументів.
  • ❌ Передавати назву кодування рядковим літералом "UTF-8" і писати catch (UnsupportedEncodingException e).
  • ❌ Намагатися перетворювати бінарні дані (файли картинок, хеші) у String через стандартний конструктор.
  • ❌ Вважати, що 1 літера завжди дорівнює 1 байту в UTF-8.

Пов’язані питання