1.概述

Java传统IO是指java.io包中,以InputStream、OutputStream及其子类为代表的字节流,和Reader、Writer及其子类为代表的字符流,外加其他辅助类(文件类、异常类等)为基础,通过对操作系统函数的封装,形成的一套对IO设备的读写功能。

java.io包是Java提供最早的IO处理类,无论是字符流还是字节流,均通过同步阻塞的方式读写数据,也就是我们经常说的BIO,因此可以将java.io包视为传统IO。

2.流的基础知识

2.1 流对象到底是什么?

简单说Java中的流对象就是一个传送带,传送带的一端连接操作系统内核,另一端连接我们的Java业务代码。Java项目作为运行在操作系统上的应用程序,是无法直接访问IO设备的,当我们想要从磁盘等地方读写数据时,只能通过调用内核函数来间接操作,流的作用就是封装内核调用等底层复杂操作,帮助开发者实现数据在内核和业务代码之间传送。

以文件读写为例,当我们想要从本地磁盘读取数据时,需要创建一个传送带(FileInputStream)并指定文件的路径,二进制数字就可以从内核不断的流向Java应用程序:
图片

2.2 流的方向

流按照方向可分为输入流输出流,这种划分是站在内存角度去定义的。数据从IO设备流向内存的过程就是流的输入过程,例如InputStream、Reader的子类,用于应用程序从设备读取数据;数据从内存流向IO设备的过程就是流的输出过程,例如OutputStream、Writer的子类,用于应用程序向设备输出数据。

由此可以看出传统IO类的读写是单向的,输入流只能读取数据,输出流只能写入数据,读取和写入需要创建不同的流对象去完成:
图片

2.3 流的单位

流按照单位可分为字节流字符流,字节流就是所有的IO操作都基于字节,字节流就是所有的IO操作都基于字符。对于字节流来说很好理解,因为所有的IO设备都只认二进制值,字节就是由8个二进制值组成的。而字符与二进制值不存在直接的关系,因此Java中每个字节流在创建的时候必须指定一个编码集,用于维护二进制值与字符的关系。

如果你读写的内容完全来自于ASCll表(美国信息互换标准代码)包含的符号组成,那么字节流就可以完成读写功能,因为ASCll表中的符号可以用0到127来表示,单个字节就可以表示一个符号,每读取一个字节都可以按照ASCll表转化为符号,每写入一个字节先通过ASCll表转化为字节再写入。

如果你读写的内容包含汉字特殊符号(♬、✿、★、♨等),单个字节是无法进行表示的,必须借助编码集实现。每个编码集都有一个映射表,用于记录哪几个字节值代表哪个汉字或特殊符号,例如UTF-8编码集中,汉字‘云’对应的编码为‘云’,编码的每个符号在ASCll表都存在,那么汉字就可以借助编码集顺理成章的转化为多个字节,因此你可以理解为字符流是对字节流的封装。

2.4 流的处理类型

流按照是否与IO设备直接连接,可分为节点流处理流。节点流就是直接指定一个地点(磁盘、内存条等)进行读写,处理流并不直连IO设备,而是采用装饰器模式对节点流进行包装,在对外提供的方法不变的前提下,对原有功能进行增强。

以FileInputStream和BufferedImputStream为例,FileInputStream属于节点流,因为它指定文件路径后就可以直接读取,而BufferedImputStream是对它的装饰包装,当调用BufferedImputStream的read方法读取一个字节时,内部调用FileInputStream的read方法读取8192个字节将缓冲区填满,后续再读字节时就可以直接从缓冲区返回,直到内存读完。这样就减少了和操作系统的IO次数,实现对read方法的增强。

2.5 随机/顺序读写

暂无

3.字节流

3.1 InputStream

InputStream是所有字节输入流的顶层类,定义了从IO设备读取字节到内存的一些基本、抽象方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
public abstract class InputStream implements Closeable {

// 用于确定要使用的最大缓冲区大小,调用skip方法时使用
private static final int MAX_SKIP_BUFFER_SIZE = 2048;

// 读取方法,需要子类重写
public abstract int read() throws IOException;

// 读取参数数组大小的字节,并将读取结果填充到参数中
public int read(byte b[]) throws IOException {
return read(b, 0, b.length);
}

// 读取指定长度的字节,并将读取结果填充到参数数组中,从off坐标开始,填充len位
// 由于方法是循环读取,每次都需要与内核交互一次,因此效率很低,子类通常会重写
public int read(byte b[], int off, int len) throws IOException {
// 省略...
}

// 跳过n位字节,最多不能超过MAX_SKIP_BUFFER_SIZE
public long skip(long n) throws IOException {
// 省略...
}

// 返回读取的目标,一共有多少字节,需要子类重写
public int available() throws IOException {
return 0;
}

// 关闭流
public void close() throws IOException {}

// 标记当前读取的位置,方法为空需要子类重写
public synchronized void mark(int readlimit) {}

// 将读取的坐标重置到mark的位置,方法默认抛异常需要子类重写
public synchronized void reset() throws IOException {
throw new IOException("mark/reset not supported");
}

// 是否支持mark,默认不支持,子类根据需要重写
public boolean markSupported() {
return false;
}


① read()方法明明是读取一个字节,为什么返回值是int类型呢?
首先,Java返回int类型是因为操作系统(例如Linux)提供的read函数返回的就是int类型。假设Linux系统的read()函数返回的是字节,那么返回的值范围就是[-128,127],这样Java在调用时就无法判断流是否已读到末尾,因为-128到127的任何一个值都可能是读到的字节值,没有多余的值去告诉Java已经读完了,因此Linux的read()函数返回字节无法实现单字节读取。

至于Linux为什么不用null来表示已读完,可能是因为POXIS规范吧。Linux采用高位补零的方式将读取的byte值转化为int并返回,因此无论读取的字节值是多少,再高位补零后永远不可能是负数,当字节已经读到末尾时再读取则返回-1。在Java代码中拿到int值需要高位抹零(int转byte)得到真正的字节值:
图片


② 字节流如何重复读取数据?
mark、reset、markSupported三个方法都是为了字节流能重复读取而设计的,默认是不支持的,并且在InputStream层面并没有规定markSupported返回false对另外俩个方法有什么影响,子类在重写这三个方法来实现重复读取时,可以随意设计。具体的重写思路:
图片


③ 流为什么要关闭?
以Linux系统为例,大多数流对象的IO操作都是基于文件描述符进行的,因此流对象的创建通常要在Linux系统中通过open函数申请一个fd。Linxu系统对单个进程或整个系统所能打开的fd数量有限制,超过限制数会open失败,即使调高上限值,如果流使用完毕不关闭fd也会造成内存浪费,因为每个fd在内核中都会单独分配一个打开文件句柄。

3.2 OutputStream

OutputStream是所有字节输出流的顶层类,定义了向IO设备写入字节的一些基本、抽象方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public abstract class OutputStream implements Closeable, Flushable {

// 写入单个字节,参数是int类型,估计是想和read对应上吧
public abstract void write(int b) throws IOException;

// 写入多个字节
public void write(byte b[]) throws IOException {
write(b, 0, b.length);
}

// 写入多个字节,指定范围,内部还是for循环一个个写
public void write(byte b[], int off, int len) throws IOException {
// 省略...
}

// 刷新
public void flush() throws IOException {
}

// 关闭流
public void close() throws IOException {
}

输出流相比较输入流要简单很多,flush()方法的作用是刷新输出流,或者说清空输出流,这个清空不是直接删掉,而是把它挪到该去的地方。很多带缓冲概念的子类会重写这个方法,将缓存的数据手动刷回对应的IO设备中,例如常用的BufferedOutputStream。不存在缓存概念的例如FileOutputStream、ByteArrayOutputStream等就不会重写,因为没地方可以刷。

3.3 汉字处理

我们经常说读取中文内容需要用字符流,使用字节流会出现乱码。首先,只要包涵中文的内容,无论读还是写都必定会涉及到字符集,其次字节流并不是每次读取包含汉字的内容,都会出现乱码,要分情况看待。

例如使用文件字节流FileInputStream完成对中文内容进行读取,需要一次性读取整个文件的所有字节,并将结果存入某个字节数组中,最后手动指定字符集转化为String:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class Main {

public static void main(String[] args) throws Exception{

// 创建输入流
FileInputStream fileInputStream = new FileInputStream("/Users/lvtao/Downloads/test");

// 一次性读取数据,放入数组
byte[] bytes = new byte[fileInputStream.available()];
fileInputStream.read(bytes);

// 指定字符集并转String
String result = new String(bytes, "UTF-8");
System.out.println(result);

// 打印读取结果
fileInputStream.close();
}
}


如果文件的数据量较小,单次读完没啥问题,但很多场景不得不分段读取,比如文件太大需要读取一批处理一批,这时候需要按上面的方式得到多段String。以UTF-8编码集为例,一个汉字占用3个字节,如果在分批读取时,某个汉字对应的3个字节被分割在俩段分别读取,这时候就会出现乱码:
图片

输出流执行写入操作仍然避免不了字符集,只不过String的getBytes方法使用操作系统默认字符集而已。总的来说字节流没有字符集的概念,也不关心读写的内容到底有没有汉字、特殊符号,所有操作只认字节。因此面对包含汉字的内容,要么手动处理字节和汉字的转化(可能乱码),要么使用字符流解决。

4.字符流

字符流与字节流的不同之处在于,字符流每次读取至少是一个字符,这个字符可能是数字、字母、特殊符号、汉字等,但绝对不会出现字节流那种,把一个汉字或特殊符号对应的多个字节拆成多分的情况,因此字符流没有乱码这一说(前提读和写使用的是同一种编码集)。

4.1 Reader

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
public abstract class Reader implements Readable, Closeable {

// 锁对象,用于保证部分操作的线程安全
protected Object lock;

// 空构造器,将自身作为锁
protected Reader() {
this.lock = this;
}

// 指定一个对象作为锁对象
protected Reader(Object lock) {
if (lock == null) {
throw new NullPointerException();
}
this.lock = lock;
}

// 读取多个字符到CharBuffer中,内部调用抽象read方法
public int read(java.nio.CharBuffer target) throws IOException {
// 省略...
}

// 读取单个字符,内部调用抽象read方法
public int read() throws IOException {
// 省略...
}

// 读取多个字符,内部调用抽象read方法
public int read(char cbuf[]) throws IOException {
return read(cbuf, 0, cbuf.length);
}

// 读取多个字节,需要子类重写
abstract public int read(char cbuf[], int off, int len) throws IOException;

// 最多跳过的缓冲数量
private static final int maxSkipBufferSize = 8192;

// 跳过数组
private char skipBuffer[] = null;

public long skip(long n) throws IOException {
// 省略...
}

public boolean ready() throws IOException {
return false;
}

// 重复读取那一套
public boolean markSupported() {
return false;
}

// 重复读取那一套
public void mark(int readAheadLimit) throws IOException {
throw new IOException("mark() not supported");
}

// 重复读取那一套
public void reset() throws IOException {
throw new IOException("reset() not supported");
}

// 关闭流
abstract public void close() throws IOException;
}

4.2 Writer

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
public abstract class Writer implements Appendable, Closeable, Flushable {

private char[] writeBuffer;

private static final int WRITE_BUFFER_SIZE = 1024;

protected Object lock;protected Writer() {
this.lock = this;
}

protected Writer(Object lock) {
if (lock == null) {
throw new NullPointerException();
}
this.lock = lock;
}

public void write(int c) throws IOException {
synchronized (lock) {
if (writeBuffer == null){
writeBuffer = new char[WRITE_BUFFER_SIZE];
}
writeBuffer[0] = (char) c;
write(writeBuffer, 0, 1);
}
}

public void write(char cbuf[]) throws IOException {
write(cbuf, 0, cbuf.length);
}

abstract public void write(char cbuf[], int off, int len) throws IOException;

public void write(String str) throws IOException {
write(str, 0, str.length());
}

public void write(String str, int off, int len) throws IOException {
synchronized (lock) {
char cbuf[];
if (len <= WRITE_BUFFER_SIZE) {
if (writeBuffer == null) {
writeBuffer = new char[WRITE_BUFFER_SIZE];
}
cbuf = writeBuffer;
} else {
cbuf = new char[len];
}
str.getChars(off, (off + len), cbuf, 0);
write(cbuf, 0, len);
}
}

public Writer append(CharSequence csq) throws IOException {
if (csq == null)
write("null");
else
write(csq.toString());
return this;
}

public Writer append(CharSequence csq, int start, int end) throws IOException {
CharSequence cs = (csq == null ? "null" : csq);
write(cs.subSequence(start, end).toString());
return this;
}

public Writer append(char c) throws IOException {
write(c);
return this;
}

abstract public void flush() throws IOException;

abstract public void close() throws IOException;
}

4.3 汉字处理

常用的字符流中,除了CharArrayReader、CharArrayWriter这俩实现类,完全就是在内存中读写字符,根本不会涉及和字节的转换,其他实现类的最底层都是对StreamEncoder、StreamDecoder类的装饰。

这俩个类内部又是对InputStream、OutputStream的装饰,因此是最接近字节流的字符流。每次调用InputStream的read方法后,会将字节解码成汉字再返回。每次写入包含汉字的内容时,会编码成字节在调用OutputStream的write方法。

StreamEncoder与StreamDecoder的底层通过Charset对象,完成对汉字、特殊符号的读写。我们可以写个Demo看看Charset是怎么处理汉字的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public static void main(String[] args) throws Exception{

// 1.创建一个Charset抽象类的UTF-8字符集实现类
Charset charset = Charset.forName("UTF-8");

// 2.1创建UTF-8字符集的编码对象
CharsetEncoder encoder = charset.newEncoder();

// 2.2要写入的内容
String text = "1949年10月1日,中华人民共和国成立";
CharBuffer charBuffer = CharBuffer.wrap(text.toCharArray());

// 2.3将包含汉字的字符串,编码成字节,供后续写入
ByteBuffer encodeByteBuffer = encoder.encode(charBuffer);

// 3.1创建UTF-8字符集的解码对象
CharsetDecoder decoder = charset.newDecoder();

// 3.2读取字节内容并转化为字符
CharBuffer decoderCharBuffer = decoder.decode(encodeByteBuffer);

// 3.3打印读取结果
System.out.println(decoderCharBuffer.toString());
}

Charset对象本质上就是对操作系统的字符集功能进行封装,Java中创建一个字符流对象需要指定一个字符集,并且会调用操作系统函数,检查字符集名称存不存在,如果没有指定,会使用当前操作系统的默认字符集。

评论