×

重拾Java(02) - 字符编码

96
jackfrued
2017.01.17 18:38* 字数 2802

ASCII码

在计算机内部,所有的信息最终都表示为一堆二进制形式的数据。每一个二进制位(bit)有0和1两种状态,因此八个二进制位就可以组合出256种状态,称为一个字节(byte),从0000000到11111111。上世纪60年代,美国制定了一套字符编码,对英语字符与二进制位之间的关系做了统一规定,称之为ASCII码(American Standard Code for Information Interchange)并沿用至今。ASCII码一共规定了128个字符的编码,比如空格是32(00100000),大写的字母A是65(01000001)。这128个符号(包括32个不能打印出来的控制符号),只占用了一个字节的后面7位,最前面的1位统一规定为0。
  英语用128个符号编码就够了,但是其他语言很多都不止128个符号,比如在法语中,字母上方有注音符号,这种字符就无法用ASCII码表示。为此,在某些欧洲国家会利用字节中闲置的最高位编入新的符号,例如法语中的é的编码为130(二进制10000010)。这样一来,这些欧洲国家使用的编码体系,可以表示最多256个符号。但是这里又出现了新的问题,不同的国家有不同的字母,因此,哪怕它们都使用256个符号的编码方式,代表的字母却不一样。比如,130在法语编码中代表了é,在希伯来语编码中却代表了字母ג,但是不管怎样,所有这些编码方式中,0127表示的符号是一样的,不一样的只是128255的这一段。
  至于亚洲国家的文字,使用的符号就更多了,1994年由中华书局、中国友谊出版公司出版的《中华字海》就收录了85568个汉字。一个字节只能表示256种符号肯定是不够的,就必须使用多个字节表达一个符号。比如,简体中文常见的编码GB2312使用两个字节表示一个汉字,所以理论上最多可以表示65536个符号。

Unicode

世界上存在着多种编码方式,同一个二进制数字可以被解释成不同的符号。因此,要想打开一个文本文件,就必须知道它的编码方式,否则用错误的编码方式解读,就会出现乱码。可以想象,如果有一种编码,将世界上所有的符号都纳入其中。每一个符号都给予一个独一无二的编码,那么乱码问题就会消失,这就是Unicode。Unicode是一个很大的集合,现在的规模可以容纳100多万个符号。每个符号的编码都不一样,比如,U+0041表示英语的大写字母A,U+660A表示汉字"昊"。需要注意的是,Unicode只是一个符号集,它只规定了符号的二进制代码,却没有规定这个二进制代码应该如何存储。
比如,汉字"昊"的Unicode是十六进制数660A,转换成二进制数是0110 0110 0000 1010,也就是说这个符号的表示至少需要2个字节。表示其他更大的符号,可能需要3个字节或者4个字节。这里就有两个问题,第一个问题是,如何才能区别Unicode和ASCII?计算机怎么知道三个字节表示一个符号,而不是分别表示三个符号呢?第二个问题是,我们已经知道,英文字母只用一个字节表示就够了,如果Unicode统一规定,每个符号用三个或四个字节表示,那么每个英文字母前都必然有二到三个字节是0,这对于存储来说是极大的浪费,文本文件的大小会因此大出二三倍,这是无法接受的。为此出现了Unicode的多种存储方式,也就是说有多种不同的二进制格式可以用来表示Unicode。

UTF-8

UTF-8就是在互联网上使用最广的一种Unicode的实现方式。其他实现方式还包括UTF-16(字符用两个字节或四个字节表示)和UTF-32(字符用四个字节表示),不过在互联网上基本不用。UTF-8最大的一个特点,就是它是一种变长的编码方式。它可以使用1~4个字节表示一个符号,根据不同的符号而变化字节长度。UTF-8的编码规则很简单,只有二条:

  1. 对于单字节的符号,字节的第一位设为0,后面7位为这个符号的Unicode码。因此对于英语字母,UTF-8编码和ASCII码是相同的。
  2. 对于n字节的符号(n>1),第一个字节的前n位都设为1,第n+1位设为0,后面字节的前两位一律设为10。剩下的没有提及的二进制位,全部为这个符号的Unicode码。
    下表总结了编码规则,字母x表示可用编码的位。
Unicode符号范围 UTF-8编码方式
0000 0000-0000 007F 0xxxxxxx
0000 0080-0000 07FF 110xxxxx 10xxxxxx
0000 0800-0000 FFFF 1110xxxx 10xxxxxx 10xxxxxx
0001 0000-0010 FFFF 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx

说明:解读UTF-8编码非常简单。如果一个字节的第一位是0,则这个字节单独就是一个字符;如果第一位是1,则连续有多少个1,就表示当前字符占用多少个字节。

Java中的编解码

I/O操作时的编解码

在进行I/O操作时经常会遇到将字节流转换成字符流的场景,Java的API提供了InputStreamReader和OutputStreamWriter来解决这样的问题,而这两个类的构造器中都可以指定编码/解码的方式。

InputStreamReader(InputStream in)  // 使用默认的字符集
InputStreamReader(InputStream in, String charsetName)  throws UnsupportedEncodingException
InputStreamReader(InputStream in, Charset cs)
InputStreamReader(InputStream in, CharsetDecoder dec)
OutputStreamWriter(OutputStream out)  // 使用默认的字符集
OutputStreamWriter(OutputStream out, String charsetName) throws UnsupportedEncodingException
OutputStreamWriter(OutputStream out, Charset cs)
OutputStreamWriter(OutputStream out, CharsetEncoder enc)

从JDK 1.4引入了NIO开始,我们可以使用Charset类提供encode和decode方法实现字符数组和字节数组的转换,代码如下所示:

Charset cs = Charset.forName("utf-8");
String str = "骆昊";
ByteBuffer buffer1 = cs.encode(str);
// 骆                           昊
// e9        aa        86       e6       98       8a
// 11101001  10101010  10000110 11100110 10011000 10001010
for (int index = 0; index < buffer1.limit(); index += 1) {
    System.out.print(Integer.toHexString(buffer1.get(index) & 0xff) + " ");
}
System.out.println();
CharBuffer buffer2 = cs.decode(buffer1);
// 骆昊
System.out.println(buffer2.toString());

字符串的编解码

Java中的String类提供了用字节数组和指定的编码构造字符串对象的操作,同时也提供了将字符串按照指定的编码解码成字节数组的操作,下面我们来做几个小实验。

实验1:中文变成'?'。

public static void main(String[] args) throws UnsupportedEncodingException {
    String str = "hello, 骆昊";
    byte[] buffer = str.getBytes("iso-8859-1");
    // hello, ??
    System.out.println(new String(buffer));
}

说明:ISO-8859-1是单字节编码,中文“骆昊”的编码(0x9a86和0x660a)会被转换成0x3f,而0x3f是ASCII码中的'?',所以中文就变成了问号,而且中文字符的编码信息已经丢失,再怎么解码也没有机会还原出原来的中文字符了。所以这种现象也称之为“编码黑洞”,因为它把不认识的字符给吞噬掉了。很多Java的框架和产品默认都使用了ISO-8859-1,所以这个问题很常见。

实验2:中文变成看不懂的字符。

public static void main(String[] args) throws UnsupportedEncodingException {
    String str = "hello, 骆昊";
    byte[] buffer = str.getBytes("gbk");
    // hello, Âæê»
    System.out.println(new String(buffer, "iso-8859-1"));   
}

说明:这种情况在使用浏览器的时候也很常见,服务器传过来的是中文字符但是浏览器的编码却设置为ISO-8859-1就会出这种问题。

如果中文经过了多次编解码,那么还有可能遇到一个中文字符变成多个问号的情况。其实要解决这些编码问题原则非常简单,首先如果要表示中文字符就不能使用单字节编码,这样势必会出现“黑洞”;其次编码和解码使用的“码”应当是一致的。

URL编码

URL是统一资源定位符(Universal Resource Locator)的缩写,是Internet上标准的资源地址。它最初是由万维网和浏览器的发明者英国人Tim Berners-Lee发明用来作为万维网的地址,现在已经被W3C编制为Internet标准(RFC 1738)。统一资源定位符的标准格式如下:

协议://服务器域名或地址:[端口号]/资源路径/文件名[?查询参数]

我们试一试在用谷歌搜索“骆昊”,来看看浏览器地址栏中的URL到底是什么样的。

https://www.google.com.hk/#safe=strict&q=%E9%AA%86%E6%98%8A

URL中允许出现的字符分为保留字符(有特殊含义的字符)与未保留字符,未保留字符包括英文大小写字母、0-9的数字以及‘-’、 ‘_’、 ‘.’和'~',保留字符包括 ‘!’、 ‘*’、 ‘'’、 ‘(’、 ‘)’、 ‘;’、 ‘:’、 ‘@’、 ‘&’、 ‘=’、 ‘+’、 ‘$’、 ‘,’、 ‘/’、 ‘?’、 ‘#’、 ‘[’和‘]’。如果URL中需要用到保留字符或者非URL允许的字符则需要使用百分号编码,例如:‘=’要处理成‘%3D’、‘+’要处理成‘%2B’、而上面要搜索的‘骆’和‘昊’两个中文字符被处理成了百分号编码的‘%E9%AA%86’和‘%E6%98%8A’。

Java中要将URL中的非URL允许字符处理成百分号编码有非常简单的办法,就是使用URLEncoder类的encode方法,代码如下所示。

public static void main(String[] args) throws UnsupportedEncodingException {
    String urlStr = "Java 骆昊";
    String encodedUrlStr = URLEncoder.encode(urlStr, "utf-8");
    // Java+%E9%AA%86%E6%98%8A
    System.out.println(encodedUrlStr);
}

几种编码格式的比较

表示中文可以选择的编码方式很多,包括GB2312、GBK、GB18030、UTF-8和UTF-16。UTF-16定义了Unicode字符在计算机中的存取方式,用固定长度的两个字节来表示所有的字符,Java中的char类型之所以是两个字节就是因为Java使用了UTF-16作为内存中字符存储的格式。UTF-16的编码效率高,字符与字节之间的转换也相对简单,但是如果在网络上传输数据的话会遇到大尾数和小尾数字节顺序转换的问题,因此UTF-8更适合在网络上传输数据,而UTF-16更适合在内存中使用。UTF-8使用了变长存储的方式,对ASCII字符采用单字节存储,对其他字符可以使用1~6个字节来表示,编码效率介于GBK和UTF-16之间,因此开发Java Web应用时,强烈建议使用UTF-8这种编码方式。

推荐阅读

  1. ASCII - https://zh.wikipedia.org/wiki/ASCII
  2. Unicode - https://zh.wikipedia.org/wiki/Unicode
  3. UTF-8 - https://zh.wikipedia.org/wiki/UTF-8
  4. 中文编码 - https://zh.wikipedia.org/wiki/%E5%AD%97%E7%AC%A6%E7%BC%96%E7%A0%81
Java开发日记
Web note ad 1