Java 中的 Base64 编码:完整指南
情况是这样的:你有一堆字节。一个文件、一个密码、一张证书、一个 13 字节的问候、一个 200 兆字节的上传。而你需要把它们塞进一个只懂文本的东西里:一个 JSON 字段、一个 HTTP 头、一个数据库列、一个 URL、一个配置文件。这就是 Base64 的全部工作,而本指南就是把它做好的 Java 手册。先快速定向一下,因为主页会一步步讲过这个格式:Base64 把每三个字节的数据改写为四个字符,字符取自一个 64 字母的字母表,当最后一个块偏短时,会补上一个或两个 = 填充。这趟旅程的代价是尺寸:每三个字节变成四个字符,所以编码输出比输入大约大 33%,若涉及换行还要再多一点。
头条新闻,而且是个好消息。自 2014 年 3 月 18 日起,每个 JDK 的标准库里都自带一套完整的 Base64 工具:java.util.Base64。不用下载,不需要 Maven 坐标,也没有本地库。一个 import,三种编码器"性格",从 Java 8 到今天的 Java 26 行为一致。本文的一切内容都建立在这一个类之上,而且它从不在数据本身上面抛异常:编码器的活儿不可能因无效输入而失败,因为每一个可能的字节都可编码。
开始之前先划一条诚实的边界:这是故事里编码器这一侧的内容。你将学会那个真正决定正确性的字符串到字节的决定、填充与折行旋钮、base64url 及其面向令牌的无填充模式,还有 Java 开发者最常遇见编码输出的那些用例。解码那边,真正的痛苦大多住在那里,有它自己的指南,链接在本文末尾。
一个 import,零下载
在 Java 里"安装" Base64,就是你在白板前给出的那句一行回答:"它在 JDK 里。"类 java.util.Base64 自 1.8 起就是 java.base 模块的一部分,十二年来它的 javadoc 仍然写着 Since: 1.8。你唯一要装的只是一个 JDK:任何厂商的 Java 8 或更新版本都可以(Oracle、Eclipse Temurin、Amazon Corretto、Zulu),在基于 Debian 的系统上那只是一条命令:
sudo apt install openjdk-17-jdk-headless
这个 API 是一个工厂:你从不亲手构造编码器,而是向这个类要一个。编码器一侧有四扇门,全都返回嵌套类 Base64.Encoder 的实例:
| 工厂方法 | 字母表 | 输出形状 |
|---|---|---|
getEncoder() |
A-Z a-z 0-9 + / |
带填充,无换行 |
getUrlEncoder() |
A-Z a-z 0-9 - _ |
带填充,无换行 |
getMimeEncoder() |
A-Z a-z 0-9 + / |
带填充,76 字符行,CRLF |
getMimeEncoder(int, byte[]) |
A-Z a-z 0-9 + / |
带填充,你定的行长度,你定的分隔符 |
有三个属性值得先知道。实例线程安全,工厂每次调用都返回同一个共享实例,所以 Base64.getEncoder() == Base64.getEncoder() 为真;在静态字段里建一个,到处共享。编码器从不在数据上面抛异常:每个字节值都有编码,所以不存在要处理的"无效输入"状态,你会遇到的异常只关于配置错误(坏的行分隔符)或目标数组太小。而这一列表里每个编码器默认都加填充;那个把它关掉的旋钮 withoutPadding() 出现在 base64url 部分,因为那才是你需要的地方。
你仍会在代码库里遇到老库,所以先快速圈一下地盘。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 都是默认选择:零依赖,而且社区基准测试反复发现它是这一群库里最快的(安全与速度部分还会再讲)。
你的第一次编码
编码日常生活的百分之九十,装得进三行。下面就是全部仪式,用的是维基百科 Base64 条目解释字母表时用的那个最小例子:
import java.nio.charset.StandardCharsets;
import java.util.Base64;
public class FirstEncode {
public static void main(String[] args) {
byte[] text = "Man".getBytes(StandardCharsets.UTF_8);
String packed = Base64.getEncoder().encodeToString(text);
System.out.println(packed); // TWFu
}
}
字符串 TWFu 是维基百科 Base64 条目解释字母表时用的例子,所以如果你的编码器把 "Man" 变成它,那这台机器是诚实的。但请看那个例子的第一行,因为在 Java 里真正发生编码的是这一行。故意没有 encodeToString(String) 方法。Java 的 String 是 UTF-16 码元的序列,不是字节,而 Base64 是字节格式,所以 API 让你自己决定字节问题:"Man".getBytes(StandardCharsets.UTF_8)。就是这一个带着显式字符集的调用,决定了 "café" 在未来一百年里保持正确,也是整篇文章里唯一最重要的习惯。下一节专门讲它,因为替代方案就是经典的乱码 bug。
第二行有两个备注。encodeToString() 返回一个由编码后字节构建的 String;javadoc 解释说它使用 ISO-8859-1 字符集构造结果,实际上这不是个问题,因为每个 Base64 输出字符都是纯 ASCII,在 Latin-1、UTF-8 和字符集动物园的大多数成员里看起来一模一样。而如果你更想自己掌管输出缓冲区,encode(byte[]) 返回一个新的 byte[],encode(byte[] src, byte[] dst) 写进你提供的目标并返回写入数量(如果目标不够长,则抛出 IllegalArgumentException: Output byte array is too small for encoding all input bytes,且一个字节都不写)。
字符集的决定
用经典案例把字符串到字节这一步落到实处。"café" 是一个词,但用字节表示时,完全取决于你选的字符集:
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
public class CharsetEncode {
public static void main(String[] args) {
byte[] utf8 = "café".getBytes(StandardCharsets.UTF_8);
byte[] latin1 = "café".getBytes(Charset.forName("ISO-8859-1"));
System.out.println(utf8.length + " vs " + latin1.length);
// 5 vs 4:重音在 UTF-8 里是两个字节,在 Latin-1 里是一个
System.out.println(Base64.getEncoder().encodeToString(utf8));
// Y2Fmw6k=
System.out.println(Base64.getEncoder().encodeToString(latin1));
// Y2Fm6Q==
}
}
同一个词,两个不同的 Base64 字符串,只要告诉读者用哪个字符集,两者都"正确"。全部教训一句话就装得下:编码器对你给的字节忠心耿耿,而字节是你负责的。实践中这意味着:和你的对接方约定 UTF-8,显式传 StandardCharsets.UTF_8,并把字符集写进规范、schema 或提交信息,因为接收端的任何人都无法仅凭 Base64 猜出它。这个 bug 的解码侧孪生兄弟,是姊妹指南的主题。
一个版本备注,因为它改变了懒惰代码的失败模式。无参的 new String(bytes) 和没有字符集的 String.getBytes() 用平台默认字符集,它历史上在 Windows 上是 Cp1252,在 Linux 上是某种依赖区域设置的值。自 JDK 18(JEP 400,"UTF-8 by Default")起,默认在所有平台上都是 UTF-8,所以在现代 JVM 上懒惰形式碰巧是对的。这并不使它安全:你的代码会活得比它编写时所针对的 JDK 更久,而接手的人不该需要知道默认值是什么。把字符集写出来。
一个相关的设计细节:整个 API 里任何地方都没有 encode(String) 重载,这是刻意的。管线的其他每一步(数组、缓冲区、流)都接收字节,而一个接收 String 的方法不得不再替你选一个字符集,而这正是 JDK 拒绝做的决定。唯一存在的 String 类型方法 encodeToString 在输出侧,那里不存在字符集问题:Base64 输出是纯 ASCII。这个 API 的整体形状就是一个小论点:"有意识地决定你的字节"。
填充、折行与 MIME 旋钮
Java 的编码器默认替你做了两个格式化决定,两个都值得理解,因为两个都是你能拨的旋钮。第一个是填充:每个编码器都会补上那些 = 字符,让输出成为四的倍数,正如 RFC 4648 所要求的:除非所引用的规范另有说明,实现 MUST 在编码数据末尾包含适当的填充字符。第二个是折行:只有 MIME 编码器折行,每 76 个字符用回车加换行,而且它不在最后的短行之后追加行分隔符,这个细节 javadoc 明确点了名,而其他工具经常搞错:
| 编码器 | 给输出加填充 | 折行 | 行分隔符 |
|---|---|---|---|
getEncoder() |
是 | 否 | 不适用 |
getUrlEncoder() |
是 | 否 | 不适用 |
getMimeEncoder() |
是 | 是,76 字符 | CRLF |
getMimeEncoder(64, "\n") |
是 | 是,64 字符 | LF |
MIME 旋钮是对接手别人格式的人来说 API 里最有用的部分。标准构造器是 getMimeEncoder()(76,CRLF,直接来自 RFC 2045);双参数版本 getMimeEncoder(int lineLength, byte[] lineSeparator) 让你复刻其他惯例。两个要知道的怪癖:行长度会"向下舍入到最近的 4 的倍数",所以要 77 会悄悄给你 76,而舍入后不是正数的值会直接取消折行;分隔符不得包含 Base64 字母表里的任何字符,否则构造器当场抛出 IllegalArgumentException,因为一个可能与数据混淆的分隔符就是等着发生的 bug。下面是旋钮实物的演示,MIME 标准味和 PEM 味各一种:
import java.nio.charset.StandardCharsets;
import java.util.Base64;
public class WrapDials {
public static void main(String[] args) {
byte[] data = "Hello, wrapped world! This line keeps going and going and going until it finally has to wrap.".getBytes(StandardCharsets.UTF_8);
Base64.Encoder mime = Base64.getMimeEncoder();
Base64.Encoder pem = Base64.getMimeEncoder(64, "\n".getBytes(StandardCharsets.ISO_8859_1));
System.out.println(mime.encodeToString(data));
// 76 字符一行,行之间是 CRLF
System.out.println(pem.encodeToString(data));
// 64 字符一行,行之间是裸 LF
}
}
两个实用备注。如果你的消费方期望折行字符串以换行结尾(有些邮件工具确实如此),在编码之后自己加上:JDK 刻意停在最后一个短行之后。而如果你生产的是将住在 URL 或令牌里的数据,折行完全是拨错了旋钮;那些消费方要的是一个长行,通常还要无填充,这正是下一节的内容。
base64url 与无填充旋钮
标准 Base64 的字母表以 + 和 / 收尾,而这两个恰恰是在 URL 里不安分的字符:+ 在查询字符串里还没等服务器解析就已经是空格了,/ 是路径分隔符,一个悬着的 = 则渴望被百分号编码成一个三字符怪兽。RFC 4648 第 5 节画出了修正方案:URL 和文件名安全字母表,+ 变成 -,/ 变成 _,当长度可以隐式得知时,末尾的 = 填充通常被丢掉。RFC 对命名毫不含糊:这种编码"不应被视为与 base64 编码相同",而你会听到的名字是 base64url。JSON Web Token、OAuth state 参数、API 会话 ID 和十一字符的视频 ID 全都住在这个方言里。
Java 用 getUrlEncoder() 给你字母表,但这里有个会抓人的旋钮:URL 安全编码器默认仍然加填充,而令牌标准不想要填充。RFC 7515 明说 JWS 部分使用 base64url"省略所有末尾的 '=' 字符……并且不包含任何换行、空白或其他附加字符"。所以规范的 Java JWT 配方是一个两方法链:
import java.nio.charset.StandardCharsets;
import java.util.Base64;
public class TokenParts {
public static void main(String[] args) {
Base64.Encoder url = Base64.getUrlEncoder().withoutPadding();
byte[] header = "{\"alg\":\"HS256\",\"typ\":\"JWT\"}".getBytes(StandardCharsets.UTF_8);
byte[] payload = "{\"sub\":\"1234567890\",\"name\":\"John Doe\"}".getBytes(StandardCharsets.UTF_8);
System.out.println(url.encodeToString(header));
// eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
System.out.println(url.encodeToString(payload));
// eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIn0
}
}
withoutPadding() 调用返回一个新的编码器实例,行为完全相同,只是省略末尾填充;原实例不受影响,javadoc 说得精确。解码一侧同时接受带填充和不带填充的输入,所以你不带填充生产的值仍然能被严格解码器读出,这就是为什么无填充是任何跨越 API 边界的东西的安全选择。现在,一句大免责声明:上面两部分是 JWT 的未签名一半。一个真正的令牌需要对 "header.payload" 计算的签名,那是加密学,不是编码。在生产环境里,用 JOSE 库铸造并验证令牌:JJWT(0.13.0)或 nimbus-jose-jwt(10.9.1)。举例说,JJWT 的 API 构件只差一个坐标:
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.13.0</version>
</dependency>
<!-- 按项目文档,在运行时添加 jjwt-impl 和 jjwt-jackson -->
YouTube ID 是这个旋钮的另一面:十一字符的无填充 base64url,一个必须能扛住被粘到任何允许 URL 的地方而不死的标识符。如果你的系统生成的标识符要在 URL 里旅行,上面的 withoutPadding() 链就是该抄的形状。
编码文件
日常的文件活儿是解码器最爱的活儿的镜像:读一个文件,编码它,把文本写出去。用 java.nio.file 四行搞定:
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Base64;
public class EncodeFile {
public static void main(String[] args) throws Exception {
byte[] raw = Files.readAllBytes(Paths.get("report.pdf"));
String packed = Base64.getEncoder().encodeToString(raw);
Files.write(Paths.get("report.pdf.b64"), packed.getBytes(StandardCharsets.ISO_8859_1));
System.out.println(raw.length + " -> " + packed.length());
}
}
最后那个打印就是 33% 的账单,变得可见了。一个 1 MB 的文件变成大约 1.33 MB 的文本(原长的 4/3,再加至多两个填充字符),而如果你按 MIME 风格折行,换行还要再加百分之几:老邮件时代的算术至今仍然成立,是 4/3 乘以 78/76,也就是一个折行 MIME 载荷约为原长的 1.37 倍。两个推论。第一,任何存储或消息字段都要按编码后的长度定大小,而不是按原始长度:一个 VARCHAR(255) 列能爽快地装下一个 192 字节的原始值,却会拒收它 256 字符的编码。第二,编码方向是让内存变糟的那个方向,所以对大文件,数组版本是错的工具,流式部分才是对的工具。给文件圈一点小快乐:因为输出开头的字符是输入开头字节的纯函数,每个 Base64 编码的 PNG 都以 iVBORw0K 开头,每个编码的 GIF 都以 R0lGOD 开头;在任何一个字节被解码之前,你就能认出文件类型。
JSON、API 与 Data URI
编码输出在线上最常见的两个住处。
一:JSON 里的二进制。文件上传端点、内容 API、密钥库和 webhook 把二进制作为 Base64 文本嵌在 JSON 里,因为原始字节会打破 JSON 字符串转义。编码器一侧在边界处是一行代码,唯一的决定是规范要哪个方言:
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Base64;
public class JsonField {
public static void main(String[] args) throws Exception {
byte[] image = Files.readAllBytes(Paths.get("logo.png"));
// 规范说是 base64url,无填充:
String field = Base64.getUrlEncoder().withoutPadding().encodeToString(image);
// 把 "field" 作为普通字符串值交给你的 JSON 库。
System.out.println(field.length());
}
}
坑不在编码,而在读规范。有些 API 要带填充的标准 Base64,有些要不带填充的 base64url,还有几个对两者都宽松。规范沉默时,最省事的修法是看对面给的一个示例值:任何位置的 - 或 _ 都能定下字母表,末尾的 = 能定下填充。方言搞错通常不会让对面崩溃;它通常弄坏文件,而那是最慢的一类 bug。
二:data URI。把图片内联进 HTML 或 CSS 的那个 data:image/png;base64,... 字符串就是 RFC 2397 的 data URI:data:、可选的媒体类型、可选的 ;base64 标志、一个逗号,然后是数据。构造它只是字符串拼接,唯一的决定是标志在不在(没有标志意味着载荷是百分号编码的文本,而对二进制来说没人想要这个):
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Base64;
public class DataUriBuild {
public static void main(String[] args) throws Exception {
byte[] icon = Files.readAllBytes(Paths.get("icon.png"));
String b64 = Base64.getEncoder().encodeToString(icon);
String uri = "data:image/png;base64," + b64;
System.out.println(uri.substring(0, Math.min(40, uri.length())) + "...");
// data:image/png;base64,iVBORw0KGgo...
}
}
RFC 自己的建议在这里格外适用:data URI 是给短值用的。内联一个 50 KB 的图标是正常的取舍(少一次请求);内联一张 5 MB 的照片是穿着便利外衣的性能 bug。保住标志,让媒体类型保持诚实,让字节保持小。
构造 Basic 认证头
网上最老的认证头至今仍是 Java 里最简单的 Base64 用例,因为它恰好是一次编码调用。按 RFC 7617,Basic 请求发送 Authorization: Basic,后跟 username:password 的 Base64 编码;RFC 自己的例子 QWxhZGRpbjpvcGVuIHNlc2FtZQ==,就是乔装改扮的 "Aladdin:open sesame"。在客户端,构造这个头是两行 Base64 加一次现代 HTTP 调用:
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
public class BasicAuthClient {
public static void main(String[] args) throws Exception {
byte[] credentials = ("alice:secret123").getBytes(StandardCharsets.UTF_8);
String header = "Basic " + Base64.getEncoder().encodeToString(credentials);
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://example.com/api/status"))
.header("Authorization", header)
.GET()
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.statusCode());
}
}
这个头配三条提醒。第一,RFC 明说 Basic 是编码,不是保护:凭据对任何能看到包的人都可读,所以这个头只和它底下的 HTTPS 一样强,而在 TLS 之外用它都是馊主意。第二,字符集:RFC 预期 US-ASCII 凭据(其他一律 UTF-8,charset 认证参数只是建议性的),所以选 StandardCharsets.UTF_8,两边保持一致。第三,版本备注:java.net.http 客户端来自 Java 11;在更老的 JVM 上,同一个头用一次 setRequestProperty 调用挂到 HttpURLConnection 上,无论哪种方式,Base64 那一行都相同。在同一个头的服务端,解析和解码是姊妹指南的示例,带着第一冒号分割和常数时间比较。两侧是同一个 API 的两次调用,这就是它的静水深流的优雅。
配置、环境变量与列里的值
Base64 是一个文本容器,这就是它为什么出现在你料想不到的地方:env 文件里带分号的数据库 DSN、properties 文件里带引号的密码、config map 里多行的证书、TEXT 列里的二进制 blob(因为 schema 设计时还没人考虑过 BLOB)。编码一侧是一次调用,而诚实的定位就是它的本相:格式安全技巧,不是保密技巧:
import java.nio.charset.StandardCharsets;
import java.util.Base64;
public class ConfigEncode {
public static void main(String[] args) {
String dsn = "pg:host=db;password=qu\"ote";
byte[] raw = dsn.getBytes(StandardCharsets.UTF_8);
String packed = Base64.getEncoder().encodeToString(raw);
System.out.println(packed);
// cGc6aG9zdD1kYjtwYXNzd29yZD1xdSJvdGU=
System.out.println("DB_DSN_B64=" + packed);
}
}
两条规则让它保持诚实。第一,永远不要把机密存成 Base64 然后管它叫加密:Base64 不增加熵,也不移除信息,开发者一旦读到文件,一次调用就能解码出这个值,而 RFC 的安全章节指的正是这个失败:人们把"编码"过的协议交互一粘,凭据就暴露了。如果值是机密的,先加密,只有当通道要求文本时再把密文打包进 Base64。第二,给尺寸留预算:存储值比原始大约大三分之一,装得下原始值的列或字段装不下编码后的。而当值回来时,在边界处解码它,并保持为字节(对二进制)或显式字符集的字符串(对文本);那个方向是姊妹指南的地盘。
大数据的流式处理
编码是让内存变糟的方向,所以这里的大文件故事是关于把工作集保持在小。文件部分那个示例的数组版本,一直好用到文件不再舒服地装进内存为止;再往上,流适配器才是正解。wrap(OutputStream) 返回一个边写边编码的输出流,所以一个数吉字节的大文件永远不会作为一个字节数组被持有:
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Base64;
public class StreamEncode {
public static void main(String[] args) throws Exception {
OutputStream packed = Base64.getEncoder().wrap(Files.newOutputStream(Paths.get("bigfile.b64")));
InputStream raw = Files.newInputStream(Paths.get("bigfile.bin"));
byte[] buf = new byte[8192];
int n;
while ((n = raw.read(buf)) != -1) {
packed.write(buf, 0, n);
}
packed.close();
raw.close();
}
}
这个流上有一个行为值得高亮,因为 javadoc 自己就指向了它:包装流内部可以留有几个剩余字节,推荐做法是"使用后立即关闭返回的输出流,在此期间它会把所有可能的剩余字节刷新到底层输出流"。如果你停止写入、在关闭之前就读输出文件,你数据的尾部还坐在编码器里,文件看起来就像被截断了。这就是示例在别的东西碰文件之前先关闭 packed 的原因,而在生产环境你会把两个流都放进 try-with-resources 块里。养成习惯:在编码流上,关闭是编码的一部分。
遇见老江湖
接手来的代码库里满是早于 java.util.Base64 的 Base64 API,认出它们能让你免于"为什么它把我的输出折行了"之类的谜团。你真正会碰到的有四个:
| API | 你会在哪里遇到 | 怎么办 |
|---|---|---|
sun.misc.BASE64Encoder / BASE64Decoder |
Java 8 之前的代码 | 迁移到 java.util.Base64;Java 9 已移除 |
javax.xml.bind.DatatypeConverter |
XML 时代代码、老 web service | Java 11 移除(JEP 320);迁移 |
org.apache.commons.codec.binary.Base64 |
必须在 8 之前 JVM 上运行的代码 | 为 8 之前的支持保留;否则 JDK 类是默认 |
com.google.common.io.BaseEncoding |
重度 Guava 和大数据栈 | 照样能用;JDK 类没有依赖 |
sun.misc 这一对是有戏剧性的那个。它曾是一个内部的、不受支持的 API(那种在当时的 JDK 上编译良好、消失时连弃用警告都不给的 API),它的输出有自己的习惯,比如把编码文本折行,而相当多的"我的 Base64 里有换行"bug 正是出自这里。2017 年 9 月 Java 9 发布时,模块系统清理把它移除了,官方迁移指南毫不含糊:"值得注意的是,sun.misc.BASE64Encoder 和 sun.misc.BASE64Decoder 被移除了。改用受支持的 java.util.Base64 类,它在 JDK 8 中加入。"对仍然引用老类的代码运行 jdeps,工具会把该依赖标记为 "JDK removed internal API",这已经是 JDK 能给出的最接近交通锥的东西了。来自 JAXB 的 DatatypeConverter 活得更久但命运相似,在 Java 9 时代随 Java EE 模块一起被弃用,在 Java 11 被 JEP 320 "Remove the Java EE and CORBA Modules" 干脆移除。两次迁移都是机械性的:老的 printBase64Binary 和 BASE64Encoder().encode 调用与 getEncoder().encodeToString 一一对应(折行差异除外),而一旦代码跑在 java.util.Base64 上,它就能在 8 到 26 的每个 JDK 上运行,无需再费心思。
安全与速度
安全部分很短,因为编码器的活儿不可能在数据上失败,但它不是空的。Base64 不是加密,标准用字面说得清清楚楚:Base 编码"在视觉上隐藏了本易于识别的信息,比如密码,但不提供任何计算上的保密性",同一节还指出这"已知会导致安全事件"。编码器一侧的实用推论:不要为了让机密变安全而去编码它(现在它更不安全了,因为它能塞进更多通道);如果值是机密的,先加密再编码密文;还要记住可塑性孪生兄弟:接收方可以在不改变解码数据的情况下,把一种合法拼写换成另一种(填充不同、空闲比特里塞垃圾)。确定性编码器在这里帮上忙:java.util.Base64 对一个输入恰好产出一个输出,所以如果你的系统既写又读一个值,拼写是稳定的,需要规范形式检查的是信任边界上的外部值。
速度方面,编码器一侧与解码器一侧同一个故事:在现代 JVM 上,内置实现足够快,Base64 几乎从不是瓶颈,而且它是基准测试的参照点。姊妹指南提到的那个 2025 年 gRPC-java 基准测试(issue 11857,JMH,JDK 17 和 21)把 JDK 编码器的吞吐量放到 Guava 的大约 2.5 到 3.8 倍,差距最大的是 x86。两个实用备注:热路径上共享一个编码器实例(工厂本来就返回同一个共享实例),优先用 encode(byte[], byte[]) 写进预先定好大小的数组以跳过分配;对巨大数据,流式部分是内存故事,折行的成本相对磁盘只是噪音。Base64 里唯一真正的性能税是尺寸本身,没有任何实现,包括这个,能把它谈下来。
陷阱清单
所有陷阱集中陈列,个个都是 Java 专属:
- 缺失的字符集。不带显式字符集的
text.getBytes()用平台默认值:JDK 18+ 上碰巧对,更老的版本上错,原则上处处都错。传StandardCharsets.UTF_8,并把字符集写进规范。 - 带填充的 JWT。
getUrlEncoder()默认加填充,而令牌不想要填充。withoutPadding()调用是配方的一部分,不是可选配件;末尾带着=的令牌,会让一些验证器拒收,一些验证器弄坏。 - 折行的输出。MIME 编码器按 76 字符 CRLF 折行,且不加尾部换行。消费方期望尾部换行时,加上它;消费方期望完全没有换行时,别用 MIME 编码器。
- 双重编码。编码一个已经是 Base64 的值,会产出一个完全合法、完全无用的字符串。经典起因:一个字段从 API 到达时已经编码过,你的代码"好心"又编码了一遍。编码之前先检查。
- URL 里的加号。标准 Base64 输出含有
+,它在查询字符串里还没等服务器看到就已经是空格。一个标准字母表的值必须走 URL 时,要么百分号编码它,要么从一开始就用 URL 安全字母表生成它。 - 33% 的账单。装得下原始列的值装不下编码后的列。按
4 * ceil(n / 3)给存储、消息字段和头定大小,并记住折行的 MIME 输出还要在这之上多百分之几。 - 没关闭的流。包装输出流把剩余字节留到关闭为止。关闭之前读文件,得到的是被截断的编码。每次都用 try-with-resources。
- 一览无余的机密。Base64 是封箱胶带,不是锁。配置文件、日志或环境变量里的编码凭据就是可读凭据。先加密,否则干脆别存。
- Android 之墙。在 Android 上,
java.util.Base64只从 API 级别 26 起存在;再往下的框架类是android.util.Base64,带着它自己的标志常量(NO_PADDING、URL_SAFE和NO_WRAP)。不检查就硬编码其中一个,恰好会在你从没测过的设备上碎掉。 - 行长度怪癖。
getMimeEncoder(77, ...)会悄悄按 76 折行,因为长度会向下舍入到四的倍数,而要求 3 或更小会彻底关闭折行。如果你的格式要求奇数的行长度,MIME 旋钮不是对的工具。
从 sun.misc 到标准库
Java 的故事很短,前后分明。2014 年之前,如果你需要在 JDK 里用 Base64,拿到的是内部的一对 sun.misc.BASE64Encoder 和 sun.misc.BASE64Decoder,从第一天起就不受支持,带着它们 76 字符折行的习惯;或者在 XML 代码里伸手拿 javax.xml.bind.DatatypeConverter;或者把 Apache Commons Codec 或 Guava 加进构建,很多企业代码库就是这样落得三套 Base64 实现、谁也分不清谁的下场。2014 年 3 月 18 日,Java 8 带来了 java.util.Base64:一个类,三种字母表,RFC 4648 和 RFC 2045 的规则被正确实现,工厂模式,填充与折行旋钮,双向的流适配器。它就是这门语言一开始就该有的 Base64,而 javadoc 从那以后一直写着 Since: 1.8。
清理分两波到来。Java 9(2017 年 9 月 21 日)作为模块系统清理的一部分移除了 sun.misc 这一对,迁移指南把每个开发者都指向 JDK 8 的类;Java 11 移除了 JAXB 模块及其 DatatypeConverter(JEP 320)。Java 18(2022 年 3 月 22 日)落地 JEP 400 "UTF-8 by Default",它完全没碰 Base64,却改变了喂给它的懒惰 getBytes() 调用的失败模式:平台默认字符集在所有操作系统上变成 UTF-8,所以老的乱码模式在新 JVM 上干脆不再复现。自 1.8 以来,公开 API 没有改动过任何一个方法。动过的是底下的引擎:bug 修复和性能工作,所以社区基准测试不断发现标准库版本跑赢它所替换的那些老库。今天,在 8 到 26 的任何一个 JDK 上,"我如何在 Java 里 Base64 这个"的答案就是一个 import 加一次工厂调用,而且十多年来一直如此。
几个极客小确幸
因为一本手册该以微笑收尾,这里有一些纯粹好玩的 Java 专属事实:
- javadoc 写着
Since: 1.8,而且这话为真已经十二年。没有加一个方法,没有删一个方法,没有变一个行为:这门语言里冻结得最久的 API 面之一,而你用它时无需思考。 - 按 javadoc,
encodeToString用 ISO-8859-1 字符集构造结果 String。实践里这是完全不必要的细节,因为 Base64 输出是纯 ASCII,在 Latin-1、UTF-8 和字符集动物园的大多数成员里看起来一样,但 javadoc 还是要告诉你,这就是 JDK 的 JDK 本色。 - MIME 编码器在最后的短行之后不加行分隔符。其他工具,包括一些非常有名的邮件库,会在折行输出末尾加一个 CRLF。如果你与参考实现的 diff 恰好是末尾两个字符,你就找到了这个怪癖。
- 向
getMimeEncoder要 77 字符的行,它给你 76:行长度会悄悄向下舍入到最近的四的倍数,因为一个把四字符组拆开的折行会产出垃圾。这个 API 拒绝构造一行坏折行,而不是先征求你的许可。 Base64.getEncoder() == Base64.getEncoder()为真。工厂方法每次调用都返回同一个共享实例,所以"要一个新的"这个 API 只是单例的戏服,而线程安全的承诺只是对 JVM 本来就在做的事的描述。- 在 Android 上,孪生 API
android.util.Base64把同样的决定暴露为标志:NO_PADDING、URL_SAFE、NO_WRAP。两个 API,一张决定表,这是 Base64 设计如今已经尘埃落定的无声见证。 - RFC 4648 第 5 节是 "base64url" 这个名字的出生地:规范说 URL 安全编码"可以被称为 base64url",并警告它"不应被视为与 base64 编码相同"。它的起源被脚注引到 2001 年一个 P2P-hackers 邮件列表帖子,所以你粘进每个 URL 的这个名字,有着邮件列表的血统。
- 编码单词
base64,你得到YmFzZTY0,没有填充,因为六是三的倍数。一个描述自己本身的格式,技术等价于一个用摩尔斯电码说话的镜子,而这是镜子自己的倒影。 - 对 Java 8 之前的代码运行
jdeps -jdkinternals,看它把sun.misc.BASE64Encoder标记为 "JDK removed internal API"。官方迁移指南里这个工具的例子就是一个 Base64 类,那等于 JDK 指着你的 import 说"这事我们谈过了"。 - 1.37 因子。每个折行 MIME 载荷的成本约为原大小的 1.37 倍(字母表占 4/3,CRLF 节奏占 78/76),这个比例稳定到老邮件算术至今仍在引用它:1990 年代邮件基础设施对每个附件收取的过路费,正是
getMimeEncoder()今天开的账单。
朝着另一个方向
这就是故事里编码器的那一侧,也是两者中更温顺的那个:活儿从不在数据上失败,陷阱关乎你的决定(字符集、填充、折行、方言),而不是别人的意外,整个 API 装得进一个 import。另一个方向是 Base64 不再便利、开始敌对的地方,因为解码是你遇见别人填充选择、别人换行、别人字符集和别人护甲的地方,而一个 IllegalArgumentException 站在你和真相之间。Java 中的 Base64 解码,从本页链接过去,以同样的深度覆盖解码器:三种解码器脾气、确切的错误消息、填充规则、base64url 与 JWT、MIME 与 PEM,以及集中陈列的 Java 专属陷阱。把两篇当作一对读,整个主题就是你的了。
最后更新: 2026-09-08
相关文章: Java 中的 Base64 解码:完整指南