前言: 这是为下一篇对 Netty 的理解打下基础
1.I/O 模型基本说明
1). I/O 模型简单的理解:就是用什么样的通道进行数据的发送和接收,很大程度上决定了程序通信的性能
2). Java 共支持 3 中网络编程模型/ IO模式: BIO、NIO、AIO
3). Java BIO: 同步并阻塞(传统阻塞型),服务器实现模式为一个连接一个线程,即客户端有连接请求时服务器就需要启动一个线程进行处理,如果这个连接不做任何事情就会造成不必要的线程开销
4). Java NIO: 同步非阻塞,服务器实现模式为一个线程处理多个请求连接,即客户端发送的连接请求会注册到多了复用器上,多路复用器轮询到连接有 I/O 请求就进行处理
5). Java AIO: 异步非阻塞,AIO 引入异步通道的概念,采用了 Proactor 模式,简单化了程序编写,有效的请求才启动线程,它的特点是先由操作系统完成后才通知服务端程序启动线程去处理,一般适用于连接数较多且连接时间较长的应用。
2.Java BIO 基本介绍
1). Java BIO 就是传统的 java io 编程,其相关的类和接口在 java.io
2). BIO(Blocking I/O): 同步阻塞,一个连接一个线程,如果这个连接不做任何事情就会造成不必要的线程开销,可以通过线程池机制改善。
这种方式适用于连接数目比较小且固定的架构,对服务器资源要求比较高,并发局限于应用中。
2.1 Java BIO 工作机制
原理图:
2.2 Java BIO 应用
说明:
1). 使用BIO 模型编写一个服务端,监听6000端口,当有客户端连接时,就启动一个线程与之通讯
2). 要求使用线程池机制改善,可以连接多个客户端
3). 服务端可以接收客户端发送的数据 (telnet 方式即可)
public class BioServer {
public static void main(String[] args) throws Exception {
ExecutorService threadPool = Executors.newCachedThreadPool();
ServerSocket socket = new ServerSocket(6000);
System.out.println("服务器启动了");
while (true) {
//监听,等待客户端连接
final Socket accept = socket.accept();
System.out.println("连接到了一个客户端");
//就创建一个线程,与之通讯,(单独写一个方法)
threadPool.execute(new Runnable() {
public void run() {
//可以和客户端通讯
handler(accept);
}
});
}
}
public static void handler(Socket socket) {
byte[] bytes = new byte[1024];
try {
System.out.println("线程信息id=" + Thread.currentThread().getId() + "名字="
+ Thread.currentThread().getName());
//通过socket 获取到输入流
InputStream inputStream = socket.getInputStream();
//循环读取客户端发送的数据
while (true) {
System.out.println("reading........!!!!");
int read = inputStream.read(bytes);
if (read != -1) {
//输出客户端发送的数据
System.out.println(new String(bytes, 0, read));
}
}
} catch (Exception e) {
e.printStackTrace();
} finally {
//关闭socket
try {
socket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
然后如果你的电脑是windows 的话:
- cmd
- telnet 127.0.0.1 6000
- ctel + ]
- send hello
- … 就能看到结果了
阻塞的表现: 连接建立后,如果当前线程暂时没有数据可读,则线程就阻塞在 Read 操作上,造成资源浪费
(可以看上面代码的打印结果哦…)
3.Java NIO 基本介绍
1). JavaNIO 全称 javanon-blockingIO,是指JDK提供的新API。从JDK1.4开始,Java提供了一系列改进的输入/输出的新特性,被统称为NIO(即NewIO),是同步非阻塞的
2). NIO 相关类都被放在了 java.io 包及子包下,并且对原 java.io 包中的很多类进行改写
3). NIO 有三大核心部分: Channel (通道)、Buffer(缓冲区)、Selector(选择器)
4). NIO 是面向缓冲区,或者面向 块编程的。数据读取到一个它稍后处理的缓冲区,需要时可在缓冲区中前后移动,这就增加了处理过程中的灵活性,使用它可以提供非阻塞式的高伸缩性网络
5). Java NIO 的非阻塞模式,使一个线程从某通道发送请求或者读取数据,但是它仅能得到目前可用的数据,如果目前没有数据可用时,就什么都不会获取,而不是保持线程阻塞,所以直至数据变的可以读取之前,该线程可以继续做其他事情。非阻塞写也是如此,一个线程请求写入一些数据到某通道,但不需要等待它完全写入,这个线程同时可以去做别的事情
6). 通俗理解: NIO 是可以做到用一个线程来处理多个操作的。假设有 10000 个请求过来,根据实际情况,可以分配50或者100个线程来处理。不像前面你的 阻塞 IO ,必须分配 10000 个
7). HTTP 2.0 使用了多路复用的技术,做到了同一个连接并发处理多个请求,而且并发请求的数量比 HTTP1.1 大了好几个数量级
4.NIO 和 BIO 的比较
1). BIO 以流的方式处理数据,而 NIO 以块的方式处理数据,块 I/O 的效率比流 I/O 高很多
2). BIO 是阻塞的, NIO 是非阻塞的
3). BIO 基于字节流和字符流进行操作,而 NIO 基于 Channel(通道)和 Buffer(缓冲区)进行操作,数据总数从通道读取到缓冲区,或者从缓冲区写入到通道。 Selector(选择器)用于监听多个通道的事件(比如:连接请求,数据到达等),因此使用 单个线程就可以监听多个客户端通道
5. NIO三大核心原理示意图
一张图描述 NIO 的 Selector、Channel 和 Buffer 的关系 (简单版)
1). 每个 channel 都会对应一个 Buffer
2). Selector 对应一个线程,一个线程对应多个 channel
3). 该图反应了有三个 channel 注册到了该 Selector //程序
4). 程序切换到哪个 channel 是有事件决定的, Event 就是一个重要的概念
5). Selector 会根据不同的事件,在各个通道上切换
6). Buffer 就是一个内存块,底层是有一个数组
7). 数据的读取写入是通过 Buffer ,BIO 中要么是输入流,或者是输出流,不能双向。但是 NIO 的 Buffer 是可以读也可以写,需要 flip 方法切换
8). channel 是双向的,可以返回底层操作系统的情况, 比如 Linux,底层的操作系统通道就是双向的
5.1 缓冲区(Buffer)
Buffer: 缓冲区本质是一个可以读写数据的内存块,可以理解成一个 容器对象(含数组) ,该对象提供了一组方法,可以更轻松地使用内存块,缓冲区对象内置了一些机制,能够跟踪和记录缓冲区的状态变化情况。Channel 提供从文件、网络读取数据的渠道,但是读取或写入数据都必须经由 Buffer
1).NIO 中, Buffer 是一个顶层父类,它是一个抽象类,类的层级关系图如下:
2). Buffer 类定义了所有的缓冲区都具有的四个属性来提供关于其他所包含的数据元素的信息
5.2 通道(Channel)
1). NIO 的通道类似于流,但有些区别
- 通道可以同时进行读写,而流只能读或者只能写
- 通道可以实现异步读写数据
- 通道可以从缓冲读数据,也可以写数据到缓冲
2). BIO 中的 stream 是单向的,例如 FileInputStream对象只能进行读取数据的操作, 而 NIO 中的通道(Channel)是双向的,可以读操作,也可以写操作
3). Channel 在 NIO 中是一个接口: public interface Channel extends Closeable {}
4). 常用的 Channel 接口的实现类有: FileChannel、DatagramChannel、ServerSocketChannel 和 SocketChannel
注:(ServerSocketChannel 类似 ServerSocket,SocketChannel 类似 Socket)
5). FileChannel 用于文件的数据读写,DatagramChannel 用于 UDP 的数据读写,ServerSocketChannel、SocketChannel 用于 TCP 的数据读写
5.2.1 FileChannel 类
FileChannel 主要用于对本地文件进行 IO 操作,常见的方法有:
1). public abstract int read(ByteBuffer dst); // 从通道读取数据并放到缓冲区
2). public abstract int write(ByteBuffer src); // 把缓冲区的数据写到通道
3). public abstract long transferFrom(ReadableByteChannel src, long position, long count); // 从目标通道中复制数据到当前通道
4). public abstract long transferTo(long position, long count, WritableByteChannel target); // 把数据从当前通道复制给目标通道
需求示例1:(本地文件写数据)
使用前面的 ByteBuffer(缓冲)和 FileChannel(通道),将 “Hello World ”写入到 file01.txt
public class NioFileChannel {
public static void main(String[] args) throws IOException {
String str = "Helloworld";
//创建一个输出流 -> channel
FileOutputStream fileOutputStream = new FileOutputStream("D:\\file.txt");
//通过 fileOutputStream 获取对应的 FileChannel
//这个 channel 真实的类型是 FileChannelImpl
FileChannel channel = fileOutputStream.getChannel();
//创建一个缓存区 ByteBuffer
ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
//将 str 放到 byteBuffer
byteBuffer.put(str.getBytes());
//对 byteBuffer 进行反转
byteBuffer.flip();
//将 byteBuffer 数据写到 fileChannel
channel.write(byteBuffer);
fileOutputStream.close();
}
}
需求示例2:(本地文件读数据)
使用前面的 ByteBuffer 和 FileChannel,将 file01.txt 中的数据读到程序,打印在控制台
public class NioFileChannel02 {
public static void main(String[] args) throws IOException {
File file = new File("D:\\file.txt");
FileInputStream fileInputStream = new FileInputStream(file);
//通过 fileInputStream 获取对应的 channel -> 实际类型 FileChannelImpl
FileChannel channel = fileInputStream.getChannel();
//创建缓冲区
ByteBuffer byteBuffer = ByteBuffer.allocate((int) file.length());
//将通道 channel 的数据读到 ByteBuffer
channel.read(byteBuffer);
// 将 byteBuffer 的字节数据,转换成String
System.out.println(new String(byteBuffer.array()));
fileInputStream.close();
}
}
需求示例4:拷贝文件 transferFrom 方法
使用 FileChannel 和 方法 transferFrom,完成文件的拷贝
public class NioFileChannel04 {
public static void main(String[] args) throws IOException {
FileInputStream fileInputStream = new FileInputStream("D:\\a.png");
FileOutputStream fileOutputStream = new FileOutputStream("D:\\a_copy.png");
//获取各个流对应的 fileChannel
FileChannel source = fileInputStream.getChannel();
FileChannel dest = fileOutputStream.getChannel();
//使用 transferForm 完成两个 channel 管道间的数据的拷贝
dest.transferFrom(source, 0, source.size());
//关闭相关的流
source.close();
dest.close();
fileInputStream.close();
fileOutputStream.close();
}
}
5.3 关于 Buffer 和 Channel 的注意事项和细节
1). ByteBuffer 支持类型化的 put 和 get ,put 放入的是什么类型数据,get 就应该使用相应的数据类型来取,否则可能会出现 BufferUnderflowException 异常
2). 可以将一个普通的 Buffer 转成只读 Buffer
3). NIO 还提供了 MappedByteBuffer ,可以让文件直接在内存(堆外的内存)中进行修改,而如何同步到文件由 NIO 来完成
6. Selector(选择器)
基本介绍:
1). Java 的 NIO,用非阻塞的 IO 方式。可以用一个线程,处理多个的客户端连接,就会使用到 Selector (选择器)
2). Selector 能够检测到多个注册的通道上是否有事件发生(注意:多个 Channel 以事件的方式可以注册到同一个 Selector),如果有事件发生,便获取事件然后针对每个事件进行相应的处理。这样就可以只用一个单线程去管理多个通道,也就是管理多个连接和请求
3). 只有在 连接/通道 真正有读写事件发生的时候,才会进行读写,就大大地减少了系统的开销,并且不必为每个连接都创建一个线程,不用去维护多个线程
4). 避免了多线程之间的上下文切换导致的开销
6.1 Selector 类相关方法
Selector 类是一个抽象类,相关方法和说明如下:
1). public static Selector open(); //得到一个选择器对象
2). public abstract int select(long timeout); //监测所有注册的通道,当其中有 IO 操作可以进行时,将对应的 SelectionKey 加入到内部集合中并返回,参数用来设置超时时间
3). public abstract Set selectedKeys(); //从内部稽核中得到所有的 SelectionKey
6). public abstract int selectNow(); // 不阻塞,立马返还
7). public abstract int select(long timeout); //阻塞 timeout 毫秒,在 timeout 毫秒后返回
8). public abstract int select(); //阻塞
6.1.1 Selector、SelectionKey、ServerSocketChannel和SocketChannel关系梳理图
对上图的说明:
1). 当客户端连接时,会通过ServerSocketChannel 得到 SocketChannel
2). Selector 进行监听 select 方法,返回事件发生的通道的个数
3). 将 SocketChannel 注册到 Selector 上, reguster(Selector sel, int ops ),一个 selector 上可以注册多个 SocketChannel
4). 注册后返回一个 SelectionKey, 会和该 Selector 关联 (集合)
5). 进一步得到各个 SelectionKey(有事件发生)
6). 再通过 SelectionKey 反向获取 SocketChannel 的方法: channnel ()
7). 可以通过拿到 channel,完成业务处理
(上面标红的 ops 这个参数下面会细说)
6.1.2 SelectionKey
SelectionKey: 表示 Selector 和网络通道的注册关系,一共有四种:
- int OP_ACCEPT: 有新的网络连接可以 accept,值为16
- int OP_CONNECT:代表连接已经建立,值为8
- int OP_READ:代表读操作,值为1
- int OP_WRITE:代表写操作,值为4
源码中:
SelectionKey 相关方法:
6.1.3 ServerSocketChannel
1). ServerSocketChannel 在服务器端监听新的客户端 Socket 连接
2). 相关方法如下图:
6.1.4 SocketChannel
1). SocketChannel,网络 IO 通道,具体负责进行读写操作。NIO 把缓冲区的数据写入通道,或者吧通道里面的数据读到缓冲区
2). 相关的方法如下图:
需求示例: NIO 非阻塞 网络编程快速入门
用 NIO 非阻塞 实现服务器端和客户端之间的数据简单通讯 (非阻塞)
服务端:
public class NioServer {
public static void main(String[] args) throws IOException {
//创建一个 ServerSocketChannel -> SocketChannel
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
//得到一个 Selector 对象
Selector selector = Selector.open();
//绑定一个端口6666.在服务器端监听
serverSocketChannel.socket().bind(new InetSocketAddress(6666));
//设置非阻塞
serverSocketChannel.configureBlocking(false);
// 把 serverSocketChannel 注册到 selector 关心事件为: OP_ACCEPT
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
//循环等待客户端连接
while (true) {
//这里我们等待1秒,如果没有事件发生,返回
if (selector.select(1000) == 0) {
//没有事件发生
System.out.println("服务器等待了1 秒,无连接");
continue;
}
//如果返回的>0,就获取到相应的 SelectionKey 集合
//1. 如果返回的>0,表示已经获取到关注的事件
//2. selector.selectedKeys(); 返回关注的事件的集合,
// 通过 selectionKeys 反向获取通道
Set<SelectionKey> selectionKeys = selector.selectedKeys();
//遍历 Set<SelectionKey> ,使用迭代器遍历
Iterator<SelectionKey> keyIterator = selectionKeys.iterator();
while (keyIterator.hasNext()) {
//获取到 selectionKey
SelectionKey key = keyIterator.next();
//根据key 对应的通道发生的事件做相应的处理
if (key.isAcceptable()) {
//如果是 OP_ACCEPT ,有新的客户端连接
//该客户端生成一个 SocketChannel
SocketChannel socketChannel = serverSocketChannel.accept();
System.out.println("客户端连接成功,生成一个 socketChannel" + socketChannel.hashCode());
//将 socketChannel 设置为非阻塞
socketChannel.configureBlocking(false);
//将 socketChannel 注册到 selector,关注事件为 OP_READ,同事给 socketChannel关联一个 Buffer
socketChannel.register(selector, SelectionKey.OP_READ, ByteBuffer.allocate(1024));
}
if (key.isReadable()) {
//发生了 OP_READ
//通过key 反向获取到对应的 channel
SocketChannel channel = (SocketChannel) key.channel();
//获取到该 channel 关联的 buffer
ByteBuffer buffer = (ByteBuffer) key.attachment();
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 = "HelloJarvis";
ByteBuffer buffer = ByteBuffer.wrap(str.getBytes());//wrap:产生一个字节数组到 Buffer 里面去
//发送数据,讲 buffer 数据写入到 channel
socketChannel.write(buffer);
System.in.read();
}
}
运行结果:
说明:
- 这里每一次的 client 去连接客户端,都有一个 SocketChannel 生成,这里打印了它们的 hashcode 值,可以发现它们是不同的
- 可以明显看出服务端在无客户端连接时,并没有阻塞,而是在执行自己的逻辑(一直打印:服务器等待了1秒。无连接 这句话)
7. NIO与零拷贝
1). 零拷贝是网络编程的关键,很多性能优化都离不开
2). 在 Java 程序中,常用的零拷贝有 mmap(内存映射)和 sendFile。那么,他们在 OS 里,是怎么样的一个设计
mmap 优化:
1). mmap 是通过内存映射,将文件映射到内核缓冲区,同时,用户空间可以共享内核空间的数据。这样,在进行网络传输时,就可以减少内核空间到用户控件的拷贝次数
2). mmap 示意图:
sendFile优化:
1). Linux2.1 版本 提供了 sendFile 函数,其基本原理如下:数据根本不经过用户态,直接从内核缓冲区进入到 SocketBuffer,同时,由于和用户态完全无关,就减少了一次上下文切换
2). 示意图:
提示:零拷贝从操作系统角度,是没有 cpu 拷贝
3). Linux 在 2.4 版本中做了一些改动,避免了从内核缓冲区拷贝到 SocketBuffer 的操作,知道拷贝到协议栈中,从而再一次减少了数据拷贝,具体如下图:
这里其实有一次 cpu 拷贝 kernel buffer -> socket buffer 。但是,拷贝的信息很少,比如 lenght,offset ,消耗低,可以忽略
7.1 mmap 和 sendFile 的区别
1). mmap 适合小数据量读写,sendFile 适合大文件传输
2). mmap 需要4次上下文切换,3次数据拷贝;sendFile 需要3次上下文切换,最少2次数据拷贝
3). sendFile 可以利用 DMA 方式,减少 CPU 拷贝, mmap 则不能(必须从内核拷贝到 Socket 缓冲区)