一般一个字符集等同于一个编码方式,ANSI 体系( ANSI 是一种字符代码,为使计算机支持更多语言,通常使用 0x80~0xFF 范围的 2 个字节来表示 1 个字符)的字符集如 ASCII、ISO 8859-1、GB2312、 GBK 等等都是如此。一般我们说一种编码都是针对某一特定的字符集。一个字符集上也可以有多种编码方式,例如 UCS 字符集(也是 Unicode 使用的字符集)上有 UTF-8、UTF-16、UTF-32 等编码方式。
从计算机字符编码的发展历史角度来看,大概经历了三个阶段:
我们知道,计算机内部,所有信息最终都是一个二进制值。每一个二进制位(bit)有0和1两种状态,因此八个二进制位就可以组合出256种状态,这被称为一个字节(byte)。也就是说,一个字节一共可以用来表示256种不同的状态,每一个状态对应一个符号,就是256个符号,从00000000到11111111。
上个世纪60年代,美国制定了一套字符编码,对英语字符与二进制位之间的关系,做了统一规定。这被称为 ASCII 码(American Standard Code for Information Interchange),一直沿用至今。
ASCII 码一共规定了128个字符的编码,比如空格SPACE是32(二进制00100000),大写的字母A是65(二进制01000001)。这128个符号(包括32个不能打印出来的控制符号),只占用了一个字节的后面7位,最前面的一位统一规定为0。
英语用128个符号编码就够了,但是用来表示其他语言,128个符号是不够的。比如,在法语中,字母上方有注音符号,它就无法用 ASCII 码表示。于是,一些欧洲国家就决定,利用字节中闲置的最高位编入新的符号。比如,法语中的é的编码为130(二进制10000010)。这样一来,这些欧洲国家使用的编码体系,可以表示最多256个符号。
但是,这里又出现了新的问题。那就是不同的国家的字符集可能不同,就算它们都能用 256 个字符表示全,但是同一个码点(也就是 8 位二进制数)表示的字符可能可能不同。例如,144 在阿拉伯人的 ASCII 码中是 گ,而在俄罗斯的 ASCII 码中是 ђ。
因此,ASCII 码的问题在于尽管所有人都在 0 - 127 号字符上达成了一致,但对于 128 - 255 号字符上却有很多种不同的解释。
2.1)GB2312
亚洲语言有更多的字符需要被存储,一个字节已经不够用了。但是这难不倒智慧的中国人民,我们不客气地把那些 127 号之后的奇异符号们直接取消掉, 规定:
这样我们就可以组合出大约 7000 多个简体汉字了。在这些编码里,我们还把数学符号、罗马希腊的字母、日文的假名们都编进去了,连在 ASCII 里本来就有的数字、标点、字母都统统重新编了两个字节长的编码,这就是常说的 全角字符。而原来在 127 号以下的那些就叫 半角字符 了。
中国人民看到这样很不错,于是就把这种汉字方案叫做 GB2312。GB2312 是对 ASCII 的中文扩展。
2.2)GBK
但是中国的汉字太多了,很快就发现有许多人名没有办法打出来。于是我们不得不继续把 GB2312 没有用到的码位找出来老实不客气地用上。
后来还是不够用,于是干脆不再要求低字节一定是 127 号之后的内码,只要第一个字节是大于 127 就固定表示这是一个汉字的开始,不管后面跟的是不是扩展字符集里的内容。结果扩展之后的编码方案被称为 GBK 标准,GBK 包括了 GB2312 的所有内容,同时又增加了近 20000 个新的汉字(包括繁体字)和符号。
2.3)GB18030 / DBCS
后来少数民族也要用电脑了,于是我们再扩展,又加了几千个新的少数民族的字,GBK 扩成了 GB18030。从此之后,中华民族的文化就可以在计算机时代中传承了。
中国的程序员们看到这一系列汉字编码的标准是好的,于是通称他们叫做 DBCS(Double Byte Charecter Se)t:双字节字符集。
在 DBCS 系列标准里,最大的特点是两字节长的汉字字符和一字节长的英文字符并存于同一套编码方案里,因此他们写的程序为了支持中文处理,必须要注意字串里的每一个字节的值,如果这个值是大于 127 的,那么就认为一个双字节字符集里的字符出现了。
因为当时各个国家都像中国这样搞出一套自己的编码标准,结果互相之间谁也不懂谁的编码,谁也不支持别人的编码。最终,美国人意识到他们应该提出一种标准方案来展示世界上所有语言中的所有字符,出于这个目的,Unicode 诞生了。Unicode 源于一个很简单的想法:将全世界所有的字符包含在一个集合里,计算机只要支持这一个字符集,就能显示所有的字符,再也不会有乱码了。
Unicode 当然是一个很大的集合,现在的规模可以容纳100多万个符号。每个符号的编码都不一样,比如,U+0639表示阿拉伯字母Ain,U+0041表示英语的大写字母A,U+4E25表示汉字严。具体的符号对应表,可以查询unicode.org(Index),或者专门的汉字对应表(字体编辑用中日韩汉字Unicode编码表 - 编著:资深中韩翻译金圣镇 金圣镇)。
1)设计思路:
它从 0 开始,为每个符号指定一个编号,这叫做”码点”(code point)。比如,码点 0 的符号就是 null(表示所有二进制位都是 0)。
U+0000 = null
上式中,U+表示紧跟在后面的十六进制数是 Unicode 的码点。
2)基本平面和辅助平面:
这么多符号,Unicode 不是一次性定义的,而是分区定义。每个区可以存放 65536 个(2^16)字符,称为一个平面(plane)。目前,一共有 17 个平面,也就是说,整个 Unicode 字符集的大小现在是 2^21。
最前面的 65536 个字符位,称为基本平面(缩写 BMP),它的码点范围是从 0 一直到 2^16-1,写成 16 进制就是从 U+0000 到 U+FFFF。所有最常见的字符都放在这个平面,这是 Unicode 最先定义和公布的一个平面。
剩下的字符都放在辅助平面(缩写 SMP),码点范围从 U+010000 一直到 U+10FFFF。
Unicode 只规定了每个字符的码点,到底用什么样的字节序表示这个码点,就涉及到编码方法。
Unicode 没有规定字符对应的二进制码如何存储。以汉字“汉”为例,它的 Unicode 码点是 0x6c49,对应的二进制数是 110110001001001,二进制数有 15 位,这也就说明了它至少需要 2 个字节来表示。可以想象,在 Unicode 字典中往后的字符可能就需要 3 个字节或者 4 个字节,甚至更多字节来表示了。
这就导致了一些问题,计算机怎么知道你这个 2 个字节表示的是一个字符,而不是分别表示两个字符呢?这里我们可能会想到,那就取个最大的,假如 Unicode 中最大的字符用 4 字节就可以表示了,那么我们就将所有的字符都用 4 个字节来表示,不够的就往前面补 0。这样确实可以解决编码问题,但是却造成了空间的极大浪费,如果是一个英文文档,那文件大小就大出了 3 倍,这显然是无法接受的。
于是,为了较好的解决 Unicode 的编码问题, UTF-8 和 UTF-16 两种当前比较流行的变长编码方式诞生了。当然还有一个 UTF-32 的编码方式,也就是上述那种定长编码,字符统一使用 4 个字节,虽然看似方便,但是却不如另外两种编码方式使用广泛。
补充:历史上曾经出现了 Unicode 的多种存储方式,也就是说有许多种不同的二进制格式,可以用来表示 Unicode。导致Unicode 在很长一段时间内无法推广,直到互联网的出现。
互联网的普及,强烈要求出现一种统一的编码方式。UTF-8 就是在互联网上使用最广的一种 Unicode 的实现方式。其他实现方式还包括 UTF-16(字符用两个字节或四个字节表示)和 UTF-32(字符用四个字节表示),不过在互联网上基本不用。
UTF-8 最大的一个特点,就是它是一种变长的编码方式。它可以使用1~4个字节表示一个符号,根据不同的符号而变化字节长度。UTF-8 的编码规则很简单,只有二条:
下表总结了编码规则,字母x表示可用编码的位。
跟据上表,解读 UTF-8 编码非常简单。解码的过程也十分简单:如果一个字节的第一位是0,则这个字节单独就是一个字符;如果第一位是1,则连续有多少个1,就表示当前字符占用多少个字节。
下面,还是以汉字严为例,演示如何实现 UTF-8 编码。
严的 Unicode 是4E25(100111000100101),根据上表,可以发现4E25处在第三行的范围内(0000 0800 - 0000 FFFF),因此严的 UTF-8 编码需要三个字节,即格式是1110xxxx 10xxxxxx 10xxxxxx。然后,从严的最后一个二进制位开始,依次从后向前填入格式中的x,多出的位补0。这样就得到了,严的 UTF-8 编码是11100100 10111000 10100101,转换成十六进制就是E4B8A5。
Windows 内核、Java、Objective-C (Foundation)、JavaScript 中都会将字符的基本单元定为两个字节的数据类型,也就是我们在 C / C++ 中遇到的 wchar_t 类型或 Java 中的 char 类型等等,这些类型占内存两个字节,因为 Unicode 中常用的字符都处于 0x0 - 0xFFFF 的范围之内,因此两个字节几乎可以覆盖大部分的常用字符。
UTF-16 编码介于 UTF-32 与 UTF-8 之间,同时结合了定长和变长两种编码方法的特点。它的编码规则很简单:基本平面的字符占用 2 个字节,辅助平面的字符占用 4 个字节。也就是说,UTF-16 的编码长度要么是 2 个字节(U+0000 到 U+FFFF),要么是 4 个字节(U+010000 到 U+10FFFF)。
1)那么问题来了,当我们遇到两个字节时,到底是把这两个字节当作一个字符还是与后面的两个字节一起当作一个字符呢?
这里有一个很巧妙的地方,在基本平面内,从 U+D800 到 U+DFFF 是一个空段,即这些码点不对应任何字符。因此,这个空段可以用来映射辅助平面的字符。
辅助平面的字符位共有 2^20 个,因此表示这些字符至少需要 20 个二进制位。UTF-16 将这 20 个二进制位分成两半,前 10 位映射在 U+D800 到 U+DBFF(空间大小 2^10),称为高位(H),后 10 位映射在 U+DC00 到 U+DFFF(空间大小 2^10),称为低位(L)。这意味着,一个辅助平面的字符,被拆成两个基本平面的字符表示。
因此,当我们遇到两个字节,发现它的码点在 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。
2)Unicode3.0 中给出了辅助平面字符的转换公式:
H = Math.floor((c-0x10000) / 0x400)+0xD800
L = (c - 0x10000) % 0x400 + 0xDC00
根据编码公式,可以很方便的计算出字符的 UTF-16 编码。
以 𝌆 字符为例,它是一个辅助平面字符,码点为 U+1D306,将其转为 UTF-16 的计算过程如下。
H = Math.floor((0x1D306-0x10000)/0x400)+0xD800 = 0xD834
L = (0x1D306-0x10000) % 0x400+0xDC00 = 0xDF06
所以,字符的 UTF-16 编码就是 0xD834 0xDF06,长度为四个字节。
UTF-32 是最直观的编码方法,每个码点使用四个字节表示,字节内容一一对应码点。比如,码点 0 就用四个字节的 0 表示,码点 597D 就在前面加两个字节的 0。
U+0000 = 0x0000 0000
U+597D = 0x0000 597D
UTF-32 的优点在于,转换规则简单直观,查找效率高。缺点在于浪费空间,同样内容的英语文本,它会比 ASCII 编码大四倍。这个缺点很致命,导致实际上没有人使用这种编码方法,HTML 5 标准就明文规定,网页不得编码成 UTF-32。
JavaScript 语言采用 Unicode 字符集,但是只支持一种编码方法。这种编码既不是 UTF-16,也不是 UTF-8,更不是 UTF-32。上面那些编码方法,JavaScript 都不用。JavaScript 用的是 UCS-2!
怎么突然杀出一个 UCS-2?这就需要讲一点历史。
互联网还没出现的年代,曾经有两个团队,不约而同想搞统一字符集。一个是 1988 年成立的 Unicode 团队,另一个是 1989 年成立的 UCS 团队。等到他们发现了对方的存在,很快就达成一致:世界上不需要两套统一字符集。
1991 年 10 月,两个团队决定合并字符集。也就是说,从今以后只发布一套字符集,就是 Unicode,并且修订此前发布的字符集,UCS 的码点将与 Unicode 完全一致。
UCS 的开发进度快于 Unicode,1990 年就公布了第一套编码方法 UCS-2,使用 2 个字节表示已经有码点的字符。(那个时候只有一个平面,就是基本平面,所以 2 个字节就够用了。)UTF-16 编码迟至 1996 年 7 月才公布,明确宣布是 UCS-2 的超集,即基本平面字符沿用 UCS-2 编码,辅助平面字符定义了 4 个字节的表示方法。
两者的关系简单说,就是 UTF-16 取代了 UCS-2,或者说 UCS-2 整合进了 UTF-16。所以,现在只有 UTF-16,没有 UCS-2。
那么,为什么 JavaScript 不选择更高级的 UTF-16,而用了已经被淘汰的 UCS-2 呢?
答案很简单:非不想也,是不能也。因为在 JavaScript 语言出现的时候,还没有 UTF-16 编码。
Java 语言设计之初就认识到统一字符集( Unicode )的重要性,积极拥抱了问世不久的 Unicode 标准。Java 语言规定:Java 的 char 类型是 UTF-16 的 code unit ,也就是一定是16位(2字节);然后字符串是 UTF-16 code unit 的序列;这样,Java规定了字符要用UTF-16编码。
在 Java 编码体系中要区分清楚内码和外码:
Java最初设计的Charactor用两个字节来表示unicode字符,这没有问题, 因为最初unicode中的字符还比较少, Java 1.1之前采用Unicode version 1.1.5, JDK 1.1中支持Unicode 2.0, JDK 1.1.7支持Unicode 2.1, Java SE 1.4 支持 Unicode 3.0, Java SE 5.0开始支持Unicode 4.0。直到Unicode 3.0, Java用两个字节来表示unicode字符还没有问题,因为Unicode 3.0最多 49,259 个字符, 两个字节可以表示 65,535 个字符,还足够容的下所有的uicode3.0字符。但是Unicode 4.0(事实上自Unicode 3.1), 字符集进行很大的扩充,已经达到了96,447个字符,Unicode 11.0已经包含 137,374 个字符。随着Unicode字符集的不断完善,原本用来表示世界上所有字符的16个位在后期不能胜任这个任务(2个字节最多可以表示65535个字符)。所以,Java内部使用了一种可变长的,向后兼容的UTF-16。
1)码元和码点概念:
通俗来说:能表示一个字符的16位的(一个char)或n*16位(多个char)的编码称作码点,每个16位编码(每个char)称作码元。
2)java内部采用UTF-16编码:
前面介绍过UTF-16,采用2-4个字节存储:
为了实现 UTF-16 的变长编码语义,Java也引入码点和码元两个概念,Java 规定 char 仍然只能是一个 16 位的码元,也就是说 Java 的char类型不一定能表示一个UTF-16的“字符”, 只需1个码元的码点才可以完整的存在 char 里,而对于那些不常见的字符需要两个码元才能表示。
String 作为 char 的序列,可以包含由两个码元组成的 “surrogate pair” 来表示需要 2 个码元表示的 UTF-16 码点,为此 Java 的标准库新加了一套用于访问码点的 API,而这套 API 就表现出了 UTF-16 的变长特性。
自 Java 1.5 java.lang.String就提供了Code Point方法, 用来获取完整的Unicode字符和Unicode字符数量:
1)获取字符串中字符实际长度(码点个数):
string.length()返回的是Code Unit的长度,而不再是Unicode中字符的长度。对于传统的BMP平面的代码点,String.length和我们传统理解的字符的数量是一致的,对于扩展的字符,String.length可能是我们理解的字符长度的两倍。
要想获取字符串的字符实际长度(码点个数),可以使用public int codePointCount(int beginIndex, int endIndex) api:
- private static void test1() {
- // 中文常见字
- String s = "你好";
- System.out.println("1. string length =" + s.length()); //2
- System.out.println("1. string char length =" + s.toCharArray().length); //2
- System.out.println("1. codePointCount length =" + s.codePointCount(0, s.length())); //2
- System.out.println();
-
- // emojis
- s = "👦👩";
- System.out.println("2. string length =" + s.length()); //4
- System.out.println("2. string char length =" + s.toCharArray().length); //4
- System.out.println("2. codePointCount length =" + s.codePointCount(0, s.length())); //2
- System.out.println();
- // 中文生僻字
- s = "𡃁妹";
- System.out.println("3. string length =" + s.length()); //3
- System.out.println("3. string char length =" + s.toCharArray().length); //3
- System.out.println("3. codePointCount length =" + s.codePointCount(0, s.length())); //2
- }
说明:对于普通的字符(一个码元表示的码点)组成的字符串,length()和codePointCount返回的值相等;如果字符串中有emoji或者生僻字(两个码元表示一个码点),只有codePointCount可以准确获取字符长度。
2)获取字符串中某个字符:
- private static void test2() {
- String s = "你好";
- System.out.println("charAt=" + s.charAt(0)); //你
- System.out.println((int)s.charAt(0)); //20320
- System.out.println("codePointAt=" + s.codePointAt(0)); //20320
-
- s = "𡃁妹";
- System.out.println(s.charAt(0)); //? 乱码
- System.out.println(s.codePointAt(0)); //135361
-
- String word2 = "𝕆";
- String firstUnit = Integer.toHexString(word2.charAt(0));
- String secondUnit = Integer.toHexString(word2.charAt(1));
- String codePoint = Integer.toHexString(word2.codePointAt(0));
- System.out.println("第一个单元:" + firstUnit + " 第二个:" + secondUnit + " 码点:" +codePoint);
- }
3)字符串截取:
截取字符串时,有时候字符串会包含Emoji表情、以及一些特殊符号,用String的substring()进行截取操作,结果就有可能是乱码。这是因为JVM运行时使用UTF-16编码,对于普通的字符都是使用char类型存储(2个字节),而对于中文、emoji表情是用两个char存储(4个字节),substring是按照char截取的,就有可能只截取了半个中文字符,sting提供了offsetByCodePoints方法该方法返回此String 中从给定的 index 处偏移 codePointOffset 个Unicode代码点的索引,来辅助实现substring方法。
- private static void test3() {
- String s = "你好妹";
- System.out.println(s.substring(1, 2)); //好
-
- s = "你𡃁妹";
- System.out.println(s.substring(1, 2)); //?
- System.out.println(subStr(s, 1, 2)); //𡃁
- }
- private static String subStr(String value, int startIndex, int endIndex) {
- String result;
- if (StringUtils.isEmpty(value)) return "";
- if (endIndex <= 0 || value.length() <= endIndex) return value;
-
- try {
- result = value.substring(value.offsetByCodePoints(0, startIndex),
- value.offsetByCodePoints(0, endIndex));
- } catch (Exception e) {
- result = "";
- }
- return result;
- }
在 Android 手机或者 iPhone 的各种输入法键盘中,会自带一些 Emoji 表情符号,如 IPhone 手机系统键盘包含的表情符号有:
如果在移动端发布文本内容时包含了这种 Emoji 表情符号,通过接口传递到服务器端,服务器端再存入 MySQL 数据库:
原因分析:
这是由于字符集不支持的异常,因为 Emoji 表情是四个字节,而 mysql 的 utf-8 编码最多三个字节,所以导致数据插不进去。真正的 utf8 编码(大家都使用的标准),最大支持 4 个 bytes。正是由于 mysql 的 utf8 少一个 byte,导致中文的一些特殊字符和 emoji 都无法正常的显示。mysql 真正的 utf8 其实是 utf8mb4,这是在 5.5.3 版本之后加入的。而目前的“utf8”其实是 utf8mb3。所以尽量不要使用默认的 utf8,使用 utf8mb4 才是正确的选择。
从 mysql 5.5.3 之后版本基本可以无缝升级到 utf8mb4 字符集。同时,utf8mb4 兼容 utf8 字符集,utf8 字符的编码、位置、存储在 utf8mb4 与 utf8 字符集里一样的,不会对有现有数据带来损坏。