在上篇《Java IO(2)阻塞式输入输出(BIO)》的末尾谈到了什么是阻塞式输入输出,通过Socket编程对其有了大致了解。现在再重新回顾梳理一下,对于只有一个“客户端”和一个“服务器端”来讲,服务器端需要阻塞式接收客户端的请求,这里的阻塞式表示服务器端的应用代码会被挂起直到客户端有请求过来,在高并发的应用场景有多个客户端发起连接下非阻塞式IO(NIO)是不二之选(且只需要在服务器端使用1个线程来管理,并不需要多个线程来处理多个连接)。在现实情况下,Tomcat、Jetty等很多Web服务器均使用了NIO技术。
接下来对于非阻塞式输入输出(NIO)的学习以及理解首先从它的三个基础概念讲起。
在NIO中,你需要忘掉“流”这个概念,取而代之的是“通道”。举例在网络应用程序中有多个客户端连接,此时数据传输的概念并不是“流”而“通道”,通道与流最大的不同就是,通道是双向的,而流是单向的(例如InputStream、OutputStream)。
在NIO中并不是简单的将流的概念替换为了通道,与通道搭配的是缓冲区。在BIO的字节流中并不会使用到缓冲区,而是直接操作文件通过字节方式直接读取,而NIO则不同,它会将通道中的数据读入缓存区,或者将缓存区的数据写入通道。
如果使用NIO的应用程序中只有一个Channel,选择器则是可以不需要的,而如果有多个Channel,换言之有多个连接时,此时通过选择器,在服务器端的应用程序中就只需要1个线程对多个连接进行管理。
当然从最开始就说到Channel是双向的,所以在最终图的示例为下图所示:
下面再重新回到这三个概念,详细解释它们是如何协同工作的。
通常情况下Channel会和Buffer配合使用,但可以不使用Channel。首先需要明确的是,应用程序不管是从文件(包括网络或者其他什么地方)中读取数据,还是写入数据到文件(包括网络或者其他什么地方)都需要Buffer。
1 ByteBuffer buffer = ByteBuffer.allocate(1024);
2 byte b = 121;
3 buffer.put(b);
4 buffer.flip(); //读写转换,由“写模式”转换为“读模式”
5 System.out.println((char)buffer.get());
第1行,分配一个1KB大小的Buffer缓冲区,ByteBuffer.allcoate返回HeapByteBuffer实例。
第3行,向Buffer中写入一个字节。
第4行,Buffer由“写模式”转换为“读模式”。
第5行,ByteBuffer.get方法读取Buffer中的数据,并且position索引+1。 在上面的代码中有一个重点——flip方法,这个方法的存在是由于Buffer兼顾了读和写的操作,在ByteBuffer的实现中有三个重要的成员变量需要注意: capacity——Buffer容量 position——索引位置 limit——读时表示最大容量,即limit = capacity;写时表示最后一个数据所在的索引位置。 用图例来说明上面代码的执行过程。
从上图可以清晰的看到Buffer内部是如何进行读写操作的,其中调用flip方法是很关键且重要的一个步骤,试想如果不调用flip进行读写转换,此时position、limit、capacity的索引位置将会如下图所示。
此时进行读的操作将会得到一个错误数据(0)。 尽管在讲这个小标题“直接将数据写入Buffer,应用程序从Buffer中获取数据”,但实际上已经简要介绍了Buffer的内部实现原理。
通过上面的例子可以看到,Channel和Buffer并不一定要在一起,单独使用Buffer也是可以的,但要使用Chnnel那就必须得配合Buffer。
此时的数据来源是文件,开头提过在NIO中忘掉“流”,记住“通道”。在NIO中可以通过传统的流获取通道。例如从输入流FileInputSteram中调用getChannel,或者从输出流FileOutputStream中调用getChannel,当然还有兼顾输入和输出的RandomAccessFile类从中调用getChannel。
BIO中首先获取流,NIO中首先获取通道。
1 RandomAccessFile file = new RandomAccessFile("/Users/yulinfeng/Documents/Coding/Idea/simplenio/src/main/java/com/demo/test.json", "rw");
2 FileChannel channel = file.getChannel();
3 ByteBuffer buffer = ByteBuffer.allocate(1024);
4 channel.read(buffer);
5 buffer.flip();
6 System.out.println(new String(buffer.array()));
看到这段NIO读取文件数据的代码,心中默写传统的BIO是如何读取文件数据的。
1 InputStream in = new FileInputStream("/Users/yulinfeng/Documents/Coding/Idea/simplenio/src/main/java/com/demo/test.json");
2 byte[] bytes = new byte[1024];
3 in.read(bytes);
4 System.out.println(new String(bytes));
展开代码可以看到,基本上如出一辙,在NIO中就是多了Buffer这个媒介来读取数据。
回到NIO读取文件数据的代码。 第1行,获取文件流。 第2行,获取Channel通道。 第3-6行,创建Buffer缓冲区,并将数据读取从通道读取到缓冲区。 同样还是用图例来说明上面代码的执行过程。
最后调用ByteBuffer.array方法返回缓冲区中的值,此时并未移动position的数组下标。这个例子结合图例我相信能很清楚地看到NIO是如何从文件中读取数据的,下面这个例子将输出数据到文件。
前面都是应用程序从Buffer中获取数据并且用图例的方式了解了它的内部运行原理。本例将把数据通过Buffer写到文件中,当然得记住还需要通过Channel才能写入文件。
1 RandomAccessFile file = new RandomAccessFile("E:\\IdeaProjects\\simplenio\\src\\main\\java\\com\\demo\\out\\test.json", "rw");
2 FileChannel channel = file.getChannel();
3 ByteBuffer buffer = Charset.forName("utf-8").encode("{\"name\": \"Kevin\"}"); //这里会自动进行读写转换,第1个例子需要手动调用flip方法进行读写模式的转换
通过上面的例子很容易想到,首先需要通道,那么就利用可读可写的RandomAccessFile获取通道;其次需要缓冲区;最后将缓冲区的数据写入到通道中即可。这段代码其实可以把重点放到是如何从缓冲区写到管道的。
第1-2行,通过可读可写的RandomAccessFile类获取Channel通道。(要是只需要写文件,也可以通过FileOutputStream.getChannel获得)
第3行,将字符串{“name”: “Kevin”}通过UTF-8编码写入Buffer缓冲区,NIO会对自动对其进行读写模式的转换,不需要手动调用flip方法。
第4行,将Buffer中的数据写入通道。
NIO不易掌握,需要反复练习,所以本文会给出多个例子反复操练并领会NIO的设计哲学。
这个例子有两种实现方式,第一种基于上面的例子就能拼凑出来,第二种则需要掌握一个新的API——transferFrom / transferTo
1 RandomAccessFile readFile = new RandomAccessFile("E:\\IdeaProjects\\simplenio\\src\\main\\java\\com\\demo\\inout\\from.json", "rw");
2 FileChannel readChannel = readFile.getChannel();
3 ByteBuffer buffer = ByteBuffer.allocate(1024);
4 readChannel.read(buffer);
5 buffer.flip(); //读写转换
6 RandomAccessFile writeFile = new RandomAccessFile("E:\\IdeaProjects\\simplenio\\src\\main\\java\\com\\demo\\inout\\to.json", "rw");
7 FileChannel writeChannel = writeFile.getChannel();
8 writeChannel.write(buffer);
经过上面的几个例子写出这个示例应该没什么问题,需要注意的是第x行的buffer.flip方法是读写转换,这在上面有提到过。
1 RandomAccessFile fromFile = new RandomAccessFile("E:\\IdeaProjects\\simplenio\\src\\main\\java\\com\\demo\\inout\\from.json", "rw");
2 FileChannel fromChannel = fromFile.getChannel();
3 RandomAccessFile toFile = new RandomAccessFile("E:\\IdeaProjects\\simplenio\\src\\main\\java\\com\\demo\\inout\\to.json", "rw");
4 FileChannel toChannel = toFile.getChannel();
5
6 toChannel.transferFrom(fromChannel, 0, fromChannel.size());
通过transferFrom就能将一个通道直接输出到另一个通道而不需要缓冲区做中转。
前面的例子全是有关本地文件的读写操作,在一个应用程序中有可能免不了通过网络来传输数据,传统的Socket编程利用的是BIO,也就是阻塞式输入输出。而NIO同样也可应用到Socket网络编程中。下面两个例子均是1个客户端对应1个服务器端。此时并不能很好的体会BIO和NIO的区别,若多个客户端对应1个服务器端,此时NIO的优点便很快显现,不过要实现多个客户端对应1个服务器端则需要Selector(选择器),由于现在还并未详细认识它所以将“多个客户端对应1个服务器端”放置在后面提及。
BIO Socket是我取的名字,意思是利用传统的阻塞式IO来进行Socket编程,本文虽主讲NIO,但也需要了解并熟练掌握BIO。故,在此先使用传统的IO来进行Socket编程以便能对下文的NIO Socket有一个类比。在本例中使用UDP协议传输数据。
1 /**
2 * BIO客户端
3 * Created by Kevin on 2017/12/18.
4 */
5 public class Client {
6 public static void main(String[] args) throws Exception{
7 String data = "this is Client.";
8 DatagramSocket socket = new DatagramSocket();
9 DatagramPacket packet = new DatagramPacket(data.getBytes(), data.getBytes().length, InetAddress.getByName("127.0.0.1"), 8989);
10 socket.send(packet);
11 }
12 }
1 /**
2 * 服务器端
3 * BIO Created by Kevin on 2017/12/18.
4 */
5 public class Server {
6 public static void main(String[] args) throws Exception{
7 DatagramSocket socket = new DatagramSocket(8989);
8 byte[] data = new byte[1024];
9 DatagramPacket packet = new DatagramPacket(data, data.length);
10 socket.receive(packet); //服务器端在未收到数据时,会在此处被阻塞挂起
11 System.out.println(new String(packet.getData()));
12 }
13 }
这是我们比较熟悉的Socket编程,其中有特点的就是在服务器端的第x行代码,此处若未收到来自客户端的数据,服务器端将会被阻塞。
在通常情况下,对于网络编程用的比较多的还是阻塞式。非阻塞式在应用程序中并不是特别常见,但它在Tomcat等Web服务器中却很常见。这是因为对于非阻塞式的网络编程其最大的优点或者说是最大的使用场景就是面对多个客户端时良好的性能表现。
此处我们还是在单一的客户端场景下使用非阻塞式网络编程(多个客户端就会使用到Selector选择器,下文会展开)。同样在本例中使用UDP协议传输数据。
1 /**
2 * NIO客户端
3 * Created by Kevin on 2017/12/18.
4 */
5 public class Client {
6 public static void main(String[] args) throws Exception{
7 DatagramChannel channel = DatagramChannel.open(); //类似读取本地文件,首先都需要建立一个通道
8 ByteBuffer buffer = Charset.forName("utf-8").encode("this is Client."); //其次建立一个缓冲区
9 channel.send(buffer, new InetSocketAddress("127.0.0.1", 8989));
10 }
11 }
1 /**
2 *NIO 服务器端
3 * Created by Kevin on 2017/12/18.
4 */
5 public class Server {
6 public static void main(String[] args) throws Exception{
7 DatagramChannel channel = DatagramChannel.open();
8 channel.socket().bind(new InetSocketAddress("127.0.0.1", 8989));
9 ByteBuffer buffer = ByteBuffer.allocate(1024);
10 channel.receive(buffer); //服务器端没有收到来自客户端的数据,会在这里和BIO Socket一样被阻塞
11 System.out.println(new String(buffer.array()));
12 }
13 }
对于NIO Socket的服务器端第10行可能会感到疑惑,既然是非阻塞的那么为什么在这个地方还是被阻塞了呢?在未收到客户端的数据时为什么还是被阻塞挂起了呢?这就需要用开头提到的这是1个客户端对应1个服务器端的场景,BIO和NIO并无明显区别,对于BIO或许更有优势,因为它的API相对来说更简单一些。而如果是多个客户端,如果使用NIO,服务器端会利用Selector(选择器)来选择准备好了的数据,而不会想此例一样一直等待一个客户端传输数据。接下来就是对Selector选择器的进一步认识。
看到这里对于NIO似乎还只有一个认识,API变得负责了,莫名其妙地从“流”的概念转换为了“通道”“+“缓冲区”,并且似乎和BIO并无多大区别。要我说,最大的区别和改进莫过于彻底理解NIO中的Selector(选择器)。 在《Java IO(2)阻塞式输入输出(BIO)》一文的末尾提到了在服务器端利用线程来处理数据以便使得程序能拥有更大的吞吐量,这种利用新开一个线程来处理接收到的数据不失为一种常用的计策。但是,在程序中,我个人认为还是要谨慎使用多线程,毕竟线程的上下文切换是有一定的开销的,况且线程如果过多还有可能造成Java虚拟机的栈溢出。Selector选择器的出现就可以使用1个线程来管理。
上面的示例程序都只有一个通道,也就是说同时只会读取或写入一个文件,如果现在有多个客户端,此时也就有多个通道,Selector选择器将会选择已经准备好了的通道读取数据。
要使用Selector选择器,免不了大致会经过以下几个流程:创建Selector选择器;将Channel通道修改为非阻塞模式(只有Socket才能修改为非阻塞模式,FileChannel不能修改),并将通道注册至Selector;Selector调用select方法对通道进行选择。
1 /**
2 * NIO 客户端,此处只有一个客户端连接
3 * Created by Kevin on 2017/12/24.
4 */
5 public class Client {
6 public static void main(String[] args) throws Exception{
7 DatagramChannel channel = DatagramChannel.open();
8 ByteBuffer buffer = Charset.forName("utf-8").encode("this is Client.");
9 channel.send(buffer, new InetSocketAddress("127.0.0.1", 8989));
10 }
11 }
如上注释所说,此处的示例仍然是只有一个客户端连接,对于服务器端的连接下面将会使用Selector选择器,重要部分在注释中已说明。
1 /**
2 * NIO 服务器端
3 * Created by Kevin on 2017/12/23.
4 */
5 public class Server {
6 public static void main(String[] args) throws Exception{
7 Selector selector = Selector.open(); //Selector选择器
8 DatagramChannel channel = DatagramChannel.open(); //Channel通道
9 channel.configureBlocking(false);
10 channel.bind(new InetSocketAddress("127.0.0.1", 8989));
11 channel.register(selector, SelectionKey.OP_READ); //此通道注册在Selector时关注是否可读
12 while (true) {
13 selector.select(); //如果没有一个注册到此Selector上的通道就绪,则阻塞;反之,只要有一个通道就绪则不会被阻塞。selectNow方法不论是否有通道就绪,都不会阻塞。
14 Iterator<SelectionKey> iterator = selector.selectedKeys().iterator(); //选择就绪的通道
15 while (iterator.hasNext()) {
16 SelectionKey key = iterator.next();
17 iterator.remove();
18 if (key.isReadable()) { //收到客户端数据
19 receive(key);
20 }
21 if (key.isWritable()) { //服务器端通道准备好向客户端发送数据
22 send(key);
23 }
24 }
25 }
26 }
27
28 /**
29 * 服务器端收到客户端数据,并做处理
30 * @param key
31 */
32 private static void receive(SelectionKey key) throws Exception{
33 DatagramChannel channel = (DatagramChannel) key.channel();
34 ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
35 channel.receive(byteBuffer);
36 System.out.println(new String(byteBuffer.array()));
37 }
38 /**
39 * 服务器端通道已准备好向客户端发送数据
40 * @param key
41 */
42 private static void send(SelectionKey key) {
43
44 }
45 }
对于使用Selector选择器,可以使得服务器端只使用1个线程来管理多个连接,尽管在上面的例子没有给出示例代码,但这种场景在Web应用中可以说是必然的,因为对于客户端(浏览器)一定是很多的,而服务器就只有一个,此时正是NIO场景的最大使用,当然上面的例子也可以看到JDK原生NIO编程相比于BIO是略微有点复杂的,市面上也有很多优秀的第三方NIO框架——Netty、Mina均是对NIO的再次封装,这在以后也会提到,此篇关于NIO的了解暂到此处,以后将会在对此有更深刻的理解时再次讲解。下篇将介绍——AIO(异步输入输出)。