深度解析 IO 字符集技术
一、背景介绍
很多刚接触计算机的同学,可能会发出一个疑问,字符是怎么在计算机之间实现传输的?
要了解这个问题,还得从计算机的起源说起!
在计算机软件里面,一切的信息都可以用 1 和 0 来表示(严格说连 0 和 1 都没有,只有开和关),也被称为二进制位,英文简称:bit,音译为“比特”,比特是计算机内存中的最小单位(也称原子单位),在计算机系统中,每 bit 可用 0 或 1 表示数位讯号。
在上篇文章中,我们了解到不管是磁盘还是网络传输,最小的存储单元都是字节。
有的同学可能会发出疑问,为什么不直接使用比特存储?字节和比特又有什么关系呢?
虽然比特是硬件上的最小单元,但是光靠 1 和 0 很难知道是什么意思,比特就好比身体的细胞,由于颗粒度太细,很难知道这个细胞属于那个地方,于是就有了字节这个概念,字节就好比身体的某个器官,更便于识别。
简单的说,从单位换算角度,一个字节 = 8 个比特!
通过这一串的 8 个 1 和 0 的不同排列方式,可以表达出 256 个(2的8次方)不同的意思,这样换算率在当时的美国科学家看来,已经足够表达英文中全部字母大小写及符号加控制符了,也就是下文我们要介绍的 ASCII 字母代码表。
上个世纪 60 年代,为了更好的便于计算机传输字符信息,美国制定了一套字符编码规则,对英语字符与二进制位之间的关系做了统一规定,这编码规则被称为 ASCII 编码(美国标准信息交换码),一直沿用至今。
ASCII 编码一共规定了 128 个字符的编码规则,这 128 个字符形成的集合就叫做ASCII 字符集。
在早期的 ASCII 编码中,规定使用单字节中低位的 7 个比特去编码所有的字符,每个字符占用一个字节的后面7位,最前面的1位统一规定为 0。
在这个编码规则下,当你在键盘上输入字母 A,计算机会根据 ASCII 字符代码表,找到对应的十进制码值 65,然后换算成二进制码值 01000001,传输到目的地;接受端收到信号之后,会将二进制码值 01000001 再换算成十进制码值 65,然后再根据字符代码表,将十进制码值 65 解码成字母 A,最后输出到控制台。
由此,整个计算机之间的信息传输交换完成!
在 ASCII 编码中,编号 0~31 是控制字符如换行回车删除等,32~126 是可打印字符,可以通过键盘输入并且能够显示出来,一个英文字符占用一个字节。
对于英语来说,用 128 个符号编码就够了,但是随着计算机的快速发展,用来表示其他语言,128 个符号是远远不够的。
所以当 ASCII 码到欧洲的时候,一些欧洲国家就决定对 ASCII 编码进行适当的 扩展和改造,现有的编码规则维持不变,把字节中闲置的最高位也编入新的符号。比如,法语中的 é 的编码为 130(二进制 10000010 )。这样一来,这些欧洲国家使用的编码体系,可以表示最多 256 个符号,这个编码统称为 EASCII(Extended ASCII)。
但是欧洲的语言体系有个特点:小国家特别多,每个国家可能都有自己的语言体系,语言环境十分复杂。因此即使 EASCII 可以表示 256 个字符,也不能统一欧洲的语言环境。
为了解决上面这个问题,欧洲的工程师们想出了一个折中的方案:在 EASCII 中表示的 256 个字符中,前 128 字符和 ASCII 编码表示的字符完全一样,后 128 个字符每个国家或地区都有自己的编码标准。
比如,130 在法语编码中代表了 é,但是在希伯来语编码中代表字母 Gimel (ג),在俄语编码中又会代表另一个符号。但是不管怎样,所有这些编码方式中,0—127 表示的符号是一样的,不一样的只是 128—255 的这一段。
根据这个规则,就形成了很多子标准:ISO-8859-1、ISO-8859-2、ISO-8859-3、……、ISO-8859-16。这些子标准适用于欧洲不同的国家地区。具体关于 ISO-8859 的标准请参考这个链接地址。
到了亚洲国家,使用的文字符号就更多了,汉字就多达 10 万多个。根据上面的信息,我们知道一个字节最多只能表示 256 种符号,这对于汉字来说肯定是不够的,必须使用多个字节表达一个符号。因此才出现了后面的 GB2312、Unicode 等字符集,简体中文常见的编码方式是 GB2312,使用两个字节表示一个汉字,所以理论上最多可以表示 65536 个符号;而 Unicode 字符集是一个很大的字符集合,最多可以使用 4 个字节来表示一个符号,可以容纳 100 多万个符号。
关于字符集的故事发展,我们在此不过深入的讲解,有兴趣的朋友可以看看这个链接地址!
下面我们重点介绍一下 Unicode 字符集!
二、Unicode 字符集
在上文的信息中,我们了解到不同的国家有不同的字符集,如果通过电子邮件把信息传送到另外一个国家的计算机系统中,看到的可能就不是那个原始发送的字符了,很有可能而是乱码!
因为计算机里面并没有真正的字符,字符都是以数字的形式存在的,通过邮件传送一个字符,实际上传送的是这个字符对应的字符编码,同一个数字在不同的国家和地区代表的很可能是不同的符号。
为了解决各个国家和地区之间各自使用不同的本地化字符编码带来的不便,工程师们将全世界所有的符号进行了统一编码,称之为 Unicode,也被称为统一码、万国码。
所有字符不再区分国家和地区,都是人类共有的符号,如"中"字在 Unicode 中不再是 GBK 中的 D6D0,而是在任何地方都是 4e2d,如果所有的计算机系统都使用这种编码方式,那么 4e2d 这个字在任何地方都代表汉字中的"中"。
需要注意的是,Unicode 只是一个字符集,它只规定了符号的二进制代码,却没有规定这个二进制代码应该如何编码如何存储。这就造成了两个问题:
- 问题1:如何才能区别 Unicode 和 ASCII ?计算机怎么知道三个字节表示一个符号,而不是分别表示三个符号呢?
- 问题2:我们知道,英文字母只用一个字节表示就够了,如果 unicode 统一规定,每个符号用三个或四个字节表示,那么每个英文字母前都必然有二到三个字节是 0,这对于存储来说是极大的浪费,文本文件的大小会因此大出二三倍,这对当时存储器来说,是无法满足的。
为了解决 Unicode 字符集中的一些问题,就出现了 UTF(Unicode Transformation Formats) 系列的编码规则。UTF 编码规则具体规定了 Unicode 字符集中的字符是如何编码的。
下面我们就来看看 UTF 系列编码的具体实现。
三、UTF 编码规则
3.1、UTF-16
早期,Unicode 转换格式规定不管什么字符都使用两个字节表示,两个字节其实就是 16 Bit,所以叫做 UTF-16。
UTF-16 编码非常方便,每两个字节表示一个字符,这个在字符串操作时大大简化了操作,编码效率也比较高,尤其适合在本地磁盘和内存之间操作,可以进行字符和字节之间的快速切换。
但是缺陷也很明显,首先就是一个字符占用两个字节,因为很大一部分字符用一个字节表示就够了,现在需要用两个字节,存储空间放大了一倍;其次在网络之间传输数据,容易因为大小端问题,传输后读取的数据会出现乱码。
3.2、UTF-8
随着互联网的普及,强烈要求出现一种统一的编码方式,为了解决 UTF-16 中的缺陷,基于此又诞生了一种可变长度技术,每个编码区域有不同的字节长度,不同类型的字符可以是由 1~4 个字节组成,这种编码规则我们成它为 UTF-8,由 Ken Thompson 于1992年创建,用在网页上可以统一展示页面上的中文英文繁体及其它语言正常显示。
UTF-8 最大的一个特点,就是它是一种变长的编码方式。它使用 1~4 个字节表示一个符号,根据不同的符号而变化字节长度,UTF-8 编码可以容纳 2^21 个字符,总共 200 多万个字符。
UTF-8的编码规则很简单,只有二条:
- 1.对于单字节的符号,字节的第一位设为0,后面7位为这个符号的 unicode码。因此对于英语字母,UTF-8 编码和 ASCII 码是相同的,可以完全兼容过去的编码规则
- 2.对于 n 字节的符号(n>1),第一个字节的前 n 位都设为1,第 n+1 位设为0,后面字节的前两位一律设为 10。剩下的没有提及的二进制位,全部为这个符号的 unicode 码
对不同范围的字符使用不同长度的编码方式,详细的规则如下,其中字母 x 表示可用编码的二进制位。
比如『汉』这个字的 Unicode 编码是 0x6C49。0x6C49 在 0x0800 ~ 0xFFFF 之间,使用 3 字节模板:1110xxxx 10xxxxxx 10xxxxxx。将 0x6C49 写成二进制是:0110 1100 0100 1001, 用这个比特流依次代替模板中的x,得到:11100110 10110001 10001001。
关于 UTF-8 编码技术更加详细的解说,可以参考这个链接!
四、Java 与字符编码
Java 语言内部使用的是 Unicode 字符集,采用 UTF-16 方式编码字符。
但其实,Java 内部还实现了ASCII、LATIN1、ISO8859-1、UTF-8、GBK 等字符集的编码规则,可以很容易实现这些编码之间的相互转换。
在保证跨平台特性的前提下,也支持了全扩展的本地平台字符集,默认显示输出和键盘输入都是采用的本地编码规则,因此,免不了二者的转化问题。
以 windows 操作系统为例,我们看一个简单的例子!
public static void main(String[] args) throws Exception {
// 我们采用 GBK 进行编码
byte b[] = "我们一起来学习 Java 语言".getBytes("GBK");
File file = new File("encoding.txt");
OutputStream out = new FileOutputStream(file);
out.write(b);
out.close();
}
打开输出的文件,内容如下:
我们一起来学习 Java 语言
正常情况下输出,无编码问题,但是如果改成这样呢
public static void main(String[] args) throws Exception {
// 我们采用 ISO8859-1 进行编码
byte b[] = "我们一起来学习 Java 语言".getBytes("ISO8859-1");
File file = new File("encoding.txt");
OutputStream out = new FileOutputStream(file);
out.write(b);
out.close();
}
输出的文件,内容如下:
?????Java??
乱码问题就出现了!
原因相信大家都知道了,就是字符编码和解码的规则不一样导致的。
Java 中的各个类,对于英文字符的支持都非常好,可以正常地写入文件中,但对于中文字符就未必了!
从 Java 源代码到写入文件正确的内容,要经过 Java 源代码 -> Java 字节码 -> 虚拟机 -> 文件几个步骤,在上述过程中的每一步都必须正确地处理汉字的编码,才能够使最终有我们期望的结果。
其中 Java 源代码 -> Java 字节码这一步骤,Java 编译器 Javac 使用的字符集是系统默认的字符集,比如在中文 Windows 操作系统上就是 GBK,而在 Linux 操作系统上是 ISO8859-1。所以经常有同学发出疑问,自己在本地的 windows 系统上运行的很正常,但是把代码部署到了 Linux 操作系统上编译的类中源文件中的中文字符就出现乱码了。
解决办法就是在编译的时候添加 encoding 参数,并指定对应的编码规则,比如 GBK 或者 UTF-8,这样才能够与平台无关。
如果想要查询 jdk 使用的是哪种编码规则,可以通过如下方式查询:
public static void main(String[] args) {
System.getProperties().list(System.out);
}
输出的内容比较多,重点看下file.encoding
变量值就可以,比如小编当前的电脑显示结果如下:
file.encoding=GBK
表明了 JDK 使用的是 GBK 字符集,当对字符串进行操作时,都做了 Unicode 到 GBK 的转换,既然 JDK 用的 GBK 编码,那么用 ISO8859-1 字符集显示 GBK 编码出来的中文当然是有问题的。
因此在实际使用过程中,推荐大家统一编码规则,比如采用比较通用的 UTF-8 编码规则,可以避免无端的文字乱码问题。
五、小结
本位主要围绕计算机进行字符传输时碰到的问题,进行了一次简单的知识梳理总结,内容难免有所遗漏,欢迎网友留言指出!
最近网上有传闻说,采用中文来编程,大家可以试想一下,采用中文来编程会是个什么样的结果?
通过上面的分析,我们可以得出一个结论,那就是采用中文编程,如果没有统一编码规则的情况下,会是个灾难;其次也会增加程序员们的工作难度,因为从字节来看,一个汉字至少等于英文的两个字符,所以使用汉字会更加占内存。
还有一点就是,英文最多也就 26 个字符,比较简单,在所有的计算机上都非常通用,如果换成中文的话,截止目前,中文的符号已经超过 10 万个了,还没有完全收集全,如果换成中文来编程,需要穷举所有的中文字符,以防干扰程序的正常执行,这在目前看来基本弊大于利!