说明:
学习视频地址:https://www.bilibili.com/video/BV1DJ411m7NR?p=1
笔记文档部分内容参考:https://blog.csdn.net/youth_lql/category_10959696.html
IO模型基本介绍
- IO模型简单理解:就是用什么样的通道进行数据的发送和接收,很大程度上决定了程序通信的性能
- Java目前支持三种网络编程模型:BIO,NIO,AIO
- BIO:传统的同步阻塞,服务器实现模式为一个连接对应一个线程,即客户端有连接请求时服务端就需要启动一个线程进行处理,那么在高并发情况下,就会启动大量的线程,如果连接后,没有进行数据通信,线程就会闲置,造成大量的资源开销。而且线程中进行数据读写的时候会阻塞,直到数据准备好,才进行处理
- NIO:同步非阻塞,服务器实现模式为一个线程处理多个连接(请求),即客户端发送的连接请求都会注册到多路复用器上,多路复用器轮询到连接通道有IO请求就进行处理【一个线程管理多个连接,或管理多个通道的IO请求,可以减少BIO中大量的闲置线程。因为实际情况下,一个连接建立之后,并不是一直处于活动状态,通过观察者模式实现轮询监听,可以减少闲置线程,并有效利用cpu资源】
- AIO(NIO.2):异步非阻塞,jdk1.7引入的,目前还未得到广泛应用。AIO引入了异步通道的概念,采用了Proactor模式,简化了程序编写,有效的请求才启动线程。它的特点是先由操作系统完成后才通知服务端程序启动线程去处理,一般适用于连接数较多且连接时间较长的应用
BIO,NIO,AIO适用场景分析
- BIO适用于连接数较少且固定的架构,这种方式对服务器资源要求比较高,并发局限于应用中,jdk1.4以前的唯一选择,但程序简单易于理解
- NIO适用于连接数较多且连接时间较短(轻操作)的架构,比如聊天服务器,弹幕系统,服务器间通讯等。编程比较复杂,JDK1.4开始支持
- AIO适用于连接数较多且连接时间较长(重操作)的架构,比如相册服务器,充分调用OS参与并发操作,编程比较复杂,JDK1.7开始支持
BIO模型
基本介绍
- Java BIO 就是传统的java io编程,其相关的类和接口在 java.io 中
- BIO(blocking io):同步阻塞,它可以通过线程池机制进行改善(只是可以实现多个客户端连接服务器)
BIO基本模型架构
BIO编程的简单流程
- 服务器端启动一个 ServerSocket
- 客户端启动一个Socket,对服务端进行通信。默认情况下服务端需要对每个客户连接建立一个线程与之通信
- 客户端发出请求后,先咨询服务器是否有线程响应,如果没有,则会等待,或者被拒绝
- 如果有响应,客户端线程会等待请求结束后,再继续执行
Java BIO 应用实例
实例说明:
- 使用BIO模型编写一个服务端程序,监听6666端口,当有客户端连接时,就启动一个线程与之通信
- 要求使用线程池机制改善,可以连接多个客户端
- 服务端可以接收客户端发送的数据(telnet方式即可)
/**
* BIO服务端
* 1. 一个客户端连接,对应一个线程
* 2. 服务端启动后,如果没有客户端连接,程序会阻塞在 serverSocket.accept()
* 3. 客户端连接后,如果没有向服务端发送数据,程序会阻塞在 inputStream.read(bytes)
**/
public class BIOServer {
public static void main(String[] args) throws IOException {
// 线程池机制
// 1. 创建一个线程池
ExecutorService cachedThreadPool = Executors.newCachedThreadPool();
ServerSocket serverSocket = new ServerSocket(6666);
System.out.println("服务端启动了");
while(true){
// 监听,等待客户端连接【会阻塞】
System.out.println("等待连接...");
final Socket socket = serverSocket.accept();
System.out.println("连接到一个客户端");
// 2. 如果有客户端连接了,就创建一个线程与之通信
cachedThreadPool.execute(()->{
// 与客户端通讯的方法
handler(socket);
});
}
}
public static void handler(Socket socket){
try{
System.out.println("线程信息 id = "+ Thread.currentThread().getId() + " 名称 = "+Thread.currentThread().getName());
byte[] bytes = new byte[1024];
InputStream inputStream = socket.getInputStream(); // 通过socket获取输入流
while(true){ // 循环读取客户端发送的数据
System.out.println("read...");
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窗口:
执行命令telnet localhost 6666
模拟多个客户端与服务端建立网络连接,并发送消息。通过观察服务端消息处理方法的打印,可以确认,BIO处理客户端请求的模式是:一个线程处理一个客户端的连接和IO请求
打印信息:
服务端启动了
连接到一个客户端
线程信息 id = 12 名称 = pool-1-thread-1
1
2
连接到一个客户端
线程信息 id = 13 名称 = pool-1-thread-2
3
3
3
Java BIO存在的问题
- 每个请求都需要创建独立的线程,与对应的客户端进行数据Read,业务处理,数据Write
- 当并发数较大时,需要创建大量的线程来处理连接,系统资源占用较大
- 连接建立后,如果当前线程暂时没有数据可读,则线程就会一直阻塞在Read操作上,造成线程资源浪费
NIO模型
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可以做到用一个线程来处理多个操作。假设有1w个请求过来,根据实际情况,可以分配50或100个线程来处理。不像之前BIO那样,必须配置1w个线程进行处理。
- HTTP2.0使用了多路复用技术,做到了同一个连接并发处理多个请求,而且并发的数量比HTTP1.1大了好几个数量级
BIO和NIO的区别
- BIO以流的方式处理数据,NIO以块的方式处理数据,块I/O的效率比流I/O高很多
- BIO是阻塞的,NIO是非阻塞的
- BIO基字节流和字符流进行操作。而NIO基于Channel和Buffer进行操作,数据总是从缓冲区写入到通道中,从通道读出到缓冲区中的。Selector用于监听多个通道的事件(比如:连接请求,数据到达等),因此,可以使用单个线程就可以监听多个客户端通道
NIO三大核心原理示意图
Selector,Channel和Buffer的关系
- 每个Channel都会对应一个Buffer
- 一个Selector对应一个线程,一个线程可以对应多个Channel(连接)
- 该图反映了有三个Channel注册到Selector上了
- 程序切换到哪个Channel,是由事件决定的,Event是一个重要的概念
- Selector会根据不同的事件,在各个通道上切换
- Buffer就是一个内存块,底层是一个数组,它是双向的(简单理解为盛放数据的容器)
- 数据的读写是通过Buffer,这个和BIO不同,BIO中要么是输入流,要么是输出流,不能双向,但是NIO的buffer可以读也可以写,需要用flip方法进行读写操作的切换
- Channel也是双向的,可以返回底层操作系统的情况,比如Linux,底层操作系统的通道就是双向的(简单理解为数据流动、传输的管道,且是双向的)
Buffer缓冲区
Buffer类定义了所有缓冲区都具有的四个属性,来提供关于其所包含的数据元素的信息:
private int mark = -1; // 标记
private int position = 0; // 位置,下一个要被读/写的元素的索引,每次读写缓冲区数据时都会改变该值,为下次读写作准备
private int limit; // 表示缓冲区的当前终点,不能对缓冲区超过极限的位置进行读写操作,且极限是可以修改的
private int capacity; // 容量,即可容纳的最大数据量,在缓冲区创建时被设定,并且不能改变
除了boolean类型外,Java中其他基本数据类型都有一个Buffer类型与之对应,最常用的是ByteBuffer(二进制数据),该类的主要方法有:
Channel通道
NIO的通道类似于流,但是有些区别:
- 通道可以同时进行读写,而流只能读或者只能写
- 通道可以实现异步读写数据
- 通道可以从缓冲区中读数据,也可以写数据到缓冲区中
- channel通道中不存数据,数据都是存在buffer中的,通道只是一个数据流动的工具(channel类似厨房洗菜池的下水管,洗菜池子就相当于一个buffer)
基本介绍
channel是NIO中的一个接口
public interface Channel extends Closeable{}
常用的Channel类有:FileChannel,DatagramChannel,ServerSocketChannel,SocketChannel(ServerSocketChannel类似ServerSocket,SocketChannel类似Socket)
- FileChannel 用于文件的数据读写
- DatagramChannel 用于UDP的数据读写
- ServerSocketChannel,SocketChannel 用于TCP的数据读写
实例1:将一个文件中的数据读出
public static void main(String[] args) throws IOException {
// 创建一个文件输入流
File file = new File("E:\\learn\\java-learn\\netty-learn\\netty\\src\\main\\java\\com\\atguigu\\nio\\file01.txt");
FileInputStream fileInputStream = new FileInputStream(file);
// 通过fileInputStream获取对应的fileChannel
FileChannel channel = fileInputStream.getChannel();
// 创建缓冲区
ByteBuffer byteBuffer = ByteBuffer.allocate((int) file.length());
// 将通道的数据读入到缓冲区中
channel.read(byteBuffer);
// 将bytebuffer中的字节数据转成String
System.out.println(new String(byteBuffer.array()));
// 关闭流
fileInputStream.close();
}
实例2:将一个字符串数据写入到一个文件中
public static void main(String[] args) throws IOException {
String str = "hello,尚硅谷";
// 创建一个输出流 --> channel
FileOutputStream fileOutputStream = new FileOutputStream("E:\\learn\\java-learn\\netty-learn\\netty\\src\\main\\java\\com\\atguigu\\nio\\file01.txt");
// 通过fileOutputStream获取对应的FileChannel
FileChannel fileChannel = fileOutputStream.getChannel();
// 创建一个缓存区ByteBuffer
ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
// 将str放入到bytebuffer中(读操作)
byteBuffer.put(str.getBytes());
// 将bytebuffer中的数据写入到channel(写操作)
byteBuffer.flip(); // 读写切换
fileChannel.write(byteBuffer);
// 关闭流
fileOutputStream.close();
}
实例3:使用一个Buffer完成文件读取
public static void main(String[] args) throws IOException {
FileInputStream fileInputStream = new FileInputStream("src/main/1.txt");
FileChannel fileChannel01 = fileInputStream.getChannel();
FileOutputStream fileOutputStream = new FileOutputStream("src/main/1.txt");
FileChannel fileChannel02 = fileOutputStream.getChannel();
ByteBuffer byteBuffer = ByteBuffer.allocate(512);
while (true) {
// 这里有一个重要的操作,将buffer的标识位重置(position=0)
byteBuffer.clear(); // 清空buffer。如果不复位的话,因为第一次循环读完数据后 position=limit,第二次读的时候 read=0,之后会一直循环read=0
int read = fileChannel01.read(byteBuffer);
if(read != -1){
byteBuffer.flip();
fileChannel02.write(byteBuffer);
}else{
break;
}
}
fileInputStream.close();
fileOutputStream.close();
}
实例3的流程图
关于Buffer和Channel的注意细节和事项
- ByteBuffer支持类型化的put和get,put放入的是什么数据类型,get就应该使用相应的数据类型来取出,否则可能有BufferUnderflowException异常
public static void main(String[] args) {
// 创建一个Buffer
ByteBuffer buffer = ByteBuffer.allocate(64);
// 类型化方式放入数据
buffer.putInt(100);
buffer.putLong(9);
buffer.putChar('尚');
buffer.putShort((short) 4);
buffer.flip(); // 读写切换
// 取出
System.out.println(buffer.getInt());
System.out.println(buffer.getLong());
System.out.println(buffer.getChar());
System.out.println(buffer.getShort());
}
- 可以将一个普通Buffer转成一个只读的Buffer
public static void main(String[] args) {
ByteBuffer buffer = ByteBuffer.allocate(64);
for (int i = 0; i < 64; i++) {
buffer.put((byte) i);
}
buffer.flip();
// 得到一个只读的buffer
ByteBuffer readOnlyBuffer = buffer.asReadOnlyBuffer();
System.out.println(readOnlyBuffer.getClass());
// 读取
while (readOnlyBuffer.hasRemaining()){
System.out.println(readOnlyBuffer.get());
}
readOnlyBuffer.put((byte) 2);
}
- NIO还提供了MappedByteBuffer,可以让文件直接在内存(堆外内存)中进行修改,而如何同步到文件由NIO来完成
- 前面讲的读写操作都是通过一个Buffer来完成的,NIO还支持多个Buffer(即Buffer数组)完成读写操作,即Scattering和Gathering
/**
* @author lixing
* @date 2022-04-26 11:26
* @description
* Scattering:将数据写入到buffer时,可以采用buffer数组,依次写入(分散)
* Gathering:从buffer读取数据时,可以采用buffer数组,依次读
*/
public class ScatteringAndGatheringTest {
public static void main(String[] args) throws IOException {
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
InetSocketAddress inetSocketAddress = new InetSocketAddress(7000);
// 绑定端口到socket,并启动
serverSocketChannel.socket().bind(inetSocketAddress);
// 创建buffer数组
ByteBuffer[] byteBuffers = new ByteBuffer[2];
byteBuffers[0] = ByteBuffer.allocate(5);
byteBuffers[1] = ByteBuffer.allocate(3);
// 等待客户端连接
SocketChannel socketChannel = serverSocketChannel.accept();
int messageLength = 8; // 假定从客户端接收8个字节
// 循环读取
while (true){
int byteRead = 0;
while(byteRead < messageLength){
long read = socketChannel.read(byteBuffers);
byteRead += read; // 累计读取的字节数
System.out.println("byteRead="+byteRead);
// 使用流打印,看看当前的这个buffer的position和limit
Arrays.stream(byteBuffers).map(buffer->"position="+buffer.position()+" , limit="+buffer.limit()).forEach(System.out::println);
}
// 将所有的buffer进行flip
Arrays.asList(byteBuffers).forEach(Buffer::flip);
// 将数据读出显示到客户端
long byteWrite = 0;
while(byteWrite < messageLength){
long l = socketChannel.write(byteBuffers);
byteWrite += l;
}
// 将所有的buffer进行clear
Arrays.asList(byteBuffers).forEach(Buffer::clear);
System.out.println("byteRead="+byteRead+" byteWrite="+byteWrite+" messagelength="+messageLength);
}
}
}
- 将数据从channel中读出到buffer中:channel.read(buffer)
- 将数据从buffer写入到channel中:channel.write(buffer)
Selector选择器
基本介绍
- Java的NIO,用非阻塞的IO方式。可以用一个线程,处理多个客户端连接,就使用到了Selector选择器
- Selector能够检测多个注册的通道上是否有事件发生(多个Channel以事件的方式可以注册到同一个Selector上)。如果有事件发生,便获取事件,然后针对每个事件进行相应的处理。这样就可以只用一个线程去管理多个通道,也就是管理多个连接和请求。
- 只有在连接通道真正有读写事件发生时,才会进行读写,大大减少了系统的开销,并且不用每个连接都创建一个线程,不用去维护多个线程
- 避免了多线程之间的上下文切换导致的开销
特点再说明
- Netty的IO线程NioEventLoop聚合了Selector,可以同时并发处理成百上千的客户端连接
- 当线程从某客户端Socket通道进行读写数据时,若没有数据可用时,线程可以进行其他任务的处理(不用阻塞等待)
- 线程通常将非阻塞IO的空闲时间用于用于在其他通道上执行IO操作,所以单独的线程可以管理多个输入和输出通道
- 由于读写操作都是非阻塞的,这就可以充分提升IO线程的运行效率,避免由于频繁IO阻塞导致的线程挂起
- 一个IO线程可以并发处理N个客户端连接和读写操作,这从根本上解决了传统BIO一个连接对应一个线程的模型。架构的性能、弹性伸缩能力和可靠性都得到了极大的提升
Selector API 介绍
Selector是一个抽象类,常用的方法和说明如下:
public abstract class Selector implements Closeable {
public static Selector open(); // 得到一个selector对象
public int select(); // 监听所有注册的channel,当其中有IO操作可以进行时,将对应的SelectionKey加入到内部集合中并返回 【不带参数的select方法是一个阻塞方法,它会阻塞直到获取到一个有IO操作的channel】
public int select(long timeout); // 可设置超时,即实现非阻塞
public int selectNow(); // 如果当前没有任何channel有IO操作发生,则快速返回0,即实现非阻塞
public Set<SelectionKey> selectedKeys(); // 从内部集合中得到所有的SelectionKey ,每个key即对应一个channel【即返回注册到selector上的所有channel对应的key】
}
注意事项:
- NIO中的ServerSocketChannel功能类似于ServerSocket,SocketChannel功能类似于Socket
- selector相关方法说明
- selector.select(); // 阻塞
- selector.select(1000); // 阻塞1000ms,在1000ms后返回
- selector.wakeup(); // 唤醒selector
- selector.selectNow(); // 不阻塞,立马返回
NIO非阻塞网络编程原理分析图
NIO非阻塞网络编程相关的(Selector,SelectionKey,ServerSocketChannel,SocketChannel)关系梳理图
图例说明:
- 当客户端连接时,会通过ServerSocketChannel得到SocketChannel
- 通过 register(Selector sel, int ops) 方法将SocketChannel注册到Selector上。一个selector上可以注册多个SocketChannel
- 注册后会返回一个SelectionKey,会与Selector通过集合的方式建立关联
- Selector通过select()方法进行监听,返回有事件发生的channel个数
- 进一步获取到各个有事件发生的channel对应的SelectionKey
- 再通过SelectionKey反向获取SocketChannel,通过channel()方法实现
- 可以通过得到的channel,完成业务处理
/**
* @author lixing
* @date 2022-04-26 18:01
* @description NIO服务端
*/
public class NIOServer {
public static void main(String[] args) throws IOException {
// 服务端channel
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
// 服务端channel设置成非阻塞
serverSocketChannel.configureBlocking(false);
// 服务端channel绑定网络监听端口
serverSocketChannel.socket().bind(new InetSocketAddress(6666));
// 通过Selector的open方法获取到一个Selector实例
Selector selector = Selector.open();
// 将服务端channel注册到selector上,关注客户端连接事件
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
// 循环监听客户端连接
while(true){
// 通过selector的select(long timeout)方法,设置超时1s去检测是否有客户端通道事件发生
if(selector.select(1000) == 0){ // 如果等待1s后还是没有任何事件发生,则打印(非阻塞,程序可以做其他事情)
System.out.println("服务器等待1秒,暂无客户端连接...");
continue;
}
// 如果select方法返回值大于0,表示有事件发生。先获取到所有有事件的selectionKeys集合
Set<SelectionKey> selectionKeys = selector.selectedKeys();
Iterator<SelectionKey> selectionKeyIterator = selectionKeys.iterator();
// 迭代遍历key,通过key的操作类型进行不同的处理(accept,read)
while(selectionKeyIterator.hasNext()){
SelectionKey key = selectionKeyIterator.next();
if(key.isAcceptable()){ // 如果channel上发生的是客户端连接成功的事件
// 获取到对应的客户端channel
SocketChannel socketChannel = serverSocketChannel.accept();
// 设置客户端channel为非阻塞
socketChannel.configureBlocking(false);
System.out.println("客户端连接成功,客户端channel:"+socketChannel.hashCode());
// 将客户端channel注册到selector上,关注客户端channel读的事件
socketChannel.register(selector, SelectionKey.OP_READ, ByteBuffer.allocate(1024));
}
if(key.isReadable()){ // 如果channel上发生的是数据读的事件(也就是客户端向服务端发送数据了)
// 获取到发生事件的客户端channel
SocketChannel socketChannel = (SocketChannel) key.channel();
ByteBuffer buffer = (ByteBuffer) key.attachment();
// 将客户端channel中的数据读出到buffer
socketChannel.read(buffer);
System.out.println("收到客户端"+socketChannel.hashCode()+"发送的数据:"+new String(buffer.array()));
}
// 删除当前key,防止重复操作
selectionKeyIterator.remove();
}
}
}
}
/**
* @author lixing
* @date 2022-04-26 18:25
* @description NIO客户端
*/
public class NIOClient {
public static void main(String[] args) throws IOException {
// 客户端channel
SocketChannel socketChannel = SocketChannel.open();
// 设置客户端channel为非阻塞
socketChannel.configureBlocking(false);
// 获取服务端的连接ip和port
InetSocketAddress inetSocketAddress = new InetSocketAddress("127.0.0.1", 6666);
// 与服务端建立连接
if(!socketChannel.connect(inetSocketAddress)){ // 连接需要时间,客户端不会阻塞
if(!socketChannel.finishConnect()){ // 如果连接失败,可以做其他的操作
System.out.println("因为连接需要时间,客户端不会阻塞,可以做其他工作...");
}
}
// 如果连接成功,则通过客户端channel向服务端发送数据
String str = "hello,尚硅谷~";
socketChannel.write(ByteBuffer.wrap(str.getBytes()));
System.in.read();
}
}
输出信息:
服务器等待1秒,暂无客户端连接...
服务器等待1秒,暂无客户端连接...
服务器等待1秒,暂无客户端连接...
服务器等待1秒,暂无客户端连接...
服务器等待1秒,暂无客户端连接...
服务器等待1秒,暂无客户端连接...
客户端连接成功,客户端channel:1338668845
收到客户端1338668845发送的数据:hello,尚硅谷~
服务器等待1秒,暂无客户端连接...
服务器等待1秒,暂无客户端连接...
服务器等待1秒,暂无客户端连接...
服务器等待1秒,暂无客户端连接...
服务器等待1秒,暂无客户端连接...
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(); // 是否可以写
}
- SelectionKey:表示Selector与网络通道的注册关系,一共有四种:
- int OP_ACCEPT:有新的网络可以accept,值为16
- int OP_CONNECT:代表连接已经建立,值为8
- int OP_READ:代表读操作,值为1
- int OP_WRITE:代表写操作,值为4
public static final int OP_ACCEPT = 1 << 4;
public static final int OP_CONNECT = 1 << 3;
public static final int OP_READ = 1 << 0;
public static final int OP_WRITE = 1 << 2;
ServerSocketChannel
- ServerSocketChannel在服务端监听新的客户端Socket连接
- 相关方法
public abstract class ServerSocketChannel extends AbstractSelectableChannel implements NetworkChannel {
public static ServerSocketChannel open(); // 得到一个ServerSocketChannel通道
public final ServerSocketChannel bind(SocketAddress local); // 配置ServerSocketChannel通道的网络监听端口
public final SelectableChannel configureBlocking(boolean block); // 设置通道的阻塞模式(true 阻塞 ,false 非阻塞)
public abstract SocketChannel accept(); // 接收一个连接,返回代表这个连接的socket通道对象
public final SelectionKey register(Selector sel, int ops); // 注册通道到指定的Selector选择器上,并设置监听事件。返回一个SelectionKey
}
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); // 设置SocketChannel通道的阻塞模式(true 阻塞 ,false 非阻塞)
public abstract boolean connect(SocketAddress remote); // 连接服务器
public abstract boolean finishConnect(); // 如果上面的方法连接失败,接下来就要通过该方法完成连接操作
public abstract int write(ByteBuffer src); // 往通道中写入数据
public abstract int read(ByteBuffer dst); // 从通道中读出数据
public final void close(); // 关闭通道
}
NIO网络编程应用实例 —— 群聊系统
实例要求:
- 编写一个NIO群聊系统,实现服务器端和客户端之间的数据简单通讯(非阻塞)
- 实现多人群聊(客户端之间的消息通讯,都是通过服务器端转发实现的)
- 服务端:可以监测用户上线,离线,并实现消息转发功能
- 客户端:通过channel可以无阻塞发送消息给其他用户,同时可以接收其他用户发送的消息
- 目的:进一步理解NIO非阻塞网络编程机制
代码实现步骤:
- 编写服务端
- 服务端启动并监听6667端口
- 服务端接收客户端消息,并实现转发(还需处理客户端上下线)
- 编写客户端
- 连接服务器
- 发送消息
- 接收消息
/**
* @author lixing
* @date 2022-04-27 11:27
* @description 群聊系统服务端
*/
public class NIOChatServer {
// 定义属性
private Selector selector;
private ServerSocketChannel listenChannel;
private static final int PORT = 6667;
// 构造器,初始化工作
public NIOChatServer(){
try {
// 获取选择器
selector = Selector.open();
// 获取服务端channel
listenChannel = ServerSocketChannel.open();
// 设置服务端网络通道监听端口
listenChannel.socket().bind(new InetSocketAddress(PORT));
// 设置服务端channel为非阻塞
listenChannel.configureBlocking(false);
// 将服务端channel注册到selector上,监听客户端连接事件
listenChannel.register(selector, SelectionKey.OP_ACCEPT);
}catch (IOException e){
e.printStackTrace();
}
}
// 监听方法
public void listenHandler(){
try {
// 循环监听客户端事件
while (true){
int count = selector.select(); // 阻塞监听客户端通道是否有事件发生
if(count > 0){ // 说明有事件发生
Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
while(iterator.hasNext()){
SelectionKey key = iterator.next();
if(key.isAcceptable()){ // 监听到连接事件,将连接事件的通道注册到selector上
SocketChannel socketChannel = listenChannel.accept();
socketChannel.configureBlocking(false);
socketChannel.register(selector, SelectionKey.OP_READ);
System.out.println("客户端 "+socketChannel.getRemoteAddress() + " 上线了...");
}
if(key.isReadable()){ // 监听到读事件
readClientData(key);
}
iterator.remove(); // 移除当前SelectionKey,防止重复操作
}
}
}
}catch (IOException e){
e.printStackTrace();
}
}
// 读取客户端消息
public void readClientData(SelectionKey key) throws IOException {
SocketChannel channel = null;
try{
// 获取到发生读事件的channel
channel = (SocketChannel) key.channel();
ByteBuffer buffer = ByteBuffer.allocate(1024);
// 将通道中数据读出到buffer
int read = channel.read(buffer);
if(read > 0){
String msg = new String(buffer.array());
System.out.println("接收到客户端 "+channel.getRemoteAddress()+" 消息:"+msg);
// 将消息转发给其他客户端
sendMsgToOtherClients(msg, channel);
}
}catch (IOException e){
System.out.println("客户端 "+channel.getRemoteAddress()+" 离线...");
// 取消注册,关闭通道
key.cancel();
channel.close();
}
}
// 转发消息给其他客户端
public void sendMsgToOtherClients(String msg, SocketChannel self) throws IOException {
// 转发消息的时候要排除自己
System.out.println("服务器转发消息...");
// 遍历所有注册到selector上的socketChannel,并排除自己
for(SelectionKey key: selector.keys()){
Channel channel = key.channel();
// 因为注册到selector上的channel还有服务端的ServerSocketChannel
if(channel instanceof SocketChannel && channel != self){
((SocketChannel) channel).write(ByteBuffer.wrap(msg.getBytes()));
}
}
}
public static void main(String[] args) {
// 启动服务端
NIOChatServer nioChatServer = new NIOChatServer();
nioChatServer.listenHandler();
}
}
/**
* @author lixing
* @date 2022-04-27 11:28
* @description 群聊系统客户端
*/
public class NIOChatClient {
// 定义属性
private final String HOST = "127.0.0.1";
private final int PORT = 6667;
private Selector selector;
private SocketChannel socketChannel;
private String username;
// 构造器,进行初始化操作
public NIOChatClient() throws IOException {
// 获取selector对象
selector = Selector.open();
// 与服务端建立连接
socketChannel = SocketChannel.open(new InetSocketAddress(HOST, PORT));
// 设置客户端通道为非阻塞
socketChannel.configureBlocking(false);
// 通道注册到selector上,关注读事件
socketChannel.register(selector, SelectionKey.OP_READ);
// 获取当前客户端名称
username = socketChannel.getLocalAddress().toString().substring(1);
System.out.println(username+" is ok... ");
}
// 向服务器发送消息
public void sendMsgToServer(String msg){
msg = username + "说:" + msg;
try {
socketChannel.write(ByteBuffer.wrap(msg.getBytes()));
} catch (IOException e) {
e.printStackTrace();
}
}
// 读取从服务端发送过来的消息
public void readMsgFromServer(){
try {
int count = selector.select();
if(count > 0){ // selector上有发生事件的通道
Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
while(iterator.hasNext()){
SelectionKey key = iterator.next();
if(key.isReadable()){ // 有读操作
SocketChannel socketChannel = (SocketChannel) key.channel();
ByteBuffer buffer = ByteBuffer.allocate(1024);
// 将通道中的数据读出到buffer
socketChannel.read(buffer);
String msg = new String(buffer.array());
System.out.println(msg.trim());
}
iterator.remove();
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
public static void main(String[] args) throws IOException {
// 启动服务端
NIOChatClient nioChatClient = new NIOChatClient();
new Thread(()->{
// 间隔2秒读取服务端发送过来的消息
while (true){
nioChatClient.readMsgFromServer();
try {
Thread.currentThread().sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();
// 发送消息给服务端
Scanner scanner = new Scanner(System.in);
while(scanner.hasNextLine()){
String str = scanner.nextLine();
nioChatClient.sendMsgToServer(str);
}
}
}
NIO与零拷贝
零拷贝是网络编程的关键,很多性能优化都离不开零拷贝。所谓零拷贝,从操作系统角度看,是指没有cpu拷贝,只有DMA拷贝
传统IO
- 传统IO会经历:4次拷贝(硬盘 --> 内核buffer --> 用户buffer --> socket buffer --> 协议栈),4次上下文切换。
- DMA(Direct Memory Access)拷贝:指的是直接内存拷贝
mmap
- mmap通过内存映射,将文件映射到内核缓冲区,同时,用户空间可以共享内核空间的数据。这样在进行网络传输时,就可以减少内核空间到用户空间的拷贝次数。
- 即3次拷贝,4次上下文切换
sendfile
- Linux2.1提供了sendFile函数,基本原理:数据根本不经过用户态,直接从内核缓冲区进入到socket buffer。同时,由于与用户态完全无关,就减少了一次上下文切换
- 即3次拷贝,2次上下文切换
sendfile + DMA gather copy
- Linux2.4版本中,做了一些修改,避免了从内核缓冲区拷贝到socket buffer的操作,直接拷贝到协议栈,从而再一次减少了数据拷贝。即2次拷贝,2次上下文切换
- 其实这里还是有一次cpu拷贝 kernel buffer -> socket buffer。但是,拷贝的信息很少,比如length,offset,一些描述信息的拷贝,消耗很低,可以忽略
mmap与sendfile的区别
- mmap实现零拷贝必须经过用户态,而sendfile拷贝次数更少,数据拷贝不用经过用户态
- mmap适合小数据的读写,sendfile适合大文件的传输
- 如果用户层需要对网络传输的数据进行业务处理,之后再存入文件系统/磁盘,则只能使用mmap
- 如果数据仅用来备份,不进行其他处理,可以使用sendfile方式,效率更高(如:数据库或MQ中的主从数据同步)
总结
- 我们常说的零拷贝,是从操作系统的角度来说的。因为内核缓冲区之间,没有数据是重复的(只有 kernel buffer 中有一份数据)
- 零拷贝不仅仅带来更少的数据复制,还能带来其他的性能优势,例如更少的上下文切换,更少的cpu缓存伪共享,以及无cpu校验和计算
- 零拷贝详细介绍,可参考:什么是零拷贝_从昨天的博客-CSDN博客_零拷贝
零拷贝案例
案例描述:
- 使用传统IO的方式传递一个大文件
- 使用NIO零拷贝方式传递一个大文件(transferTo)
- 比较两种传输方式消耗的时间
/**
* @author lixing
* @date 2022-04-27 15:39
* @description 传统IO拷贝,服务端
*/
public class OldIOServer {
public static void main(String[] args) throws IOException {
ServerSocket serverSocket = new ServerSocket(7001);
while (true){
Socket socket = serverSocket.accept();
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 e){
e.printStackTrace();
}
}
}
}
/**
* @author lixing
* @date 2022-04-27 15:40
* @description 传统IO拷贝,客户端
*/
public class OldIOClient {
public static void main(String[] args) throws IOException {
Socket socket = new Socket("localhost", 7001);
String fileName = "src/main/test.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)); // 发送总字节数:3575330 ,耗时:20
dataOutputStream.close();
inputStream.close();
socket.close();
}
}
/**
* @author lixing
* @date 2022-04-27 17:03
* @description NIO零拷贝,传输文件,服务端
*/
public class NewNIOServer {
public static void main(String[] args) throws IOException {
InetSocketAddress address = new InetSocketAddress(7001);
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.socket().bind(address);
ByteBuffer byteBuffer = ByteBuffer.allocate(4096);
while(true){
SocketChannel socketChannel = serverSocketChannel.accept();
int readCount = 0;
while(-1 != readCount){
try{
readCount = socketChannel.read(byteBuffer);
}catch (Exception e){
e.printStackTrace();
}
byteBuffer.rewind(); // 倒带 position=0, mark=-1作废
}
}
}
}
/**
* @author lixing
* @date 2022-04-27 17:07
* @description NIO零拷贝,传输文件,客户端
*/
public class NewIOClient {
public static void main(String[] args) throws IOException {
SocketChannel socketChannel = SocketChannel.open();
socketChannel.connect(new InetSocketAddress("localhost", 7001));
String fileName = "src/main/test.zip";
FileChannel fileChannel = new FileInputStream(fileName).getChannel();
long startTime = System.currentTimeMillis();
// 在linux下,一个transferTo方法就可以传输完成
// 在windows下一次调用transferTo只能发8MB,如果传输文件过大,需要分段传输,记录每次传输的位置
// transferTo底层使用到零拷贝
long transferCount = fileChannel.transferTo(0, fileChannel.size(), socketChannel);
System.out.println("发送的总的字节数="+transferCount+" 耗时:"+(System.currentTimeMillis()-startTime)); // 发送的总的字节数=3575330 耗时:6
fileChannel.close();
socketChannel.close();
}
}
/**
* <p> This method is potentially much more efficient than a simple loop
* that reads from this channel and writes to the target channel. Many
* operating systems can transfer bytes directly from the filesystem cache
* to the target channel without actually copying them. </p>
**/
public abstract long transferTo(long position, long count, WritableByteChannel target) throws IOException;
AIO
Java AIO基本介绍
- JDK1.7引入了Asynchronous I/O,即AIO。在进行io编程时,常用到两种模式:Reactor和Proactor。Java的NIO就是Reactor,当有事件触发时,服务端得到通知,进行相应的处理。
- AIO即NIO2.0,异步非阻塞IO。AIO引入了异步通道的概念,采用了Proactor模式,简化了程序编写,有效的请求才启动线程,它的特点是先由操作系统完成后才通知服务端程序启动线程去处理,一般适用于连接数较多且连接时间较长的应用
- 目前,AIO还没有广泛应用。Netty也是基于NIO,而不是AIO,想详细了解AIO可以查看:Java新一代网络编程模型AIO原理及Linux系统AIO介绍
BIO,NIO,AIO比较
BIO | NIO | AIO | |
---|---|---|---|
IO模型 | 同步阻塞 | 同步非阻塞(多路复用) | 异步非阻塞 |
编程难度 | 简单 | 复杂 | 复杂 |
可靠性 | 差 | 好 | 好 |
吞吐量 | 低 | 高 | 高 |
举例说明:
- 同步阻塞:到理发店理发,就一直等理发师,直到轮到自己理发
- 同步非阻塞:到理发店理发,发现前面由其他人理发,给理发师说下,先干其他事情,一会儿过来看是否轮到自己
- 异步非阻塞:给理发师打电话,让理发师上门服务,自己干其他的事情,理发师自己来家里给你理发