1

本文导读

很多人经常分不清UTF-8编码和UTF-16编码,或经常会问"Unicode编码和UTF-8编码有什么区别联系","Java的外码内码又是什么东西",这篇文章主要做一个关于编码知识的简单扫盲,包括对一些常见概念混淆进行区分讲解。

基础概念

我们经常提到的关于编码的概念可以粗略划分为两类:

  • 字符集:将一个字符映射为某个唯一的数字(码值),如字符A在ascii码中映射为65
  • 字符编码:将字符集用程序(字节)表示的一套规则,可以认为字符编码是字符集在计算机上的一种实现方式,如utf-8和utf-16都是unicode码的实现方式。

Unicode字符集介绍

Unicode字符集一开始提出的时候,认为码值范围为0-65535(0-FFFF,这一段区域也被称为Basic Multilingual Plane, 简称BMP)就可以表示所有的字符,但随着时代发展,0-FFFF也不够容纳所有字符,因此Unicode划出了一个代理区:D800-DFFF, Unicode标准规定U+D800 - U+DFFF的值不对应于任何字符。这也是为什么有些人说:有些字符需要用两个Unicode字符去表示的原因。

目前Unicode的编码空间为0-10FFFF,根据第一段落可以得知,当某个字符的Unicode码值落在0-FFFF时,则只用一个Unicode字符即可表示,否则就会用两个。

UTF-8编码介绍

Utf-8全称为8-bit Unicode Transformation Format,是一种针对Unicode字符集的可变长编码,不同的Unicode码点会使用不同的字节数去存储,如ascii码(都小于128)则会使用1个字节去存储,一些常用字符(如部分中文)会使用2~3个字节去存储,这有一些优势,首先对于ascii码完全兼容,且对于某些场景(只存在ascii码)编码后占用空间少,缺点也很明显,当遇到的都是需要占用3个字节存储的Unicode码点时,则会耗费更大的空间。

utf-8编码的基本规则如下:

Unicode码范围 Utf-8编码格式
0x0000-0x007F(0~127) 0xxxxxxx
0x0080-0x07FF(128~2047) 110xxxxx 10xxxxxx
0x0800-0xFFFF(2048-65535) 1110xxxx 10xxxxxx 10xxxxxx
0x10000-0X10FFFF 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx

首先将Unicode码用二进制表示,然后根据其所属的Unicode范围对应的Utf-8编码格式截取最后几位。

如0x0000-0x00FF对应的Utf-8编码格式是0xxxxxxx,则截取最后7位(有效表示位其实也只有7位,因为从右往左第8位一定是0),对应填到x中。

同理,0x0080-0x07FF(0000 0000 1000 0000 — 0000 0111 1111 1111),则截取后11位(有效表示位其实只有11位,因为从右往左第12位一定是0),对应填到x中。

0x0080-0xFFFF(0000 1000 0000 0000 — 1111 1111 1111 1111),截取后15位(有效表示位其实只有16位,因为从右往左第17位一定是0),对应填到x中。

0x100000-0X10FFFF(0000 0001 0000 0000 0000 — 0001 0000 1111 1111 1111 1111),截取后21位(有效表示位其实只有21位,从右往左第22位一定是0),对应填到x中。

因此,我们可以得到所有Unicode的Utf-8编码规则。

UTF-16编码介绍

在Unicode字符集中讲到,Unicode字符集存在一个拓展区域:D800-DFFF,用于表示码点在0x10000-0x10FFFF范围的字符。当碰到某个在该范围内的Utf-16字符,需要再读一个Utf-16字符,将两个Utf-16字符组合表示一个Unicode字符。

Unicode码范围 Utf-16编码格式
0x0000-0xFFFF(0~65535) 使用2个字节存储
0x10000-0x10FFFF(65536~) 使用4个字节存储,需要利用上述提到的代理区

接下来将Unicode码用二进制表示,尝试将它用Utf-16编码格式进行编码。

对应0x0000-0xFFFF范围的Unicode码,直接将这16为对应填入两个字节(恰好16位)就可以得到Utf-16编码。

而对应0x10000-0x10FFFF的Unicode码,需要有一些特殊处理:

  1. 取后20位(减去10000),将这20位数字分为高10位和低10位,高、低10位的范围即为0-0x3FF(00 0000 0000 — 11 1111 1111)
  2. 将高位加上0xD800,得到值范围为0xD800—0xDBFF,将低位加上0xDC00,得到值范围为0xDC00—0xDFFF;
  3. 将高位处理后的值(又称前导代理)放在前2个字节中,将低位处理后的值(后导代理)放在后2个字节中。

通过处理,前导代理和后导代理恰好占满了0xD800—0xDFFF这一段代理区域,这样处理的一个优点在于,看到每一个Utf-16编码,可以很清楚地确定它是属于前导代理、后导代理还是除此以外的BMP区域中的Unicode。

MUTF-8编码介绍

MUTF-8(Modified UTF-8)编码,可以认为是对UTF-16编码的再编码。它的编码方式与UTF-8编码非常相似,只需要记住某些不同的情况,其他都与UTF-8编码一致。

具体的不同情况有二:

  1. 对于Unicode的0码点,UTF-8直接使用1个字节去存储(0000 0000),而MUTF-8会使用2个字节去存储,最后存储的值为0xC080(1100 0000 1000 0000)。
  2. 对于0x10000-0x10FFFF这块区域的Unicode码,之前提过UTF-8是使用4个字节去存储,而MUTF-8是对UTF-16的再编码,所以MUTF-8是对UTF-16编码的两个字符分别用3个字节去编码(因为这段区域的Unicode码值转为UTF-16编码后前导代理和后导代理的范围是0xD800—0xDFFF,明显大于0x0800),共需要6个字节

所以网上经常会提到UTF-8编码,又提到用1—6个字节去编码,其实说的是MUTF-8编码。

Java的内码与外码

Java的内码是UTF-16,外码是MUTF-8。那什么是内码和外码呢?

内码:程序内部使用的字符编码,如java的char,所以java的char是2字节16位;

外码:程序外部交互时使用的字符编码,如class文件。

在深入理解Java虚拟机第三版6.3.2节中,我们可以得知其实Java的字符串常量(如String str="hello world")都是以CONSTANT_Utf8_info类型存在常量池中的,class文件的编码是MUTF-8,所以CONSTANT_Utf8_info中存储的根据不同的实现一般是存储MUTF-8字节数组或UTF-16字符数组,每次构建时java.lang.String对象时,需要通过MUTF-8=>UTF-16的一个编码转换将外码转为内码,再将其塞到char数组(value)中。

在Java API层对字符串的操作,其实一般也是对UTF-16字符的操作,如charAt函数:

public char charAt(int index) {
    if ((index < 0) || (index >= value.length)) {
        throw new StringIndexOutOfBoundsException(index);
    }
    return value[index];
}

charAt函数实际是返回了一个char,所以是返回了一个UTF-16字符,它不一定是一个完整的Unicode码点。

当然,在Java API层也可以使用getBytes(”UTF-8")则是返回UTF-8编码的字节数组。

总结

根据上面文章的讲解,我们就可以讲清楚下面几个常常遇到的问题:

  • Unicode编码和UTF-8编码的区别? 其实Unicode只是字符集,而UTF-8是该字符集在计算机中的编码表示。
  • 为什么说UTF-8是1~6个字节? 这里的UTF-8其实在指MUTF-8编码,MUTF-8使用1~3个字节对UTF-16编码进行再编码,所以就产生了使用6个字节表示一个Unicode字符的情况。
  • Java的char到底占用几个字节?Java内码使用的是UTF-16编码,UTF-16对每个Unicode字符使用2或4个字节进行编码,所以对每个char单位,其实是占用了2个字节。

另外,java虚拟机对字符串的表示或处理很多都是使用的UTF-16编码或MUTF-8编码,而UTF-8编码一般是显式通过Java API层的String.getBytes("UTF-8")函数得到。

平时使用时,如果只用Java语言开发一般不会有什么乱码问题,但如果自己想手动实现一个Java虚拟机,或是要通过JNI做一些事情的时候,就需要去了解一下Java的这些编码知识了。如笔者之前参与的项目,用go处理Java虚拟机的编码问题就很头大,因为Go这边默认使用的是UTF-8编码,所以如果在实现常量池的过程中用Go的string去存储java.lang.String的实际内容,则可能出现一些奇怪的乱码问题。


Mulavar
33 声望19 粉丝