今天开始学习 Tomcat 源码, 本系列采用的是 Tomcat 8.5.57 源码构建, 在学习 Tomcat 源码之前, 先来学习基础知识 : 网络 I/O 模型
Java 共支持 3 种网络 /IO 模型:BIO、NIO、AIO
BIO
Blocking IO, 同步并阻塞 (传统阻塞型),一般搭配线程池来使用, 否则只有一条线程在工作! 其中 : ServerSocket#accept() 和 Socket#read()/write() 会阻塞, 如果没有线程池, 只能 一次处理一个连接, 其他连接都会阻塞
BIO 方式适用于 连接数目比较小且固定 的架构,这种方式对服务器资源要求比较高,并发局限于应用中,JDK1.4 以前的唯一选择,程序简单易理解
编写BIO程序
// 线程池应该用 ThreadPoolExcuter 创建
ThreadPoolExecutor pool = new ThreadPoolExecutor(5, 10,
5, TimeUnit.SECONDS, new ArrayBlockingQueue<>(10));
try {
ServerSocket serverSocket = new ServerSocket(6666);
while (true) {
// accept 会阻塞
Socket socket = serverSocket.accept()
pool.execute(() -> link(socket));
}....
}
private static void link(Socket socket) {
byte[] bytes = new byte[1024];
try {
InputStream inputStream = socket.getInputStream();
while (true) {
// read 也会阻塞
int read = inputStream.read(bytes);
if (read > 0) {
System.out.println("客户端发送 : " + new String(bytes, 0, read));
}
} ......
}
BIO存在的问题
- 每个请求都需要创建独立的线程,与对应的客户端进行数据读写,业务处理
- 当并发数较大时,需要创建大量线程来处理连接,系统资源占用较大。
- 连接建立后,如果当前线程暂时没有数据可读,则线程就阻塞在 read 操作上,造成线程资源浪费
NIO
Non-Blocking IO (也叫 New IO) 它是 同步非阻塞 的. 在 JDK1.4 中被引入
- NIO 有三大核心部分:Channel(通道),Buffer(缓冲区), Selector(选择器)
- NIO 是面向缓冲区 ,或者面向块编程的。数据读取到一个它稍后处理的 buffer,需要时可在 buffer 中前后移动,这就增加了处理过程中的灵活性,使用它可以提供非阻塞式的高伸缩性网络
- Java NIO 的非阻塞模式,使一个线程从 channel 发送请求或者读取数据,仅能得到可用的数据
如果没有数据可用时,并不会使线程阻塞,该线程可以继续做其他的事情。(直至数据可以读取)
非阻塞 write 也是如此,一个线程请求写入一些数据到 channel,但不需要等待它完全写入, 这个线程同时可以去做别的事情 - 通俗理解:NIO 是可以做到用一个线程来处理多个操作的
假设有 10000 个请求过来,根据实际情况,可以分配 50 或者 100 个线程来处理。不像之前的阻塞 IO 那样,非得分配 10000 个。
NIO 和 BIO 的比较
- BIO 以流的方式处理数据, 而 NIO 以块的方式处理数据,块 I/O 的效率比流 I/O 高很多
- BIO 是阻塞的,NIO 则是非阻塞的
- BIO 基于字节流和字符流进行操作,而 NIO 基于 Channel (通道) 和 Buffer (缓冲区) 进行操作
数据总是从 channel 读取到 buffer 中,或者从 buffer 写入到 channel 中 Selector(选择器) 用于监听多个 channel 的事件(比如:连接请求, 数据到达等),因此使用单个线程就可以处理多个客户端的请求
Buffer&Channel&Selector关系
- 每个 channel 都会对应一个 buffer
- selector 对应一个线程, 一个线程对应 (监听) 多个 channel (连接)
- 程序切换到哪个 channel 是由事件决定的, Event 就是一个重要的概念
- selector 会根据不同的事件,在各个 channel 上切换
- buffer 就是一个内存块 , 底层是有一个数组
- buffer 数据的读写是双向的 (需要调用 flip), 不像 BIO 那样, 要么是输入流, 要么是输出流.
缓冲区 (Buffer)
缓冲区本质上是一个可以读写数据的内存块,可以理解成是一个容器对象(含数组),该对象提供了一组方法,可以更轻松地使用内存块, 缓冲区对象内置了一些机制,能够跟踪和记录缓冲区的状态变化 情况。
Channel 提供从文件、网络读取数据的渠道,但是读取或写入的数据都必须经由 Buffer, Buffer 类的几个重要属性
private int mark = -1;
private int position = 0;//标志位
private int limit;//最大标志位
private int capacity;//数组容量
Buffer有七个子类, 最常用的就是 ByteBuffer
需要注意的是
ByteBuffer.allocateDirect(int capability);
分配的是堆外内存, 不属于GC管辖范围, 不需要内存拷贝, 所以速度较快
而ByteBuffer.allocation(int capability)
分配的是堆内内存, 属于GC管辖范围, 需要内存之间的拷贝, 速度较慢
再提一嘴 : 可以使用虚引用 (PhantomReference)
指向堆外内存的对象, 当堆外内存需要清理时, 会将这个虚引用放入一个引用队列. JVM 有一条 GC 线程专门监视这个引用队列, 当这个队列有存在引用时, 就会去清理堆外内存
通道 (Channel)
NIO 的通道类似于流, 但又有这些区别 :
- channel 可以同时进行读写,而流只能读或者只能写
- channel 可以实现异步读写数据
- channel 和 buffer 中的数据是双向的, 可以把 buffer 中的数据写入 channel , 也可以把 channel 中的数据读入 buffer
Channel 是一个接口, 常用的实现类有 :
- FileChannel : 用于文件的读写 (实现类为 : FileChannelImpl)
主要的方法有 :
read(ByteBuffer)
: 从 channel 读取数据并放到 buffer 中
write(ByteBuffer)
: 把 buffer 的数据写到 channel 中
transferFrom(ReadableByteChannel, long, long)
: 从目标 channel 中复制数据到当前 channel
transferTo(long, long, WritableByteChannel)
: 把数据从当前 channel 复制给目标 channel - DatagramChannel : 用于 UDP 的数据的读写, (实现类为 : DatagramChannelImpl )
- ServerSocketChannel : 相当 BIO 中的 ServerSocket (实现类为 : ServerSocketChannelImpl )
- SocketChannel : 相当于 BIO 中的 Socket (实现类为 : SocketChannelImpl )
关于 Buffer 和 Channel 的注意事项和细节
- ByteBuffer 支持类型化的 put 和 get, put 放入的是什么数据类型,get 就取出什么数据类型,否则可能抛出 BufferUnderflowException 异常
- 可以将一个普通 Buffer 转成只读 Buffer
- NIO 还提供了 MappedByteBuffer, 可以让文件直接在内存(堆外的内存)中进行修改, 操作系统不需要拷贝一次
- NIO 还支持通过多个 Buffer (即 Buffer 数组) 完成读写操作,即 Scattering 和 Gathering
数组在内存中是连续性的, 如果一次性分配大数组, 有可能会导致JVM堆中没有这么大的连续空间从而提前引发GC
使用多个数组就可以使得内存碎片的到充分利用
选择器 (Selector)
也叫多路复用器, Selector 能够检测多个注册的 channel 上是否有事件发生 (多个 Channel 以事件的方式可以注册到同一个 Selector)
如果有事件发生,便获取事件然后针对每个事件进行相应的处理。这样就可以只用一个单线程去管理多个 channel,也就是管理多个连接和请求
只有在 channel 真正有读写事件发生时,才会进行读写,就大大地减少了系统开销,并且不必为每个连接都创建一个线程,不用去维护多个线程 (避免了多线程之间的上下文切换导致的开销)
相关的API
Selector open();//得到一个选择器对象
int select(long timeout);//监听所有注册的channel, 返回有事件发生的channel数量
Set<SelectionKey> selectKeys();//得到保存所有 SelectionKey内部集合
select();//该方法时阻塞的, 当所有注册的channel没有事件发生时, 就会阻塞
select(long timeout);//超时退出
wakeup();//唤醒selector对象
selectNow();//不阻塞 , 立刻返回
NIO原理
- Selector 调用 select() 进行监听, 可以得到此时有事件发生的 SelectionKey 数量
- 当 Selector 监听到连接事件的时候, 通过 ServerSocketChannel 得到一个 SocketChannel 对象
- 调用 register(Selector, int, Object) , 将 SocketChannel 对象注册到 Selector上, 并返回一个 SelectionKey 对象, Selector 将 SelectionKey 对象保存在内部的 Set 集合中
- 根据 SelectionKey 的事件进行对应处理
- Selector 对象通过调用 selectKeys() 得到所有的 SelectionKey 对象, 调用 selectedKeys() 得到所有的有事件发生的 SelectionKey 对象
- SelectionKey 对象 调用 channel(), 得到 Channel 对象, 可以用该 Channel 对象对事件进行处理
NIO程序
public static void main(String[] args) throws IOException {
Selector selector = Selector.open();
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
//绑定端口
serverSocketChannel.socket().bind(new InetSocketAddress(6001));
//设置为非阻塞
serverSocketChannel.configureBlocking(false);
//将ServerSocketChannel注册到 Selector上
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
while (true) {
if (selector.select(1000) == 0) {
System.out.println("没有任何事件发生....., 已等待一秒!");
continue;
}
//如果有事件发生
Set<SelectionKey> selectionKeys = selector.selectedKeys();
Iterator<SelectionKey> keyIterator = selectionKeys.iterator();
while (keyIterator.hasNext()) {
SelectionKey key = keyIterator.next();
if (key.isAcceptable()) {//此时有注册事件
SocketChannel socketChannel = serverSocketChannel.accept();
//设置为非阻塞
socketChannel.configureBlocking(false);
//将该 SocketChannel 对象注册到 Selector 上
socketChannel.register(selector, SelectionKey.OP_READ, ByteBuffer.allocate(1024));
System.out.println("此时有一个连接注册成功!");
}
if (key.isReadable()) {// 此时有 OP_READ 事件
SocketChannel channel = (SocketChannel) key.channel();
ByteBuffer buffer = (ByteBuffer) key.attachment();
channel.read(buffer);
System.out.println("客户端发送数据 : " + new String(buffer.array()));
}
keyIterator.remove();
}
}
}
SocketChannel socketChannel = SocketChannel.open();
socketChannel.configureBlocking(false);
InetSocketAddress address = new InetSocketAddress("127.0.0.1", 6001);
//如果连接不成功!
if (!socketChannel.connect(address)){
//如果释放连接失败
while (!socketChannel.finishConnect()) {
System.out.println("连接失败! 客户端可以作其他事情!");
}
}
ByteBuffer wrap = ByteBuffer.wrap("Hello NIO!".getBytes());
socketChannel.write(wrap);
//不释放连接
System.in.read();
AIO
- JDK 7 引入了 Asynchronous I/O,即异步IO 。在进行 I/O 编程中,常用到两种模式:Reactor 和 Proactor。Java 的 NIO 就是 Reactor,当有事件触发时,服务器端得到通知,进行相应的处理
- AIO 即 NIO2.0,它是 异步非阻塞的。AIO 引入异步通道的概念,采用了 Proactor 模式,简化了程序编写, 有效的请求才启动线程,它的特点是先由操作系统完成后才通知服务端程序启动线程去处理,一般适用于连接 数较多且连接时间较长的应用
- 目前 AIO 还没有广泛应用,Netty 也是基于 NIO, 而不是 AIO
三者的对比
零拷贝
传统的 IO 拷贝
系统有三次用户态和内核态之间切换, 及其影响性能
DMA : direct memory access 直接内存拷贝 (不使用 CPU)
mmap 优化
mmap 通过内存映射,将文件映射到内核缓冲区,同时,用户空间可以共享内核空间的数据。这样,在进行网络传输时,就可以减少内核空间到用户空间的拷贝次数
sendFile 优化
Linux 2.1 版本 提供了 sendFile 函数,其基本原理如下:数据根本 不经过用户态,直接从内核缓冲区进入到 Socket Buffer,同时,由于和用户态完全无关,就减少了一次上下文切换
提示:零拷贝从操作系统角度,是没有 cpu 拷贝
mmap 和 sendFile 的区别
- mmap 适合小数据量读写,sendFile 适合大文件传输。
- mmap 需要 4 次上下文切换,3 次数据拷贝;sendFile 需要 3 次上下文切换,最少 2 次数据拷贝。
- sendFile 可以利用 DMA 方式,减少 CPU 拷贝,mmap 则不能(必须从内核拷贝到 Socket 缓冲区)
零拷贝
Linux 在 2.4 版本中,做了一些修改,实现真正的零拷贝. 避免了从内核缓冲区拷贝到 Socket buffer 的操作,直接拷贝到协议栈, 从而再一次减少了数据拷贝。
这里其实有 一次 cpu 拷贝 kernel buffer -> socket buffer 但是,拷贝的信息很少,比如 lenght , offset , 消耗低,可以忽略
我们说零拷贝,是从操作系统的角度来说的。因为内核缓冲区之间,没有数据是重复的(只有 kernel buffer 有 一份数据)。
零拷贝不仅仅带来更少的数据复制,还能带来其他的性能优势,例如 更少的上下文切换,更少的 CPU 缓存伪 共享以及无 CPU 校验和计算