第11讲|Java提供了哪些IO方式?NIO如何实现多路复用?

IO一直是软件开发中的核心部分之一,伴随着海量数据增长和分布式系统的发展,IO扩展能力愈发重要。幸运的是,Java平台IO机制经过不断完善,虽然在某些方面仍有不足,但已经在实践中证明了其构建高扩展性应用的能力。

今天我要问你的问题是,Java提供了哪些IO方式? NIO如何实现多路复用?

典型回答

Java IO方式有很多种,基于不同的IO抽象模型和交互方式,可以进行简单区分。

首先,传统的java.io包,它基于流模型实现,提供了我们最熟知的一些IO功能,比如File抽象、输入输出流等。交互方式是同步、阻塞的方式,也就是说,在读取输入流或者写入输出流时,在读、写动作完成之前,线程会一直阻塞在那里,它们之间的调用是可靠的线性顺序。

java.io包的好处是代码比较简单、直观,缺点则是IO效率和扩展性存在局限性,容易成为应用性能的瓶颈。

很多时候,人们也把java.net下面提供的部分网络API,比如Socket、ServerSocket、HttpURLConnection也归类到同步阻塞IO类库,因为网络通信同样是IO行为。

第二,在Java 1.4中引入了NIO框架(java.nio包),提供了Channel、Selector、Buffer等新的抽象,可以构建多路复用的、同步非阻塞IO程序,同时提供了更接近操作系统底层的高性能数据操作方式。

第三,在Java 7中,NIO有了进一步的改进,也就是NIO 2,引入了异步非阻塞IO方式,也有很多人叫它AIO(Asynchronous IO)。异步IO操作基于事件和回调机制,可以简单理解为,应用操作直接返回,而不会阻塞在那里,当后台处理完成,操作系统会通知相应线程进行后续工作。

考点分析

我上面列出的回答是基于一种常见分类方式,即所谓的BIO、NIO、NIO 2(AIO)。

在实际面试中,从传统IO到NIO、NIO 2,其中有很多地方可以扩展开来,考察点涉及方方面面,比如:

IO的内容比较多,专栏一讲很难能够说清楚。IO不仅仅是多路复用,NIO 2也不仅仅是异步IO,尤其是数据操作部分,会在专栏下一讲详细分析。

知识扩展

首先,需要澄清一些基本概念:

不能一概而论认为同步或阻塞就是低效,具体还要看应用和系统特征。

对于java.io,我们都非常熟悉,我这里就从总体上进行一下总结,如果需要学习更加具体的操作,你可以通过教程等途径完成。总体上,我认为你至少需要理解:

下面是我整理的一个简化版的类图,阐述了日常开发应用较多的类型和结构关系。



1.Java NIO概览

首先,熟悉一下NIO的主要组成部分:

Charset.defaultCharset().encode("Hello world!"));

2.NIO能解决什么问题?

下面我通过一个典型场景,来分析为什么需要NIO,为什么需要多路复用。设想,我们需要实现一个服务器应用,只简单要求能够同时服务多个客户端请求即可。

使用java.io和java.net中的同步、阻塞式API,可以简单实现。

public class DemoServer extends Thread {
    private ServerSocket serverSocket;
    public int getPort() {
        return  serverSocket.getLocalPort();
    }
    public void run() {
        try {
            serverSocket = new ServerSocket(0);
            while (true) {
                Socket socket = serverSocket.accept();
                RequestHandler requestHandler = new RequestHandler(socket);
                requestHandler.start();
            }
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            if (serverSocket != null) {
                try {
                    serverSocket.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
                ;
            }
        }
    }
    public static void main(String[] args) throws IOException {
        DemoServer server = new DemoServer();
        server.start();
        try (Socket client = new Socket(InetAddress.getLocalHost(), server.getPort())) {
            BufferedReader bufferedReader = new BufferedReader(new                   InputStreamReader(client.getInputStream()));
            bufferedReader.lines().forEach(s -> System.out.println(s));
        }
    }
 }
// 简化实现,不做读取,直接发送字符串
class RequestHandler extends Thread {
    private Socket socket;
    RequestHandler(Socket socket) {
        this.socket = socket;
    }
    @Override
    public void run() {
        try (PrintWriter out = new PrintWriter(socket.getOutputStream());) {
            out.println("Hello world!");
            out.flush();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
 }

其实现要点是:

这样,一个简单的Socket服务器就被实现出来了。

思考一下,这个解决方案在扩展性方面,可能存在什么潜在问题呢?

大家知道Java语言目前的线程实现是比较重量级的,启动或者销毁一个线程是有明显开销的,每个线程都有单独的线程栈等结构,需要占用非常明显的内存,所以,每一个Client启动一个线程似乎都有些浪费。

那么,稍微修正一下这个问题,我们引入线程池机制来避免浪费。

serverSocket = new ServerSocket(0);
executor = Executors.newFixedThreadPool(8);
 while (true) {
    Socket socket = serverSocket.accept();
    RequestHandler requestHandler = new RequestHandler(socket);
    executor.execute(requestHandler);
}

这样做似乎好了很多,通过一个固定大小的线程池,来负责管理工作线程,避免频繁创建、销毁线程的开销,这是我们构建并发服务的典型方式。这种工作方式,可以参考下图来理解。



如果连接数并不是非常多,只有最多几百个连接的普通应用,这种模式往往可以工作的很好。但是,如果连接数量急剧上升,这种实现方式就无法很好地工作了,因为线程上下文切换开销会在高并发时变得很明显,这是同步阻塞方式的低扩展性劣势。

NIO引入的多路复用机制,提供了另外一种思路,请参考我下面提供的新的版本。

public class NIOServer 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);
            // 注册到Selector,并说明关注点
            serverSocket.register(selector, SelectionKey.OP_ACCEPT);
            while (true) {
                selector.select();// 阻塞等待就绪的Channel,这是关键点之一
                Set<SelectionKey> selectedKeys = selector.selectedKeys();
                Iterator<SelectionKey> iter = selectedKeys.iterator();
                while (iter.hasNext()) {
                    SelectionKey key = iter.next();
                   // 生产系统中一般会额外进行就绪状态检查
                    sayHelloWorld((ServerSocketChannel) key.channel());
                    iter.remove();
                }
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
    private void sayHelloWorld(ServerSocketChannel server) throws IOException {
        try (SocketChannel client = server.accept();) {          client.write(Charset.defaultCharset().encode("Hello world!"));
        }
    }
   // 省略了与前面类似的main
}

这个非常精简的样例掀开了NIO多路复用的面纱,我们可以分析下主要步骤和元素:

可以看到,在前面两个样例中,IO都是同步阻塞模式,所以需要多线程以实现多任务处理。而NIO则是利用了单线程轮询事件的机制,通过高效地定位就绪的Channel,来决定做什么,仅仅select阶段是阻塞的,可以有效避免大量客户端连接时,频繁线程切换带来的问题,应用的扩展能力有了非常大的提高。下面这张图对这种实现思路进行了形象地说明。



在Java 7引入的NIO 2中,又增添了一种额外的异步IO模式,利用事件和回调,处理Accept、Read等操作。 AIO实现看起来是类似这样子:

AsynchronousServerSocketChannel serverSock =        AsynchronousServerSocketChannel.open().bind(sockAddr);
serverSock.accept(serverSock, new CompletionHandler<>() { //为异步操作指定CompletionHandler回调函数
    @Override
    public void completed(AsynchronousSocketChannel sockChannel, AsynchronousServerSocketChannel serverSock) {
        serverSock.accept(serverSock, this);
        // 另外一个 write(sock,CompletionHandler{})
        sayHelloWorld(sockChannel, Charset.defaultCharset().encode
                ("Hello World!"));
    }
  // 省略其他路径处理方法...
});

鉴于其编程要素(如Future、CompletionHandler等),我们还没有进行准备工作,为避免理解困难,我会在专栏后面相关概念补充后的再进行介绍,尤其是Reactor、Proactor模式等方面将在Netty主题一起分析,这里我先进行概念性的对比:

今天我初步对Java提供的IO机制进行了介绍,概要地分析了传统同步IO和NIO的主要组成,并根据典型场景,通过不同的IO模式进行了实现与拆解。专栏下一讲,我还将继续分析Java IO的主题。

一课一练

关于今天我们讨论的题目你做到心中有数了吗?留一道思考题给你,NIO多路复用的局限性是什么呢?你遇到过相关的问题吗?

请你在留言区写写你对这个问题的思考,我会选出经过认真思考的留言,送给你一份学习鼓励金,欢迎你与我一起讨论。

你的朋友是不是也在准备面试呢?你可以“请朋友读”,把今天的题目分享给好友,或许你能帮到他。