【旃蒙】深度探索-JAVA NIO VS 传统 IO(二)编码解码

如果这篇文章对您有些用处,请点赞告诉我O(∩_∩)O

接上篇,继续研究第(2)个问题:

(1)传统IO中也有BufferInputStream,BufferReader,和NIO中的Buffer相比有什么不同?

(2)传统IO中的Reader,Writer也能实现编码解码,和NIO中的Charset相比有什么不同?

(3)传统IO对流read,writer,NIO对通道read,writer有什么不同?是否支持一些新的IO操作?

(4)传统IO里面没有Selector这个特性,看不懂,它能带来什么好处?

一、基本概念

要弄懂Charset,就必须了解编码解码含义。

假设将一段文字写入文件,然后在从文件中读出来。

这样一个简单的场景,计算机如何操作呢?

问题1:如何将文字转换为0,1的数字?

首先大家都知道计算机只认识0,1,无法理解字符,也就无法理解汉字。如何将文字转换为数字呢?

人们想到要给世界上所有的字符都指定唯一的一个数字与其对应。这个数字称为码点,范围是0-0x10FFFF,整套数字称为统一码UNICODE。

下图是汉字在UNICODE范围,网上也有使用4E00~9FA5判断是否为汉字,其实不是非常准确。(0,1的二进制太长,我们更喜欢用十六进制表示)

(此图来自qqxiuzi)

如:

问题2:如何“断字”?

解决了字符对应数字的问题后,如果按照UNICODE码点直接存入文件,有可能是2个字节也有可能是3个,当我们从文件中读出一个字节,如何分辨属于前者还是后者。如:“李𠗃” 使用UNICODE码点存入文件后为674E205C3,只有读出正确的码点,才能对照出正确字符,那么此时几个字节对应一个字符呢?分辨不出。

于是人们想到给码点规定统一的长度,即统一4个字节表示一个UNICODE码点(不够补0),写入和读出时按照统一规则“断字”。这种二次编码(UNICODE编码的编码)称为UCS-4,范围为 U+00000000~U+7FFFFFFF。“李𠗃” 使用UCS-4编码后为0000674E 00205C3,读出时4个字节一读就可以得到正确的码点。

问题3:如何避免空间浪费?

使用定长编码解决了“断字”的问题。但UNICODE码点是2~3个字节,现在都使用4个字节二次编码,这样文字越多,存储空间浪费越多。接着人们想到了动态长度编码,即将UNICODE码点,按照不同范围,放到1~4个字节的模板里,再次编码。

如:

说明:

(1)如此编码写入文件后,读出时,每次读取一个字节,当读到以0开头的字节,表示该字符只有一个字节,当读到以110开头的字节,表示该字符需要读取2个字节,当读到以1110开头的字节,表示该字符需要读取3个字节,当读到以11110开头的字节,表示该字符需要读取4个字节。

取得完整的编码字节后,就可以根据模板规则,反向解码为UNICODE码点,最终分辨出是什么字符,这就解决了“断字”问题。这种二次编码就是最常见的UTF-8编码。

(2)使用UTF-8编码,大部分字符都在1~3个字节的模板中,而UCS-4编码每个字符都需要4个字节,如此就避免了空间的浪费。

问题4:如何避免CPU浪费?

UTF-8就完美了么?不,从来没有最完美,只有最适合。UTF-8编码中对于英文和数字,都在1个字节的模板中且不需要分割计算。自然是最快,空间占用最小。但对于世界上大多数其他国家的文字,都在2~4字节模板中,如基本汉字UNICODE码点是4E00-9FA5,使用UTF-8编码基本汉字属于三字节模板表示。也就是说由于每个汉字码点都需要重新计算分割后放入模板,这就需要额外使用CPU资源。如果计算机大部分工作都与汉字解析相关,会造成CPU计算资源浪费。

有没有一种编码,既可以解决“断字”,又不会使用额外空间,对于大部分字符还不会额外使用CPU呢?让我们换种思路,回到最初的需求:为了区分2个字节或3个字节的UNICODE码点。可以观察到大部分字符码点都是2个字节,我们实际需要特殊处理的只是3个字节的情况,于是有了UTF-16编码:将UNICODE码点,按照不同范围,放到2或4个字节的模板里。

如:

说明:

(1)对于10000 ~ 10FFFF范围内的UNICODE码点,为什么要先减掉10000,再放入4字节模板?

因为UTF-16的4字节模板的范围从10000 ~ 10FFFF ,前面的10是统一值。只需要对后4位再次编码即可,两种模板本身就是区别。

(2)UTF-16编码写入文件后,读出时,每次读取两个字节,当读取到110110开头的字节时,表示该字符匹配的是4字节模板,问题又来了,如果2字节模板中也有110110开头的情况,怎么办?

由于每次读取两个字符,那么读到以110110开头的两字节(高位),数值范围是11011000 00000000 ~11011011 11111111 即 D800 ~DBFF,读到以110111开头的两字节(低位),数值范围是11011100 00000000 ~ 11011111 11111111 即 DC00 ~ DFFF。结合起来即D800 ~ DFFF,那么将这个范围从2字节模板范围中排除即可。

(3)如果我们写的程序以处理汉字为主,使用UTF-16编码会更适合,因为基本汉字占用2个字节,比UTF-8占用3个字节少,并且2字节模板直接使用UNICODE码点作为编码,不需要额外CPU分割计算。如果我们写的程序以处理数字或英文为主,使用UTF-8编码更适合,因为数字英文在UTF-8编码中只占1个字节,而UTF-16需要占用2个。

综上:

(注:编码类型当然不止这几种,这里不求大和全,只求化为自己的理解,文章中各种名词与其他文章可能不同)

二、传统IO和NIO中编码解码

1、JVM内存中字符编码

在JAVA中的char类型表示字符,在JVM内存中,char类型会以UTF-16的编码的数字存在,且规定char类型占用2个字节。

问题来了,UTF-16的4字节模板在java中如何用char表示?

JAVA编译器告诉你如果一个char不够就两个,可以用字符数组。

//		char c = '𠗃'; //编译报错
		char[] cs = {'\uD841', '\uDDC3'}; //一个char不够,使用字符数组
		System.out.println(String.valueOf(cs)); //控制台输出'𠗃'
		String msg ="𠗃"; //字符串中被编译为"\uD841\uDDC3"

接下来,让我们看下在main函数中将字符串“𠗃”以UTF-8编码写入文件并读出的整个编码解码过程。

2、读写文件时编码解码的三种方式

方式一、使用msg.getBytes("UTF-8")编码,new String(msg, "UTF-8")解码。

//写入
try (BufferedOutputStream bis = new BufferedOutputStream(new FileOutputStream(FILE));) {
    bis.write(msg.getBytes("UTF-8")); //编码
} catch (IOException e) {
    e.printStackTrace();
}

//读出
try (BufferedInputStream bis = new BufferedInputStream(new FileInputStream(FILE));) {
    byte[] bs = new byte[1024];
    while(bis.read(bs) != -1) {
        System.out.println(new String(bs, "UTF-8")); //解码
    }
} catch (IOException e) {
    e.printStackTrace();
}

说明:

这种方式优势在于直接使用字节流,代码简单,缺点在于,无法保证读出字符所需的字节是完整的,如使用UTF-8编码,每个汉字3个字节,一次读出1024个字节对应341个汉字多1个字节。对此需要手动处理:将每次读取的字节暂存在一个新的数组中合并起来,最后一次性解码。注意暂存的内容不能太大。

方式二、使用传统IO类OutputStreamWriter编码,InputStreamReader解码

//写入
try (BufferedWriter br
            = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(FILE), "UTF-8"));) { //编码
    br.write(msg);
} catch (IOException e) {
    e.printStackTrace();
}

//读出
try (BufferedReader br
             = new BufferedReader(new InputStreamReader(new FileInputStream(FILE), "UTF-8"));) { //解码
    String line = null;
    while ((line = br.readLine()) != null) {
        System.out.println(line);
    }
} catch (IOException e) {
    e.printStackTrace();
}

说明:

这种方式使用装饰器模式,添加OutputStreamWriter,InputStreamReader编码解码,代码稍微复杂些,但对于不完整字符的情况,有部分处理逻辑降低乱码几率,至少能给出错误码预警。(后面详细介绍)

OutputStreamWriter构造函数中使用sun.nio.cs.StreamEncoder编码,InputStreamReader构造函数中使用sun.nio.cs.StreamDecoder解码。让我们透过源码理解下JDK保证字符完整性的思路。(觉得复杂的同学可以跳过)

写入流程:

读出流程:

方式三、使用NIO类Charset编码解码

//写入
Charset charset = Charset.forName("UTF-8");
try (FileChannel fc = new FileOutputStream(FILE).getChannel();) {
    ByteBuffer bb = charset.encode(msg); //编码
    fc.write(bb);
} catch (IOException e) {
    e.printStackTrace();
}

//读出
try (FileChannel fc = new FileInputStream(FILE).getChannel();) {
    ByteBuffer bb = ByteBuffer.allocate(1024);
    while(fc.read(bb) != -1) {
        bb.flip();
        String line = charset.decode(bb).toString(); //解码
        System.out.println(line);
        bb.clear();
    }
} catch (IOException e) {
    e.printStackTrace();
}

查看源码,Charset编码解码思路如下:

说明:

(1)使用Charset编码解码和使用OutputStreamWriter编码,InputStreamReader解码思路图黄色部分相同,证明最底层的编码解码逻辑都是相同的,

(2)对于写入文件-编码过程:

使用BufferedWriter写入文件时,先写入字符缓冲(8K字符),等到字符缓冲满了或调用flush才写入文件。当8K字符缓冲已满,并且最后一个字符是不完整字符,如'\uD841'(𠗃的一半),此时flush,会对字符缓冲中所有字符编码,如果遇到不完整字符,会返回并先暂存到StreamEncoder.leftoverChar中,等到下一次再写入缓冲时优先配对编码。但如果此时结束写入,即调用close时,会触发flushLeftoverChar方法,会在文件末尾写入错误代码63,显示问号。

而使用Charset+ByteBuffer写入文件时,虽然名字也有“Buffer”的字样,但不会暂存任何数据,整块写入。如果遇到不完整字符,会在文件末尾替换为错误码63写入,文件中显示问号。

(3)对于读入文件-解码过程:

使用BufferedReader读取文件时,先从字符缓冲(默认8K字符)中读取,如果不够,则从文件中循环读取8K字节,放入字节缓冲,然后解码填入字符缓冲,直到字符缓冲被填满。当从文件中某次读取8K字节的最后一个字节是'11011000'(𠗃的UTF-8编码的第一个字节),即字符不完整字节,则无法解码,依旧保留在字节缓冲中,等待下一次再读取8K字节时优先匹配解码。如果此时结束读入,字节缓冲中的字符不完整字节会丢失。

而使用Charset + ByteBuffer读取文件时,虽然名字也有“Buffer”的字样,但不会暂存任何数据,整块读取、如果遇到字符不完整字节,会被替换为错误码65535输出,控制台中显示乱码。

(4)CharsetDecoder.decode 和 CharsetEncoder.encode 中都有同样输出错误码的代码:

if (action == CodingErrorAction.REPLACE) {
    if (out.remaining() < replacement.length())
        return CoderResult.OVERFLOW;
    out.put(replacement); //输出错误码63或65535
}

三、解答问题

回到文章最开头,本章需要解决的问题:

(2)传统IO中的Reader Writer也能实现编码解码,和NIO中的Charset相比有什么不同?

1、相同之处:使用Charset或OutputStreamWriter,InputStreamReader转换字符时,最底层都使用CharsetEncoder.encode编码,CharsetDecoder.decode解码。

2、不同之处:

BufferWriter,BufferReader读写文件时,由于有暂存,如果碰到"不完整字符"或"字符不完整字节"时,无法编码解码,不会立即输出错误码,而是留下来,等下次写入或者读取时尝试优先匹配。

而Charset + ByteBuffer 读写文件时,由于没有暂存,如果碰到"不完整字符"或"字符不完整字节"时,无法编码解码,会直接输出错误码。

综上,传统IO和NIO在编码解码时,除了Buffer含义不同,其他大致相同,都需要关注"不完整字符"或"字符不完整字节"情况,注意错误码63和65535的处理。

 

未完待续!

  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值