>>分享Java Web开发技术,并且对孙卫琴的《Tomcat与Java Web开发技术详解》提供技术支持 书籍支持  卫琴直播  品书摘要  在线测试  资源下载  联系我们
发表一个新主题 开启一个新投票 回复文章 您是本文章第 24432 个阅读者 刷新本主题
 * 贴子主题:  深入分析Java Web中的中文编码问题 回复文章 点赞(0)  收藏  
作者:javathinker    发表时间:2016-10-09 13:37:36     消息  查看  搜索  好友  复制  引用

作者:wkcing

编码问题一直困扰着开发人员,尤其在Java中更加明显,因为Java是跨平台语言,不同平台之间编码之间的切换较多。本文将向你详细介绍Java中编码问题出现的根本原因,你将了解到:Java 中经常遇到的几种编码格式的区别;Java中经常需要编码的场景;出现中文问题的原因分析;在开发Java web程序时可能会存在编码的几个地方,一个HTTP请求怎么控制编码格式?如何避免出现中文问题?

为什么要编码

不知道大家有没有想过一个问题,那就是为什么要编码?我们能不能不编码?要回答这个问题必须要回到计算机是如何表示我们人类能够理解的符号的,这些符号也就是我们人类使用的语言。由于人类的语言有太多,因而表示这些语言的符号太多,无法用计算机中一个基本的存储单元——byte来表示,因而必须要经过拆分或一些翻译工作,才能让计算机能理解。我们可以把计算机能够理解的语言假定为英语,其它语言要能够在计算机中使用必须经过一次翻译,把它翻译成英语。这个翻译的过程就是编码。所以可以想象只要不是说英语的国家要能够使用计算机就必须要经过编码。这看起来有些霸道,但是这就是现状,这也和我们国家现在在大力推广汉语一样,希望其它国家都会说汉语,以后其它的语言都翻译成汉语,我们可以把计算机中存储信息的最小单位改成汉字,这样我们就不存在编码问题了。
所以总的来说,编码的原因可以总结为:

计算机中存储信息的最小单元是一个字节即8个bit,所以能表示的字符范围是0~255个
人类要表示的符号太多,无法用一个字节来完全表示

要解决这个矛盾必须需要一个新的数据结构char,从char到byte必须编码

如何“翻译”

明白了各种语言需要交流,经过翻译是必要的,那又如何来翻译呢?计算中提拱了多种翻译方式,常见的有ASCII、ISO-8859-1、GB2312、GBK、UTF-8、UTF-16等。它们都可以被看作为字典,它们规定了转化的规则,按照这个规则就可以让计算机正确的表示我们的字符。目前的编码格式很多,例如GB2312、GBK、UTF-8、UTF-16这几种格式都可以表示一个汉字,那我们到底选择哪种编码格式来存储汉字呢?这就要考虑到其它因素了,是存储空间重要还是编码的效率重要。根据这些因素来正确选择编码格式,下面简要介绍一下这几种编码格式。

常见的字符编码

(1) ASCII码
学过计算机的人都知道ASCII码,总共有128个,用一个字节的低7位表示,0~31是控制字符如换行回车删除等;32~126是打印字符,可以通过键盘输入并且能够显示出来。
(2) ISO-8859-1
128个字符显然是不够用的,于是ISO组织在ASCII码基础上又制定了一些列标准用来扩展ASCII编码,它们是ISO-8859-1~ISO-8859-15,其中ISO-8859-1涵盖了大多数西欧语言字符,所有应用的最广泛。ISO-8859-1仍然是单字节编码,它总共能表示256个字符。
(3) GB2312
它的全称是《信息交换用汉字编码字符集 基本集》,它是双字节编码,总的编码范围是A1-F7,其中从A1-A9是符号区,总共包含682个符号,从B0-F7是汉字区,包含6763个汉字。
(4) GBK
全称叫《汉字内码扩展规范》,是国家技术监督局为windows95所制定的新的汉字内码规范,它的出现是为了扩展GB2312,加入更多的汉字,它的编码范围是8140~FEFE(去掉XX7F)总共有23940个码位,它能表示21003个汉字,它的编码是和GB2312兼容的,也就是说用GB2312编码的汉字可以用GBK来解码,并且不会有乱码。
(5) GB18030
全称是《信息交换用汉字编码字符集》,是我国的强制标准,它可能是单字节、双字节或者四字节编码,它的编码与GB2312编码兼容,这个虽然是国家标准,但是实际应用系统中使用的并不广泛。
(6) UTF-16
说到UTF必须要提到Unicode(Universal Code 统一码),ISO试图想创建一个全新的超语言字典,世界上所有的语言都可以通过这本字典来相互翻译。可想而知这个字典是多么的复杂,关于Unicode的详细规范可以参考相应文档。Unicode是Java和XML的基础,下面详细介绍Unicode在计算机中的存储形式。
UTF-16具体定义了Unicode字符在计算机中存取方法。UTF-16用两个字节来表示Unicode转化格式,这个是定长的表示方法,不论什么字符都可以用两个字节表示,两个字节是16个bit,所以叫UTF-16。UTF-16表示字符非常方便,每两个字节表示一个字符,这个在字符串操作时就大大简化了操作,这也是Java以UTF-16作为内存的字符存储格式的一个很重要的原因。
(7) UTF-8
UTF-16统一采用两个字节表示一个字符,虽然在表示上非常简单方便,但是也有其缺点,有很大一部分字符用一个字节就可以表示的现在要两个字节表示,存储空间放大了一倍,在现在的网络带宽还非常有限的今天,这样会增大网络传输的流量,而且也没必要。而UTF-8采用了一种变长技术,每个编码区域有不同的字码长度。不同类型的字符可以是由1~6个字节组成。
(8) UTF-8有以下编码规则:
如果一个字节,最高位(第8位)为0,表示这是一个ASCII字符(00 - 7F)。可见,所有ASCII编码已经是UTF-8了。
如果一个字节,以11开头,连续的1的个数暗示这个字符的字节数,例如:110xxxxx代表它是双字节UTF-8字符的首字节。
如果一个字节,以10开始,表示它不是首字节,需要向前查找才能得到当前字符的首字节

Java中需要编码的场景

前面描述了常见的几种编码格式,下面将介绍Java中如何处理对编码的支持,什么场合中需要编码。

I/O操作中存在的编码

我们知道涉及到编码的地方一般都在字符到字节或者字节到字符的转换上,而需要这种转换的场景主要是在I/O的时候,这个I/O包括磁盘I/O和网络I/O,关于网络I/O部分在后面将主要以Web应用为例介绍。

Reader类是Java的I/O中读字符的父类,而InputStream类是读字节的父类,InputStreamReader类就是关联字节到字符的桥梁,它负责在I/O过程中处理读取字节到字符的转换,而具体字节到字符的解码实现它有委托StreamDecoder去实现,在StreamDecoder解码过程中必须由用户指定Charset编码格式。值得注意的是如果你没有指定Charset,将使用本地环境中的默认字符集,例如在中文环境中将使用GBK编码。
写的情况也是类似,字符的父类是Writer,字节的父类是OutputStream,通过OutputStreamWriter转换字符到字节。

同样StreamEncoder类负责将字符编码成字节,编码格式和默认编码规则与解码是一致的。
如下面一段代码,实现了文件的读写功能:

清单1.I/O涉及的编码示例

String file = "c:/stream.txt"; String charset = "UTF-8"; //写字符换转成字节流
FileOutputStream outputStream = new FileOutputStream(file);
OutputStreamWriter writer = new OutputStreamWriter(outputStream, charset);
try {
  writer.write("这是要保存的中文字符");
} finally { writer.close(); } //读取字节转换成字符

FileInputStream inputStream = new FileInputStream(file);
InputStreamReader reader = new InputStreamReader(inputStream, charset);
StringBuffer buffer = new StringBuffer();
char[] buf = new char[64]; int count = 0;
try {
  while ((count = reader.read(buf)) != -1){
    buffer.append(buffer, 0, count);
  }
} finally { reader.close(); }

在我们的应用程序中涉及到I/O操作时只要注意指定统一的编解码Charset字符集,一般不会出现乱码问题,有些应用程序如果不注意指定字符编码,中文环境中取操作系统默认编码,如果编解码都在中文环境中,通常也没问题,但是还是强烈的不建议使用操作系统的默认编码,因为这样,你的应用程序的编码格式就和运行环境绑定起来了,在跨环境下很可能出现乱码问题。

内存中操作中的编码

在Java开发中除了I/O涉及到编码外,最常用的应该就是在内存中进行字符到字节的数据类型的转换,Java中用String表示字符串,所以String类就提供转换到字节的方法,也支持将字节转换为字符串的构造函数。如下代码示例:

String s = "这是一段中文字符串";
byte[] b = s.getBytes("UTF-8");
String n = new String(b,"UTF-8");

另外一个是已经被被废弃的ByteToCharConverter和CharToByteConverter类,它们分别提供了convertAll方法可以实现byte[]和char[]的互转。如下代码所示:

ByteToCharConverter charConverter = ByteToCharConverter.getConverter("UTF-8");
char c[] = charConverter.convertAll(byteArray);
CharToByteConverter byteConverter = CharToByteConverter.getConverter("UTF-8");
byte[] b = byteConverter.convertAll(c);

这两个类已经被Charset类取代,Charset提供encode与decode分别对应char[]到byte[]的编码和byte[]到char[]的解码。如下代码所示:
Charset charset = Charset.forName("UTF-8");
ByteBuffer byteBuffer = charset.encode(string);
CharBuffer charBuffer = charset.decode(byteBuffer);

编码与解码都在一个类中完成,通过forName设置编解码字符集,这样更容易统一编码格式,比ByteToCharConverter和CharToByteConverter类更方便。

Java中还有一个ByteBuffer类,它提供一种char和byte之间的软转换,它们之间转换不需要编码与解码,只是把一个16bit的char格式,拆分成为2个8bit的byte表示,它们的实际值并没有被修改,仅仅是数据的类型做了转换。如下代码所以:

ByteBuffer heapByteBuffer = ByteBuffer.allocate(1024);
ByteBuffer byteBuffer = heapByteBuffer.putChar(c);

以上这些提供字符和字节之间的相互转换只要我们设置编解码格式统一一般都不会出现问题。

Java中如何编解码

前面介绍了几种常见的编码格式,这里将以实际例子介绍Java中如何实现编码及解码,下面我们以“I am 君山”这个字符串为例介绍Java中如何把它以ISO-8859-1、GB2312、GBK、UTF-16、UTF-8编码格式进行编码的。

/* String编码 */
public static void encode() {
  String name = "I am 君山";
  toHex(name.toCharArray());
  try {
    byte[] iso8859 = name.getBytes("ISO-8859-1");
    toHex(iso8859);
    byte[] gb2312 = name.getBytes("GB2312");
    toHex(gb2312); byte[] gbk = name.getBytes("GBK");
    toHex(gbk); byte[] utf16 = name.getBytes("UTF-16");
    toHex(utf16); byte[] utf8 = name.getBytes("UTF-8");
    toHex(utf8);
  } catch (UnsupportedEncodingException e) {
    e.printStackTrace();
  }
}

我们把name字符串按照前面说的几种编码格式进行编码转化成byte数组,然后以16进制输出,我们先看一下Java是如何进行编码的。

首先根据指定的charsetName通过Charset.forName(charsetName)合法的Charset类,然后根据Charset创建CharsetEncoder对象,再调用CharsetEncoder.encode对字符串进行编码,不同的编码类型都会对应到一个类中,实际的编码过程是在这些类中完成的。

根据charsetName找到Charset类,然后根据这个字符集编码生成CharsetEncoder,这个类是所有字符编码的父类,针对不同的字符编码集在其子类中定义了如何实现编码,有了CharsetEncoder对象后就可以调用encode方法去实现编码了。这个是String.getBytes编码方法,其它的如StreamEncoder中也是类似的方式。下面看看不同的字符集是如何将前面的字符串编码成byte数组的?
如字符串“I am 君山”的char数组为49 20 61 6d 20 541b 5c71,下面把它按照不同的编码格式转化成相应的字节。

按照ISO-8859-1编码
字符串“I am 君山”用ISO-8859-1编码:

49 20 61 6d 20 541b 5c71
I a m 君山

49 20 61 6d 20 3f 3f
char[]
ISO-8859-1编码
byte[]

从上看出7 个char 字符经过ISO-8859-1 编码转变成7 个byte 数组,ISO-8859-1 是单字节编码,中文“君山”被转化
成值是3f 的byte。3f 也就是“?”字符,所以经常会出现中文变成“?”很可能就是错误的使用了ISO-8859-1 这个编
码导致的。中文字符经过ISO-8859-1 编码会丢失信息,通常我们称之为“黑洞”,它会把不认识的字符吸收掉。由于现
在大部分基础的Java 框架或系统默认的字符集编码都是ISO-8859-1,所以很容易出现乱码问题,后面将会分析不同的
乱码形式是怎么出现的。

按照GB2312 编码
字符串“I am 君山”用GB2312 编码:
49 20 61 6d 20 541b 5c71
I a m 君山
49 20 61 6d 20 be fd
char[]
GB2312编码
byte[]
c9 bd

GB2312 对应的Charset 是sun.nio.cs.ext. EUC_CN 而对应的CharsetDecoder 编码类是sun.nio.cs.ext. DoubleByte,GB2312
字符集有一个char 到byte 的码表,不同的字符编码就是查这个码表找到与每个字符的对应的字节,然后拼装成byte
数组。查表的规则如下:
c2b[c2bIndex[char >> 8] + (char & 0xff)]
如果查到的码位值大于oxff 则是双字节,否则是单字节。双字节高8 位作为第一个字节,低8 位作为第二个字节,如
下代码所示:

if (bb > 0xff) { // DoubleByte
  if (dl - dp < 2)
    return CoderResult.OVERFLOW;
  da[dp++] = (byte) (bb >> 8);
  da[dp++] = (byte) bb;
} else { // SingleByte
  if (dl - dp < 1)
    return CoderResult.OVERFLOW;
  da[dp++] = (byte) bb;
}

从上可以看出前5 个字符经过编码后仍然是5 个字节,而汉字被编码成双字节,在第一节中介绍到GB2312 只支持
6763 个汉字,所以并不是所有汉字都能够用GB2312 编码。

按照GBK 编码
字符串“I am 君山”用GBK 编码:

49 20 61 6d 20 541b 5c71
I a m 君山
49 20 61 6d 20 be fd
char[]
GBK编码
byte[]
c9 bd

你可能已经发现上面结果与GB2312 编码的结果是一样的,没错GBK 与GB2312 编码结果是一样的,由此可以得出GBK
编码是兼容GB2312 编码的,它们的编码算法也是一样的。不同的是它们的码表长度不一样,GBK 包含的汉字字符更
多。所以只要是经过GB2312 编码的汉字都可以用GBK 进行解码,反过来则不然。

按照UTF-16 编码

字符串“I am 君山”用UTF-16 编码:

49 20 61 6d 20 541b 5c71
I a m 君山
49 00 61 00 00 54 1b
char[]
UTF-16编码
byte[]
00 20 00 6d 20 5c 71

用UTF-16 编码将char 数组放大了一倍,单字节范围内的字符,在高位补0 变成两个字节,中文字符也变成两个字节。
从UTF-16 编码规则来看,仅仅将字符的高位和地位进行拆分变成两个字节。特点是编码效率非常高,规则很简单,由
于不同处理器对2 字节处理方式不同,Big-endian(高位字节在前,低位字节在后)或Little-endian(低位字节在前,
高位字节在后)编码,所以在对一串字符串进行编码是需要指明到底是Big-endian 还是Little-endian,所以前面有两
个字节用来保存BYTE_ORDER_MARK 值,UTF-16 是用定长16 位(2 字节)来表示的UCS-2 或Unicode 转换格式,
通过代理对来访问BMP 之外的字符编码。

按照UTF-8 编码

字符串“I am 君山”用UTF-8 编码:

49 20 61 6d 20 541b 5c71
I a m 君山
49 20 61 6d 20 e5 90
char[]
UTF-8 编码
byte[]
9b e5 b1 b1

UTF-16虽然编码效率很高,但是对单字节范围内字符也放大了一倍,这无形也浪费了存储空间,另外UTF-16采用顺序编码,不能对单个字符的编码值进行校验,如果中间的一个字符码值损坏,后面的所有码值都将受影响。而UTF-8这些问题都不存在,UTF-8对单字节范围内字符仍然用一个字节表示,对汉字采用三个字节表示。

/* UTF-8编码代码片段 */

private CoderResult encodeArrayLoop(CharBuffer src,ByteBuffer dst){
  char[] sa = src.array();
  int sp = src.arrayOffset() + src.position();
  int sl = src.arrayOffset() + src.limit();
  byte[] da = dst.array();
  int dp = dst.arrayOffset() + dst.position();
  int dl = dst.arrayOffset() + dst.limit();
  int dlASCII = dp + Math.min(sl - sp, dl - dp);
}

ASCII 字符不用编码,直接复制 while (dp < dlASCII && sa[sp] < '\切换,如Java的内存编码就是采用UTF-16编码。但是它不适合在网络之间传输,因为网络传输容易损坏字节流,一旦字节流损坏将很难恢复,想比较而言UTF-8更适合网络传输,对汉字都是以字符形式
更多 0




程序猿的技术大观园:www.javathinker.net



[这个贴子最后由 admin 在 2017-08-01 13:32:13 重新编辑]
  Java面向对象编程-->Java语言的基本语法和规范
  JavaWeb开发-->Servlet技术详解(Ⅱ)
  JSP与Hibernate开发-->使用JPA和注解
  Java网络编程-->用Swing组件展示HTML文档
  精通Spring-->绑定表单
  Vue3开发-->Vue组件开发基础
  服务器端推送技术汇总
  程序员:我终于知道HTTP的post和get请求方式的区别
  JSP 语法
  Servlet 网页重定向
  apache做反向代理服务器
  Tomcat虚拟主机配置以及各种优化
  Java web使用监听器实现定时周期性执行任务的功能
  设置和获取Cookie
  Tomcat中对静态资源的处理
  关于JSTL标签库版本的升级和下载
  JSP自定义标签的用法
  使用Java Mail API收发邮件
  用Servlet API中的Part接口实现文件上传
  关于GBK,GB2312,UTF-8字符编码的区别
  Servlet的非阻塞I/O处理方式
  更多...
 IPIP: 已设置保密
楼主      
1页 0条记录 当前第1
发表一个新主题 开启一个新投票 回复文章


中文版权所有: JavaThinker技术网站 Copyright 2016-2026 沪ICP备16029593号-2
荟萃Java程序员智慧的结晶,分享交流Java前沿技术。  联系我们
如有技术文章涉及侵权,请与本站管理员联系。