title: Unicode & UTF 编码方式学习
date: 2021/03/26 14:03
引言
一个同事问了一个问题,为什么 String 内部使用的是 char[] 来表示的,但是调用 str.getBytes().length 得到的值却不是 2 呢?在 java 中一个 char 的大小不是固定占用 2 个字节吗?
public static void main(String[] args) {
String str = "A";
System.out.println("length = " + str.getBytes().length); // 1
String str2 = "中";
System.out.println("length = " + str2.getBytes().length); // 3
}
首先 java 中的 char 采用的是 UTF-16GE 编码规则,其中 Unicode 的基本平面(BMP,0000 ~ FFFF)它采用 2 个字节表示,补充平面(10000 ~ 10FFFF)采用 4 个字节来表示,因为基本平面的字符比较常用,所以 Java 中的 char 也就只占 2 个字节,当遇到补充平面的就需要用字符串来表示了。
其次 java 中的 String 很简单,其中保存了一个 char[],因为 char 采用的是 UTF-16GE 编码规则,所以字符串也是用的 UTF-16GE 编码规则,那么获取到的应该是两个字节才对呀!问题就出在 String.getBytes()
方法这,我们看一下吧:
因为 idea 中文件的 encoding 默认为 u8,所以调用 getBytes() 方法的时候就将我们 u16 的 char[] 转换成了 u8。
这个 encoding 可以通过 JVM 参数进行设置。
什么是 Unicode
世界上有很多的国家的字符,每个国家的各种计算机厂商都对自己常用的字符进行编码,在编码的时候基本忽略了别的国家的字符和编码,甚至忽略了同一国家的其他计算机厂商,这样造成的结果就是,出现了太多的编码,且互相不兼容。
世界上所有的字符能不能统一编码呢?可以,这就是Unicode。
Unicode 做了一件事,就是给世界上所有字符都分配了一个唯一的数字编号,这个编号范围从0x000000到0x10FFFF(2^21),包括110多万。但大部分常用字符都在0x0000到0xFFFF(2^16)之间,即65536个数字之内。每个字符都有一个Unicode编号,这个编号一般写成16进制,在前面加U+。大部分中文的编号范围在U+4E00到U+9FA5,例如,"贤"的Unicode是U+8D24。
Unicode 并没有规定这个编号怎么对应到二进制表示,它只规定了哪些值能表示哪些字符,但没有规定了每个字符对应的二进制是什么,而Unicode本身只规定了每个字符的数字编号是多少。
Unicode其实应该是一个码值表。Unicode的功用是为每一个字符提供一个唯一的数字码,而对数字码的存储规则的定义则需要依靠UTF-8/UTF-16/UTF-32
UTF-8/UTF-16/UTF-32是通过对 Unicode 码值进行对应规则转换后,编码保持到内存/文件中。UTF-8/UTF-16 都是可变长度的编码方式。
编码规则
UTF-32
这个最简单,就是字符编号的整数二进制形式,四个字节。
但有个细节,就是字节的排列顺序,如果第一个字节是整数二进制中的最高位,最后一个字节是整数二进制中的最低位,那这种字节序就叫“大端”(Big Endian, BE),否则,正好相反的情况,就叫“小端”(Little Endian, LE)。对应的编码方式分别是UTF-32BE和UTF-32LE。比如:
Unicode编码 | UTF32-LE | UTF32-BE |
---|---|---|
0x006C49 | 49 6C 00 00 | 00 00 6C 49 |
0x020C30 | 30 0C 02 00 | 00 02 0C 30 |
注意:之所以有大端和小端两种方式,是因为硬件读写顺序的不同。
大端:数据的高字节保存在内存的低地址中,低字节保存到内存的高地址中,和我们的阅读习惯一致;小端则相反,常用的X86结构是小端模式。
采用大端方式进行数据存放符合人类的正常思维,而采用小端方式进行数据存放利于计算机处理。
可以看出,每个字符都用四个字节表示,非常浪费空间,实际采用的也比较少。
UTF-32是因为UTF-16编码方式不能表示全部的字符而扩充的编码方式。
其实我觉得用 3 个字节就够了啊,反正也就 10FFFF 个字符啊。
UTF-16
在了解 UTF-16 编码方式之前,先了解一下另外一个概念——"平面"。
在上面的介绍中,提到了 Unicode 是一本很厚的字典,她将全世界所有的字符定义在一个集合里。这么多的字符不是一次性定义的,而是分区定义。每个区可以存放 65536 个(2^16)字符,称为一个平面(plane)。目前,一共有 17 个平面(65536*17 = 1,114,112 也就是110多万),也就是说,整个 Unicode 字符集的大小现在是 2^21。
最前面的 65536 个字符位,称为基本平面(简称 BMP ),它的码点范围是从 0 到 2^16-1,写成 16 进制就是从 U+0000 到 U+FFFF,所有最常见的字符都放在这个平面,这是 Unicode 最先定义和公布的一个平面。剩下的字符都放在辅助平面(简称 SMP ),码点范围从 U+010000 到 U+10FFFF。
基本了解了平面的概念后,再说回到 UTF-16。UTF-16 编码介于 UTF-32 与 UTF-8 之间,同时结合了定长和变长两种编码方法的特点。它的编码规则很简单:基本平面的字符占用 2 个字节,辅助平面的字符占用 4 个字节。也就是说,UTF-16 的编码长度要么是 2 个字节(U+0000 到 U+FFFF,也就是),要么是 4 个字节(U+010000 到 U+10FFFF)。
那么问题来了,当我们遇到两个字节时,到底是把这两个字节当作一个字符还是与后面的两个字节一起当作一个字符呢?
为了将两个字节的UTF-16编码与四个字节的UTF-16编码区分开来,Unicode编码的设计者将0xD800-0xDFFF(2^11 个编码)保留下来,并称为代理区(Surrogate):
辅助平面的字符位共有 2^20 个,因此表示这些字符至少需要 20 个二进制位。UTF-16 将这 20 个二进制位分成两半,前 10 位映射在 U+D800 到 U+DBFF,称为高代理位(H),后 10 位映射在 U+DC00 到 U+DFFF,称为低代理位(L)。这意味着,一个辅助平面的字符,被拆成两个基本平面的字符表示。
D800-DB7F | High Surrogates | 高位替代 |
---|---|---|
DC00-DFFF | Low Surrogates | 低位替代 |
总结下:
UTF-16将基本平面(BMP)使用两个字节表示,一共有 2^16 - 2^11 (减去代理区的字编码)个编码
代理区一共 2^11 个编码,辅助平面差不多 2^20 个字符,刚好可以通过 2^10 * 2^10 来表示出来。
如果U≥0x10000,我们先计算U'=U-0x10000,然后将U'写成二进制形式:yyyy yyyy yyxx xxxx xxxx,U的UTF-16编码(二进制)就是:110110yyyyyyyyyy 110111xxxxxxxxxx。
按照上述规则,Unicode编码0x10000-0x10FFFF的UTF-16编码有四个字节,前两个字节的高6位是110110,后两个字节的高6位是110111。可见,前两个字节的取值范围(二进制)是11011000 00000000到11011011 11111111,即0xD800-0xDBFF。后两个字节取值范围(二进制)是11011100 00000000到11011111 11111111,即0xDC00-0xDFFF。
因此,当我们遇到两个字节,发现它的码点在 U+D800 到 U+DBFF 之间,就可以断定,紧跟在后面的两个字节的码点,应该在 U+DC00 到 U+DFFF 之间,这四个字节必须放在一起解读。
接下来,以汉字"𠮷"为例,说明 UTF-16 编码方式是如何工作的。
汉字"𠮷"的 Unicode 码点为 0x20BB7
,该码点显然超出了基本平面的范围(0x0000 - 0xFFFF),因此需要使用四个字节表示。首先用 0x20BB7 - 0x10000
计算出超出的部分,然后将其用 20 个二进制位表示(不足前面补 0 ),结果为0001000010 1110110111
。接着,将前 10 位映射到 U+D800 到 U+DBFF 之间,后 10 位映射到 U+DC00 到 U+DFFF 即可。U+D800
对应的二进制数为 1101100000000000
,直接填充后面的 10 个二进制位即可,得到 1101100001000010
,转成 16 进制数则为 0xD842
。同理可得,低位为 0xDFB7
。因此得出汉字"𠮷"的 UTF-16 编码为 0xD842 0xDFB7
。
和UTF-32一样,UTF-16也有UTF-16LE和UTF-16BE之分,例如:
Unicode编码 | UTF-16LE | UTF-16BE | UTF32-LE | UTF32-BE |
---|---|---|---|---|
0x006C49 | 49 6C | 6C 49 | 49 6C 00 00 | 00 00 6C 49 |
0x020C30 | 30 DC 43 D8 | D8 43 DC 30 | 30 0C 02 00 | 00 02 0C 30 |
注意:UTF-16常用于系统内部编码,我们平常说的 “Unicode编码是2个字节” 这句话,其实是因为windows系统默认的Unicode编码就是UTF-16,在常用基本字符上2个字节的编码方式已经够用导致的误解,其实是可变长度的。在没有特殊说明的情况下,常说的Unicode编码可以理解为UTF-16编码,而且是UTF-16BE编码
UTF-16 比 UTF-32 节省了很多空间,但是任何一个字符都至少需要两个字节表示,对于美国和西欧国家而言,还是很浪费的,于是有了 UTF-8。
UTF-8
UTF-8就是使用变长字节表示,每个字符使用的字节个数与其Unicode编号的大小有关,编号小的使用的字节就少,编号大的使用的字节就多,使用的字节个数从1到4个不等。
具体来说,各个Unicode编号范围对应的二进制格式如下表所示
Unicode编码(十六进制) | UTF-8 字节流(二进制) |
---|---|
000000-00007F | 0xxxxxxx |
000080-0007FF | 110xxxxx 10xxxxxx |
000800-00FFFF | 1110xxxx 10xxxxxx 10xxxxxx |
010000-10FFFF | 11110xxx10xxxxxx10xxxxxx10xxxxxx |
图中的x表示可以用的二进制位,而每个字节开头的1或0是固定的。
小于128的(即0x00-0x7F之间的字符),编码与Ascii码一样,最高位为0。其他编号的第一个字节有特殊含义,最高位有几个连续的1表示一共用几个字节表示,而其他字节都以10开头。4字节模板有21个x,即可以容纳21位二进制数字。Unicode的最大码位0x10FFFF也只有21位。
对于一个Unicode编号,具体怎么编码呢?首先将其看做整数,转化为二进制形式(去掉高位的0),然后将二进制位从右向左依次填入到对应的二进制格式x中,填完后,如果对应的二进制格式还有没填的x,则设为0。
例1:“汉”字的Unicode编码是0x6C49。0x6C49在0x0800-0xFFFF之间,使用3字节模板:1110xxxx 10xxxxxx 10xxxxxx。将0x6C49写成二进制是:0110 1100 0100 1001, 用这个比特流依次代替模板中的x,得到:11100110 10110001 10001001,即E6 B1 89。
例2:Unicode编码0x20C30在0x010000-0x10FFFF之间,使用4字节模板:11110xxx 10xxxxxx 10xxxxxx 10xxxxxx。将0x20C30写成21位二进制数字(不足21位就在前面补0):0 0010 0000 1100 0011 0000,用这个比特流依次代替模板中的x,得到:11110000 10100000 10110000 10110000,即F0 A0 B0 B0。
注意:UTF-8和UTF-32/UTF-16不同的地方是UTF-8是兼容 Ascii(因为他存储 ASCII 码的时候也用的 1 个字节)的,对大部分中文而言,一个中文字符需要用三个字节表示。UTF-8的优势是网络上数据传输英文字符只需要1个字节,可以节省带宽资源。所以当前大部分的网络应用都使用UTF-8编码,因为网络应用的代码编写全部都是使用的英文编写,占据空间小,网络传输速度快。
字节顺序标记(BOM)
字节顺序标记(英语:byte-order mark,BOM),他是 Unicode 定义的一个标识字节序的一个字符,所以在其他编码中是没有 BOM 这个概念的。
字符 U+FEFF 如果出现在字节流的开头,则用来标识该字节流的字节序,是高位在前还是低位在前。U+FEFF只能出现在字节流的开头。
UTF-16
BOM出现在字符串流的第一个字符,用来标识这个字符串流是大端还是小端。
编码 | 16 进制 | 字节数组 |
---|---|---|
UTF-16GE | FEFF(BOM) 4E2D(中) | -2 -1 78 45 |
UTF-16LE | FFFE(BOM 倒过来) 2D4E(中的Unicode 码 16 进制倒过来) | -1 -2 45 78 |
UTF-8
在UTF-8中,虽然在 Unicode 标准上允许字节顺序标记的存在,但实际上并不一定需要。UTF-8编码过的字节顺序标记则被用来标示它是UTF-8的文件。
编码 | 16 进制 | 字节数组 |
---|---|---|
UTF-16GE | FEFF(BOM) 4E2D(中) | -2 -1 78 45 |
UTF-16LE | FFFE(BOM 倒过来) 2D4E(中的Unicode 码 16 进制倒过来) | -1 -2 45 78 |
UTF-8 | e4 b8 ad | -28 -72 -83(中) |
UTF-8 with BOM | ef bb bf(BOM)e4 b8 ad(中) | -17 -69 -65(BOM)-28 -72 -83(中) |
为啥 u8 中的字节都是负数呢?
因为除了ASCII (0~7F)码表是以 0 开头的,其他的都以 1 开头
为什么 UTF-8 没有大小端的区分?
我觉得有大小端主要是为了让计算机能更快的读数据,而 u8 几乎所有的字符(除了前 128)都需要手动的转成 Unicode 码,所以没必要
为什么 UTF-8 不需要字节顺序标记?
因为utf8是单字节为编码单元,在网络传输时,不存在字节序列问题。在解码时,由于首字节记录了总字节数,所以能正确解码。
因为utf16是定长编码,总是以2个字节为编码单元,在网络传输时,不存在字节序列问题。在解码时,由于cpu硬件差异,存在字节序问题,所以通过BOM来标记字节顺序;
例如,一个“奎”的Unicode编码是594E,“乙”的Unicode编码是4E59。如果我们收到UTF-16字节流“594E”,那么这是“奎”还是“乙”?如果BOM是大端序,那么代码点就应该是594E,那么就是“奎”,如果BOM是小端序,那么代码点就应该是4E59,就是“乙”了。
UTF-8 比 UTF-16 好处?
- 前 128 位可以使用 1 个字节表示
- 向下兼容 ASCII 码(都是一个字节)
- 没有字节序的要求
UTF-32
虽然字节顺序标记亦可以用于UTF-32,但这个编码很少用于传输,其规则如同UTF-16。
乱码的原因和可逆性
乱码产生的根源一般情况下可以归结为三方面即:编码引起的乱码、解码引起的乱码以及缺少某种字体库引起的乱码(这种情况需要用户安装对应的字体库),其中大部分乱码问题是由不合适的解码方式造成的。
Java 与 Unicode
在 Java内部进行字符处理时,采用的都是Unicode,具体编码格式是UTF-16BE。简单回顾一下,UTF-16使用两个或四个字节表示一个字 符,Unicode编号范围在65536以内的占两个字节,超出范围的占四个字节,BE (Big Endian)就是先输出高位字节,再输出低位字节,这与整数的内存表示是一致的。
char本质上是一个固定占用两个字节的无符号正整数,这个正整数对应于Unicode编号,用于表示那个Unicode编号对应的字符。
由于固定占用两个字节,char只能表示Unicode编号在65536以内的字符,而不能表示超出范围的字符。
那超出范围的字符怎么表示呢?只能使用String类来表示,例如汉字"𠮷"的 Unicode 码点为 0x20BB7
,该码点显然超出了65535,所只能用String表,而当粘贴到代码中时,自动转换为了两个字符"\uD842\uDFB7"
@Test
public void test() throws UnsupportedEncodingException {
// 此处要写的是 u16 编码规则的字符串值,因为 java 中 char 和 string 都是 u16 的规范
String str = "\u4e2d"; // 中
System.out.println("===================utf32===================");
// 00004e2d
System.out.println(bytesToHexStr(str.getBytes("utf-32")));
// 00004e2d
System.out.println(bytesToHexStr(str.getBytes("utf-32be")));
// 2d4e0000
System.out.println(bytesToHexStr(str.getBytes("utf-32le")));
System.out.println(new String(str.getBytes("utf-32"), "utf-32"));
System.out.println("===================utf16===================");
// feff4e2d,为什么这个地方多了 feff?
// 因为我们获取的时候没有指定大小端,所以它帮我们加了 BOM,从而让我们确定读取方式。
System.out.println(bytesToHexStr(str.getBytes(StandardCharsets.UTF_16)));
// 4e2d
System.out.println(bytesToHexStr(str.getBytes(StandardCharsets.UTF_16BE)));
// 2d4e
System.out.println(bytesToHexStr(str.getBytes(StandardCharsets.UTF_16LE)));
System.out.println(new String(str.getBytes(StandardCharsets.UTF_16), StandardCharsets.UTF_16));
System.out.println("===================utf8===================");
// e4b8ad
System.out.println(bytesToHexStr(str.getBytes(StandardCharsets.UTF_8)));
System.out.println(new String(str.getBytes(StandardCharsets.UTF_8), StandardCharsets.UTF_8));
System.out.println("===================ascii===================");
// 任何超出码表的都会被编码成 3f,对应码表的? ![](https://tva1.sinaimg.cn/large/008eGmZEgy1gox8ommuzdj316i0eon81.jpg)
System.out.println(bytesToHexStr(str.getBytes(StandardCharsets.US_ASCII)));
System.out.println(new String(str.getBytes(StandardCharsets.US_ASCII), StandardCharsets.US_ASCII));
}
private static final char[] HEX_CHAR = {'0', '1', '2', '3', '4',
'5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'};
public static String bytesToHexStr(byte[] bytes) {
int index = 0;
char[] hexChar = new char[bytes.length * 2];
for (byte aByte : bytes) {
hexChar[index++] = HEX_CHAR[aByte >> 4 & 0xF];
hexChar[index++] = HEX_CHAR[aByte & 0xF];
}
return new String(hexChar);
}
为什么 Java 选用 UTF-16
因为当时 Java 选择的是UCS-2(UCS2 是 UTF-16 的前身,表示的是 Unicode BMP 上的全部字符),是一个定长的编码,在当时以单个码元能表示所有字符,以
下标获取字符的开销基本等同于在数组中索引一个字符,用起来是最方便的,多占用的内存大小也是可以接受的。到了后来UCS-2无法表示所有Unicode字符的时候,过渡到了兼容它的UTF-16 上也是最自然以及迁移成本最低的选择,这很好理解。
UTF-16 浪费内存的问题
UTF-16 中字符最小都要占用 1 个字节,如果只用 00~FF 的字符时相当于多占用了一倍的内存,所以 JDK 在 JEP254 Comoact Strings中优化了这个问题。我们看下新版本的 String 的源码吧。
String s = new String(new char[]{'a', 'b', '中', '\uD83D', '\uDC33'});
System.out.println("s = " + s.length());
String s = "ab";
System.out.println("s = " + s.length());
注意:并不是说 jdk9 采用了 u8 作为字符串的编码方式,它还是采用的 u16ge,只不过通过代码层面上节省了前 256 个字符的一个字节。而且 u8 中能用一个字节表示的字符范围是 0~7F(128 个)
正确的遍历字符串的方式
@Test
public void test2() {
String str = "abc\uD83D\uDC33\uD83D\uDC33\uD83D\uDC20123中文";
for (int offset = 0; offset < str.length(); ) {
// 从当前偏移量获取到下一个字符的 Unicode 编码(10 进制)
int i = str.codePointAt(offset);
// 获取到这个 Unicode 字符占用了多少个 java char
int charCount = Character.charCount(i);
// 切割出这个字符
String ch = str.substring(offset, offset + charCount);
// do something...
System.out.println(ch);
offset += charCount;
}
}
为什么常量池中的字符串是 utf8,运行时还要把他转成 utf16
我猜可能是因为 u8 中前 128 个字符只需要用 1 个字节,使用 u8 作为字符串的类型可能生成的 class 文件会比较小。
而且 class 文件的编码格式也是 u8,里面总不能用 gbk 的字符串吧。
String 有长度限制吗?
String 的长度是有限制的。
- 编译期的限制:字符串的UTF8编码值的字节数不能超过65535(底层的限制),字符串的长度不能超过65534(javac 编译器的限制);
- 运行时限制:字符串的长度不能超过2^31-1(int 的最大值),占用的内存数不能超过虚拟机能够提供的最大值。
https://www.cnblogs.com/54chensongxia/p/13640352.html
https://segmentfault.com/a/1190000020381075
MySQL 中的 utf8 和 utf8mb4
在mysql 5.5 之前,utf8编码只支持1-3个字节,从mysql 5.5开始,可支持4个字节UTF编码utf8mb4,一个字符最多能有4字节,所以能支持更多的字符集。
https://mp.weixin.qq.com/s/CcC2VUYdCq2U5BcSpFMUaw
总结
Unicode 只是一个数字
编码规则 UTF 表示的是采用什么策略将这个数字转换成二进制
Unicode 值(16 进制值) | 字符 | UTF-32GE(4 字节) | UTF-16GE | UTF-8 | jdk8 字符串(u16) | jdk9 字符串压缩(u16) |
---|---|---|---|---|---|---|
中(中) | 中 | \U00004e2d | \u4e2d(2 字节) | e4 b8 ad(3 字节) | 2 字节 | 2 字节 |
() | BOM,字节顺序标记 | \U0000feff | \ufeff(2 字节) | ef bb bf(3 字节) | 2 字节 | 2 字节 |
🐳(🐳) | 🐳 | \U0001F433 | \uD83D\uDC33(4 字节) | f0 9f 90 b3(4 字节) | 4 字节 | 4 字节 |
a(a) | a | \U00000061 | \u0061(2 字节) | 61(1 字节) | 2 字节 | 1 字节 |
€(€)刚好超出 ASCII | � | \U00000080 | \u0080(2 字节) | c2 80(2 字节) | 2 字节 | 1 字节 |
参考文章
【原创】经验分享:一个小小emoji尽然牵扯出来这么多东西?