需要使用 Base64 格式吗?那么本网站正好适合您!使用我们的在线工具对数据进行编码或解码,便捷好用。

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 2b2f;反过来做,则会得到 2d5f。字母表不匹配是野外最常见的单一 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 崩溃,消息里通常带着 2d5f2b2f。每次都要让解码器匹配协议。
  • 来自野外的尾随空白。从终端、环境变量或配置文件复制的值常常带着换行到达,严格解码器会把它变成 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()nullNullPointerException,不是空数组。如果变量可能为 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(它精心挑选字母表,剔除 7Ogo 这类容易看混的字符)。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.BASE64Encodersun.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 编码:完整指南