字符集与字符编码那些事

生活中,我们使用着各种各样的字符,比如数字、字母、汉字,甚至还有表情,比如😀。平时,我们习惯使用输入法输入字符,所以我们不需要了解字符底层的一些知识,比如字符的存储、字符的编码转换等,但也正因如此,我们常常面对乱码的问题而感到手足无措。
今天,这篇文章将给你带来字符集及字符编码的一些知识,让你摆脱有关字符编码的一切烦恼!

一、什么是字符集?

百度百科定义: 字符集(Character set)是多个字符的集合,字符集种类较多,每个字符集包含的字符个数不同,常见字符集名称:ASCII字符集、GB2312字符集、BIG5字符集、 GB18030字符集、Unicode字符集等.

简单点讲,字符及其编码的集合就是字符集。通过字符集,我们能够在计算机中使用一串数字来表示一个字符,比如A是1,B是2,C是3,要表示“ABC”,我们用“123”(这里不太严谨,应该是1,2,3对应的二进制表示)就可以了。这时你可能要问了,为什么不直接记录字形呢?其实记录字形是很浪费存储空间的。字形一般是放在字库中,也就是字体文件中,我们记录字符对应的编码(那串数字),然后在字库中按照这串数字查找相应的字形,最终打印在屏幕上显示,这样就能大大减少存储的空间,也方便移植(你的字体可能在别的机器上找不到)。

如今,随着计算机的发展,已经涌现出了各种各样的字符集,以适应不同国家的发展需求。一开始,ASCII码字符集出现,这个字符集是美国国家标准学会制定的,它使用7个bit表示一个字符,共计128个字符,它包含33个控制字符以及95个可打印字符,而后为了满足对一些欧洲常用字符的表示,又增加了128个字符,用一个字节(8个bit)表示,扩充后的字符集又叫ASCII扩展字符集(扩展前的编码被称为标准ASCII或基础ASCII)。

互联网技术传入中国后,1981年,由我国国家标准局制定的汉字字符集GB2312正式发布,GB2312是我国标准的简体中文字符集,使用2个字节表示,涵盖了我们日常能够使用到的99.75%的字符,它同时也兼容美国的ASCII字符集。然而,对于一些罕见文字,GB2312却无能为力。所以不久后GBK诞生了,它是GB2312的扩展,支持国际标准ISO/IEC10646-1和国家标准GB13000-1中的全部中日韩汉字,并包含了BIG5编码中的所有汉字,现今,我们使用的windows系统的默认字符编码就是GBK。到了2000年,GB18030标准发布,它使用变长字节编码(1,2或4个字节),它与GBK兼容,并支持Unicode的所有码位(码位就是代表字符的那串数字,也即code point),它解决了汉字、日文假名、朝鲜语和中国少数民族文字组成的大字符集的计算机编码问题。

其他的一些常见字符集还有:

  • Big5,它是用于编码中文繁体字的,使用2个字节,由台湾省的一些机构制定;
  • Latin1,如果你使用过Mysql,那这个字符集你也一定熟悉,它是曾经Mysql的默认字符集(现在好像已变更为utf8mb4)。Latin1也叫ISO-8859-1,它是单字节编码,兼容标准ASCII,它还包括西欧语言、希腊语、泰语、阿拉伯语、希伯来语对应的文字符号。

二、什么是字符编码?

既然有了字符集,那么直接用字符集所代表的那串数字去表示一个符号就不就可以了吗?是的,当然可以,ASCII码就是这么做的。

public static void main(String[] args) {
	String s = "123456";
	byte[] codePoints = s.getBytes(Charset.forName("ascii"));
	for (int i = 0; i < codePoints.length; i++) {
	    System.out.print(codePoints[i] + " ");
	}
}
//这段代码打印的是字符串“123456”(ASCII编码)的码点,结果如下:
49 50 51 52 53 54

GBK字符集也是这样做的

public static void main(String[] args) {
	String s = "中国加油";
	byte[] codePoints = s.getBytes(Charset.forName("gbk"));
	for (int i = 0; i < codePoints.length; i++) {
	    System.out.print(Integer.toHexString(Byte.toUnsignedInt(codePoints[i]))+ " ");
	}
}
//结果如下,因为是两个字节编码一个汉字,所以总共有8个字节:
d6 d0 b9 fa bc d3 d3 cd

我们用编码转换工具校验一下,如图:
GBK-编码转换示例
了解上面的知识后,我们再来看看utf-8编码,我们知道utf-8编码能够编码unicode字符,那好,我们来看看:

public static void main(String[] args) {
	String s = "中国加油";
	byte[] codePoints = s.getBytes(Charset.forName("utf-8"));
	for (int i = 0; i < codePoints.length; i++) {
	    System.out.print(Integer.toHexString(Byte.toUnsignedInt(codePoints[i]))+ " ");
	}
}
//结果如下:
e4 b8 ad e5 9b bd e5 8a a0 e6 b2 b9

再来验证一下,看图:
Unicode-编码转换示例
我们发现,结果出现了很大的差异,这是为什么呢?

很简单,因为utf-8并没有直接用字符集所代表的一串数字去表示一个符号!!!在完全弄清楚之前,我们需要先说说字符集与字符编码的差别。

字符编码是把字符集中的字符编码为特定的二进制数,以便在计算机中存储。一般而言,一种字符集就对应一种字符编码,也就我们上面提到的像ASCII、GBK、GB2312、ISO-8859-1编码,它在计算机中所存储的数据就是对应的字符集码点。但是也有字符集不这么干,比如Unicode字符集,就包含utf-8、utf-16、utf-32等编码方式,这后三种就是对应Unicode字符集的字符编码方式。UTF是“Unicode Transformation Format”的缩写,可以翻译成Unicode字符集转换格式,即怎样将Unicode定义的码点转换成存储于计算机的一串二进制数据。那为什么需要使用那么多种的字符编码呢?因为这是考虑到编码转换的效率、大小端问题、存储空间等因素决定的。

我们知道,Unicode字符集是为了收录全球所有地域出现的字符而存在的,百度百科对它这样定义:

Unicode(统一码、万国码、单一码)是计算机科学领域里的一项业界标准,包括字符集、编码方案等。Unicode 是为了解决传统的字符编码方案的局限而产生的,它为每种语言中的每个字符设定了统一并且唯一的二进制编码,以满足跨语言、跨平台进行文本转换、处理的要求。

为了给每一个字符分配一段独一无二的二进制串,如今,Unicode不得不使用4个字节(曾今使用2个字节编码)来给字符进行编码,但是四个字节的编码代价对于使用英文的国家来说会造成大量内存空间的浪费,因为对于他们来说,ASCII编码使用一个字节就足够编码所有的常用字符了。由此,就出现了众多的中间转换格式,也就是我们所熟知的UTF-8、UTF-16、UTF-32编码!

三、Unicode的编码方式

还是先来看一下Unicode官网的陈述:

Fundamentally, computers just deal with numbers. They store letters and other characters by assigning a number for each one. Before the Unicode standard was developed, there were many different systems, called character encodings, for assigning these numbers. These earlier character encodings were limited and did not cover characters for all the world’s languages. Even for a single language like English, no single encoding covered all the letters, punctuation, and technical symbols in common use. Pictographic languages, such as Japanese, were a challenge to support with these earlier encoding standards.

Early character encodings also conflicted with one another. That is, two encodings could use the same number for two different characters, or use different numbers for the same character. Any given computer might have to support many different encodings. However, when data is passed between computers and different encodings it increased the risk of data corruption or errors.

大概地说,Unicode是为了解决不同国家不同地域所使用的诸多字符编码标准相互冲突而出现的,同时志在覆盖全球所有的语言文字、科学符号、标记等等。全球化的今天,需要一个更兼容更广大的字符编码标准,以减少各种标准差异性带来的影响。Unicode为每一个字符提供了一个唯一指定的数字,它并没有指定这串数字该如何在计算机中储存,所以,你完全可以实现一种属于自己的编码来支持Unicode字符的储存。

Unicode provides a unique number for every character, no matter what the platform, program, or language is.

要介绍Unicode的编码方式,我们需要了解Unicode发展过程中的一些历史知识。事情是这样的,一开始,有两个机构在分别尝试创立一套全球统一的单一字符集,一个是国际标准化组织(ISO),另一个就是Unicode联盟(Unicode Consortium),后来,这两个组织互相发现对方在同自己做一样的事情,于是两者为了避免出现两套不同的字符集,干脆达成合作,开始合并各自的工作成果。在1991年,Unicode联盟与 ISO/IEC JTC1/SC2 同意保持 Unicode 码表与 ISO 10646 标准保持兼容并密切协调各自标准近一步的扩展。所以,虽然是两套标准(Unicode标准与ISO 10646标准),但实际上这两个标准的字符编码是一致的,导致的结果是,Unicode 1.1 对应于 ISO 10646-1:1993,Unicode 3.0 对应于 ISO 10646-1:2000,Unicode 3.2 对应于 ISO 10646-2:2001,Unicode 4.0 对应于 ISO 10646:2003,Unicode 5.0 对应于 ISO 10646:2003等。

ISO 10646编码的字符集又叫做UCS(Universal Character Set,即通用字符集),它包含UCS-2与UCS-4两个编码版本,前者使用2个字节编码,后者用4个字节。为了便于阐述Unicode的编码方式,这里会借用UCS的概念。在一开始,Unicode遵从UCS-2,使用16位的编码空间,也就是每个字符占用2个字节的空间,能够容纳65536个字符,即U+0000-U+FFFF,这16位元的空间构成了基本多文种平面,也就是BMP(Basic Multilingual Plane),也叫做第0平面(平面的概念会稍后叙述),但是这个空间很快被填满,所以最新的Unicode版本又扩展了16个辅助平面(至今还未被填满),共计17个平面,从U+0000至U+10FFFF。扩展的方式是在通过再扩展5个bit的方式实现的:

扩展位(16进制)平面空间
000000-FFFF平面0
010000-FFFF平面1
020000-FFFF平面2
0000-FFFF平面n
0F0000-FFFF平面15
100000-FFFF平面16
Unicode字符平面映射表:
平面始末字符值中文名称
:-:-:-
0号平面U+0000 - U+FFFF基本多文种平面
1号平面U+10000 - U+1FFFF多文种补充平面
2号平面U+20000 - U+2FFFF表意文字补充平面
3号平面U+30000 - U+3FFFF表意文字第三平面
4号平面至13号平面U+40000 - U+DFFFF(尚未使用)
14号平面U+E0000 - U+EFFFF特别用途补充平面
15号平面U+F0000 - U+FFFFF保留作为私人使用区(A区)
16号平面U+100000 - U+10FFFF保留作为私人使用区(B区)

虽然使用21个bit就足够覆盖17个平面的编码空间,但事实上为了与UCS-4保持一致,所以辅助平面的字符仍旧占用了四个字节的空间。未来版本会扩充到 ISO 10646-1 实现级别 3,即涵盖 UCS-4 的所有字符。UCS-4 是一个更大的尚未填充完全的 31 位字符集,加上恒为 0 的首位,共需占据 32 位,即 4 字节。理论上最多能表示 2的31次方个字符,完全可以涵盖一切语言所使用的符号。基本多文种平面(BMP)的字符的编码表示为 U+hhhh,其中每个 h 代表一个十六进制数字,与 UCS-2 编码完全相同。而其对应的 4 字节 UCS-4 编码后两个字节一致,前两个字节则所有位均为 0。

虽然每个平面拥有65536个编码空间,但并不是所有的位置都是被填满的,有的平面划分了保留范围而不允许分配字符。比如,平面0有两个编码范围是没有分配字符的,从0xE000至0xF8FF,有6400个码位,用于保留给大家存放自定义字符的区域(PUA);而从0xD800至0xDFFF,共2048个码位,则是用于UTF-16编码,这会在文章的后面被提到。
平面0的保留范围

四、UTF-8的编码方式

UTF-8(8-bit Unicode Transformation Format)是一种针对Unicode的可变长度字符编码,也是一种前缀码。UTF-8编码使用1~6个字节编码Unicode字符,是现今互联网主流推荐的编码方案。下面给出Unicode 和 UTF-8 之间的转换方式 ( x 字符表示码点占据的位 ):

码点的位数码点起值码点终值字节序列Byte 1Byte 2Byte 3Byte 4Byte 5Byte 6
7U+0000U+007F10xxxxxxx
11U+0080U+07FF2110xxxxx10xxxxxx
16U+0800U+FFFF31110xxxx10xxxxxx10xxxxxx
21U+10000U+1FFFFF411110xxx10xxxxxx10xxxxxx10xxxxxx
26U+200000U+3FFFFFF5111110xx10xxxxxx10xxxxxx10xxxxxx10xxxxxx
31U+4000000U+7FFFFFFF61111110x10xxxxxx10xxxxxx10xxxxxx10xxxxxx10xxxxxx

虽然UTF-8编码一个字符最多需要花费6个字节,但那只是理论层面上的,实际上Unicode字符的最大码位只到U+10FFFF,所以四个字节足够。对于中文而言,因为中文编码大都位于BMP,所以大部分中文字符需要花费3个字节来编码。现在以字符“陈”为例,讲解一些编码的规则,汉字“陈”的Unicode编码位U+9648,位于U+0800-U+FFFF范围之间,所以占据三个字节,二进制序列为:1001 0110 0100 1000‬,其UTF-8编码为:0xe99988,二进制表示为:1110 1001 1001 1001 1000 1000,我们发现:
编码比较
显而易见的规律,对吧?

UTF-8编码是兼容标准ASCII的,所以0000-007F范围里的字符与ASCII码是一一映射的,此时使用一个字节就能编码且头字节的最高有效位为0,而对于其他范围的Unicode的字符,则需要2~6个字节编码,且头字节中从最高有效位开始连续的1的个数暗示着表示该字符的字节的个数,除头字节外,后续的字节均以‘10’开头,这样的安排能够让文字处理器快速地找出每个字符的二进制序列,提高编码的效率。另外,UTF-8是以8位字节单元对UCS进行编码的,所以UTF-8不需要关心字节序的问题。

题外话:我们知道,如果让0x12345678存储在机器中,那么就存在一个字节存储顺序的问题,是让高地址存储最高有效字节,还是让低地址存储最高有效字节?这就是“大名鼎鼎”的大小端问题。在大端机器中,高字节保存在低地址处,在小端机器中,高字节保存在高地址处(0x1000-0x1003表示递增的地址)。对于UTF-8编码而言,因为存储在机器中的二进制序列以一系列字节单元编码,所以不需要考虑存储或读取的顺序问题,文本处理器能够通过字节前缀识别出正确的字符序列:

-0x10000x10010x10020x1003
大端12345678
小端78563412

五、UTF-16的编码方式

还记得第0平面保留的那一段编码范围吗?基本多语言平面内,从U+D800到U+DFFF之间的码位区段是永久保留不映射到Unicode字符的。UTF-16会利用保留下来的0xD800-0xDFFF区块的码位来对辅助平面的字符的码位进行编码。

注意:在使用UCS-2的时代,U+D800…U+DFFF内的值是被占用,用于某些字符的映射。但只要不构成代理对,许多UTF-16编码解码还是能把这些不符合Unicode标准的字符映射正确的辨识、转换成合规的码元。虽然按照Unicode标准,这种码元序列本来应算作编码错误。

UTF-16的编码方式:

16进制编码范围UTF-16表示方法(二进制)10进制码范围字节数量
U+0000 - U+FFFFxxxx xxxx xxxx xxxx - yyyy yyyy yyyy yyyy0-655352
U+10000 - U+10FFFF1101 10yy yyyy yyyy - 1101 11xx xxxx xxxx65536-11141114

举个例子,汉字“𤭢”,它的Unicode编码为U+24B62:计算0x24B62 - 0x10000,得到结果即0x14B62,它的二进制表示为0001 0100 1011 0110 0010‬,这里称为序列A,UTF-16编码为0xD852DF62,进制表示为1101 1000 0101 0010 1101 1111 0110 0010,称为序列B。将A序列分成两部分A1:00 0101 0010,A2: 11 0110 0010,将序列A1 与 0xDB00相加得到B1,将A2 与 0xDC00相加得到B2,将B1和B2合并成为一个新的序列,即得到序列B。

字符Unicode码二进制UTF-16 二进制UTF-16 十六进制UTF-16BEUTF-16LE
𤭢U+24B620010 0100 1011 0110 00101101 1000 0101 0010 1101 1111 0110 0010D852 DF62D8 52 DF 6252 D8 62 DF

可以发现,UTF-16使用2或4个字节来编码字符,它将Unicode编码分为两个部分,基本多语言平面字符与辅助平面字符,前者的UTF-16字符编码等同于Unicode编码,只需2个字节,后者则借助BMP平面上保留的区块U+D800到U+DFFF为其编码,需要4个字节。大部分的汉字编码仅需2个字节就可以了。

UTF-16编码是要区分大小端的(UTF-16可被实现为UTF-16LE或UTF-16BE)。一般来说,以Macintosh制作或存储的文字使用大端格式,以Microsoft或Linux制作或存储的文字使用小端格式。

维基百科: 为了弄清楚UTF-16文件的大小尾序,在UTF-16文件的开首,都会放置一个U+FEFF字符作为Byte Order Mark(UTF-16 LE以 FF FE 代表,UTF-16 BE以 FE FF 代表),以显示这个文本文件是以UTF-16编码,其中U+FEFF字符在UNICODE中代表的意义是 ZERO WIDTH NO-BREAK SPACE,顾名思义,它是个没有宽度也没有断字的空白。

相较UTF-8,UTF-16编码汉字要更节省存储空间,但是UTF-16不兼容ASCII码,而且存储英文字符时需要耗费更多空间。

我们知道Java是使用UTF-16编码字符串的,而且char类型使用两个字节保存字符,可是如果字符串中有的字符需要使用四个字节保存时会发生什么事呢?

public static void main(String[] args) {
    String s = "\uD852\uDF62";//“𤭢”
    System.out.println(s.length());
    System.out.println(s.codePointCount(0, s.length()));
}
//结果如下:
2
1

我们发现,length()方法并没有正确返回结果,length()的返回值并不是字符串所包含的字符数目(返回的是char数组的长度)!反倒codePointCount()方法返回了正确的值,codePointCount()方法能返回指定范围的代码点的数目,其实就是范围内的字符个数。

六、UTF-32的编码方式

使用固定长度4个字节编码Unicode字符,其实,Unicode的UTF-32编码就是其对应的32位无符号整数。看代码:

public static void main(String[] args) {
	String s = "中国加油";//unicode:00004E2D 000056FD 000052A0 00006CB9
	byte[] codePoints = s.getBytes(Charset.forName("utf-32"));
	for (int i = 0; i < codePoints.length; i++) {
	    System.out.print(Integer.toHexString(Byte.toUnsignedInt(codePoints[i]))+ " ");
	}
}
//结果:
0 0 4e 2d 0 0 56 fd 0 0 52 a0 0 0 6c b9 

UTF-32的主要缺点是每个字符使用四个字节,空间浪费较多,它也需要注意大小端,它可被实现为UTF-32LE或UTF-32BE。

七、什么是乱码?

如果你了解了前面提到的知识,那么我们就能发现,对于每一种字符集、字符编码而言,都会采用不同的编码方式,而不同的编码方式意味着使用不同的二进制串来表示一个字符。如果在文本编辑器中设置文本保存的编码方式为A,那么我们就只能按照字符编码A(或者兼容编码A的字符编码B)来识别读取这串二进制序列,否则我们就会遇到乱码的情况。我们需要知道的是,编辑器不都是聪明的,它并不能精确地识别一个文本文件的编码方式,所以只能按照默认的编码方式尝试解析一串二进制序列,这样,乱码就出现了。下面,我们尝试重现一下乱码出现的场景:

//utf-8 --> utf-16
public static void main(String[] args) {
	String s = "中国加油";
	byte[] codePoints = s.getBytes(Charset.forName("utf-8"));
	String ss = new String(codePoints, StandardCharsets.UTF_16);
	System.out.println(ss);
}
//出现乱码,运行结果如下:
귥鮽ꃦ늹

//utf-16 --> ascii
public static void main(String[] args) {
	String s = "中国加油";
	byte[] codePoints = s.getBytes(Charset.forName("ascii"));
	String ss = new String(codePoints, StandardCharsets.US_ASCII);
	System.out.println(ss);
}
//出现乱码,结果如下:
????

八、其他

1.什么是代码页?代码页是字符集编码的别名,也有人称"内码表"。早期,代码页是IBM称呼电脑BIOS本身支持的字符集编码的名称。在windows系统的命令提示符界面敲入chcp就可以获知系统当前活动的代码页,如果是936,说明系统当前使用的是GBK编码;

2.关于BOMUnicode标准建议使用BOM(Byte Order Mark)来区分字节序,也就是在传输字符序列时先传输被作为BOM的字符“零宽无中断空格”。这个字符的编码是FEFF,而反过来的FFFE(UTF-16)FFFE0000(UTF-32)在Unicode中都是未定义的码位,它们不应该出现在实际传输中。借助BOM标记可以帮助判断内容字节的存储顺序。这里,UTF-8不需要用BOM来说明字节顺序,但是可以用来表明编码方式。

UTF编码Byte Order Mark (BOM)
UTF-8 without BOM
UTF-8 with BOMEF BB BF
UTF-16LEFF FE
UTF-16BEFE FF
UTF-32LEFF FE 00 00
UTF-32BE00 00 FE FF

九、资料参考

  1. 字符,字符集,字符编码
  2. 百度百科
  3. unicode官网(加载比较慢)
  4. Unicode–维基百科(可访问)
  5. Unicode字符平面映射

十、勘误表

  • 第一次编辑 2020/3/16
  • 4
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值