文章目录
1. NIO与IO的区别与联系
- NIO是JDK1.4中引入的一组新的API(New IO),可以替代标准的Java IO,NIO与IO有着同样的作用和目的,但是NIO将以更加高效的方式进行文件的读写操作;
- NIO是以块处理数据的,但是普通的IO是以最基础的字节流进行写入和读取的,所以在执行效率上NIO会比普通的IO高出不少;
- NIO虽然不同于IO用OutPutStream输出流和InPutStream输入流来对数据进行处理,但是NIO的工作又基于这种流的形式采用通道和缓冲区对数据进行处理;
- NIO的通道是双向的,普通的IO使用的流是单向的,但是它们都是全双工的;
- NIO的缓冲区可以进行分片,可以建立只读缓冲区,直接缓冲区和间接缓冲区,只读缓冲区就是只能进行读操作的缓冲区,直接缓冲区是被分配在直接内存之中的缓冲区,间接缓冲区是被分配在Java Heap上的缓冲区;直接缓冲区是为了加快I/O速度而以一种特殊的方式分配其内存的缓冲区;
- NIO与传统的BIO的核心区别就是NIO采用的是多路复用的IO模型,BIO采用的是阻塞的IO模型,所以说NIO要比BIO的执行效率要高;
2. NIO三大组件的解释
-
NIO一共包括三大组件,分别是通道(channel),缓冲区(buffer),选择器(selector);
-
通道(channel)是对原IO包中流的模拟,到达任何目的地的数据或者是从任何目的地读取的数据都必须通过一个channel进行传输。channel就是一个对象,可以通过它来读取和写入数据;
-
缓冲区(buffer)实质上是一个容器对象,发送给一个通道的所有对象都必须先放置于缓冲区;同样的,从通道中读取的任何数据也需要先读取到缓冲区之中。所有的数据都通过buffer对象进行处理,我们永远都不可能将字节数据直接写入到通道中,同样我们也不会直接从通道中读取数据,而是先将数据放置于缓冲区之中,之后再对数据进行处理;缓冲区总是会包含一些要写入或者是读取的数据,在NIO中加入buffer对象是NIO于IO的一个重要区别;缓冲区实质上是一个数组,通常情况下它是一个字节数组,偶尔也会使用其他类型的数组。不过缓冲区又不仅仅是一个普通的数组,缓冲区提供了对数据的结构化访问,而且还可以跟踪系统的读写进程;缓冲区的类型包括:ByteBuffer,CharBuffer,ShortBuffer,IntBuffer,LongBuffer,FloatBuffer,DoubleBuffer;

-
选择器(selector)在字面上并不好理解,需要结合服务器的设计演化来理解它的用途;在使用selector之前,处理socket连接使用以下的两种方法;一个是使用线程的技术,另一个使用线程池的技术;
-
使用线程技术为每一个连接开辟一个线程,分别去处理对应的socket连接;但是使用多线程存在以下的几个问题:每一个线程都需要占用一定的内存空间,当连接数过多的时候会开辟大量的线程,导致占用大量的内存;当开辟的线程数过多的时候,线程上下文切换的成本会变得非常的高,所以说这种方式只适合连接数较少的场景;

-
为了解决以上的问题,我们可以改用线程池技术,使用线程池让线程池中的线程区处理相应的socket连接,但是使用这种方式仍存在以下的几个问题:在阻塞模式下线程只能处理一个连接,线程池中的线程在获取任务之后,只有在这个线程执行完任务之后才回去获取下一个任务,当一个socket连接一直不断开的时候,其对应的线程就无法去处理其它的socket连接,有的任务可能会无法得到及时的处理;所以使用线程池的方式只适合短链接的场景,短连接就是建立连接发送请求并得到响应之后就立即断开连接,从而使线程池中的线程可以快速的处理其他连接;

-
在使用线程的方式和使用线程池的方式之间一种折中的方式就是使用选择器(selector),选择器的作用就是配合一个线程处理多个管道(由于fileChannel是阻塞式的,所以说它并不能使用选择器),选择器会获取它所负责的管道上发生的事件,这些管道工作在非阻塞的模式下,当一个管道中没有执行任务时,选择器可以去执行其它管道中的任务,这种方式适合连接数较多但是流量少的场景;如没有事件就绪,那么调用selector的select()方法会阻塞线程,直到channel中发生了就绪事件,select()方法就会返回这些事件并交给相应的线程处理;

3. NIO的工作流程步骤
- 首先创建ServerSocketChannel 对象以及真正处理业务的线程;
- 给刚刚创建的ServerSocketChannel 对象进行绑定一个对应的端口,然后设置为非阻塞;
- 创建Selector对象并打开,然后把这个Selector对象注册到ServerSocketChannel 中,并设置好监听的事件,监听 SelectionKey.OP_ACCEPT事件;
- 接着就是Selector对象进行死循环监听每一个Channel通道的事件,循环执行 Selector.select() 方法,轮询就绪的 Channel;
- 从Selector中获取所有的SelectorKey(这个就可以看成是不同的事件),如果SelectorKey是处于 OP_ACCEPT 状态,说明是新的客户端接入,调用 ServerSocketChannel.accept 接收新的客户端;
- 把这个接受的新客户端的Channel通道注册到ServerSocketChannel上,并且把之前的OP_ACCEPT 状态改为SelectionKey.OP_READ读取事件状态,并设置为非阻塞的,然后把当前的这个SelectorKey给移除掉,说明这个事件完成了;
- 如果第5步的时候过来的事件不是OP_ACCEPT 状态,那就是OP_READ读取数据的事件状态,然后调用本文章的上面的那个读取数据的机制就可以了;
4. 使用ByteBuffer
-
为了对NIO能够有进一步的了解,我们首先使用NIO解决以下的需求:有一个文本文件data.txt,内容为:1234567890abcd;现在需要做的就是使用FileChannel来读取文件中的内容;
-
在给出完整的代码之前,我们需要先连接一些方法:
- 向buffer中写数据的时候需要使用channel.read(buffer);
- 调用flip()函数将缓冲区切换至读模式,flip会是buffer中的limit变为当前的position,将当前的position变为0;
- 从buffer中读取数据,使用buffer.get();
- 调用clear()或者compact()方法将buffer切换至写模式,调用clear()方法的时候会使position变为0,limit变为capacity,之后写进来的数据会覆盖现有的数据(反正已经没有用了);调用compact()方法的时候会将缓冲区中未读的数据压缩至缓冲区的最前方;
-
之后编写的代码都需要以下的依赖,在此给出相应的pom.xml文件:
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>com.zhyn</groupId> <artifactId>PracticeForNetty</artifactId> <version>1.0-SNAPSHOT</version> <properties> <maven.compiler.source>8</maven.compiler.source> <maven.compiler.target>8</maven.compiler.target> </properties> <dependencies> <dependency> <groupId>io.netty</groupId> <artifactId>netty-all</artifactId> <version>4.1.39.Final</version> </dependency> <dependency> <groupId>com.google.code.gson</groupId> <artifactId>gson</artifactId> <version>2.8.5</version> </dependency> <dependency> <groupId>com.google.guava</groupId> <artifactId>guava</artifactId> <version>19.0</version> </dependency> <dependency> <groupId>ch.qos.logback</groupId> <artifactId>logback-classic</artifactId> <version>1.2.3</version> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <version>1.18.18</version> </dependency> </dependencies> </project> -
使用NIO的API读取文件中的内容的完整代码
package com.zhyn.nio; import lombok.extern.slf4j.Slf4j; import java.io.FileInputStream; import java.io.IOException; import java.nio.ByteBuffer; import java.nio.channels.FileChannel; @Slf4j public class TestByteBuffer { public static void main(String[] args) { // FileChannel: 数据的读写通道,只能工作在阻塞模式下,所以说它不能和selector配合使用 // 和网络相关的Channel才能和selector一起使用 // FileChannel虽然是双向可读可写的,但是它的获取源头决定了它到底是可读的还是可写的,In只能读,Out只能写 // 1. 通过输入输出流获取通道 2. 通过RandomAccessFile获取通道 try(FileChannel channel = new FileInputStream("data.txt").getChannel()) { // 准备缓冲区,ByteBuffer实例不能通过new的方式创建,需要使用静态方法创建 // 使用allocate方法划分一块内存大小作为缓冲区,设置为10的话就只会读取10个字节的数据 ByteBuffer buffer = ByteBuffer.allocate(10); // 缓存区是占用内存大小的,所以说并不会为一个很大的文件分配一个很大的缓冲区,而是分多次读取 while (true) { // 从channel读取数据写入buffer缓冲区,返回值是读取到的实际字节数,返回结果为-1的时候表示读取到缓冲区的末尾 int len = channel.read(buffer); log.debug("读取到的字节数 {}", len); if (len == -1) break; // 打印buffer的内容需要切换至buffer的读模式 buffer.flip(); while (buffer.hasRemaining()) { // 无参的get()每次获取一个字节,可以强转成一个字符进行打印,每当调用一次get()方法的时候,position就会往后移动一位 byte b = buffer.get(); log.debug("实际字节 {}", (char)b); } // 切换到写模式,以便继续读文件的时候将后续的内容写到buffer缓冲区 buffer.clear(); } } catch (IOException e) { e.printStackTrace(); } } } -
运行结果

-
字节缓冲区的核心属性:字节缓冲区的父类Buffer中有几个核心属性,如下所示
// mark <= position <= limit <= capacity private int mark = -1; private int position = 0; private int limit; private int capacity;- capacity:缓冲区的容量,通过构造函数赋予且一旦设置之后就无法更改了;
- limit:缓冲区的界限,位于limit及之后的索引上的数据就是无意义的数据,类似于ArrayList之中的size属性;
- position:下一个要操作的数据元素的索引;
- mark:当调用mark()方法时,mark用于记录当前position的值,我们可以在需要的时候调用reset()方法将position恢复到mark记录下来的值;
-
操作缓冲区的核心方法:
-
buffer缓冲区在刚开始的时候如下图所示

-
在写模式下,position是写入数据的索引位置,limit初始时是容量大小的值;put()方法可以将一个数据写入到buffer缓冲区,每当一次put()方法执行的时候,position的值就会加一,指向下一个可以写入的位置;以下展示了在缓冲区中写入三个元素后缓冲区的快照;

-
flip()方法:flip方法会切换对缓冲区的造作模式,由写切换为读或者是由读切换为写,再进行该操作之后大部分情况下都是切换为读操作,position = 0,limit指向最后一个可以读取的元素的下一个索引值,capacity的值保持不变;如果是写模式转换为读模式,那么buffer中的各个核心属性将会恢复为put方法中的值;以下展示了切换为读操作之后缓冲区的快照;

-
get方法:get方法读取缓冲区中的一个值,没当执行一次get方法之后,position的值就会加一,如果超过了limit的值则会抛出异常;但是get(i)方法不会改变position的值;以下展示了在调用一次get()方法之后buffer的快照;

-
rewind()方法:该方法只能在读模式下使用,当该方法执行后,position = 0,mark = -1;下图展示了在执行一次rewind()方法之后buffer的快照;

-
clear()方法:该方法会将缓冲区的各个核心属性的值设置为最初的状态,也就是position = 0,limit = capacity;但是buffer中的数据依然存在,只不过会在下一次写入的时候被覆盖掉(反正这是个时候这些数据已经没已用了);下图展示了在执行一次clear()方法之后buffer的快照;

-
compact()方法:该方法是ByteBuffer的方法而不是Buffer的方法,该方法在执行之后会把未读完的数据向前压缩,然后将position的值更新到下一次要写入的位置并切换到写模式,在数据向前压缩的过程中,已经被读取到的值会被后面的值覆盖掉;下图展示了在执行一次compact()方法之后buffer的快照;

-
clear()方法与compact()方法的比较:clear只是将缓冲区的核心状态进行重置,而compact方法除了重置limit,mark以及将position的值更新到下一次要写入的位置之外,还涉及到数据在内存中的拷贝(也就是调用arraycopy方法),所以说compact方法更加的耗时,但是该方法可以保留我们还没有读取到的数据,而clear方法却不可以,所以我们需要根据实际的情况来判断到底是用哪一个方法来进行模式切换;
-
mark()和reset()方法:mark方法会将position的值保存到mark属性中;reset反复噶会将position的值改为mark中所保存的值;
-
-
为了便于在展示ByteBuffer在调用各个常用的方法之后的具体快照信息,这里引入ByteBufferUtil工具类:
package com.zhyn.nio; import io.netty.util.internal.StringUtil; import java.nio.ByteBuffer; import static io.netty.util.internal.MathUtil.isOutOfBounds; import static io.netty.util.internal.StringUtil.NEWLINE; public class ByteBufferUtil { private static final char[] BYTE2CHAR = new char[256]; private static final char[] HEXDUMP_TABLE = new char[256 * 4]; private static final String[] HEXPADDING = new String[16]; private static final String[] HEXDUMP_ROWPREFIXES = new String[65536 >>> 4]; private static final String[] BYTE2HEX = new String[256]; private static final String[] BYTEPADDING = new String[16]; static { final char[] DIGITS = "0123456789abcdef".toCharArray(); for (int i = 0; i < 256; i++) { HEXDUMP_TABLE[i << 1] = DIGITS[i >>> 4 & 0x0F]; HEXDUMP_TABLE[(i << 1) + 1] = DIGITS[i & 0x0F]; } int i; // Generate the lookup table for hex dump paddings for (i = 0; i < HEXPADDING.length; i++) { int padding = HEXPADDING.length - i; StringBuilder buf = new StringBuilder(padding * 3); for (int j = 0; j < padding; j++) { buf.append(" "); } HEXPADDING[i] = buf.toString(); } // Generate the lookup table for the start-offset header in each row (up to 64KiB). for (i = 0; i < HEXDUMP_ROWPREFIXES.length; i++) { StringBuilder buf = new StringBuilder(12); buf.append(NEWLINE); buf.append(Long.toHexString(i << 4 & 0xFFFFFFFFL | 0x100000000L)); buf.setCharAt(buf.length() - 9, '|'); buf.append('|'); HEXDUMP_ROWPREFIXES[i] = buf.toString(); } // Generate the lookup table for byte-to-hex-dump conversion for (i = 0; i < BYTE2HEX.length; i++) { BYTE2HEX[i] = ' ' + StringUtil.byteToHexStringPadded(i); } // Generate the lookup table for byte dump paddings for (i = 0; i < BYTEPADDING.length; i++) { int padding = BYTEPADDING.length - i; StringBuilder buf = new StringBuilder(padding); for (int j = 0; j < padding; j++) { buf.append(' '); } BYTEPADDING[i] = buf.toString(); } // Generate the lookup table for byte-to-char conversion for (i = 0; i < BYTE2CHAR.length; i++) { if (i <= 0x1f || i >= 0x7f) { BYTE2CHAR[i] = '.'; } else { BYTE2CHAR[i] = (char) i; } } } /** * 打印所有内容 * @param buffer */ public static void debugAll(ByteBuffer buffer) { int oldlimit = buffer.limit(); buffer.limit(buffer.capacity()); StringBuilder origin = new StringBuilder(256); appendPrettyHexDump(origin, buffer, 0, buffer.capacity()); System.out.println("+--------+-------------------- all ------------------------+----------------+"); System.out.printf("position: [%d], limit: [%d]\n", buffer.position(), oldlimit); System.out.println(origin); buffer.limit(oldlimit); } /** * 打印可读取内容 * @param buffer */ public static void debugRead(ByteBuffer buffer) { StringBuilder builder = new StringBuilder(256); appendPrettyHexDump(builder, buffer, buffer.position(), buffer.limit() - buffer.position()); System.out.println("+--------+-------------------- read -----------------------+----------------+"); System.out.printf("position: [%d], limit: [%d]\n", buffer.position(), buffer.limit()); System.out.println(builder); } public static void main(String[] args) { ByteBuffer buffer = ByteBuffer.allocate(10); buffer.put(new byte[]{97, 98, 99, 100}); debugAll(buffer); } private static void appendPrettyHexDump(StringBuilder dump, ByteBuffer buf, int offset, int length) { if (isOutOfBounds(offset, length, buf.capacity())) { throw new IndexOutOfBoundsException( "expected: " + "0 <= offset(" + offset + ") <= offset + length(" + length + ") <= " + "buf.capacity(" + buf.capacity() + ')'); } if (length == 0) { return; } dump.append( " +-------------------------------------------------+" + NEWLINE + " | 0 1 2 3 4 5 6 7 8 9 a b c d e f |" + NEWLINE + "+--------+-------------------------------------------------+----------------+"); final int startIndex = offset; final int fullRows = length >>> 4; final int remainder = length & 0xF; // Dump the rows which have 16 bytes. for (int row = 0; row < fullRows; row++) { int rowStartIndex = (row << 4) + startIndex; // Per-row prefix. appendHexDumpRowPrefix(dump, row, rowStartIndex); // Hex dump int rowEndIndex = rowStartIndex + 16; for (int j = rowStartIndex; j < rowEndIndex; j++) { dump.append(BYTE2HEX[getUnsignedByte(buf, j)]); } dump.append(" |"); // ASCII dump for (int j = rowStartIndex; j < rowEndIndex; j++) { dump.append(BYTE2CHAR[getUnsignedByte(buf, j)]); } dump.append('|'); } // Dump the last row which has less than 16 bytes. if (remainder != 0) { int rowStartIndex = (fullRows << 4) + startIndex; appendHexDumpRowPrefix(dump, fullRows, rowStartIndex); // Hex dump int rowEndIndex = rowStartIndex + remainder; for (int j = rowStartIndex; j < rowEndIndex; j++) { dump.append(BYTE2HEX[getUnsignedByte(buf, j)]); } dump.append(HEXPADDING[remainder]); dump.append(" |"); // Ascii dump for (int j = rowStartIndex; j < rowEndIndex; j++) { dump.append(BYTE2CHAR[getUnsignedByte(buf, j)]); } dump.append(BYTEPADDING[remainder]); dump.append('|'); } dump.append(NEWLINE + "+--------+-------------------------------------------------+----------------+"); } private static void appendHexDumpRowPrefix(StringBuilder dump, int row, int rowStartIndex) { if (row < HEXDUMP_ROWPREFIXES.length) { dump.append(HEXDUMP_ROWPREFIXES[row]); } else { dump.append(NEWLINE); dump.append(Long.toHexString(rowStartIndex & 0xFFFFFFFFL | 0x100000000L)); dump.setCharAt(dump.length() - 9, '|'); dump.append('|'); } } public static short getUnsignedByte(ByteBuffer buffer, int index) { return (short) (buffer.get(index) & 0xFF); } } -
以下展示了ByteBuffer在调用各个常见的方法之后buffer的快照:
package com.zhyn.nio; import java.nio.ByteBuffer; import static com.zhyn.nio.ByteBufferUtil.debugAll; public class TestByteBufferReadWrite { public static void main(String[] args) { ByteBuffer buffer = ByteBuffer.allocate(10); // 向buffer中写入一个字节的数据 buffer.put((byte) 0x61); // 97 'a' debugAll(buffer); // 向buffer中写入三个字节的数据 buffer.put(new byte[]{0x62, 0x63, 0x64}); // 'b' 'c' 'd' debugAll(buffer); //System.out.println(buffer.get()); // 如果不将buffer缓冲区切换到读模式的话position的值就不会被重置,最后将不会读取到我们希望的数据,而是0 buffer.flip(); debugAll(buffer); System.out.println(buffer.get()); System.out.println(buffer.get()); debugAll(buffer); buffer.compact(); debugAll(buffer); buffer.put(new byte[]{0x65, 0x6f}); debugAll(buffer); } }// 向缓冲区中添加了一个字节的数据,此时的position = 1 +--------+-------------------- all ------------------------+----------------+ position: [1], limit: [10] +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 61 00 00 00 00 00 00 00 00 00 |a......... | +--------+-------------------------------------------------+----------------+ // 向缓冲区中加入了三个字节的数据,此时的position = 4 +--------+-------------------- all ------------------------+----------------+ position: [4], limit: [10] +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 61 62 63 64 00 00 00 00 00 00 |abcd...... | +--------+-------------------------------------------------+----------------+ // 调用了flip()方法切换到了读模式,此时的position = 0, limit = 4,表示可以读取[0, 4)之间的数据 +--------+-------------------- all ------------------------+----------------+ position: [0], limit: [4] +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 61 62 63 64 00 00 00 00 00 00 |abcd...... | +--------+-------------------------------------------------+----------------+ 97 98 // 在调用两次get()方法之后,position = 2 +--------+-------------------- all ------------------------+----------------+ position: [2], limit: [4] +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 61 62 63 64 00 00 00 00 00 00 |abcd...... | +--------+-------------------------------------------------+----------------+ // 调用了compact()方法进行模式切换,此时原position处以及之后的值都被压缩到ByteBuffer前面去了 // position的值被更新至下一次进行写操作的索引值 +--------+-------------------- all ------------------------+----------------+ position: [2], limit: [10] +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 63 64 63 64 00 00 00 00 00 00 |cdcd...... | +--------+-------------------------------------------------+----------------+ // 调用put()方法添加两个字节的数据,原position处以及之后的值都会被覆盖,此时position = 4 +--------+-------------------- all ------------------------+----------------+ position: [4], limit: [10] +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 63 64 65 6f 00 00 00 00 00 00 |cdeo...... | +--------+-------------------------------------------------+----------------+package com.zhyn.nio; import java.nio.ByteBuffer; import static com.zhyn.nio.ByteBufferUtil.debugAll; public class TestByteBufferRead { public static void main(String[] args) { ByteBuffer buffer = ByteBuffer.allocate(10); buffer.put(new byte[]{'a', 'b', 'c', 'd'}); buffer.flip(); buffer.get(new byte[4]); debugAll(buffer); // rewind 从头开始读 buffer.rewind(); debugAll(buffer); System.out.println((char)buffer.get()); } }+--------+-------------------- all ------------------------+----------------+ position: [4], limit: [4] +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 61 62 63 64 00 00 00 00 00 00 |abcd...... | +--------+-------------------------------------------------+----------------+ // 调用了一次rewind方法,position = 0; +--------+-------------------- all ------------------------+----------------+ position: [0], limit: [4] +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 61 62 63 64 00 00 00 00 00 00 |abcd...... | +--------+-------------------------------------------------+----------------+ aa b c d c d d +--------+-------------------- all ------------------------+----------------+ position: [4], limit: [4] +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 61 62 63 64 00 00 00 00 00 00 |abcd...... | +--------+-------------------------------------------------+----------------+ -
ByteBuffer和字符串之间的相互转换
- 方法1:字符串通过getByte()方法获得byte数组,然后将byte数组放入ByteBuffer中,从而完成编码工作;然后buffer先调用ByteBuffer的flip方法,然后通过StandardCharsets的decode方法进行解码;
package com.zhyn.nio; import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; import static com.zhyn.nio.ByteBufferUtil.debugAll; public class TestByteBufferString { public static void main(String[] args) { ByteBuffer buffer_1 = ByteBuffer.allocate(16); buffer_1.put("hello".getBytes()); // 通过字符串的getByte方法获得字节数组,放入缓冲区 debugAll(buffer_1); // 将缓冲区中的数据转化为字符串 buffer_1.flip(); // 通过StandardCharsets解码,获得CharBuffer,再通过toString获得字符串 String str = StandardCharsets.UTF_8.decode(buffer_1).toString(); System.out.println(str); debugAll(buffer_1); } }+--------+-------------------- all ------------------------+----------------+ position: [5], limit: [16] +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 68 65 6c 6c 6f 00 00 00 00 00 00 00 00 00 00 00 |hello...........| +--------+-------------------------------------------------+----------------+ hello +--------+-------------------- all ------------------------+----------------+ position: [5], limit: [5] +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 68 65 6c 6c 6f 00 00 00 00 00 00 00 00 00 00 00 |hello...........| +--------+-------------------------------------------------+----------------+- 方法2:通过StandardCharsets的encode()方法获得ByteBuffer,此时获得的ByteBuffer为读模式,无需通过flip切换模式,然后再通过StandardCharsets的decoder()方法解码;
package com.zhyn.nio; import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; import static com.zhyn.nio.ByteBufferUtil.debugAll; public class TestByteBufferString { public static void main(String[] args) { // 通过StandardCharsets的encode方法获得ByteBuffer // 此时获得的ByteBuffer为读模式,无需通过flip切换模式 ByteBuffer buffer_1 = StandardCharsets.UTF_8.encode("world"); debugAll(buffer_1); String str = StandardCharsets.UTF_8.decode(buffer_1).toString(); System.out.println(str); debugAll(buffer_1); } }+--------+-------------------- all ------------------------+----------------+ position: [0], limit: [5] +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 77 6f 72 6c 64 |world | +--------+-------------------------------------------------+----------------+ world +--------+-------------------- all ------------------------+----------------+ position: [5], limit: [5] +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 77 6f 72 6c 64 |world | +--------+-------------------------------------------------+----------------+- 方法3:字符串调用getByte()方法获得字节数组,通过将字节数组传给ByteBuffer的wrap()方法获得ByteBuffer,然后调用解码代码即可,同样无需调用flip方法切换为读模式;
package com.zhyn.nio; import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; import static com.zhyn.nio.ByteBufferUtil.debugAll; public class TestByteBufferString { public static void main(String[] args) { // 通过StandardCharsets的encode方法获得ByteBuffer // 此时获得的ByteBuffer为读模式,无需通过flip切换模式 ByteBuffer buffer_1 = ByteBuffer.wrap("java".getBytes()); debugAll(buffer_1); String str = StandardCharsets.UTF_8.decode(buffer_1).toString(); System.out.println(str); debugAll(buffer_1); } }+--------+-------------------- all ------------------------+----------------+ position: [0], limit: [4] +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 6a 61 76 61 |java | +--------+-------------------------------------------------+----------------+ java +--------+-------------------- all ------------------------+----------------+ position: [4], limit: [4] +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 6a 61 76 61 |java | +--------+-------------------------------------------------+----------------+ -
分散读集中写
- 分散读取一个文本文件,使用如下方式读取,可以将数据填充至多个 buffer
package com.zhyn.nio; import java.io.IOException; import java.io.RandomAccessFile; import java.nio.ByteBuffer; import java.nio.channels.FileChannel; import static com.zhyn.nio.ByteBufferUtil.debugAll; public class TestScatteringReads { public static void main(String[] args) { try(FileChannel channel = new RandomAccessFile("words.txt", "r").getChannel()) { ByteBuffer buffer_1 = ByteBuffer.allocate(3); ByteBuffer buffer_2 = ByteBuffer.allocate(3); ByteBuffer buffer_3 = ByteBuffer.allocate(5); // 将3个buffer组合在一起 // 分散读集中写可以减少数据在多个buffer之间进行拷贝复制,进而提高运行效率 // 从管道中读取数据写到ByteBuffer数组中 channel.read(new ByteBuffer[]{buffer_1, buffer_2, buffer_3}); buffer_1.flip(); buffer_2.flip(); buffer_3.flip(); debugAll(buffer_1); debugAll(buffer_2); debugAll(buffer_3); } catch (IOException e) { e.printStackTrace(); } } }+--------+-------------------- all ------------------------+----------------+ position: [0], limit: [3] +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 6f 6e 65 |one | +--------+-------------------------------------------------+----------------+ +--------+-------------------- all ------------------------+----------------+ position: [0], limit: [3] +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 74 77 6f |two | +--------+-------------------------------------------------+----------------+ +--------+-------------------- all ------------------------+----------------+ position: [0], limit: [5] +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 74 68 72 65 65 |three | +--------+-------------------------------------------------+----------------+- 使用如下方式写入,可以将多个 buffer 的数据填充至 channel
package com.zhyn.nio; import java.io.IOException; import java.io.RandomAccessFile; import java.nio.ByteBuffer; import java.nio.channels.FileChannel; import java.nio.charset.StandardCharsets; public class TestGatheringWrites { public static void main(String[] args) { ByteBuffer buffer_1 = StandardCharsets.UTF_8.encode("hello"); ByteBuffer buffer_2 = StandardCharsets.UTF_8.encode("world"); ByteBuffer buffer_3 = StandardCharsets.UTF_8.encode("你好"); try(FileChannel channel = new RandomAccessFile("words2.txt", "rw").getChannel()) { channel.write(new ByteBuffer[]{buffer_1, buffer_2, buffer_3}); } catch (IOException e) { e.printStackTrace(); } } } -
粘包与半包问题:
网络上有多条数据发送给服务端,数据之间使用 \n 进行分隔
但由于某种原因这些数据在接收时,被进行了重新组合,例如原始数据有3条为
Hello,world\n
I’m ZhangSan\n
How are you?\n
变成了下面的两个 byteBuffer (黏包,半包)
Hello,world\nI’m ZhangSan\nHo
w are you?\n
现在要求你编写程序,将错乱的数据恢复成原始的按 \n 分隔的数据
黏包是为了效率高,将数据合在一起进行发送比一条一条的发送数据效率要高,就比如快递员将快递攒成一小车才开始配送快递,发送方在发送数据时,会将数据整合在一起,当数据达到一定的数量后再一起发送,这就导致了多条信息被放在一个缓冲区中被一起发送出去;
接收方的缓冲区的大小是有限的,当接收方的缓冲区满了以后,就需要将信息截断,等缓冲区空了以后再继续放入数据;这就会导致一段完整的数据最后被截断的现象发生;package com.zhyn.nio; import java.nio.ByteBuffer; import static com.zhyn.nio.ByteBufferUtil.debugAll; public class TestByteBufferExam { public static void main(String[] args) { ByteBuffer source = ByteBuffer.allocate(32); source.put("Hello,world\nI'm ZhangSan\nHo".getBytes()); split(source); source.put("w are you?\n".getBytes()); split(source); } private static void split(ByteBuffer source) { source.flip(); for (int i = 0; i < source.limit(); i++) { // 找到一个完整的信息 if (source.get(i) == '\n') { int length = i + 1 - source.position(); // 把这条消息存入新的ByteBuffer ByteBuffer target = ByteBuffer.allocate(length); for (int j = 0; j < length; j++) { target.put(source.get()); } debugAll(target); } } source.compact(); debugAll(source); } }+--------+-------------------- all ------------------------+----------------+ position: [12], limit: [12] +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 48 65 6c 6c 6f 2c 77 6f 72 6c 64 0a |Hello,world. | +--------+-------------------------------------------------+----------------+ +--------+-------------------- all ------------------------+----------------+ position: [13], limit: [13] +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 49 27 6d 20 5a 68 61 6e 67 53 61 6e 0a |I'm ZhangSan. | +--------+-------------------------------------------------+----------------+ +--------+-------------------- all ------------------------+----------------+ position: [2], limit: [32] +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 48 6f 6c 6c 6f 2c 77 6f 72 6c 64 0a 49 27 6d 20 |Hollo,world.I'm | |00000010| 5a 68 61 6e 67 53 61 6e 0a 48 6f 00 00 00 00 00 |ZhangSan.Ho.....| +--------+-------------------------------------------------+----------------+ +--------+-------------------- all ------------------------+----------------+ position: [13], limit: [13] +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 48 6f 77 20 61 72 65 20 79 6f 75 3f 0a |How are you?. | +--------+-------------------------------------------------+----------------+ +--------+-------------------- all ------------------------+----------------+ position: [0], limit: [32] +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 48 6f 77 20 61 72 65 20 79 6f 75 3f 0a 27 6d 20 |How are you?.'m | |00000010| 5a 68 61 6e 67 53 61 6e 0a 48 6f 00 00 00 00 00 |ZhangSan.Ho.....| +--------+-------------------------------------------------+----------------+
5.文件编程
-
FileChannel的工作模式:FileChannel只能工作在阻塞模式下,所以说它不能和Selector搭配使用;
-
FileChannel的获取方式:我们不能直接打开FileChannel,必须通过FileInputStream,FileOutputStream或者是RandomAccessFile来获取FileChannel,通过FileInputStream获取的channel只能读;通过FileOutputStream获取的channel只能写;通过RandomAccessFile获取的channel的读写模式要根据构造RandomAccessFile时的读写模式决定;
-
通过FileInputStream获取的channel或只是RandomAccessFile的读模式获取到的channel调用read()方法的时候,会从channel中读取数据填充到ByteBuffer,返回值表示从channel中读取到了多少字节的数据,-1表示已经读完了,也就是到达了文件的末尾;
int readBytes = hannel.read(buffer); -
channel的写入:channel的写入是从buffer缓冲区中读数据然后填充到channel中,由于channel也是有大小限制的,所以说write并不能保证一次将buffer中的全部内容都写入到channel中,正确地进行写操作的方式如下所示:
ByteBuffer buffer = ...; buffer.put(...) // 存入数据 buffer.flip(); // 切换到读模式 while(buffer.hasRemaining()) { channel.write(buffer); } -
channel的关闭:由于通道占用有一些资源所以说必须对通道进行关闭,我们可以使用tey-catch语句对相应的资源进行关闭,此时我们就使用到了带资源的try语句,如果我们使用finally的话可能会造成一个异常被覆盖,也就是在try语句块中抛出的某个异常也会在finally语句块中被抛出,这样就会导致原有的异常丢失,转而抛出finally语句中的异常,为了避免这个问题的出现,我们可以使用带资源的try语句来处理(前提是这个资源所对应的类实现了AutoCloseable接口),try()括号中可以写多行语句,它会自动关闭括号中的资源;
public class TestChannel { public static void main(String[] args) throws IOException{ try (FileInputStream fis = new FileInputStream("stu.txt"); FileOutputStream fos = new FileOutputStream("student.txt"); FileChannel inputChannel = fis.getChannel(); FileChannel outputChannel = fos.getChannel()) { // 执行相应的操作... } } } -
channel的位置:和buffer缓冲区一样,channel也存在位置属性,可以通过position()方法获取当前的位置,也可以通过position(i)方法设置channel中位置的值;如果设置的当前位置是文件的末尾,那么读取操作会返回-1,并且在进行写入操作的时候会以追加的形式写入数据,如果newPos的值超过了文件的末尾,那么后续写入的内容就会和之前的文件末尾之间产生空洞;
int pos = channel.position(); long newPos = ...; channel.position(newPos); -
强制写入:很多操作系统出于对性能的考虑,会将数据进行缓存而不是立刻写进磁盘,等到缓冲区的数据大小达到操作系统设置的值之后操作系统会一次性的将所有的数据写入磁盘。我们以调用force(true)函数将文件中的内容和文件所对应的元数据信息立即写入到磁盘;
-
练习:在两个channel之间进行数据的传输,我们可以使用transTo()方法来进行数据的传输,该方法可以快速高效的将一个channel中的数据传输到另一个channel中,但一次只能传输2GB大小的内容,并且在底层使用了零拷贝技术;

package com.zhyn.nio; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.nio.channels.FileChannel; public class TestFileChannelTransferTo { public static void main(String[] args) { // data.txt文件中的内容是1234567890abc,to.txt的起始内容是空的,在代码执行完毕之后会和data.txt中的内容保持一致 try(FileChannel from = new FileInputStream("data.txt").getChannel(); FileChannel to = new FileOutputStream("to.txt").getChannel()) { long size = from.size(); for (long surplus = size; surplus > 0; ) { System.out.println("position:" + (size - surplus) + " surplus:" + surplus); // transferTo方法会返回实际传输的字节数 // 该方法可以快速,高效的将一个channel中的数据传输到另一个channel // 但是它一次最多只能传输2G的内容,它的底层使用了零拷贝技术,所以说它的效率是非常高的 surplus -= from.transferTo((size - surplus), surplus, to); } } catch (IOException e) { e.printStackTrace(); } } } -
Path与Paths:在JDK7中引入了Path与Paths,Path用来表示文件的路径,而Paths是一个工具类,用来获取Path实例;
Path source = Paths.get("1.txt"); // 相对路径,不带盘符,使用user.dir环境变量来定位1.txt Path source = Paths.get("d:\\1.txt"); // 绝对路径,代表了d:\1.txt反斜杠需要转义 Path source = Paths.get("d:/1.txt"); // 绝对路径,同样代表了d:\1.txt Path projects = Paths.get("d:\\data", "projects"); // 代表了d:\data\projects // . 代表了当前路径 // .. 代表了上一级目录的路径 // 如果文件的目录结构如下所示: d: |- data |- projects |- a |- b // 那么以下的代码的输出结果将会是: Path path = Paths.get("d:\\data\\projects\\a\\..\\b"); System.out.println(path); System.out.println(path.normalize()); // 正常化路径 会去除 . 以及 .. d:\data\projects\a\..\b d:\data\projects\b -
判断文件是否存在:我们可以通过get("…")方法查找文件路径,通过exists(path)方法来判断文件是否存在;
Path path = Paths.get("myfiles/data.txt"); System.out.println(Files.exists(path)); -
创建一级/多级目录:通过createDirectory(path)方法可以创建一级目录,如果目录已经存在则会抛出FileAlreadyExistsException异常,并且该方法不能一次创建多级目录,否则会抛出NoSuchFileException异常;如果我们想要创建多级目录,那么可以使用createDiirectories(path)方法;
// 创建一级目录 Path path = Paths.get("myfiles/d1"); Files.createDirectory(path); // 创建多级目录 Path path = Paths.get("myfiles/d1/d2"); Files.createDirectories(path); -
拷贝文件:使用copy(source, target)方法可以将source文件中的内容拷贝到target文件中,如果target文件已经存在了,那么该方法将会抛出FileAlreadyExistsException异常,如果想要使用source文件覆盖掉target文件,那么就需要在copy方法中添加StandardCopyOption属性参数:
Path source = Paths.get("myfiles/source.txt"); Path target = Paths.get("myfiles/target.txt"); // 不会出现覆盖现象的copy Files.copy(source, target); // source会覆盖掉已经存在的target Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING);package cn.itcast.nio.c3; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Paths; public class TestFilesCopy { public static void main(String[] args) throws IOException { long start = System.currentTimeMillis(); String source = "D:\\Snipaste-1.16.2-x64"; String target = "D:\\Snipaste-1.16.2-x64aaa"; Files.walk(Paths.get(source)).forEach(path -> { try { // 把source部分替换成target部分 String targetName = path.toString().replace(source, target); // 是目录 if (Files.isDirectory(path)) { // 创建targetName文件夹 Files.createDirectory(Paths.get(targetName)); } // 是普通文件 else if (Files.isRegularFile(path)) { // 把文件拷贝到targetName文件夹 Files.copy(path, Paths.get(targetName)); } } catch (IOException e) { e.printStackTrace(); } }); long end = System.currentTimeMillis(); System.out.println(end - start); } } -
移动文件:通过move(source, target, StandardCopyOption)函数将source处的文件移动到target处
Path source = Paths.get("myfiles/data.txt"); Path target = Paths.get("myfiles/data.txt"); // StandardCopyOption.ATOMIC_MOVE 保证了文件移动的原子性 Files.move(source, target, StandardCopyOption.ATOMIC_MOVE); -
删除文件/目录:delete(target)方法可以删除target文件/目录,如果文件不存在,就会抛出NoSuchFileException异常;一个目录只有在目录之中没有内容的时候才可以删除,如果目录中还有内容,就会抛出DirectoryNotEmptyException异常;
Path target = Paths.get("myfiles/target.txt"); // 删除文件 Files.delete(target); Path target = Paths.get("myfiles/d1"); // 删除文件夹 Files.delete(target); -
遍历文件:我们可以使用Files工具类中的walkFileTree(Path, FileVisitor)方法来对文件结构进行遍历,其中需要传入的两个参数分别是Path:文件的起始路径,FileVisitor:文件的访问器,访问器的设计采用了设计模式中的访问者模式,FileVisitor是一个接口,它的实现类是SimpleFileVisitor,SimpleFileVisitor有四个用于遍历的方法,分别是preVisitDirectory(访问目录前的操作),visitFile(访问文件的操作),visitFileFailed(访问文件失败时的操作),postVisitDirectory(访问目录之后的操作);
public class TestWalkFileTree { public static void main(String[] args) throws IOException { Path path = Paths.get("C:\\JDK 8"); // 文件目录数目 AtomicInteger dirCount = new AtomicInteger(); // 文件数目 AtomicInteger fileCount = new AtomicInteger(); Files.walkFileTree(path, new SimpleFileVisitor<Path>() { @Override public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException { System.out.println("===>" + dir); // 增加文件目录数 dirCount.incrementAndGet(); return super.preVisitDirectory(dir, attrs); } @Override public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { System.out.println(file); // 增加文件数 fileCount.incrementAndGet(); return super.visitFile(file, attrs); } }); // 打印数目 System.out.println("文件目录数:" + dirCount.get()); System.out.println("文件数:" + fileCount.get()); } }
6. 网络编程
6.1 阻塞模式
-
在阻塞模式下相关的方法会导致线程暂停,ServerSocketChannel.accept()方法会在没有连接建立时让线程暂停;SocketChannel.read()会在没有数据可读的时候让线程暂停;阻塞的表现就是线程暂停运行,期间不会占用CPU,所以说阻塞的时候线程相当于是闲置的;
-
在单线程下阻塞方法之间相互影响以至于程序几乎不能正常工作,需要多线程的支持,但是在多线程的模式下又出现了很多新的问题,具体表现在以下的几个方面:
- 32位的JVM一个线程的大小是320KB,64位JVM一个线程的大小是1024KB,如果连接数过多的话必然会导致OOM异常,并且线程太多的话反而会因为频繁的上下文切换导致性能的降低;
- 虽然可以使用线程池的方式来减少线程数,但是该方法治标不治本,在连接数很多的时候且长时间占用线程池中的线程,那么线程池中所有的线程都可能会被阻塞,因此这种方式不适合长链接只适合短连接;
-
阻塞模式下的代码以及运行结果如下所示:
package com.zhyn.nio; import java.io.IOException; import java.net.InetSocketAddress; import java.nio.ByteBuffer; import java.nio.channels.ServerSocketChannel; import java.nio.channels.SocketChannel; import java.util.ArrayList; import static com.zhyn.nio.ByteBufferUtil.debugRead; public class Sever_0 { public static void main(String[] args) { // 创建一个缓冲区 ByteBuffer buffer = ByteBuffer.allocate(16); // 获得服务器的通道 try(ServerSocketChannel serverSocketChannel = ServerSocketChannel.open()) { // 为服务器的通道绑定相应的端口 serverSocketChannel.bind(new InetSocketAddress(8080)); // 存放用户连接的集合 ArrayList<SocketChannel> channels = new ArrayList<>(); // 服务器循环接收连接 while (true) { System.out.println("before connecting..."); // accept()方法会在没有连接的时候阻塞线程 SocketChannel socketChannel = serverSocketChannel.accept(); System.out.println("after connecting..."); channels.add(socketChannel); for (SocketChannel channel : channels) { System.out.println("before reading..."); // 处理管道中的数据,当没有数据可读的时候会阻塞线程 channel.read(buffer); buffer.flip(); debugRead(buffer); buffer.clear(); System.out.println("after reading..."); } } } catch (IOException e) { e.printStackTrace(); } } }package com.zhyn.nio; import java.io.IOException; import java.net.InetSocketAddress; import java.nio.channels.SocketChannel; public class Client { public static void main(String[] args) throws IOException { SocketChannel sc = SocketChannel.open(); sc.connect(new InetSocketAddress("localhost", 8080)); // SocketAddress address = sc.getLocalAddress(); // sc.write(Charset.defaultCharset().encode("1234\n56789abcdefghidsafdffdafFSFFFSDFEFA\n")); System.in.read(); } } -
运行结果如下所示:客户端与服务器在建立连接之前服务器会因为accept()方法而阻塞

-
启动客户端客户端与服务器建立连接之后,在客户端发送消息之前服务器会因为管道为空而阻塞;

-
客户端向管道中发送数据之后服务器会处理管道中的数据,只后悔再次进入循环并被accept方法阻塞;


-
如果客户端继续向管道中发送数据,服务器会因为被accept阻塞而无法处理客户端发送到管道中的信息;


6.2 非阻塞模式
-
非阻塞模式下,相关方法都会不会让线程暂停,我们可以通过SeverSocketChannel的configureBlocking(false)方法将获取连接设置为非阻塞的,通过SocketChannel的configureBlocking(false)将从通道中读取数据设置为非阻塞的,此时ServerSocketChannel.accept方法在没有连接建立时会返回 null并继续运行;而SocketChannel.read方法在没有数据可读时会返回 0,但线程不必阻塞,它可以去执行其它 SocketChannel 的 read 或是去执行 ServerSocketChannel 的 accept ;
-
但非阻塞模式下即使没有连接建立和可读数据,线程仍然在不断运行,白白浪费了CPU资源;以下展示了在非阻塞模式下服务器端的代码,由于这种方式会导致CPU资源被大量的浪费,所以在实际情况中并不会使用这种方式来处理请求;
public class Server { public static void main(String[] args) { // 创建缓冲区 ByteBuffer buffer = ByteBuffer.allocate(16); // 获得服务器通道 try(ServerSocketChannel server = ServerSocketChannel.open()) { // 为服务器通道绑定端口 server.bind(new InetSocketAddress(8080)); // 用户存放连接的集合 ArrayList<SocketChannel> channels = new ArrayList<>(); // 循环接收连接 while (true) { // 设置为非阻塞模式,没有连接时返回null,不会阻塞线程 server.configureBlocking(false); SocketChannel socketChannel = server.accept(); // 通道不为空时才将连接放入到集合中 if (socketChannel != null) { System.out.println("after connecting..."); channels.add(socketChannel); } // 循环遍历集合中的连接 for(SocketChannel channel : channels) { // 处理通道中的数据 // 设置为非阻塞模式,若通道中没有数据,会返回0,不会阻塞线程 channel.configureBlocking(false); int read = channel.read(buffer); if(read > 0) { buffer.flip(); ByteBufferUtil.debugRead(buffer); buffer.clear(); System.out.println("after reading"); } } } } catch (IOException e) { e.printStackTrace(); } } }
6.3 多路复用模式
-
单线程可以配合Selector完成对多个Channel可读写事件的监控,这就是多路复用技术;多路复用仅针对于非阻塞的网络IO,由于普通文件IO没有非阻塞模式,所以普通文件IO不能够和Selector搭配使用;如果我们不使用Selector的非阻塞模式,那么线程在绝大多数的时间内都在做无用功,而Selector能够保证有可连接/可读/可写事件发生的时候才会进行连接/读取/写入操作;限于网络传输的能力,Channel 未必时时可写,一旦 Channel 可写,就会触发 Selector 的可写事件;Selector让被使用的线程能够被充分的利用并减少了上下文切换;

-
创建Selector:
Selector selector = Selector.open(); -
绑定channel事件:也称为注册事件,selector只会关心被绑定的事件;channel必须工作在非阻塞模式下,绑定事件的类型有:connect(客户端连接成功的时候触发),accept(服务器端成功接收连接的时候触发),read(数据可读入的时候触发,可能会出现因为接收方接收能力较弱数据暂时不能读入的情况),write(数据可写的时候触发,可能会出现发送方发送能力较弱数据暂时不能写出的情况);
channel.configureBlocking(false); SelectionKey key = channel.register(selector, 绑定事件); -
监听Channel事件:可以通过以下的三种方法来监听是否有事件发生,方法的返回值代表了有多少channel发生了事件;
// 阻塞直到绑定的事件发生 int count = selector.select(); // 阻塞直到绑定的事件发生或者是时间超时 int count = selector.select(long timeout); // 不会阻塞,不管有没有事件发生都会立即返回,我们可以根据返回的值检查是否有事件发生 int count = selector.selectNow(); -
select在以下情况中不会阻塞:
- 客户端发起连接请求的时候会触发accept事件;
- 客户端发送数据,客户端正常关闭,客户端异常关闭都会触发read事件,如果客户端发送的数据大小大于buffer缓冲区的大小,那么客户端就会多次触发读取事件;
- channel可写的时候会触发write事件;
- linux有一个nio的bug,当这个bug发生的时候select也会停止阻塞;
- 调用selector.wakeup()或调用selector.close()以及selector所在的线程被中断的时候select均不会发生阻塞;
-
使用多路复用处理accept事件的代码:
package com.zhyn.nio; import lombok.extern.slf4j.Slf4j; import java.io.IOException; import java.net.InetSocketAddress; import java.nio.channels.SelectionKey; import java.nio.channels.Selector; import java.nio.channels.ServerSocketChannel; import java.nio.channels.SocketChannel; import java.util.Iterator; @Slf4j public class Server_1 { public static void main(String[] args) throws IOException { // 1. 创建selector用于管理多个channel Selector selector = Selector.open(); ServerSocketChannel serverChannel = ServerSocketChannel.open(); // 管道必须是非阻塞的,因为只有非阻塞的管道才能和selector组合使用 serverChannel.configureBlocking(false); // 2. 建立channel与selector之间的联系,就是将channel注册在selector // SelectionKey可以在事件发生的时候得到事件的相关信息以及具体哪一个管道发生了指定的事件 // 对于同一类型的事件,channel不同但是最终的key是相同的 SelectionKey key = serverChannel.register(selector, 0, null); // 将key设置为它只关心的事件,该事件也可以通过以上语句的ops设置 key.interestOps(SelectionKey.OP_ACCEPT); log.debug("register key:{}", key); // 将管道绑定至一个端口 serverChannel.bind(new InetSocketAddress(8080)); while (true) { // 3. 不断调用select方法,当没有事件发生的时候,线程会被阻塞,当有事件发生的时候线程会恢复运行 // 如果我们不对事件进行处理,也就是有未处理事件的时候,select()是不会阻塞的 // 如果不想处理事件可以使用cancel()将事件取消 selector.select(); // 4. 处理事件, selectKeys内部包含了所有发生的事件 Iterator<SelectionKey> iterator = selector.selectedKeys().iterator(); while (iterator.hasNext()) { SelectionKey key1 = iterator.next(); log.debug("{}", key1); // 得到发生的事件对应的channel,此时我们只有serverChannel,所以发生事件的channel一定是serverChannel ServerSocketChannel channel = (ServerSocketChannel) key1.channel(); // 此时假设客户端只会发出连接请求事件 SocketChannel socketChannel = channel.accept(); log.debug("{}", socketChannel); } } } }package com.zhyn.nio; import java.io.IOException; import java.net.InetSocketAddress; import java.nio.channels.SocketChannel; public class Client { public static void main(String[] args) throws IOException { SocketChannel sc = SocketChannel.open(); sc.connect(new InetSocketAddress("localhost", 8080)); System.in.read(); } } -
运行结果

-
事件发生之后可不可以不进行处理:事件发生之后要么处理,要么取消(cancel),不能什么都不做,否则下一次该事件仍会触发,因为nio底层使用的是水平触发;
-
Read事件:在Accept事件中,如果有客户端与服务器建立了连接,则需要将服务器对应的channel设置为非阻塞的,并注册在选择器之中;我们在这里尝试添加Read事件,在事件触发之后将执行读取操作;
-
添加读取操作之后的代码以及运行结果:
package com.zhyn.nio; import lombok.extern.slf4j.Slf4j; import java.io.IOException; import java.net.InetSocketAddress; import java.nio.ByteBuffer; import java.nio.channels.SelectionKey; import java.nio.channels.Selector; import java.nio.channels.ServerSocketChannel; import java.nio.channels.SocketChannel; //import java.nio.charset.Charset; import java.util.Iterator; import static com.zhyn.nio.ByteBufferUtil.debugAll; //import static com.zhyn.c1.ByteBufferUtil.debugRead; @Slf4j public class Server_2 { public static void main(String[] args) throws IOException { // 1. 创建selector用于管理多个channel Selector selector = Selector.open(); // 创建服务器端的channel ServerSocketChannel serverChannel = ServerSocketChannel.open(); // 管道必须是非阻塞的,因为只有非阻塞的管道才能和selector组合使用 serverChannel.configureBlocking(false); // 2. 建立channel与selector之间的联系,就是将channel注册在selector // SelectionKey可以在事件发生的时候得到事件的相关信息以及具体哪一个管道发生了指定的事件 // 对于同一类型的事件,channel不同但是最终的key是相同的 SelectionKey key = serverChannel.register(selector, 0, null); // 将key设置为它只关心的事件,该事件也可以通过以上语句的ops设置 key.interestOps(SelectionKey.OP_ACCEPT); log.debug("register key:{}", key); // 将管道绑定至一个端口 serverChannel.bind(new InetSocketAddress(8080)); while (true) { // 3. 不断调用select方法,当没有事件发生的时候,线程会被阻塞,当有事件发生的时候线程会恢复运行 // 如果我们不对事件进行处理,也就是有未处理事件的时候,select()是不会阻塞的 // 如果不想处理事件可以使用cancel()将事件取消 // select()的返回值是就绪的事件个数 selector.select(); // 4. 处理事件, selectKeys内部包含了所有发生的事件 Iterator<SelectionKey> iterator = selector.selectedKeys().iterator(); while (iterator.hasNext()) { SelectionKey key1 = iterator.next(); // 每当事件被处理之后,key中的相应的事件属性就会被移除,但是key还是存在于selectedKey集合中 // 再次执行的时候还是会遍历到已经被处理到的事件的key,比如会遇到需要处理accept事件的key // 但是这时候连接已经被建立过了,不需要再建立连接(因为是旧的key嘛),所以会出现空指针异常 // 所以我们应当自行移除已经被处理过的key iterator.remove(); log.debug("{}", key1); // 5. 区分事件的类型,先建立连接后读取数据 if (key1.isAcceptable()) { // 得到发生的事件对应的channel,只有服务器的channel会关心accept事件 ServerSocketChannel channel = (ServerSocketChannel) key1.channel(); // 获取与客户端的连接并得到对应的客户端的channel SocketChannel socketChannel = channel.accept(); // 将客户端的channel设置为非阻塞的 socketChannel.configureBlocking(false); ByteBuffer buffer = ByteBuffer.allocate(16); // 将客户端的channel注册在选择器上并将ByteBuffer作为一个附件关联到scKey上 SelectionKey scKey = socketChannel.register(selector, 0, buffer); scKey.interestOps(SelectionKey.OP_READ); log.debug("{}", socketChannel); log.debug("scKey:{}", scKey); } else if (key1.isReadable()) { // 如果是读事件,那么一定是SocketChannel try { SocketChannel channel = (SocketChannel) key1.channel(); // 由于还要处理发送的消息大于16字节的情况,所以ByteBuffer不应该是局部变量,而应该让两次读操作都使用同一个buffer(因为是读取同一个channel) // 但是又不能直接将buffer提到外面,这样做的话多个channel会共用同一个buffer,而每一个channel都应该拥有一个自己的buffer // 解决方式就是使用attachment附件,也就是register()方法的第三个参数,将buffer作为附件关联到客户端的scKey上面 // 注意是在对channel进行读取操作的时候,后续的操作与之前的操作是针对同一个buffer进行的,防止因channel中的数据过大而无法一次性的全部写入buffer // 却又在后续读取同一个channel数据写入buffer时不再是该channel对应的buffer // ByteBuffer buffer = ByteBuffer.allocate(16); // 获取selectionKey上面关联的附件,在后续的read事件中操作的将使同一个buffer,可以将第一次没有处理完的数据继续进行处理 ByteBuffer buffer = (ByteBuffer) key1.attachment(); // 如果是正常断开,read方法的返回值是-1,在正常断开的时候,如果不注销相应的key也会导致服务器空转 int read = channel.read(buffer); if (read == -1) { key1.cancel(); } else { /* 这种处理方式忽略了消息边界,正常情况下都是要处理消息边界的 buffer.flip(); debugRead(buffer); System.out.println(Charset.defaultCharset().decode(buffer)); */ split(buffer); // 表明没有划分出任何一条消息,也就是单个消息的大小超过了缓冲区的大小 if (buffer.position() == buffer.limit()) { // 所以可以对buffer缓冲区进行扩容操作 ByteBuffer newBuffer = ByteBuffer.allocate(buffer.capacity() << 1); buffer.flip(); // 将旧的buffer中的内容放置于新的buffer之中 newBuffer.put(buffer); // 同样的我们也需要将新的buffer与selectionKey进行关联,以确保后续读取channel中的数据写入buffer中的操作都是channel与buffer一一对应的 key1.attach(newBuffer); } } // 不捕获异常的话强制停止客户端的时候服务端也会被挂掉 } catch (IOException e) { e.printStackTrace(); //客户端关闭之后(正常断开或者是异常断开)会引发一个read事件,但是这个read事件不会被处理,必须将这个key反注册掉(从selector的keys集合中真正删除key),不然的话服务器会一直空转 key1.cancel(); } } } } } private static void split(ByteBuffer source) { source.flip(); for (int i = 0; i < source.limit(); i++) { // 找到一个完整的信息 if (source.get(i) == '\n') { int length = i + 1 - source.position(); // 把这条消息存入新的ByteBuffer ByteBuffer target = ByteBuffer.allocate(length); for (int j = 0; j < length; j++) { target.put(source.get()); } debugAll(target); System.out.println(source.capacity()); } } source.compact(); } }package com.zhyn.nio; import java.io.IOException; import java.net.InetSocketAddress; //import java.net.SocketAddress; import java.nio.channels.SocketChannel; import java.nio.charset.Charset; public class Client { public static void main(String[] args) throws IOException { SocketChannel sc = SocketChannel.open(); sc.connect(new InetSocketAddress("localhost", 8080)); // SocketAddress address = sc.getLocalAddress(); sc.write(Charset.defaultCharset().encode("1234\n56789abcdefghidsafdffdafFSFFFSDFEFA\n")); System.in.read(); } }20:55:53 [DEBUG] [main] c.z.nio.Server_2 - register key:sun.nio.ch.SelectionKeyImpl@7dc36524 20:55:57 [DEBUG] [main] c.z.nio.Server_2 - sun.nio.ch.SelectionKeyImpl@7dc36524 20:55:57 [DEBUG] [main] c.z.nio.Server_2 - java.nio.channels.SocketChannel[connected local=/127.0.0.1:8080 remote=/127.0.0.1:59990] 20:55:57 [DEBUG] [main] c.z.nio.Server_2 - scKey:sun.nio.ch.SelectionKeyImpl@27f674d 20:55:57 [DEBUG] [main] c.z.nio.Server_2 - sun.nio.ch.SelectionKeyImpl@27f674d +--------+-------------------- all ------------------------+----------------+ position: [5], limit: [5] +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 31 32 33 34 0a |1234. | +--------+-------------------------------------------------+----------------+ // 缓冲区初始的大小就是16 16 20:55:57 [DEBUG] [main] c.z.nio.Server_2 - sun.nio.ch.SelectionKeyImpl@27f674d 20:55:57 [DEBUG] [main] c.z.nio.Server_2 - sun.nio.ch.SelectionKeyImpl@27f674d 20:55:57 [DEBUG] [main] c.z.nio.Server_2 - sun.nio.ch.SelectionKeyImpl@27f674d +--------+-------------------- all ------------------------+----------------+ position: [36], limit: [36] +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 35 36 37 38 39 61 62 63 64 65 66 67 68 69 64 73 |56789abcdefghids| |00000010| 61 66 64 66 66 64 61 66 46 53 46 46 46 53 44 46 |afdffdafFSFFFSDF| |00000020| 45 46 41 0a |EFA. | +--------+-------------------------------------------------+----------------+ // 后来发现16字节大小的容量不够,就先扩大为32,发现还是没有划分出一个完整的消息,就再次扩充为64 64 -
为什么要删除事件:当处理完一个事件后,一定要调用迭代器的remove方法移除对应的事件,否则会出现错误,原因如下:以我们上面的代码为例,当调用了serverChannel.register方法之后,Selector之中就会维护一个集合,用于存放SelectionKey以及对应的通道,当selector中的通道对应的事件发生之后,selecionKey会被放到另一个集合中,但是selecionKey不会被自动移除,所以需要我们在处理完一个事件后通过迭代器手动移除其中的selecionKey,否则会导致已被处理过的事件再次被处理从而引发各种错误;

-
断开处理:当客户端与服务器之间的连接断开时,会给服务器端发送一个读事件,对异常断开和正常断开需要加以不同的方式进行处理;正常断开时,服务器端的channel.read(buffer)方法的返回值为-1,所以当结束到返回值为-1时,需要调用key的cancel方法取消此事件,并在取消后移除该事件;在我们之前的代码之中通过构造器获得SelectionKey之后就直接将其移除了;异常断开时,会抛出IOException异常, 在try-catch的catch块中捕获异常并调用key的cancel方法即可;
-
消息边界处理:如果不处理消息边界的话程序的运行一定会出现问题的,比如将缓冲区的大小设置为4个字节,发送2个汉字(你好)并通过decode解码并打印时会出现乱码:
ByteBuffer buffer = ByteBuffer.allocate(4); // 解码并打印 System.out.println(StandardCharsets.UTF_8.decode(buffer)); // 运行结果: 你� �� -
这是因为UTF-8字符集下,1个汉字占用3个字节,此时缓冲区大小为4个字节,一次读操作无法处理完通道中的所有数据,所以一共会触发两次读事件。这就导致 “你好” 的 “好” 字被拆分为了前半部分和后半部分发送,解码时就会出现问题;
-
在传输文本的时候可能会出现以下的三种情况:文本大于缓冲区大小,此时需要将缓冲区进行扩容;发生半包现象;发生粘包现象;

-
解决该问题往往有以下的几种解决方案:一种思路是固定消息长度,数据包大小一定,服务器按预定长度读取,缺点是浪费带宽;另一种思路是按分隔符拆分,缺点是效率低;还有一种思路是TLV 格式,即 Type 类型、Length 长度、Value 数据(也就是在消息开头用一些空间存放后面数据的长度),如HTTP请求头中的Content-Type与Content-Length。类型和长度已知的情况下,就可以方便获取消息大小,分配合适的 buffer,缺点是 buffer 需要提前分配,如果内容过大,则影响 server 吞吐量;Http 1.1 是 TLV 格式,Http 2.0 是 LTV 格式;我们采取的思路是按分隔符拆分;

-
按分隔符拆分的实现方式就是附件与扩容,Channel的register方法还有第三个参数:附件,可以向其中放入一个Object类型的对象,该对象会与登记的Channel以及其对应的SelectionKey绑定,可以从SelectionKey.attachment()方法获取到对应通道的附件;
public final SelectionKey register(Selector sel, int ops, Object att); ByteBuffer buffer = (ByteBuffer) key.attachment(); -
我们需要在Accept事件发生后,将通道注册到Selector中时,对每个通道添加一个ByteBuffer附件,让每个通道发生读事件时都使用自己的buffer,避免与其他通道发生冲突而导致问题;
ByteBuffer buffer = ByteBuffer.allocate(16); // 将客户端的channel注册在选择器上并将ByteBuffer作为一个附件关联到scKey上 SelectionKey scKey = socketChannel.register(selector, 0, buffer); -
当Channel中的数据大小大于缓冲区大小时,需要对缓冲区进行扩容操作。此代码中的扩容判定方法是:如果buffer.position() == buffer.limit(),表明没有划分出任何一条消息,也就是单个消息的大小超过了缓冲区的大小,此时创建新的缓冲区,其大小扩大为两倍。同时还要将旧缓冲区中的数据拷贝到新的缓冲区中,同时调用SelectionKey的attach方法将新的缓冲区作为新的附件放入SelectionKey中;
-
ByteBuffer的大小分配:每个 channel 都需要记录可能被切分的消息,因为 ByteBuffer 不能被多个 channel 共同使用,因此需要为每个 channel 维护一个独立的 ByteBuffer;ByteBuffer 不能太大,比如一个 ByteBuffer 1Mb 的话,要支持百万连接就要 1Tb 内存,因此需要设计大小可变的 ByteBuffer;一种思路是首先分配一个较小的 buffer,例如 4k,如果发现数据不够,再分配 8k 的 buffer,将 4k buffer 内容拷贝至 8k buffer,优点是消息连续容易处理,缺点是数据拷贝耗费性能;另一种思路是用多个数组组成 buffer,一个数组不够,把多出来的内容写入新的数组,与前面的区别是消息存储不连续解析复杂,优点是避免了拷贝引起的性能损耗;
-
write事件:服务器通过Buffer向通道中写入数据时,可能因为通道容量小于Buffer中的数据大小,导致无法一次性将Buffer中的数据全部写入到Channel中,这时便需要分多次写入,具体步骤如下:执行一次写操作,将Buffer中的内容写入到SocketChannel中,然后判断Buffer中是否还有数据;若Buffer中还有数据,则需要将SockerChannel注册到Seletor中,并关注写事件,同时将未写完的Buffer作为附件一起放入到SelectionKey中;每次写之后都需要判断Buffer中是否还有数据(是否写完)。若写完,需要移除SelecionKey中的Buffer附件,因为buffer所占用的内存是非常大的,我们需要避免其占用过多内存,同时还需移除对写事件的关注;
package com.zhyn.nio; import java.io.IOException; import java.net.InetSocketAddress; import java.nio.ByteBuffer; import java.nio.channels.SelectionKey; import java.nio.channels.Selector; import java.nio.channels.ServerSocketChannel; import java.nio.channels.SocketChannel; import java.nio.charset.Charset; import java.util.Iterator; public class WriteServer { public static void main(String[] args) throws IOException { // ServerSocketChannel对应于服务器;SocketChannel对应于客户端 ServerSocketChannel serverSocketChannel = ServerSocketChannel.open(); // 设置为非阻塞模式,只有这样才能够使用selector serverSocketChannel.configureBlocking(false); Selector selector = Selector.open(); serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT); serverSocketChannel.bind(new InetSocketAddress(8080)); while (true) { // 有事件发生的时候select()方法才会继续向下运行 selector.select(); // 拿到selector监听到的所有发生的事件,并创建一个迭代器(因为要对key结构进行删除) Iterator<SelectionKey> iterator = selector.selectedKeys().iterator(); while (iterator.hasNext()) { // 获取selector监听到的已经发生的事件对应的key结构 SelectionKey key = iterator.next(); // 主动删除key结构,因为将key结构所对应的事件处理过之后,key结构并不会自动删除 iterator.remove(); // 如果是连接请求时间 if (key.isAcceptable()) { // key.channel()拿到的其实就是ServerSocketChannel // 但是SocketChannel不能直接获得,因为SocketChannel有多个; // 我们必须根据触发事件对应的key获得相应的SocketChannel // 建立连接 SocketChannel socketChannel = serverSocketChannel.accept(); socketChannel.configureBlocking(false); SelectionKey scKey = socketChannel.register(selector, 0, null); scKey.interestOps(SelectionKey.OP_READ); // 向客户端发送大量的数据 StringBuilder stringBuilder = new StringBuilder(); for (int i = 0; i < 5000000; i++) { stringBuilder.append("a"); } // 将数据放置于缓冲区, ByteBuffer buffer = Charset.defaultCharset().encode(stringBuilder.toString()); /* // write()方法不能保证一次性的将所有的数据都发送到客户端里面,它的返回值就是实际写入的字节数 // 所以只要buffer中还存在数据,就一直向channel中写入数据发送到客户端 while (buffer.hasRemaining()) { // write()的底层通过操作系统的发送缓冲区来控制一次能够发送多少的数据 // 当我们要发送的数据太大的时候,服务器的write是不能够一次性的把所有数据都发送给客户端的 // 通过while不停地发送数据不符合非阻塞的思想 // 相当于如果有新的事件发生的时候我们无法处理(此时while一直在循环) // 当操作系统分配的发送缓冲区满的时候,我们可以去处理其他的事件,当发送缓冲区空的时候我们再继续处理写事件 int write = socketChannel.write(buffer); System.out.println(write); } */ int write = socketChannel.write(buffer); System.out.println(write); // 有剩余内容的时候关注可写事件 if (buffer.hasRemaining()) { scKey.interestOps(scKey.interestOps() + SelectionKey.OP_WRITE); // 再次发生写事件的时候继续将buffer中剩余的数据写入到同一个管道 // 所以要把未写完的数据用附件的方式挂到scKey上 scKey.attach(buffer); } } else if (key.isWritable()) { // 取出作为附件的buffer,继续向channel中写数据 ByteBuffer buffer = (ByteBuffer) key.attachment(); // 得到key关注的SocketChannel SocketChannel sc = (SocketChannel) key.channel(); int write = sc.write(buffer); System.out.println(write); // 由于buffer所占用的内存是非常大的,所以要进行清理操作 if (!buffer.hasRemaining()) { key.attach(null); // 不需要再关注可写事件 key.interestOps(0); } } } } } }package com.zhyn.nio; import java.io.IOException; import java.net.InetSocketAddress; import java.nio.ByteBuffer; import java.nio.channels.SocketChannel; public class WriteClient { public static void main(String[] args) throws IOException { SocketChannel socketChannel = SocketChannel.open(); socketChannel.connect(new InetSocketAddress(8080)); int count = 0; while (true) { ByteBuffer buffer = ByteBuffer.allocate(1024 * 1024); // 将管道(channel)中的数据写入到客户端的缓冲区里面 count += socketChannel.read(buffer); System.out.println(count); // 将客户端的缓冲区清理之后才能够继续把channel的内容写入到buffer buffer.clear(); } } } -
运行结果:


6.4 多线程优化
-
现在的计算机都是多核CPU,我们在设计时要充分考虑CPU资源的使用情况,避免让CPU资源被白白的浪费;以上的代码中都只有一个选择器,没有充分利用多核的CPU,我们需要对以上的代码进行改进;具体来说就是要分两组选择器充分利用CPU资源:单线程配一个选择器,专门处理 accept 事件;创建与 CPU 核心数相同数目的线程,每个线程配一个选择器,轮流处理 read 事件;
-
实现思路就是创建一个负责处理Accept事件的Boss线程,与多个负责处理Read事件的Worker线程;Boss线程接受并处理Accepet事件,当Accept事件发生后,调用Worker的register(SocketChannel socket)方法,让Worker去处理Read事件,其中需要根据round robin轮询去判断将任务分配给哪个Worker;Worker线程处理Read事件;
// 1. 创建固定数量的 worker 并初始化,Runtime.getRuntime().availableProcessors()可以获得CPU的物理核心数 // 如果使用的是虚拟机,且虚拟机只被分配了4个CPU,而物理核心是8个,那么该方法的返回值依然是8 Worker[] workers = new Worker[Runtime.getRuntime().availableProcessors()]; // 用于负载均衡的原子整数 AtomicInteger index = new AtomicInteger(); // round robin 轮询 // boss调用并初始化selector , 启动worker-0 workers[index.getAndIncrement() % workers.length].register(sc); -
完整代码实现
package com.zhyn.nio; import lombok.extern.slf4j.Slf4j; import java.io.IOException; import java.net.InetSocketAddress; import java.nio.ByteBuffer; import java.nio.channels.*; import java.util.Iterator; import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.atomic.AtomicInteger; import static com.zhyn.nio.ByteBufferUtil.debugAll; @Slf4j public class MultiThreadServer { public static void main(String[] args) throws IOException { Thread.currentThread().setName("boss"); ServerSocketChannel ssc = ServerSocketChannel.open(); ssc.configureBlocking(false); Selector boss = Selector.open(); SelectionKey bossKey = ssc.register(boss, 0, null); bossKey.interestOps(SelectionKey.OP_ACCEPT); ssc.bind(new InetSocketAddress(8080)); // 1. 创建固定数量的 worker 并初始化,Runtime.getRuntime().availableProcessors()可以获得CPU的物理核心数 // 如果使用的是虚拟机,且虚拟机只被分配了4个CPU,而物理核心是8个,那么该方法的返回值依然是8 Worker[] workers = new Worker[Runtime.getRuntime().availableProcessors()]; for (int i = 0; i < workers.length; i++) { workers[i] = new Worker("worker-" + i); } // 用于负载均衡的原子整数 AtomicInteger index = new AtomicInteger(); while(true) { boss.select(); Iterator<SelectionKey> iter = boss.selectedKeys().iterator(); while (iter.hasNext()) { SelectionKey key = iter.next(); iter.remove(); if (key.isAcceptable()) { SocketChannel sc = ssc.accept(); sc.configureBlocking(false); log.debug("connected...{}", sc.getRemoteAddress()); // 2. 关联 selector log.debug("before register...{}", sc.getRemoteAddress()); // round robin 轮询 workers[index.getAndIncrement() % workers.length].register(sc); // boss线程调用并初始化selector,启动worker线程 log.debug("after register...{}", sc.getRemoteAddress()); } } } } static class Worker implements Runnable{ private Thread thread; private Selector selector; private String name; private volatile boolean start = false; // 还未初始化 private ConcurrentLinkedQueue<Runnable> queue = new ConcurrentLinkedQueue<>(); public Worker(String name) { this.name = name; } // 初始化线程和selector public void register(SocketChannel sc) throws IOException { // 保证相关的线程只启动一次 if(!start) { selector = Selector.open(); thread = new Thread(this, name); thread.start(); start = true; } // 该方法相当于是给select发一个票,无论wakeup什么时候执行,当select阻塞的时候都会被唤醒,然后这个票就会被销毁 // selector.select()是在worker线程中执行的,而sc.register(selector, SelectionKey.OP_READ, null)是在boss线程中执行的 // 但是它们又共同使用了相同的selector,此时它们就会相互影响 // 如果是selector.select()先执行,那么select()就会发生阻塞,进而影响到register // 具体的表现就是select()发生阻塞时register就不能执行了,直到有事件到达select()阻塞结束 // 有两种主要的解决方式,一种是像这样直接使用selector.wakeup()唤醒select()方法; // 还有一种是使用同步队列,用于Boss线程与Worker线程之间的通信 selector.wakeup(); // 唤醒select方法 sc.register(selector, SelectionKey.OP_READ, null); // boss } @Override public void run() { while(true) { try { selector.select(); // worker阻塞 Iterator<SelectionKey> iter = selector.selectedKeys().iterator(); while (iter.hasNext()) { SelectionKey key = iter.next(); iter.remove(); if (key.isReadable()) { try { ByteBuffer buffer = ByteBuffer.allocate(16); SocketChannel channel = (SocketChannel) key.channel(); log.debug("read...{}", channel.getRemoteAddress()); final int read = channel.read(buffer); if (read == -1) key.cancel(); buffer.flip(); debugAll(buffer); } catch (IOException e) { e.printStackTrace(); key.cancel(); } } } } catch (IOException e) { e.printStackTrace(); } } } } }package com.zhyn.nio; import java.io.IOException; import java.net.InetSocketAddress; import java.nio.channels.SocketChannel; import java.nio.charset.Charset; public class MultiThreadClient { public static void main(String[] args) throws IOException { SocketChannel socketChannel = SocketChannel.open(); socketChannel.connect(new InetSocketAddress(8080)); socketChannel.write(Charset.defaultCharset().encode("1234567890abcdef")); System.in.read(); } } -
运行结果:

-
selector.wakeup()方法的说明:该方法相当于是给select()发一个票,无论wakeup()与select()以什么样的顺序执行,当select()阻塞的时候都会被唤醒,然后这个票就会被销毁;
-
另一种实现方式
package com.zhyn.nio; import java.io.IOException; import java.net.InetSocketAddress; import java.nio.ByteBuffer; import java.nio.channels.SelectionKey; import java.nio.channels.Selector; import java.nio.channels.ServerSocketChannel; import java.nio.channels.SocketChannel; import java.util.Iterator; import java.util.Set; import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.atomic.AtomicInteger; public class ThreadsServer { public static void main(String[] args) { try (ServerSocketChannel server = ServerSocketChannel.open()) { // 当前线程为Boss线程 Thread.currentThread().setName("Boss"); server.bind(new InetSocketAddress(8080)); // 负责轮询Accept事件的Selector Selector boss = Selector.open(); server.configureBlocking(false); server.register(boss, SelectionKey.OP_ACCEPT); // 创建固定数量的Worker Worker[] workers = new Worker[4]; // 用于负载均衡的原子整数 AtomicInteger robin = new AtomicInteger(0); for(int i = 0; i < workers.length; i++) { workers[i] = new Worker("worker-"+i); } while (true) { boss.select(); Set<SelectionKey> selectionKeys = boss.selectedKeys(); Iterator<SelectionKey> iterator = selectionKeys.iterator(); while (iterator.hasNext()) { SelectionKey key = iterator.next(); iterator.remove(); // BossSelector负责Accept事件 if (key.isAcceptable()) { // 建立连接 SocketChannel socket = server.accept(); System.out.println("connected..."); socket.configureBlocking(false); // socket注册到Worker的Selector中 System.out.println("before read..."); // 负载均衡,轮询分配Worker workers[robin.getAndIncrement()% workers.length].register(socket); System.out.println("after read..."); } } } } catch (IOException e) { e.printStackTrace(); } } static class Worker implements Runnable { private Thread thread; private volatile Selector selector; private String name; private volatile boolean started = false; /** * 同步队列,用于Boss线程与Worker线程之间的通信 */ private ConcurrentLinkedQueue<Runnable> queue; public Worker(String name) { this.name = name; } public void register(final SocketChannel socket) throws IOException { // 只启动一次 if (!started) { thread = new Thread(this, name); selector = Selector.open(); queue = new ConcurrentLinkedQueue<>(); thread.start(); started = true; } // 向同步队列中添加SocketChannel的注册事件 // 在Worker线程中执行注册事件 queue.add(new Runnable() { @Override public void run() { try { socket.register(selector, SelectionKey.OP_READ); } catch (IOException e) { e.printStackTrace(); } } }); // 唤醒被阻塞的Selector // select类似LockSupport中的park,wakeup的原理类似LockSupport中的unpark selector.wakeup(); } @Override public void run() { while (true) { try { selector.select(); // 通过同步队列获得任务并运行 Runnable task = queue.poll(); if (task != null) { // 获得任务,执行注册操作 task.run(); } Set<SelectionKey> selectionKeys = selector.selectedKeys(); Iterator<SelectionKey> iterator = selectionKeys.iterator(); while(iterator.hasNext()) { SelectionKey key = iterator.next(); iterator.remove(); // Worker只负责Read事件 if (key.isReadable()) { try { // 简化处理,省略细节 SocketChannel socket = (SocketChannel) key.channel(); ByteBuffer buffer = ByteBuffer.allocate(16); final int read = socket.read(buffer); if (read == -1) key.cancel(); buffer.flip(); ByteBufferUtil.debugAll(buffer); } catch (IOException e) { e.printStackTrace(); key.cancel(); } } } } catch (IOException e) { e.printStackTrace(); } } } } } -
运行结果

7. NIO与BIO
7.1 Stream与Channel
- stream 不会自动缓冲数据,channel 会利用系统提供的发送缓冲区、接收缓冲区,更为底层;
- stream 仅支持阻塞 API,channel 同时支持阻塞、非阻塞 API,网络 channel 可配合 selector 实现多路复用;
- 二者均为全双工,即读写可以同时进行,虽然Stream是单向流动的,但是它也是全双工的;
7.2 IO模型
- 同步:线程自己去获取结果(一个线程),例如:线程调用一个方法后,需要等待方法返回结果;
- 异步:线程自己不去获取结果,而是由其它线程返回结果(至少两个线程),例如:线程A调用一个方法后,继续向下运行,运行结果由线程B返回;
- 当调用一次 channel.read 或 stream.read 后,会由用户态切换至操作系统内核态来完成真正数据读取,而读取操作又分为两个阶段,分别为:等待数据阶段和复制数据阶段;

- 根据UNIX 网络编程 - 卷 I,IO模型主要有以下几种;
- 阻塞IO:用户线程进行read操作时,需要等待操作系统执行实际的read操作,此期间用户线程是被阻塞的,无法执行其他操作;

- 非阻塞IO:用户线程可以在一个循环中一直调用read方法,若内核空间中还没有数据可读,立即返回,当然只是在等待阶段非阻塞;用户线程发现内核空间中有数据后,等待内核空间执行复制数据,待复制结束后返回结果;

- 多路复用:Java中通过Selector实现多路复用,当没有事件是,调用select方法会被阻塞住,一旦有一个或多个事件发生后,就会处理对应的事件,从而实现多路复用;
- 多路复用与阻塞IO的区别:阻塞IO模式下,若线程因accept事件被阻塞,发生read事件后,仍需等待accept事件执行完成后,才能去处理read事件,而在多路复用模式下,一个事件发生后,若另一个事件处于阻塞状态,不会影响该事件的执行;

- 异步IO:线程1调用方法后理解返回,不会被阻塞也不需要立即获取结果,当方法的运行结果出来以后,由线程2将结果返回给线程1;

7.3 零拷贝
- 零拷贝指的是数据无需拷贝到 JVM 内存中,同时具有以下三个优点:更少的用户态与内核态的切换,不利用 CPU 计算,减少 CPU 缓存伪共享问题的出现,适合小文件传输;
- 传统 IO 问题:传统的 IO 将一个文件通过 socket 写出;
File f = new File("myfiles/data.txt"); RandomAccessFile file = new RandomAccessFile(f, "r"); byte[] buffer = new byte[(int)f.length()]; file.read(buffer); Socket socket = ...; socket.getOutputStream().write(buffer); - 内部工作流如下所示:

- Java本身并不具备IO读写能力,因此在read()方法调用之后需要从 Java 程序的用户态切换至内核态,去调用操作系统(Kernel)的读能力,将数据读入内核缓冲区。这期间用户线程阻塞,操作系统使用 DMA(Direct Memory Access)来实现文件读取,期间也不会使用 CPU;DMA也可以理解为硬件单元,用来解放 CPU 完成文件 IO;
- 从内核态切换回用户态,将数据从内核缓冲区读入用户缓冲区(即 byte[] buffer),这期间 CPU 会参与拷贝,无法利用 DMA;
- 调用 write 方法,这时将数据从用户缓冲区(byte[] buf)写入 socket 缓冲区,CPU 会参与拷贝;
- 接下来要向网卡写数据,Java不具备这项能力,因此需要从用户态切换至内核态,调用操作系统的写能力,使用 DMA 将 socket 缓冲区的数据写入网卡,不会使用 CPU;
- 可以看到中间环节较多,Java 的 IO 操作实际不是物理设备级别的读写,而只是对缓存的复制,底层的真正读写是操作系统来完成的;用户态与内核态的切换发生了 3 次,数据拷贝了共 4 次,这个操作比较重量级;
- NIO进行的优化:NIO通过 DirectByteBuf 对以上的实现进行了优化,ByteBuffer.allocate(10)在底层对应 HeapByteBuffer,使用的还是 Java 内存,具体来说就是JVM的堆区;ByteBuffer.allocateDirect(10),底层对应DirectByteBuffer,使用的是操作系统内存;

- 大部分步骤与优化前相同,但是Java 可以使用 DirectByteBuffer 将堆外内存映射到 JVM 内存中来直接访问使用;这块内存不受 JVM 垃圾回收的影响,因此内存地址固定,有助于 IO 读写;Java 中的 DirectByteBuf 对象仅维护了此内存的虚引用,内存回收分成两步:当引用的对象ByteBuffer被垃圾回收以后,虚引用对象Cleaner就会被放入引用队列中,然后调用Cleaner的clean方法来释放直接内存,DirectByteBuffer 的释放底层调用的是 Unsafe 的 freeMemory 方法;然后通过专门线程访问引用队列,根据虚引用释放堆外内存;这种优化方式减少了一次数据拷贝,用户态与内核态的切换次数没有减少;
- 以下展示的方式同样对相关的操作进行了优化,这些优化都是零拷贝的,即无需将数据拷贝到用户缓冲区 (JVM内存) 中;第一种在底层采用了 linux 2.1 后提供的 sendFile 方法,Java 中对应着两个 channel 调用 transferTo/transferFrom 方法拷贝数据;Java 调用 transferTo 方法后,要从 Java 程序的用户态切换至内核态,使用 DMA将数据读入内核缓冲区,不会使用 CPU;然后数据从内核缓冲区传输到 socket 缓冲区,CPU 会参与拷贝;最后使用 DMA 将 socket 缓冲区的数据写入网卡,不会使用 CPU;这种方法只发生了1次用户态与内核态的切换,数据拷贝了 3 次。linux 2.4 对上述方法再次进行了优化;Java 调用 transferTo 方法后,要从 Java 程序的用户态切换至内核态,使用 DMA将数据读入内核缓冲区,不会使用 CPU,之后只会将一些 offset 和 length 信息拷入 socket 缓冲区,该操作几乎无消耗,最后使用 DMA 将 内核缓冲区的数据写入网卡,不会使用 CPU;


7.4 AIO
-
AIO 用来解决数据复制阶段的阻塞问题,同步意味着,在进行读写操作时,线程需要等待结果,还是相当于闲置;异步意味着,在进行读写操作时,线程不必等待结果,而是将来由操作系统来通过回调方式由另外的线程来获得结果;异步模型需要底层操作系统(Kernel)提供支持,Windows 系统通过 IOCP 实现了真正的异步 IO,Linux 系统异步 IO 在 2.6 版本引入,但其底层实现还是用多路复用模拟了异步 IO,性能没有优势;
package com.zhyn.nio; import lombok.extern.slf4j.Slf4j; import java.io.IOException; import java.nio.ByteBuffer; import java.nio.channels.AsynchronousFileChannel; import java.nio.channels.CompletionHandler; import java.nio.file.Paths; import java.nio.file.StandardOpenOption; import static com.zhyn.nio.ByteBufferUtil.debugAll; @Slf4j public class AioDemo { public static void main(String[] args) throws IOException { try{ AsynchronousFileChannel s = AsynchronousFileChannel.open( Paths.get("data.txt"), StandardOpenOption.READ); ByteBuffer buffer = ByteBuffer.allocate(2); log.debug("begin..."); s.read(buffer, 0, null, new CompletionHandler<Integer, ByteBuffer>() { @Override public void completed(Integer result, ByteBuffer attachment) { log.debug("read completed...{}", result); buffer.flip(); debugAll(buffer); } @Override public void failed(Throwable exc, ByteBuffer attachment) { log.debug("read failed..."); } }); } catch (IOException e) { e.printStackTrace(); } log.debug("do other things..."); System.in.read(); } } -
运行结果

-
响应文件读取成功的是另一个线程 Thread-8,主线程并没有 IO 操作阻塞;

本文详细介绍了Java NIO,对比了NIO与IO的区别,解释了NIO三大组件,阐述其工作流程。还介绍了ByteBuffer的使用,包括读写操作、属性和方法。在网络编程方面,探讨了阻塞、非阻塞、多路复用模式及多线程优化。最后对比了NIO与BIO,提及零拷贝和AIO。
2089

被折叠的 条评论
为什么被折叠?



