白话Java I/O模型
I/O的很多操作和使用,其实并不是一个非常直观的概念,特别是打开文件、创建buffer。这对于终端用户来讲是个非常奇葩和奇怪的过程。我只是想要从一个文件里读取内容,从过程上来讲,我只需要知道:
- 读取的source文件
- 写入的目的地
那我干嘛要去关心神马打开文件、创建stream和buffer?!
所以,要理解I/O这一套东西以及它所涉及的stream、buffer,你必须先理解计算机的底层是如何工作的。如果没有这一步的底层基础理论做支撑,所有的I/O操作将无法变得直观。
为理解I/O所需要用到的底层知识并不算多,就几点:
- 计算机的对数据的操作一定要经过内存。无论你是计算出来的数据、从硬盘中的文件读取的数据、从网络中读取到的数据,都必须要经过内存。source data先到内存,然后内存再到destination。
- 既然涉及到内存,就存在一个“有限”的问题。所有的程序都是往内存跑,无疑这一个非常宝贵的资源。那么你的“传输数据”任务,并没有那么高的优先级可以任意地去占有这个资源。你有且只能获取一部分,特别地,还应该是相对较小的一部分区域,来供你做数据传输。
- 所有在计算机中的数据,无论是文字、图片、声音都是0、1这样的bits。所以,最为通用的传输数据方式必然是面向字节的,也就是围绕字节这个概念来做的。
面对以上这些现实,你不得不在programming时考虑上述问题。因为你不是终端用户只需要一个简单的接口。你是细节的操作者,必须对以上限制做出具体的可操作性的回应。
- 因为你只能使用一小部分的内存空间做数据转移,所以这就必然需要一个buffer的概念去指代内存这部分的小空间。所以你要创建buffer并为它分配大小,然后所有的数据转移都通过这块小小的中转站。(这就像是一个城市的快递中心,所有全国各地发往这个地区的快件,都必须通过这个中转站来做统一调配。)这是对计算机的体系架构——所有数据都必须通过内存——所作出的回应。
- 因为所有的数据从最底层讲是字节(bit),那么就可以使用字节流这个概念去指代数据动态转移这个过程。而数据的转移,就是把一堆字节流从source运往destination。但由于上面的原因,这个过程无法直接完成,所以你必须把字节流从:
source --> buffer --> destination
,或者destination --> buffer --> source
。 - 由于传输的都是字节流,所以你需要一个工具把这个stream给开垦出来,所以你需要有一个File式的对象,从上面可以取得一个Channel或者Stream,也即是把file转换为字节流的池子,以便直接把文件的字节流拿给buffer。它们就像是data的矿源,通过buffer这辆采矿的小车,不断地把矿石(data)从矿源(source)运到外面(destination)。
有了这部分的知识,我们再来看“Java中的NIO是如何读取文件的”就不会变得怪异了。
RandomAccessFile aFile = new RandomAccessFile("test.txt", "rw");
FileChannel inChannel = aFile.getChannel();
//create buffer with capacity of 48 bytes
ByteBuffer buf = ByteBuffer.allocate(48);
int bytesRead = inChannel.read(buf); //read into buffer.
while (bytesRead != -1) {
buf.flip(); //make buffer ready for read
while(buf.hasRemaining()){
System.out.print((char) buf.get()); // read 1 byte at a time
}
buf.clear(); //make buffer ready for writing
bytesRead = inChannel.read(buf);
}
aFile.close();
让我们将之前讨论的过程复现一遍:
- 首先创建
RandomAccessFile
对象,来提供真实文件test.txt
在Java中对应的对象(可以理解为一个bean)。这个对象将提供各种服务来配合Java内部各种机制的操作。无疑,提供一个Channel
是它的本质工作之一。 - 创建内存中的中转区域
buffer
,然后将上面的文件Channel的字节流直接接入到这个buffer。 - 然后再从buffer把字节流输出到
System.out.print
对应的std io
。
接下来可以深入更多的细节。
由于buffer
是被重复利用的部分,所以这涉及到清理buffer的概念buf.clear()
。那你可能会说,为什么不可以自动地清理buffer?因为这里的buffer是一个底层的基础服务。对于上层的应用来讲,有些场景是需要清理buffer,有些场景是需要复用buffer的内容。你怎么可以一概而论地认为所有应用场景都是只需要消费一次buffer中的数据呢?所以,作为底层设施来讲,你必须提供足够的灵活性,让developer自己决定是否需要清理buffer。
再来比较奇怪的是flip()
这部分,为什么需要对buffer做flip操作呢?这就涉及到内存中buffer的管理问题。
这里的管理方式其实很常见,在内存中,基本上都是以数组的形式提供堆栈结构来管理数据。那么,这就涉及到对这个数组的操作问题。你要有一个表征position的指针来指导数据的写入方向。
- 对“写数据”这个过程来讲,数据的position指针是从起始点,index为0的点,逐步增大index来写数据的。只要一直在做“写”操作,这个指针就会在buffer中不断地往index增大的方向移动。
- 显然,当你需要读数据时,你是需要从这个buffer的起始点再开始。所以,你需要一个操作把position指针复位到起始位置,然后从这个地方开始不断地往下读。(一个值得思考的问题是:为什么不引入两个position指针,一个用来读,一个用来写。这样不是就不用把“读”的position指针复位了吗?)
如果多走一步,为了验证flip()
是否真的在让position指针复原,你还可以使用以下代码:
System.out.println("Read " + bytesRead);
// switch the buffer from writing mode into reading mode
buf.flip();
while (buf.hasRemaining()) {
System.out.println((char) buf.get());
}
System.out.println("---------------------------");
// reset the pointer back to original point again
buf.flip();
while (buf.hasRemaining()) {
System.out.println((char) buf.get());
}
可以看到,从buffer中又一次获取到了同样的信息。
从这个例子可以看到很多深层次的东西。例如,为什么你必须了解底层的内存运作机制、操作系统的运转机制?因为这些细节决定了你该以什么样的方式去设计你的编程模式,也决定了你应该如何去理解编程语言中提供的一些机制,或者为什么一个库应该会这样设计。这是一切具体行动的现实。
“具备什么功能”是这底层基础设施提供的封装好的API。但你要做的是programming的工作,不得不利用底层的基础设施去构建新的服务和产品。如果没办法理解这些底层机制,你就没办法真正地去构建东西。很多的问题,其实可以被绕过又或是不可能被实现,不在于逻辑有问题,而是单纯的信息差,你并不知道这个构建出的抽闲概念下面隐藏的真实东西。
如果能够理解内存的利用方式,那么,“分片、以stack的形式来做操作”的模式将成为你本能的一部分。进而,涉及到的position移动或者flip()
的指针回调问题,就会成为你的直观。
当然,积累这部分的基础知识是非常枯燥和乏味的。但如同所有的基本功,它们不会在短期内为你提供足够的回报,但却会为你将来形成正确的“直觉”和“直观”做出巨大的贡献。
任何的抽象概念都具备直观,只不过这个直观所依赖的基础不同。抽象如“概率论基础”,其“形象”的直观,其实是数学系本科所学的“经典概率论”,否则你会迷失在“测度论”的细节里。但这个“直观”对于其它专业的人来讲,并不直观,甚至是异常复杂。而这个就是所谓的牢固的前提基础知识。进一步,如果你想要学好“概率论基础”这样高度抽象的topic,你必须先夯实“经典概率论”这个基础,必须先建立对它的深刻认知。否则,你对“概率论基础”的理解根本无从谈起。
同样的,如果你希望理解类似于编程语言中I/O库的设计、理解各类缓存中间件、消息队列中间件的设计,你必须要先建立“计算机如何运作”这个前提基础。只有你熟悉了计算机的运作方式,能够以计算机底层的习惯去思考问题、处理问题,你才能够看到各种组件设计的直观,才会看到各种莫名其妙的“绕路”到底是在解决什么、是为了什么。
所以,这样一个学习过程是任何抽象技能所避免不了的。你必须通过反复的阅读和练习来掌握第一层的基础概念,熟悉到让这一层的抽象变成你脑海中的一个条件反射式的直观。再这个新建立的直观本能基础上,你才可以去理解更高层次的抽象。