深刻理解 java I/O

Java 的 I/O 类库的基本架构css

I/O 问题是任何编程语言都没法回避的问题,能够说 I/O 问题是整我的机交互的核心问题,由于 I/O 是机器获取和交换信息的主要渠道。在当今这个数据大爆炸时代,I/O 问题尤为突出,很容易成为一个性能瓶颈。正因如此,因此 Java 在 I/O 上也一直在作持续的优化,如从 1.4 开始引入了 NIO,提高了 I/O 的性能。关于 NIO 咱们将在后面详细介绍。前端

Java 的 I/O 操做类在包 java.io 下,大概有将近 80 个类,可是这些类大概能够分红四组,分别是:java

  1. 基于字节操做的 I/O 接口:InputStream 和 OutputStream
  2. 基于字符操做的 I/O 接口:Writer 和 Reader
  3. 基于磁盘操做的 I/O 接口:File
  4. 基于网络操做的 I/O 接口:Socket

前两组主要是根据传输数据的数据格式,后两组主要是根据传输数据的方式,虽然 Socket 类并不在 java.io 包下,可是我仍然把它们划分在一块儿,由于我我的认为 I/O 的核心问题要么是数据格式影响 I/O 操做,要么是传输方式影响 I/O 操做,也就是将什么样的数据写到什么地方的问题,I/O 只是人与机器或者机器与机器交互的手段,除了在它们可以完成这个交互功能外,咱们关注的就是如何提升它的运行效率了,而数据格式和传输方式是影响效率最关键的因素了。咱们后面的分析也是基于这两个因素来展开的。linux

基于字节的 I/O 操做接口ios

基于字节的 I/O 操做接口输入和输出分别是:InputStream 和 OutputStream,InputStream 输入流的类继承层次以下图所示:程序员


图 1. InputStream 相关类层次结构( 查看大图
图 1. InputStream 相关类层次结构 

输入流根据数据类型和操做方式又被划分红若干个子类,每一个子类分别处理不一样操做类型,OutputStream 输出流的类层次结构也是相似,以下图所示:web


图 2. OutputStream 相关类层次结构( 查看大图
图 2. OutputStream 相关类层次结构 

这里就不详细解释每一个子类如何使用了,若是不清楚的话能够参考一下 JDK 的 API 说明文档,这里只想说明两点,一个是操做数据的方式是能够组合使用的,如这样组合使用数据库

OutputStream out = new BufferedOutputStream(new ObjectOutputStream(new FileOutputStream("fileName"))编程

还有一点是流最终写到什么地方必需要指定,要么是写到磁盘要么是写到网络中,其实从上面的类图中咱们发现,写网络实际上也是写文件,只不过写网络还有一步须要处理就是底层操做系统再将数据传送到其它地方而不是本地磁盘。关于网络 I/O 和磁盘 I/O 咱们将在后面详细介绍。后端

基于字符的 I/O 操做接口

无论是磁盘仍是网络传输,最小的存储单元都是字节,而不是字符,因此 I/O 操做的都是字节而不是字符,可是为啥有操做字符的 I/O 接口呢?这是由于咱们的程序中一般操做的数据都是以字符形式,为了操做方便固然要提供一个直接写字符的 I/O 接口,如此而已。咱们知道字符到字节必需要通过编码转换,而这个编码又很是耗时,并且还会常常出现乱码问题,因此 I/O 的编码问题常常是让人头疼的问题。关于 I/O 编码问题请参考另外一篇文章 《深刻分析Java中的中文编码问题》

下图是写字符的 I/O 操做接口涉及到的类,Writer 类提供了一个抽象方法 write(char cbuf[], int off, int len) 由子类去实现。


图 3. Writer 相关类层次结构( 查看大图
图 3. Writer 相关类层次结构 

读字符的操做接口也有相似的类结构,以下图所示:


图 4.Reader 类层次结构( 查看大图
图 4.Reader 类层次结构 

读字符的操做接口中也是 int read(char cbuf[], int off, int len),返回读到的 n 个字节数,无论是 Writer 仍是 Reader 类它们都只定义了读取或写入的数据字符的方式,也就是怎么写或读,可是并无规定数据要写到哪去,写到哪去就是咱们后面要讨论的基于磁盘和网络的工做机制。

字节与字符的转化接口

另外数据持久化或网络传输都是以字节进行的,因此必需要有字符到字节或字节到字符的转化。字符到字节须要转化,其中读的转化过程以下图所示:


图 5. 字符解码相关类结构
图 5. 字符解码相关类结构 

InputStreamReader 类是字节到字符的转化桥梁,InputStream 到 Reader 的过程要指定编码字符集,不然将采用操做系统默认字符集,极可能会出现乱码问题。StreamDecoder 正是完成字节到字符的解码的实现类。也就是当你用以下方式读取一个文件时:


清单 1.读取文件
				 
 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,只不过这里的解码字符集是默认字符集。

写入也是相似的过程以下图所示:


图 6. 字符编码相关类结构
图 6. 字符编码相关类结构 

经过 OutputStreamWriter 类完成,字符到字节的编码过程,由 StreamEncoder 完成编码过程。

 

磁盘 I/O 工做机制

前面介绍了基本的 Java I/O 的操做接口,这些接口主要定义了如何操做数据,以及介绍了操做两种数据结构:字节和字符的方式。还有一个关键问题就是数据写到何处,其中一个主要方式就是将数据持久化到物理磁盘,下面将介绍如何将数据持久化到物理磁盘的过程。

咱们知道数据在磁盘的惟一最小描述就是文件,也就是说上层应用程序只能经过文件来操做磁盘上的数据,文件也是操做系统和磁盘驱动器交互的一个最小单元。值得注意的是 Java 中一般的 File 并不表明一个真实存在的文件对象,当你经过指定一个路径描述符时,它就会返回一个表明这个路径相关联的一个虚拟对象,这个多是一个真实存在的文件或者是一个包含多个文件的目录。为什么要这样设计?由于大部分状况下,咱们并不关心这个文件是否真的存在,而是关心这个文件到底如何操做。例如咱们手机里一般存了几百个朋友的电话号码,可是咱们一般关心的是我有没有这个朋友的电话号码,或者这个电话号码是什么,可是这个电话号码到底能不能打通,咱们并非时时刻刻都去检查,而只有在真正要给他打电话时才会看这个电话能不能用。也就是使用这个电话记录要比打这个电话的次数多不少。

什么时候真正会要检查一个文件存不存?就是在真正要读取这个文件时,例如 FileInputStream 类都是操做一个文件的接口,注意到在建立一个 FileInputStream 对象时,会建立一个 FileDescriptor 对象,其实这个对象就是真正表明一个存在的文件对象的描述,当咱们在操做一个文件对象时能够经过 getFD() 方法获取真正操做的与底层操做系统关联的文件描述。例如能够调用 FileDescriptor.sync() 方法将操做系统缓存中的数据强制刷新到物理磁盘中。

下面以清单 1 的程序为例,介绍下如何从磁盘读取一段文本字符。以下图所示:


图 7. 从磁盘读取文件
图 7. 从磁盘读取文件 

当传入一个文件路径,将会根据这个路径建立一个 File 对象来标识这个文件,而后将会根据这个 File 对象建立真正读取文件的操做对象,这时将会真正建立一个关联真实存在的磁盘文件的文件描述符 FileDescriptor,经过这个对象能够直接控制这个磁盘文件。因为咱们须要读取的是字符格式,因此须要 StreamDecoder 类将 byte 解码为 char 格式,至于如何从磁盘驱动器上读取一段数据,由操做系统帮咱们完成。至于操做系统是如何将数据持久化到磁盘以及如何创建数据结构须要根据当前操做系统使用何种文件系统来回答,至于文件系统的相关细节能够参考另外的文章。

 

Java Socket 的工做机制

Socket 这个概念没有对应到一个具体的实体,它是描述计算机之间完成相互通讯一种抽象功能。打个比方,能够把 Socket 比做为两个城市之间的交通工具,有了它,就能够在城市之间来回穿梭了。交通工具备多种,每种交通工具也有相应的交通规则。Socket 也同样,也有多种。大部分状况下咱们使用的都是基于 TCP/IP 的流套接字,它是一种稳定的通讯协议。

下图是典型的基于 Socket 的通讯的场景:


图 8.Socket 通讯示例
图 8.Socket 通讯示例 

主机 A 的应用程序要能和主机 B 的应用程序通讯,必须经过 Socket 创建链接,而创建 Socket 链接必须须要底层 TCP/IP 协议来创建 TCP 链接。创建 TCP 链接须要底层 IP 协议来寻址网络中的主机。咱们知道网络层使用的 IP 协议能够帮助咱们根据 IP 地址来找到目标主机,可是一台主机上可能运行着多个应用程序,如何才能与指定的应用程序通讯就要经过 TCP 或 UPD 的地址也就是端口号来指定。这样就能够经过一个 Socket 实例惟一表明一个主机上的一个应用程序的通讯链路了。

创建通讯链路

当客户端要与服务端通讯,客户端首先要建立一个 Socket 实例,操做系统将为这个 Socket 实例分配一个没有被使用的本地端口号,并建立一个包含本地和远程地址和端口号的套接字数据结构,这个数据结构将一直保存在系统中直到这个链接关闭。在建立 Socket 实例的构造函数正确返回以前,将要进行 TCP 的三次握手协议,TCP 握手协议完成后,Socket 实例对象将建立完成,不然将抛出 IOException 错误。

与之对应的服务端将建立一个 ServerSocket 实例,ServerSocket 建立比较简单只要指定的端口号没有被占用,通常实例建立都会成功,同时操做系统也会为 ServerSocket 实例建立一个底层数据结构,这个数据结构中包含指定监听的端口号和包含监听地址的通配符,一般状况下都是“*”即监听全部地址。以后当调用 accept() 方法时,将进入阻塞状态,等待客户端的请求。当一个新的请求到来时,将为这个链接建立一个新的套接字数据结构,该套接字数据的信息包含的地址和端口信息正是请求源地址和端口。这个新建立的数据结构将会关联到 ServerSocket 实例的一个未完成的链接数据结构列表中,注意这时服务端与之对应的 Socket 实例并无完成建立,而要等到与客户端的三次握手完成后,这个服务端的 Socket 实例才会返回,并将这个 Socket 实例对应的数据结构从未完成列表中移到已完成列表中。因此 ServerSocket 所关联的列表中每一个数据结构,都表明与一个客户端的创建的 TCP 链接。

数据传输

传输数据是咱们创建链接的主要目的,如何经过 Socket 传输数据,下面将详细介绍。

当链接已经创建成功,服务端和客户端都会拥有一个 Socket 实例,每一个 Socket 实例都有一个 InputStream 和 OutputStream,正是经过这两个对象来交换数据。同时咱们也知道网络 I/O 都是以字节流传输的。当 Socket 对象建立时,操做系统将会为 InputStream 和 OutputStream 分别分配必定大小的缓冲区,数据的写入和读取都是经过这个缓存区完成的。写入端将数据写到 OutputStream 对应的 SendQ 队列中,当队列填满时,数据将被发送到另外一端 InputStream 的 RecvQ 队列中,若是这时 RecvQ 已经满了,那么 OutputStream 的 write 方法将会阻塞直到 RecvQ 队列有足够的空间容纳 SendQ 发送的数据。值得特别注意的是,这个缓存区的大小以及写入端的速度和读取端的速度很是影响这个链接的数据传输效率,因为可能会发生阻塞,因此网络 I/O 与磁盘 I/O 在数据的写入和读取还要有一个协调的过程,若是两边同时传送数据时可能会产生死锁,在后面 NIO 部分将介绍避免这种状况。

 

NIO 的工做方式

BIO 带来的挑战

BIO 即阻塞 I/O,无论是磁盘 I/O 仍是网络 I/O,数据在写入 OutputStream 或者从 InputStream 读取时都有可能会阻塞。一旦有线程阻塞将会失去 CPU 的使用权,这在当前的大规模访问量和有性能要求状况下是不能接受的。虽然当前的网络 I/O 有一些解决办法,如一个客户端一个处理线程,出现阻塞时只是一个线程阻塞而不会影响其它线程工做,还有为了减小系统线程的开销,采用线程池的办法来减小线程建立和回收的成本,可是有一些使用场景仍然是没法解决的。如当前一些须要大量 HTTP 长链接的状况,像淘宝如今使用的 Web 旺旺项目,服务端须要同时保持几百万的 HTTP 链接,可是并非每时每刻这些链接都在传输数据,这种状况下不可能同时建立这么多线程来保持链接。即便线程的数量不是问题,仍然有一些问题仍是没法避免的。如这种状况,咱们想给某些客户端更高的服务优先级,很难经过设计线程的优先级来完成,另一种状况是,咱们须要让每一个客户端的请求在服务端可能须要访问一些竞争资源,因为这些客户端是在不一样线程中,所以须要同步,而每每要实现这些同步操做要远远比用单线程复杂不少。以上这些状况都说明,咱们须要另一种新的 I/O 操做方式。

NIO 的工做机制

咱们先看一下 NIO 涉及到的关联类图,以下:


图 9.NIO 相关类图
图 9.NIO 相关类图 

上图中有两个关键类:Channel 和 Selector,它们是 NIO 中两个核心概念。咱们还用前面的城市交通工具来继续比喻 NIO 的工做方式,这里的 Channel 要比 Socket 更加具体,它能够比做为某种具体的交通工具,如汽车或是高铁等,而 Selector 能够比做为一个车站的车辆运行调度系统,它将负责监控每辆车的当前运行状态:是已经出战仍是在路上等等,也就是它能够轮询每一个 Channel 的状态。这里还有一个 Buffer 类,它也比 Stream 更加具体化,咱们能够将它比做为车上的座位,Channel 是汽车的话就是汽车上的座位,高铁上就是高铁上的座位,它始终是一个具体的概念,与 Stream 不一样。Stream 只能表明是一个座位,至因而什么座位由你本身去想象,也就是你在去上车以前并不知道,这个车上是否还有没有座位了,也不知道上的是什么车,由于你并不能选择,这些信息都已经被封装在了运输工具(Socket)里面了,对你是透明的。NIO 引入了 Channel、Buffer 和 Selector 就是想把这些信息具体化,让程序员有机会控制它们,如:当咱们调用 write() 往 SendQ 写数据时,当一次写的数据超过 SendQ 长度是须要按照 SendQ 的长度进行分割,这个过程当中须要有将用户空间数据和内核地址空间进行切换,而这个切换不是你能够控制的。而在 Buffer 中咱们能够控制 Buffer 的 capacity,而且是否扩容以及如何扩容均可以控制。

理解了这些概念后咱们看一下,实际上它们是如何工做的,下面是典型的一段 NIO 代码:


清单 2. 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 selectedKeys = 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 ssChannel = (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 都是这个处理方式,关于 Tomcat 和 Jetty 的 NIO 处理方式能够参考文章《 Jetty 的工做原理和与 Tomcat 的比较》。

下图是描述了基于 NIO 工做方式的 Socket 请求的处理过程:


图 10. 基于 NIO 的 Socket 请求的处理过程
图 10. 基于 NIO 的 Socket 请求的处理过程 

上图中的 Selector 能够同时监听一组通讯信道(Channel)上的 I/O 状态,前提是这个 Selector 要已经注册到这些通讯信道中。选择器 Selector 能够调用 select() 方法检查已经注册的通讯信道上的是否有 I/O 已经准备好,若是没有至少一个信道 I/O 状态有变化,那么 select 方法会阻塞等待或在超时时间后会返回 0。上图中若是有多个信道有数据,那么将会将这些数据分配到对应的数据 Buffer 中。因此关键的地方是有一个线程来处理全部链接的数据交互,每一个链接的数据交互都不是阻塞方式,因此能够同时处理大量的链接请求。

Buffer 的工做方式

上面介绍了 Selector 将检测到有通讯信道 I/O 有数据传输时,经过 selelct() 取得 SocketChannel,将数据读取或写入 Buffer 缓冲区。下面讨论一下 Buffer 如何接受和写出数据?

Buffer 能够简单的理解为一组基本数据类型的元素列表,它经过几个变量来保存这个数据的当前位置状态,也就是有四个索引。以下表所示:


表 1.Buffer 中的参数项
索引 说明
capacity 缓冲区数组的总长度
position 下一个要操做的数据元素的位置
limit 缓冲区数组中不可操做的下一个元素的位置,limit<=capacity
mark 用于记录当前 position 的前一个位置或者默认是 0

在实际操做数据时它们有以下关系图:


Figure xxx. Requires a heading 

咱们经过 ByteBuffer.allocate(11) 方法建立一个 11 个 byte 的数组缓冲区,初始状态如上图所示,position 的位置为 0,capacity 和 limit 默认都是数组长度。当咱们写入 5 个字节时位置变化以下图所示:


Figure xxx. Requires a heading 

这时咱们须要将缓冲区的 5 个字节数据写入 Channel 通讯信道,因此咱们须要调用 byteBuffer.flip() 方法,数组的状态又发生以下变化:


Figure xxx. Requires a heading 

这时底层操做系统就能够从缓冲区中正确读取这 5 个字节数据发送出去了。在下一次写数据以前咱们在调一下 clear() 方法。缓冲区的索引状态又回到初始位置。

这里还要说明一下 mark,当咱们调用 mark() 时,它将记录当前 position 的前一个位置,当咱们调用 reset 时,position 将恢复 mark 记录下来的值。

还有一点须要说明,经过 Channel 获取的 I/O 数据首先要通过操做系统的 Socket 缓冲区再将数据复制到 Buffer 中,这个的操做系统缓冲区就是底层的 TCP 协议关联的 RecvQ 或者 SendQ 队列,从操做系统缓冲区到用户缓冲区复制数据比较耗性能,Buffer 提供了另一种直接操做操做系统缓冲区的的方式即 ByteBuffer.allocateDirector(size),这个方法返回的 byteBuffer 就是与底层存储空间关联的缓冲区,它的操做方式与 linux2.4 内核的 sendfile 操做方式相似。

 

I/O 调优

下面就磁盘 I/O 和网络 I/O 的一些经常使用的优化技巧进行总结以下:

磁盘 I/O 优化

性能检测

咱们的应用程序一般都须要访问磁盘读取数据,而磁盘 I/O 一般都很耗时,咱们要判断 I/O 是不是一个瓶颈,咱们有一些参数指标能够参考:

如咱们能够压力测试应用程序看系统的 I/O wait 指标是否正常,例如测试机器有 4 个 CPU,那么理想的 I/O wait 参数不该该超过 25%,若是超过 25% 的话,I/O 极可能成为应用程序的性能瓶颈。Linux 操做系统下能够经过 iostat 命令查看。

一般咱们在判断 I/O 性能时还会看另一个参数就是 IOPS,咱们应用程序须要最低的 IOPS 是多少,而咱们的磁盘的 IOPS 能不能达到咱们的要求。每一个磁盘的 IOPS 一般是在一个范围内,这和存储在磁盘的数据块的大小和访问方式也有关。可是主要是由磁盘的转速决定的,磁盘的转速越高磁盘的 IOPS 也越高。

如今为了提升磁盘 I/O 的性能,一般采用一种叫 RAID 的技术,就是将不一样的磁盘组合起来来提升 I/O 性能,目前有多种 RAID 技术,每种 RAID 技术对 I/O 性能提高会有不一样,能够用一个 RAID 因子来表明,磁盘的读写吞吐量能够经过 iostat 命令来获取,因而咱们能够计算出一个理论的 IOPS 值,计算公式以下因此:

( 磁盘数 * 每块磁盘的 IOPS)/( 磁盘读的吞吐量 +RAID 因子 * 磁盘写的吞吐量 )=IOPS

这个公式的详细信息请查阅参考资料 Understanding Disk I/O

提高 I/O 性能

提高磁盘 I/O 性能一般的方法有:

  1. 增长缓存,减小磁盘访问次数
  2. 优化磁盘的管理系统,设计最优的磁盘访问策略,以及磁盘的寻址策略,这里是在底层操做系统层面考虑的。
  3. 设计合理的磁盘存储数据块,以及访问这些数据块的策略,这里是在应用层面考虑的。如咱们能够给存放的数据设计索引,经过寻址索引来加快和减小磁盘的访问,还有能够采用异步和非阻塞的方式加快磁盘的访问效率。
  4. 应用合理的 RAID 策略提高磁盘 IO,每种 RAID 的区别咱们能够用下表所示:

表 2.RAID 策略
磁盘阵列 说明
RAID 0 数据被平均写到多个磁盘阵列中,写数据和读数据都是并行的,因此磁盘的 IOPS 能够提升一倍。
RAID 1 RAID 1 的主要做用是可以提升数据的安全性,它将一份数据分别复制到多个磁盘阵列中。并不能提高 IOPS 可是相同的数据有多个备份。一般用于对数据安全性较高的场合中。
RAID 5 这中设计方式是前两种的折中方式,它将数据平均写到全部磁盘阵列总数减一的磁盘中,往另一个磁盘中写入这份数据的奇偶校验信息。若是其中一个磁盘损坏,能够经过其它磁盘的数据和这个数据的奇偶校验信息来恢复这份数据。
RAID 0+1 如名字同样,就是根据数据的备份状况进行分组,一份数据同时写到多个备份磁盘分组中,同时多个分组也会并行读写。

网络 I/O 优化

网络 I/O 优化一般有一些基本处理原则:

  1. 一个是减小网络交互的次数:要减小网络交互的次数一般咱们在须要网络交互的两端会设置缓存,好比 Oracle 的 JDBC 驱动程序,就提供了对查询的 SQL 结果的缓存,在客户端和数据库端都有,能够有效的减小对数据库的访问。关于 Oracle JDBC 的内存管理能够参考《 Oracle JDBC 内存管理》。除了设置缓存还有一个办法是,合并访问请求:如在查询数据库时,咱们要查 10 个 id,我能够每次查一个 id,也能够一次查 10 个 id。再好比在访问一个页面时经过会有多个 js 或 css 的文件,咱们能够将多个 js 文件合并在一个 HTTP 连接中,每一个文件用逗号隔开,而后发送到后端 Web 服务器根据这个 URL 连接,再拆分出各个文件,而后打包再一并发回给前端浏览器。这些都是经常使用的减小网络 I/O 的办法。
  2. 减小网络传输数据量的大小:减小网络数据量的办法一般是将数据压缩后再传输,如 HTTP 请求中,一般 Web 服务器将请求的 Web 页面 gzip 压缩后在传输给浏览器。还有就是经过设计简单的协议,尽可能经过读取协议头来获取有用的价值信息。好比在代理程序设计时,有 4 层代理和 7 层代理都是来尽可能避免要读取整个通讯数据来取得须要的信息。
  3. 尽可能减小编码:一般在网络 I/O 中数据传输都是以字节形式的,也就是一般要序列化。可是咱们发送要传输的数据都是字符形式的,从字符到字节必须编码。可是这个编码过程是比较耗时的,因此在要通过网络 I/O 传输时,尽可能直接以字节形式发送。也就是尽可能提早将字符转化为字节,或者减小字符到字节的转化过程。
  4. 根据应用场景设计合适的交互方式:所谓的交互场景主要包括同步与异步阻塞与非阻塞方式,下面将详细介绍。

同步与异步

所谓同步就是一个任务的完成须要依赖另一个任务时,只有等待被依赖的任务完成后,依赖的任务才能算完成,这是一种可靠的任务序列。要么成功都成功,失败都失败,两个任务的状态能够保持一致。而异步是不须要等待被依赖的任务完成,只是通知被依赖的任务要完成什么工做,依赖的任务也当即执行,只要本身完成了整个任务就算完成了。至于被依赖的任务最终是否真正完成,依赖它的任务没法肯定,因此它是不可靠的任务序列。咱们能够用打电话和发短信来很好的比喻同步与异步操做。

在设计到 IO 处理时一般都会遇到一个是同步仍是异步的处理方式的选择问题。由于同步与异步的 I/O 处理方式对调用者的影响很大,在数据库产品中都会遇到这个问题。由于 I/O 操做一般是一个很是耗时的操做,在一个任务序列中 I/O 一般都是性能瓶颈。可是同步与异步的处理方式对程序的可靠性影响很是大,同步可以保证程序的可靠性,而异步能够提高程序的性能,必须在可靠性和性能之间作个平衡,没有完美的解决办法。

阻塞与非阻塞

阻塞与非阻塞主要是从 CPU 的消耗上来讲的,阻塞就是 CPU 停下来等待一个慢的操做完成 CPU 才接着完成其它的事。非阻塞就是在这个慢的操做在执行时 CPU 去干其它别的事,等这个慢的操做完成时,CPU 再接着完成后续的操做。虽然表面上看非阻塞的方式能够明显的提升 CPU 的利用率,可是也带了另一种后果就是系统的线程切换增长。增长的 CPU 使用时间能不能补偿系统的切换成本须要好好评估。

两种的方式的组合

组合的方式能够由四种,分别是:同步阻塞、同步非阻塞、异步阻塞、异步非阻塞,这四种方式都对 I/O 性能有影响。下面给出分析,并有一些经常使用的设计用例参考。


表 3. 四种组合方式
组合方式 性能分析
同步阻塞 最经常使用的一种用法,使用也是最简单的,可是 I/O 性能通常不好,CPU 大部分在空闲状态。
同步非阻塞 提高 I/O 性能的经常使用手段,就是将 I/O 的阻塞改为非阻塞方式,尤为在网络 I/O 是长链接,同时传输数据也不是不少的状况下,提高性能很是有效。
这种方式一般能提高 I/O 性能,可是会增长 CPU 消耗,要考虑增长的 I/O 性能能不能补偿 CPU 的消耗,也就是系统的瓶颈是在 I/O 仍是在 CPU 上。
异步阻塞 这种方式在分布式数据库中常常用到,例如在网一个分布式数据库中写一条记录,一般会有一份是同步阻塞的记录,而还有两至三份是备份记录会写到其它机器上,这些备份记录一般都是采用异步阻塞的方式写 I/O。
异步阻塞对网络 I/O 可以提高效率,尤为像上面这种同时写多份相同数据的状况。
异步非阻塞 这种组合方式用起来比较复杂,只有在一些很是复杂的分布式状况下使用,像集群之间的消息同步机制通常用这种 I/O 组合方式。如 Cassandra 的 Gossip 通讯机制就是采用异步非阻塞的方式。
它适合同时要传多份相同的数据到集群中不一样的机器,同时数据的传输量虽然不大,可是却很是频繁。这种网络 I/O 用这个方式性能能达到最高。

虽然异步和非阻塞可以提高 I/O 的性能,可是也会带来一些额外的性能成本,例如会增长线程数量从而增长 CPU 的消耗,同时也会致使程序设计的复杂度上升。若是设计的不合理的话反而会致使性能降低。在实际设计时要根据应用场景综合评估一下。

下面举一些异步和阻塞的操做实例:

在 Cassandra 中要查询数据一般会往多个数据节点发送查询命令,可是要检查每一个节点返回数据的完整性,因此须要一个异步查询同步结果的应用场景,部分代码以下:


清单 3.异步查询同步结果
				 
 class AsyncResult implements IAsyncResult{ 
    private byte[] result_; 
    private AtomicBoolean done_ = new AtomicBoolean(false); 
    private Lock lock_ = new ReentrantLock(); 
    private Condition condition_; 
    private long startTime_; 
    public AsyncResult(){        
        condition_ = lock_.newCondition();// 建立一个锁
        startTime_ = System.currentTimeMillis(); 
    }    
 /*** 检查须要的数据是否已经返回,若是没有返回阻塞 */ 
 public byte[] get(){ 
        lock_.lock(); 
        try{ 
            if (!done_.get()){condition_.await();} 
        }catch (InterruptedException ex){ 
            throw new AssertionError(ex); 
        }finally{lock_.unlock();} 
        return result_; 
 } 
 /*** 检查须要的数据是否已经返回 */ 
    public boolean isDone(){return done_.get();} 
 /*** 检查在指定的时间内须要的数据是否已经返回,若是没有返回抛出超时异常 */ 
    public byte[] get(long timeout, TimeUnit tu) throws TimeoutException{ 
        lock_.lock(); 
        try{            boolean bVal = true; 
            try{ 
                if ( !done_.get() ){ 
           long overall_timeout = timeout - (System.currentTimeMillis() - startTime_); 
                    if(overall_timeout > 0)// 设置等待超时的时间
                        bVal = condition_.await(overall_timeout, TimeUnit.MILLISECONDS); 
                    else bVal = false; 
                } 
            }catch (InterruptedException ex){ 
                throw new AssertionError(ex); 
            } 
            if ( !bVal && !done_.get() ){// 抛出超时异常
                throw new TimeoutException("Operation timed out."); 
            } 
        }finally{lock_.unlock();      } 
        return result_; 
 } 
 /*** 该函数拱另一个线程设置要返回的数据,并唤醒在阻塞的线程 */ 
    public void result(Message response){        
        try{ 
            lock_.lock(); 
            if ( !done_.get() ){                
                result_ = response.getMessageBody();// 设置返回的数据
                done_.set(true); 
                condition_.signal();// 唤醒阻塞的线程
            } 
        }finally{lock_.unlock();}        
    }    
 } 

 

总结

本文阐述的内容较多,从 Java 基本 I/O 类库结构开始提及,主要介绍了磁盘 I/O 和网络 I/O 的基本工做方式,最后介绍了关于 I/O 调优的一些方法。


参考资料

学习

  • 查看文章 《深刻分析Java中文编码问题》(developerWorks,2011 年 7 月):详细介绍 Java 中编码问题出现的根本缘由,你将了解到:Java 中常常遇到的几种编码格式的区别;Java 中常常须要编码的场景;出现中文问题的缘由分析;在开发 Java web 程序时可能会存在编码的几个地方,一个 HTTP 请求怎么控制编码格式?如何避免出现中文问题? 

  • Oracle JDBC内存管理》:这里详细分析 Oracle JDBC 内存管理的处理方式。 

  • 《Jetty 的工做原理和与 Tomcat 的比较》:这里介绍了 Jetty 是如何使用 NIO 技术处理 HTTP 链接请求的,以及与 Tomcat 处理有何不一样之处。 

  • Java I/O Performance:sun.com 上的文章,介绍了一些 I/O 调优的基本方法。 

  • Java NIO.2:这里介绍了 JDK7 里面的新的 I/O 技术,能够参考学习下。 

  • Understanding Disk I/O:这里介绍了一点关于磁盘 I/O 一些检测和调优方法,本文也引用了一些知识点。 

  • developerWorks Java 技术专区:这里有数百篇关于 Java 编程各个方面的文章。 
相关文章
相关标签/搜索