一篇文章理解BIO与NIO(一)1.0

BIO与NIO(阻塞与非阻塞)

IO的本质

Java的I/O本质是Java应用程序与操作系统内核进行数据交互,Java的I/O底层代码实现了对操作系统指令的封装。FileInputStream的read0方法,它有native修饰,我们在代码中并不能看到它的实现原理。本文重点讲网络I/O,因为阻塞和非阻塞主要发生在网络I/O中。这里我抛出俩个问题:

  1. 阻塞发生在什么地方(既然实现原理我们看不到,肯定和我们代码没有关系,要从操作系统层面去理解)。
  2. 为什么阻塞和非阻塞主要发生在网络I/O中。
    一篇文章理解BIO与NIO(一)1.0_第1张图片

BI/O实现

我们先上代码

try (
				//服务按Socket,接收客户端请求,建立连接,返回Socket,进行数据通信
                ServerSocket serverSocket = new ServerSocket(6666)
) {
    System.out.println("BIO Server has started ,listening on port: " + serverSocket.getLocalSocketAddress());
    //死循环接收客户端请求,在serverSocket.accept()方法阻塞
    while (!Thread.interrupted()) {
        Socket clientSocket = serverSocket.accept();
        System.out.println("Connection From " + clientSocket.getRemoteSocketAddress());
        try (
                Scanner scanner = new Scanner(clientSocket.getInputStream());
        ) {
        	//死循环接收客户端数据,在scanner.nextLine();方法阻塞
            while (scanner.hasNext()) {
                String request = scanner.nextLine();
                if ("bye".equals(request)) {
                    break;
                }
                System.out.println(String.format("[%s] From %s : %s", Thread.currentThread().getName(), clientSocket.getRemoteSocketAddress(), request));
                String response = String.format("[%s] From BIO Server :%s \r\n", Thread.currentThread().getName(), request);
                clientSocket.getOutputStream().write(response.getBytes());
            }
        }
    }
} catch (Exception e) {
    e.printStackTrace();
}

到这里我们实现了最简单的一个BI/O通信,我们运行起来跑跑试试看。windows可以用telnet,如果指令不可用,在服务管理那里打开telnet服务即可。

telnet 127.0.0.1 6666
  1. 到这里我们会发现有一个问题,当我们要建立第二个连接时,发生了阻塞,不会受到服务端的相应消息。当我们退出第一个连接时,第二个连接就可以顺利相应了。
  2. 这是因为我们代码主要在main线程中执行,只有一个线程,当第一个请求接收到后,main线程就阻塞在String request = scanner.nextLine()中,无法再接收请求。
  3. 现实中这种一对一的通信基本不可能,服务端一定要能满足多客户端的连接。我们可以提取发生阻塞的代码块,放到另外一个线程中去处理,一个请求我们就一个线程去处理。
ExecutorService executor = new ThreadPoolExecutor(0,
                200, 3, TimeUnit.SECONDS,
                new SynchronousQueue<>(), Executors.defaultThreadFactory(), new ThreadPoolExecutor.AbortPolicy());

try (
        ServerSocket serverSocket = new ServerSocket(7777)
) {
    System.out.println("BIO Server has started ,listening on port: " + serverSocket.getLocalSocketAddress());
    while (!Thread.interrupted()) {
        Socket clientSocket = serverSocket.accept();
        System.out.println("Connection From " + clientSocket.getRemoteSocketAddress());
        executor.submit(() -> {
            try (
                    Scanner scanner = new Scanner(clientSocket.getInputStream());
            ) {
                while (scanner.hasNext()) {
                    String request = scanner.nextLine();
                    if ("bye".equals(request)) {
                        break;
                    }
                    System.out.println(String.format("[%s] From %s : %s", Thread.currentThread().getName(), clientSocket.getRemoteSocketAddress(), request));
                    String response = String.format("[%s] From BIO Server :%s \r\n", Thread.currentThread().getName(), "finish");
                    clientSocket.getOutputStream().write(response.getBytes());
                }
            } catch (Exception ex) {
                ex.printStackTrace();
            }
        });
    }
} catch (Exception e) {
    e.printStackTrace();
}finally {
    executor.shutdown();
}

上面代码是通过线程池的方式来实现的,线程池的参数为初始线程数为0,最大线程数为200,空闲生命周期为3秒,设置没有数据缓冲的队列,拒绝策略是抛弃处理并且抛出异常。(以上设置只是为了突出BI/O的缺点)
让我们运行起来看看,是否可以处理多个客户端请求。下面代码粗略的模拟客户端请求。

//设置并发数量
int tps = 200;
//创建线程池,初始线程数量为并发数量
ExecutorService executor = new ThreadPoolExecutor(tps, tps, 5, TimeUnit.SECONDS,
        new SynchronousQueue<>(), Executors.defaultThreadFactory(), new ThreadPoolExecutor.AbortPolicy());

//循环发起请求
for (int i = 0; i < tps; i++) {
    //一秒中完成
    int sleep = 1000 / tps;
    TimeUnit.MILLISECONDS.sleep(sleep);

    executor.submit(() -> {
        try (
                Socket socket = new Socket("127.0.0.1", 7777);

                OutputStream os = socket.getOutputStream();
                OutputStreamWriter osw = new OutputStreamWriter(os, "UTF-8");

                InputStream is = socket.getInputStream();
                InputStreamReader isr = new InputStreamReader(is, "UTF-8");
                BufferedReader br = new BufferedReader(isr)
        ) {
            osw.write("Hello World");
            osw.flush();

            socket.shutdownOutput();

            String response;
            while ((response = br.readLine()) != null) {
                System.out.println(response);
            }
        } catch (Exception ex) {
            ex.printStackTrace();
        }
    });
}
//关闭线程池
executor.shutdown();

我们打开jconsole.exe,在jdk的安装目录bin下 ,主要监控 JVM 的概览、内存、线程、类、vm概要、MBean等内容。选择对应的类名称,查看ServerSocket服务的线程消耗。
一篇文章理解BIO与NIO(一)1.0_第2张图片
一篇文章理解BIO与NIO(一)1.0_第3张图片
一篇文章理解BIO与NIO(一)1.0_第4张图片
客户端请求代码执行之后,发现我们的BI/O服务不仅能够满足多客户端请求,并且服务线程最大线程数量也在可控范围,当我们将并发数量提高到1000时,他的线程数量也变化不大,我们是不是已经完美实现了高并发情况下线程数量可控与响应时间短。答案是否定的,我们来加一行代码再看看会发生什么。

//延时1秒中,再发送数据
TimeUnit.SECONDS.sleep(1);

osw.write("Hello World");
osw.flush();

我们只要在输出流执行前,暂停1秒。再次运行看看我们的服务器的线程情况。TPS先从200开始
一篇文章理解BIO与NIO(一)1.0_第5张图片
这线程的开销就不那么美观了,一定情况下是不可接受的。在无延时的情况下,客户端建立连接,发送数据,线程完成任务后,线程释放回线程池,线程利用率高。但是如果发生延时,工作线程一直等待数据,占用线程,高并发下,必然会导致线程数量开销大幅增加。但是现实情况,网络通信中的网络时延是普遍的,不可避免的。
一篇文章理解BIO与NIO(一)1.0_第6张图片
发送延时:主机和路由器发送数据帧所需要的时间,也就是从发送该帧的第一个比特开始,直到最后一个比特发送完毕所需要的时间。
传播延时:主要指电磁波在信道中传播一定距离所花费的时间。
处理延时:主机或网络节点(节点交换机和路由器)处理分组所花费的时间,包括对分组首部的分析,从分组提取数据部分,进行差错检验和查找合适路由等。
排队延时:指分组进入网络节点后,需先在其输入队列中排队等待处理,以及在处理完毕后再输出队列等待转发的时间。

NI/O实现

JDK1.4后有了NIO,我们先上代码

//管道
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
//非阻塞
serverSocketChannel.configureBlocking(false);
serverSocketChannel.bind(new InetSocketAddress(8888));
System.out.println("NIO Server has started ,listening on port:" + serverSocketChannel.getLocalAddress());

//观察者模式 选择器
Selector selector = Selector.open();
//每个客户端来了,就把客户端连接就注册到Selector选择器中,默认是accepted
//相当于Map
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);

//数组的形式实现
//缓存区
ByteBuffer buffer = ByteBuffer.allocate(1024);

while (!Thread.interrupted()) {
    int select = selector.select();
    if (select == 0) {
        continue;
    }
    Set<SelectionKey> selectionKeys = selector.selectedKeys();
    Iterator<SelectionKey> iterators = selectionKeys.iterator();
    while (iterators.hasNext()) {
        SelectionKey key = iterators.next();
        //判断selectionKey的channel的状态
        if (key.isAcceptable()) {
            ServerSocketChannel serverSocket = (ServerSocketChannel) key.channel();
            SocketChannel socketChannel = serverSocket.accept();
            //客户端的来源打印出来
            System.out.println("Connection From " + socketChannel.getRemoteAddress());
            socketChannel.configureBlocking(false);
            //修改为可读状态
            socketChannel.register(selector, SelectionKey.OP_READ);
        }
        if (key.isReadable()) {
            SocketChannel socketChannel = (SocketChannel) key.channel();
            //数据的交互以buffer为中间桥梁
            socketChannel.read(buffer);
            String request = new String(buffer.array()).trim();
            System.out.println(String.format("[%s] From %s : %s", Thread.currentThread().getName(), socketChannel.getRemoteAddress(), request));
            String response = String.format("[%s] From BIO Server :%s \r\n", Thread.currentThread().getName(), "finish");
            socketChannel.write(ByteBuffer.wrap(response.getBytes()));
            buffer.clear();
            socketChannel.close();
        }
        iterators.remove();
    }
}

我们把上述代码放到main线程中执行,和测试BI/O代码一样,测试下来它线程开销情况如图
一篇文章理解BIO与NIO(一)1.0_第7张图片
它的线程开销没有变化,请求也处理完成,简直完美!看日志,发现都只有一个main线程在运行。客户端的延时增加到3s,对它也没有影响。为什么会这样呢。

阻塞在什么地方

这里我们引用Richard Stevens的“UNIX® Network Programming Volume 1, Third Edition: The Sockets Networking ”,6.2节“I/O Models ”。
一篇文章理解BIO与NIO(一)1.0_第8张图片
上面就是我们的用户进程发起一个read指令,操作系统内核空间接收到指令后,开始准备数据,用户进程就在等待内核空间,这期间线程一直在阻塞状态。当我们的应用线程需要从网卡中获取网络数据时,网络延时的越长,我们工作线程的阻塞时间就越长。

非阻塞如何实现

非阻塞I/O模型

一篇文章理解BIO与NIO(一)1.0_第9张图片
用户进程发出read操作时,如果内核空间中的数据还没有准备好,那么它并不会阻塞用户进程,而是立刻返回一个error。从用户进程角度讲 ,它发起一个read操作后,并不需要等待,而是马上就得到了一个结果。用户进程判断结果是一个error时,它就知道数据还没有准备好,就可以去干其他事。过一会它再次发送read操作。一旦内核空间中的数据准备好了,并且又再次收到了用户进程的system call,那么它马上就将数据拷贝到了用户内存,然后返回。

多路复用I/O

一篇文章理解BIO与NIO(一)1.0_第10张图片
我们也可以称这种IO方式为事件驱动IO(event driven IO)。单个用户进程可以同时处理多个网络连接的IO。它的基本原理就是select/epoll这个function会不断的轮询所负责的所有socket,当某个socket有数据到达了,就通知用户进程。我们代码中体现主要是三个组件Selector、Channel、Buffer。

小结

阻塞是发生在内核空间准备数据的时候,NI/O就是用户进程不会等待内核空间准备数据。虽然多了每次询问的动作,但是对于减少的阻塞时间,是十分划算的。但是如果不是高并发环境下,建议还是使用多路复用+BI/O的方式,提高CPU的利用率。

你可能感兴趣的:(网络,多线程,nio,并发编程,java)