Декодирование Base64 в Java: полное руководство
Оно приезжает в тикете поддержки, в ответе API, в секрете Kubernetes или запрятанным посреди URL: длинная полоса букв и цифр, где изредка мелькают +, /, - или _, а на хвосте, может быть, болтаются один-два знака =. Кто-то говорит, что это Base64 и что внутри то, что вам нужно: пароль, JSON-нагрузка, сертификат, фото. Это руководство - рецепт на Java, как вернуть всё обратно. Короткая разминка, потому что домашняя страница подробно разбирает формат: Base64 переписывает каждые три байта данных в четыре символа, взятых из алфавита на 64 буквы, и прицепляет к концу один-два заполнителя =, когда последний кусок короче. Декодирование - это сокращающееся направление этой сделки: четыре символа входят, три байта выходят, поэтому результату всегда нужно примерно на четверть меньше места, чем вводу.
А теперь главная новость, и она радостная. С 18 марта 2014 года каждый JDK поставляется со всем готовым инструментарием Base64 в стандартной библиотеке: java.util.Base64. Никакого скачивания, никакой Maven-координаты, никакой нативной библиотеки. Один импорт, семь фабричных методов, три алфавита - и одно и то же поведение от Java 8 до сегодняшней Java 26. Всё в этой статье построено на этом одном классе.
Одна честная граница перед стартом: здесь мы на стороне декодера. Вы научитесь подбирать правильный декодер под тот алфавит, с которым сталкиваетесь, читать сообщения об ошибках JDK так, как врач читает снимок, превращать байты в текст без кракозябр, снимать PEM-броню, пропускать многогигабайтные нагрузки через поток и замечать ловушки безопасности, которые этот формат тихо подкладывает под ноги. Другое направление - укладывать байты в строку - получит своё руководство, и ссылка на него будет в конце этой статьи.
Что у вас уже есть
Установка Base64 в Java - это ответ в одну строку, который вы даёте у доски: «Оно в JDK.» Класс java.util.Base64 входит в модуль java.base с версии 1.8, и в 2026 году в его javadoc всё ещё написано Since: 1.8. Единственное, что вы устанавливаете, - это JDK: подойдёт любая Java 8 и новее от любого вендора (Oracle, Eclipse Temurin, Amazon Corretto, Zulu), а на машине на базе Debian это одна команда:
sudo apt install openjdk-17-jdk-headless
API устроено как фабрика: декодер вы никогда не конструируете, вы просите у класса один. Семь фабричных методов выдают по три характера в каждом направлении, а сторона декодеров выглядит так:
| Фабричный метод | Алфавит | Характер | Когда тянуться за ним |
|---|---|---|---|
getDecoder() |
A-Z a-z 0-9 + / |
Строгий: отклоняет любой посторонний символ | Данные, которые вы производите или контролируете |
getUrlDecoder() |
A-Z a-z 0-9 - _ |
Строгий, URL-безопасный алфавит | JWT, токены, ID, всё, что родилось в URL |
getMimeDecoder() |
A-Z a-z 0-9 + / |
Снисходительный: пропускает любой символ вне алфавита | Почта, по-настоящему перенесённый по строкам ввод, PEM-тела |
getEncoder(), getUrlEncoder(), getMimeEncoder() |
как выше | Кодирование, территория сестры-руководства | Каждый раз, когда вы производите Base64, а не читаете его |
Три свойства возвращаемых экземпляров стоит выучить наизусть. Первое: они потокобезопасны - в javadoc сказано, что экземпляры «безопасны для использования несколькими параллельными потоками», а исходный код показывает, что фабричные методы при каждом вызове возвращают один и тот же общий экземпляр, так что Base64.getDecoder() == Base64.getDecoder() - правда. Создайте один декодер в статическом поле и делитесь им на весь сервис: вы даже ничего не копируете. Второе: между вызовами они не держат состояния, так что сбрасывать нечего и синхронизировать нечего. Третье: передача null там, где ожидается массив байтов или строка, - это не мягкое «ничего не происходит», а NullPointerException, ровно то, что обещает javadoc класса.
В кодовых базах вы всё ещё будете встречать старые библиотеки, поэтому держите под рукой краткую карту местности. Apache Commons Codec (сейчас 1.22.1) носит собственную org.apache.commons.codec.binary.Base64 с версии 1.0, с API Builder, которое выставляет в виде регуляторов политику «строгий или снисходительный», длину строки и разделитель; это правильный инструмент только если вам нужна поддержка JVM до Java 8 или его помощники для проверки формы. Guava отгружает com.google.common.io.BaseEncoding, ветерана с сопоставимыми силами, всё ещё популярного в стеках больших данных. Для всего, что крутится на современной JVM, java.util.Base64 - выбор по умолчанию: ноль зависимостей, и бенчмарки комьюнити неизменно находят его самым быстрым из всех (об этом подробнее в разделе о производительности).
Декодируем первую строку
Девяносто процентов жизни декодирования умещается в несколько строк. Вот весь обряд, на самом маленьком примере, которым сам RFC объясняет алфавит:
import java.nio.charset.StandardCharsets;
import java.util.Base64;
public class FirstDecode {
public static void main(String[] args) {
byte[] bytes = Base64.getDecoder().decode("TWFu");
String text = new String(bytes, StandardCharsets.UTF_8);
System.out.println(text); // Man
}
}
Четыре предложения о том, что только что произошло. Первое: точка входа - это экземпляр, а не класс: decode() живёт на объекте Base64.Decoder, который вы получили от фабрики. Второе, и это самое важное проектное решение во всём API: результат - это массив байтов, никогда не String. Нагрузка может быть фразой, JPEG или хэшем, и до того, как вы поймёте, что у вас в руках, не следует обращаться со всеми ними одинаково, поэтому JDK намеренно останавливается на байтах. Третье: прыжок из байтов в текст - это отдельный, продуманный шаг с явной кодировкой, и именно на этом шаге «café» превращается в кракозябры, если вы невнимательны; раздел о кодировке ниже посвящён ему. Четвёртое: пустая строка - значение первого класса: Base64.getDecoder().decode("") даёт вам массив нулевой длины, без исключений и без торжественности.
Для тестовых данных в голове запомните: TWFu - это собственный дымовой тест стандарта: если ваш код декодирования превращает её в Man, машина честна. Обратный путь в другую сторону - это две строки того же API, и полный разбор его ждёт в руководстве по кодированию, ссылка на которое будет в конце.
Команда декодеров
Java не даёт вам одного декодера; она даёт три, и разница между ними - это решение о политике: какой алфавит принимать и сколько бардака терпеть. Все три - экземпляры одного вложенного класса Base64.Decoder. Javadoc класса формулирует раскол одним предложением на каждый характер. Для базового и URL-безопасного декодеров: декодер «отклоняет данные, содержащие символы вне алфавита base64». Для MIME-декодера: «все разделители строк или прочие символы, не найденные в таблице алфавита base64, игнорируются при декодировании». Это второе предложение - вся MIME-история в одной строке, и у неё есть клыки, потому что «игнорируются» означает всё, что не является символом алфавита, а не только переводы строк.
Правило выбора короткое. По умолчанию - getDecoder(). Если значение пришло из URL, из токена или из API, которое пообещало URL-безопасность, переключитесь на getUrlDecoder(). Только когда вы по-настоящему ожидаете MIME-ввода (переводы строк каждые 76 символов, прямиком из почтовой системы), тянитесь за getMimeDecoder(). В сомнении выбирайте строгость: работа строгого декодера - сделать так, чтобы сюрпризы падали, и именно этого вы хотите на границе доверия. Снисходительный декодер, с другой стороны, - это лупа для порчи: строка с чужими символами декодируется во что-то правдоподобное и неправильное, без единой ошибки.
Читаем жалобы декодера
Строгие декодеры падают громко, и падают точно. Каждая плохая строка выбрасывает IllegalArgumentException, чьё сообщение точно говорит, что пошло не так, поэтому в первый раз, когда продакшен-строка взрывается, вы читаете вот эту таблицу. Сообщения ниже - точная формулировка актуального JDK:
| Ввод (в getDecoder, если не указано иное) | Что не так | Точное сообщение |
|---|---|---|
"SGVs bG8s" |
пробрался пробел | Illegal base64 character 20 |
"SGVs\nbG8s" |
пробрался перевод строки | Illegal base64 character a |
"SGVs$bG8s" |
знак доллара не входит в алфавит | Illegal base64 character 24 |
"SGVsbG8-" |
URL-безопасный дефис в стандартном декодере | Illegal base64 character 2d |
"ab+c" в getUrlDecoder() |
знак плюс в URL-безопасном декодере | Illegal base64 character 2b |
"S" |
одним символом не собрать байт | Input byte[] should at least have 2 bytes for base64 bytes |
"SG=VsbG8s" |
заполнитель посреди данных | Input byte array has wrong 4-byte ending unit |
"Zm8==" |
два заполнителя там, где положен один | Input byte array has incorrect ending byte at 4 |
"Z=" |
один символ, а за ним сразу заполнитель | Last unit does not have enough valid bits |
"SGVsbG8sIHdvcmxkIQ==xx" |
мусор после заполнителей | Input byte array has incorrect ending byte at 20 |
Шестнадцатеричное число в сообщении - это байтовое значение виновного символа, напечатанное через Integer.toString(byte, 16): 20 - это пробел, a - перевод строки, d - возврат каретки, 24 - знак доллара, 2d - URL-безопасный дефис, 2b - плюс, 2f - слэш, 5f - подчёркивание. Две странности, которые стоит держать в кармане. Первая: сообщение может стать отрицательным: подайте декодеру строку с é, и он посетует Illegal base64 character -17, потому что символ сначала отображается в байт Latin-1 0xE9, который как знаковый байт Java равен минус 23, а минус 23 в шестнадцатеричной записи - это минус 17. Ваш логгер ошибок на короткое время занимается знаковой арифметикой. Второе: позиция. В семействе incorrect ending byte at N N - это индекс с нуля первого байта, который декодер не смог осмыслить, а это подарок, когда вы ищете место порчи в нагрузке методом половинного деления.
Одна смена костюма, о которой стоит знать: когда декодирование идёт через обёрнутый поток (вариант wrap(InputStream), будет разобран ниже), те же проблемы проступают как IOException с префиксом 0x: Illegal base64 character 0x20 (актуальные JDK; поточный декодер JDK 8 печатает значение из таблицы, -1, а не байт). Та же проблема, другое исключение, чуть другая запись. А снисходительный MIME-декодер, конечно, не жалуется ни на что из этого: он просто пропускает. В этом и есть цена снисходительного характера.
Правила заполнителя
Каждая Base64-строка в дикой природе молча даёт обещание о заполнителе, и обещание Java неожиданно дружелюбно. Javadoc декодера говорит об этом точно: символ заполнителя = «принимается и интерпретируется как конец закодированных байтовых данных, но не обязателен». Финальная единица из двух или трёх символов декодируется так, как будто она была дополнена, а если заполнители есть, их должно быть ровно нужное количество. Поведение актуального JDK на классических примерах:
| Ввод | Результат |
|---|---|
"" |
пустой массив байтов, без ошибки |
"Zm8" |
"fo", заполнитель просто отсутствует |
"Zm8=" |
"fo", каноническая запись |
"Zm8==" |
IllegalArgumentException: неверный конечный байт на позиции 4 |
"Zm9v=" |
IllegalArgumentException: неверная 4-байтовая конечная единица |
"Zg==" |
"f", один байт |
"Z=" |
IllegalArgumentException: в последней единице недостаточно валидных бит |
"AA==" |
ровно один байт, NUL-байт 0x00 |
"AAAA" |
три NUL-байта |
Прочитайте эту таблицу дважды. Пустая строка декодируется в ничто, а AA== декодируется в единственный NUL-байт: в Base64 «ничто» и «ноль» - разные существа, и оба - совершенно валидный ввод. И заполнитель, если есть, должен быть точным: Zm8= - правильно, Zm8== - неправильно, Zm9v= - неправильно, а заполнитель посреди строки - неправильно. Практическое следствие для ваших собственных протоколов: выберите одну запись (с заполнителем или без) и держите её на обоих концах, потому что значение, которое может приехать в двух записях, - это значение, которое где-то по дороге сломает наивную проверку на равенство.
base64url: алфавит, созданный для URL
Стандартный Base64 заканчивает свой алфавит символами + и /, и это ровно те два символа, которые плохо себя ведут в URL: + в строке запроса - уже пробел, ещё до того, как Java его увидит, / - разделитель путей, а свисающий = просится в процентное кодирование и превращается в трёхсимвольного монстра. Раздел 5 RFC 4648 рисует исправление: безопасный для URL и имён файлов алфавит, где + становится -, / становится _, а хвостовой заполнитель = обычно отбрасывается, когда длина известна неявно. RFC непреклонно настаивает на названии: эту кодировку «не следует считать той же, что base64-кодирование». Вы встретите её как base64url, и именно в ней живут JSON Web Tokens, параметры state OAuth, сессионные ID API и одиннадцатисимвольные ID видео.
Самая знаменитая base64url-нагрузка в вебе - это JWT, и заглянуть внутрь неё - дело трёх строк. Части токена по конвенции не дополняются, и URL-декодер этому рад, потому что заполнитель принимается, но не обязателен, помните:
import java.nio.charset.StandardCharsets;
import java.util.Base64;
public class JwtPeek {
public static void main(String[] args) {
String token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
+ ".eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ"
+ ".SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
String[] parts = token.split("\\.");
byte[] header = Base64.getUrlDecoder().decode(parts[0]);
byte[] payload = Base64.getUrlDecoder().decode(parts[1]);
System.out.println(new String(header, StandardCharsets.UTF_8));
// {"alg":"HS256","typ":"JWT"}
System.out.println(new String(payload, StandardCharsets.UTF_8));
// {"sub":"1234567890","name":"John Doe","iat":1516239022}
}
}
Здесь живут два честных дисклеймера. Первое: декодировать JWT - значит заглянуть, а не доверять: третья часть - это подпись, а две части, которые вы только что прочли, не секретны и не аутентифицированы. Доверять нагрузке до проверки её подписи - классическая JWT-ошибка, и исправление - передать проверку JOSE-библиотеке вроде JJWT (0.13.0) или nimbus-jose-jwt (10.9.1), а не катать собственный крипто. Второе: ошибки указывают направление: подайте строку стандартного алфавита в getUrlDecoder(), и получите Illegal base64 character 2b или 2f, а в обратную сторону будет 2d или 5f. Несовпадение алфавитов - самая частая в дикой природе ошибка декодирования Base64, и сообщение об ошибке указывает на неё в считанные мгновения. Если токен в строке запроса должен был быть стандартным Base64, его + и / скорее всего изувечила передача ещё до того, как они добрались до вас, и ошибка декодирования говорит вам про баг на стороне источника, а не про ваш декодер.
От байтов к словам
Каждый вызов декодирования в этой статье намеренно останавливается на байтах, потому что Base64 - байтовый формат, точка. Вопрос «какой это текст?» - ваш, и современный ответ по умолчанию - UTF-8. Хотя есть одна деталь кодировки на стороне декодирования API, которая удивляет людей, поэтому вот она. Перегрузка decode(String) не интерпретирует вашу строку как UTF-8. Javadoc говорит об этом точно: вызов «имеет ровно тот же эффект, что и вызов decode(src.getBytes(StandardCharsets.ISO_8859_1))». Это не баг, а приём: алфавит Base64 - чистый ASCII, так что прогон строки через Latin-1 подаёт декодеру ровно те же байты с нулевой стоимостью конвертации, а любой не-ASCII символ во вводе просто становится невалидным символом, который строгий декодер отклоняет (откуда в сообщениях об ошибках берутся отрицательные шестнадцатеричные числа).
Кодировка нагрузки - это совершенно отдельное решение, то, что происходит на шаге new String(bytes, charset). Вот классический случай: «café» в UTF-8 - это пять байтов 63 61 66 C3 A9, которые кодируются в Y2Fmw6k=:
import java.nio.charset.StandardCharsets;
import java.util.Base64;
public class CharsetDecode {
public static void main(String[] args) {
byte[] packed = Base64.getDecoder().decode("Y2Fmw6k=");
System.out.println(new String(packed, StandardCharsets.UTF_8));
// café, акцент выжил
System.out.println(new String(packed, StandardCharsets.ISO_8859_1));
// caf и за ним кракозябры, байты UTF-8, прочитанные как Latin-1
}
}
Вторая строка - это режим отказа, который надо узнавать мгновенно: UTF-8 нагрузка, прочитанная через Latin-1, даёт строку ровно на один символ длиннее и со сдвигом на один байт. Лечение всегда одно: договориться с производителем о кодировке и передавать её явно. И передавать явно в коде, а не только в голове: конструктор без аргументов new String(bytes) использует кодировку по умолчанию платформы, которая на сервере под Windows может быть Cp1252, а на старом Linux - чем угодно, что машина сочтёт нужным. С JDK 18 (JEP 400, «UTF-8 по умолчанию») по умолчанию везде UTF-8, так что на современной JVM форма без аргументов случайно оказывается правильной, но код всё равно должен говорить об этом, потому что следующий человек, который его читает, не должен гадать, что там по умолчанию. А когда нагрузка вообще не текст, тот же код просто получает другой финал: байты на входе, байты на выходе, вплоть до самого последнего шага.
Когда нагрузка - это файл
Самая частая файловая задача - обратная к какому-то экспортному рутине: приезжает текстовый файл .b64, и вам нужен исходный файл обратно. Со строгим декодированием это уже выглядит как продакшен-код:
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Base64;
public class DecodeFile {
public static void main(String[] args) throws Exception {
byte[] packed = Files.readAllBytes(Paths.get("payload.bin.b64"));
byte[] raw = Base64.getDecoder().decode(packed);
Files.write(Paths.get("payload.bin"), raw);
}
}
Ничто на этом пути не заботится о том, текст ли это, ZIP-архив или видео: byte[] - это просто байты. Арифметика размера тоже работает на вас: декодированный вывод в три четверти короче закодированного ввода, так что декодирование никогда не ухудшает картину с памятью, и закодированный файл в сотни мегабайт - меньший из двух. Хорошая привычка - позволить байтам заявить о себе до того, как вы доверитесь любой метке. Первые восемь байтов PNG - это всегда магическое число 89 50 4E 47 0D 0A 1A 0A, а значит, каждый Base64-закодированный PNG, который вы когда-либо встретите, начинается с одного и того же префикса, iVBORw0K: если нагрузка «утверждает», что она изображение, но не начинается так, что-то уже идёт не так.
Если у вас уже есть буфер назначения, двухмассивная перегрузка пишет прямо в него и возвращает ровно число байтов, которые там осели, без промежуточного выделения:
import java.nio.charset.StandardCharsets;
import java.util.Base64;
public class DecodeInto {
public static void main(String[] args) {
byte[] src = "SGVsbG8sIHdvcmxkIQ==".getBytes(StandardCharsets.ISO_8859_1);
byte[] dst = new byte[16];
int written = Base64.getDecoder().decode(src, dst);
System.out.println(written); // 13
System.out.println(new String(dst, 0, written, StandardCharsets.UTF_8));
// Hello, world!
}
}
Одна острая грань у этой перегрузки, задокументированная в javadoc: если буфер назначения слишком мал, не записывается ни одного байта, и вы получаете IllegalArgumentException: Output byte array is too small for decoding all input bytes. Считайте размер буфера по простой арифметике, примерно 3 * n / 4 минус заполнитель, и исключение так и не покажется. Есть ещё перегрузка ByteBuffer, которая возвращает свежий буфер с лимитом, установленным в длину декодирования, - удобная вещь, когда ваш конвейер живёт в NIO.
С провода: заголовки, JSON и data URI
Base64 встречается с Java чаще всего на сетевом крае. Три формы заслуживают по разобранному примеру.
Форма одна: заголовок Basic auth HTTP. Самый старый заголовок аутентификации в вебе всё ещё едет на Base64. По RFC 7617, Basic-запрос присылает Authorization: Basic, за которым следует Base64-кодирование username:password, и RFC прямо говорит, что это кодирование, а не защита: любой, у кого есть поимка пакетов, прочитает обе половины одним нажатием клавиши. Собственный пример RFC, QWxhZGRpbjpvcGVuIHNlc2FtZQ==, декодируется в Aladdin:open sesame. Разбор заголовка на стороне сервера - дело нескольких строк:
import java.nio.charset.StandardCharsets;
import java.util.Base64;
public class BasicAuth {
public static String[] credentials(String header) {
if (header == null || !header.startsWith("Basic ")) {
return null;
}
byte[] packed = header.substring(6).getBytes(StandardCharsets.ISO_8859_1);
byte[] raw = Base64.getDecoder().decode(packed);
String userPass = new String(raw, StandardCharsets.UTF_8);
int colon = userPass.indexOf(':');
if (colon < 0) {
return null;
}
return new String[] {userPass.substring(0, colon), userPass.substring(colon + 1)};
}
}
Две детали держат это в безопасности. Разделение по первому двоеточию важно, потому что пароль законно может содержать собственные двоеточия. И сравнение декодированного пароля с вашим сохранённым значением должно быть постоянным по времени: пропустите оба значения через SHA-256 и сравните дайджесты через MessageDigest.isEqual, никогда не обычным equals, по которому атакующий сможет таймингом выстроить список пользователей. Раздавайте это только по HTTPS; на обычном соединении слой Base64 - просто декор.
Форма вторая: бинарник внутри JSON. Значительная доля современных API встраивает бинарные данные в JSON в виде Base64-текста: эндпоинты загрузки файлов, контентные API, хранилища секретов и вебхуки - все так делают, потому что сырые байты иначе нарушили бы правила экранирования JSON-строк. Паттерн всегда один: поле приезжает обычной строкой, и вы декодируете его на границе, а не внутри ваших доменных объектов:
import java.util.Base64;
public class ApiField {
public static void main(String[] args) {
// Разобранный JSON нёс: "content" : "iVBORw0KGgoAAA..."
String field = "iVBORw0KGgo=";
byte[] image = Base64.getUrlDecoder().decode(field);
// Некоторые API говорят на стандартном Base64. Прочитайте спецификацию
// и затем выберите getDecoder() или getUrlDecoder() соответственно.
System.out.println(image.length); // 8
}
}
Ловушка здесь - не в декодировании; она в чтении спецификации. Некоторые API хотят стандартный Base64 с заполнителем, некоторые - base64url без него, а некоторые снисходительны к обоим. Когда спецификация молчит, самый дешёвый способ - посмотреть на пример значения от другой стороны: - или _ где угодно в значении решает вопрос об алфавите, а хвостовой = - о заполнителе.
Форма третья: data URI. Кто-то вставляет изображение в форму, и фронтенд отдаёт вам data URI целиком: data:image/png;base64,iVBORw0KGgo.... RFC 2397 определяет форму: data:, опциональный медиа-тип, опциональный флажок ;base64, запятая, а потом данные. Когда флажок есть, нагрузка - Base64; когда нет, нагрузка - процентно-кодированный обычный текст, реже, но законно. Если медиа-тип опущен, по умолчанию text/plain;charset=US-ASCII. Разобрать один - просто:
import java.util.Base64;
public class DataUri {
public static void main(String[] args) {
String uri = "data:image/png;base64,iVBORw0KGgo=";
int comma = uri.indexOf(',');
String meta = uri.substring(5, comma);
String payload = uri.substring(comma + 1);
boolean isBase64 = meta.endsWith(";base64");
String mime = isBase64 ? meta.substring(0, meta.length() - 7) : meta;
byte[] raw = Base64.getDecoder().decode(payload);
System.out.println(mime + " -> " + raw.length + " bytes");
// image/png -> 8 bytes
}
}
В этом формате живут две ловушки. Первая - отсутствующий флажок ;base64: законный data URI без флажка несёт процентно-кодированную нагрузку, и прогон её через Base64.getDecoder() закончится исключением. Вторая - заявленный медиа-тип: это подсказка от отправителя, а не факт, так что проверьте магические байты того, что вы декодировали, прежде чем положить это в папку «png». И помните собственный совет RFC: data URI - для коротких значений; изображение в мегабайты внутри URL - это запах, а не паттерн.
Почта, MIME и PEM-броня
Base64 родилась для почты, и почтового вида Base64 всё ещё постоянно приезжают в Java-программы. Стандарт MIME (RFC 2045) сделал Base64 одним из кодирований пересылки бинарных данных и добавил два домашних правила: закодированные строки не должны превышать 76 символов, а декодеры должны игнорировать любой символ вне алфавита, включая переводы строк. Строгие декодеры отклоняют самый первый перевод строки; getMimeDecoder() создан именно под такой ввод:
import java.nio.charset.StandardCharsets;
import java.util.Base64;
public class MimeDecode {
public static void main(String[] args) {
String wrapped = "SGVs\nbG8s\r\nIHN0\nYW5kYXJk";
byte[] bytes = Base64.getMimeDecoder().decode(wrapped);
System.out.println(new String(bytes, StandardCharsets.UTF_8));
// Hello, standard
}
}
Это работает, но о подвохе стоит знать, потому что у подвоха есть клыки. Снисходительный декодер не «игнорирует переводы строк»; он игнорирует всё, чего нет в его алфавите. Если стандартная Base64-строка повредится чужими символами, мусор исчезнет, а остаток декодируется во что-то правдоподобное, поэтому тянитесь за getMimeDecoder() только когда действительно ожидаете MIME-ввод.
Младший брат MIME в дикой природе - это PEM-броня, всё это дело -----BEGIN CERTIFICATE-----, которое оборачивает сертификаты и ключи. Вот в чём ловушка: строки обёртки наполнены обычными буквами алфавита. Буквы в «BEGIN CERTIFICATE» - это просто буквы Base64, так что если сунуть целый PEM-блок вместе с обёрткой, обёртка декодируется как будто она данные. Снимите обёртку сами, а затем подайте голое тело декодеру:
import java.util.Base64;
public class PemDecode {
public static void main(String[] args) {
String pem = "-----BEGIN CERTIFICATE-----\n"
+ "TUlJQm96Q0NBVWlnQXdJQkFnSUpBSXBhVDJUaVFvZU1BMEdDU3FHU0liM0RRRUE9\n"
+ "-----END CERTIFICATE-----\n";
String body = pem.replaceAll("(?m)^-----.*$", "").replaceAll("\\s", "");
byte[] der = Base64.getDecoder().decode(body);
System.out.println(der.length); // тело DER, без обёртки
}
}
PEM по конвенции переносит строки по 64 символа (MIME - по 76), и когда пробельные символы уходят, строгий декодер и MIME-декодер сходятся в результате. Пользуйтесь строгим: у сюрприза хоть есть приличие бросать исключение. Для грязного, но стандартного случая классический рецепт - убрать известные пробельные символы и декодировать строгим экземпляром, чтобы любой оставшийся мусор принёс IllegalArgumentException, а не порченный сертификат.
Значения в конфиге, окружении и базе
Base64 - это текстовый контейнер, поэтому она появляется там, где вы её и не ждёте. В базах данных бинарный объект (файл, иконка, сериализованная структура) может жить в TEXT-колонке в виде Base64, переживая каждый инструмент, который предполагает текст; ждите, что хранимое значение будет примерно на треть больше исходного, и размерьте колонку соответственно. В файлах конфигурации и переменных окружения Base64 - это трюк, чтобы провезти значения, которые иначе сломали бы формат: DSN с точками с запятой, пароль с кавычками, многострочный сертификат. Декодирование при старте - и есть вся работа:
import java.nio.charset.StandardCharsets;
import java.util.Base64;
public class ConfigDecode {
public static void main(String[] args) {
String value = System.getenv("DB_DSN_B64");
if (value == null) {
return;
}
byte[] raw = Base64.getDecoder().decode(value);
String dsn = new String(raw, StandardCharsets.UTF_8);
// dsn может быть: pg:host=db;password=qu"ote
}
}
Та же осторожность применяется здесь дважды. Первая: это безопасность формата, а не секретность: в момент, когда разработчик читает файл конфигурации, он может декодировать значение одним вызовом, поэтому никогда не храните секрет как Base64 и не называйте это шифрованием; раздел о безопасности ниже разберётся в этом подробно. Вторая: валидируйте при старте: порченое или наполовину вставленное значение из окружения - это IllegalArgumentException от строгого вызова, и проверка в две строки превращает заумную ошибку рантайма в понятное сообщение о старте. Одна Java-специфичная заметка для базы данных: держите декодированный бинарник как byte[] (параметр byte[] в вашем JDBC-коде) и никогда не гоняйте бинарник туда-сюда через String, потому что конструкторы строк - это то место, где бинарные нагрузки отправляются на смерть.
Перекачиваем большие вещи
Для нагрузок, которые велики, но всё ещё помещаются в буфер, которым вы управляете, массивные API подходят. Для нагрузок, которым в памяти вообще не место, ход - поточный адаптер: wrap(InputStream) возвращает входной поток, который декодирует по мере чтения, так что многогигабайтный закодированный файл никогда не приходится держать в массиве байтов:
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Base64;
public class StreamDecode {
public static void main(String[] args) throws Exception {
InputStream packed = Base64.getDecoder().wrap(Files.newInputStream(Paths.get("bigfile.b64")));
OutputStream raw = Files.newOutputStream(Paths.get("bigfile.bin"));
byte[] buf = new byte[8192];
int n;
while ((n = packed.read(buf)) != -1) {
raw.write(buf, 0, n);
}
raw.close();
packed.close();
}
}
Две детали, о которых стоит знать. Методы чтения обёрнутого потока выбрасывают IOException, когда встречают байты, которые не декодируются, так что порченный файл падает поточным исключением вместо IllegalArgumentException. И закрытие обёрнутого потока закрывает базовый поток, поэтому в примере packed закрывается последним, после цикла копирования, а в продакшен вы положили бы оба в блок try-with-resources. (Буфер 8192 - просто просторный буфер чтения; обёрнутый поток декодирует внутри, так что размер, которым вы читаете, - это выбор ради производительности, а не требование протокола.)
А теперь предупреждение про легаси, потому что это единственный настоящий баг во всей истории, и у него есть номер бага. На каждом JDK до 16 (в баг-репорте он воспроизводится на 8, 10 и 11) чтение обёрнутого декодера с определёнными размерами буфера дописывает два чужих нулевых байта в конец декодированных данных: JDK 8222187, чья классическая репродукция соединяет семибайтовый буфер чтения с простым восьмибайтовым вводом, и он исправлен в JDK 16. Если вам приходится перекачивать на легаси JDK 8, перепроверяйте длину декодированного после копирования, потому что баг срабатывает на определённых сочетаниях ввода и буфера, и даже 4096-байтовый буфер встречался в дикой природе, - а лучше обновите JDK, который всё равно починил бы ещё сотню вещей.
Плёнка, а не замок
Теперь раздел, который отделяет осторожных от обжёгшихся. Base64 - это не шифрование, и сам стандарт говорит об этом дважды. Раздел 12 RFC 4648: Base-кодирование «визуально скрывает иначе легко узнаваемую информацию, такую как пароли, но не обеспечивает никакой вычислительной конфиденциальности», и далее отмечается, что это «известно как причина инцидентов безопасности», когда кто-то вставляет обмен по протоколу в тикет и случайно раскрывает пароль. Совет RFC для реализаторов тоже заслуживает рамы: «декодер не должен падать на невалидном вводе, включая, например, встроенные NUL-символы».
Более тонкая ловушка - это маллиабельность. Помните, что каждый символ несёт шесть бит, и что короткий финальный блок оставляет запасные биты, которые в корректной кодировке должны быть нулевыми. Неаккуратный или злонамеренный кодировщик может положить мусор в эти запасные биты, и результат по-прежнему выглядит совершенно валидным: MQ== и MT== оба декодируются в единственный байт цифры 1. Java занимает прощающую сторону: Base64.getDecoder().decode("MT==") не проверяет незначимые биты и охотно отдаёт вам тот же байт. Почему это важно? Потому что две разные строки, декодирующиеся в одни и те же данные, ломают предположение об «единственной записи», на которое молча опираются проверки хэшей, дедупликация и сравнение подписей, а атакующий, который может подправлять закодированное значение в пути, способен подменить одну запись другой. Статья 2022 года «Маллиабельность Base64 на практике» Чатзигианниса и Халкиаса (ACM ASIA CCS 2022) как раз проходит по этим несоответствиям в реальных реализациях. Собственные слова RFC о запасных битах: они «могут быть использованы для утечки информации, для обхода сравнения строк на равенство или для запуска проблем реализации». Практическое правило - не «никогда не декодировать»; оно в «знай свою границу»: для данных между вашими собственными системами щедрость JDK подходит, но для данных, пересекающих границу доверия, требуйте каноническую форму (правильная длина, нулевые запасные биты, одна запись заполнителя) до того, как доверять чему-либо декодированному.
Заметки о производительности
Вот хорошие новости в одном предложении: на современной JVM встроенный декодер достаточно быстрый, так что Base64 почти никогда не будет вашим узким местом, и это тот референс, относительно которого остальная экосистема гоняет свои бенчмарки. Вот пример: в 2025 году проект gRPC-java публично прогнал бенчмарк своей Guava-основанной обработки Base64 против java.util.Base64 (issue 11857), и реализация JDK вышла примерно в 2.5-3.8 раза быстрее в кодировании и в 1.3-2.1 раза быстрее в декодировании на JDK 17 и 21, с самым большим отрывом на x86. Это сильный намёк о том, куда нацелено усердие JDK в реализации, и это тот же вывод, который вы будете снова и снова находить в Base64-бенчмарках: быстрая теперь версия из стандартной библиотеки, а не легаси.
Две практические заметки. Первая: для огромных файлов вы управляете не скоростью, а профилем памяти, и поэтому существует раздел о потоковой обработке: wrap(InputStream) держит рабочий набор в рамках вашего буфера чтения. Вторая: если вы всё-таки оказались на горячем пути, где декодируются миллионы маленьких значений, разделяйте один экземпляр декодера (фабрика и так возвращает один и тот же общий, как отмечено в начале), пропускайте перегрузку decode(String), когда у вас уже есть байты (она сначала копирует строку через Latin-1), и пусть перегрузка decode(byte[], byte[]) пишет в заранее отмеренный массив назначения, чтобы пропустить танец с выделением.
Ловушки с Java-акцентом
Ловушки, собранные в одном месте, все они Java-специфичные:
- Не тот декодер под алфавит. base64url-строка в
getDecoder()(или наоборот) - классический крашIllegal base64 character, обычно с2d,5f,2bили2fв сообщении. Подбирайте декодер под протокол, каждый раз. - Хвостовые пробельные из дикой природы. Значения, скопированные из терминала, переменной окружения или файла конфигурации, часто приезжают с переводом строки, и строгий декодер превращает это в
Illegal base64 character a. обработайте ввод черезstrip(), либо пользуйтесь MIME-декодером только когда данные по-настоящему перенесены по строкам. - Ловушка обёртки.
getMimeDecoder()не понимает PEM-заголовки, и буквы в BEGIN и CERTIFICATE декодируются как данные. Снимайте строки обёртки сами, всегда. - Снисходительность MIME как ярлык. Декодирование MIME-декодером просто чтобы «быть в безопасности» молча пропускает любой чужой символ вне алфавита, так что порченная нагрузка может выйти правдоподобной и неправильной. Используйте его только для настоящего MIME-ввода.
- Кодировка на удачу. Безаргументный
new String(bytes)использует кодировку платформы по умолчанию. На JDK 18+ это UTF-8, но код должен передаватьStandardCharsets.UTF_8явно, либо наслаждайтесь кракозябрами после следующей миграции серверов. - Строкование бинарника.
new String(decodedPng)и обратно - это уничтожение данных: любая последовательность байтов, невалидная в вашей кодировке, становится символом замены, и обратный путь односторонний. Байты на входе, байты на выходе, вплоть до самого последнего шага. - Доверие к запасным битам.
MT==декодируется так же, какMQ==, так что нагрузка с мусором, спрятанным в незначимых битах, проходит все проверки, которые проводит JDK. Если протокол важен, требуйте каноническую форму. - Потоки JDK 8, 11 и 12. Обёрнутый декодер на этих версиях может дописывать два чужих нулевых байта при определённых размерах буфера (JDK 8222187, исправлено в 16). На 16 и новее это не проблема; на старых - проблема.
- null - это не пустота. Передача
nullвdecode()- этоNullPointerException, а не пустой массив. Если переменная может быть null, обеспечьте ей значение до вызова. - Android - отдельный зверинец. На Android
java.util.Base64существует только с уровня API 26; ниже - это фреймворковый классandroid.util.Base64со своими константами флагов. Код, который захардкодил один или другой без проверки, ломается ровно на тех устройствах, которые вы никогда не тестировали. - Забыто, что это не безопасность. Base64 прячет пароль от взгляда и от всех остальных. Если данные секретны, сначала зашифруйте их, и только потом упакуйте, если канал требует текст.
Долгий путь к java.util.Base64
История формата старше Java. В 1980-х почтовая инфраструктура интернета умела нести только 7-битный ASCII, и люди, которым нужно было перемещать бинарники, придумывали локальные диалекты: uuencode для UNIX (его алфавит идёт по последовательным кодам ASCII, так что кодирование - это одно прибавление 32 без таблицы поиска) и BinHex для машин Apple (он вычистил свой алфавит, отбросив визуально перепутываемые символы вроде 7, O, g и o). В 1987 протокол Privacy-Enhanced Mail (RFC 989) стандартизировал 64-символьную схему со строками по 64 символа для переноса сертификатов, и в 1993 RFC 1421 сохранил алфавит и правила заполнителя. В 1996 MIME (RFC 2045, обновляющий RFC 1521 1993 года) принёс схему, уже названную «base64» по её 64-символьному алфавиту, зафиксировал длину строки в 76 символов, которая до сих пор переносит ваши почтовые вложения, и записал правило снисходительного декодера, которое getMimeDecoder() реализует по сей день. В 2003 RFC 3548 пытался навести порядок во всём семействе и заявил, что декодеры должны отклонять символы вне алфавита, а в 2006 RFC 4648 стал стандартом, который цитируют все, с таблицами алфавитов, вариантом base64url в разделе 5 и разделом о безопасности, который держит честным последний раздел этой статьи.
Собственная глава Java чуть более драматична. Годами единственным Base64 внутри JDK была внутренняя пара sun.misc.BASE64Encoder и sun.misc.BASE64Decoder, тот самый вид API, который компилируется сегодня и исчезает без предупреждения о депрекации, а если Base64 нужен был в XML-мире, там был ещё javax.xml.bind.DatatypeConverter из JAXB. Все остальные пользовались Apache Commons Codec или Guava. Затем 18 марта 2014: Java 8 отгрузила java.util.Base64, реализующую RFC 4648 и RFC 2045 в одном классе с фабричным паттерном, которым вы пользуетесь всё это время. Три с половиной года спустя Java 9 (21 сентября 2017) навсегда убрала пару sun.misc, и официальный гайд по миграции говорит об этом прямо: «В частности, sun.misc.BASE64Encoder и sun.misc.BASE64Decoder были удалены. Вместо них используйте поддерживаемый класс java.util.Base64, который был добавлен в JDK 8». Прогоните jdeps по старому коду, который всё ещё ссылается на них, и инструмент пометит зависимость как «JDK removed internal API». Java 11 довершила дело, убрав модуль JAXB и его DatatypeConverter вместе с ним (JEP 320). С 1.8 публичное API не поменяло ни одного метода, и javadoc по-прежнему несёт свой первоначальный тег Since: 1.8. А двигался мотор под капотом: починки багов (поточный баг JDK 8222187, исправленный в JDK 16) и работа над производительностью, поэтому бенчмарки комьюнити снова и снова приходят к тому же выводу. Двенадцать лет, одно API, и это всё ещё самый быстрый Base64, за который не придётся платить.
Забавные факты, Java-версия
Потому что полное руководство должно заканчиваться улыбкой, вот несколько Java-специфичных фактов, которые просто забавны:
- Javadoc Oracle для
decode(byte[] src, byte[] dst)обещает, что «до броска IllegalargumentException часть байтов могла быть записана в выходной массив байтов». Не IllegalArgumentException, а IllegalargumentException, со строчной a. Опечатка - в настоящем исходнике JDK, и она там с 2014 года. Документация, так преданная опечатке, встречается реже, чем следовало бы. - Декодируйте строку с
é, и сообщение об ошибке будетIllegal base64 character -17: отрицательное шестнадцатеричное число, потому что символ становится байтом Latin-10xE9, который как знаковый байт Java равен минус 23, и JDK печатает его в шестнадцатеричной системе. Ваш логгер ошибок на короткое время занимается знаковой арифметикой. Base64.getDecoder() == Base64.getDecoder()- правда. Исходный код возвращает общий статический экземпляр при каждом вызове, так что API «получи новый» - это костюм для синглтона, и обещание потокобезопасности - просто описание того, что JVM и так уже делает.- Подайте URL-декодеру строку из четырёх подчёркиваний,
"____", и он вернёт три байта чистого0xFF. Подчёркивание - это значение 63 в алфавите, четыре из них дают 24 бита, а 24 бита единиц - это тройка байтовFF FF FF. В этом нет ничего противозаконного, и это самая смешная часть. AA==декодируется в единственный NUL-байт, а пустая строка - в ничто. В Base64 «ничто» и «ноль» - разные существа, и оба - совершенно валидный ввод.- Маленькая строка
TWFu, которая декодируется вMan, стала любимым дымовым тестом экосистемы: она встречается в RFC, в Wikipedia, в справочных руководствах и в большинстве Base64-туториалов на земле, так что каждый декодер, написанный с тех пор, оказывает тот же крохотный поклон. - Каждый Base64-закодированный PNG, который вы когда-либо декодировали, начинается с
iVBORw0K. Это магическое число PNG в маскировке, и это один из самых узнаваемых восьмисимвольных префиксов в интернете. - URL-безопасный раздел RFC 4648 - там, где рождается название «base64url»: спецификация говорит, что кодирование «можно называть base64url», и предупреждает, что его «не следует считать той же, что base64-кодирование». Происхождение URL-безопасного алфавита отнесено сноской к посту 2001 года на рассылке P2P-hackers, так что название, которое вы вставляете в каждый URL, имеет родословную из рассылки.
- ID видео YouTube - это base64url без заполнителя, привычная одиннадцатисимвольная строка, которую можно вставить куда угодно в URL. Формат, придуманный для почтовых вложений, теперь крутит видеоплатформу, и
getUrlDecoder()- та часть вашего JDK, которая заставляет это работать. - Декодируйте строку
YmFzZTY0, и вы получите словоbase64обратно, без заполнителя, потому что шесть делится на три. Формат, описывающий сам себя, - технический эквивалент зеркала, которое говорит азбукой Морзе.
Другое направление
Вот и сторона декодера, и именно здесь живёт большая часть боли, потому что декодирование - это место, где вы встречаете данные других людей: их выбор заполнителя, их переводы строк, их кодировки, их токены, их обёртки. Другое направление - превращать байты в Base64-строку с помощью кодировщиков java.util.Base64 - зверь поспокойнее: оно никогда не бросает исключение на невалидном вводе (невалидного ввода для кодирования не существует), вместо сообщения об ошибке оно заставляет расплачиваться размером, и у него есть собственный набор ловушек (шаг с кодировкой, MIME-регуляторы, решение о заполнителе для токенов), который получит своё руководство. Кодирование Base64 в Java, на которое есть ссылка с этой страницы, разбирает кодировщик с той же глубиной, и оба читаются в паре вполне комфортно.
Последнее обновление: 2026-09-08
Связанная статья: Кодирование Base64 в Java: полное руководство