不惑JAVA之JAVA基础 - NIO (二)

上面主要讲解了java IO类及其如何使用,本篇主要讲NIO原理。

NIO原理

阻塞IO

非阻塞与阻塞也就是我们常说的IO与NIO。在聊非阻塞前,我们先来看看网络通讯中的阻塞。

常见的网络 IO 通讯流

不惑JAVA之JAVA基础 - NIO (二)_第1张图片

上面是一个网络通信IO流程图,何为阻塞呢 ?

  • 在以上过程中若连接还没到来,那么 accept 会阻塞 , 程序运行到这里不得不挂起, CPU 转而执行其他线程。
  • 在以上过程中若数据还没准备好, read 会一样也会阻塞。

阻塞式网络 IO 的特点:多线程处理多个连接。每个线程拥有自己的栈空间并且占用一些 CPU 时间。每个线程遇到外部为准备好的时候,都会阻塞掉。阻塞的结果就是会带来大量的进程上下文切换。且大部分进程上下文切换可能是无意义的。比如假设一个线程监听一个端口,一天只会有几次请求进来,但是该 cpu 不得不为该线程不断做上下文切换尝试,大部分的切换以阻塞告终。

阻塞I/O通信模型

假如现在你对阻塞I/O已有了一定了解,我们知道阻塞I/O在调用InputStream.read()方法时是阻塞的,它会一直等到数据到来时(或超时)才会返回;同样,在调用ServerSocket.accept()方法时,也会一直阻塞到有客户端连接才会返回,每个客户端连接过来后,服务端都会启动一个线程去处理该客户端的请求。阻塞I/O的通信模型示意图如下:
不惑JAVA之JAVA基础 - NIO (二)_第2张图片

举一个例子来简单理解一下:
在IO设计中,我们从InputStream或 Reader逐字节读取数据。假设你正在处理一基于行的文本数据流,例如:

    Name: Anna
    Age: 25
    Email: [email protected]
    Phone: 1234567890
BufferedReader reader = new BufferedReader(new InputStreamReader(input));

String nameLine   = reader.readLine();
String ageLine    = reader.readLine();
String emailLine  = reader.readLine();
String phoneLine  = reader.readLine();

请注意处理状态由程序执行多久决定。换句话说,一旦reader.readLine()方法返回,你就知道肯定文本行就已读完, readline()阻塞直到整行读完,这就是原因。你也知道此行包含名称;同样,第二个readline()调用返回的时候,你知道这行包含年龄等。 正如你可以看到,该处理程序仅在有新数据读入时运行,并知道每步的数据是什么。一旦正在运行的线程已处理过读入的某些数据,该线程不会再回退数据(大多如此)。下图也说明了这条原则:
不惑JAVA之JAVA基础 - NIO (二)_第3张图片

非阻塞IO

把整个过程切换成小的任务,通过任务间协作完成。

  • 由一个专门的线程来处理所有的 IO 事件,并负责分发。(说的就是Selector后面会讲到)
  • 事件驱动机制:事件到的时候触发,而异步去监视事件。
  • 线程通讯:线程之间通过 wait,notify 等方式通讯。保证每次上下文切换都是有意义的。减少无谓的进程切换。

以下是异步 IO 的结构,也就是非阻塞基本原理图:
不惑JAVA之JAVA基础 - NIO (二)_第4张图片
Reactor 就是上面隐喻的售票员角色(这个就是反应器原理)。每个线程的处理流程大概都是读取数据、解码、计算处理、编码、发送响应。

NIO 的相关类图

不惑JAVA之JAVA基础 - NIO (二)_第5张图片
可以看到NIO,有几个关键的点:Buffer、Channel和Selector下面我们将一一给大家讲解。

Buffer(缓冲区)

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

  • position:指定了下一个将要被写入或者读取的元素索引,它的值由get()/put()方法自动更新,在新创建一个Buffer对象时,position被初始化为0;
  • limit:指定还有多少数据需要取出(在从缓冲区写入通道时),或者还有多少空间可以放入数据(在从通道读入缓冲区时);
  • capacity:指定了可以存储在缓冲区中的最大数据容量,实际上,它指定了底层数组的大小,或者至少是指定了准许我们使用的底层数组的容量;
  • mark 用于记录当前 position 的前一个位置或者默认是 0。

以上四个属性值之间有一些相对大小的关系:0 <= position <= limit <= capacity。如果我们创建一个新的容量大小为10的ByteBuffer对象,在初始化的时候,position设置为0,limit和 capacity被设置为10,在以后使用ByteBuffer对象过程中,capacity的值不会再发生变化,而其它两个个将会随着使用而变化。四个属性值分别如图所示:

不惑JAVA之JAVA基础 - NIO (二)_第6张图片

现在我们可以从通道中读取一些数据到缓冲区中,注意从通道读取数据,相当于往缓冲区中写入数据。如果读取4个自己的数据,则此时position的值为4,即下一个将要被写入的字节索引为4,而limit仍然是10,如下图所示:

不惑JAVA之JAVA基础 - NIO (二)_第7张图片

下一步把读取的数据写入到输出通道中,相当于从缓冲区中读取数据,在此之前,必须调用flip()方法,该方法将会完成两件事情:

  1. 把limit设置为当前的position值
  2. 把position设置为0

由于position被设置为0,所以可以保证在下一步输出时读取到的是缓冲区中的第一个字节,而limit被设置为当前的position,可以保证读取的数据正好是之前写入到缓冲区中的数据,如下图所示:

不惑JAVA之JAVA基础 - NIO (二)_第8张图片

现在调用get()方法从缓冲区中读取数据写入到输出通道,这会导致position的增加而limit保持不变,但position不会超过limit的值,所以在读取我们之前写入到缓冲区中的4个自己之后,position和limit的值都为4,如下图所示:

不惑JAVA之JAVA基础 - NIO (二)_第9张图片

在从缓冲区中读取数据完毕后,limit的值仍然保持在我们调用flip()方法时的值,调用clear()方法能够把所有的状态变化设置为初始化时的值,如下图所示:

不惑JAVA之JAVA基础 - NIO (二)_第10张图片

验证代码

import java.io.*;
import java.nio.*;
import java.nio.channels.*;

public class Program {
    public static void main(String args[]) throws Exception {
        FileInputStream fin = new FileInputStream("d:\\test.txt");
        FileChannel fc = fin.getChannel();

        ByteBuffer buffer = ByteBuffer.allocate(10);
        output("初始化", buffer);

        fc.read(buffer);
        output("调用read()", buffer);

        buffer.flip();
        output("调用flip()", buffer);

        while (buffer.remaining() > 0) {
            byte b = buffer.get();
            // System.out.print(((char)b));
        }
        output("调用get()", buffer);

        buffer.clear();
        output("调用clear()", buffer);

        fin.close();
    }

    public static void output(String step, Buffer buffer) {
        System.out.println(step + " : ");
        System.out.print("capacity: " + buffer.capacity() + ", ");
        System.out.print("position: " + buffer.position() + ", ");
        System.out.println("limit: " + buffer.limit());
        System.out.println();
    }
}

完成的输出结果为:
不惑JAVA之JAVA基础 - NIO (二)_第11张图片

用到方法介绍(参见的API)

清除、反转和重绕
除了访问位置、限制、容量值的方法以及做标记和重置的方法外,此类还定义了以下可对缓冲区进行的操作:

  • clear() 使缓冲区为一系列新的通道读取或相对放置 操作做好准备:它将限制设置为容量大小,将位置设置为 0。
  • flip() 使缓冲区为一系列新的通道写入或相对获取 操作做好准备:它将限制设置为当前位置,然后将位置设置为 0。
  • rewind() 使缓冲区为重新读取已包含的数据做好准备:它使限制保持不变,将位置设置为 0。

如果还行对缓冲区的分片与数据共享,只读缓冲区等进行了解可以参见Java NIO使用及原理分析(三),这篇博文。

Channel(通道)

Java NIO的通道类似流,但又有些不同:

  • 既可以从通道中读取数据,又可以写数据到通道。但流的读写通常是单向的;
  • 通道可以异步地读写;
  • 通道中的数据总是要先读到一个Buffer,或者总是要从一个Buffer中写入。

通道实现

这些是Java NIO中最重要的通道的实现:

  • FileChannel 从文件中读写数据;
  • DatagramChannel 能通过UDP读写网络中的数据;
  • SocketChannel 能通过TCP读写网络中的数据;
  • ServerSocketChannel 可以监听新进来的TCP连接,像Web服务器那样。对每一个新进来的连接都会创建一个SocketChannel。

ServerSocketChannel

// 打开 ServerSocketChannel
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();

// 绑定9999端口
serverSocketChannel.socket().bind(new InetSocketAddress(9999));

// 常不会仅仅只监听一个连接,在while循环中调用 accept()方法。
while(true){
// 监听新进来的连接.
// 通过 ServerSocketChannel.accept() 方法监听新进来的连接。当 accept()方法返回的时候,它返回一个包含新进来的连接的 SocketChannel。因此, accept()方法会一直阻塞到有新连接到达。
    SocketChannel socketChannel =
            serverSocketChannel.accept();

    //do something with socketChannel...
}

Selector(选择器)

我们再来回顾一下传统的阻塞IO。通常在进行同步I/O操作时,如果读取数据,代码会阻塞直至有 可供读取的数据。同样,写入调用将会阻塞直至数据能够写入。传统的Server/Client模式会基于TPR(Thread per Request),服务器会为每个客户端请求建立一个线程,由该线程单独负责处理一个客户请求。这种模式带来的一个问题就是线程数量的剧增,大量的线程会增大服务器的开销。大多数的实现为了避免这个问题,都采用了线程池模型,并设置线程池线程的最大数量,这由带来了新的问题,如果线程池中有200个线程,而有200个用户都在进行大文件下载,会导致第201个用户的请求无法及时处理,即便第201个用户只想请求一个几KB大小的页面。传统的 Server/Client模式如下图所示:

不惑JAVA之JAVA基础 - NIO (二)_第12张图片

NIO中非阻塞I/O采用了基于Reactor模式的工作方式,I/O调用不会被阻塞,相反是注册感兴趣的特定I/O事件,如可读数据到达,新的套接字连接等等,在发生特定事件时,系统再通知我们。NIO中实现非阻塞I/O的核心对象就是Selector,Selector就是注册各种I/O事件地 方,而且当那些事件发生时,就是这个对象告诉我们所发生的事件,如下图所示:

不惑JAVA之JAVA基础 - NIO (二)_第13张图片

当有读或写等任何注册的事件发生时,可以从Selector中获得相应的SelectionKey,同时从 SelectionKey中可以找到发生的事件和该事件所发生的具体的SelectableChannel,以获得客户端发送过来的数据。

使用NIO中非阻塞I/O编写服务器处理程序,大体上可以分为下面三个步骤:

  1. 向Selector对象注册感兴趣的事件
  2. 从Selector中获取感兴趣的事件
  3. 根据不同的事件进行相应的处理

向Selector对象注册感兴趣的事件

/*
 * 注册事件
 * */
protected Selector getSelector() throws IOException {
    // 创建Selector对象
    Selector sel = Selector.open();

    // 创建可选择通道,并配置为非阻塞模式
    ServerSocketChannel server = ServerSocketChannel.open();
    server.configureBlocking(false);

    // 绑定通道到指定端口
    ServerSocket socket = server.socket();
    InetSocketAddress address = new InetSocketAddress(port);
    socket.bind(address);

    // 向Selector中注册感兴趣的事件
    server.register(sel, SelectionKey.OP_ACCEPT); 
    return sel;
}

创建了ServerSocketChannel对象,并调用configureBlocking()方法,配置为非阻塞模式,接下来的三行代码把该通道绑定到指定端口,最后向Selector中注册事件,此处指定的是参数是OP_ACCEPT,即指定我们想要监听accept事件,也就是新的连接发 生时所产生的事件,对于ServerSocketChannel通道来说,我们唯一可以指定的参数就是OP_ACCEPT。

从Selector中获取感兴趣的事件

/*
 * 开始监听
 * */ 
public void listen() { 
    System.out.println("listen on " + port);
    try { 
        while(true) { 
            // 该调用会阻塞,直到至少有一个事件发生
            selector.select(); 
            Set keys = selector.selectedKeys();
            Iterator iter = keys.iterator();
            while (iter.hasNext()) { 
                SelectionKey key = (SelectionKey) iter.next(); 
                iter.remove(); 
                process(key); 
            } 
        } 
    } catch (IOException e) { 
        e.printStackTrace();
    } 
}

在非阻塞I/O中,内部循环模式基本都是遵循这种方式。首先调用select()方法,该方法会阻塞,直到至少有一个事件发生,然后再使用selectedKeys()方法获取发生事件的SelectionKey,再使用迭代器进行循环。

根据不同的事件编写相应的处理代码

/*
 * 根据不同的事件做处理
 * */
protected void process(SelectionKey key) throws IOException{
    // 接收请求
    if (key.isAcceptable()) {
        ServerSocketChannel server = (ServerSocketChannel) key.channel();
        SocketChannel channel = server.accept();
        channel.configureBlocking(false);
        channel.register(selector, SelectionKey.OP_READ);
    }
    // 读信息
    else if (key.isReadable()) {
        SocketChannel channel = (SocketChannel) key.channel(); 
        int count = channel.read(buffer); 
        if (count > 0) { 
            buffer.flip(); 
            CharBuffer charBuffer = decoder.decode(buffer); 
            name = charBuffer.toString(); 
            SelectionKey sKey = channel.register(selector, SelectionKey.OP_WRITE); 
            sKey.attach(name); 
        } else { 
            channel.close(); 
        } 
        buffer.clear(); 
    }
    // 写事件
    else if (key.isWritable()) {
        SocketChannel channel = (SocketChannel) key.channel(); 
        String name = (String) key.attachment(); 
        ByteBuffer block = encoder.encode(CharBuffer.wrap("Hello " + name)); 
        if(block != null)
        {
            channel.write(block);
        }
        else
        {
            channel.close();
        }
     }
}
事件名 对应值
服务端接收客户端连接事件 SelectionKey.OP_ACCEPT
客户端连接服务端事件 SelectionKey.OP_CONNECT
读事件 SelectionKey.OP_READ
写事件 SelectionKey.OP_WRITE

总体代码,请点击这里

我不想把一篇博文写的成“新华字典”,如果想更加细致的了解NIO Selector 阻塞唤醒原理。如果您还想刨根问底想知道反应器的原理,那就看Reactor(反应器)模式,这样你就可以知道反应器分五个成员:Reactor(反应器)、Synchronous Event Demultiplexer(同步事件分离器)、Handle(描述符)、Event Handler(事件处理接口)、Concrete Event Handler(具体的事件处理接口)。

Java Socket 的工作机制(简单了解)

Socket 这个概念没有对应到一个具体的实体,它是描述计算机之间完成相互通信一种抽象功能。打个比方,可以把 Socket 比作为两个城市之间的交通工具,有了它,就可以在城市之间来回穿梭了。交通工具有多种,每种交通工具也有相应的交通规则。Socket 也一样,也有多种。大部分情况下我们使用的都是基于 TCP/IP 的流套接字,它是一种稳定的通信协议。

下图是典型的基于 Socket 的通信的场景:
图 8.Socket 通信示例
不惑JAVA之JAVA基础 - NIO (二)_第14张图片

主机 A 的应用程序要能和主机 B 的应用程序通信,必须通过 Socket 建立连接,而建立 Socket 连接必须需要底层 TCP/IP 协议来建立 TCP 连接。建立 TCP 连接需要底层 IP 协议来寻址网络中的主机。我们知道网络层使用的 IP 协议可以帮助我们根据 IP 地址来找到目标主机,但是一台主机上可能运行着多个应用程序,如何才能与指定的应用程序通信就要通过 TCP 或 UPD 的地址也就是端口号来指定。这样就可以通过一个 Socket 实例唯一代表一个主机上的一个应用程序的通信链路了。

建立通信链路

当客户端要与服务端通信,客户端首先要创建一个 Socket 实例,操作系统将为这个 Socket 实例分配一个没有被使用的本地端口号,并创建一个包含本地和远程地址和端口号的套接字数据结构,这个数据结构将一直保存在系统中直到这个连接关闭。在创建 Socket 实例的构造函数正确返回之前,将要进行 TCP 的三次握手协议,TCP 握手协议完成后,Socket 实例对象将创建完成,否则将抛出 IOException 错误。
与之对应的服务端将创建一个 ServerSocket 实例,ServerSocket 创建比较简单只要指定的端口号没有被占用,一般实例创建都会成功,同时操作系统也会为 ServerSocket 实例创建一个底层数据结构,这个数据结构中包含指定监听的端口号和包含监听地址的通配符,通常情况下都是“*”即监听所有地址。之后当调用 accept() 方法时,将进入阻塞状态,等待客户端的请求。当一个新的请求到来时,将为这个连接创建一个新的套接字数据结构,该套接字数据的信息包含的地址和端口信息正是请求源地址和端口。这个新创建的数据结构将会关联到 ServerSocket 实例的一个未完成的连接数据结构列表中,注意这时服务端与之对应的 Socket 实例并没有完成创建,而要等到与客户端的三次握手完成后,这个服务端的 Socket 实例才会返回,并将这个 Socket 实例对应的数据结构从未完成列表中移到已完成列表中。所以 ServerSocket 所关联的列表中每个数据结构,都代表与一个客户端的建立的 TCP 连接。

数据传输

传输数据是我们建立连接的主要目的,如何通过 Socket 传输数据,下面将详细介绍。
当连接已经建立成功,服务端和客户端都会拥有一个 Socket 实例,每个 Socket 实例都有一个 InputStream 和 OutputStream,正是通过这两个对象来交换数据。同时我们也知道网络 I/O 都是以字节流传输的。当 Socket 对象创建时,操作系统将会为 InputStream 和 OutputStream 分别分配一定大小的缓冲区,数据的写入和读取都是通过这个缓存区完成的。写入端将数据写到 OutputStream 对应的 SendQ 队列中,当队列填满时,数据将被发送到另一端 InputStream 的 RecvQ 队列中,如果这时 RecvQ 已经满了,那么 OutputStream 的 write 方法将会阻塞直到 RecvQ 队列有足够的空间容纳 SendQ 发送的数据。值得特别注意的是,这个缓存区的大小以及写入端的速度和读取端的速度非常影响这个连接的数据传输效率,由于可能会发生阻塞,所以网络 I/O 与磁盘 I/O 在数据的写入和读取还要有一个协调的过程,如果两边同时传送数据时可能会产生死锁,在后面 NIO 部分将介绍避免这种情况。

参考:
深入分析Java io的工作机制
Java NIO原理图文分析及代码实现
JAVA NIO 简介
Java NIO使用及原理分析(二)
Java NIO系列教程(二) Channel
Java NIO使用及原理分析 (四)

你可能感兴趣的:(Java基础,不惑JAVA)