Java程序员需要掌握的Java IO机制

作者:陌北有棵树,Java人,架构师社区合伙人!



IO(输入/输出)是指内存和外部设备之间复制数据的过程,这里的外部设备包括磁盘、终端、网络。由于在Linux中,所有的一切都被抽象为文件,包括终端设备,Socket,于是在上层共用一套IO操作也是情理之中的事情了。

这里首先对传统IO和NIO在JDK中构成做一个说明:传统的IO相关数据API在java.io,网络编程相关的API在java.net,这个时候我们在进行网络时,需要调用上述两个包中的API。

但在JDK 1.4引入了java.nio,提供了3个新的抽象:Buffer,Channel,Selector,可以理解为整合了java.iojava.net,并且为我们做了一层封装,让我们在网络编程时不需要再去调用io包中的API。关于具体是如何封装的,后面会进行说明。

传统IO

这里主要了解一下IO包的组成,IO流的本质是数据传输,并且流是单向的,所以我们也可以更形象的称呼其为IO流,IO流主要包括字节流,字符流。

那么,我们怎么来理解流这个概念呢?这是一种对数据传输很好的抽象,数据在设备传输的过程,想象成水流、电流这些概念,是不是更好理解了呢?

既然程序的本质是处理数据,而IO流又是对数据流的抽象,那么Java中一定会有处理IO流的方式。

研究如何处理之前,要先来看IO流的分类,从不同的维度有不同的划分方式:

数据类型维度:字节流、字符流

数据流向维度:输入流、输出流

这里只列出了部分IO包中类的继承关系:

NIO

基本概念

关于NIO,这里要掌握的不仅仅是技术上的实现,还有一些设计思想,都是十分值得学习和借鉴的。

首先我们要先把Java的NIO和操作系统的IO模型分开理解,可能有一些初学者会犯这个问题,Java的NIO是在操作系统之上做的封装,与我们在《Unix网络编程》中提到的五种IO模型不是一个东西,但思想上是类似的。本文讨论的范围是在Java的应用层面,截止JVM对操作系统的调用。关于更底层的,在Linux上依赖的是epoll,在Windows下依赖的是icop,这个后续会继续研究。但其实也是应该掌握的知识。

首先说一下Java NIO中新抽象的三个模型:

  • Buffer:数据容器

  • Channel:支持批量式IO操作的抽象

  • Selector:选择器

Buffer(缓冲区):

NIO的Buffer对象包含了一个缓冲区,这个缓冲区实际上就是一个类似于数组的内存块,可以写入和读取数据。之所以提供这样一个对象,封装一组API,让我们更加轻松的使用内存块,传统BIO的写入和读取的都是一个byte[]数组,对于程序编写来说是比较麻烦的

Buffer数据读取写入的步骤:

  1. 数据写入缓冲区

  2. 调用flip()方法,转换读取模式

  3. 从缓冲区读取数据

  4. 调用clear()或compact(),清除缓冲区

Buffer的三个属性:

capacity:Buffer的容量

position:分别在写入模式和读取模式时,代表写入和读取的位置

limit:在写入模式时为buffer的容量,在读取模式时为已写入的数据量

为什么Buffer能更方便的操作,其中记录了我们每个操作点。同时ByteBuffer为了提高性能,提供了两种实现:堆内存和直接内存。

使用直接内存的优势:

  1. 少一次内存拷贝:在常规文件IO或者网络IO的情况下,需要调用操作系统的API,会先把堆内存的数据复制到堆外,至于为什么要复制一份数据到堆外,我们知道JVM有GC机制,在GC过程中,会移动对象的位置,这样操作系统在读取或写入数据就找不到数据了。但这样一来,显然增加了一次内存拷贝的过程,如果我们直接通过直接内存的方式,省略掉这次非必须的内存拷贝,可以达到优化性能的目的。

    创建直接内存的API:

    public static ByteBuffer allocateDirect(int capacity) {
           return new DirectByteBuffer(capacity);
    }
  2. 降低GC压力:如果我们直接使用直接内存(堆外内存)呢?首先申请一块不受GC管理的堆外内存,但需要我们手动回收堆外内存。在java.nio.DirectByteBuffer中,有一个虚引用的Cleaner对象,执行GC时会触发Deallocator回收内存。

    private static class Deallocator
           implements Runnable
       
    {


       ......
           public void run() {
               if (address == 0) {
                   // Paranoia
                   return;
               }
               unsafe.freeMemory(address);
               address = 0;
               Bits.unreserveMemory(size, capacity);
           }

       }

虽然堆外内存好处很多,但还是建议在网络传输,文件读取等大数据量的IO密集操作时才使用,分配一些大文件大数据读取时才使用。同时设置MaxDirectMemorySize避免耗尽整个机器内存。

Channel(通道)

NIO中提供了新的网络操作API - Channel,总结起来有两个目的:简化操作和实现非阻塞操作

包含了TCP/UDP以及文件IO的相关操作:

  • java.nio.channels.FileChannel

  • java.nio.channels.DatagramChannel

  • java.nio.channels.ServerSocketChannel

  • java.nio.channels.SocketChannel

这里我们主要来看SocketChannel和ServerSocketChannel。

概括来说,可以把SocketChannel和ServerSocketChannel理解为为java.net.Socketjava.net.ServerSocket的升级版。前面说到的java NIO包的两个升级点,都体现在这里,简化了网络操作API,实现了非阻塞操作。

如果对于java.net.Socketjava.net.ServerSocket不太了解,这里做一个简单的总结:Socket代表一个具体的连接;ServerSocket:代表一个端口绑定,端口监听。

对于ServerSocket和ServerSocketChannel在阻塞方式上的不同实现,主要在于accept方法的调整:

  • ServerSocket中accept方法会一直等待,直到新的连接出现

  • ServerSocketChannel如果没有新的连接,会立即返回Null,所以需要客户端检查返回是否为Null。针对不同的操作需要增加额外的判断

    写操作:需要在一个循环中调用write方法,可能并没有写入内容就返回了。

    读操作:可能读取不到任何数据,所以需要根据返回的int值判断读取了多少字节。

Selector(选择器)

有了Buffer和Channel,就可以写出一个NIO的程序了,但是还存在一个问题,我们需要通过循环检查的方式,来判断是否有读取和写入。首先这种循环检查的方式肯定是低效的,另外,在高并发场景下也会存在问题。

于是Java NIO中抽象出了Selector的模型,用来检查一个或多个NIO Channel,能够监听当前通道是否已经可以进行数据读取或写入了。实现了单个线程可以管理多个通道(也就是多个网络连接)。

实现方式:一个线程使用Selector监听多个通道。

java.nio.channels.SelectionKey中四个常量代表四个不同类型的事件,我们可以通过名字来进行识别:

public static final int OP_READ = 1 << 0;
public static final int OP_WRITE = 1 << 2;
public static final int OP_CONNECT = 1 << 3;
public static final int OP_ACCEPT = 1 << 4;

那么Selector是如何实现一个线程处理多个通道的呢?

这里涉及到的设计思想:事件驱动机制开发人员通过Selector注册对于通道感兴趣的事件类型,线程通过监听事件来触发相应的执行代码。具体的实现代码如下:

public class NIOTest extends Thread  {
    public void run() {
        try (Selector selector = Selector.open();
             ServerSocketChannel serverSocket = ServerSocketChannel.open()) {// 创建Selector和Channel
            serverSocket.bind(new InetSocketAddress(InetAddress.getLocalHost(), 8888));
            serverSocket.configureBlocking(false);
            serverSocket.register(selector, SelectionKey.OP_ACCEPT);
            while (true) {
                selector.select();  
                Set<SelectionKey> selectedKeys = selector.selectedKeys();
                Iterator<SelectionKey> iter = selectedKeys.iterator();
                while (iter.hasNext()) {
                    SelectionKey key = iter.next(); 
                    doSomething((ServerSocketChannel) key.channel());
                    iter.remove();
                }
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
    private void doSomething(ServerSocketChannel server) throws IOException {
        try (SocketChannel client = server.accept()) { client.write(Charset.defaultCharset().encode("Hello world!"));
        }
    }

}

如何继续优化性能

如果在生产环境,我们使用一个线程去处理所有的连接通道,那么这个线程是不是就会成为了性能瓶颈呢?

下面是Java界的超级大佬Doug Lea在《Scalable IO in Java》提出的一些关于Java NIO的编程建议,首先他总结到,绝大部分网络服务,Web服务也好,分布式服务也好,都有着共同的结构流程:

  1. Read request - 通过IO读取网络中字节流

  2. Decode request - 将字节流转换为对象

  3. Process service - 业务逻辑

  4. Encode reply - 对象转换为字节流

  5. Send reply - 通过IO将字节流发到网络

基于Reactor线程模型 - 单线程版本,下图模型就是上面实现的最基础的版本抽象而来。

为了解决这个问题,首先提出的方案是引入线程池来处理业务逻辑,于是改进为下图中的单Reactor线程模型:

虽然引入了多线程,但从图中我们也可以看出,Reactor仍然是单线程的,所以我们继续改进,将Reactor拆分为MainReactor和SubReactor,MainReactor负责监听连接,SubReactor负责处理accept连接。多Reactor线程模型如下图:

参考:

Scalable IO in Java

长按订阅更多精彩▼

如有收获,点个在看,诚挚感谢