文章目录
1. IO 模型
1.1 I/O 模型基本说明
-
I/O 模型简单的理解:就是用什么样的通道进行数据的发送和接收,很大程度上决定了程 序通信的性能
-
Java共支持3种网络编程模型/IO模式:BIO、NIO、AIO
-
Java BIO : 同步并阻塞(传统阻塞型),服务器实现模式为一个连接一个线程,即客户端 有连接请求时服务器端就需要启动一个线程进行处理,如果这个连接不做任何事情会造成 不必要的线程开销
-
Java NIO : 同步非阻塞,服务器实现模式为一个线程处理多个请求(连接),即客户端发送的连接请求都会注册到多路复用器上,多路复用器轮询到连接有I/O请求就进行处理
-
Java AIO(NIO.2) : 异步非阻塞,AIO 引入异步通道的概念,采用了 Proactor 模式,简化了程序编写,有效的请求才启动线程,它的特点是先由操作系统完成后才通知服务端程
1.2 BIO、NIO、AIO适用场景分析
- BIO方式适用于连接数目比较小且固定的架构,这种方式对服务器资源要求比较高, 并发局限于应用中,JDK1.4以前的唯一选择,但程序简单易理解。
- NIO方式适用于连接数目多且连接比较短(轻操作)的架构,比如聊天服务器,弹幕 系统,服务器间通讯等。编程比较复杂,JDK1.4开始支持。
- AIO方式使用于连接数目多且连接比较长(重操作)的架构,比如相册服务器,充分 调用OS参与并发操作,编程比较复杂,JDK7开始支持。
2. BIO
2.1 基本介绍
- Java BIO 就是传统的java io 编程,其相关的类和接口在 java.io
- BIO(blocking I/O) : 同步阻塞,服务器实现模式为一个连接一个线程,即客户端有连 接请求时服务器端就需要启动一个线程进行处理,如果这个连接不做任何事情会造 成不必要的线程开销,可以通过线程池机制改善(实现多个客户连接服务器)。 【后有 应用实例】
- BIO方式适用于连接数目比较小且固定的架构,这种方式对服务器资源要求比较高, 并发局限于应用中,JDK1.4以前的唯一选择,程序简单易理解
2.2 工作机制
- BIO编程简单流程
- 服务器端启动一个 ServerSocket
- 客户端启动 Socket 对服务器进行通信,默认情况下服务器端需要对每个客户建立一个线程与之通讯
- 客户端发出请求后, 先咨询服务器 是否有线程响应,如果没有则会等待,或者被拒绝
- 如果有响应,客户端线程会等待请 求结束后,在继续执行
2.3 应用实例
- 要求:
- 使用BIO模型编写一个服务器端,监听6666端口,当有客户端连接时,就启 动一个线程与之通讯。
- 要求使用线程池机制改善,可以连接多个客户端.
- 服务器端可以接收客户端发送的数据(telnet 方式即可)。
-
编写代码
public class BIOServer { public static void main(String[] args) throws IOException { // 1. 创建一个线程池 ExecutorService threadPool = new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS, new SynchronousQueue<Runnable>()); // 监听“6666” 端口,接收客户连接请求,并生成与客户端连接的Socket ServerSocket serverSocket = new ServerSocket(6666); System.out.println("服务器启动了"); while (true){ // 监听,等待客户端连接 final Socket socket = serverSocket.accept(); System.out.println("连接到一个客户端"); // 2. 如果有客户端连接,就创建一个线程,与之通信 threadPool.execute(()->{ handler(socket); }); } } /** * 和客户端通信的方法 * 循环的读取客户端的数据,然后输出 */ public static void handler(Socket socket){ // 打印线程信息 System.out.println("线程信息:{id:"+Thread.currentThread().getId()+", " + "name: "+Thread.currentThread().getName()); // 用于接收数据 byte[] bytes = new byte[1024]; // 通过 socket 获取输入流 try { InputStream inputStream = socket.getInputStream(); // 循环的读取客户端发送的数据 while (true){ System.out.println("进行通信线程信息:{id:"+Thread.currentThread().getId()+", " + "name: "+Thread.currentThread().getName()); int read = inputStream.read(bytes); if (read != -1){ // 说明还可以读 // 输出客户端发送的数据 System.out.println(new String(bytes,0, read)); }else { // 读取完毕 break; } } } catch (IOException e) { e.printStackTrace(); }finally { System.out.println("关闭连接"); try { socket.close(); } catch (IOException e) { e.printStackTrace(); } } } }
-
连接服务,测试
打开 CMD,连接 6666 端口
输入 Ctrl + ],传递数据
查看控制台 -
小结
- 从上面的结果可以发现,处理请求的线程 和 服务端客户端之间连接的线程是同一个
- 通过 Debug 方式运行可以发现,当连接上服务端之后,不进行任何操作,改线程只会阻塞在 int read = inputStream.read(bytes);
2.4 问题分析
- 每个请求都需要创建独立的线程,与对应的客户端进行数据 Read,业务处理,数据 Write 。
- 当并发数较大时,需要创建大量线程来处理连接,系统资源占 用较大。
- 连接建立后,如果当前线程暂时没有数据可读,则线程就阻塞 在 Read 操作上,造成线程资源浪费
3. NIO
3.1 基本介绍
- Java NIO 全称 java non-blocking IO,是指 JDK 提供的新 API。从 JDK1.4 开始,Java 提供了一系列改进的输入/输出 的新特性,被统称为 NIO(即 New IO),是同步非阻塞的
- NIO 相关类都被放在 java.nio 包及子包下,并且对原 java.io 包中的很多类进行改写。
- NIO 有三大核心部分:Channel(通道),Buffer(缓冲区), Selector(选择器)
- NIO是 面向缓冲区 ,或者面向块编程的。数据读取到一个 它稍后处理的缓冲区,需要时可在缓冲区中前后移动,这就 增加了处理过程中的灵活性,使用它可以提供非阻塞式的高 伸缩性网络
- Java NIO的非阻塞模式,使一个线程从某通道发送请求或者读取数据,但是它仅能得到目前可用的数据,如果目前没有数据可用时,就什么都不会获取,而不是保持线程阻塞,所以直至数据变的可以读取之前,该线程可以继续做其他的事情。 非阻塞写也是如此,一个线程请求写入一些数据到某通道,但不需要等待它完全写入,这 个线程同时可以去做别的事情。
- 通俗理解:NIO是可以做到用一个线程来处理多个操作的。假设有10000个请求过来, 根据实际情况,可以分配50或者100个线程来处理。不像之前的阻塞IO那样,非得分配10000个。
- HTTP2.0使用了多路复用的技术,做到同一个连接并发处理多个请求,而且并发请求 的数量比HTTP1.1大了好几个数量级
3.2 NIO 和 BIO 的比较
- BIO 以流的方式处理数据,而 NIO 以块的方式处理数据,块 I/O 的效率比流 I/O 高很多
- BIO 是阻塞的,NIO 则是非阻塞的
- BIO基于字节流和字符流进行操作,而 NIO 基于 Channel(通道)和 Buffer(缓冲区)进 行操作,数据总是从通道读取到缓冲区中,或者从缓冲区写入到通道中。 Selector(选择器)用于监听多个通道的事件(比如:连接请求,数据到达等),因此使用单个线程就可以监听多个客户端通道
3.3 NIO 三大核心
- Selector 、 Channel 和 Buffer 的简单关系图
- 关系图的说明:
- 每个 Channel 都会对应一个 Buffer
- Selector 对应一个线程, 一个 Selector 对应多个 Channel(连接)
- 该图反应了有三个 Channel 注册到该 selector
- 程序切换到哪个 Channel 是由事件决定的, Event 就是一个重要的概念
- Selector 会根据不同的事件,在各个 Channel(通道)上切换
- Buffer 就是一个内存块 , 底层是有一个数组
- 数据的读取写入是通过 Buffer, 这个和BIO , BIO 中要么是输入流,或者是 输出流, 不能双向,但是NIO的 Buffer 是可以读也可以写, 需要 flip 方法切换
- Channel 是双向的, 可以返回底层操作系统的情况, 比如 Linux , 底层的操作系统 通道就是双向的
3.4 缓冲区(Buffer)
1. 基本介绍
缓冲区(Buffer):缓冲区本质上是一个可以读写数据的内存块,可以理解成是一个 容器对象(含数组),该对象提供了一组方法,可以更轻松地使用内存块,,缓冲区对象内置了一些机制,能够跟踪和记录缓冲区的状态变化情况。Channel 提供从文件、 网络读取数据的渠道,但是读取或写入的数据都必须经由 Buffer,如图:
2. Buffer 类及其子类
- 在 NIO 中,Buffer 是一个顶层父类,它是一个抽象类, 类的层级关系图
- 常用Buffer子类一览
- ByteBuffer,存储字节数据到缓冲区
- ShortBuffer,存储字符串数据到缓冲区
- CharBuffer,存储字符数据到缓冲区
- IntBuffer,存储整数数据到缓冲区
- LongBuffer,存储长整型数据到缓冲区
- DoubleBuffer,存储小数到缓冲区
- FloatBuffer,存储小数到缓冲区
-
Buffer 的简单案例
public class BasicBuffer { public static void main(String[] args) { // 创建一个 Buffer, 一个可以存放 5 个整数的 Buffer IntBuffer intBuffer = IntBuffer.allocate(5); // 向 Buffer 中存放数据 for (int i = 0; i < intBuffer.capacity(); i++) { intBuffer.put(i); } // 从 Buffer 读取数据 // 对 Buffer 进行读写切换 intBuffer.flip(); while (intBuffer.hasRemaining()){ System.out.println(intBuffer.get()); } } }
-
Buffer类定义了所有的缓冲区都具有的四个属性来提供关于其所包含的数据元素 的信息:
- 这几个属性的大小关系 :mark <= position <= limit <= capacity
- Buffer类相关方法 一览
-
标 ★ 为常用
public abstract class Buffer { //JDK1.4时,引入的api public final int capacity( )// ★ 返回此缓冲区的容量 public final int position( )// ★ 返回此缓冲区的位置 public final Buffer position (int newPositio)// ★ 设置此缓冲区的位置 public final int limit( )// ★ 返回此缓冲区的限制 public final Buffer limit (int newLimit)// ★ 设置此缓冲区的限制 public final Buffer mark( )//在此缓冲区的位置设置标记 public final Buffer reset( )//将此缓冲区的位置重置为以前标记的位置 public final Buffer clear( )// ★ 清除此缓冲区, 即将各个标记恢复到初始状态,但是数据并没有真正擦除, 后面操作会覆盖 public final Buffer flip( )// ★ 反转此缓冲区 public final Buffer rewind( )//重绕此缓冲区 public final int remaining( )//返回当前位置与限制之间的元素数 public final boolean hasRemaining( )// ★ 告知在当前位置和限制之间是否有元素 public abstract boolean isReadOnly( );// ★ 告知此缓冲区是否为只读缓冲区 //JDK1.6时引入的api public abstract boolean hasArray();// ★ 告知此缓冲区是否具有可访问的底层实现数组 public abstract Object array();// ★ 返回此缓冲区的底层实现数组 public abstract int arrayOffset();//返回此缓冲区的底层实现数组中第一个缓冲区元素的偏移量 public abstract boolean isDirect();//告知此缓冲区是否为直接缓冲区 }
-
从前面可以看出对于 Java 中的基本数据类型(boolean除外),都有一个 Buffer 类型与之 相对应,最常用的自然是ByteBuffer 类(二进制数据)—— 网络传输的底层都是字节传输形式,该类的主要方法如下:
public abstract class ByteBuffer { //缓冲区创建相关api public static ByteBuffer allocateDirect(int capacity)// ★ 创建直接缓冲区 public static ByteBuffer allocate(int capacity)// ★ 设置缓冲区的初始容量 public static ByteBuffer wrap(byte[] array)//把一个数组放到缓冲区中使用 //构造初始化位置offset和上界length的缓冲区 public static ByteBuffer wrap(byte[] array,int offset, int length) //缓存区存取相关API public abstract byte get( );// ★ 从当前位置position上get,get之后,position会自动+1 public abstract byte get (int index);// ★ 从绝对位置get ,position 不会变化 public abstract ByteBuffer put (byte b);// ★ 从当前位置上添加,put之后,position会自动+1 public abstract ByteBuffer put (int index, byte b);// ★ 从绝对位置上put ,position 不会变化 }
3.5 通道(Channel)
1. 基本介绍
-
NIO的通道类似于流,但有些区别如下:
• 通道可以同时进行读写,而流只能读或者只能写
• 通道可以实现异步读写数据
• 通道可以从缓冲读数据,也可以写数据到缓冲:
-
BIO 中的 stream 是单向的,例如 FileInputStream 对 象只能进行读取数据的操作,而 NIO 中的通道 (Channel)是双向的,可以读操作,也可以写操作。
-
Channel在NIO中是一个接口
-
常用的 Channel 类有:FileChannel、 DatagramChannel、ServerSocketChannel 和 SocketChannel。
- 在 Server 服务器端中有一个 ServerSocketChannel,每当有 Client 客户端 来连接的时候,先访问 Server 的 ServerSocketChannel,然后由 ServerSocketChannel 为每一个连接创建一个 SocketChannel
- FileChannel 用于文件的数据读写
- DatagramChannel 用于 UDP 的数据读写
- ServerSocketChannel 和 SocketChannel 用于 TCP 的数据读写。
2. FileChannel 类
FileChannel主要用来对本地文件进行 IO 操作,常见的方法有
- public int read(ByteBuffer dst) ,从通道读取数据并放到缓冲区中
- public int write(ByteBuffer src) ,把缓冲区的数据写到通道中
- public long transferFrom(ReadableByteChannel src, long position, long count),从目标通道 中复制数据到当前通道
- public long transferTo(long position, long count, WritableByteChannel target),把数据从当 前通道复制给目标通道
3. 应用实例1-本地文件写数据
- 要求:
- 使用前面的ByteBuffer(缓冲) 和 FileChannel(通道), 将 “Hello,NIO_Demo01” 写入 到file01.txt 中
- 文件不存在就创建
-
流程图
-
代码实现
public class FileChannel01 { public static void main(String[] args) throws Exception { // 1. 得到数据 String str = "Hello"; // 2. 把数据写入 Buffer // 创建一个输出流 , channel FileOutputStream fileOutputStream = new FileOutputStream("D:\\file01.txt"); // 通过输出流,获取对应的 FileChannel // fileChannel 真实类型是 fileChannelImpl FileChannel fileChannel = fileOutputStream.getChannel(); // 创建一个缓冲区 ByteBuffer byteBuffer = ByteBuffer.allocate(1024); // 把数据放入到 byteBuffer byteBuffer.put(str.getBytes()); // 3. 把 Buffer 的数据传入输出流 // 4. 通过 输出流 中的 fileChannel 对象把数据写入 // 反转 Buffer byteBuffer.flip(); // 把 Buffer 的数据写入 fileChannel fileChannel.write(byteBuffer); // 关闭流 fileOutputStream.close(); } }
-
测试
4. 应用实例2-本地文件读数据
- 要求:
- 使用的ByteBuffer(缓冲) 和 FileChannel(通道), 将 file01.txt 中的数据读入到程序,并显示在控制台屏幕
- 假定文件已经存在
public class FileChannel02 {
public static void main(String[] args) throws Exception {
// 1. 通过 输入流 中的 fileChannel 对象把数据读出
// 创建输入流
File file = new File("D:\\file01.txt");
FileInputStream inputStream = new FileInputStream(file);
// 通过 输入流 获得对应的 FileChannel
FileChannel fileChannel = inputStream.getChannel();
// 2. 把 输入流 的数据传入 Buffer
// 创建缓冲区
ByteBuffer buffer = ByteBuffer.allocate((int) file.length());
// 把数据从 fileChannel 读入到缓冲区
fileChannel.read(buffer);
// 3. 把数据从 Buffer 中取出
// 将缓冲区的字节转换成字符串
String s = new String(buffer.array());
// 4. 显示数据
System.out.println(s);
}
}
5. 应用实例3-使用一个Buffer完成文件读写(复制)
- 要求:
- 使用 FileChannel(通道) 和 方法 read , write,完成文件的拷贝
- 拷贝一个文本文件 file01.txt , 放在同级目录下 —— file02.txt
public class FileChannel03 {
public static void main(String[] args) throws Exception {
// 创建输入流对象,并获取对应的 Channel
File file = new File("D:\\file01.txt");
FileInputStream inputStream = new FileInputStream(file);
FileChannel inputStreamChannel = inputStream.getChannel();
// 创建输出流对象,并获取对应的 Channel
FileOutputStream outputStream = new FileOutputStream("D:\\file02.txt");
FileChannel outputStreamChannel = outputStream.getChannel();
// 创建 Buffer
ByteBuffer buffer = ByteBuffer.allocate(512);
int read = 0;
while (read != -1){
// 重置 Buffer 中的标志位,以免上一轮循环中 Buffer 中的信息,影响本轮操作
buffer.clear();
// 循环的 从 输入流 读取数据并写入到 输出流
read = inputStreamChannel.read(buffer);
buffer.flip();
outputStreamChannel.write(buffer);
}
// 关闭输入、输出流
inputStream.close();
outputStream.close();
}
}
-
注意
buffer.clear();
这句话很重要在 (文件的大小) > (Buffer 的大小) 的情况下,会产生死循环。
- 第一轮循环没有把文件数据读完,但是 Buffer 已经被填满,并且 Position(标志位)已经移到最后一位了
- 到了第二轮循环时, Position 在最后一位,等于了 Limit ,也即 Position == Limit ,这时
read = inputStreamChannel.read(buffer);
这句话的结果就为 0 ,因为不满足退出循环的条件(read == -1),就会产生死循环。
6. 应用实例4-拷贝文件transferFrom 方法
- 要求:
- 使用 FileChannel(通道) 和 方法 transferFrom ,完成文件的拷贝
- 拷贝一张图片
public class FileChannel04 {
public static void main(String[] args) throws Exception {
// 创建输入流对象,并获取对应的 Channel
FileInputStream inputStream = new FileInputStream("D:\\Demo.png");
FileChannel inputStreamChannel = inputStream.getChannel();
// 创建输出流对象,并获取对应的 Channel
FileOutputStream outputStream = new FileOutputStream("D:\\Demo-2.png");
FileChannel outputStreamChannel = outputStream.getChannel();
// 使用 transferForm 完成拷贝
// 参数 : 被复制的流的Channel ; 起始位置 ; 结束位置
outputStreamChannel.transferFrom(inputStreamChannel,0,inputStreamChannel.size());
// 关闭输入、输出流
inputStream.close();
outputStream.close();
}
}
7. 关于Buffer 和 Channel的注意事项和细节
-
ByteBuffer 支持类型化的 put 和 get, put 放入的是什么数据类型,get 就应该使用相应的数据类型来取出(取出的顺序也要和存入的顺序一致),否则可能有 BufferUnderflowException 异常。
-
可以将一个普通Buffer 转成只读Buffer,如果对一个只读类型的 Buffer 进行写操作会报错 ReadOnlyBufferException
ByteBuffer buffer = ByteBuffer.allocate(3); ByteBuffer byteBuffer = buffer.asReadOnlyBuffer(); System.out.println(buffer); System.out.println(byteBuffer);
-
NIO 还提供了 MappedByteBuffer, 可以让文件直接在内存(堆外的内存)中进行修改, 而如何同步到文件由NIO 来完成.
/* 说明 1. MappedByteBuffer 可以让文件直接在内存中修改,这样操作系统并不需要拷贝一次 2. MappedByteBuffer 实际类型是 DirectByteBuffer */ public static void main(String[] args) throws Exception { RandomAccessFile randomAccessFile = new RandomAccessFile("D:\\file01.txt", "rw"); // 获取对应的文件通道 FileChannel channel = randomAccessFile.getChannel(); // 参数 :使用 只读/只写/读写 模式 ; 可以修改的起始位置 ; 映射到内存的大小,即可以将文件的多少个字节映射到内存 // 这里就表示,可以对 file01.txt 文件中 [0,5) 的字节进行 读写操作 MappedByteBuffer map = channel.map(FileChannel.MapMode.READ_WRITE, 0, 5); // 进行修改操作 map.put(0, (byte) 'A'); map.put(3, (byte) '3'); // 关闭通道 channel.close(); }
-
前面我们讲的读写操作,都是通过一个Buffer 完成的,NIO 还支持 通过多个 Buffer (即 Buffer 数组) 完成读写操作,即 Scattering 和 Gathering ,遵循 依次写入,依次读取。
代码演示
public class ScatteringAndGatheringTest { /* Scattering : 将数据写入到 Buffer 时,可以采用 Buffer 数组,依次写入【分散】 Gathering : 从 Buffer 读取数据,可以采用 Buffer 数组,依次读取 */ public static void main(String[] args) throws Exception { // 使用 ServerSocketChannel 和 InetSocketAddress 网络 ServerSocketChannel serverSocketChannel = ServerSocketChannel.open(); InetSocketAddress inetSocketAddress = new InetSocketAddress(6666); // 绑定端口到 socket,并启动 serverSocketChannel.socket().bind(inetSocketAddress); // 创建 Buffer 数组 ByteBuffer[] byteBuffers = new ByteBuffer[2]; byteBuffers[0] = ByteBuffer.allocate(5); byteBuffers[1] = ByteBuffer.allocate(3); // 等待客户端连接(telnet) SocketChannel socketChannel = serverSocketChannel.accept(); // 循环的读取数据 while (true){ // 表示累计读取的字节数 int byteRead = 0; // 假设从客户端最多接收 8 个字节 while (byteRead < 8){ // 自动把数据分配到 byteBuffers-0、byteBuffers-1 long read = socketChannel.read(byteBuffers); byteRead += read; // 使用流打印,查看当前 Buffer 的 Position 和 Limit Arrays.asList(byteBuffers).stream(). map(byteBuffer -> "{position: "+byteBuffer.position()+", limit: "+byteBuffer.limit()+"}") .forEach(System.out::println); } // 将所有的 Buffer 进行反转,为后面的其他操作做准备 Arrays.asList(byteBuffers).forEach(Buffer::flip); // 将数据读出,显示到客户端 int byteWrite = 0; while (byteWrite < 8){ long write = socketChannel.write(byteBuffers); byteWrite += write; } // 将所有的 Buffer 进行清空,为后面的其他操作做准备 Arrays.asList(byteBuffers).forEach(Buffer::clear); // 打印处理的字节数 System.out.println("{byteRead: "+byteRead+", byteWrite: "+byteWrite+"}"); } }
打开 CMD
测试-1 —— 刚好发送 8 个字节
测试-2 —— 发送不足 8 个字节
测试-3 —— 发送超过 8 个字节
3.6 Selector(选择器)
1. 基本介绍
- Java 的 NIO,用非阻塞的 IO 方式。可以用一个线程,处理多个的客户端连接,就会使用到Selector(选择器)
- Selector 能够检测多个注册的通道上是否有事件发生(注意:多个Channel以事件的方式可以注册到同一个Selector),如果有事件发生,便获取事件然 后针对每个事件进行相应的处理。这样就可以只用一个单线程去管理多个通道,也就是管理多个连接和请求。
- 只有在 连接/通道 真正有读写事件发生时,才会进行读写,就大大地减少 了系统开销,并且不必为每个连接都创建一个线程,不用去维护多个线程
- 避免了多线程之间的上下文切换导致的开销
2. Selector(选择器)
- Selector示意图
- 说明:
- Netty 的 IO 线程 NioEventLoop 聚合了 Selector(选择器, 也叫多路复用器),可以同时并发处理成百上千个客 户端连接。
- 当线程从某客户端 Socket 通道进行读写数据时,若没 有数据可用时,该线程可以进行其他任务。
- 线程通常将非阻塞 IO 的空闲时间用于在其他通道上 执行 IO 操作,所以单独的线程可以管理多个输入和 输出通道。
- 由于读写操作都是非阻塞的,这就可以充分提升 IO 线程的运行效率,避免由于频繁 I/O 阻塞导致的线程 挂起。
- 一个 I/O 线程可以并发处理 N 个客户端连接和读写操作,这从根本上解决了传统同步阻塞 I/O 一连接一线 程模型,架构的性能、弹性伸缩能力和可靠性都得到 了极大的提升。
3. Selector类相关方法
- Selector 类是一个抽象类, 常用方法和说明如下
public abstract class Selector implements Closeable {
public static Selector open();//得到一个选择器对象
public int select(long timeout);//监控所有注册的通道,当其 中有 IO 操作可以进行时,将 对应的 SelectionKey 加入到内部集合中并返回,参数用来 设置超时时间
public Set<SelectionKey> selectedKeys();//从内部集合中得 到所有的 SelectionKey
}
4. 注意事项
-
NIO中的 ServerSocketChannel功能类似ServerSocket,SocketChannel功能类 似Socket
-
selector 相关方法说明
selector.select()//阻塞
selector.select(1000);//阻塞1000毫秒,在1000毫秒后返回
selector.wakeup();//唤醒
selector selector.selectNow();//不阻塞,立马返还
3.7 NIO 非阻塞 网络编程原理分析
- NIO 非阻塞 网络编程相关的(Selector、SelectionKey、 ServerScoketChannel和SocketChannel) 关系梳理图
- 说明:
- 当客户端连接时,会通过 ServerSocketChannel 得到 SocketChannel
- Selector 进行监听 select 方法, 返回有事 件发生的通道的个数.
- 将socketChannel注册到Selector上, register(Selector sel, int ops), 一个 selector上可以注册多个SocketChannel
- 注册后返回一个 SelectionKey, 会和该 Selector 关联(集合)
- 进一步得到各个 SelectionKey (有事件发 生)
- 在通过 SelectionKey 反向获取 SocketChannel , 方法 channel()
- 可以通过 得到的 channel , 完成业务处理
3.8 NIO 非阻塞 网络编程快速入门
- 案例要求:
编写一个 NIO 入门案例,实现服务器端和客户端之间的数据简单通讯(非阻塞)
- 服务端
public class NIOServer {
public static void main(String[] args) throws Exception{
// 创建 ServerSocketChannel
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
// 得到一个 Selector 实例
Selector selector = Selector.open();
// 绑定端口,在服务端进行监听
serverSocketChannel.socket().bind(new InetSocketAddress(6666));
// 设置为非阻塞
serverSocketChannel.configureBlocking(false);
// 把 ServerSocketChannel 注册到 Selector 关心事件为 OP_ACCEPT(连接)
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
// 循环等待客户端连接
while (true){
// 等待一秒,如果没有事件发生,就继续
if (selector.select(1000) == 0){
System.out.println("服务器等待了 1 s,无连接");
continue;
}
// 如果有事件发生,获取到发生事件的 SelectionKey 集合
Set<SelectionKey> selectionKeys = selector.selectedKeys();
// 通过 SelectionKey 反向获取对应通道
Iterator<SelectionKey> keyIterator = selectionKeys.iterator();
while (keyIterator.hasNext()){
// 获取 keyIterator
SelectionKey key = keyIterator.next();
// 根据 key 发生的事件,做相应的处理
// 如果是连接的事件
if (key.isAcceptable()){
// 通过 serverSocketChannel 给该客户端生成一个 SocketChannel
SocketChannel socketChannel = serverSocketChannel.accept();
// 设置为非阻塞
socketChannel.configureBlocking(false);
// 将当前的 socketChannel 注册到 Selector,关心事件为 OP_READ(读),
// 同时给 socketChannel 关联一个 Buffer
socketChannel.register(selector,SelectionKey.OP_READ, ByteBuffer.allocate(1024));
}
// 如果是读事件
if (key.isReadable()){
// 通过 key 反向获取对应的 Channel
SocketChannel channel = (SocketChannel)key.channel();
// 获取该 SocketChannel 关联的 Buffer
ByteBuffer buffer = (ByteBuffer) key.attachment();
// 把 Channel 中的数据读入到 Buffer 中
channel.read(buffer);
System.out.println("from 客户端——"+new String(buffer.array()));
}
// 处理完毕后要手动删除当前的 SelectionKey,避免多线程重复操作
keyIterator.remove();
}
}
}
}
- 客户端
public class NIOClient {
public static void main(String[] args) throws IOException {
// 得到一个网络通道
SocketChannel socketChannel = SocketChannel.open();
// 设置非阻塞
socketChannel.configureBlocking(false);
// 提供服务端的 ip、 端口
InetSocketAddress inetSocketAddress = new InetSocketAddress("127.0.0.1", 6666);
// 连接服务端
if (!socketChannel.connect(inetSocketAddress)){
// 如果没有完成
while (!socketChannel.finishConnect()){
System.out.println("连接中……因为连接需要时间,客户端不会阻塞,可以做其他工作");
}
}
// 如果连接成功就发送数据
String str = "Hello,NIO";
// wrap 通过参数中的字节数组的大小,直接生成对应大小的 Buffer,并把字节数组存入
ByteBuffer byteBuffer = ByteBuffer.wrap(str.getBytes());
// 发送数据, 把 Buffer 中的数据写入 Channel
socketChannel.write(byteBuffer);
}
}
-
启动测试
先启动 服务端,再启动客户端
3.9 SelectionKey
-
SelectionKey,表示 Selector 和网络通道的注册关系(OPS), 共四种:
int OP_ACCEPT:有新的网络连接可以 accept,值为 16
int OP_CONNECT:代表连接已经建立,值为 8
int OP_READ:代表读操作,值为 1
int OP_WRITE:代表写操作,值为 4源码中:
public static final int OP_READ = 1 << 0;
public static final int OP_WRITE = 1 << 2;
public static final int OP_CONNECT = 1 << 3;
public static final int OP_ACCEPT = 1 << 4; -
SelectionKey相关方法
public abstract class SelectionKey { public abstract Selector selector();//得到与之关联的 Selector 对象 public abstract SelectableChannel channel();//得到与之关 联的通道 public final Object attachment();//得到与之关联的共享数 据 public abstract SelectionKey interestOps(int ops);//设置或改 变监听事件 public final boolean isAcceptable();//是否可以 accept public final boolean isReadable();//是否可以读 public final boolean isWritable();//是否可以写 }
3.10 ServerSocketChannel
-
ServerSocketChannel 在服务器端监听新的客户端 Socket 连接
-
相关方法如下:
public abstract class ServerSocketChannel extends AbstractSelectableChannel implements NetworkChannel{ public static ServerSocketChannel open()//得到一个 ServerSocketChannel 通道 public final ServerSocketChannel bind(SocketAddress local)//设置服务器端端口 号 public final SelectableChannel configureBlocking(boolean block)//设置阻塞或非 阻塞模式,取值 false 表示采用非阻塞模式 public SocketChannel accept()//接受一个连接,返回代表这个连接的通道对象 public final SelectionKey register(Selector sel, int ops)//注册一个选择器并设置 监听事件 }
3.11 SocketChannel
-
SocketChannel,网络 IO 通道,具体负责进行读写操作。NIO 把缓冲区的数据写入通 道,或者把通道里的数据读到缓冲区。
-
相关方法如下
public abstract class SocketChannel extends AbstractSelectableChannel implements ByteChannel, ScatteringByteChannel, GatheringByteChannel, NetworkChannel{ public static SocketChannel open();//得到一个 SocketChannel 通道 public final SelectableChannel configureBlocking(boolean block);//设置阻塞或非阻塞 模式,取值 false 表示采用非阻塞模式 public boolean connect(SocketAddress remote);//连接服务器 public boolean finishConnect();//如果上面的方法连接失败,接下来就要通过该方法 完成连接操作 public int write(ByteBuffer src);//往通道里写数据 public int read(ByteBuffer dst);//从通道里读数据 public final SelectionKey register(Selector sel, int ops, Object att);//注册一个选择器并 设置监听事件,最后一个参数可以设置共享数据 public final void close();//关闭通道 }
3.12 NIO 网络编程应用实例-群聊系统
- 要求:
- 编写一个 NIO 群聊系统,实现服务器端和客户端之间的数据简单通讯(非阻塞)
- 实现多人群聊
- 服务器端:可以监测用户上线,离线, 并实现消息转发功能
- 客户端:通过channel 可以无阻塞发送 消息给其它所有用户,同时可以接受 其它用户发送的消息(有服务器转发得到)
- 目的:进一步理解NIO非阻塞网络编程 机制
-
代码实现
服务端
public class Server { // 定义属性 private Selector selector; private ServerSocketChannel listenerChannell; private static final int PORT = 6666; // 构造器 public Server(){ try { // 得到选择器 selector = Selector.open(); // 得到 ServerSocketChannel listenerChannell = ServerSocketChannel.open(); // 绑定端口 listenerChannell.socket().bind(new InetSocketAddress(PORT)); // 设置非阻塞 listenerChannell.configureBlocking(false); // 把 listenerChannell 注册到 Selector 中,关注连接事件 listenerChannell.register(selector, SelectionKey.OP_ACCEPT); }catch (Exception e){ e.printStackTrace(); }finally { } } // 监听,处理客户端的连接事件 public void listen(){ try { // 循环处理 while (true){ int select = selector.select(); if (select > 0){ // 表示有事件要处理 // 遍历得到 SelectionKey 集合 Iterator<SelectionKey> iterator = selector.selectedKeys().iterator(); while (iterator.hasNext()){ // 取出 SelectionKey SelectionKey key = iterator.next(); //处理连接事件 if (key.isAcceptable()){ // 通过 ServerSocketChannel 获得 socketChannel SocketChannel socketChannel = listenerChannell.accept(); // 设置非阻塞 socketChannel.configureBlocking(false); // 将 socketChannel 注册到 Selector socketChannel.register(selector, SelectionKey.OP_READ); // 给出提示 System.out.println(socketChannel.getRemoteAddress() + "上线了~"); } // 处理读事件 if (key.isReadable()){ // 处理读的方法 read(key); } iterator.remove(); } }else { System.out.println("等待中……"); } } }catch (Exception e){ e.printStackTrace(); }finally { } } // 读取客户端消息 public void read(SelectionKey key){ // 定义一个 SocketChannel SocketChannel socketChannel = null; try { // 得到关联的 Channel socketChannel = (SocketChannel) key.channel(); // 创建 ByteBuffer ByteBuffer buffer = ByteBuffer.allocate(1024); // 将 socketChannel 的数据读到 Buffer int read = socketChannel.read(buffer); // 根据 read 的值,做出对应的处理 if (read > 0){ // 读取到了数据 String s = new String(buffer.array()); System.out.println("【服务端】收到客户端消息:"+ s); // 向其他客户端转发消息,需要排除自己 sendMessageToOther(s,socketChannel); } }catch (IOException e){ // 如果在读取数据时, 发生异常,则表示离线了 try { System.out.println(socketChannel.getRemoteAddress() + "离线了~"); // 取消注册 key.channel(); // 关闭通道 socketChannel.close(); } catch (IOException ioException) { ioException.printStackTrace(); } } } // 转发消息给其他客户端 public void sendMessageToOther(String message, SocketChannel selfChannel) throws IOException { System.out.println("服务器转发消息中……"); // 遍历所有注册到 Selector 上的 socketChannel ,并排除自己 for (SelectionKey key : selector.keys()) { // 通过 key 取出对应的 SocketChannel Channel channel = key.channel(); // 排除自己, channel 必须是一个 SocketChannel 类型的 并且 channel 不等于自己 if (channel instanceof SocketChannel && channel != selfChannel){ // 转换 Channel 类型 SocketChannel dest = (SocketChannel) channel; // 将 message 存储到 Buffer ByteBuffer buffer = ByteBuffer.wrap(message.getBytes()); // 将 Buffer 的数据写入通道 dest.write(buffer); } } } public static void main(String[] args) { // 创建一个服务器对象 Server server = new Server(); server.listen(); } }
客户端
public class Client { // 定义相关属性 // 服务器的IP private final String HOST = "127.0.0.1"; // 服务器的端口 private final int PORT = 6666; private Selector selector; private SocketChannel socketChannel; private String username; // 构造器 public Client() throws IOException { // 完成初始化 selector = Selector.open(); // 连接服务器 socketChannel = SocketChannel.open(new InetSocketAddress(HOST, PORT)); // 设置 非阻塞 socketChannel.configureBlocking(false); // 将 socketChannel 注册到 Selector socketChannel.register(selector, SelectionKey.OP_READ); // 得到 username username = socketChannel.getLocalAddress().toString().substring(1); System.out.println(username + "is OK!"); } // 向服务器发送消息 public void sendMessage(String message){ message = username + "说:"+ message; try { // 把 message 写入 buffer socketChannel.write(ByteBuffer.wrap(message.getBytes())); // 读取从服务器端回复的消息 }catch (Exception e){ e.printStackTrace(); }finally { } } public void readmessage(){ try { int select = selector.select(); if (select > 0){ // 有事件发生的通道 Iterator<SelectionKey> iterator = selector.selectedKeys().iterator(); while (iterator.hasNext()){ SelectionKey key = iterator.next(); if (key.isReadable()){ // 得到相关的通道 SocketChannel channel = (SocketChannel) key.channel(); ByteBuffer buffer = ByteBuffer.allocate(1024); channel.read(buffer); String msg = new String(buffer.array()); System.out.println(msg.trim()); } } }else { System.out.println("没有可用的通道"); } }catch (Exception e){ e.printStackTrace(); }finally { } } public static void main(String[] args) throws IOException { // 启动客户端 Client client = new Client(); // 启动一个线程,每个三秒读取从服务器端读取数据 new Thread(()->{ while (true){ client.readmessage(); try { Thread.sleep(3000); }catch (Exception e){ e.printStackTrace(); } } }).start(); // 发送数据给服务端 Scanner scanner = new Scanner(System.in); while (scanner.hasNextLine()){ String line = scanner.nextLine(); client.sendMessage(line); } } }
-
启动服务端,和三个客户端,进行测试
- 客户端-1 发送消息
- 客户端-1 发送消息
-
客户端退出
3.13 NIO与零拷贝
1. 基本介绍
- 零拷贝是网络编程的关键,很多性能优化都离不开。
- 零拷贝不是指不拷贝,而是没有 CPU 拷贝。
- 在 Java 程序中,常用的零拷贝有 mmap(内存映射) 和 sendFile。那么,他们在 OS 里,到底是怎么样的一个的设计?我们分析 mmap 和 sendFile 这两个零拷贝
2. 传统IO数据读写
-
Java 传统 IO 和 网络编程的一段代码
File file = new File("test.txt"); RandomAccessFile raf = new RandomAccessFile(file, "rw"); byte[] arr = new byte[(int) file.length()]; raf.read(arr); Socket socket = new ServerSocket(8080).accept(); socket.getOutputStream().write(arr);
-
传统 IO 读写的流程图
- 由上图可得:传统的 IO 进行了 4次拷贝, 3次状态切换
3. mmap 优化
mmap 通过内存映射,将文件映射到内核缓冲区,同时,用户空间可以共享内核空间的数据。这样,在进行网络传输时,就可以减少内核空间到用户控件的拷贝次数。如下图
- MMAP 进行了 3次拷贝,3次转换
- 比传统的优化了一些,少了 1次拷贝,但是还没有做到 零拷贝
4. sendFile 优化
- Linux 2.1 版本 提供了 sendFile 函数,其基本 原理如下:数据根本不 经过用户态,直接从内 核缓冲区进入到 Socket Buffer,同时,由于和用 户态完全无关,就减少 了一次上下文切换
- Linux 在 2.4 版本中,做了一些修改,避免了从内核缓冲区拷贝到 Socket buffer 的操作,直接拷贝到 协议栈,从而再一次减少 了数据拷贝
- 到这里,已经优化到了 2次拷贝(忽略 内核->Socket 的拷贝),2次状态切换
5. 再次理解
- 我们说零拷贝,是从操作系统的角度来说的。因为内核缓冲区之间,没有数据是 重复的(只有 kernel buffer 有一份数据)。
- 零拷贝不仅仅带来更少的数据复制,还能带来其他的性能优势,例如更少的上下 文切换,更少的 CPU 缓存伪共享以及无 CPU 校验和计算。
6. mmap 和 sendFile 的区别
- mmap 适合小数据量读写,sendFile 适合大文件传输。
- mmap 需要 3 次上下文切换,3 次数据拷贝;sendFile 需要 2 次上下文切换,最 少 2 次数据拷贝。
- sendFile 可以利用 DMA 方式,减少 CPU 拷贝,mmap 则不能(必须从内核拷贝 到 Socket 缓冲区)。
7. NIO 零拷贝案例
- 要求
- 使用传统的IO 方法传递一个大文件
- 使用NIO 零拷贝方式传递(transferTo)一个大文件
- 看看两种传递方式耗时时间分别是多少
-
传统方式
服务端
public class OldIOServer { public static void main(String[] args) throws Exception { // 监听 7001,端口 ServerSocket serverSocket = new ServerSocket(7001); while (true) { // 监视连接 Socket socket = serverSocket.accept(); // 通过 serversocket 获得输入流 DataInputStream dataInputStream = new DataInputStream(socket.getInputStream()); try { byte[] byteArray = new byte[4096]; // 循环的读取数据 while (true) { int readCount = dataInputStream.read(byteArray, 0, byteArray.length); if (-1 == readCount) { break; } } } catch (Exception ex) { ex.printStackTrace(); } } } }
客户端
public class OldIOClient { public static void main(String[] args) throws Exception { Socket socket = new Socket("localhost", 7001); // 需要拷贝的大文件 String fileName = "protoc-3.6.1-win32.zip"; InputStream inputStream = new FileInputStream(fileName); DataOutputStream dataOutputStream = new DataOutputStream(socket.getOutputStream()); byte[] buffer = new byte[4096]; long readCount; long total = 0; long startTime = System.currentTimeMillis(); while ((readCount = inputStream.read(buffer)) >= 0) { total += readCount; dataOutputStream.write(buffer); } System.out.println("发送总字节数: " + total + ", 耗时: " + (System.currentTimeMillis() - startTime)); dataOutputStream.close(); socket.close(); inputStream.close(); } }
启动测试
-
零拷贝(transferTo)
服务端
public class Server { public static void main(String[] args) throws IOException { ServerSocketChannel serverSocketChannel = ServerSocketChannel.open(); serverSocketChannel.socket().bind(new InetSocketAddress(7001)); // 创建 Buffer ByteBuffer buffer = ByteBuffer.allocate(4096); while (true){ SocketChannel socketChannel = serverSocketChannel.accept(); int readBytes = 0; while (readBytes != -1){ try { readBytes = socketChannel.read(buffer); }catch (Exception e){ e.printStackTrace(); } // 倒带 : Position = 0,Mark 作废 buffer.rewind(); } } } }
客户端
public class Client { public static void main(String[] args) throws IOException { SocketChannel socketChannel = SocketChannel.open(); socketChannel.connect(new InetSocketAddress("localhost",7001)); String fileName = "protoc-3.6.1-win32.zip"; FileChannel channel = new FileInputStream(fileName).getChannel(); long startTime = System.currentTimeMillis(); // 开始传输 // transferTo 方法底层使用 零拷贝 //在 Linux 下,一个 transferTo 方法,就可以完成传输 //在 Windows 下,transferTo 一次调用只能发送 8M 文件,所以就需要分段传输,而且要注意传输时的位置 long transfer = channel.transferTo(0, channel.size(), socketChannel); System.out.println("发送总的字节数 :"+ transfer + " ,总耗时:"+(System.currentTimeMillis() - startTime)); } }
启动测试
- 因为文件不算很大,所以差别也没很大,文件越大优化效果越明显。
4. Java AIO 基本介绍
- JDK 7 引入了 Asynchronous I/O,即 AIO。在进行 I/O 编程中,常用到两种模式: Reactor和 Proactor。Java 的 NIO 就是 Reactor,当有事件触发时,服务器端得 到通知,进行相应的处理
- AIO 即 NIO2.0,叫做异步不阻塞的 IO。AIO 引入异步通道的概念,采用了 Proactor 模式,简化了程序编写,有效的请求才启动线程,它的特点是先由操作系统完成后才通知服务端程序启动线程去处理,一般适用于连接数较多且连接时间较长的应用
- 目前 AIO 还没有广泛应用,Netty 也是基于NIO, 而不是AIO, 因此我们就不详解 AIO了,参考资料 《Java新一代网络编程模型AIO原理及Linux系统 AIO介绍》 http://www.52im.net/thread-306-1-1.html
5. BIO、NIO、AIO对比
- 举例说明
- 同步阻塞(BIO):到理发店理发,就一直等理发师,直到轮到自己理发,且在等的过程是什么也不干。
- 同步非阻塞(NIO):到理发店理发,发现前面有其它人理发,给理发师说 下,先干其他事情,一会过来看是否轮到自己.
- 异步非阻塞(AIO):给理发师打电话,让理发师上门服务,自己干其它事情,理发师自己来家给你理发