Кодирование в Java: как перевести строку в байты и обратно

Кодирование в Java: как перевести строку в байты и обратно Полезное

Кодирование строки в Java — это перевод текста (String) в массив байтов (byte[]) по правилам конкретной кодировки: text.getBytes(StandardCharsets.UTF_8). Обратная операция — декодирование: new String(bytes, StandardCharsets.UTF_8). Главное правило одно: кодировать и декодировать нужно одной и той же кодировкой и всегда указывать ее явно, иначе появляются «кракозябры».

Все примеры ниже проверены на Java 25 (Eclipse Temurin 25.0.4, Linux) 24.09.2026.

Три слоя: символ, код, байты

В теме легко смешать три разные вещи, поэтому сначала короткий словарь:

  • Кодовая точка (code point) — номер символа в Unicode, например буква «Я» = U+042F.
  • char в Java — 16-битная кодовая единица UTF-16. Большинство символов помещается в один char, но эмодзи и редкие иероглифы занимают два (суррогатная пара).
  • Байты — то, как кодовая точка записана в конкретной кодировке: «Я» в UTF-8 = D0 AF, в windows-1251 = DF.

Сквозная цепочка на одном примере: «Я» -> U+042F -> D0 AF в UTF-8. Строка String с точки зрения API — последовательность char (единиц UTF-16), и во внешние байты она превращается только в момент кодирования. Поэтому вопрос «сколько байтов занимает строка» без названной кодировки не имеет ответа. Подробнее про устройство самих кодировок — в статье о кодировках символов и формате UTF-8.

Минимальный рабочий пример

import java.nio.charset.StandardCharsets;
import java.util.HexFormat;

public class Main {
    public static void main(String[] args) {
        String text = "Привет, Java";

        byte[] bytes = text.getBytes(StandardCharsets.UTF_8);
        System.out.println("Символов (char): " + text.length());
        System.out.println("Байтов в UTF-8:  " + bytes.length);
        System.out.println("HEX: " + HexFormat.ofDelimiter(" ").formatHex(bytes));

        String back = new String(bytes, StandardCharsets.UTF_8);
        System.out.println("Обратно: " + back);
        System.out.println("Совпадает: " + back.equals(text));
    }
}

Вывод:

Символов (char): 12
Байтов в UTF-8:  18
HEX: d0 9f d1 80 d0 b8 d0 b2 d0 b5 d1 82 2c 20 4a 61 76 61
Обратно: Привет, Java
Совпадает: true

Разбор:

  • getBytes(StandardCharsets.UTF_8) кодирует строку. Кириллица в UTF-8 занимает по 2 байта, латиница, пробел и запятая — по 1, отсюда 6 x 2 + 6 = 18 байтов.
  • new String(bytes, StandardCharsets.UTF_8) декодирует байты обратно, строка совпадает с исходной.
  • HexFormat (есть с Java 17) печатает байты в шестнадцатеричном виде — так удобнее всего отлаживать кодировки.

Почему StandardCharsets, а не строка "UTF-8": перегрузка getBytes(String charsetName) объявляет проверяемое исключение. Код "Привет".getBytes("UTF-8") без try не компилируется:

Unsupported.java:3: error: unreported exception UnsupportedEncodingException; must be caught or declared to be thrown
        byte[] b = "Привет".getBytes("UTF-8");
                                    ^
1 error

С константой StandardCharsets.UTF_8 исключения нет, а опечатка в имени кодировки невозможна. Для кодировок вне StandardCharsets (например, windows-1251) используют Charset.forName("windows-1251").

Сколько байтов занимает символ

Размер зависит от символа и кодировки. Значения ниже получены в том же окружении через s.getBytes(cs):

Символ Кодовая точка length() UTF-8 UTF-16 UTF-16BE windows-1251
A U+0041 1 1: 41 4: fe ff 00 41 2: 00 41 1: 41
Я U+042F 1 2: d0 af 4: fe ff 04 2f 2: 04 2f 1: df
😀 U+1F600 2 4: f0 9f 98 80 6: fe ff d8 3d de 00 4: d8 3d de 00 1: 3f (это «?»)

Что видно из таблицы:

  • String.length() считает char, а не символы: у эмодзи длина 2. Число кодовых точек дает codePointCount(0, s.length()), но и это не всегда число видимых символов: «é» из буквы e и комбинирующего ударения U+0301 дает 2, эмодзи «семья» из трех человек через U+200D — 5 кодовых точек и 8 char. Видимые символы (графемы) считает java.text.BreakIterator.getCharacterInstance(): для «семьи» в нашем прогоне он вернул 1.
  • StandardCharsets.UTF_16 при кодировании добавляет метку порядка байтов FE FF (BOM), поэтому «A» занимает 4 байта. Если BOM не нужен, берут UTF_16BE или UTF_16LE — про порядок байтов подробно в статье о байтах, битах и их порядке.
  • Эмодзи нет в windows-1251, и getBytes молча заменил его на ? (байт 3F). Данные потеряны.

Еще одна частая ловушка: тип byte в Java знаковый (от -128 до 127). Первый байт буквы «Я» в UTF-8 (0xD0) при печати даст -48. Чтобы получить значение 0-255, используют b & 0xFF, в нашем прогоне это 208.

Откуда берутся кракозябры

Кракозябры (mojibake) появляются, когда байты записали в одной кодировке, а прочитали в другой. Сами байты при этом целы — неверна их интерпретация. Три типичных сценария:

import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;

public class Mojibake {
    public static void main(String[] args) {
        Charset cp1251 = Charset.forName("windows-1251");

        byte[] utf8 = "Привет".getBytes(StandardCharsets.UTF_8);
        System.out.println(new String(utf8, cp1251));

        byte[] win = "Привет".getBytes(cp1251);
        System.out.println(new String(win, StandardCharsets.UTF_8));

        byte[] ascii = "Привет".getBytes(StandardCharsets.US_ASCII);
        System.out.println(new String(ascii, StandardCharsets.US_ASCII));
    }
}

Вывод:

Привет
������
??????

Как читать симптомы:

Что видно Что произошло Как исправить
РџСЂ... (пары вида «Р?», «С?») UTF-8 байты прочитаны как windows-1251 Декодировать как UTF-8
������ (символ U+FFFD) Байты windows-1251 прочитаны как UTF-8 Декодировать как windows-1251
?????? Текст закодирован в кодировку без кириллицы Кодировать в UTF-8; прежний byte[] уже не восстановить

Первый случай обычно обратим: new String(bad.getBytes(cp1251), StandardCharsets.UTF_8) вернет «Привет». Граница — байт 0x98: в windows-1251 он не занят, Java декодирует его в U+FFFD, и такой символ (например, сербская «ј» = D1 98) уже не вернуть. Для русского текста в UTF-8 этот байт не встречается. Во втором и третьем случаях данные при декодировании или кодировании уже заменены на U+FFFD или ?, и восстановить их из строки нельзя — только из исходных байтов.

Кодировка по умолчанию: почему код «работает у меня»

Методы без явной кодировки — getBytes(), new String(bytes), new InputStreamReader(in), FileReader — берут кодировку по умолчанию. С Java 18 (JEP 400) это UTF-8 на всех платформах. До Java 18 она определялась системой: на русской Windows это обычно windows-1251, на Linux и macOS — как правило UTF-8. Отсюда классика: файл, записанный на сервере, читается с кракозябрами на рабочей машине со старой JDK.

Проверить окружение можно так:

import java.nio.charset.Charset;

public class Defaults {
    public static void main(String[] args) {
        System.out.println("java.version:     " + System.getProperty("java.version"));
        System.out.println("defaultCharset:   " + Charset.defaultCharset());
        System.out.println("file.encoding:    " + System.getProperty("file.encoding"));
        System.out.println("native.encoding:  " + System.getProperty("native.encoding"));
        System.out.println("stdout.encoding:  " + System.getProperty("stdout.encoding"));
    }
}

В контейнере с локалью LANG=C вывод такой:

java.version:     25.0.4
defaultCharset:   UTF-8
file.encoding:    UTF-8
native.encoding:  ANSI_X3.4-1968
stdout.encoding:  ANSI_X3.4-1968

Кодировка по умолчанию — UTF-8, а вывод в консоль (stdout.encoding, появилось в Java 19) идет в кодировке системы, здесь ASCII. В такой среде Main печатает ???????? (char): 12, хотя строки внутри программы правильные. Это проблема вывода, а не данных: помогает UTF-8 локаль терминала или флаг java -Dstdout.encoding=UTF-8 Main. На Windows аналогичная ситуация бывает в консоли с кодовой страницей 866 или 1251.

Практический вывод: даже на Java 18+ указывайте кодировку явно. Код читается однозначно и не сломается, если его запустят на старой JDK или с -Dfile.encoding=COMPAT.

Строгое декодирование: ошибка вместо «�»

new String(bytes, UTF_8) никогда не бросает исключение на битых данных — он молча вставляет U+FFFD. Для входных данных извне (файлы, сеть, импорт) лучше узнать о проблеме сразу. Для этого используют CharsetDecoder в режиме REPORT:

import java.nio.ByteBuffer;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.Charset;
import java.nio.charset.CharsetDecoder;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;

public class Strict {
    static String decodeStrict(byte[] data) throws CharacterCodingException {
        CharsetDecoder decoder = StandardCharsets.UTF_8.newDecoder()
                .onMalformedInput(CodingErrorAction.REPORT)
                .onUnmappableCharacter(CodingErrorAction.REPORT);
        return decoder.decode(ByteBuffer.wrap(data)).toString();
    }

    public static void main(String[] args) {
        byte[] good = "Привет".getBytes(StandardCharsets.UTF_8);
        byte[] bad = "Привет".getBytes(Charset.forName("windows-1251"));
        try {
            System.out.println("good: " + decodeStrict(good));
            System.out.println("bad:  " + decodeStrict(bad));
        } catch (CharacterCodingException e) {
            System.out.println("Ошибка: " + e);
        }
    }
}
good: Привет
Ошибка: java.nio.charset.MalformedInputException: Input length = 1

Для файлов то же поведение дает Files.readString(path): он читает в UTF-8 и бросает MalformedInputException на невалидных байтах, а не подменяет их.

Потоки: не декодируйте байты кусками

Когда данные приходят блоками (сеть, буфер файла), граница блока может разрезать многобайтовый символ. Если декодировать каждый блок отдельно через new String, символ на стыке сломается:

import java.io.ByteArrayInputStream;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;

public class Chunks {
    public static void main(String[] args) throws IOException {
        byte[] data = "Привет".getBytes(StandardCharsets.UTF_8);

        // Неверно: декодируем куски по 5 байт по отдельности
        StringBuilder wrong = new StringBuilder();
        for (int i = 0; i < data.length; i += 5) {
            byte[] part = Arrays.copyOfRange(data, i, Math.min(i + 5, data.length));
            wrong.append(new String(part, StandardCharsets.UTF_8));
        }
        System.out.println("По кускам: " + wrong);

        // Верно: Reader сам собирает символ из соседних кусков
        InputStream in = new ByteArrayInputStream(data);
        try (BufferedReader reader = new BufferedReader(
                new InputStreamReader(in, StandardCharsets.UTF_8))) {
            System.out.println("Через Reader: " + reader.readLine());
        }
    }
}
По кускам: Пр��вет
Через Reader: Привет

Буква «и» (D0 B8) попала на границу блоков и превратилась в два U+FFFD. InputStreamReader с явной кодировкой хранит незавершенный символ до следующего блока. Для текстовых файлов проще всего Files.readString и Files.newBufferedReader(path, StandardCharsets.UTF_8).

Двоичные данные — не через String

Хеши, ключи, изображения и зашифрованные данные — это не текст. Если прогнать их через new String(...), невалидные для UTF-8 байты заменятся, и массив не восстановится:

import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Base64;

public class Binary {
    public static void main(String[] args) {
        byte[] raw = {(byte) 0xFF, (byte) 0xFE, 0x41};

        String asText = new String(raw, StandardCharsets.UTF_8);
        byte[] broken = asText.getBytes(StandardCharsets.UTF_8);
        System.out.println("Через String: " + Arrays.toString(broken));

        String b64 = Base64.getEncoder().encodeToString(raw);
        byte[] restored = Base64.getDecoder().decode(b64);
        System.out.println("Base64: " + b64 + " -> " + Arrays.toString(restored));
    }
}
Через String: [-17, -65, -67, -17, -65, -67, 65]
Base64: //5B -> [-1, -2, 65]

Вместо 3 исходных байтов получилось 7: каждый «плохой» байт стал EF BF BD (U+FFFD в UTF-8). Для передачи байтов в текстовом виде используют Base64 или hex (HexFormat).

Если не получилось: диагностика по симптому

  1. Видите РџСЂ... и пары вида «Р?», «С?» — UTF-8 прочитан как windows-1251. Найдите место без явной кодировки (new String(bytes), FileReader, InputStreamReader без второго аргумента).
  2. Видите � — байты не являются валидным UTF-8. Выясните реальную кодировку источника, а не «чините» строку.
  3. Видите ? вместо букв — текст закодирован в кодировку без нужных символов, либо консоль не умеет UTF-8. Проверьте stdout.encoding и кодировку записи.
  4. Кракозябры в строковых литералах прямо в коде — исходник сохранен не в той кодировке, что ожидает javac. С JDK 18 javac по умолчанию читает UTF-8; на старых версиях задайте javac -encoding UTF-8 или <project.build.sourceEncoding> в Maven.
  5. Проверяйте байты, а не вывод: HexFormat.ofDelimiter(" ").formatHex(bytes) покажет, что на самом деле лежит в массиве.

Выводы

  • Строка в байты: getBytes(StandardCharsets.UTF_8), байты в строку: new String(bytes, StandardCharsets.UTF_8) — с одной и той же кодировкой.
  • Кодировку указывайте явно даже на Java 18+, где по умолчанию UTF-8: код станет переносимым между JDK и ОС.
  • length() считает char (единицы UTF-16), а не символы и не байты; размер в байтах зависит от кодировки.
  • Кракозябры вида РџСЂ... для русского текста обычно обратимы, � и ? — уже потеря данных; для внешних данных включайте строгое декодирование.
  • Двоичные данные не превращают в String — только Base64 или hex.

Где применяется / связь с практикой

Освойте тему на практике

С байтами и кодировками разработчик сталкивается постоянно: чтение файлов и CSV, HTTP-запросы, работа с базами данных, сокеты, логи. Ошибка в одной строке с new String(bytes) без кодировки может неделями портить данные, пока кто-то не запустит сервис на другой ОС. Работу со строками, вводом-выводом и стандартной библиотекой по шагам разбирают на курсе «Java разработчик. Базовый уровень». Посмотреть формат занятий можно на открытых уроках Otus.

FAQ

Чем Charset отличается от кодировки?
В Java класс Charset описывает конкретную схему кодирования (UTF-8, windows-1251): он умеет и кодировать, и декодировать. Абстрактный набор символов (Unicode) и способ записать его в байты (UTF-8, UTF-16) — разные уровни, но в API Java оба называются charset.

Как обрезать строку до N байтов в UTF-8, не разрезав символ?
Кодировать по одному символу (по кодовым точкам) и останавливаться, пока сумма байтов не превысит лимит, либо использовать CharsetEncoder с CharBuffer и ByteBuffer нужного размера: он запишет только целые символы.

Нужен ли BOM в UTF-8 файлах для Java?
Нет. Java при чтении UTF-8 не удаляет BOM (EF BB BF) автоматически, и в начале первой строки окажется символ U+FEFF. Если файлы приходят с BOM, его нужно пропустить вручную.

OTUS Журнал
Скидка 5% 12-19 октября на курсы (popup)
Бесплатные открытые уроки (поп-ап)