彻底理解Java IO
Java中的IO类库设计的比较繁琐,IO这一块知识又是基础必备的,而且工作学习中经常用到。这一块知识看起来不难,但是想深入全面掌握也还是要花点功夫的。不是有句玩笑话说吗,“欠下的技术债总要还的”,刚好最近我准备总结一波Okhttp,Okio,就先把Java IO 这一块知识先做个总结,算是给后面2篇总结打个铺垫吧。(JDK源码基于1.8.0)
流
谈到IO,我们会想到从磁盘读取的文件IO,网络请求的Socket IO,还有可能我们不怎么常用的跨进程通信的管道IO......
这些在Java中都被抽象为“流”,读取源就是输入流(InputStream),输出目标就是输出流(OutputStream)。
字节与字符
要理解IO,首先要清楚我们IO操作的对象,主要有字节和字符二种。
二进制文件中存储的数据都是二进制形式,一个字节是8bit,Java中对应的类型是byte,比如数字255存储到二进制文件中的值就是0xFF(11111111)。
文本文件中存储的数据都是字符形式,具体一个字符占多大空间取决于使用的编码格式,比如我们重用的UTF-8编码,一个英文字母占1个字节,一个中文汉字占3个字节,Java中对应的类型是char,数字255存储到文本文件中就是三个字符序列'2','5','5'。
Java IO大概可以分为二大类的IO,字节IO和字符IO,它们有各自的一套继承链,字节IO的基类实现是InputStream和OutputStream,字符IO的基类实现是Reader和Writer, 他们都是抽象类,我们开看一下JDK中的io继承体系图(图片来源网络)。
jdk_io 继承体系图(来源网络).jpeg
字节输入输出流
由于Java中IO的类比较多,篇幅关系,只会拎出来一些我觉得值得记下来的知识点记录下来,有一些不常用的类只需要了解个大概,知道与其他类的差异即可,使用细节可以再参考资料来指导使用。
InputStream
作为字节输入流的抽象基类,主要定义了二个read方法,
// 读取下一个byte,它的取值范围是(0-255),如果返回-1,说明已经到结尾了
public abstract int read() throws IOException;
// 读取字节到目标byte数组b中,
public int read(byte b[]) throws IOException{
return read(b, 0, b.length);
}
通过这个方法的定义,可以看出,InputStream类中read方法的默认实现中,每次只能读取一个字节,这个知识点在看源码之前我是不清楚了(窃以为这样效率是不是有点低了)。读取byte数组的方法的实现也是基于read方法的,我们看一下具体实现。
public int read(byte b[], int off, int len) throws IOException {
// 做状态检查
if (b == null) {
throw new NullPointerException();
} else if (off < 0 || len < 0 || len > b.length - off) {
throw new IndexOutOfBoundsException();
} else if (len == 0) {
return 0;
}
// 先尝试读取一个字节, 注意这里有可能抛出IOException
int c = read();
if (c == -1) {
return -1;
}
b[off] = (byte)c;
int i = 1;
try {
// 循环读取字节,填充到目标字节数组b, 这里对异常进行了catch
for (; i < len ; i++) {
c = read();
if (c == -1) {
break;
}
b[off + i] = (byte)c;
}
} catch (IOException ee) {
}
return i;
}
原以为一次读取填充一个byte数组效率应该能高点,然而并没有,看源码知道,它的实现是通过循环调用read一个一个字节的读取来实现的。这里有一个比较有意思的地方,读取字节数组时,会先尝试读取第一个字节,如果失败或者异常了读取就终止了,如果成功了再循环读取后面的字节,之后如果出现异常不会抛出,而会将前面已经成功读取的字节数返回。
FilterInputStream
FilterInputStream内部持有另一个InputStream的引用,这是一个典型的装饰者模式应用, 对传入的InputStream进行增强。这个类的实现比较简单。
protected volatile InputStream in;
protected FilterInputStream(InputStream in) {
this.in = in;
}
public int read() throws IOException {
return in.read();
}
BufferedInputStream
BufferedInputStream继承于FilterInputStream,所以它也能装饰一个InputStream,它增强的功能是增加一个缓冲buffer,读取的时候先将字节读入buffer中,buffer的默认大小是8192,这样通过空间换时间的方式提高读取效率,特别是在 大文件的读写时提升很明显,后面会给出一个小实验做下对比。
BufferedInputStream的核心方法是fill方法,将字节读入buffer缓冲数组中,看下代码实现。
private void fill() throws IOException {
// 获取buffer数组
byte[] buffer = getBufIfOpen();
// markpos 表示当前有没有设置pos标记,可以通过reset来还原到标记的位置开始读
if (markpos < 0)
pos = 0; /* no mark: throw away the buffer */
else if (pos >= buffer.length) /* no room left in buffer */
// 如果buffer空间不足,会想办法给buffer腾出空间
if (markpos > 0) { /* can throw away early part of the buffer */
// 如果有设置过markpos,那么markpos之前的空间是可以腾出来的
int sz = pos - markpos;
// 把markpos到pos这一段的buffer前移markpos个位置
System.arraycopy(buffer, markpos, buffer, 0, sz);
pos = sz;
markpos = 0;
} else if (buffer.length >= marklimit) {
// 如果buffer的大小超过marklimit,将标记还原
markpos = -1; /* buffer got too big, invalidate mark */
pos = 0; /* drop buffer contents */
} else if (buffer.length >= MAX_BUFFER_SIZE) {
throw new OutOfMemoryError("Required array size too large");
} else { /* grow buffer */
// 对buffer 进行扩容 扩容后的大小是当前pos * 2
int nsz = (pos <= MAX_BUFFER_SIZE - pos) ?
pos * 2 : MAX_BUFFER_SIZE;
if (nsz > marklimit)
nsz = marklimit;
byte nbuf[] = new byte[nsz];
System.arraycopy(buffer, 0, nbuf, 0, pos);
// CAS来更新buffer,
if (!bufUpdater.compareAndSet(this, buffer, nbuf)) {
// Can't replace buf if there was an async close.
// Note: This would need to be changed if fill()
// is ever made accessible to multiple threads.
// But for now, the only way CAS can fail is via close.
// assert buf == null;
throw new IOException("Stream closed");
}
buffer = nbuf;
}
count = pos;
// 从装饰的inputstream中读满整个buffer
int n = getInIfOpen().read(buffer, pos, buffer.length - pos);
if (n > 0)
count = n + pos;
}
再来看下read方法的实现, 注意BufferedInputStream中的read都加锁了,所以是线程安全的。
public synchronized int read() throws IOException {
if (pos >= count) {
// 如果读取的位置查过buffer的位置,拉取字节到buffer
fill();
if (pos >= count)
return -1;
}
// 直接从buffer数组中读取byte,这里还与Oxff进行了一次与,暂时没看来有何意义。
return getBufIfOpen()[pos++] & 0xff;
}
再看读取字节数组的实现
private int read1(byte[] b, int off, int len) throws IOException {
int avail = count - pos;
// buffer已经读到尾部了
if (avail <= 0) {
/* If the requested length is at least as large as the buffer, and
if there is no mark/reset activity, do not bother to copy the
bytes into the local buffer. In this way buffered streams will
cascade harmlessly. */
// 如果需要读取的长度大于buffer的长度,直接降级为从源inputstream中读取,避免buffer急剧增大
if (len >= getBufIfOpen().length && markpos < 0) {
return getInIfOpen().read(b, off, len);
}
// 填充buffer
fill();
avail = count - pos;
// 如果可用字节还是0,说明已经到尾部了,返回-1
if (avail <= 0) return -1;
}
// 取当前可用的字节 与 期望读取的字节数的 较小值
int cnt = (avail < len) ? avail : len;
// 拷贝可用的字节数到目标byte数组,所以我们在使用的时候出入固定的len数也没有问题,
// 只会将可用的byte写入数组。
System.arraycopy(getBufIfOpen(), pos, b, off, cnt);
pos += cnt;
return cnt;
}
BufferedInputStream的默认缓冲区大小是8192个字节,Jdk选择这样一个默认大小实现肯定是有原因的,所以我们在项目中考虑是否需要使用BufferInputStream来装饰时需要注意二点。
- 目标输入流字节数是否比较大,如果比较大考虑使用Buffered。
- 我们在调用read方法传入的byte[]的大小最好能被8192整除,比如我们经常使用的1024或者2048,这样刚好8次和4次刚好将缓冲区buffer清空,触发下一次fill,提高读取效率。
FileInputStream
FileInputStream 继承于 InputStream,它会在构造器中通过文件的path打开一个文件, 最终会调用open0 这个 native方法。
public FileInputStream(File file) throws FileNotFoundException {
String name = (file != null ? file.getPath() : null);
SecurityManager security = System.getSecurityManager();
if (security != null) {
security.checkRead(name);
}
if (name == null) {
throw new NullPointerException();
}
if (file.isInvalid()) {
throw new FileNotFoundException("Invalid file path");
}
fd = new FileDescriptor();
fd.attach(this);
path = name;
open(name);
}
private void open(String name) throws FileNotFoundException {
open0(name);
}
private native void open0(String name) throws FileNotFoundException;
FileInputStream用native重写了read方法
private native int read0() throws IOException;
private native int readBytes(byte b[], int off, int len) throws IOException;
由于FileInputStream底层支持一次读取多个字节,所以FileInputStream非常适合用上面的BufferedInputStream来装饰使用。
FileInputStream还有一个getChannel方法,这属于nio的知识,通过nio来进行文件拷贝简单高效,示例代码如下,文章结尾会给出一个文件拷贝的小测试,对比几种拷贝方式的耗时。
FileChannel fileChannelA = new FileInputStream(new File( "tempA.txt"))
.getChannel();
FileChannel fileChannelB = new FileInputStream(new File( "tempB.txt"))
.getChannel();
fileChannelA.transferTo(0, fileChannelA.size(), fileChannelB);
DataInputStream
DataInputStream也是继承于FilterInputStream,它主要装饰增强的功能是,帮我们封装了很有有用方法,比如
public final byte readByte() throws IOException {....}
public final int readInt() throws IOException {...}
public final double readDouble() throws IOException {...}
public final String readUTF() throws IOException {...}
//.... 更多的方法可以自行查看源码
这里我们看下readInt的实现,其他的方法实现大同小异,大家可以自行查阅源码理解。
public final int readInt() throws IOException {
int ch1 = in.read();
int ch2 = in.read();
int ch3 = in.read();
int ch4 = in.read();
if ((ch1 | ch2 | ch3 | ch4) < 0)
throw new EOFException();
return ((ch1 << 24) + (ch2 << 16) + (ch3 << 8) + (ch4 << 0));
}
我们知道int是4个字节,而我们每次读取是一个字节,所以我们需要读取4次,然后通过位运算将结果拼装起来(这里又使用了移位运算)。
关于字节输入流InputStream就说这些,水平有限,更详细深入的要各位自行深究了。
字节输出流 OutputStream
OutputStream 主要定义的方法就是 write(int b), write(byte b[])。有了前面InputStream的知识,很容易类比理解,一个写一个读。
与InputStream的继承结构基本是一一对应的,输出流也有很多子类实现,比如FilterOutputStream, BufferedOutputStream, FileOutputStream, DataOutputStream 等,由于实现跟输入流类似,这里就不赘述了,大家可以自行查阅源码来理解。
字符输入输出流
字节输入输出流操作的对象是byte,那么字符输入输出流操作的对象自然是char,字符输入流的基类是Reader。
Reader
看一下内部读取方法的实现
public int read() throws IOException {
char cb[] = new char[1];
if (read(cb, 0, 1) == -1)
return -1;
else
return cb[0];
}
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;
可以看到方法读取的返回是char或者char数组。Reader只是一个基础的基类,它同样有BufferedReader来增加缓冲区提高读取效率,还有LineNumberReader来提供按行读取的封装。
我们重点看下InputStreamReader,它能够将InputStream字节输入流转化为字符输入流
InputStreamReader
public InputStreamReader(InputStream in) {
super(in);
try {
sd = StreamDecoder.forInputStreamReader(in, this, (String)null); // ## check lock object
} catch (UnsupportedEncodingException e) {
// The default encoding should always be available
throw new Error(e);
}
}
它的转换能力是通过Nio里面的StreamDecoder对象来获得的。
public int read() throws IOException {
return sd.read();
}
public int read(char cbuf[], int offset, int length) throws IOException {
return sd.read(cbuf, offset, length);
}
可以看到读取操作完全是通过sd来完成的。关于StreamDecoder的具体实现,以后有机会再深入吧。
写在最后
其实IO 还有很多我没有说到的知识点,我们常用的FileReader类也是继承于InputStreamReader。JDK中还有很多的IO类,还有字符输出流,基类是Writer,也有跟Reader类似的继承链,
Java IO 的实现其实很是很难,但是很繁琐,初看起来眼花缭乱,但其实我们也不需要死记硬背去把每个类的实现都记下来,我们只需要心中有个全局把握,掌握关于Java IO 的蓝图,使用的时候也会更有自信。
下一篇是关于Okio的理解,可以跟Java IO的实现做个对比,看大神们如何另辟蹊径,简化IO操作的。
附录
4种copy文件的方式对比测试:
public static void main(String[] args) throws IOException {
//parpare
long time = System.currentTimeMillis();
copyWithFile();
Log.d("FileInputStream take time:" + (System.currentTimeMillis() - time));
time = System.currentTimeMillis();
copyWithBuffer();
Log.d("BufferedInputStream take time:" + (System.currentTimeMillis() - time));
time = System.currentTimeMillis();
copyWithNIO();
Log.d("nio take time:" + (System.currentTimeMillis() - time));
time = System.currentTimeMillis();
copyWithNioDirect();
Log.d("nio direct take time:" + (System.currentTimeMillis() - time));
}
public static void copyWithFile() throws IOException {
FileInputStream fileInputStream = new FileInputStream(new File(BASE_PATH + "WPS2019.dmg"));
FileOutputStream fileOutputStream = new FileOutputStream(
new File(BASE_PATH + "wps.copy1")
);
byte[] bytes = new byte[1024];
int read = 0;
while ((read = fileInputStream.read(bytes)) != -1) {
fileOutputStream.write(read);
}
fileOutputStream.flush();
fileInputStream.close();
fileOutputStream.close();
}
public static void copyWithBuffer() throws IOException{
BufferedInputStream fileInputStream = new BufferedInputStream(new FileInputStream(new File(BASE_PATH + "WPS2019.dmg")));
BufferedOutputStream fileOutputStream = new BufferedOutputStream(new FileOutputStream(
new File(BASE_PATH + "wps.copy2")
));
byte[] bytes = new byte[1024];
int read = 0;
while ((read = fileInputStream.read(bytes)) != -1) {
fileOutputStream.write(read);
}
fileOutputStream.flush();
fileInputStream.close();
fileOutputStream.close();
}
public static void copyWithNIO() throws IOException{
FileChannel fileChannel = new FileInputStream(new File(BASE_PATH + "WPS2019.dmg"))
.getChannel();
ByteBuffer buffer = ByteBuffer.allocate(1024);
FileChannel fileChannel2 = new FileOutputStream(
new File(BASE_PATH + "wps.copy3")
).getChannel();
while ((fileChannel.read(buffer)) != -1) {
buffer.flip();
fileChannel2.write(buffer);
buffer.clear();
}
}
public static void copyWithNioDirect() throws IOException{
FileChannel fileChannel = new FileInputStream(new File(BASE_PATH + "WPS2019.dmg"))
.getChannel();
FileChannel fileChannel2 = new FileOutputStream(
new File(BASE_PATH + "wps.copy4")
).getChannel();
fileChannel.transferTo(0, fileChannel.size(), fileChannel2);
}
wps文件大小是190M,在我自己电脑上面的测试结果:
FileInputStream take time:1369
BufferedInputStream take time:114
nio take time:1745
nio direct take time:214
可以看到BufferedInputStream 速度最快,nio direct方式次之,另外二种速度比较慢。