1 前言
之前我写了关于http上传文件转成Base64的文章(再学http-为什么文件上传要转成Base64? - 掘金 (juejin.cn)),算是基本上了解了Base64编码的原理,但是所谓知道的越多,不知道的越多,我在后面发现还是有许多问题值得深究。我们都知道Base64编码自己有一个编码表,0到63码值对应的字符是"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",每次编码都需要通过这个编码表找到对应的字符,那么问题来了,假如有一个原字符'A',在经过Base64编码后得到'QQ==',字符'Q'(先忽略其他字符)对应的Base64码值是16(二进制是00010000),那么运行在jvm内部怎么去表示这些字符,因为内存中最终存储的都是二进制数,那在jvm的内存中是不是存储的就是'00010000'二进制数?这个问题比较难描述,如果不理解,可简单地理解为一个字符比如'A'在jvm内部是怎么存储的。
2 jvm内部的编码方式
在java中程序最终是需要靠jvm去运行的,jvm会加载编译好的class文件,那么它是以什么样的编码方式把class文件存到内存中,java语言规范描述的是java遵循Unicode编码,具体采用utf-16编码方式,官方描述可以参考Chapter 3. Lexical Structure (oracle.com)的3.1小节。
2.1 Unicode
Unicode是国际标准字符集,编码范围0x0000 - 0x10FFFF,可表示一百多万个字符(0x10FFFF的十进制是1114111),每个编号都是唯一的,我们称这样的编号为代码点,而utf-16和utf-8是Unicode的具体实现,既然有Unicode标准字符集,为什么还要有具体实现呢?因为Unicode表示字符的字节数是可变的,这样在具体的程序中就无法区分是一个完整的代码点还是代码点的前缀,比如字符“😊”的unicode编码是'0x1F60A',但是如果读取时只读到'0x1F60',则就表示为'ὠ',和原来所表示的意思就完全不一样了。所以需要某种具体编码实现去解决这个问题。
2.2 utf-16编码
jvm内部采用的编码方式是utf-16,我们首先来看看utf-16的具体编码规则,utf-16是以固定长度2字节或4字节来表示每个字符的,0x0000 - 0xFFFF的字符使用2个字节存储(基本平面),2字节存储的就是原Unicode的编码,0x10000 - 0x10FFFF的字符使用4字节表示(辅助平面),前2个字节的前6位二进制固定为110110,后两个字节的前6位二进制固定为110111, 前后部分各剩余10位二进制从右到左填充符号的Unicode原编码减去0x10000的结果,不足补0,通过这种方式也就能解决前面说的Unicode编码前缀的问题。我们再通过一个例子来说明这种转换。
给定的字符是汉字'𠀝',读kong,Unicode是0x2001D,因为0x2001D大于0xFFFF,所以在utf-16编码中需要用4个字节表示,转换的最终的utf-16编码是'uD840uDC1D'(0x和u都表示16进制,java字符串中用u表示),得到的这个结果大家可在网上查找在线转换工具去验证下。
接着回到jvm内部编码的方式,当我们把'uD840uDC1D'当成一个字符串打印出来的时候,控制台显示的就是'𠀝',这里有个小细节,当使用idea编辑器时,复制字符'𠀝'到字符串变量上,idea会自动帮我们转换成utf-16。
因为字符串就是输入什么输出就是什么,那也就证明了'𠀝'在jvm内部是以'uD840uDC1D'utf-16的方式存储的。
3 jvm内部编码转成Base64编码的过程
3.1 字符串在jvm内部存储只有一种方式
现在我们来回答开篇提到的问题,字符'Q'在jvm内部是怎么存储的?Q对应的Unicode编码是'0x0051',用两个字节表示,那么在jvm存储的就是'u0051',转成二进制是'0000000001010001',而不是之前想的'00010000',同样对于Base64编码'QQ=='的原字符'A'在jvm内部的存储就是'u0041',对应的二进制是'0000000001000001'。所以字符串在jvm内部的存储与其他外部编码方式没有关系,内部只有utf-16的一种方式,往下看来具体分析。
3.2 转换过程
到这里先提两个问题,Base64编码本质上是对什么编码?jvm内部存储的utf-16编码字符是怎么转成我们需要的编码方式字符?我们首先看jdk提供的一个Base64工具类。
encode方法需要传入的参数是字节数组,那就说明Base64实际是对字节流编码,进一步说明如果字节流不一样那么Base64编码的结果就不一样,这与获取字节数组时传入的编码方式有关,也就是这个例子中getBytes方法的参数。我们看下下面的运行结果。
这里我把字符改成了'𠀝',因为'A'在不同编码方式中的编码都是一样的,体现不出来效果。从上面我们可以看到当用UTF-8和GBK获取的字节数组通过Base64编码的结果是不一样的。接着我们来看看刚刚提到的第二个问题,jvm内部存储到转成Base64编码涉及到哪些过程。
String encodeBase64 = Base64.getEncoder().encodeToString("𠀝".getBytes("UTF-8"));
System.out.println(encodeBase64);
这一行代码整个转换过程如下:
(1) getBytes方法通过UTF-8的方式获取'𠀝'的字节数组,'𠀝'在jvm内部以utf-16的编码方式存在表示为'uD840uDC1D'。
(2) utf-16转成Unicode表示为'0x2001D',根据这个Unicode编码转成utf-8表示为'xf0xa0x80x9d'(x代表十六进制),对应的二进制就是'11110000101000001000000010011101',这一步我们可以来验证下。
public static void main(String[] args) throws UnsupportedEncodingException {
String str = "uD840uDC1D";
System.out.println("原字符:" + str);
final byte[] bytes = str.getBytes(StandardCharsets.UTF_8);
// 这里使用了BigInteger类,1代表正数
BigInteger bigInt = new BigInteger(1, bytes);
// toString(2)将BigInteger对象转换为二进制字符串
String binaryString = bigInt.toString(2);
System.out.println("转成utf-8后二进制:" + binaryString);
String hexString = bigInt.toString(16);
System.out.println("转成utf-8后十六进制:" + hexString);
}
运行这段程序的结果如下:
很明显与我们的预期相同。
(3) encodeToString方法对得到的字节数组'xf0xa0x80x9d'编码,输出'8KCAnQ==',最终这一串字符串又以utf-16的方式存回jvm内存中。
4 总结
在上面的分析中我们可以得出,Base64的码值并不会最终存储在jvm内部的,编码值只是一种让我们找到对应可打印字符的一种映射,同样在网络中传输也不会是对应的编码值,而是通过某种外部编码方式(如utf-8)转换得到的字节流。说了这么多,感觉有点晕了,但其实这里要大家记住的只有jvm内部编码方式是utf-16这一点,以这个为基础去理解其他转换就很简单了。