深入分析Java I/O的工作机制

Java的I/O类库级别框架,磁盘IO和网络IO的工作机制,Java Socket工作方式,NIO介绍

Posted by Wanglizhi on May 22, 2016

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

Java从1.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操作接口输入和输出分别是InputStream和OutputStream,两者都被划分成若干子类,每个子类处理不同的操作类型。

1、操作数据的方式是可以组合使用的

OutputStream out = new BufferedOutputStream(new ObjectOutputStream("fileName"));

2、流最终写到的地址必须要指定,要么写到磁盘,要么写到网络

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

不管是磁盘还是网络传输,最小的存储单元都是字节,而不是字符,添加操作字符的接口主要是为了操作方便。

write(char chuff[], int off, int len);

int read(char chuff[], int off, int len);

字节与字符的转化接口

InputStreamReader类是字节到字符的转化桥梁,转化过程中需要指定编码字符集,否则采用默认字符集。OutputStreamWriter类似

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();
}
//注意FileReader继承了InputStreamReader类

磁盘I/O工作机制

几种访问文件的方式

因为磁盘设备是由操作系统管理的,应用程序要访问物理设备只能通过系统调用的方式来工作,读写分别对应read()和write()两个系统调用。系统调用就存在内核空间地址和用户空间地址切换的问题。

1、标准访问文件方式

read() -> 内核高速缓存 -> 没有则读取磁盘,然后缓存在系统中

write() -> 内核高速缓存 -> 对用户来说写操作已经完成,至于什么时候写到磁盘由操作系统决定,除非显式调用sync

2、直接I/O方式

直接I/O就是应用程序直接访问磁盘数据,不经多内核数据缓冲区,目的是减少一次从内核缓冲区到用户程序缓存的数据复制。但是每次都读磁盘,非常缓慢。通常直接I/O与异步I/O结合使用,会得到比较好的性能

3、同步访问文件方式

读和写都是同步操作,与标准访问不同的是,只有当数据被成功写到磁盘时,才返回给应用程序成功标志

4、异步访问文件方式

异步访问就是发出请求后,线程会接着去处理其他事情,而不是阻塞等待,当请求数据返回后继续处理下面的操作。

5、内存映射方式

内存映射方式指操作系统将内存中的某一块区域与磁盘中的文件关联起来,当要访问内存中一段数据时,转换为访问文件的某一段数据。

Java访问磁盘文件

Java序列化技术

Java序列化就是将一个对象转化成一串二进制表示的字节数组,通过保存或转移这些字节数据来达到持久化的目的。需要持久化,对象必须继承java.io.Serializable接口。

总结:

  • 当父类继承Serializable接口,所有子类都可以被序列化
  • 子类实现了Serializable接口,父类没有,父类中的属性不能被序列化(不报错,数据会丢失),但是子类中的属性仍能正确序列化
  • 如果序列化的属性是对象,这个对象也必须可序列化,否则会报错
  • 在反序列化时,如果对象的属性有修改或删减,修改的部分会丢失,但不会报错
  • 如果serialVersionUID被修改,反序列化会失败

网络I/O工作机制

TCP状态转化

  1. CLOSED:起始点,在超时或者连接关闭时进入此状态
  2. LISTEN:Server端在等待连接时的状态,Server端为此要调用Socket、bind、listen函数,就能进入此状态,等待客户端连接
  3. SYN-SENT:客户端发起连接,发送SYN给服务器端。如果服务器端不能连接,则直接进入CLOSED状态。
  4. SYN-RCVD:与3对应,服务器端接受客户端SYN请求,服务器由LISTEN状态进入SYN-RCVD状态,同时回应一个ACK,发送SYN给客户端。另外一种情况是,客户端在发起SYN的同时接收到服务器端的SYN请求,客户端会由SYN-SENT到SYN-RCVD状态
  5. ESTABLISHED:服务器和客户端在完成三次握手后进入状态,说明已经可以开始传输数据了
  6. FIN-WAIT-1:主动关闭的一方,由状态5进入此状态,具体动作是发送FIN给对方
  7. FIN-WAIT-2:主动关闭的一方,接收到对方的FIN ACK,进入此状态。由此不能再接收到对方的数据,但是能够向对方发送数据
  8. CLOSE-WAIT:接收到FIN以后,被动关闭的一方进入此状态。具体动作是接收到FIN同时发送ACK
  9. LAST-ACK:被动关闭的一方,发起关闭请求,由状态8进入此状态。具体动作是发送FIN给对方,同时在接收到ACK时进入CLOSED状态。
  10. CLOSING:两边同时发起关闭请求,会由FIN-WAIT1进入此状态。具体动作是接收到FIN请求,同时响应一个ACK
  11. TIME-WAIT:有三个状态可以转换为此状态
  • 由FIN-WAIT-2转换到TIME-WAIT,在双方不同时发起FIN情况下,主动关闭一方在接收到FIN后进入的状态
  • 由CLOSING转换到TIME-WAIT,双头同时发起关闭,同时接收FIN并做了ACK,由CLOSING进入TIME-WAIT
  • 由FIN-WAIT-1转换到ITME-WAIT,同时接收到FIN(对方发起)和ACK(本身发起的FIN回应)

影响网络传输的因素

  • 网络带宽:带宽就是一秒钟一条物理链路最大能够传输的比特数(bit)
  • 传输距离
  • TCP拥塞控制:TCP传输方和接收方步调要一致,TCP在传输时设定一个“窗口”,这个窗口大小是由带宽和RTT(响应时间)决定的

Java Socket的工作机制

基于TCP/IP的流套接字,它是一种稳定的通信协议。

建立通信链路

客户端:创建Socket实例,操作系统会分配本地端口,并创建一个包含本地和远程地址和端口的套接字数据结构,这个数据结构一直保存在系统中知道连接关闭,三次握手完成后,Socket实例对象创建完成。

服务端:创建ServerSocket,调用accept()进入阻塞状态等待请求,当请求到来时创建一个新的套接字数据结构,关联到未完成的连接数据结构列表中,当三次握手完成后,返回Socket实例并转移到已完成列表中。

数据传输

建立连接后,服务端和客户端都会拥有一个Socket实例,每个Socket实例都有一个InputStream和OutputStream,并通过这两个对象交换数据。系统会为InputStream和OutputStream分配缓存区,数据的读写都是通过缓存区完成的。

写入端将数据写入SendQ队列,当队列填满时,数据将会被转移到另一端的Re从v中,如果RecvQ队列已满,那么write方法将会阻塞知道RecvQ队列有足够的空间容纳SendQ发送的数据。

如果两边同时传送数据可能会产生死锁,NIO将介绍如何避免这种情况。

NIO的工作方式

BIO带来的挑战

BIO即阻塞I/O,数据在写入OutputStream或者从InputStream读取时都有可能会阻塞,一旦阻塞,线程将失去CPU的使用权。对于大规模访问量和有性能要求的情况下,采用当前的网络I/O,有一些解决办法,如一个客户端一个处理线程,出现阻塞时只是一个线程阻塞而不会影响其他线程,同时采用线程池的办法来减少线程创建和回收的成本。

但是当需要几百万HTTP长连接的情况下,不可能创建那么多线程保持连接。

NIO的工作机制

NIO中的两个核心概念:Channel和Selector。典型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.OPREAD){
        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。

图中Selector可以同时坚挺一组通信信道(Channel)上的I/O状态,前提是已经注册到这些信道中。

如果有多个信道有数据,将会把这些数据分配到对应的数据Buffer中。所以关键的地方是,有一个线程来处理所有连接的数据交互,每个连接的数据交互都不是阻塞方式,所以可以同时处理大量的连接请求。

Buffer的工作方式

Buffer可以简单理解为一组基本数据类型的元素列表,通过几个变量来保存这个数据当前位置状态,即四个索引。

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

通过Channel获取的I/O数据首先要经过系统的Socket缓冲区再讲数据复制到Buffer中,缓冲区即RecvQ或SendQ队列,从操作系统缓冲区到用户缓冲区复制比较耗性能。另外一种直接操作操作系统缓冲区的方式,DirectByteBuffer,它通过Native代码操作非JVM堆栈的内存空间。

NIO的数据访问方式

1、FileChannel.transterTo, FileChannel.transferFrom

数据直接在内核空间移动,在Linux中使用sendFile系统调用。

2、FileChannel.map

将文件按照一定大小块映射为内存区域,当程序访问这个内存区域时将直接操作这个文件数据,省去了数据从内核空间向用户空间赋值的损耗。

I/O调优

磁盘I/O优化

1、性能检测

系统的I/O wait指标,通过iostat命令查看

IOPS

2、提升I/O性能

  • 增加缓存,减少磁盘访问
  • 优化磁盘管理系统,设计最有的磁盘方式策略以及磁盘寻址策略
  • 设计合理的磁盘存储数据块,以及访问这些数据块的策略
  • 应用合理的RAID策略,RAID0、RAID1、RAID5。。。

TCP网络参数调优

cat /proc/met/netstat:查看TCP的统计信息

cat /proc/net/snmp:查看当前系统的连接情况

netstat -s:查看网络的统计信息

网络I/O优化

  • 减少网络交互次数
  • 减少网络传输数据量的大小
  • 尽量减少编码

交互场景:同步与异步、阻塞与非阻塞

1、同步与异步

同步,只有等待被依赖任务完成后,依赖任务才能完成,两者任务状态可以保持一致,是一种可靠的任务序列

异步,只是通知被依赖的任务要完成什么,只要自己完成了整个任务就算完成了,是不可靠的任务序列。

类似打电话和发短信

2、阻塞和非阻塞

主要是从CPU的小号来说的,阻塞就算是CPU停下来等待慢操作完成后,才接着完成其他的事。非阻塞就算在慢执行时CPU去干别的事情,但会增加系统的线程切换。

3、两种方式的组合

  • 同步阻塞:最常用的用法,I/O性能较差,CPU大部分处于空闲
  • 同步非阻塞:在I/O长连接同时传输数据不是很多的情况下,提升性能非常有效。但是会增加CPU消耗
  • 异步阻塞:分布式数据库中,写一条记录,通常会有一份是同步阻塞的记录,两至三备份记录采用异步阻塞的方式写I/O
  • 异步非阻塞:用起来比较负责,只有在复杂的分布式情况下使用,集群之间的消息同步机制一般使用这种I/O组合方式

设计模式解析之适配器模式

Java I/O 中的是配置模式:InputStreamReader,字符和字节数据转换类,实现了Reader接口,并持有了InputStream的引用,这里是通过StreamDecoder类间接持有的,因为从byte到char要经过编码。

设计模式解析之装饰器模式

装饰器模式结构

  • Component:抽象接口
  • ConcreteComponent:实现接口的所有功能
  • Decorator:装饰器角色,持有Component对象引用
  • ConcreateDecorator:具体的装饰器实现者,负责实现装饰器角色定义的功能

Java I/O中的装饰器模式

InputStream:抽象接口

FileInputStream实现了组件的所有接口

FilterInputStream即装饰角色,持有了InputStream对象实例的引用。

BufferedInputStream是具体的装饰器实现者,给InputStream类附加了功能。

参考 深入分析Java Web技术内幕 第二章