🔤 Раздел 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.

Связанные вопросы