Java IO的工作机制

      IO问题可以说是当今Web应用中所面临的主要问题之一,应为当前这个海量数据时代,数据在网络中随处流动。在这个流动的过程中都涉及IO问题,可以说大部分Web应用系统的瓶颈都是I/O瓶颈。

一、Java的I/O类库的基本架构


     Java的IO操作类在包java.io下,大概有将近80个类,这些类大概可以分为以下4组:
     ●  基于字节操作的I/O接口:InputStream 和 OutputStream
     ●  基于字符操作的I/O接口:Writer 和 Reader
     ●  基于磁盘操作的I/O接口:File
     ●  基于网络操作的I/O接口:Socket
     前两组主要是传输数据的数据格式,后两组主要是传输数据的方式,虽然Socket类并不在java.io包下,但仍然将其划分在一起。I/O的核心问题要么是数据格式影响I/O操作,要么是传输方式影响I/O操作,既将什么样的数据写到什么地方的问题。IO只是人与机器或者机器与机器交互的手段,除了它们能够完成这个交互功能外,我们关注的就是如何提高它的运行效率了,而数据格式和传输方式是影响最关键的因素。
     不管是磁盘还是网络传输,最小的存储单元都是字节,而不是字符,所以I/O操作的都是字节而不是字符,但我们操作的都是字符形式,为了操作便提供一个直接写字符的I/O接口。我们知道从字符到字节必须要经过编码转换,而这个编码又非常耗时,而且会经常出现乱码问题。InputStreamReader类是从字节到字符的转化桥梁(需要指定编码字符集)StreadDecoder正是完成从字节到字符解码的实现类。例如用如下的方式读取文件时:

//FileReader继承了InputStreamReader类,实际上是读取文件流,然后通过StreamDecode解码成char
//解码字符集是默认字符集
try{
     StringBuffer sb = new StringBuffer();
     char[] buf = new char[1024];
     FileReader fr = new FileReader("file");
     while(fr.read(buf)>0){
         str.append(buf);
     }
     str.toString();
}catch (IOException e){
}

     读取和写入文件I/O操作都是调用操作系统提供的接口,因为磁盘设备是由操作系统管理的,应用程序要访问物理设备只能通过系统调用的方式来工作。读和写分别对应Read()和Write()两个系统调用。而只要是系统调用就可能存在内核空间地址和用户空间地址切换的问题,操作系统为了保护系统本身的运行安全,而将内核程序运行使用的内存空间和用户程序运行的内存空间进行隔离。但会带来从内核空间向用户空间复制数据的问题,例如:数据不一致和耗时问题,此时为了加速I/O访问,在内核空间使用缓存机制,也就是将从磁盘读取的文件按照一定的组织方式进行缓存,如果用户访问的是同一磁盘地址的空间数据。那么操作系统将从内核缓存中直接取出返回给用户程序。

二、标准访问文件的方式


       标准访问文件的方式就是当应用程序调用read()接口时,操作系统检查在内核的高速缓存中有木有需要的数据,如果已经缓存了,那么就直接从缓存中返回,如果没有,则从磁盘中读取,然后缓存在操作系统的缓存中。
       写入的方式是,用户的应用程序调用write()接口将数据从用户地址空间复制到内核地址空间的缓存中。这时对用户程序来说写操作就已经完成,至于什么时候来写到磁盘中有操作系统决定,除非显示地调用了sync同步命令。
      Java IO的工作机制_第1张图片

 三、Java Socket 的工作机制


      Socket这个概念没有对应到一个具体的实体,它描述计算机之间完成相互通信的一种抽象功能。
      Java IO的工作机制_第2张图片

     主机A的应用程序要能和主机B的应用程序通信,必须通过Socket建立连接,而建立Socket连接必须由底层TCP/IP来建立TCP连接。建立TCP连接需要底层IP来寻找网络的主机。但一台电脑上可能运行着多个应用程序,如何才能与指定的应用程序通信就要通过TCP或UDP的地址也就是端口号来指定。这样就可以通过一个Socket实例来唯一代表一个主机上的应用程序的通信链路了。
     当客户端要与服务端通信时,客户端首先要创建一个Socket实例,操作系统将为这个Socket实例分配一个没有被使用的本地端口号,并创建一个包含本地地址、远程地址和端口号的套接字数据结构,这个数据结构将一直保存在系统中直到这个连接关闭。在创建Socket实例的构造函数正确返回之前,将要进行TCP的3次握手协议,TCP握手成功后,Socket实例创建成功,否则抛出IOException异常。
     与之对应的服务端将创建一个ServerSocket实例,创建ServerSocket只需要指定一个未占用的端口即可,同时操作系统也会为ServerSocket实例创建一个底层数据结构,在这个数据结构中包含指定监听的端口号和包含监听地址的通配符,通常情况下都是“*”,即监听所有地址。之后当调用accept()方法时,将进入阻塞状态,等待客户端的请求。当一个新的请求到来时,将为这个链接创建一个新的套接字数据结构。
     数据传输是建立连接的主要目的,当连接建立成功时,服务端和客户端都会拥有一个Socket实例,每个Socket实例都有一个InputStream和OutputStream,并通过这两个对象来交换数据。同时我们也知道网络I/O都是以字节流传输的,当创建Socket对象时,操作系统将会为InputStream和OutputStream分别分配一定大小的缓存区,数据的写入和读取都是通过这个缓存区完成的。写入端将数据写入到OutputStream对应的SendQ队列中,当数据填满时,数据将被转移到另一端InputStream的RecvQ队列中,如果RecvQ已满,那么OutputStream的write就会阻塞,知道ReqvQ队列有足够的空间容纳SendQ发送的数据。特别注意的是,这个缓存区的大小和写入端与读取端的速度非常影响数据的传输效率,因为可能会出现阻塞,所以网络I/O与磁盘I/O不同的是数据的写入和读取还要有一个协调的过程,如果在两边同时传输数据可能会产生死锁。

四、BIO带来的挑战


      BIO即阻塞IO,不管是磁盘I/O还是网络I/O,数据在写入OutputStream或者从InputStream读取时都有可能出现阻塞,一旦有阻塞,线程就会失去CPU的使用权,这在当前的大规模访问量和有性能要求的情况下是不能接受的。虽然有些解决办法:例如一个客户端对应一个处理线程,出现阻塞时只是一个线程阻塞而不会影响其他线程工作,还有为了减小系统线程的开销,采用线程池的办法来减少线程创建和回收的成本,但是在一些使用场景下仍然无法解决的。如当前一些需要大量HTTP长连接的情况,如像淘宝现在使用的Web旺旺,服务端需要同时保持几百万的HTTP连接,但并不是每时每刻都在保持数据传输,在这种情况下不可能同时创建保持几百万的HTTP连接。即使线程池的数量不是问题,但我们要给某些客户端更高的服务优先级时,很难通过设计线程的优先级来完成。还有,每个客户端需要访问服务端的竞争资源,这些客户端在不同线程中,因此需要同步,要实现这种同步操作远比用单线程复杂得多。这些问题我们可以通过NIO挺松的解决。

五、NIO的工作机制


      NIO中有两个关键类:Channel和Selector,它们是NIO中的两个核心概念。Channel比Socket更加具体,可以看做是Socket的一种落地实体。Selector主要监控Channel的运行状态。还有一个Buffer类,它比Stream(只是一个概念)更加具体。Channel如果是汽车的话Buffer就是汽车上的座位。NIO引入了Channel、Buffer和Selector就是想将Socket、Stream等概念具体化,让程序员能够控制它们。例如BIO中的SendQ队列值超出队列大小时需要切割,这个过程需要用户空间和内核空间进行切换,这个切换不是你可以控制的,但在Buffer中我们可以控制Buffer的容量、是否扩展以及如何扩展。下面我们看下NIO的典型代码:

public void selector() throws IOException {
	ByteBuffer buffer = ByteBuffer.allocate(1024);
	//调用Selector的静态方法创建一个Selector选择器
	Selector selector = Selector.open();
	//创建一个服务端的Channel,绑定一个Socket
	ServerSocketChannel socketChannel = ServerSocketChannel.open();
	//将管道设置为非阻塞
	socketChannel.configureBlocking(false);
	//为通信信道绑定一个端口
	socketChannel.bind(new InetSocketAddress(8080));
	//将通信信道注册到选择器上,注册监听事件   接收就绪:SelectionKey.OP_ACCEPT
	socketChannel.register(selector, SelectionKey.OP_ACCEPT);
	while(true) {
		//获取检查已经注册在这个选择器上的所有通信信道是否有需要的事件发生
		//如果有某个事件发生就会返回所有的SelectorKey
		Set selectedKeys = selector.selectedKeys();
		Iterator iterator = selectedKeys.iterator();
		while(iterator.hasNext()) {
			SelectionKey selectionKey = iterator.next();
			//创建ready集合的方法:readyOps()返回一个bit mask,代表在相应channel上可以进行的IO操作。
			if((selectionKey.readyOps() & SelectionKey.OP_ACCEPT) == SelectionKey.OP_ACCEPT) {
				//返回该SelectionKey对应的channel。
				ServerSocketChannel channel = (ServerSocketChannel)selectionKey.channel();
				//接受到服务端的请求 创建一个新连接
				SocketChannel sc = channel.accept();
				sc.configureBlocking(false);
				//声明这个channel只对读操作感兴趣。
				sc.register(selector, SelectionKey.OP_READ);
				iterator.remove();
			}else if((selectionKey.readyOps() & SelectionKey.OP_READ) == SelectionKey.OP_READ) {
				SocketChannel sc = (SocketChannel) selectionKey.channel();
				while(true) {
					buffer.clear();
                                        //从这里读取的数据时buffer,这个buffer是我们可以控制的缓冲区
					int read = sc.read(buffer);
					if(read <= 0) {
						break;
					}
					//将写模式转变为读模式
					buffer.flip();
					System.out.println("received : " + new String(buffer.array()));
				}
				iterator.remove();
			}
		}
	}
}

      在上面的程序中,将Server端的监听连接请求的事件和处理请求的事件放在一个线程中,但是在事件应用中,我们通常会把它们放在两个线程中:一个线程专门负责监听客户端的连接请求而是以阻塞方式执行的;另一个线程专门负责处理请求,这个专门处理请求的线程才会真正采用NIO的方式,像Web服务器Tomcat和Jetty都是使用这个处理方式。
      Selector可以同时监听一组通信信道(Channel)上的I/O状态,前提是这个Selector已经注册到这些通信信道中。选择器Selector可以调用select()方法检查已经注册的通信信道上I/O是否已经准备好,如果没有至少一个信道I/O状态有变化,那么select方法会阻塞等待或在超时时间后返回0。如果有多个信道有数据,那么将会把这些数据分配到对应的数据Buffer中。所以关键的地方是,有一个线程来处理所有连接的数据交互,每个连接的数据交互都不是阻塞方式,所以可以同时处理大量的连接请求。

六、Buffer 的工作方式


     可以把Buffer简单地理解为一组基础数据类型的元素列表,它通过几个变量来保存这个数据的当前位置状态,也就是有4个索引:

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

     我们通过ByteBuffer.allocate(11)方法创建一个11个byte的数据缓冲区,初始状态如下:
     Java IO的工作机制_第3张图片

      position的位置为0,capacity和limit默认都是数组长度。当写入5个字节时,位置变化如下图:
     Java IO的工作机制_第4张图片

     这时我们需要将缓冲区的5个字节数据写入Channel通信信道,所以我们调用byteBuffer.filp()方法,数组的状态发生如下变化:
     Java IO的工作机制_第5张图片

      这时底层操作系统就可以从缓冲区中正确读取这5个字节数据并发送出去了。在下一次写数据之前我们再强调一下clear()方法,缓冲区的状态又回到初始位置。mark()方法记录当前position的前一个位置,当我们调用reset时,position将恢复mark记录下来的值。还有一点就是Channel获取的I/O数据首先要经过操作系统的Socket缓冲区,再将数据复制到Buffer中,这个操作系统的缓冲区就是底层的TCP所关联的RecvQ或者SendQ队列,从操作系统缓冲区到用户缓冲区复制数据比较耗性能,Buffer提供了另一种直接操作操作系统缓存的方式ByteBuffer.allocateDirector(size)。

七、TCP网络参数调优


     要能够建立一个TCP连接,必须知道对方的IP和一个未被使用的端口号,由于32位操作系统的端口号通常由两个字节表示,也就是只有2^16=65535个,所以一台主机能够同时建立的连接数是有限的,当然操作系统还有一些端口是受保护的,如80端口、22端口,这些端口都不能被随意占用。在Linux中可以通过查看/proc/sys/net/ipv4/ip_local-port_range文件来知道当前这个主机可以使用的端口范围。如果可使用的端口过少,在遇到大量并发请求时就会成为瓶颈,由于端口有限导致大量请求等待建立链接,这样性能就会压不上去。另外如果发下大量的TIME_WAIT的话,可以设置/proc/sys/net/ipv4/tcp_fin_timeout为更小的值来快速释放请求。

你可能感兴趣的:(Web后端)