Java 中的 Base64 解码:完整指南
它可能出现在支持工单里、API 响应中、Kubernetes secret 中,也可能埋在 URL 的中段:一长串字母和数字,偶尔夹杂 +、/、- 或 _,末尾还拖着也许一个、也许两个 =。有人说它是 Base64,里面装着你需要的东西:一个密码、一个 JSON 载荷、一张证书、一张照片。本指南就是把它取回来的 Java 配方。先快速定向一下,因为主页会深入讲解这个格式:Base64 把每三个字节的数据改写为四个字符,字符取自一个 64 字母的字母表,当最后一个块偏短时,还会在末尾补上一个或两个 = 填充。解码就是这笔交易里缩水的那个方向:四个字符进去,三个字节出来,所以结果总是比输入少占大约四分之一的空间。
先说重点,而且是个好消息。自 2014 年 3 月 18 日起,每个 JDK 的标准库里都自带一套完整的 Base64 工具:java.util.Base64。不用下载,不需要 Maven 坐标,也没有本地库。一个 import,七个工厂方法,三种字母表,从 Java 8 到今天的 Java 26 行为一致。本文的一切内容都建立在这一个类之上。
开始之前先划一条诚实的边界:这是故事里解码器这一侧的内容。你将学会为遇到的字母表挑出正确的解码器,像医生读片子一样读懂 JDK 的错误信息,把字节变成文字而不产生乱码,解开 PEM 护甲,流式处理数吉字节的载荷,还能认出这个格式悄悄留在路上的安全陷阱。反方向,把字节打包成字符串,有它自己的指南,链接在本文末尾。
你已经拥有的东西
在 Java 里"安装" Base64,就是你在白板前给出的那句一行回答:"它在 JDK 里。"类 java.util.Base64 自 1.8 起就是 java.base 模块的一部分,到 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)自 1.0 起就带着自己的 org.apache.commons.codec.binary.Base64,它的 Builder API 把严格或宽松的策略、行长度、分隔符都做成了旋钮;只有当你必须支持 Java 8 之前的 JVM,或想要它的形状检查助手时,它才是合适的工具。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、令牌,或一个承诺了 "URL-safe" 的 API,就换成 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) 变体,下面会讲)时,同样的问题会以带 0x 前缀的 IOException 形式浮现:Illegal base64 character 0x20(当前 JDK;JDK 8 的流解码器打印的是查表值 -1,而不是字节)。同样的问题,不同的异常,拼写上略有不同。而宽松的 MIME 解码器,当然,对这些一概不抱怨:它只是跳过。这就是宽松脾气的代价。
填充规则
野外的每个 Base64 字符串都对填充许下了一个无声的承诺,而 Java 的承诺格外友好。解码器 javadoc 说得原原本本:填充字符 = "会被接受并被解释为编码字节数据的结束,但不是必需的"。末尾由两个或三个字符组成的单元会像被填充过一样解码,而当填充存在时,它必须恰好是正确数量。当前 JDK 在经典例子上的行为:
| 输入 | 结果 |
|---|---|
"" |
空字节数组,无错误 |
"Zm8" |
"fo",填充只是缺席 |
"Zm8=" |
"fo",规范的写法 |
"Zm8==" |
IllegalArgumentException:incorrect ending byte at 4 |
"Zm9v=" |
IllegalArgumentException:wrong 4-byte ending unit |
"Zg==" |
"f",一个字节 |
"Z=" |
IllegalArgumentException:last unit does not have enough valid bits |
"AA==" |
恰好一个字节,NUL 字节 0x00 |
"AAAA" |
三个 NUL 字节 |
把这张表读两遍。空字符串解码为什么都没有,而 AA== 解码为一个 NUL 字节:在 Base64 里,"什么都没有"和"一个零"是两种不同的生物,两者都是完全合法的输入。而且填充一旦存在,必须精确:Zm8= 对,Zm8== 错,Zm9v= 错,字符串中间的填充也错。对你自己协议的实际后果:选定一种写法(有填充或无填充),并在两端强制执行它,因为一个能以两种写法到达的值,就是某个下游朴素相等检查的破坏者。
base64url:为 URL 而生的字母表
标准 Base64 的字母表以 + 和 / 收尾,而这两个恰恰是在 URL 里不安分的字符:+ 在查询字符串里还没等 Java 看到就已经是空格了,/ 是路径分隔符,一个悬着的 = 则渴望被百分号编码成一个三字符怪兽。RFC 4648 第 5 节画出了修正方案:URL 和文件名安全字母表,+ 变成 -,/ 变成 _,当长度可以隐式得知时,末尾的 = 填充通常被丢掉。RFC 对命名毫不含糊:这种编码"不应被视为与 base64 编码相同"。你会以 base64url 之名遇见它,JSON Web Token、OAuth state 参数、API 会话 ID 和十一字符的视频 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 漏洞,修法是把手续交给 JJWT(0.13.0)或 nimbus-jose-jwt(10.9.1)这样的 JOSE 库,而不是自己发明加密。第二,错误会指明方向:把一个标准字母表的字符串交给 getUrlDecoder(),你会得到 Illegal base64 character 2b 或 2f;反过来做,则会得到 2d 或 5f。字母表不匹配是野外最常见的单一 Base64 解码失败,而错误消息会在一瞬间就指向它。如果查询字符串里的一个令牌本应是标准 Base64,那它的 + 和 / 很可能在你看到之前就已经被传输层糟蹋了,这个解码错误是在告诉你上游有个 bug,而不是你的解码器有问题。
从字节到词语
本文里的每次解码调用都故意停在字节上,因为 Base64 是字节格式,句号。"那是什么文字?"这个问题由你来回答,而现代默认答案是 UTF-8。不过,解码一侧的 API 里有一个会让人意外的字符集细节,就在这里。重载 decode(String) 并不把你的字符串当作 UTF-8 来解释。javadoc 说得原原本本:一次调用"与调用 decode(src.getBytes(StandardCharsets.ISO_8859_1)) 效果完全相同"。这不是 bug - 这是个技巧: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 by Default")起,默认在所有平台上都是 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 重载,返回一个新缓冲区,其 limit 被设为解码长度,当你的管线住在 NIO 里时很方便。
来自网络:头部、JSON 与 Data URI
Base64 与 Java 相遇最多的地方是网络边缘。三种形状各值一个完整示例。
形状一:HTTP Basic 认证头。网上最老的认证头至今还骑在 Base64 上。按 RFC 7617,Basic 请求发送 Authorization: Basic,后跟 username:password 的 Base64 编码,而 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 把二进制作为 Base64 文本嵌在 JSON 里:文件上传端点、内容 API、密钥库和 webhook 都这么干,因为原始字节否则会打破 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 字符串被杂字符弄坏,垃圾会消失,剩下的解码成貌似合理的结果,所以只有在你真正预期 MIME 形状输入时才去拿 getMimeDecoder()。
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 是一个文本容器,这就是它为什么出现在你料想不到的地方。在数据库里,一个二进制 blob(一个文件、一个图标、一个序列化结构)可以以 Base64 的形式住在 TEXT 列里,扛过所有假设"这是文本"的工具;预期存储值比原始大约大三分之一,并据此给列定大小。在配置文件和环境变量里,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[](你 JDBC 代码里的 byte[] 参数),永远不要让二进制经 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 缓冲区只是一个宽裕的读缓冲区;包装流在内部解码,所以你一次读多大是性能选择,不是协议要求。)
现在来一条遗留警告,因为这是整个故事里唯一真正的 bug,而且它有 bug 编号。在 16 之前的每个 JDK 上(bug 报告在 8、10 和 11 上复现了它),用某些缓冲区大小读取包装解码器时,会在解码数据末尾追加两个游荡的零字节:JDK 8222187,其经典复现是把一个七字节读缓冲区配上一个普通的八字节输入,它在 JDK 16 中修复。如果你不得不在遗留的 JDK 8 上流式处理,复制之后要复查解码长度,因为这个 bug 只在特定的输入与缓冲区组合下触发,而野外甚至报告过 4096 字节缓冲区的案例;或者更好,升级 JDK,反正那还会修好大约一百件别的事。
封箱胶带,不是锁
现在来到把谨慎的人和被烧过的人分开的那一节。Base64 不是加密,标准本身就把这事说了两遍。RFC 4648 第 12 节:Base 编码"在视觉上隐藏了本易于识别的信息,比如密码,但不提供任何计算上的保密性",接着它指出,当有人把协议交互粘进工单、意外暴露了密码时,这"已知会导致安全事件"。RFC 给实现者的建议也值得装裱起来:"解码器不应在无效输入上崩溃,例如内嵌的 NUL 字符"。
更隐蔽的陷阱是可塑性。记住每个符号携带六个比特,而且偏短的末尾单元会留下空闲比特,在格式良好的编码里它们必须为零。一个马虎或怀敌意的编码器可以往那些空闲比特里塞垃圾,而结果看起来仍然完全合法:MQ== 和 MT== 都解码为数字 1 的那一个字节。Java 在这事上站在宽容一边:Base64.getDecoder().decode("MT==") 不校验非有效比特,乐呵呵地递给你同一个字节。为什么要在乎?因为两个解码到相同数据的不同字符串,会打破"唯一拼写"这个假设,而哈希检查、去重和签名比较都悄悄依赖着它;一个能在传输中篡改编码值的攻击者,就能把一种拼写换成另一种。2022 年 Chatzigiannis 和 Chalkias 的论文 "实践中的 Base64 可塑性"(ACM ASIA CCS 2022)逐一梳理了真实世界实现里正是这些不一致。RFC 对空闲比特的原话:它们"可能被滥用于泄露信息,或用于绕过字符串相等比较,或触发实现问题"。实用规则不是"永远不解码",而是"知道你的边界":你自己系统之间的数据,JDK 的慷慨没问题;但跨越信任边界的数据,在你信任任何解码结果之前,先强制规范形式(长度正确、空闲比特为零、填充只有一种拼写)。
性能备注
好消息一句话:在现代 JVM 上,内置解码器足够快,Base64 几乎从不是你的瓶颈,而且它是整个生态系拿来当基准参照的那一个。举个例子:2025 年 gRPC-java 项目公开基准测试了它基于 Guava 的 Base64 处理与 java.util.Base64 的对比(issue 11857),JDK 实现在 JDK 17 和 21 上编码快约 2.5 到 3.8 倍、解码快约 1.3 到 2.1 倍,差距最大的是 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 不是空。给
decode()传null是NullPointerException,不是空数组。如果变量可能为 null,在调用前先把 null 合并掉。 - Android 是另一个动物园。在 Android 上,
java.util.Base64只从 API 级别 26 起存在;再往下的框架类是android.util.Base64,带着它自己的一套标志常量。不检查就硬编码其中之一的代码,恰好会在你从没测过的设备上碎掉。 - 忘了它不是安全。Base64 只把密码藏过一瞥,除此之外谁都藏不住。如果数据是机密的,先加密,只有当通道要求文本时再打包。
通往 java.util.Base64 的漫漫长路
格式的故事比 Java 更老。1980 年代,互联网的邮件基础设施只能携带 7 位 ASCII,想搬二进制的人们发明了地方方言:UNIX 的 uuencode(它的字母表按连续 ASCII 码排布,所以编码只需加一次 32,没有查表)和 Apple 机器的 BinHex(它精心挑选字母表,剔除 7、O、g 和 o 这类容易看混的字符)。1987 年,隐私增强邮件协议(RFC 989)把 64 字符方案标准化,行宽 64 字符,用来承载证书;1993 年的 RFC 1421 保留了字母表和填充规则。1996 年 MIME(RFC 2045,更新 1993 年的 RFC 1521)携带了这个已按其 64 字符字母表命名为 "base64" 的方案,定下了至今仍在折叠你邮件附件的 76 字符行宽,写下了 getMimeDecoder() 到今天仍在实现的宽松解码器规则。2003 年 RFC 3548 试图整理整个家族,宣布解码器应当拒收字母表之外的字符;2006 年 RFC 4648 成为人人引用的标准,带着字母表、第 5 节的 base64url 变体,以及让本文最后一节保持诚实的安全章节。
Java 自己的章节要戏剧化一点。多年来 JDK 里唯一的 Base64 是内部的一对 sun.misc.BASE64Encoder 和 sun.misc.BASE64Decoder,那种今天还能编译、明天就消失且没有弃用警告的 API;如果你在 XML 世界里需要 Base64,还有来自 JAXB 的 javax.xml.bind.DatatypeConverter。其他人都用 Apache Commons Codec 或 Guava。然后是 2014 年 3 月 18 日:Java 8 带来了 java.util.Base64,在同一个类里用你一直使用的那种工厂方法模式实现了 RFC 4648 和 RFC 2045。三年半后,Java 9(2017 年 9 月 21 日)永久移除了 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 标签。动过的是底下的引擎:bug 修复(JDK 8222187 流 bug,JDK 16 修复)和性能工作,所以社区基准测试不断落到同一个结论上。十二年,一个 API,它仍是你能免费拿到的最快的 Base64。
趣闻,Java 版
因为一份完整指南该以微笑收尾,这里有一些纯粹好玩的 Java 专属事实:
decode(byte[] src, byte[] dst)的 Oracle javadoc 承诺"抛出 IllegalargumentException 之前,可能已有一些字节写入输出字节数组"。不是 IllegalArgumentException,是 IllegalargumentException,小写的 a。这个错别字就在真实的 JDK 源码里,从 2014 年待到现在。文档对一个错别字如此矢志不渝,是少见的。- 解码一个包含
é的字符串,错误消息是Illegal base64 character -17:一个负数十六进制数,因为这个字符变成 Latin-1 字节0xE9,作为带符号 Java 字节等于负 23,而 JDK 用十六进制打印它。你的错误日志,短暂地,在做带符号算术。 Base64.getDecoder() == Base64.getDecoder()为真。源码每次调用都返回同一个共享静态实例,所以"要一个新的"这个 API 只是单例的戏服,而线程安全的承诺只是对 JVM 本来就在做的事的描述。- 给 URL 解码器喂一串四个下划线的字符串
"____",它返回三个纯0xFF字节。下划线的字母表值是 63,四个凑成 24 比特,24 比特全 1 就是字节三元组FF FF FF。这完全不犯法,这才是最好笑的部分。 AA==解码为一个 NUL 字节,而空字符串解码为什么都没有。在 Base64 里,"什么都没有"和"一个零"是两种不同的生物,两者都是完全合法的输入。- 那个解码为
Man的小字符串TWFu,成了整个生态系最爱的冒烟测试:它出现在 RFC 里、维基百科里、参考手册里和地球上大多数 Base64 教程里,所以此后的每个解码器都在献上同样的小小致敬。 - 你解码过的每一个 Base64 编码 PNG 都以
iVBORw0K开头。那是乔装改扮的 PNG 魔数,也是互联网上最容易认出的八字符前缀之一。 - RFC 4648 的 URL 安全章节是 "base64url" 这个名字的出生地:规范说这种编码"可以被称为 base64url",并警告它"不应被视为与 base64 编码相同"。URL 安全字母表的起源被脚注引到 2001 年一个 P2P-hackers 邮件列表帖子,所以你粘进每个 URL 的这个名字,有着邮件列表的血统。
- YouTube 视频 ID 是不带填充的 base64url,那段你熟得不能再熟的十一字符字符串,可以粘在 URL 的任何地方。这个为邮件附件设计的格式如今在驱动一个视频平台,而
getUrlDecoder()就是你 JDK 里让它跑起来的那部分。 - 解码字符串
YmFzZTY0,你会拿回base64这个词,不需要填充,因为六是三的倍数。一个描述自己本身的格式,技术等价于一个用摩尔斯电码说话的镜子。
另一个方向
这就是故事里解码器的那一侧,也是大多数痛苦所在,因为解码是你遇见别人的数据的地方:他们的填充选择、他们的换行、他们的字符集、他们的令牌、他们的护甲。另一个方向,用 java.util.Base64 的编码器把字节变成 Base64 字符串,是更温顺的动物:它从不在无效输入上抛异常(没有可编码的无效输入),它要付的是尺寸账单,而不是读错误消息,而它自己的一套陷阱(字符集这一步、MIME 旋钮、令牌的填充决定)有它自己的指南。Java 中的 Base64 编码,从本页链接过去,以同样的深度覆盖编码器,两篇读起来恰好是一对。
最后更新: 2026-09-08
相关文章: Java 中的 Base64 编码:完整指南