2 深入分析 Java IO的工作机制(一)
- 大部分Web应用系统的瓶颈都是I/O瓶颈
2.1 Java的I/O类库的基本架构
- Java的I/O操作类在包java.io下,大概有将近80个类,这些类大概可以分成如下4组。
- 基于字节操作的I/O接口:InputStream和OutputStream。
- 基于字符操作的I/O接口:Writer和Reader。
- 基于磁盘操作的I/O接口:File。
- 基于网络操作的I/O接口:Socket。
- 前两组主要是传输数据的数据格式,后两组主要是传输数据的方式。
- 数据格式和传输方式是影响效率最关键的因素。后面的分析也是基于这两个因素来展开。
2.2.1 基于字节的I/O操作接口
-
基于字节的I/O操作接口输入和输出分别是InputStream和OutputStream,InputStream的类层次结构如图2-1所示。
-
输入流根据数据类型和操作方式又被分成若干个子类,每个子类分别处理不同的操作类型。OutputStream的类层次结构也类似,如图2-2所示。
-
说明两点,一是操作数据的方式是可以组合使用的,如这样组合使用:
OutputStream out = new BufferedOutputStream(new ObjectOutputStream(new FileOutputStream("fileName")));
- 二是必须要指定流最终写到什么地方,要么是写到磁盘,要么是写到网络中,其实从上面的类层次结构图中可以发现,写网络实际上也是写文件,只不过写网络还有一步需要处理,就是让底层操作系统再将数据传递到其他地方而不是本地磁盘。
2.1.2 基于字符的I/O操作接口
-
不管是磁盘还是网络传输,最小的存储单元都是字节,而不是字符,所以I/O操作的都是字节而不是字符,但是为什么有操作字符的I/O接口呢?这是因为我们的程序中通常操作的数据都是字符形式的,为了操作方便要提供一个直接写字符的I/O接口。从字符到字节必须要经过编码转换,而这个编码又非常耗时,而且还会经常出现乱码问题。
-
如图2-3所示是写字符的I/O操作接口涉及的类,Writer类提供了一个抽象方法write(char cbuf[], int off, int len)。
-
读字符的操作接口也有类似的类结构,如图2-4所示。
-
读字符的操作接口是int read(char cbuf[], int off, int len),返回读到的n个字节数,不管是Writer还是Reader类,它们都只定义了读取或写入的数据字符 的方式,也就是怎样写或读,但是并没有规定数据要写到哪里,这些内容就是我们后面要讨论的基于磁盘和网络的工作机制。
2.1.3 字节与字符的转化接口
- 数据持久化或网络传输都是以字节进行的,所以必须要有从字符到字节或从字节到字符的转化。从字符到字节需要转化,其中读的转化过程如图2-5所示。
- InputStreamReader类是从字节到字符的转化桥梁,从InputStream到Reader的过程要指定编码字符集,否则将采用操作系统默认的字符集,很可能会出现乱码问题。StreamDecoder正是完成从 字节到字符的解码的实现类。也就是当你用如下方式读取一个文件时:
try {
StringBuffer str = new StringBuffer();
char[] buf = new char[1024];
FileReader f = new FileReader("file");
while (f.read(buf) > 0) {
str.append(buf);
}
str.toString();
} catch (IOException e) {
}
-
FileReader类就是按照上面的工作方式读取文件的,FileReader继承了InputStreamReader类,实际上是读取文件,然后通过StreamDecoder解码成char,只不过这里的解码字符集是默认字符 集。
-
写入也是类似的过程,如图2-6所示。
-
通过OutputStreamWriter类完成了从字符到字节的编码过程,由StreamEncoder完成编码过程。
2.2 磁盘I/O工作机制
- 在介绍Java读取和写入磁盘文件之前,先来看看应用程序访问文件有哪几种方式。
2.2.1 几种访问文件的方式
- 读取和写入文件I/O操作都是调用操作系统提供的接口,因为磁盘设备是由操作系统管理的,应用程序要访问物理设备只能通过系统调用的方式来工作。读和写分别对应read()和write()两个 系统调用。而只要是系统调用就可能存在内核空间地址和用户空间地址切换的问题,这是操作系统为了保护系统本身的运行安全,而将内核程序运行使用的内存空间和用户程序运行的内存空间进行隔离造成的。但是这样虽然保证了内核程序运行的安全性,但是也必然存在数据可能需要从内核空间向用户空间复制的问题。
- 如果遇到非常耗时的操作,如磁盘I/O,数据从磁盘复制到内核空间,然后又从内核空间复制到用户空间,将会非常缓慢。这时操作系统为了加速I/O访问,在内核空间使用缓存机制,也就是 将从磁盘读取的文件按照一定的组织方式进行缓存,如果用户程序访问的是同一段磁盘地址的空间数据,那么操作系统将从内核缓存中直接取出返回给用户程序,这样可以减少I/O的响应时间。
1. 标准访问文件的方式
- 标准访问文件的方式就是当应用程序调用read()接口时,操作系统检查在内核的高速缓存中有没有需要的数据,如果已经缓存了,那么就直接从缓存中返回,如果没有,则从磁盘中读取,然后缓存在操作系统的缓存中。
- 写入的方式是,用户的应用程序调用write()接口将数据从用户地址空间复制到内核地址空间的缓存中。这时对用户程序来说写操作就已经完成,至于什么时候再写到磁盘中由操作系统决定,除非显式地调用了sync同步命令。
- 标准访问文件的方式如图2-7所示。
2. 直接I/O的方式
- 所谓的直接I/O的方式就是应用程序直接访问磁盘数据,而不经过操作系统内核数据库缓冲区,这样做的目的就是减少一次从内核缓冲区到用户程序缓冲的数据复制。这种访问文件的方式通常是在对数据的缓存管理由应用程序实现的数据库管理系统中。如在数据库管理系统中,系统明确地知道应该缓存哪些数据,应该失效哪些数据,还可以对一些热点数据做预加载,提前将热点数据加载到内存,可以加速数据的访问效率。在这些情况下,如果是由操作系统进行缓存,则很难做到,因为操作系统并不知道哪些是热点数据,哪些数据可能只会访问一次就不会再访问,操作系统只是简单地缓存最近一次从磁盘读取的数据。
- 但是直接I/O也有负面影响,如果访问的数据不在应用程序缓存中,那么每次数据都会直接从磁盘进行加载,这种直接加载会非常缓慢。通常直接I/O与异步I/O结合使用,会得到比较好的性能。
- 直接I/O方式如图2-8所示。
3. 同步访问文件的方式
- 同步访问文件的方式比较容易理解,就是数据的读取和写入都是同步操作的,它与标准访问文件的方式不同的是,只有当数据被成功写到磁盘时才返回给应用程序成功的标志。
- 这种访问文件的方式性能比较差,只有在一些对数据安全性要求比较高的场景中才会使用,而且通常这种操作方式的硬件都是定制的。
- 同步访问文件的方式如图2-9所示。
4. 异步访问文件的方式
- 异步访问文件的方式就是当访问数据的线程发出请求之后,线程会接着去处理其他事情,而不是阻塞等待,当请求的数据返回后继续处理下面的操作。这种访问文件的方式可以明显地提高应用程序的效率,但是不会改变访问的效率。
- 异步访问文件的方式如图2-10所示。
5. 内存映射的方式
- 内存映射的方式是指操作系统将内存中的某一块区域与磁盘中的文件关联起来,当要访问内存中的一段数据时,转换为访问文件的某一段数据。这种方式的目的同样是减少数据从内核空间缓存到用户空间缓存的数据复制操作,因为这两个空间的数据是共享的。
- 内存映射方式如图2-11所示。
2.2.2 Java访问磁盘文件
- 下面介绍如何将数据持久化到物理磁盘。
- 数据在磁盘中的唯一最小描述就是文件,也就是说上层应用程序只能通过文件来操作磁盘上的数据,文件也是操作系统和磁盘驱动器交互的最小单元。在Java中通常的File并不代表一个真是存在的文件对象,当你指定一个路径描述符时,它就会返回一个代表这个路径的虚拟对象,这个可能是一个真实存在的文件或者是一个包含多个文件的目录。为何要这样设计呢?因为在大多数情况下,我们并不关心这个文件是否真的存在,而是关心对这个文件到底如何操作。
- 何时会真正检查一个文件存不存在?就是在真正要读取这个文件时。例如,FileInputStream类都是操作一个文件的接口,注意到在创建一个FileInputStream对象时会创建一个FileDescriptor对象,其实这个对象就是真正代表一个存在的文件对象的描述。当我们在操作一个文件对象时 可以通过getFD()方法获取真正操作的与底层操作系统相关联的文件描述。例如,可以调用FileDescriptor.sync()方法将操作系统缓存中的数据强制刷新到物理磁盘中。
- 下面以前面读取文件的程序为例介绍如何从磁盘读取一段文本字符,如图2-12所示。
- 当传入一个文件路径时,将会根据这个路径创建一个File对象来标识这个文件,然后根据这个File对象创建真正读取文件的操作对象,这时将会真正创建一个关系真实存在的磁盘文件的文件描述FileDescriptor,通过这个对象可以直接控制这个磁盘文件。由于我们需要读取的是字符格式,所以需要StreamDecoder类将byte解码为char格式,至于如何从磁盘驱动上读取一段数据,操作系统会帮我们完成。至于操作系统是如何将数据持久化到磁盘及如何建立数据结构的,需要根据当前操作系统使用何种文件系统来回答。
2.2.3 Java序列化技术
- Java序列化就是将一个对象转化成一串二进制表示的字节数组,通过保存或转移这些字节数据来达到持久化的目的。需要持久化,对象必须继承java.io.Serializable接口。反序列化则是相反的过程,将这个字节数组再重新构造对象。我们知道反序列化时,必须有原始类作为模板,才能将这个对象还原,从这个过程我们可以猜测,序列化的数据并不像class文件那样保存类的完整的结构信息。那么序列化的数据到底都含有哪些信息呢?下面以一个最简单的类为例讲解序列化的数据都含有哪些信息,如下面的代码所示:
public class Serialize implements Serializable {
private static final long serialVersionUID = -6849794470754660011L;
public int num = 1390;
public static void main(String[] args) {
try {
FileOutputStream fos = new FileOutputStream("d:/serialize.dat");
ObjectOutputStream oos = new ObjectOutputStream(fos);
Serialize serialize = new Serialize();
oos.writeObject(serialize);
oos.flush();
oos.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
2.3 网络I/O工作机制
- 数据从一台主机发送到网络中的另一台主机需要经过很多步骤。首先需要有相互沟通的意向。其次要有能够沟通的物理渠道(物理链路):是通过电话,还是直接面对面交流。再次,双方 见面时语言要能够交流,而且双方说话的步调要一致,明白什么时候该自己说话,什么时候该对方说话(通信协议)。
2.3.1 TCP状态的转化
- 我们先看看如何建立和关闭一个TCP连接,TCP连接的状态转换如图2-13所示。
2.3.2 影响网络传输的因素
- 将一份数据从一个地方正确地传输到另一个地方所需要的时间我们称之为响应时间。影响这个响应时间的因素很多。
2.3.3 Java Socket的工作机制
-
Socket描述计算机之间完成相互通信的一种抽象功能。打个比方,可以把Socket比作两个城市之间的交通工具,有了它,就可以在城市之间来回穿梭了。交通工具有多种,每种交通工具也 有相应的交通规则。Socket也一样,也有多种。大部分情况下我们使用的都是基于TCP/IP的流套接字,它是一种稳定的通信协议。
-
图2-14是典型的基于Socket的通信场景。
-
主机A的应用程序要和主机B的应用程序通信,必须通过Socket建立连接,而建立Socket连接必须由底层TCP/IP来建立TCP连接。建立TCP连接需要底层IP来寻址网络中的主机。我们知道网络 层使用的IP可以帮助我们根据IP地址来找到目标主机,但是在一台主机上可能运行着多个应用程序,如何才能与指定的应用程序通信就要通过TCP或UPD的地址也就是端口号来指定。这样就可以通过一个Socket实例来唯一代表一个主机上的应用程序的通信链路了。
2.3.4 建立通信链路
- 当客户端要与服务端通信时,客户端首先要创建一个Socket实例,操作系统将为这个Socket实例分配一个没有被使用的本地端口号,并创建一个包含本地地址、远程地址和端口号的套接字 数据结构,这个数据结构将一直保存在系统中直到这个连接关闭。在创建Socket实例的构造函数正确返回之前,将要进行TCP的3次握手协议,TCP握手协议完成后,Socket实例对象将创建完成,否则将抛出IOException错误。
- 与之对应的服务端将创建一个ServerSocket实例,创建ServerSocket比较简单,只要指定的端口号没有被占用,一般实例创建都会成功,同时操作系统也会为ServerSocket实例创建一个底 层数据结构,在这个数据结构中包含指定监听的端口号和包含监听地址的通配符,通常情况下都是“*”,即监听所有地址。之后当调用accept()方法时,将进入阻塞状态,等待客户端的请求。当一个新的请求到来时,将为这个连接创建一个新的套接字数据结构,该套接字数据的信息包含的地址和端口信息正是请求源地址和端口。这个新创建的数据结构将会关联到ServerSocket实例的一个未完成的连接数据结构列表中。注意,这时服务端与之对应的Socket实例并没有完成创建,而要等到与客户端的3次握手完成后,这个服务端的Socket实例才会返回,并将这个Socket实例对应的数据结构从未完成列表中移除到已完成列表中。所以与ServerSocket所关联的列表中每个数据结构都代表与一个客户端建立的TCP连接。
2.3.5 数据传输
- 传输数据是我们建立连接的主要目的,下面将详细介绍如何通过Socket传输数据。
- 当连接已经建立成功时,服务端和客户端都会拥有一个Socket实例,每个Socket实例都有一个InputStream和OutputStream,并通过这两个对象来交换数据。同时我们也知道网络I/O都是以 字节流传输的,当创建Socket对象时,操作系统将会为InputStream和OutputStream分别分配一定大小的缓存区,数据的写入和读取都是通过这个缓存区完成的。写入端将数据写到OutputStream对应饿SendQ队列中,当队列填满时,数据将被转移到另一端InputStream的RecvQ队列中,如果这时RecvQy已经满了,那么OutputStream的write方法将会阻塞,直到RecvQ队列有足够的空间容纳SendQ发送的数据。特别值得注意的是,这个缓存区的大小及写入端的速度和读取端的速度非常影响这个链接的数据传输效率,由于可能会发生阻塞,所以网络I/O与磁盘I/O不同的是数据的写入和读取还要有一个协调的过程,如果在两边同时传送数据可能会发生死锁,在下面的NIO这部分将介绍如何避免这种情况。
2.4 NIO的工作方式
2.4.1 BIO带来的挑战
- BIO即阻塞I/O,不管是磁盘I/O还是网络I/O,数据在写入OutputStream或者从InputStream读取时都有可能会阻塞,一旦有阻塞,线程将会失去CPU的使用权,这在当前的大规模访问量和有性能要求的情况下是不能被接受的。虽然当前的网络I/O有一些解决方法,如一个客户端对应一个处理线程,出现阻塞时只是一个线程阻塞而不会影响其他线程工作,还有为了减少系统线程的开销,采用线程池的办法来减少线程创建和回收的成本,但是在一些使用场景下仍然无法解决的。如当前一些需要大量HTTP长连接的情况,像淘宝现在使用的Web旺旺,服务端需要同时保持几百万的HTTP连接,但并不是每时每刻这些连接都在传输数据,在这种情况下不可能同时创建这么多线程来保持连接。即使线程的数量不是问题,也仍然有一些问题是无法避免的,比如我们想给某些客户端更高的服务优先级时,很难通过设计线程的优先级来完成。另外一种情况是,每个客户端的请求在服务端可能需要访问一些竞争资源,这些客户端在不同线程中,因此需要同步,要实现这种同步操作远比用单线程复杂得多。以上述的情况说明,我们需要另外一种新的I/O操作方式。
2.4.2 NIO的工作机制
- 先看一下NIO的相关类图,如图2-15所示。
- 在图2-15中有两个关键类:Channel和Selector,它们是NIO中的两个核心概念。这里的Channel要比Socket更加具体,可以把它比作某种具体的交通工具,如汽车或高铁,而可把Selector比 作一个车站的车辆运行调度系统,它将负责监控每辆车的当前运行状态,是已经出站,还是在路上等。也就是它可以轮询每个Channel的状态。这里还有一个Buffer类,它也比Stream更加具体,我们可以将它比作车上的座位。Channel是汽车的话,Buffer就是汽车上的座位,它始终是一个具体的概念,与Stream不同,Stream只能代表一个座位,至于是什么座位随你想象,也就是你在上车前并不知道在这个车上是否还有座位,也不知自己上的是什么车,因为你并不能选择。而这些信息都已经被封装在运输工具(Socket)里面,对你是透明的。NIO引入Channel、Buffer和Selector,就是想把这些信息具体化,让程序员有机会控制它们。例如当我们调用write()往sendQ中写数据时,当一次写的数据超过SendQ长度时需要按照SendQ的长度进行分割,在这个过程中需要将用户空间数据和内核地址空间进行切换,而这个切换不是你可以控制的,但在Buffer中我们可以控制Buffer的容量、是否扩容以及如何扩容。
- 理解了这些概念后,我们看一下它们实际上是如何工作的,下面是一段典型的NIO代码:
public void selector() throws IOException {
ByteBuffer buffer = ByteBuffer.allocate(1024);
Selector selector = Selector.open();
ServerSocketChannel ssc = ServerSocketChannel.open();
ssc.configureBlocking(false);//设置为非阻塞方式
ssc.socket().bind(new InetSocketAddress(8080));
ssc.register(selector, SelectionKey.OP_ACCEPT);//注册监听的事件
while (true) {
Set selecteKeys = selector.selectedKeys();//取得所有key集合
Iterator it = selectedKeys.iterator();
while (it.hasNext()) {
SelectionKey key = (SelectionKey) it.next();
if ((key.readyOps() & SelectionKey.OP_ACCEPT) == SelectionKey.OP_ACCEPT) {
ServerSocketChannel scChannel = (ServerSocketChannel) key.channel();
SocketChannel sc = ssChannel.accept();//接受到服务端的请求
sc.configureBlocking(false);
sc.register(selector, SelectionKey.OP_READ);
it.remove();
} else if ((key.readyOps() & SelectionKey.OP_READ) == SelectionKey.OP_READ) {
SocketChannel sc = (SocketChannel) key.channel();
while (true) {
buffer.clear();
int n = sc.read(buffer);//读取数据
if (n <= 0) {
break;
}
buffer.flip();
}
it.remove();
}
}
}
}
-
调用Selector的静态工厂创建一个选择器,创建一个服务端的Channel,绑定到一个Socket对象,并把这个通信信道注册到选择器上,把这个通信信道设置为非阻塞模式。然后就可以调用Selector的selectedKeys方法来检查已经注册在这个选择器上的所有通信信道是否有需要的事件发生,如果有某个事件发生,将会返回所有的SelectionKey,通过这个对象的Channel方法就可以取得这个通信信道对象,从而读取通信的数据,而这里读取的数据是Buffer,这个Buffer是我们可以控制的缓冲器。
-
在上面的这段程序中,将Server端的监听连接请求的事件和处理请求的事件放在一个线程中,但是在事件应用中,我们通常会把它们放在两个线程中:一个线程专门负责监听客户端的连接请求,而且是以阻塞方式执行的;另外一个线程专门负责处理请求,这个线程才会真正采用NIO的方式,想Web服务器Tomcat和Jetty都是使用这种处理方式。
-
图2-16描述了基于NIO工作方式的Socket请求的处理过程。
-
图2-16中的Selector可以同时监听一组通信信道(Channel)上的I/O状态,前提是这个Selector已经注册到这些通信信道中。选择器Selector可以调用select()方法检查已经注册的通信信道上I/O是否已经准备好,如果没有至少一个信道I/O状态有变化,那么select方法会阻塞等待或在超时时间后返回0。如果有多个信道有数据,那么将会把这些数据分配到对应的数据Buffer中。所以关键的地方是,有一个线程来处理所有连接的数据交互,每个连接的数据交互都不是阻塞方式,所以可以同时处理大量的连接请求。
2.4.3 Buffer的工作方式
-
前面介绍了Selector检测到通信信道I/O有数据传输时,通过select()取得SocketChannel,将数据读取或写入Buffer缓冲区,下面讨论Buffer如何接受和写出数据。
-
可以把Buffer简单地理解为一组基本数据类型的元素列表,它通过几个变量来保存这个数据的当前位置状态,也就是有4个索引,如表2-1所示。
-
在实际操作数据时它们的关系如图2-17所示。
-
我们通过ByteBuffer.allocate(11)方法创建了11个byte的数组缓冲区,初始状态如图2-17所示,position的位置为0,capacity和limie默认都是数组长度。当我们写入5个字节时,位置变化如图2-18所示。
-
这时我们需要将缓冲区的5个字节数据写入Channel通信信道,所以我们调用byteBuffer.flip()方法,数组的状态发生如图2-19所示的变化。
-
这时底层操作系统就可以从缓冲区中正确读取5个字节数据并发送出去了。在下一次写数据之前我们再调一下clear()方法,缓冲区的索引状态又回到初始位置。
-
这里还要说明一下mark,当我们调用mark()方法时,它将记录当前position的前一个位置,当我们调用reset时,position将恢复mark记录下来的值。
-
还有一点需要说明,通过Channel获取的I/O数据首先要经过操作系统的Socket缓冲区,再将数据复制到Buffer中,这个操作系统缓冲区就是底层的TCP所关联的RecvQ或者SendQ队列,从操作系统缓冲区到用户缓冲区复制数据比较耗性能,Buffer提供了另外一种直接操作操作系统缓冲区的方式,即ByteBuffer.allocationDirector(size),这个方法返回的DirectByteBuffer就是与底层存储空间关联的缓冲区,它通过Native代码操作非JVM堆的内存空间。每次创建或者释放的时候都调用一次System.gc()。注意,在使用DirectByteBuffer时可能会引起JVM内存泄漏问题。DirectByteBuffer和Non-Direct Buffer(HeapByteBuffer)的对比如表2-2所示。
2.4.4 NIO的数据访问方式
- NIO提供了比传统的文件访问方式更好的方法,NIO有两个优化方法:一个是FileChannel.transferTo、FileChannel.transferFrom;另一个是FileChannel.map。
1. FileChannel.transferXXX
- FileChannel.transferXXX与传统的访问文件方式相比可以减少数据从内核到用户空间的复制,数据直接在内核空间中移动,在Linux中使用sendfile系统调用。
- 如图2-20所示是传统的数据访问方式,如图2-21所示是FileChannel.transferXXX的访问方式。
2. FileChannel.map
- FileChannel.map将文件按照一定大小块映射为内存区域,当程序访问这个内存区域时将直接操作这个文件数据,这种方式省去了数据从内核空间向用户空间复制的损耗。这种方式适合对大 文件的只读性操作,如大文件的MD5校验。但是这种方式是和操作系统的底层I/O实现相关的,如下面的代码所示:
public static void main(String[] args) {
int BUFFER_SIZE = 1024;
String filename = "test.db";
long fileLength = new File(filename).length();
int bufferCount = 1 + (int) (fileLength / BUFFER_SIZE);
MappedByteBuffer[] buffer = new MappedByteBuffer[bufferCount];
long remaining = fileLength;
for (int i = 0; i < bufferCount; i++) {
RandomAccessFile file;
try {
file = new RandomAccessFile(filename, "r");
buffers[i] = file.getChannel().map(FileChannel.MapMode.READ_ONLY, i * BUFFER_SIZE, (int) Math.min(remaining, BUFFER_SIZE));
} catch ( Exception e ) {
e.printStackTrace();
}
remaining -= BUFFER_SIZE;
}
}