一、三大组件简介

Channel与Buffer

Java NIO系统的核心在于:通道(Channel)和缓冲区(Buffer)。通道表示打开到 IO 设备(例如:文件、套接字)的连接。若需要使用 NIO 系统,需要获取用于连接 IO 设备的通道以及用于容纳数据的缓冲区。然后操作缓冲区,对数据进行处理

简而言之,通道负责传输,缓冲区负责存储

常见的Channel有以下四种,其中FileChannel主要用于文件传输,其余三种用于网络通信

  • FileChannel
  • DatagramChannel
  • SocketChannel
  • ServerSocketChannel

Buffer有以下几种,其中使用较多的是ByteBuffer

  • ByteBuffer

    • MappedByteBuffer
    • DirectByteBuffer
    • HeapByteBuffer
  • ShortBuffer
  • IntBuffer
  • LongBuffer
  • FloatBuffer
  • DoubleBuffer
  • CharBuffer

1、Selector

在使用Selector之前,处理socket连接还有以下两种方法

使用多线程技术

为每个连接分别开辟一个线程,分别去处理对应的socke连接

这种方法存在以下几个问题

  • 内存占用高

    • 每个线程都需要占用一定的内存,当连接较多时,会开辟大量线程,导致占用大量内存
  • 线程上下文切换成本高
  • 只适合连接数少的场景
    • 连接数过多,会导致创建很多线程,从而出现问题

使用线程池技术

使用线程池,让线程池中的线程去处理连接

这种方法存在以下几个问题

  • 阻塞模式下,线程仅能处理一个连接

    • 线程池中的线程获取任务(task)后,只有当其执行完任务之后(断开连接后),才会去获取并执行下一个任务
    • 若socke连接一直未断开,则其对应的线程无法处理其他socke连接
  • 仅适合

    短连接

    场景

    • 短连接即建立连接发送请求并响应后就立即断开,使得线程池中的线程可以快速处理其他连接

使用选择器

selector 的作用就是配合一个线程来管理多个 channel(fileChannel因为是阻塞式的,所以无法使用selector),获取这些 channel 上发生的事件,这些 channel 工作在非阻塞模式下,当一个channel中没有执行任务时,可以去执行其他channel中的任务。适合连接数多,但流量较少的场景

若事件未就绪,调用 selector 的 select() 方法会阻塞线程,直到 channel 发生了就绪事件。这些事件就绪后,select 方法就会返回这些事件交给 thread 来处理

2、ByteBuffer

使用案例

使用方式

  • 向 buffer 写入数据,例如调用 channel.read(buffer)

  • 调用 flip() 切换至

    读模式

    • flip会使得buffer中的limit变为position,position变为0
  • 从 buffer 读取数据,例如调用 buffer.get()

  • 调用 clear() 或者compact()切换至

    写模式

    • 调用clear()方法时position=0,limit变为capacity
    • 调用compact()方法时,会将缓冲区中的未读数据压缩到缓冲区前面
  • 重复以上步骤

使用ByteBuffer读取文件中的内容

public class TestByteBuffer {public static void main(String[] args) {// 获得FileChanneltry (FileChannel channel = new FileInputStream("stu.txt").getChannel()) {// 获得缓冲区ByteBuffer buffer = ByteBuffer.allocate(10);int hasNext = 0;StringBuilder builder = new StringBuilder();while((hasNext = channel.read(buffer)) > 0) {// 切换模式 limit=position, position=0buffer.flip();// 当buffer中还有数据时,获取其中的数据while(buffer.hasRemaining()) {builder.append((char)buffer.get());}// 切换模式 position=0, limit=capacitybuffer.clear();}System.out.println(builder.toString());} catch (IOException e) {}}
}

打印结果

0123456789abcdef

核心属性

字节缓冲区的父类Buffer中有几个核心属性,如下

// Invariants: mark <= position <= limit <= capacity
private int mark = -1;
private int position = 0;
private int limit;
private int capacity;Copy
  • capacity:缓冲区的容量。通过构造函数赋予,一旦设置,无法更改
  • limit:缓冲区的界限。位于limit 后的数据不可读写。缓冲区的限制不能为负,并且不能大于其容量
  • position下一个读写位置的索引(类似PC)。缓冲区的位置不能为负,并且不能大于limit
  • mark:记录当前position的值。position被改变后,可以通过调用reset() 方法恢复到mark的位置。

以上四个属性必须满足以下要求

mark <= position <= limit <= capacity

核心方法

put()方法

  • put()方法可以将一个数据放入到缓冲区中。
  • 进行该操作后,postition的值会+1,指向下一个可以放入的位置。capacity = limit ,为缓冲区容量的值。

flip()方法

  • flip()方法会切换对缓冲区的操作模式,由写->读 / 读->写
  • 进行该操作后
    • 如果是写模式->读模式,position = 0 , limit 指向最后一个元素的下一个位置,capacity不变
    • 如果是读->写,则恢复为put()方法中的值

get()方法

  • get()方法会读取缓冲区中的一个值
  • 进行该操作后,position会+1,如果超过了limit则会抛出异常
  • 注意:get(i)方法不会改变position的值

rewind()方法

  • 该方法只能在读模式下使用
  • rewind()方法后,会恢复position、limit和capacity的值,变为进行get()前的值

clean()方法

  • clean()方法会将缓冲区中的各个属性恢复为最初的状态,position = 0, capacity = limit
  • 此时缓冲区的数据依然存在,处于“被遗忘”状态,下次进行写操作时会覆盖这些数据

mark()和reset()方法

  • mark()方法会将postion的值保存到mark属性中
  • reset()方法会将position的值改为mark中保存的值

compact()方法

此方法为ByteBuffer的方法,而不是Buffer的方法

  • compact会把未读完的数据向前压缩,然后切换到写模式
  • 数据前移后,原位置的值并未清零,写时会覆盖之前的值

clear() VS compact()

clear只是对position、limit、mark进行重置,而compact在对position进行设置,以及limit、mark进行重置的同时,还涉及到数据在内存中拷贝(会调用arraycopy)。**所以compact比clear更耗性能。**但compact能保存你未读取的数据,将新数据追加到为读取的数据之后;而clear则不行,若你调用了clear,则未读取的数据就无法再读取到了

所以需要根据情况来判断使用哪种方法进行模式切换

方法调用及演示

ByteBuffer调试工具类

需要先导入netty依赖

<dependency><groupId>io.netty</groupId><artifactId>netty-all</artifactId><version>4.1.51.Final</version>
</dependency>Copy
import java.nio.ByteBuffer;import io.netty.util.internal.MathUtil;
import io.netty.util.internal.StringUtil;
import io.netty.util.internal.MathUtil.*;/*** @author Panwen Chen* @date 2021/4/12 15:59*/
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 paddingsfor (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(StringUtil.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 conversionfor (i = 0; i < BYTE2HEX.length; i++) {BYTE2HEX[i] = ' ' + StringUtil.byteToHexStringPadded(i);}// Generate the lookup table for byte dump paddingsfor (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 conversionfor (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);}private static void appendPrettyHexDump(StringBuilder dump, ByteBuffer buf, int offset, int length) {if (MathUtil.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("         +-------------------------------------------------+" +StringUtil.NEWLINE + "         |  0  1  2  3  4  5  6  7  8  9  a  b  c  d  e  f |" +StringUtil.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 dumpint rowEndIndex = rowStartIndex + 16;for (int j = rowStartIndex; j < rowEndIndex; j++) {dump.append(BYTE2HEX[getUnsignedByte(buf, j)]);}dump.append(" |");// ASCII dumpfor (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 dumpint rowEndIndex = rowStartIndex + remainder;for (int j = rowStartIndex; j < rowEndIndex; j++) {dump.append(BYTE2HEX[getUnsignedByte(buf, j)]);}dump.append(HEXPADDING[remainder]);dump.append(" |");// Ascii dumpfor (int j = rowStartIndex; j < rowEndIndex; j++) {dump.append(BYTE2CHAR[getUnsignedByte(buf, j)]);}dump.append(BYTEPADDING[remainder]);dump.append('|');}dump.append(StringUtil.NEWLINE +"+--------+-------------------------------------------------+----------------+");}private static void appendHexDumpRowPrefix(StringBuilder dump, int row, int rowStartIndex) {if (row < HEXDUMP_ROWPREFIXES.length) {dump.append(HEXDUMP_ROWPREFIXES[row]);} else {dump.append(StringUtil.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的方法

public class TestByteBuffer {public static void main(String[] args) {ByteBuffer buffer = ByteBuffer.allocate(10);// 向buffer中写入1个字节的数据buffer.put((byte)97);// 使用工具类,查看buffer状态ByteBufferUtil.debugAll(buffer);// 向buffer中写入4个字节的数据buffer.put(new byte[]{98, 99, 100, 101});ByteBufferUtil.debugAll(buffer);// 获取数据buffer.flip();ByteBufferUtil.debugAll(buffer);System.out.println(buffer.get());System.out.println(buffer.get());ByteBufferUtil.debugAll(buffer);// 使用compact切换模式buffer.compact();ByteBufferUtil.debugAll(buffer);// 再次写入buffer.put((byte)102);buffer.put((byte)103);ByteBufferUtil.debugAll(buffer);}
}

运行结果

// 向缓冲区写入了一个字节的数据,此时postition为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为5
+--------+-------------------- all ------------------------+----------------+
position: [5], limit: [10]+-------------------------------------------------+|  0  1  2  3  4  5  6  7  8  9  a  b  c  d  e  f |
+--------+-------------------------------------------------+----------------+
|00000000| 61 62 63 64 65 00 00 00 00 00                   |abcde.....      |
+--------+-------------------------------------------------+----------------+// 调用flip切换模式,此时position为0,表示从第0个数据开始读取
+--------+-------------------- all ------------------------+----------------+
position: [0], limit: [5]+-------------------------------------------------+|  0  1  2  3  4  5  6  7  8  9  a  b  c  d  e  f |
+--------+-------------------------------------------------+----------------+
|00000000| 61 62 63 64 65 00 00 00 00 00                   |abcde.....      |
+--------+-------------------------------------------------+----------------+
// 读取两个字节的数据
97
98// position变为2
+--------+-------------------- all ------------------------+----------------+
position: [2], limit: [5]+-------------------------------------------------+|  0  1  2  3  4  5  6  7  8  9  a  b  c  d  e  f |
+--------+-------------------------------------------------+----------------+
|00000000| 61 62 63 64 65 00 00 00 00 00                   |abcde.....      |
+--------+-------------------------------------------------+----------------+// 调用compact切换模式,此时position及其后面的数据被压缩到ByteBuffer前面去了
// 此时position为3,会覆盖之前的数据
+--------+-------------------- all ------------------------+----------------+
position: [3], limit: [10]+-------------------------------------------------+|  0  1  2  3  4  5  6  7  8  9  a  b  c  d  e  f |
+--------+-------------------------------------------------+----------------+
|00000000| 63 64 65 64 65 00 00 00 00 00                   |cdede.....      |
+--------+-------------------------------------------------+----------------+// 再次写入两个字节的数据,之前的 0x64 0x65 被覆盖
+--------+-------------------- all ------------------------+----------------+
position: [5], limit: [10]+-------------------------------------------------+|  0  1  2  3  4  5  6  7  8  9  a  b  c  d  e  f |
+--------+-------------------------------------------------+----------------+
|00000000| 63 64 65 66 67 00 00 00 00 00                   |cdefg.....      |
+--------+-------------------------------------------------+----------------+

调用ByteBuffer的分配内存的方法

public class TestByteBufferAllocate {public static void main(String[] args) {ByteBuffer allocate = ByteBuffer.allocate(10);ByteBuffer allocateDirect = ByteBuffer.allocateDirect(10);System.out.println(allocate.getClass()); // class java.nio.HeapByteBuffer 堆内存中的对象,所以收到垃圾(GC)回收的影响;读写效率相对不高;System.out.println(allocateDirect.getClass()); // class java.nio.DirectByteBuffer 直接内存(系统内存)里面的对象,不会受到垃圾回收的影响;读写效率比较高,会少一次的复制拷贝;分配时的效率比较低,并且容易出现内存泄露,需要手动释放内存}
}

字符串与ByteBuffer的相互转换

方法一

编码:字符串调用getByte方法获得byte数组,将byte数组放入ByteBuffer中

解码先调用ByteBuffer的flip方法,然后通过StandardCharsets的decoder方法解码

public class Translate {public static void main(String[] args) {// 准备两个字符串String str1 = "hello";String str2 = "";ByteBuffer buffer1 = ByteBuffer.allocate(16);// 通过字符串的getByte方法获得字节数组,放入缓冲区中buffer1.put(str1.getBytes());ByteBufferUtil.debugAll(buffer1);// 将缓冲区中的数据转化为字符串// 切换模式buffer1.flip();// 通过StandardCharsets解码,获得CharBuffer,再通过toString获得字符串str2 = StandardCharsets.UTF_8.decode(buffer1).toString();System.out.println(str2);ByteBufferUtil.debugAll(buffer1);}
}

运行结果

+--------+-------------------- 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...........|
+--------+-------------------------------------------------+----------------+Copy

方法二

编码:通过StandardCharsets的encode方法获得ByteBuffer,此时获得的ByteBuffer为读模式,无需通过flip切换模式

解码:通过StandardCharsets的decoder方法解码

public class Translate {public static void main(String[] args) {// 准备两个字符串String str1 = "hello";String str2 = "";// 通过StandardCharsets的encode方法获得ByteBuffer// 此时获得的ByteBuffer为读模式,无需通过flip切换模式ByteBuffer buffer1 = StandardCharsets.UTF_8.encode(str1);ByteBufferUtil.debugAll(buffer1);// 将缓冲区中的数据转化为字符串// 通过StandardCharsets解码,获得CharBuffer,再通过toString获得字符串str2 = StandardCharsets.UTF_8.decode(buffer1).toString();System.out.println(str2);ByteBufferUtil.debugAll(buffer1);}
}

运行结果

+--------+-------------------- all ------------------------+----------------+
position: [0], limit: [5]+-------------------------------------------------+|  0  1  2  3  4  5  6  7  8  9  a  b  c  d  e  f |
+--------+-------------------------------------------------+----------------+
|00000000| 68 65 6c 6c 6f                                  |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                                  |hello           |
+--------+-------------------------------------------------+----------------+Copy

方法三

编码:字符串调用getByte()方法获得字节数组,将字节数组传给ByteBuffer的wrap()方法,通过该方法获得ByteBuffer。同样无需调用flip方法切换为读模式

解码:通过StandardCharsets的decoder方法解码

public class Translate {public static void main(String[] args) {// 准备两个字符串String str1 = "hello";String str2 = "";// 通过StandardCharsets的encode方法获得ByteBuffer// 此时获得的ByteBuffer为读模式,无需通过flip切换模式ByteBuffer buffer1 = ByteBuffer.wrap(str1.getBytes());ByteBufferUtil.debugAll(buffer1);// 将缓冲区中的数据转化为字符串// 通过StandardCharsets解码,获得CharBuffer,再通过toString获得字符串str2 = StandardCharsets.UTF_8.decode(buffer1).toString();System.out.println(str2);ByteBufferUtil.debugAll(buffer1);}
}

运行结果

+--------+-------------------- all ------------------------+----------------+
position: [0], limit: [5]+-------------------------------------------------+|  0  1  2  3  4  5  6  7  8  9  a  b  c  d  e  f |
+--------+-------------------------------------------------+----------------+
|00000000| 68 65 6c 6c 6f                                  |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                                  |hello           |
+--------+-------------------------------------------------+----------------+Copy

ByteBuffer分散读集中写

分散读

public class TestScatteringRead {public static void main(String[] args) {// 获取channel的两种方式 1. 通过输入输出流getChannel;2. 通过RandomAccessFile.getChannel()获取try (FileChannel channel = new RandomAccessFile("word1.txt", "r").getChannel()) {ByteBuffer allocate0 = ByteBuffer.allocate(3);ByteBuffer allocate1 = ByteBuffer.allocate(3);ByteBuffer allocate2 = ByteBuffer.allocate(5);// 将通道(channel)中的数据依次读入缓存区中channel.read(new ByteBuffer[]{allocate0, allocate1, allocate2});// 切换缓冲区读写模式allocate0.flip();allocate1.flip();allocate2.flip();ByteBufferUtil.debugAll(allocate0);ByteBufferUtil.debugAll(allocate1);ByteBufferUtil.debugAll(allocate2);} catch (IOException e) {}}
}

集中写

public class TestGatheringWrite {public static void main(String[] args) {// 将指定字符串转换为bytebufferByteBuffer buffer1 = StandardCharsets.UTF_8.encode("hello");ByteBuffer buffer2 = StandardCharsets.UTF_8.encode("word");ByteBuffer buffer3 = StandardCharsets.UTF_8.encode("李雷");// 获取到文件对应的通道try (FileChannel channel = new RandomAccessFile("word2.txt", "rw").getChannel()) {// 将缓冲区中的数据写入通道中channel.write(new ByteBuffer[]{buffer1, buffer2, buffer3});} catch (IOException e) {}}
}

粘(黏)包与半包

现象

网络上有多条数据发送给服务端,数据之间使用 \n 进行分隔
但由于某种原因这些数据在接收时,被进行了重新组合,例如原始数据有3条为

  • Hello,world\n
  • I’m Nyima\n
  • How are you?\n

变成了下面的两个 byteBuffer (粘包,半包)

  • Hello,world\nI’m Nyima\nHo
  • w are you?\n

出现原因

粘包

发送方在发送数据时,并不是一条一条地发送数据,而是将数据整合在一起,当数据达到一定的数量后再一起发送。这就会导致多条信息被放在一个缓冲区中被一起发送出去

半包

接收方的缓冲区的大小是有限的,当接收方的缓冲区满了以后,就需要将信息截断,等缓冲区空了以后再继续放入数据。这就会发生一段完整的数据最后被截断的现象

解决办法

  • 通过get(index)方法遍历ByteBuffer,遇到分隔符时进行处理。

    注意

    :get(index)不会改变position的值

    • 记录该段数据长度,以便于申请对应大小的缓冲区
    • 将缓冲区的数据通过get()方法写入到target中
  • 调用compact方法切换模式,因为缓冲区中可能还有未读的数据

public class ByteBufferDemo {public static void main(String[] args) {ByteBuffer buffer = ByteBuffer.allocate(32);// 模拟粘包+半包buffer.put("Hello,world\nI'm Nyima\nHo".getBytes());// 调用split函数处理split(buffer);buffer.put("w are you?\n".getBytes());split(buffer);}private static void split(ByteBuffer buffer) {// 切换为读模式buffer.flip();for(int i = 0; i < buffer.limit(); i++) {// 遍历寻找分隔符// get(i)不会移动positionif (buffer.get(i) == '\n') {// 缓冲区长度int length = i+1-buffer.position();ByteBuffer target = ByteBuffer.allocate(length);// 将前面的内容写入target缓冲区for(int j = 0; j < length; j++) {// 将buffer中的数据写入target中target.put(buffer.get());}// 打印查看结果ByteBufferUtil.debugAll(target);}}// 切换为写模式,但是缓冲区可能未读完,这里需要使用compactbuffer.compact();}
}

运行结果

+--------+-------------------- 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: [10], limit: [10]+-------------------------------------------------+|  0  1  2  3  4  5  6  7  8  9  a  b  c  d  e  f |
+--------+-------------------------------------------------+----------------+
|00000000| 49 27 6d 20 4e 79 69 6d 61 0a                   |I'm Nyima.      |
+--------+-------------------------------------------------+----------------+
+--------+-------------------- 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?.   |
+--------+-------------------------------------------------+----------------+Copy

二、文件编程

1、FileChannel

工作模式

FileChannel只能在阻塞模式下工作,所以无法搭配Selector

获取

不能直接打开 FileChannel,必须通过 FileInputStream、FileOutputStream 或者 RandomAccessFile 来获取 FileChannel,它们都有 getChannel 方法

  • 通过 FileInputStream 获取的 channel 只能读
  • 通过 FileOutputStream 获取的 channel 只能写
  • 通过 RandomAccessFile 是否能读写根据构造 RandomAccessFile 时的读写模式决定

读取

通过 FileInputStream 获取channel,通过read方法将数据写入到ByteBuffer中

read方法的返回值表示读到了多少字节,若读到了文件末尾则返回-1

int readBytes = channel.read(buffer);

可根据返回值判断是否读取完毕

while(channel.read(buffer) > 0) {// 进行对应操作...
}

写入

因为channel也是有大小的,所以 write 方法并不能保证一次将 buffer 中的内容全部写入 channel。必须需要按照以下规则进行写入

// 通过hasRemaining()方法查看缓冲区中是否还有数据未写入到通道中
while(buffer.hasRemaining()) {channel.write(buffer);
}

关闭

通道需要close,一般情况通过try-with-resource进行关闭,最好使用以下方法获取strea以及channel,避免某些原因使得资源未被关闭

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()) {// 执行对应操作...}}
}

位置

position

channel也拥有一个保存读取数据位置的属性,即position

long pos = channel.position();

可以通过position(int pos)设置channel中position的值

long newPos = ...;
channel.position(newPos);

设置当前位置时,如果设置为文件的末尾

  • 这时读取会返回 -1
  • 这时写入,会追加内容,但要注意如果 position 超过了文件末尾,再写入时在新内容和原末尾之间会有空洞(00)

强制写入

操作系统出于性能的考虑,会将数据缓存,不是立刻写入磁盘,而是等到缓存满了以后将所有数据一次性的写入磁盘。可以调用 force(true) 方法将文件内容和元数据(文件的权限等信息)立刻写入磁盘

2、两个Channel传输数据

transferTo方法

使用transferTo方法可以快速、高效地将一个channel中的数据传输到另一个channel中,但一次只能传输2G的内容

transferTo底层使用了零拷贝技术

public class TestChannel {public static void main(String[] args){try (FileInputStream fis = new FileInputStream("stu.txt");FileOutputStream fos = new FileOutputStream("student.txt");FileChannel inputChannel = fis.getChannel();FileChannel outputChannel = fos.getChannel()) {// 参数:inputChannel的起始位置,传输数据的大小,目的channel// 返回值为传输的数据的字节数// transferTo一次只能传输2G的数据inputChannel.transferTo(0, inputChannel.size(), outputChannel);} catch (IOException e) {e.printStackTrace();}}
}

当传输的文件大于2G时,需要使用以下方法进行多次传输

public class TestChannel {public static void main(String[] args){try (FileInputStream fis = new FileInputStream("stu.txt");FileOutputStream fos = new FileOutputStream("student.txt");FileChannel inputChannel = fis.getChannel();FileChannel outputChannel = fos.getChannel()) {long size = inputChannel.size();long capacity = inputChannel.size();// 分多次传输while (capacity > 0) {// transferTo返回值为传输了的字节数capacity -= inputChannel.transferTo(size-capacity, capacity, outputChannel);}} catch (IOException e) {e.printStackTrace();}}
}

3、Path与Paths

  • Path 用来表示文件路径
  • Paths 是工具类,用来获取 Path 实例
Path source = Paths.get("1.txt"); // 相对路径 不带盘符 使用 user.dir 环境变量来定位 1.txtPath source = Paths.get("d:\\1.txt"); // 绝对路径 代表了  d:\1.txt 反斜杠需要转义Path source = Paths.get("d:/1.txt"); // 绝对路径 同样代表了  d:\1.txtPath 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

4、Files

查找

检查文件是否存在

Path path = Paths.get("helloword/data.txt");
System.out.println(Files.exists(path));

创建

创建一级目录

Path path = Paths.get("helloword/d1");
Files.createDirectory(path);
  • 如果目录已存在,会抛异常 FileAlreadyExistsException
  • 不能一次创建多级目录,否则会抛异常 NoSuchFileException

创建多级目录用

Path path = Paths.get("helloword/d1/d2");
Files.createDirectories(path);

拷贝及移动

拷贝文件

Path source = Paths.get("helloword/data.txt");
Path target = Paths.get("helloword/target.txt");Files.copy(source, target);
  • 如果文件已存在,会抛异常 FileAlreadyExistsException

如果希望用 source 覆盖掉 target,需要用 StandardCopyOption 来控制

Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING);

移动文件

Path source = Paths.get("helloword/data.txt");
Path target = Paths.get("helloword/data.txt");Files.move(source, target, StandardCopyOption.ATOMIC_MOVE);
  • StandardCopyOption.ATOMIC_MOVE 保证文件移动的原子性

删除

删除文件

Path target = Paths.get("helloword/target.txt");Files.delete(target);
  • 如果文件不存在,会抛异常 NoSuchFileException

删除目录

Path target = Paths.get("helloword/d1");Files.delete(target);
  • 如果目录还有内容,会抛异常 DirectoryNotEmptyException

遍历

可以使用Files工具类中的walkFileTree(Path, FileVisitor)方法,其中需要传入两个参数

  • Path:文件起始路径

  • FileVisitor:文件访问器,

    使用访问者模式

    • 接口的实现类

      SimpleFileVisitor

      有四个方法

      • preVisitDirectory:访问目录前的操作
      • visitFile:访问文件的操作
      • visitFileFailed:访问文件失败时的操作
      • postVisitDirectory:访问目录后的操作
public class TestWalkFileTree {public static void main(String[] args) throws IOException {Path path = Paths.get("D:\\JDK 8");// 文件目录数目AtomicInteger dirCount = new AtomicInteger();// 文件数目AtomicInteger fileCount = new AtomicInteger();Files.walkFileTree(path, new SimpleFileVisitor<Path>(){@Overridepublic FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException {System.out.println("===>"+dir);// 增加文件目录数dirCount.incrementAndGet();return super.preVisitDirectory(dir, attrs);}@Overridepublic 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());}
}

运行结果如下

...
===>D:\JDK 8\lib\security\policy\unlimited
D:\JDK 8\lib\security\policy\unlimited\local_policy.jar
D:\JDK 8\lib\security\policy\unlimited\US_export_policy.jar
D:\JDK 8\lib\security\trusted.libraries
D:\JDK 8\lib\sound.properties
D:\JDK 8\lib\tzdb.dat
D:\JDK 8\lib\tzmappings
D:\JDK 8\LICENSE
文件目录数:23
文件数:280

三、网络编程

1、阻塞

  • 阻塞模式下,相关方法都会导致线程暂停

    • ServerSocketChannel.accept 会在没有连接建立时让线程暂停
    • SocketChannel.read 会在通道中没有数据可读时让线程暂停
    • 阻塞的表现其实就是线程暂停了,暂停期间不会占用 cpu,但线程相当于闲置,不能处理其他的任务
  • 单线程下,阻塞方法之间相互影响,几乎不能正常工作,需要多线程支持
  • 但多线程下,有新的问题,体现在以下方面
    • 32 位 jvm 一个线程 320k,64 位 jvm 一个线程 1024k,如果连接数过多,必然导致 OOM,并且线程太多,反而会因为频繁上下文切换导致性能降低
    • 可以采用线程池技术来减少线程数和线程上下文切换,但治标不治本,如果有很多连接建立,但长时间 inactive,会阻塞线程池中所有线程,因此不适合长连接,只适合短连接

服务端代码

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) {System.out.println("before connecting...");// 没有连接时,会阻塞线程SocketChannel socketChannel = server.accept();System.out.println("after connecting...");channels.add(socketChannel);// 循环遍历集合中的连接for(SocketChannel channel : channels) {System.out.println("before reading");// 处理通道中的数据// 当通道中没有数据可读时,会阻塞线程channel.read(buffer);buffer.flip();ByteBufferUtil.debugRead(buffer);buffer.clear();System.out.println("after reading");}}} catch (IOException e) {e.printStackTrace();}}
}

客户端代码

public class Client {public static void main(String[] args) {try (SocketChannel socketChannel = SocketChannel.open()) {socketChannel.connect(new InetSocketAddress("localhost", 8089));socketChannel.write(StandardCharsets.UTF_8.encode("hello"));System.out.println("waiting...");} catch (IOException e) {e.printStackTrace();}}
}

运行结果

  • 客户端-服务器建立连接前:服务器端因accept阻塞

  • 客户端-服务器建立连接后,客户端发送消息前:服务器端因通道为空被阻塞

  • 客户端发送数据后,服务器处理通道中的数据。再次进入循环时,再次被accept阻塞

  • 之前的客户端再次发送消息**,服务器端因为被accept阻塞**,无法处理之前客户端发送到通道中的信息

2、非阻塞

  • 可以通过ServerSocketChannel的configureBlocking(false)方法将获得连接设置为非阻塞的。此时若没有连接,accept会返回null
  • 可以通过SocketChannel的configureBlocking(false)方法将从通道中读取数据设置为非阻塞的。若此时通道中没有数据可读,read会返回-1

服务器代码如下

public class Server {public static void main(String[] args) {// 创建缓冲区ByteBuffer buffer = ByteBuffer.allocate(16);// 获得服务器通道try (ServerSocketChannel serverSocketChannel = ServerSocketChannel.open()) {// 为服务器通道绑定端口serverSocketChannel.bind(new InetSocketAddress(8089));// 将服务端连接通道设置为非阻塞模式,此种状模式,server.accept()在没有客户端连接请求建立时,返回值是null此时若没有连接,accept会返回nullserverSocketChannel.configureBlocking(false);// 用户存放连接的集合List<SocketChannel> channelList = new ArrayList<>();// 循环接收连接while (true) {// configureBlocking = false (非阻塞模式)下,执行到此代码,如没有客户端连接请求建立,返回值为nullSocketChannel socketChannel = serverSocketChannel.accept();// 通道不为空时才将连接放入到集合中if (null != socketChannel) {System.out.println("client connecting...");// 客户端socket通道,设置为非阻塞模式,则使用channel.read()是非阻塞,不会阻塞线程的执行socketChannel.configureBlocking(false);channelList.add(socketChannel);}for (SocketChannel channel : channelList) {// 处理通道中的数据// 在通道channel的configureBlocking = false (非阻塞模式)下,若通道中没有数据,则返回值是0,不会阻塞线程的执行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();}}
}

这样写存在一个问题,因为设置为了非阻塞,会一直执行while(true)中的代码,CPU一直处于忙碌状态,会使得性能变低,所以实际情况中不使用这种方法处理请求

3、Selector

多路复用

单线程可以配合 Selector 完成对多个 Channel 可读写事件的监控,这称之为多路复用

  • 多路复用仅针对网络 IO,普通文件 IO 无法利用多路复用
  • 如果不用 Selector 的非阻塞模式,线程大部分时间都在做无用功,而 Selector 能够保证
    • 有可连接事件时才去连接
    • 有可读事件才去读取
    • 有可写事件才去写入
      • 限于网络传输能力,Channel 未必时时可写,一旦 Channel 可写,会触发 Selector 的可写事件

4、使用及Accpet事件

要使用Selector实现多路复用,服务端代码如下改进

public class SelectorServer {public static void main(String[] args) {// 创建缓冲区ByteBuffer allocate = ByteBuffer.allocate(32);// 开启服务器通信通道try (ServerSocketChannel serverSocketChannel = ServerSocketChannel.open()) {// 为服务器绑定端口serverSocketChannel.bind(new InetSocketAddress(8089));// 设置服务器通道为非阻塞性形式serverSocketChannel.configureBlocking(false);// 获取selector,实现多路复用,注意fileChannel没有非阻塞形式,所以不能配合selector使用,进行非阻塞多路复用Selector selector = Selector.open();// 将服务器通信通道注册到selector中,并设置关注(感兴趣的)的事件serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);// 循环处理通道事件while (true) {System.out.println("selector before ready,thread is block");// 获取到选择器selector中的事件,若没有事件,则线程会处于阻塞状态;反之不会阻塞。此类现象避免了CPU空转// 返回值为已经就绪的事件数int readySelect = selector.select();System.out.println("selector ready counts : " + readySelect);// 获取选择器所有事件Set<SelectionKey> selectionKeys = selector.selectedKeys();// 获取事件的迭代器Iterator<SelectionKey> iterator = selectionKeys.iterator();while (iterator.hasNext()) {// 获取当前事件的keySelectionKey selectionKey = iterator.next();// 判断事件的类型,不同的事件做出不同的处理if (selectionKey.isAcceptable()) {// 服务端连接事件ServerSocketChannel channel = (ServerSocketChannel) selectionKey.channel();System.out.println("before accepting...");// 获取链接通道SocketChannel socketChannel = channel.accept();System.out.println("after accepting..." + socketChannel);// 需要将当前的选择器事件key进行移除,否则下次处理事件会出现 nullPointExceptioniterator.remove();}}}} catch (IOException e) {e.printStackTrace();}}
}

步骤解析

  • 获得选择器Selector
Selector selector = Selector.open();
  • 将通道设置为非阻塞模式,并注册到选择器中,并设置感兴趣的事件

    • channel 必须工作在非阻塞模式

    • FileChannel 没有非阻塞模式,因此不能配合 selector 一起使用

    • 绑定的事件类型可以有:

      • connect - 客户端连接成功时触发
      • accept - 服务器端成功接受连接时触发
      • read - 数据可读入时触发,有因为接收能力弱,数据暂不能读入的情况
      • write - 数据可写出时触发,有因为发送能力弱,数据暂不能写出的情况
// 通道必须设置为非阻塞模式
server.configureBlocking(false);
// 将通道注册到选择器中,并设置感兴趣的事件
server.register(selector, SelectionKey.OP_ACCEPT);
  • 通过Selector监听事件,并获得就绪的通道个数,若没有通道就绪,线程会被阻塞

    • 阻塞直到绑定事件发生

      int count = selector.select();
      
    • 阻塞直到绑定事件发生,或是超时(时间单位为 ms)

      int count = selector.select(long timeout);
      
    • 不会阻塞,也就是不管有没有事件,立刻返回,自己根据返回值检查是否有事件

      int count = selector.selectNow();
      
  • 获取就绪事件并得到对应的通道,然后进行处理

// 获取所有事件
Set<SelectionKey> selectionKeys = selector.selectedKeys();// 使用迭代器遍历事件
Iterator<SelectionKey> iterator = selectionKeys.iterator();while (iterator.hasNext()) {SelectionKey key = iterator.next();// 判断key的类型,此处为Accept类型if(key.isAcceptable()) {// 获得key对应的channelServerSocketChannel channel = (ServerSocketChannel) key.channel();// 获取连接并处理,而且是必须处理,否则需要取消SocketChannel socketChannel = channel.accept();// 处理完毕后移除iterator.remove();}
}

事件发生后能否不处理

事件发生后,要么处理,要么取消(cancel),不能什么都不做,否则下次该事件仍会触发,这是因为 nio 底层使用的是水平触发

5、Read事件

  • 在Accept事件中,若有客户端与服务器端建立了连接,需要将其对应的SocketChannel设置为非阻塞,并注册到选择其中
  • 添加Read事件,触发后进行读取操作
public class SelectServer {public static void main(String[] args) {ByteBuffer buffer = ByteBuffer.allocate(16);// 获得服务器通道try(ServerSocketChannel server = ServerSocketChannel.open()) {server.bind(new InetSocketAddress(8080));// 创建选择器Selector selector = Selector.open();// 通道必须设置为非阻塞模式server.configureBlocking(false);// 将通道注册到选择器中,并设置感兴趣的实践server.register(selector, SelectionKey.OP_ACCEPT);// 为serverKey设置感兴趣的事件while (true) {// 若没有事件就绪,线程会被阻塞,反之不会被阻塞。从而避免了CPU空转// 返回值为就绪的事件个数int ready = selector.select();System.out.println("selector ready counts : " + ready);// 获取所有事件Set<SelectionKey> selectionKeys = selector.selectedKeys();// 使用迭代器遍历事件Iterator<SelectionKey> iterator = selectionKeys.iterator();while (iterator.hasNext()) {SelectionKey key = iterator.next();// 判断key的类型if(key.isAcceptable()) {// 获得key对应的channelServerSocketChannel channel = (ServerSocketChannel) key.channel();System.out.println("before accepting...");// 获取连接SocketChannel socketChannel = channel.accept();System.out.println("after accepting...");// 设置为非阻塞模式,同时将连接的通道也注册到选择其中socketChannel.configureBlocking(false);socketChannel.register(selector, SelectionKey.OP_READ);// 处理完毕后移除iterator.remove();} else if (key.isReadable()) {SocketChannel channel = (SocketChannel) key.channel();System.out.println("before reading...");channel.read(buffer);System.out.println("after reading...");buffer.flip();ByteBufferUtil.debugRead(buffer);buffer.clear();// 处理完毕后移除iterator.remove();}}}} catch (IOException e) {e.printStackTrace();}}
}

删除事件

当处理完一个事件后,一定要调用迭代器的remove方法移除对应事件,否则会出现错误。原因如下

以我们上面的 Read事件 的代码为例

  • 当调用了 server.register(selector, SelectionKey.OP_ACCEPT)后,Selector中维护了一个集合,用于存放SelectionKey以及其对应的通道

    // WindowsSelectorImpl 中的 SelectionKeyImpl数组
    private SelectionKeyImpl[] channelArray = new SelectionKeyImpl[8];
    
    public class SelectionKeyImpl extends AbstractSelectionKey {// Key对应的通道final SelChImpl channel;...
    }
    

  • 选择器中的通道对应的事件发生后,selecionKey会被放到另一个集合中,但是selecionKey不会自动移除,所以需要我们在处理完一个事件后,通过迭代器手动移除其中的selecionKey。否则会导致已被处理过的事件再次被处理,就会引发错误

断开处理

当客户端与服务器之间的连接断开时,会给服务器端发送一个读事件,对异常断开和正常断开需要加以不同的方式进行处理

  • 正常断开

    • 正常断开时,服务器端的channel.read(buffer)方法的返回值为-1,所以当结束到返回值为-1时,需要调用key的cancel方法取消此事件,并在取消后移除该事件

      int read = channel.read(buffer);
      // 断开连接时,客户端会向服务器发送一个写事件,此时read的返回值为-1
      if(read == -1) {// 取消该事件的处理key.cancel();channel.close();
      } else {...
      }
      // 取消或者处理,都需要移除key
      iterator.remove();
      
  • 异常断开

    • 异常断开时,会抛出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获取到对应通道的附件

public final SelectionKey register(Selector sel, int ops, Object att)

可通过SelectionKey的attachment()方法获得附件

ByteBuffer buffer = (ByteBuffer) key.attachment();

我们需要在Accept事件发生后,将通道注册到Selector中时,对每个通道添加一个ByteBuffer附件,让每个通道发生读事件时都使用自己的通道,避免与其他通道发生冲突而导致问题

// 设置为非阻塞模式,同时将连接的通道也注册到选择其中,同时设置附件
socketChannel.configureBlocking(false);
ByteBuffer buffer = ByteBuffer.allocate(16);
// 添加通道对应的Buffer附件
socketChannel.register(selector, SelectionKey.OP_READ, buffer);

当Channel中的数据大于缓冲区时,需要对缓冲区进行扩容操作。此代码中的扩容的判定方法:Channel调用compact方法后,的position与limit相等,说明缓冲区中的数据并未被读取(容量太小),此时创建新的缓冲区,其大小扩大为两倍。同时还要将旧缓冲区中的数据拷贝到新的缓冲区中,同时调用SelectionKey的attach方法将新的缓冲区作为新的附件放入SelectionKey中

// 如果缓冲区太小,就进行扩容
if (buffer.position() == buffer.limit()) {ByteBuffer newBuffer = ByteBuffer.allocate(buffer.capacity()*2);// 将旧buffer中的内容放入新的buffer中ewBuffer.put(buffer);// 将新buffer作为附件放到key中key.attach(newBuffer);
}

改造后的服务器代码如下

public class SelectServer {public static void main(String[] args) {// 获得服务器通道try(ServerSocketChannel server = ServerSocketChannel.open()) {server.bind(new InetSocketAddress(8080));// 创建选择器Selector selector = Selector.open();// 通道必须设置为非阻塞模式server.configureBlocking(false);// 将通道注册到选择器中,并设置感兴趣的事件server.register(selector, SelectionKey.OP_ACCEPT);// 为serverKey设置感兴趣的事件while (true) {// 若没有事件就绪,线程会被阻塞,反之不会被阻塞。从而避免了CPU空转// 返回值为就绪的事件个数int ready = selector.select();System.out.println("selector ready counts : " + ready);// 获取所有事件Set<SelectionKey> selectionKeys = selector.selectedKeys();// 使用迭代器遍历事件Iterator<SelectionKey> iterator = selectionKeys.iterator();while (iterator.hasNext()) {SelectionKey key = iterator.next();// 判断key的类型if(key.isAcceptable()) {// 获得key对应的channelServerSocketChannel channel = (ServerSocketChannel) key.channel();System.out.println("before accepting...");// 获取连接SocketChannel socketChannel = channel.accept();System.out.println("after accepting...");// 设置为非阻塞模式,同时将连接的通道也注册到选择其中,同时设置附件socketChannel.configureBlocking(false);ByteBuffer buffer = ByteBuffer.allocate(16);socketChannel.register(selector, SelectionKey.OP_READ, buffer);// 处理完毕后移除iterator.remove();} else if (key.isReadable()) {SocketChannel channel = (SocketChannel) key.channel();System.out.println("before reading...");// 通过key获得附件(buffer)ByteBuffer buffer = (ByteBuffer) key.attachment();int read = channel.read(buffer);if(read == -1) {key.cancel();channel.close();} else {// 通过分隔符来分隔buffer中的数据split(buffer);// 如果缓冲区太小,就进行扩容if (buffer.position() == buffer.limit()) {ByteBuffer newBuffer = ByteBuffer.allocate(buffer.capacity()*2);// 将旧buffer中的内容放入新的buffer中buffer.flip();newBuffer.put(buffer);// 将新buffer放到key中作为附件key.attach(newBuffer);}}System.out.println("after reading...");// 处理完毕后移除iterator.remove();}}}} catch (IOException e) {e.printStackTrace();}}private static void split(ByteBuffer buffer) {buffer.flip();for(int i = 0; i < buffer.limit(); i++) {// 遍历寻找分隔符// get(i)不会移动positionif (buffer.get(i) == '\n') {// 缓冲区长度int length = i+1-buffer.position();ByteBuffer target = ByteBuffer.allocate(length);// 将前面的内容写入target缓冲区for(int j = 0; j < length; j++) {// 将buffer中的数据写入target中target.put(buffer.get());}// 打印结果ByteBufferUtil.debugAll(target);}}// 切换为写模式,但是缓冲区可能未读完,这里需要使用compactbuffer.compact();}
}

ByteBuffer的大小分配

  • 每个 channel 都需要记录可能被切分的消息,因为 ByteBuffer 不能被多个 channel 共同使用,因此需要为每个 channel 维护一个独立的 ByteBuffer
  • ByteBuffer 不能太大,比如一个 ByteBuffer 1Mb 的话,要支持百万连接就要 1Tb 内存,因此需要设计大小可变的 ByteBuffer
  • 分配思路可以参考
    • 一种思路是首先分配一个较小的 buffer,例如 4k,如果发现数据不够,再分配 8k 的 buffer,将 4k buffer 内容拷贝至 8k buffer,优点是消息连续容易处理,缺点是数据拷贝耗费性能

      • 参考实现 http://tutorials.jenkov.com/java-performance/resizable-array.html
    • 另一种思路是用多个数组组成 buffer,一个数组不够,把多出来的内容写入新的数组,与前面的区别是消息存储不连续解析复杂,优点是避免了拷贝引起的性能损耗

6、Write事件

服务器通过Buffer向通道中写入数据时,可能因为通道容量小于Buffer中的数据大小,导致无法一次性将Buffer中的数据全部写入到Channel中,这时便需要分多次写入,具体步骤如下

  • 执行一次写操作,向将buffer中的内容写入到SocketChannel中,然后判断Buffer中是否还有数据

  • 若Buffer中还有数据,则需要将SockerChannel注册到Seletor中,并关注写事件,同时将未写完的Buffer作为附件一起放入到SelectionKey中

     int write = socket.write(buffer);
    // 通道中可能无法放入缓冲区中的所有数据
    if (buffer.hasRemaining()) {// 注册到Selector中,关注可写事件,并将buffer添加到key的附件中socket.configureBlocking(false);socket.register(selector, SelectionKey.OP_WRITE, buffer);
    }
    
  • 添加写事件的相关操作key.isWritable(),对Buffer再次进行写操作

    • 每次写后需要判断Buffer中是否还有数据(是否写完)。若写完,需要移除SelecionKey中的Buffer附件,避免其占用过多内存,同时还需移除对写事件的关注
    SocketChannel socket = (SocketChannel) key.channel();
    // 获得buffer
    ByteBuffer buffer = (ByteBuffer) key.attachment();
    // 执行写操作
    int write = socket.write(buffer);
    System.out.println(write);
    // 如果已经完成了写操作,需要移除key中的附件,同时不再对写事件感兴趣
    if (!buffer.hasRemaining()) {key.attach(null);key.interestOps(0);
    }
    

整体代码如下

public class WriteServer {public static void main(String[] args) {try(ServerSocketChannel server = ServerSocketChannel.open()) {server.bind(new InetSocketAddress(8080));server.configureBlocking(false);Selector selector = Selector.open();server.register(selector, SelectionKey.OP_ACCEPT);while (true) {selector.select();Set<SelectionKey> selectionKeys = selector.selectedKeys();Iterator<SelectionKey> iterator = selectionKeys.iterator();while (iterator.hasNext()) {SelectionKey key = iterator.next();// 处理后就移除事件iterator.remove();if (key.isAcceptable()) {// 获得客户端的通道SocketChannel socket = server.accept();// 写入数据StringBuilder builder = new StringBuilder();for(int i = 0; i < 500000000; i++) {builder.append("a");}ByteBuffer buffer = StandardCharsets.UTF_8.encode(builder.toString());// 先执行一次Buffer->Channel的写入,如果未写完,就添加一个可写事件int write = socket.write(buffer);System.out.println(write);// 通道中可能无法放入缓冲区中的所有数据if (buffer.hasRemaining()) {// 注册到Selector中,关注可写事件,并将buffer添加到key的附件中socket.configureBlocking(false);socket.register(selector, SelectionKey.OP_WRITE, buffer);}} else if (key.isWritable()) {SocketChannel socket = (SocketChannel) key.channel();// 获得bufferByteBuffer buffer = (ByteBuffer) key.attachment();// 执行写操作int write = socket.write(buffer);System.out.println(write);// 如果已经完成了写操作,需要移除key中的附件,同时不再对写事件感兴趣if (!buffer.hasRemaining()) {key.attach(null);key.interestOps(0);}}}}} catch (IOException e) {e.printStackTrace();}}
}

7、优化

多线程优化

充分利用多核CPU,分两组选择器

  • 单线程配一个选择器(Boss),专门处理 accept 事件
  • 创建 cpu 核心数的线程(Worker),每个线程配一个选择器,轮流处理 read 事件

实现思路

  • 创建一个负责处理Accept事件的Boss线程,与多个负责处理Read事件的Worker线程

  • Boss线程执行的操作

    • 接受并处理Accepet事件,当Accept事件发生后,调用Worker的register(SocketChannel socket)方法,让Worker去处理Read事件,其中需要根据标识robin去判断将任务分配给哪个Worker

      // 创建固定数量的Worker
      Worker[] workers = new Worker[4];
      // 用于负载均衡的原子整数
      AtomicInteger robin = new AtomicInteger(0);
      // 负载均衡,轮询分配Worker
      workers[robin.getAndIncrement()% workers.length].register(socket);Copy
      
    • register(SocketChannel socket)方法会通过同步队列完成Boss线程与Worker线程之间的通信,让SocketChannel的注册任务被Worker线程执行。添加任务后需要调用selector.wakeup()来唤醒被阻塞的Selector

      public void register(final SocketChannel socket) throws IOException {// 只启动一次if (!started) {// 初始化操作}// 向同步队列中添加SocketChannel的注册事件// 在Worker线程中执行注册事件queue.add(new Runnable() {@Overridepublic void run() {try {socket.register(selector, SelectionKey.OP_READ);} catch (IOException e) {e.printStackTrace();}}});// 唤醒被阻塞的Selector// select类似LockSupport中的park,wakeup的原理类似LockSupport中的unparkselector.wakeup();
      }
      
  • Worker线程执行的操作

    • 从同步队列中获取注册任务,并处理Read事件

实现代码

public class ThreadsServer {public static void main(String[] args) {try (ServerSocketChannel serverSocketChannel = ServerSocketChannel.open()) {serverSocketChannel.bind(new InetSocketAddress(8089));Thread.currentThread().setName("boss");serverSocketChannel.configureBlocking(false);Selector selector = Selector.open();serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT, null);// 创建客户端读事件处理线程Worker[] workList = new Worker[4];for (int i = 0; i < 4; i++) {workList[i] = new Worker("worker-" + i);}// 用来负载客户端连接通道AtomicInteger integer = new AtomicInteger();// 循环处理服务端事件while (true) {log.info("before get Selector ready event...");int readySelect = selector.select();log.info("after get Selector ready event,count={}", readySelect);Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();while (iterator.hasNext()) {SelectionKey selectionKey = iterator.next();// 处理完事件之后,需要将对应的事件移除,防止下次出现空事件iterator.remove();// 监听服务端连接事件if (selectionKey.isAcceptable()) {ServerSocketChannel serverChannel = (ServerSocketChannel) selectionKey.channel();// 获取客户端连接对象SocketChannel socketChannel = serverChannel.accept();// 设置客户端链接通道为非阻塞模式socketChannel.configureBlocking(false);log.info("before read....");// 将服务端通道注册进新的线程workList[integer.getAndIncrement() % workList.length].register(socketChannel);log.info("after read....");}}}} catch (IOException e) {e.printStackTrace();}}static class Worker implements Runnable {// 当前工作者工作的线程private Thread thread;// word工作线程的名称private String name;// 工作线程处理客户端通道所注册的选择器private volatile Selector worker;// 判断word是否进行了注册操作,保证值的原子性private volatile boolean start = false;// 使用线程间同步队列private ConcurrentLinkedDeque<Runnable> queue = new ConcurrentLinkedDeque<>();public Worker(String name) {this.name = name;}/*** 将通道和selector关联起来,因此需要客户端连接对象参数*/public void register(SocketChannel channel) throws IOException {if (!start) {// 将当前的工作者(处理客户端链接通道的)和线程关联连起来,当前工作者需要实现runnable接口thread = new Thread(this);thread.setName(name);thread.start();// 一定要对worker进行开启操作,防止后续操作出现空指针worker = Selector.open();start = !start;}// 在此处相当于在boss线程中执行,可能会出现因为worker线程的select()方法导致阻塞,// 所以可使用线程间同步队列,或者是,使用selector选择器的wakeUp()方法
//          channel.register(worker, SelectionKey.OP_READ, null);// 方法1queue.add(() -> {try {channel.register(worker, SelectionKey.OP_READ, null);} catch (ClosedChannelException e) {e.printStackTrace();}});// 方法2 使用selector的wakeUp方法,但是wakeUp是两种方法都需要的,方法2则不需要队列进行处理// 该方法主要作用就是,唤醒阻塞的选择器,唤醒之后,即使之后的唤醒之前对selector进行绑定事件,然后进行select()调用,也能成功唤醒worker.wakeup();
//            channel.register(worker, SelectionKey.OP_READ, null);}@Overridepublic void run() {// 处理客户端通道的读事件while (true) {try {worker.select();// 在此处执行注册事件Runnable poll = queue.poll();if (null != poll) {poll.run();}Iterator<SelectionKey> iterator = worker.selectedKeys().iterator();while (iterator.hasNext()) {SelectionKey selectionKey = iterator.next();iterator.remove();log.info("current thread={}", thread.getName());if (selectionKey.isReadable()) {SocketChannel channel = (SocketChannel) selectionKey.channel();ByteBuffer buffer = ByteBuffer.allocate(16);channel.read(buffer);// 将缓冲区buffer切换为读模式buffer.flip();debugAll(buffer);buffer.clear();}}} catch (IOException e) {e.printStackTrace();}}}}
}

四、NIO与BIO

1、Stream与Channel

  • stream 不会自动缓冲数据,channel 会利用系统提供的发送缓冲区、接收缓冲区(更为底层)

  • stream 仅支持阻塞 API,channel 同时支持阻塞、非阻塞 API,网络 channel 可配合 selector 实现多路复用

  • 二者

    均为全双工,即读写可以同时进行

    • 虽然Stream是单向流动的,但是它也是全双工的

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

3、零拷贝

零拷贝指的是数据无需拷贝到 JVM 内存中,同时具有以下三个优点

  • 更少的用户态与内核态的切换
  • 不利用 cpu 计算,减少 cpu 缓存伪共享
  • 零拷贝适合小文件传输

传统 IO 问题

传统的 IO 将一个文件通过 socket 写出

File f = new File("helloword/data.txt");
RandomAccessFile file = new RandomAccessFile(file, "r");byte[] buf = new byte[(int)f.length()];
file.read(buf);Socket socket = ...;
socket.getOutputStream().write(buf);Copy

内部工作流如下

  • Java 本身并不具备 IO 读写能力,因此 read 方法调用后,要从 Java 程序的用户态切换至内核态,去调用操作系统(Kernel)的读能力,将数据读入内核缓冲区。这期间用户线程阻塞,操作系统使用 DMA(Direct Memory Access)来实现文件读,期间也不会使用 CPU

    DMA 也可以理解为硬件单元,用来解放 cpu 完成文件 IO

  • 内核态切换回用户态,将数据从内核缓冲区读入用户缓冲区(即 byte[] buf),这期间 CPU 会参与拷贝,无法利用 DMA

  • 调用 write 方法,这时将数据从用户缓冲区(byte[] buf)写入 socket 缓冲区,CPU 会参与拷贝

  • 接下来要向网卡写数据,这项能力 Java 又不具备,因此又得从用户态切换至内核态,调用操作系统的写能力,使用 DMA 将 socket 缓冲区的数据写入网卡,不会使用 CPU

可以看到中间环节较多,java 的 IO 实际不是物理设备级别的读写,而是缓存的复制,底层的真正读写是操作系统来完成的

  • 用户态与内核态的切换发生了 3 次,这个操作比较重量级
  • 数据拷贝了共 4 次

NIO 优化

通过 DirectByteBuf

  • ByteBuffer.allocate(10)

    • 底层对应 HeapByteBuffer,使用的还是 Java 内存
  • ByteBuffer.allocateDirect(10)

    • 底层对应DirectByteBuffer,使用的是操作系统内存

大部分步骤与优化前相同,唯有一点:Java 可以使用 DirectByteBuffer 将堆外内存映射到 JVM 内存中来直接访问使用

  • 这块内存不受 JVM 垃圾回收的影响,因此内存地址固定,有助于 IO 读写
  • Java 中的 DirectByteBuf 对象仅维护了此内存的虚引用,内存回收分成两步
    • DirectByteBuffer 对象被垃圾回收,将虚引用加入引用队列

      • 当引用的对象ByteBuffer被垃圾回收以后,虚引用对象Cleaner就会被放入引用队列中,然后调用Cleaner的clean方法来释放直接内存
      • DirectByteBuffer 的释放底层调用的是 Unsafe 的 freeMemory 方法
    • 通过专门线程访问引用队列,根据虚引用释放堆外内存
  • 减少了一次数据拷贝,用户态与内核态的切换次数没有减少

优化1

以下两种方式都是零拷贝,即无需将数据拷贝到用户缓冲区中(JVM内存中)

底层采用了 linux 2.1 后提供的 sendFile 方法,Java 中对应着两个 channel 调用 transferTo/transferFrom 方法拷贝数据

  • Java 调用 transferTo 方法后,要从 Java 程序的用户态切换至内核态,使用 DMA将数据读入内核缓冲区,不会使用 CPU
  • 数据从内核缓冲区传输到 socket 缓冲区,CPU 会参与拷贝
  • 最后使用 DMA 将 socket 缓冲区的数据写入网卡,不会使用 CPU

这种方法下

  • 只发生了1次用户态与内核态的切换
  • 数据拷贝了 3 次

优化2

linux 2.4 对上述方法再次进行了优化

  • Java 调用 transferTo 方法后,要从 Java 程序的用户态切换至内核态,使用 DMA将数据读入内核缓冲区,不会使用 CPU
  • 只会将一些 offset 和 length 信息拷入 socket 缓冲区,几乎无消耗
  • 使用 DMA 将 内核缓冲区的数据写入网卡,不会使用 CPU

整个过程仅只发生了1次用户态与内核态的切换,数据拷贝了 2 次

4、AIO

AIO 用来解决数据复制阶段的阻塞问题

  • 同步意味着,在进行读写操作时,线程需要等待结果,还是相当于闲置
  • 异步意味着,在进行读写操作时,线程不必等待结果,而是将来由操作系统来通过回调方式由另外的线程来获得结果

异步模型需要底层操作系统(Kernel)提供支持

  • Windows 系统通过 IOCP 实现了真正的异步 IO
  • Linux 系统异步 IO 在 2.6 版本引入,但其底层实现还是用多路复用模拟了异步 IO,性能没有优势

【netty篇】- 第0章netty网络编程必备知识[持续更新中]~相关推荐

  1. 视频教程-游戏网络编程必备知识-Unity3D

    游戏网络编程必备知识 网名:海洋,CSDN社区讲师,3D游戏引擎开发者,IT讲师,计算机图形学方向研究生,曾在浙江大学CAD&CG;国家重点实验室学习.从事IT行业15年,主导或参与了18款大 ...

  2. 2、线程池篇 - 从理论基础到具体代码示例讲解(持续更新中......)

    前言 暂无. 一.线程篇 有关线程部分的知识整理请看我下面这篇博客: 1.线程篇 - 从理论到具体代码案例最全线程知识点梳理(持续更新中-) 二.线程池基础知识 线程池优点 他的主要特点为: 线程复用 ...

  3. 《亿级流量JAVA高并发与网络编程实战》笔记--------更新中

    <亿级流量JAVA高并发与网络编程实战>笔记 第一章 高并发概述 "高并发技术" 是一个广义的概念,是指一种高效的地实现并发需求的解决方案,是技术领域的名称,可以包含架 ...

  4. 【Linux】一步一步学Linux网络编程教程汇总(更新中......)

    00. 目录 文章目录 00. 目录 01. 基础理论知识 02. 初级编程 03. 高级编程 04. LibEvent库 05. 06. 07. 01. 基础理论知识 [Linux网络编程]网络协议 ...

  5. Spring源码系列-第1章-Spring源码纵览【持续更新中】

    文章目录 必读 第1章-Spring源码纵览 概述 简单的继承关系图 Spring框架整体流程 核心组件接口分析 Resource资源 方法 实现类 ResourceLoader资源加载器 方法 实现 ...

  6. 前端面试笔试编程题(持续更新中)

    1. 求两个日期中间的有效日期(考虑闰年)(微众银行校招笔试) 如 2015-2-8 到 2015-3-3,返回[2015-2-8 2015-2-9-] 思路:使用毫秒数  一天一天地放进去 // 求 ...

  7. 350篇天涯论坛经典贴子合集分享(持续更新中ing)

    注:花时间搜集了天涯论坛神贴合集,分享给大家,无密可编辑pdf版本,就是给大家留下一些经典的回忆.当年天涯论坛那可是高人辈出啊,很多佳作神帖含金量很高.很多现在连天涯上都看不到了,有点可惜.大家且看且 ...

  8. [IDE工具篇]提高办公效率的几款软件(持续更新中)

    1.Deskpins 可以使被钉住的窗口至于最前方,无论你怎么动你都可以看得到它,意味着你不需要两个显示屏,也可以达到一遍编辑word一遍上网查资料 2.Clover/Q-Dir 三叶草的功能就是改变 ...

  9. Netty专题-(3)NIO网络编程

    之前在上一章Netty专题-(2)NIO三大核心中介绍了NIO的三大核心类Selector . Channel 和 Buffer,这一章我们将利用这些核心进行编程实现相关的一些功能.在正式进入编程之前 ...

最新文章

  1. 读书笔记:理论生态学原理及应用(一)——合作的机制
  2. 在疫情防控一线,技术能发挥什么作用?
  3. 普中28335开发攻略_TMS320F28335项目开发记录1_CCS的使用介绍
  4. office插件开发_Office神插件,打开新世界的大门
  5. java 计时 timeclock_用 java 写一个clock的类,100毫秒的时钟 求代码。。越简单越好。。最好有注释...
  6. 【转载】实用的人际关系经验
  7. 如何搞好公司和员工的关系二:以德服人才能口服心服
  8. U盘安装Linux系统
  9. 给学计算机的男友买什么礼物,毕业季,男朋友初入职场送礼好物推荐!
  10. 【知识】SpringBoot项目结构目录
  11. 如何在新浪微博中批量取消关注
  12. jQuery带logo的网页二维码生成
  13. 2021计组和微计-背下来就能拿分的知识总结
  14. 如何快速验证你的创业想法是否可行?这里有7大策略
  15. 电子邮箱的工作原理以及SMTP、POP3、IMAP之间的联系和区别
  16. 订单中心探索业务系统数据预置助力快交付之路
  17. USB-WIFI RTL8188CU模块驱动
  18. 综述:基于图学习的推荐系统;论文笔记
  19. 文本生成评价指标-A Survey
  20. 树莓派开发笔记(二)搭建智能家居系统(1) — Home Bridge + Home Assistant

热门文章

  1. 深入理解 linux swapper 进程
  2. 键盘无法使用,管理器显示hid keyboard device 有问题的解决方法
  3. C#学习过程(一周)
  4. 招行银行卡开通google visa验证功能
  5. 提车自检手册(3系,其他车辆类似)
  6. 全球在用手机数量达40亿部覆盖60%人口
  7. linux bzip2 命令,Linux系统中bzip2命令的语法参数介绍
  8. 多元建模基础(三):球面分布与椭圆分布
  9. Linux开发从入门到精通——基础篇 :1、计算机常识、Linux操作系统和文件系统介绍
  10. 雷军的清华演讲-掌握你的运气