网络编程–OS层级NIO的Channel和Buffer
上篇文章讲述了BIO同步阻塞模型,大家都知道正是因为阻塞的原因,针对多连接,必须要用多线程去处理每一个连接,形成了每连接对应每线程的线程。由此,在多连接的场景下,过多的线程会线程内存浪费以及CPU的调度消耗。
于是内核需要升级,将accept 和 read调用能设置成非阻塞的。服务器端建立连接后,内核程序会把该线程返回的文件描述符fd打上非阻塞标记NONBLOCKING。先看下抓包效果
由上面我们可以看出来,操作系统内核升级之后,确实提供了,不阻塞的方式。
既然这样的话我们就可以通过一个线程去处理多个连接了,大致的实现模式是这样的:
通过一次while去接受连接以及处理连接读写,因为accept和recv都不阻塞了。
以上是C程序大致的伪代码模型,下面说说具体java中怎么实现让设置accept和recv不阻塞,也就是打上NONBLOCKING标记。
java 对于socketchannel提供socketChannel.configureBlocking(false)的方法,去实现非阻塞。看下源码:
通过源码也是可以清晰的看出来,最后就是通过外部方法configureBlocking的调用,去实现的非阻塞设置,参数 FileDescriptor 文件描述符,就是对指定的文件描述符是否标记为非阻塞状态,和上面上述的相对应。
在jdk1.4之后引入了新的IO–NEW NIO,其中包括Channel、Buffer、Selector三大组件。今天主要讲解下Channel和Buffer,以及实现通讯。因为Selector主要是对多路复用器的封装,留在我们后面讲解,正好把BIO的演变过程说清楚。
Channel 通道
通道表示打开到 IO 设备(例如:文件、套接字)的连接。可以理解为客户端 – 服务端,之间建立起的路。起到了数据读写的作用,但是Channel是不能直接往两端写的,选要一个缓冲区Buffer。
![在这里插入图片描述](https://img-blog.csdnimg.cn/20210531205912569.png
对于以前我们BIO,它的数据读写时通过数据流的形式,比如输入流、输出流。
但是NIO他是基于通道和缓冲区去读写数据的。有什么优势呢?(此NIO暂时不带seletor)
- Channel是双向的既可以从缓冲区读,也往缓冲区可以写。(虽然通道是双向的。但输入流的通道只能用于读取数据到缓冲区,输出流的通道用于把缓冲区数据写入通道。)
- Java IO面向流意味着每次从流中读一个或多个字节,直至读取所有字节,它们没有被缓存在任何地方;而NIO时基于通道和缓冲区的,可以前后移动缓冲区数据,灵活性很强。
- Buffer中还提供了一一种文件映射mmap技术,减少了一次文件从用户缓冲区到内核缓冲区的copy过程。(下面到Buffer时候会说到)
- 非阻塞IO(Non Blocking IO)
Channel组件的实现一共分为以下几种:
- FileChannel(file) – 主要针对文件操作的通道,上面说到文件映射内存区,也只有它才能创建。而FileChannel是不能设置为非阻塞的,因为它没有继承AbstractSelectableChannel
- DatagramChannel(UDP) – 通过UDP读写网络中的数据,用的少
- SocketChannel(TCP) — TCP网络通讯客户端通道,可以设置为非阻塞
- ServerSocketChannel(TCP) – TCP网络通讯服务器端通道,可以设置为非阻塞
代码简单演示下FileChannel的使用:
public static void main(String[] args) throws IOException {
// 先拿到一个文件流,输入流
FileInputStream fileInputStream = new FileInputStream("F:\\ceshi.txt");
// 得到输入流对应的读通道
FileChannel fileChannel =fileInputStream.getChannel();
// 申请一个缓冲区
ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
// 将通道数据读取到缓冲区
fileChannel.read(byteBuffer);
// 读取缓冲区数据
System.out.println(new String(byteBuffer.array()));
// 切换模式
byteBuffer.flip();
// 将读取到的数据写入文件
// 拿到有个文件输出流
FileOutputStream outputStream = new FileOutputStream("F:\\ceshiout.txt");
// 创建写通道
FileChannel fileChannelos =outputStream.getChannel();
// 通道数据写入缓存区
fileChannelos.write(byteBuffer);
}
代码简单演示下ServerSocketChannel的创建和非阻塞设置(结合buffer下面会演示详细的):
// 1. 创建套接字通道
ServerSocketChannel ss = ServerSocketChannel.open();
// 2.将该通道设置为非阻塞模式 --> 源码父类AbstractSelectableChannel 中 configureBlocking 实现了非阻塞设置
ss.configureBlocking(true);
// 3.绑定连接端口
ss.bind(new InetSocketAddress("127.0.0.1",9999));
Buffer缓冲区
缓冲区是一块可以写入数据,然后可以从中读取数据的内存,本质上就是一个数组。这块内存被包装成NIO Buffer对象,并提供了一组方法,用来方便的访问该块内存。
个人觉得Buffer是NIO相对核心的组件。
刚才说了Channel是不能够直接读写客户端和服务器端数据的,中间需要有一个buffer缓冲区存储数据,让Channel读写。
从数据类型上区分NIO Buffer主要分为以下几类:
- ByteBuffer (常用)
- CharBuffer
- DoubleBuffer
- FloatBuffer
- IntBuffer
- LongBuffer
- ShortBuffer
这些Buffer覆盖了你能通过IO发送的基本数据类型:byte、short、int、long、float、double和char。
说一下Buffer数组结构和相关的一些方法:
(网上找了个图)
- capacity
作为一个内存块,Buffer有个固定的最大值,就是capacity。Buffer只能写capacity个byte、long、char等类型。一旦Buffer满了,需要将其清空(通过读数据或者清除数据)才能继续写数据往里写数据 - position
当写数据到Buffer中时,position表示当前的位置。初始的position值为0。当一个byte、long等数据写到Buffer后,position会向前移动到下一个可插入数据的Buffer单元。position最大可为capacity – 1。当读取数据时,也是从某个特定位置读。当将Buffer从写模式切换到读模式,position会被重置为0。当从Buffer的position处读取数据时,position向前移动到下一个可读的位置。 - limit
在写模式下,Buffer的limit表示最多能往Buffer里写多少数据。写模式下,limit等于capacity。当切换Buffer到读模式时, limit表示你最多能读到多少数据。因此,当切换Buffer到读模式时,limit会被设置成写模式下的position值。 - clear
清空缓冲区并返回对缓冲区的应用,其实是将位置设置到0,不会删除缓冲区数据,下次数据进来会更新。 - hasReamining()
判断缓冲区理是否还有元素 - mark()
对缓冲区设置标记 - remaining()
返回position和limit位置之间的元素个数 - reset
将位置position 转到以前设置的mark所在的位置 - flip
为缓冲区的界限设置为当前位置,并将当前位置重置为0。(切换模式)
以上方法的使用数组的变化网上很多文章,大家可以去看看,我这边就不做分析了,简单放下代码示例看看:
// 1. 分配一个缓冲区大小为10的字节数组
ByteBuffer buffer = ByteBuffer.allocate(10);
System.out.println(buffer.position()); // 返回当前指针位置 0
System.out.println(buffer.limit()); // 返回缓冲区最大限制 10
System.out.println(buffer.capacity()); // 返回缓冲区容量大小 10
// 向缓冲区添加元素
buffer.put("hello".getBytes());
System.out.println(buffer.position()); // 返回当前指针位置 0
System.out.println(buffer.limit()); // 返回缓冲区最大限制 10
System.out.println(buffer.capacity()); // 返回缓冲区容量大小 10
// 读取缓冲区数据
buffer.flip(); // 为缓冲区的界限设置为当前位置,并将当前位置重置为0(切换读模式)
Buffer站在内存分配的角度分为:
1.HeapByteBuffer : 堆内缓冲区。(就是buffer缓冲区分配在堆内–用户空间)
ByteBuffer buffer = ByteBuffer.allocate(10);
- DirectByteBuffer :对外缓冲区。 (就是buffer缓冲区分配在堆外–用户空间)
ByteBuffer buffer = ByteBuffer.allocateDirect(10);
![在这里插
同时
- MappedByteBuffer 文件映射缓冲区(核心),它也属于对外内存。基于mmap+write方式实现。
好处什么?
用户要想获取磁盘信息,必须系统调用进行一次用户态和内核态的切换,将文件从磁盘通过DMA复制到内核缓冲区,再copy到用户缓存区。而mmap系统调用就是创建了一个用户和内核缓冲区共享的内存空间,就免去了一次拷贝的过程。
具体讲述如下:
普通的IO要想从硬盘获取文件信息,然后再输出到网卡的过程是这样的。
经历了4次copy,其中2次是DMA复制,2次是CPU复制。
在NIO中零拷贝通过**fileChannel.map()**创建一个MappedByteBuffer缓冲区,底层通过mmap+write方式实现,文件映射之后是这样的:
从以上的4次copy 编程了3次copy,读写性能大大提高了,用户端直接读写磁盘(当然了中间还有个DMA),不需要频繁耗费性能去系统调用切换内核态copy文件。(CPU拷贝也是很消耗CPU性能的工作)
- MMAP 有哪些注意事项?
MMAP 使用时必须实现指定好内存映射的大小,mmap 在 Java 中一次只能映射 1.5~2G 的文件内存,其中RocketMQ中限制了单文件1G来避免这个问题 MMAP 可以通过 force() 来手动控制,但控制不好也会有大麻烦 MMAP 的回收问题,当MappedByteBuffer 不再需要时,可以手动释放占用的虚拟内存,但使用方式非常的麻烦。
fileChannel.map之后会立刻获得一个 1.5G 的文件,但此时文件的内容全部是 0(字节 0),之后对内存中 MappedByteBuffer 做的任何操作,都会被最终映射到文件之中。
以上是NIO中零拷贝实现原理。通过fileChannel.map()创建共享空间,系统调用mmap技术实现。
现在补充下DMA知识:
直接内存访问 DMA (Direct Memory Access):
DMA允许外设设备和内存存储器之间直接进行IO数据传输,其过程不需要CPU的参与。
现在代码看下NIO中MappedByteBuffer的用法:
//MappedByteBuffer 便是MMAP的操作类(获得一个 1.5G 的文件)
MappedByteBuffer mappedByteBuffer = fileChannel.map(FileChannel.MapMode.READ_WRITE, 0, 1.5 * 1024 * 1024 * 1024);
// write
byte[] data = new byte[4];
int position = 8;
mappedByteBuffer.put(data)
//复制图片,利用直接缓存区
public void test() throws Exception{
FileChannel inChannel = FileChannel.open(Paths.get("D:\\1.jpg"), StandardOpenOption.READ);
FileChannel outChannel = FileChannel.open(Paths.get("D:\\2.jpg"), StandardOpenOption.READ,StandardOpenOption.WRITE,StandardOpenOption.CREATE);
outChannel.transferFrom(inChannel,0, inChannel.size());
inChannel.close();
outChannel.close();
}
最后给上网络非阻塞IO代码实现(不用多路复用器):
服务器端:
public static void main(String[] args) throws IOException {
List<SocketChannel> clients = new ArrayList<SocketChannel>();
// 1. 创建套接字通道
ServerSocketChannel ss = ServerSocketChannel.open();
// 2.将该通道设置为非阻塞模式 --> 源码父类AbstractSelectableChannel 中 configureBlocking 实现了非阻塞设置
ss.configureBlocking(true);
// 3.绑定连接端口
ss.bind(new InetSocketAddress("127.0.0.1",9999));
// 4.接受客户端连接
while (true){
SocketChannel channel = ss.accept(); // 非阻塞
// System.out.println("===============接受连接=================="+channel);
if(channel==null){
}else{
// 设置客户端通道为非阻塞
channel.configureBlocking(false);
System.out.println(channel.socket().getPort());
// 将一个个连接加入到集合中
clients.add(channel);
}
// 遍历集合对一个个连接,看是否有消息过来
for(SocketChannel socketChannel : clients){
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(1024);
if(socketChannel==null){
continue;
}
// 创建一个缓冲区
int num = socketChannel.read(byteBuffer); // 非阻塞
// System.out.println("===============服务连接数据=================="+num);
if(num>0){
byteBuffer.flip();
byte[] bytes = new byte[byteBuffer.limit()];
byteBuffer.get(bytes);
System.out.println(new String(bytes));
byteBuffer.clear();
}
}
}
}
客户端:
public static void main(String[] args) throws IOException {
// 1. 创建客户端套接字通道,且设置非阻塞
SocketChannel socketChannel = SocketChannel.open();
socketChannel.configureBlocking(false);
// 2. 连接服务器端
socketChannel.connect(new InetSocketAddress("127.0.0.1", 9999));
System.out.println(socketChannel);
if (socketChannel.finishConnect()){
System.out.println("链接成功");
// 3. 发送信息到服务器端
// 创建缓冲区
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(1024);
// 向缓冲区写入数据
byteBuffer.put("你好啊".getBytes());
byteBuffer.flip();
// 缓冲区写入通道
socketChannel.write(byteBuffer);
socketChannel.close();
}else {
System.out.println("没连上");
}
}
总结:
NIO中通过channel和buffer的实现,可以把accept和read设置为非阻塞,这样可以规避BIO模型中多线程CPU消耗和线程浪费问题。
但是同时我们也发现了其中的弊端:
如果有10000个连接进来,虽然不阻塞了,但是每次我都要对着10000个连接进行全面的遍历,去获取有事件的连接数据,可能只有1 2个连接有消息事件,但是却要全部遍历了。而且大家都清楚,每一次遍历读取数据都是一次系统调用,所以这就产生了很多无意义的性能消耗,浪费事件和资源。
解决思想:
如果有10000个连接进来了,我每次不去遍历所有连接,而是去遍历有消息事件的连接就行了哈。
这就是多路复用器的设计思想,下篇介绍!