I/O问题是整个人机交互交互的核心问题,是任何编程语言都无法回避的问题,因为I/O是机器获取和交换信息的主要渠道。通常会成为一个性能瓶颈。所以java在I/O上一直在做持续的优化,如从JDK1.4开始引入了NIO,提升了I/O性能。
总结下java的I/O操作类如下:
基于字节操作的I/O接口:InputStream和OutputStream
基于字符操作的I/O接口:Writer和Reader
基于磁盘操作的I/O接口:File
基于网络操作的I/O接口:Socket
前面两组主要是传输数据的数据格式,后两组主要是传输数据的方式。个人认为I/O的核心问题要么是数据格式影响I/O操作,要么是传输方式影响I/O操作。也就是将什么样是数据写到什么地方,这就是I/O的核心。I/O只是人与机器或者是机器与机器交互的手段,除了能够完成这个交互功能外,我们关注的就是如何提高它的运行效率。
这里不详细讨论I/O的各种API,注意一下字节和字符可以通过StreamDecoder和StreamEncoder相互转换,这其实是一个适配器设计模式。
5.1 磁盘I/O工作原理
读取和写入文件I/O操作都是调用操作系统提供的接口,因为磁盘设备是由操作系统管理的,就java而言,它只是一个进程,作为一个应用程序要访问物理设备只能通过系统调用的方式来工作。读和写分别对应read()和write()两个系统调用。而只要是系统调用就可能存在内核空间地址和用户空间地址切换的问题,这是操作系统为了保护系统本身的运行安全而将内核程序运行使用内存空间和用户程序运行的内存空间隔离造成的。但是这样虽然保证了内核程序运行的安全性,但是也必然存在数据可能要从内核空间向用户空间复制的问题。当然操作磁盘的设备驱动程序和操作系统直接还夹着一层文件系统。这里就不深入说明了。
如果遇到非常耗时的操作,如磁盘I/O,数据从磁盘复制到内核空间,再从内核空间复制到用户空间。操作系统为了加速I/O访问,在内核空间使用缓存机制,如果用户访问的是同一段磁盘地址,那么操作系统将从内核取出数据之间返回给用户程序。减少I/O响应时间。这里补充一下,除了某些特殊情况,页面缓存都会透明地应用在所有I/O上,也就是说,每次从磁盘读取数据时,一定会访问一次内存,而且一定会缓存下来。因此第二次访问就更快。如果操作系统一直运行,那么只要内存足够,磁盘上的数据就会永远缓存下来。
5.1.1访问文件的方式
1、标准访问文件方式
当应用程序调用read()接口时,操作系统检查内核的高速缓存中有没有需要的数据,如果命中,直接返回。否则,从磁盘中读取,然后缓存在操作系统的缓存中。
写入的方式是,用户的应用程序调用write()接口将数据从用户地址空间复制到内核地址空间的缓存中。这时对用户来说写操作就已经完成了,至于什么时候再写到磁盘中由操作系统决定,除非显示地调用了sync同步命令。对应java中就是flush方法。
2、直接I/O方式
所谓直接I/O方式就是应用程序应用程序直接访问磁盘数据,而不经过操作系统内核数据缓冲区,目的就是减少一次从内核缓冲区到用户程序缓存的数据复制。这种方式通常是在堆数据的管理由应用程序实现的数据库管理系统中。因为操作系统不可能知道哪些是热点数据,哪些数据只会访问一次就不会访问。但是直接I/O也有负面影响,就是如果缓存没有命中,那么每次数据都会直接从磁盘加载,这种加载是十分缓慢的。
5.1.2Java如何访问磁盘
我们知道,数据在磁盘中的唯一最小描述就是文件,也就是说上层应用程序只能通过文件来操作磁盘上的数据,文件也是操作系统和磁盘驱动器交互的最小单元。值得注意的是,java中的File并不代表一个真实的文件对象,它只是一个代表这个路径的一个虚拟对象,可能是一个真实存在的文件或者是一个包含多个文件的目录。这样设计的目的是在我们并不关心这个文件是否真的存在,而是关心这个文件到底如何操作。
何时会真正检查一个文件存不存在?就是在真正要读取这个文件的时候。FileInputStream类是操作一个文件的接口,注意到在创建一个FileInputStream对象时会创建一个FileDescriptor对象,其实这个FileDescriptor对象就是真正代表一个存在的文件对象的描述。当我们在操作一个文件对象时可用通过getFD()方法获取真正操作的与底层操作系统关联的文件描述。
以从磁盘读取文件为例介绍一下这个过程:
当传入一个文件路径时,根据这个路径创建一个File对象来标识这个文件,然后根据这File对象创建真正读取文件的操作对象,这时会创建一个关联真实存在的磁盘文件的文件描述对象FileDescriptor,通过这个对象可以直接控制这个磁盘文件。
5.2 网络I/O工作原理
5.2.1Java Socket工作原理
Socket是描述计算机之间完成相互通信一种抽象功能。。Socket有多种。大部分情况下我们使用的都是基于 TCP/IP 的流套接字,它是一种稳定的通信协议。
主机 A 的应用程序要能和主机 B 的应用程序通信,必须通过 Socket 建立连接,而建立 Socket 连接必须需要底层 TCP/IP 协议来建立 TCP 连接。建立 TCP 连接需要底层 IP 协议来寻址网络中的主机。我们知道网络层使用的 IP 协议可以帮助我们根据 IP 地址来找到目标主机,但是一台主机上可能运行着多个应用程序,如何才能与指定的应用程序通信就要通过 TCP 或 UPD 的地址也就是端口号来指定。这样就可以通过一个 Socket 实例唯一代表一个主机上的一个应用程序的通信链路了。
5.2.2建立通信链路
当客户端要与服务端通信,客户端首先要创建一个 Socket 实例,操作系统将为这个 Socket 实例分配一个没有被使用的本地端口号,并创建一个包含本地和远程地址和端口号的套接字数据结构,这个数据结构将一直保存在系统中直到这个连接关闭。在创建 Socket 实例的构造函数正确返回之前,将要进行 TCP 的三次握手协议,TCP 握手协议完成后,Socket 实例对象将创建完成,否则将抛出 IOException 异常。
与之对应的服务端将创建一个 ServerSocket 实例,ServerSocket 创建比较简单只要指定的端口号没有被占用,一般实例创建都会成功,同时操作系统也会为 ServerSocket 实例创建一个底层数据结构,这个数据结构中包含指定监听的端口号和包含监听地址的通配符,通常情况下都是“*”即监听所有地址。之后当调用 accept() 方法时,将进入阻塞状态,等待客户端的请求。当一个新的请求到来时,将为这个连接创建一个新的套接字数据结构,该套接字数据的信息包含的地址和端口信息正
是请求源地址和端口。这个新创建的数据结构将会关联到 ServerSocket 实例的一个未完成的连接数据结构列表中,注意这时服务端与之对应的 Socket 实例并没有完成创建,而要等到与客户端的三次握手完成后,这个服务端的 Socket 实例才会返回,并将这个 Socket 实例对应的数据结构从未完成列表中移到已完成列表中。所以 ServerSocket 所关联的列表中每个数据结构,都代表与一个客户端的建立的 TCP 连接。
5.2.3数据传输
当连接已经建立成功,服务端和客户端都会拥有一个 Socket 实例,每个 Socket 实例都有一个 InputStream 和 OutputStream,正是通过这两个对象来交换数据。同时我们也知道网络 I/O 都是以字节流传输的。当 Socket 对象创建时,操作系统将会为 InputStream 和 OutputStream 分别分配一定大小的缓冲区,数据的写入和读取都是通过这个缓存区完成的。写入端将数据写到 OutputStream 对应的 SendQ 队列中,当队列填满时,数据将被发送到另一端
InputStream 的 RecvQ 队列中,如果这时 RecvQ 已经满了,那么 OutputStream 的 write 方法将会阻塞直到 RecvQ 队列有足够的空间容纳 SendQ 发送的数据。值得特别注意的是,这个缓存区的大小以及写入端的速度和读取端的速度非常影响这个连接的数据传输效率,由于可能会发生阻塞,所以网络 I/O 与磁盘 I/O 在数据的写入和读取还要有一个协调的过程,如果两边同时传送数据时可能会产生死锁,NIO则可以避免这种情况。
5.2.4NIO的工作机制
BIO 即阻塞 I/O,不管是磁盘 I/O 还是网络 I/O,数据在写入 OutputStream 或者从 InputStream 读取时都有可能会阻塞。一旦有线程阻塞将会失去 CPU 的使用权,这在当前的大规模访问量和有性能要求情况下是不能接受的。虽然当前的网络 I/O 有一些解决办法,如一个客户端一个处理线程,出现阻塞时只是一个线程阻塞而不会影响其它线程工作,还有为了减少系统线程的开销,采用线程池的办法来减少线 程创建和回收的成本,但是有一些使用场景仍然是无法解决的。如当前一些需要大量 HTTP 长连接的情况,服务端需要同时保持几百万的
HTTP 连接,但是并不是每时每刻这些连接都在传输数据,这种情况下不可能同时创建这么多线程来保持连接。即使线程的数量不是问题,仍然有一些问题还是无法避免 的。如这种情况,我们想给某些客户端更高的服务优先级,很难通过设计线程的优先级来完成,另外一种情况是,我们需要让每个客户端的请求在服务端可能需要访 问一些竞争资源,由于这些客户端是在不同线程中,因此需要同步,而往往要实现这些同步操作要远远比用单线程复杂很多。以上这些情况都说明,我们需要另外一 种新的 I/O 操作方式。
这里举一个例子,把A和B的通信比作A地和B地互相交通来往。而BIO和NIO中的类可以比作是交通工具。这里的 Channel 要比 Socket 更加具体,它可以比作为某种具体的交通工具,如汽车或是高铁等,而 Selector 可以比作为一个车站的车辆运行调度系统,它将负责监控每辆车的当前运行状态:是已经出战还是在路上等等,也就是它可以轮询每个 Channel 的状态。这里还有一个 Buffer 类,它也比 Stream 更加具体化,我们可以将它比作为车上的座位,Channel 是汽车的话就是汽车上的座位,高铁上就是高铁上的座位,它始终是一个具体的概念,与
Stream 不同。Stream 只能代表是一个座位,至于是什么座位由你自己去想象,也就是你在去上车之前并不知道,这个车上是否还有没有座位了,也不知道上的是什么车,因为你并不能选择,这些信息都已经被封装在了运输工具(Socket)里面了,对你是透明的。NIO 引入了 Channel、Buffer 和 Selector 就是想把这些信息具体化,让程序员有机会控制它们,如:当我们调用 write() 往 SendQ 写数据时,当一次写的数据超过 SendQ 长度是需要按照 SendQ 的长度进行分割,这个过程中需要有将用户空间数据和内核地址空间进行切换,而这个切换不是你可以控制的。而在
Buffer 中我们可以控制 Buffer 的 capacity,并且是否扩容以及如何扩容都可以控制。
调用 Selector 的静态工厂创建一个选择器,创建一个服务端的 Channel 绑定到一个 Socket 对象,并把这个通信信道注册到选择器上,把这个通信信道设置为非阻塞模式。然后就可以调用 Selector 的 selectedKeys 方法来检查已经注册在这个选择器上的所有通信信道是否有需要的事件发生,如果有某个事件发生时,将会返回所有的 SelectionKey,通过这个对象 Channel 方法就可以取得这个通信信道对象从而可以读取通信的数据,而这里读取的数据是 Buffer,这个 Buffer
是我们可以控制的缓冲器。
在实际应用中,我们通常会把监听连接请求的事件和处理请求的事件放在两个线程中,一个线程专门负责监听客户端的连接请求,而且是阻塞方式执行的;另外一个线程专门来处理请求,这个专门处理请求的线程才会真正采用 NIO 的方式,像 Web 服务器 Tomcat 和 Jetty 都是这个处理方式。
下图是描述了基于 NIO 工作方式的 Socket 请求的处理过程:
5.3 Java I/O之设计模式
5.3.1适配器模式
适配器的作用就是将一个接口适配到另一个接口,在java的I/O类库有很多这样的需求,如将字符串数据转变字节数据保存到文件中。
适配器模式的结构
Target(目标接口):所要转换的接口
Adaptee(源角色):需要适配的接口
Adapter(适配器):将源接口适配成目标接口,继承源接口,实现目标接口
Java I/O中的装饰器模式
InputStreamReader和OutputStreamWriter类分别继承了Reader和Writer接口,但是要创建它们必须在构造函数中传入一个InputStream和OutputStream的实例。InputStreamReader和OutputStreamWriter的作用也就是将InputStream和OutputStream适配到Reader和Writer。InputStreamReader的类结构如图所示
InputStreamReader类结构
InputStreamReader实现了Reader接口,并且持有了InputStream的引用,这里是通过StreamDecoder类间接持有的,因为从byte到char要经过编码。
很显然适配器就是InputStreamReader类,而源角色就是InputStream,目标接口就是Reader,OutputStreamWriter也是类似的方式。
5.3.2装饰者模式
顾名思义,装饰者模式就是在原来接口的基础上增强一些功能,类似于代理模式。对于用户来说是透明的,用户感受不到装饰前后的使用上的差别。
装饰者模式结构
Component:抽象组件角色,定义一组抽象的接口,规定这个被装饰组件有哪些功能
ConcreteComponent:实现这个抽象组件的所有功能
Decorator:装饰器角色,它持有一个Component对象实例的引用,定义一个与抽象组件一致的接口。
ConcreteDecorator:具体的装饰者实现,负责实现装饰者角色定义的功能
java I/O中的装饰器模式
下面以FilterInputStream为例介绍装饰器模式的使用
InputStream类就是以抽象组件存在的,而FileInputStream就是具体组件,它实现了抽象组件的所有接口,FilterInputStream类就是装饰角色,它实现了InputStream类所有的接口,并持有InputStream的对象实例的引用。BufferedInputStream是具体的装饰器实现者,它给InputSteam类附加了功能,使得InpuStream读取的数据保存在内存中,提高读取性能。