Як правильно перетворити 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()поверне вже спотворені дані, і файл або ключ буде зіпсовано.
Правильні способи представлення бінарних даних у вигляді тексту:
- Base64 (для мережевих протоколів та JSON):
String base64 = Base64.getEncoder().encodeToString(binaryBytes); byte[] restored = Base64.getDecoder().decode(base64); - Шістнадцятковий рядок (Hex Format, Java 17+):
String hex = HexFormat.of().formatHex(binaryBytes); byte[] restored = HexFormat.of().parseHex(hex);
🔴 Senior Level
Навантаження на GC та продуктивність при конвертації
Кожна операція str.getBytes() та new String(bytes) виконує:
- Валідацію кодування та підрахунок результуючого розміру.
- Виділення нового масиву в Heap:
getBytes()завжди алокує масив байтів точної довжини, аnew String()алокує сам заголовокStringта внутрішній масивbyte[] value. - Побайтове копіювання даних із транскодуванням.
У високонавантажених сервісах (Highload, 100 000 RPS) безперервні виклики конвертації рядків у байти і назад створюють гігантський тиск на Garbage Collector (Young Gen Pressure), призводячи до частих збирань сміття.
Рішення для Highload та Zero-Copy:
- Використання Netty
ByteBufабо Java NIOByteBuffer: парсинг протоколів безпосередньо в байтах без створення проміжних об’єктів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")?
Відповідь:
- Відсутність перевірних винятків: Метод
str.getBytes("UTF-8")вимагає обов’язковогоtry-catchдляUnsupportedEncodingException, хоча UTF-8 гарантовано підтримується будь-якою JVM. - Продуктивність: Передача рядка
"UTF-8"змушує JVM при кожному виклику звертатися до внутрішньої хеш-таблиці кодувань (Charset.availableCharsets()) та виконувати регістронезалежний пошук імені. - Безпека типів:
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-інтерв’ю
StandardCharsets: Завжди передавати готові константиStandardCharsets.UTF_8(нуль оверхеду на lookup).- Довжина: Довжина в
char(str.length()) не дорівнює довжині масивуbyte[](кирилиця в UTF-8 = 2 байти, емодзі = 4 байти). - Бінарні дані: Використовувати
Base64абоHexFormat.of().formatHex(bytes), ніколи не використовуватиnew String(bytes). - Потокове декодування: При читанні чанків із сокетів використовувати
CharsetDecoderз обробкою незавершених багатобайтових послідовностей. - Безпека паролів: Уникати
Stringчерез неможливість затирання в купі; затирати масиви нулями одразу після перевірки.
Червоні прапорці (Чого говорити категорично не можна)
- ❌ Використовувати
str.getBytes()без аргументів. - ❌ Передавати назву кодування рядковим літералом
"UTF-8"і писатиcatch (UnsupportedEncodingException e). - ❌ Намагатися перетворювати бінарні дані (файли картинок, хеші) у
Stringчерез стандартний конструктор. - ❌ Вважати, що 1 літера завжди дорівнює 1 байту в UTF-8.