字符集的概念实际上包含两个方面,一个是字符的集合,一个是编码方案。通常来说,一个字符集不仅仅定义字符集合,它还为每个符号定义一个二进制编码。例如当我们提到GB2312或者ASCII的时候,它隐式地指明了编码方案是GB2312或者ASCII。
但是Unicode字符集例外,它存在着几种不同的编码方式,例如:
- UTF-8
- UTF-16
- UTF-32;
其中UTF-8和UTF-16采用可变长度编码,UTF-32固定采用固定长度编码;
维基百科对Unicode的描述如下
Unicode的编码空间从U+0000到U+10FFFF,共有1,112,064个码位可以用来映射字符;Unicode的编码空间可划分为17个平面,每个平面包括65,536(即2^16) 个码位。17个平面的码位可表示为U+xx0000到U+xxFFFF,其中xx表示平面,从0x00到0x10。第一个平面称为基本多语言平面,其它平面称为辅助平面.基本多语言平面内的U+D800到U+DFFF之间的码位是永久保留的,不会映射到任何Unicode字符。
UTF-8
UTF-8使用1至4个字节为每个字符编码:
- 0x00-0x7F:表示US-ASCII字符,共128个码位,占用1个字节;
- 0x80-0x7FF:第一个字节由110开始,接着单字节由10开始,共1920个码位,占用2个字节;
- 0x900-0xD7FF,0xE000-0xFFFF:第一个字节由1110开始,接着的字节由10开始;占用3个字节;
- 0x10000-0x10FFFF:第一个字节由11110开始,接着的字节由10开始,占用4个字节。
对于UTF-8编码中的任意字节B,
- 如果B的第一位为0,则B独立的表示一个字符(ASCII码);
- 如果B的第一位为1,第二位为0,则B为一个多字节字符中的一个字节(非ASCII字符);
- 如果B的前两位为1,第三位为0,则B为两个字节表示的字符中的第一个字节;
- 如果B的前三位为1,第四位为0,则B为三个字节表示的字符中的第一个字节;
- 如果B的前四位为1,第五位为0,则B为四个字节表示的字符中的第一个字节;
因此,对UTF-8编码中的任意字节,根据第一位,可判断是否为ASCII字符;根据前二位,可判断该字节是否为一个字符编码的第一个字节;根据前四位(如果前两位均为1),可确定该字节为字符编码的第一个字节,并且可判断对应的字符由几个字节表示;根据前五位(如果前四位为1),可判断编码是否有错误或数据传输过程中是否有错误。
例如,希伯来语字母aleph(א)的Unicode代码是U+05D0,按照以下方法改成UTF-8:
- 它属于U+0080到U+07FF区域,说明它使用双字节,110yyyyy 10zzzzzz.
- 十六进制的0x05D0换算成二进制就是101-1101-0000.
- 这11位数按顺序放入"y"部分和"z"部分:11010111 10010000.
- 最后结果就是双字节,用十六进制写起来就是0xD7 0x90,这就是这个字符aleph(א)的UTF-8编码。
UTF-16
基本多语言平面
码位范围为U+0000到U+FFFF,包含了最常见的字符,UTF-16将这个范围内的码位编码为2个字节,数值等于对应的Unicode码位即0x0000至0xFFFF。
辅助平面
码位范围为U+10000到U+10FFFF,UTF-16将这个范围内的码位编码为4个字节,称为代理对(surrogate pair)。
具体的编码方式如下:
- 码位减去0x10000,得到的值的范围为0x00000至0xFFFFF,长度为20个比特;
- 高位的10比特的值(0至0x3FF)加上0xD800得到第一个码元或称作高位代理(high surrogate),值的范围是0xD800至0xDBFF;由于高位代理比低位代理的值要小,所以为了避免混淆使用,Unicode标准现在称高位代理为前导代理(lead surrogates);
- 低位的10比特的值(0至0x3FF)加上0xDC00得到第二个码元或称作低位代理(low surrogate),值的范围是0xDC00至0xDFFF;由于低位代理比高位代理的值要大,所以为了避免混淆使用,Unicode标准现在称低位代理为后尾代理(trail surrogates)。
综上所述,前导代理、后尾代理和基本语言平面的码位,三者互不重叠,因此可以通过检查一个码元就可以判定给定字符的下一个字符的起始码元,这意味着UTF-16是自同步的。
例如U+10437编码:
- 大于0xFFFF,采用4个字节进行编码;
- 0x10437减去0x10000,结果为0x00437,二进制为0000 0000 0100 0011 0111。
- 分区它的上10位值和下10位值(使用二进制):0000000001 and 0000110111。
- 添加0xD800到上值,以形成高位:0xD800 + 0x0001 = 0xD801。
- 添加0xDC00到下值,以形成低位:0xDC00 + 0x0037 = 0xDC37。
UTF-16编码存在三种编码格式:
-
UTF-16BE:
Big Endian,最低位地址存放高位字节
-
UTF-16LE:
Little Endian,最高位地址存放高位字节
-
UTF-16:
高字节在前还是低字节在前有流中的前两个字节确定,FEFF表示Big Endian,FFFE表示Little Endian;
public class TestUTF {
public static void main(String[] args) throws Exception {
String str = "中";
//------------编码
//Java里使用的是UTF-16BE方式来存储数据的
System.out.println(Integer.toHexString(str.charAt(0)).toUpperCase());//4E2D
/*
* 进行编码时,因为 UTF-16 编码方式本身未指定字节顺序标记,所以默认使用 Big Endian 字节
* 顺序编码,并将 Big Endian 字节顺序标记写入到流中,所以流前面多了 FE FF 二字节的高字节
* 顺序标记
*/
System.out.println(byteToHex(str.getBytes("utf-16")));//FE FF 4E 2D
/*
* 进行编码时,UTF-16BE 和 UTF-16LE charset 不会将字节顺序标记写入到流中
* 即它们所编出的码每个字符只占二个字节,要注意的是解码时要使用同样的编码
* 方式,不然会出现问题乱码
*/
System.out.println(byteToHex(str.getBytes("utf-16BE")));//4E 2D
System.out.println(byteToHex(str.getBytes("utf-16LE")));//2D 4E
//使用 utf-16BE 对高字节序进行解码,忽略字节顺序标记,即不会将流前二字节内容看作字节序标记
System.out.println(new String(new byte[]{0x4E, 0x2D}, "utf-16BE"));// 中
//使用 utf-16LE 对低字节序进行解码,忽略字节顺序标记,即不会将流前二字节内容看作字节序标记
System.out.println(new String(new byte[]{0x2D, 0x4E}, "utf-16LE"));// 中
//------------解码
/*
* 使用 utf-16 进行解码时,会根据流前两字节内部来确定是低还是高字节顺序,如果流的前两字节
* 内部不是 高字节序 FE FF,也不是低字节序 FF FE时,则默认使用 高字节序 方式来解码
*/
//因为0x4E,0x2D为“中”字的高字节表示,所以前面需要加上 FE FF 字节顺序标记来指示它
System.out.println(new String(new byte[]{(byte) 0xFE, (byte) 0xFF, 0x4E, 0x2D}, "utf-16"));//中
//因为0x2D,0x4E为“中”字的低字节表示,所以前面需要加上 FF FE 字节顺序标记来指示它
System.out.println(new String(new byte[]{(byte) 0xFF, (byte) 0xFE, 0x2D, 0x4E,}, "utf-16"));//中
//使用默认 高字节顺序 方式来解码,
System.out.println(new String(new byte[]{0x4E, 0x2D}, "utf-16"));//中
//因为 0x2D,0x4E 为“中”的低字节序,但 utf-16 默认却是以 高字节序来解的,所以出现乱码
System.out.println(new String(new byte[]{0x2D, 0x4E,}, "utf-16"));//?
}
public static String byteToHex(byte[] bt) {
StringBuilder sb = new StringBuilder(4);
for (int b : bt) {
sb.append(Integer.toHexString(b&0xff).toUpperCase());
sb.append(" ");
}
return sb.toString();
}
}
Windows平台,在UTF-8文件的开首,很多时候都放置一个U+FEFF字符(UTF-8以EF,BB,BF代表),以表明这个文本文件是以UTF-8编码
UTF-32
采用4个字节进行编码,就空间而已,其效率最差;另外其不像UTF-16,可以很容易的判断出下一个字符的开始位置,因此并不如其它Unicode编码用得广泛;
Java字符编码
Java虚拟机规范中明确说明了java的char类型使用的编码方案是UTF-16,而我们知道char类型由2个字节存储,这两个字节实际上存储的就是UTF-16编码下的码元;而通过前文可以知道,对于辅助平面字符,需要由4个字节来进行表述;因此我们通过charAt或length方法返回的码元或码元数量只是对于基本语言平面字符正确;正确的处理方式如下:
public int codePointAt(int index) {
if ((index < 0) || (index >= value.length)) {
throw new StringIndexOutOfBoundsException(index);
}
return Character.codePointAtImpl(value, index, value.length);
}
public int codePointCount(int beginIndex, int endIndex) {
if (beginIndex < 0 || endIndex > value.length || beginIndex > endIndex) {
throw new IndexOutOfBoundsException();
}
return Character.codePointCountImpl(value, beginIndex, endIndex - beginIndex);
}
可以看到,此时返回结果为int类型,而不是char,因为char由2个字节表示,而辅助平面字符需要4个字节才能表示。因此Java中如果参数是char,则说明不支持辅助平面字符;如果为int,则支持基本平面和辅助平面字符;具体的方法可以参加Character类;
跨平台(语言)调用
如果所述,我们知道Java是采用UTF-16 BigEndian存储字符的,那么如果跨语言调用,比如JNI对字符是如何处理的呢?
JNI中提供了函数GetStringUTFChars函数,将字符从UTF-16转化为UTF-8:
JNI_ENTRY(const char*, jni_GetStringUTFChars(JNIEnv *env, jstring string, jboolean *isCopy))
JNIWrapper("GetStringUTFChars");
#ifndef USDT2
DTRACE_PROBE3(hotspot_jni, GetStringUTFChars__entry, env, string, isCopy);
#else /* USDT2 */
HOTSPOT_JNI_GETSTRINGUTFCHARS_ENTRY(
env, string, (uintptr_t *) isCopy);
#endif /* USDT2 */
oop java_string = JNIHandles::resolve_non_null(string);
size_t length = java_lang_String::utf8_length(java_string);
char* result = AllocateHeap(length + 1, "GetStringUTFChars");
java_lang_String::as_utf8_string(java_string, result, (int) length + 1);
if (isCopy != NULL) *isCopy = JNI_TRUE;
#ifndef USDT2
DTRACE_PROBE1(hotspot_jni, GetStringUTFChars__return, result);
#else /* USDT2 */
HOTSPOT_JNI_GETSTRINGUTFCHARS_RETURN(
result);
#endif /* USDT2 */
return result;
JNI_END
可以看到它是通过java_lang_String::as_utf8_string方法进行转换的:
char* java_lang_String::as_utf8_string(oop java_string, char* buf, int buflen) {
typeArrayOop value = java_lang_String::value(java_string);
int offset = java_lang_String::offset(java_string);
int length = java_lang_String::length(java_string);
jchar* position = (length == 0) ? NULL : value->char_at_addr(offset);
return UNICODE::as_utf8(position, length, buf, buflen);
}
char* UNICODE::as_utf8(jchar* base, int length, char* buf, int buflen) {
u_char* p = (u_char*)buf;
u_char* end = (u_char*)buf + buflen;
for (int index = 0; index < length; index++) {
jchar c = base[index];
if (p + utf8_size(c) >= end) break; // string is truncated
p = utf8_write(p, base[index]);
}
*p = '\0';
return buf;
}
static u_char* utf8_write(u_char* base, jchar ch) {
if ((ch != 0) && (ch <=0x7f)) {//对于基本语言平面字符,UTF-16编码的数值和UTF-8相同,比如字符"a",UTF-16 BE编码为"0x0061",UTF-8为0x61
base[0] = (u_char) ch;
return base + 1;
}
//对于UTF-16编码,0xFFFF范围内的编码和Unicode编码相同;对于UTF-8,0x80-0x7FF范围内第一个字节由110开始,接着单字节由10开始,共1920个码位,占用2个字节
if (ch <= 0x7FF) {
/* 11 bits or less. */
unsigned char high_five = ch >> 6;
unsigned char low_six = ch & 0x3F;
base[0] = high_five | 0xC0; /* 110xxxxx */
base[1] = low_six | 0x80; /* 10xxxxxx */
return base + 2;
}
//对于UTF-8,0x900-0xD7FF,0xE000-0xFFFF范围内第一个字节由1110开始,接着的字节由10开始;占用3个字节;可以看到,此处并不支持辅助平面字符;
/* possibly full 16 bits. */
char high_four = ch >> 12;
char mid_six = (ch >> 6) & 0x3F;
char low_six = ch & 0x3f;
base[0] = high_four | 0xE0; /* 1110xxxx */
base[1] = mid_six | 0x80; /* 10xxxxxx */
base[2] = low_six | 0x80; /* 10xxxxxx */
return base + 3;
}
int UNICODE::utf8_size(jchar c) {
if ((0x0001 <= c) && (c <= 0x007F)) return 1;//US-ASCII, UTF-8占用一个字节
if (c <= 0x07FF) return 2;//UTF-8编码占用两个字节
return 3;//UTF-8编码占用三个字节
}
参考资料
维基百科UTF-8
维基百科UTF-16
维基百科UTF-32
聊聊java中codepoint和UTF-16相关的一些事