最近在学习netty,记录下学习过程
1. Java IO 分类
-
BIO (Blocking I/O)
BIO就是Java的传统IO模型,与其相关的实现都位于java.io包下,其通信原理是客户端、服务端之间通过Socket套接字建立管道连接,然后从管道中获取对应的输入/输出流,最后利用输入/输出流对象实现发送/接收信息。 -
NIO (Non-blocking/New I/O)
Java 中的 NIO 于 Java 1.4 中引入,对应
java.nio包,提供了Channel,Selector,Buffer等抽象。NIO 中的 N 可以理解为 Non-blocking,不单纯是 New。它是支持面向缓冲的,基于通道的 I/O 操作方法。 对于高负载、高并发的(网络)应用,应使用 NIO。 -
AIO (Asynchronous I/O)
Java 7 中引入了 NIO 的改进版 NIO 2,它是异步 IO 模型。 异步 IO 是基于事件和回调机制实现的,也就是应用操作之后会直接返回,不会堵塞在那里,当后台处理完成,操作系统会通知相应的线程进行后续的操作。
2. BIO
通过ServerSocket创建服务器端,监听9000端口,等待客户端连接,代码如下。客户端可使用telnet发送数据
public static void main(String[] args) throws IOException {
//监听9000 端口
ServerSocket serverSocket = new ServerSocket(9000);
//创建处理连接的线程池
ExecutorService threadPool = Executors.newCachedThreadPool();
while (true){
//当有连接返回连接的socket 没有连接会阻塞在这里
Socket accept = serverSocket.accept();
System.out.println("建立连接~~");
//取个线程执行当前连接的后续操作
threadPool.execute(new Runnable() {
@SneakyThrows
@Override
public void run() {
InputStream inputStream = accept.getInputStream();
byte [] bytes=new byte[1024];
while (true){
int read = inputStream.read(bytes);
//read 读到数据长度 当为-1 表示断开连接
if(read!=-1){
System.out.println(read);
System.out.println("收到消息:"+new String(bytes,0,read));
}else{
accept.close();
System.out.println("断开连接~~");
break;
}
}
}
});
}
}
服务端有两个阻塞点
- 建立连接 serverSocket.accept(),直到有新的连接
- 读取消息 inputStream.read(bytes),直到有新的消息
3. NIO
Java-NIO是基于多路复用模型实现的,其中存在三大核心理念:Buffer(缓冲区)、Channel(通道)、Selector(选择器) ,与BIO还有一点不同在于:由于BIO模型中数据传输是阻塞式的,因此必须得有一条线程维护对应的Socket连接,在此期间如若未读取到数据,该线程就会一直阻塞下去。而NIO中则可以用一条线程来处理多个Socket连接,不需要为每个连接都创建一条对应的线程维护。
-
Buffer缓冲区
缓冲区其实本质上就是一块支持读/写操作的内存,底层是由多个内存页组成的数组,我们可以将其称之为内存块,在Java中这块内存则被封装成了
Buffer对象,需要使用可直接通过已提供的API对这块内存进行操作和管理。
// 缓冲区抽象类
public abstract class Buffer {
// 标记位,与mark()、reset()方法配合使用,
// 可通过mark()标记一个索引位置,后续可随时调用reset()恢复到该位置
private int mark = -1;
// 操作位,下一个要读取或写入的数据索引
private int position = 0;
// 限制位,表示缓冲区中可允许操作的容量,超出限制后的位置不能操作
private int limit;
// 缓冲区的容量,类似于声明数组时的容量
private int capacity;
long address;
// 清空缓冲区数据并返回对缓冲区的引用指针
// (其实调用该方法后缓冲区中的数据依然存在,只是处于不可访问状态)
// 该方法还有个作用:就是调用该方法后会从读模式切换回写模式
public final Buffer clear();
// 调用该方法后会将缓冲区从写模式切换为读模式
public final Buffer flip();
// 获取缓冲区的容量大小
public final int capacity();
// 判断缓冲区中是否还有数据
public final boolean hasRemaining();
// 获取缓冲区的界限大小
public final int limit();
// 设置缓冲区的界限大小
public final Buffer limit(int n);
// 对缓冲区设置标记位
public final Buffer mark();
// 返回缓冲区当前的操作索引位置
public final int position();
// 更改缓冲区当前的操作索引位置
public final Buffer position(int n);
// 获取当前索引位与界限之间的元素数量
public final int remaining();
// 将当前索引转到之前标记的索引位置
public final Buffer reset();
// 重置操作索引位并清空之前的标记
public final Buffer rewind();
// 省略其他不常用的方法.....
}
对于Java中缓冲区的定义,首先要明白,当缓冲区被创建出来后,同一时刻只能处于读/写中的一个状态,同一时间内不存在即可读也可写的情况。理解这点后再来看看它的成员变量,重点理解下述三个成员:
pasition:表示当前操作的索引位置(下一个要读/写数据的下标)。capacity:表示当前缓冲区的容量大小。limit:表示当前可允许操作的最大元素位置(不是下标,是正常数字)。
简单了解了一下成员变量后,再来看看其中提供的一些成员方法,重点记住clear()、flip()方法,这两个方法都可以让缓冲区发生模式转换,flip()可以从写模式切换到读模式,而clear()方法本质上是清空缓冲区的意思,但清空后就代表着缓冲区回归“初始化”了,因此也可以从读模式转换到最初的写模式。
当需要使用缓冲区时,都是通过xxxBuffer.allocate(n)的方式创建,例如:
ByteBuffer buffer = ByteBuffer.allocate(10);
Buffer缓冲区的使用方式与Map容器的读/写操作类似,通过get读取数据,通过put写入数据。
不过一般在使用缓冲区的时候都会遵循如下步骤:
- ①先创建对应类型的缓冲区
- ②通过
put这类方法往缓冲区中写入数据 - ③调用
flip()方法将缓冲区转换为读模式 - ④通过
get这类方法从缓冲区中读取数据 - ⑤调用
clear()、compact()方法清空缓冲区数据
Java中的缓冲区也被分为了两大类:本地直接内存缓冲区与堆内存缓冲区,前面Buffer类的所有子实现类xxxBuffer本质上还是抽象类,每个子抽象类都会有DirectXxxBuffer、HeapXxxBuffer两个具体实现类,这两者的主要区别在于:创建缓冲区的内存是位于堆空间之内还是之外。
一般情况下,直接内存缓冲区的性能会高于堆内存缓冲区,但申请后却需要自行手动管理,不像堆内存缓冲区由于处于堆空间中,会有GC机制自动管理,所以直接内存缓冲区的安全风险要高一些。两者之间的工作原理如下:
由于堆缓冲区创建后是存在于堆空间中的,所以IO数据必须要经过一次本地内存的“转发后”才能达到堆内存,因此效率自然会低一些,同时也会占用Java堆空间。所以如若追求更好的IO性能,或IO数据过于庞大时,可通过xxxBuffer.allocateDirect()方法创建本地缓冲区使用,也可以通过isDirect()方法来判断一个缓冲区是否基于本地内存创建。
-
Buffer缓冲区
NIO中的通道与BIO中的流对象类似,但BIO中要么是输入流,要么是输出流,通常流操作都是单向传输的。而通道的功能也是用于传输数据,但它却是一个双向通道,代表着我们即可以从通道中读取对端数据,也可以使用通道向对端发送数据。
这个通道可以是一个本地文件的
IO连接,也可以是一个网络Socket套接字连接。Java中的Channel定义如下:
// NIO包中定义的Channel通道接口
public interface Channel extends Closeable {
// 判断通道是否处于开启状态
public boolean isOpen();
// 关闭通道
public void close() throws IOException;
}
复制代码
可以很明显看出,Channel通道仅被定义成了一个接口,其中提供的方法也很简单,因为具体的实现都在其子类下,Channel中常用的子类如下:
FileChannel:用于读取、写入、映射和操作本地文件的通道抽象类。DatagramChannel:读写网络IO中UDP数据的通道抽象类。SocketChannel:读写网络IO中TCP数据的通道抽象类。ServerSocketChannel:类似于BIO的ServerSocket,用于监听TCP连接的通道抽象类。........
是的,你没有看错,实现Channel接口的都是抽象类,最终具体的功能则是这些抽象类的实现类xxxChannelImpl去完成的,所以Channel通道在Java中是三层定义:顶级接口→二级抽象类→三级实现类。但由于Channel接口子类实现颇多,因此不再挨个分析,挑出最常用的ServerSocketChannel、SocketChannel举例分析,其他实现类都大致相同:
// 服务端通道抽象类
public abstract class ServerSocketChannel
extends AbstractSelectableChannel
implements NetworkChannel
{
// 构造方法:需要传递一个选择器进行初始化构建
protected ServerSocketChannel(SelectorProvider provider);
// 打开一个ServerSocketChannel通道
public static ServerSocketChannel open() throws IOException;
// 绑定一个IP地址作为服务端
public final ServerSocketChannel bind(SocketAddress local);
// 绑定一个IP并设置并发连接数大小,超出后的连接全部拒绝
public abstract ServerSocketChannel bind(SocketAddress local, int backlog);
// 监听客户端连接的方法(会发生阻塞的方法)
public abstract SocketChannel accept() throws IOException;
// 获取一个ServerSocket对象
public abstract ServerSocket socket();
// .....省略其他方法......
}
复制代码
ServerSocketChannel的作用与BIO中的ServerSocket类似,主要负责监听客户端到来的Socket连接,但观察如上代码,你会发现它并未定义数据传输(读/写)的方法,因此要牢记:ServerSocketChannel只负责管理客户端连接,并不负责数据传输。用法如下:
// 1.打开一个ServerSocketChannel监听
ServerSocketChannel ssc = ServerSocketChannel.open();
// 2.绑定监听的IP地址与端口号
ssc.bind(new InetSocketAddress("127.0.0.1",8888));
// 也可以这样绑定
// ssc.socket().bind(new InetSocketAddress("127.0.0.1",8888));
// 3.监听客户端连接
while(true){
// 不断尝试获取客户端的socket连接
SocketChannel sc = ssc.accept();
// 如果为null则代表没有连接到来,非空代表有连接
if (sc != null){
// 处理客户端连接.....
}
}
复制代码
接着再来看看SocketChannel的定义:
public abstract class SocketChannel extends AbstractSelectableChannel
implements ByteChannel, ScatteringByteChannel,
GatheringByteChannel, NetworkChannel{
// 打开一个通道
public static SocketChannel open();
// 根据指定的远程地址,打开一个通道
public static SocketChannel open(SocketAddress remote);
// 如果调用open()方法时未给定地址,可以通过该方法连接远程地址
public abstract boolean connect(SocketAddress remote);
// 将当前通道绑定到本地套接字地址上
public abstract SocketChannel bind(SocketAddress local);
// 把当前通道注册到Selector选择器上:
// sel:要注册的选择器、ops:事件类型、att:共享属性。
public final SelectionKey register(Selector sel,int ops,Object att);
// 省略其他......
// 关闭通道
public final void close();
// 向通道中写入数据,数据通过缓冲区的方式传递
public abstract int write(ByteBuffer src);
// 根据给定的起始下标和数量,将缓冲区数组中的数据写入到通道中
public abstract long write(ByteBuffer[] srcs,int offset,int length);
// 向通道中批量写入数据,批量写入一个缓冲区数组
public final long write(ByteBuffer[] srcs);
// 从通道中读取数据(读取的数据放入到dst缓冲区中)
public abstract int read(ByteBuffer dst);
// 根据给定的起始下标和元素数据,在通道中批量读取数据
public abstract long read(ByteBuffer[] dsts,int offset,int length);
// 从通道中批量读取数据,结果放入dits缓冲区数组中
public final long read(ByteBuffer[] dsts);
// 返回当前通道绑定的本地套接字地址
public abstract SocketAddress getLocalAddress();
// 判断目前是否与远程地址建立上了连接关系
public abstract boolean isConnected();
// 判断目前是否与远程地址正在建立连接
public abstract boolean isConnectionPending();
// 获取当前通道连接的远程地址,null代表未连接
public abstract SocketAddress getRemoteAddress();
// 设置阻塞模式,true代表阻塞,false代表非阻塞
public final SelectableChannel configureBlocking(boolean block);
// 判断目前通道是否为打开状态
public final boolean isOpen();
}
复制代码
SocketChannel所提供的方法大体分为三类:
- ①管理类:如打开通道、连接远程地址、绑定地址、注册选择器、关闭通道等。
- ②操作类:读取/写入数据、批量读取/写入、自定义读取/写入等。
- ③查询类:检查是否打开连接、是否建立了连接、是否正在连接等。
其中方法的具体作用其实注释写的很明确了,再单独拎出来一点聊一下:上述所提到的批量读取/写入,其实还有个别的叫法,被称为:Scatter分散读取和Gather聚集写入,其实说人话就是将通道中的数据读取到多个缓冲区,以及将多个缓冲区中的数据同时写入到通道中。
OK,再补充一句:在将
SocketChannel通道注册到选择器上时,支持OP_READ、OP_WRITE、OP_CONNECT三种事件,当然,这跟Selector选择器有关,接下来聊聊它。
-
Selector选择器
Selector是NIO的核心组件,它可以负责监控一个或多个Channel通道,并能够检测出那些通道中的数据已经准备就绪,可以支持读取/写入了,因此一条线程通过绑定一个选择器,就可以实现对多个通道进行管理,最终达到一条线程处理多个连接的效果,能够在很大程度上提升网络连接的效率。Java中的定义如下:
public abstract class Selector implements Closeable {
// 创建一个选择器
public static Selector open() throws IOException;
// 判断一个选择器是否已打开
public abstract boolean isOpen();
// 获取创建当前选择器的生产者对象
public abstract SelectorProvider provider();
// 获取所有注册在当前选择的通道连接
public abstract Set<SelectionKey> keys();
// 获取所有数据已准备就绪的通道连接
public abstract Set<SelectionKey> selectedKeys();
// 非阻塞式获取就绪的通道,如若没有就绪的通道则会立即返回
public abstract int selectNow() throws IOException;
// 在指定时间内,阻塞获取已注册的通道中准备就绪的通道数量
public abstract int select(long timeout) throws IOException;
// 获取已注册的通道中准备就绪的通道数量(阻塞式)
public abstract int select() throws IOException;
// 唤醒调用Selector.select()方法阻塞后的线程
public abstract Selector wakeup();
// 关闭创建的选择器(不会关闭通道)
public abstract void close() throws IOException;
}
复制代码
当想要实现非阻塞式IO时,那必然需要用到Selector选择器,它可以帮我们实现一个线程管理多个连接的功能。但如若想要使用选择器,那需先将对应的通道注册到选择器上,然后再调用选择器的select方法去监听注册的所有通道。
不过在向选择器注册通道时,需要为通道绑定一个或多个事件,注册后选择器会根据通道的事件进行切换,只有当通道读/写事件发生时,才会触发读写,因而可通过Selector选择器实现一条线程管理多个通道。当然,选择器一共支持4种事件:
- ①
SelectionKey.OP_READ/1:读取就绪事件,通道内的数据已就绪可被读取。 - ②
SelectionKey.OP_WRITE/4:写入就绪事件,一个通道正在等待数据写入。 - ③
SelectionKey.OP_CONNECT/8:连接就绪事件,通道已成功连接到服务端。 - ④
SelectionKey.OP_ACCEPT/16:接收就绪事件,服务端通道已准备好接收新的连接。
当一个通道注册时,会为其绑定对应的事件,当该通道触发了一个事件,就代表着该事件已经准备就绪,可以被线程操作了。当然,如果要为一条通道绑定多个事件,那可通过位或操作符拼接:
int event = SelectionKey.OP_READ | SelectionKey.OP_WRITE;
复制代码
一条通道除开可以绑定多个事件外,还能注册多个选择器,但同一选择器只能注册一次,如多次注册相同选择器就会报错。
注意:
①并非所有的通道都可使用选择器,比如FileChannel无法支持非阻塞特性,因此不能与Selector一起使用(使用选择器的前提是:通道必须处于非阻塞模式)。
②同时,并非所有的事件都支持任意通道,比如OP_ACCEPT事件则仅能提供给ServerSocketChannel使用。
OK~,简单了解了选择器的基础概念后,那如何使用它实现非阻塞模型呢?如下:
// ----NIO服务端实现--------
public class NioServer {
public static void main(String[] args) throws Exception {
System.out.println(">>>>>>>...NIO服务端启动...>>>>>>>>");
// 1.创建服务端通道、选择器与字节缓冲区
ServerSocketChannel ssc = ServerSocketChannel.open();
Selector selector = Selector.open();
ByteBuffer buffer = ByteBuffer.allocate(1024);
// 2.为服务端绑定IP地址+端口
ssc.bind(new InetSocketAddress("127.0.0.1",8888));
// 3.将服务端设置为非阻塞模式,同时绑定接收事件注册到选择器
ssc.configureBlocking(false);
ssc.register(selector, SelectionKey.OP_ACCEPT);
// 4.通过选择器轮询所有已就绪的通道
while (selector.select() > 0){
// 5.获取当前选择器上注册的通道中所有已经就绪的事件
Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
// 6.遍历得到的所有事件,并根据事件类型进行处理
while (iterator.hasNext()){
SelectionKey next = iterator.next();
// 7.如果是接收事件就绪,那则获取对应的客户端连接
if (next.isAcceptable()){
SocketChannel channel = ssc.accept();
// 8.将获取到的客户端连接置为非阻塞模式,绑定事件并注册到选择器上
channel.configureBlocking(false);
int event = SelectionKey.OP_READ | SelectionKey.OP_WRITE;
channel.register(selector,event);
System.out.println("客户端连接:" + channel.getRemoteAddress());
}
// 9.如果是读取事件就绪,则先获取对应的通道连接
else if(next.isReadable()){
SocketChannel channel = (SocketChannel)next.channel();
// 10.然后从对应的通道中,将数据读取到缓冲区并输出
int len = -1;
while ((len = channel.read(buffer)) > 0){
buffer.flip();
System.out.println("收到信息:" +
new String(buffer.array(),0,buffer.remaining()));
}
buffer.clear();
}
}
// 11.将已经处理后的事件从选择器上移除(选择器不会自动移除)
iterator.remove();
}
}
}
// ----NIO客户端实现--------
public class NioClient {
public static void main(String[] args) throws Exception {
System.out.println(">>>>>>>...NIO客户端启动...>>>>>>>>");
// 1.创建一个TCP类型的通道并指定地址建立连接
SocketChannel channel = SocketChannel.open(
new InetSocketAddress("127.0.0.1",8888));
// 2.将通道置为非阻塞模式
channel.configureBlocking(false);
// 3.创建字节缓冲区,并写入要传输的消息数据
ByteBuffer buffer = ByteBuffer.allocate(1024);
String msg = "我是熊猫!";
buffer.put(msg.getBytes());
// 4.将缓冲区切换为读取模式
buffer.flip();
// 5.将带有数据的缓冲区写入通道,利用通道传输数据
channel.write(buffer);
// 6.传输完成后情况缓冲区、关闭通道
buffer.clear();
channel.close();
}
}
复制代码
在如上案例中,即实现了一个最简单的NIO服务端与客户端通信的案例,重点要注意:注册到选择器上的通道都必须要为非阻塞模型,同时通过缓冲区传输数据时,必须要调用flip()方法切换为读取模式。
OK~,最后简单叙述一下缓冲区、通道、选择器三者关系:
如上图所示,每个客户端连接本质上对应着一个Channel通道,而一个通道也有一个与之对应的Buffer缓冲区,在客户端尝试连接服务端时,会利用通道将其注册到选择器上,这个选择器则会有一条对应的线程。在开始工作后,选择器会根据不同的事件在各个通道上切换,对于已就绪的数据会基于通道与Buffer缓冲区进行读写操作。
简单而言,在这三者之间,
Buffer负责存取数据,Channel负责传输数据,而Selector则会决定操作那个通道中的数据。