Java网络编程——NIO三大组件Buffer、Channel、Selector

2022-08-04 18:33:20 浏览数 (1)

Java NIO(Java Non-Blocking IO)也就是非阻塞IO,说是非阻塞IO,其实NIO也支持阻塞IO模型(默认就是),相对于BIO来说,NIO最大的特点是支持IO多路复用模式,可以通过一个线程监控多个IO流(Socket)的状态,来同时管理多个客户端,极大提高了服务器的吞吐能力。

在NIO中有3个比较重要的组件:Buffer、Channel、Selector

Buffer

Buffer顾名思义,缓冲区,类似于List、Set、Map,实际上它就是一个容器对象,对数组进行了封装,用数组来缓存数据,还定义了一些操作数组的API,如 put()、get()、flip()、compact()、mark() 等。在NIO中,无论读还是写,数据都必须经过Buffer缓冲区,如下图:

随便创建一个Buffer,再put两个字节:

代码语言:javascript复制
ByteBuffer byteBuffer = ByteBuffer.allocate(12);
byteBuffer.put((byte)'a');
byteBuffer.put((byte)'b'); 

发现这两个字节是被存到Buffer中一个叫hb的数组中了:

Buffer是所有缓存类的父类,对应实现有ByteBuffer、CharBuffer、IntBuffer、LongBuffer等跟ava基本数据类型对应的几个实现类:

一般最长用的就是ByteBuffer,创建 ByteBuffer 有两种方式:HeapByteBuffer 和 DirectByteBuffer:

(1)HeapByteBuffer:占用JVM堆内内存,不用考虑垃圾回收,属于用户空间,相对于DirectByteBuffer来说拷贝数据效率较低,会受到Full GC影响(Full GC后,可能需要移动数据位置)。创建HeapByteBuffer的方式为:

代码语言:javascript复制
ByteBuffer heapBuffer = ByteBuffer.allocate(1024);

(2)DirectByteBuffer:占用堆外内存,读写效率高(读数据可以减少一次数据的复制),初次分配效率较低(需要调用系统函数),不受JVM GC的影响,但使用时要注意垃圾回收,使用不当可能造成内存泄漏。创建DirectByteBuffer的方式为:

代码语言:javascript复制
ByteBuffer directBuffer = ByteBuffer.allocateDirect(1024); 

为了可以更灵活地读/写数据,Buffer中有几个比较重要的属性:

● 容量(capacity):即可以容纳的最大数据量;在缓冲区创建时被设定并且不能改变 ● 位置(position):当前读/写到哪个位置,下一次读/写就会从下一个位置开始,每次读写缓冲区数据时都会改变(累加),为下次读写作准备 ● 上限(limit):表示缓冲区的临时读/写上限,不能对缓冲区超过上限的位置进行读写操作,上限是可以修改的(flip函数)。读的时候读的是从position到limit之间的数据,写的时候也是从position位置开始写到limit位置。 ● 标记(mark):调用mark函数可以记录当前position的值(mark = position),以后再调用reset()可以让position重新恢复到之前标记的位置(position = mark)

用个例子来看下这几个属性在读/写数据过程中的变化

代码语言:javascript复制
public class BufferTest {
    public static void main(String[] args) throws IOException {
        // 1、初始化Buffer,先初始化一个长度为12的ByteBuffer,也就是创建了类型为byte一个长度为12的数组:
        ByteBuffer byteBuffer = ByteBuffer.allocate(12);
        System.out.println("【初始化ByteBuffer】 capacity: "   byteBuffer.capacity()   " position: "   byteBuffer.position()   " limit: "   byteBuffer.limit());

        // 2、写数据,写的过程中,每写入一个字节,position自增1,当写入8个字节数据后,position=8
        for (int i = 0; i < 8; i  ) {
            byteBuffer.put((byte) i);
        }
        System.out.println("【ByteBuffer写完数据】 capacity: "   byteBuffer.capacity()   " position: "   byteBuffer.position()   " limit: "   byteBuffer.limit());

        // 3、将Buffer由写模式转化为读模式
        byteBuffer.flip();
        System.out.println("【ByteBuffer调flip】 capacity: "   byteBuffer.capacity()   " position: "   byteBuffer.position()   " limit: "   byteBuffer.limit());

        // 4、读数据,如果依次读取了6个字节,那现在position就指向下标为6的位置,limit不变
        for (int i = 0; i < 6; i  ) {
            if (i == 3) {
                byteBuffer.mark();
            }
            byte b = byteBuffer.get();
        }
        System.out.println("【ByteBuffer读完数据】 capacity: "   byteBuffer.capacity()   " position: "   byteBuffer.position()   " limit: "   byteBuffer.limit());

        // 5、重置position
        byteBuffer.reset();
        System.out.println("【ByteBuffer调reset】 capacity: "   byteBuffer.capacity()   " position: "   byteBuffer.position()   " limit: "   byteBuffer.limit());
    }
}

① 初始化Buffer,先初始化一个长度为12的ByteBuffer,也就是创建了类型为byte一个长度为12的数组:

② 写数据,写的过程中,每写入一个字节,position自增1,当写入6个字节数据后,position=6,如下图:

③ 写数据转读数据,现在Buffer中一共有8个字节的数据。因为对Buffer的读/写,都是从position位置到limit位置进行读/写的。如果现在想读取Buffer中的数据,需要执行一下Buffer的flip()函数,把limit置为8(position的值),position重新置为0,这时候position到limit之间的数据才是有效的(我们想要读取的)数据。所以通常将Buffer由写模式转化为读模式时需要执行flip()函数:

④ 读数据,如果依次读取了6个字节,那现在position就指向下标为6的位置,limit不变:

⑤ 重置position,前面在position=3的时候,调用byteBuffer.mark();标记了一下当时position的值(mark=3),当读取完6个字节后,position=6。这时调用一下byteBuffer.reset()可以把position重置为当时mark的值(position=mark),也就是3:

ByteBuffer中常用的的方法还有很多:

代码语言:javascript复制
byteBuffer.put((byte) 'a'); //在position位置存入字符a对应的字节
byteBuffer.put(1, (byte) 5); //在1位置存入数字5对应的字节
byteBuffer.get();// 从position的位置读取一个字节的数据,读完后会导致position加1
byteBuffer.get(i);// 从position=i的位置读取一个字节的数据,读完后不会导致position加1
byteBuffer.reset(); // 重置position的值为mark
byteBuffer.position(5); // 重置position的值为5
byteBuffer.flip(); // 写完数据后,切换到读模式,把limit置为position的值,position置为0,
byteBuffer.clear(); // 清空ByteBuffer,position=0,limit=capacity
byteBuffer.compact(); // 读了一部分数据后,切换到写模式,会把未读的数据向前压缩,只留下有效数据(一般认为position~limit之间的数据为有效数据),比如原来pos=2,limit=8,capacity=12,执行compact()后,pos=6,limit=12,capacity=12

这里不再一一详细介绍。

Channel

Channel是对原 I/O 包中的流的模拟,到任何目的地(或来自任何地方)的所有数据都必须通过一个 Channel 对象,通道是双向的(一个Channel既可以读数据,也可以写数据),BIO中的InputStream/OutputStream是单向的(InputStream/OutputStream只能读/写数据)。

Channel 有文件通道和网络通道,文件通道的实现主要是FIleChannel,网络通道的实现主要有ServerSocketChannel(主要用于服务器接收客户端请求,类似于BIO中的ServerSocket)、SocketChannel(主要用户服务器和客户端直接的数据读写,类似于BIO中的Socket)、DatagramChannel(用于基于UDP协议的数据读写)。

FileChannel可以对文件进行读写,下面是个简单的例子:

代码语言:javascript复制
public class FileChannelTest {
    public static void main(String[] args) throws IOException {
        FileChannel fileChannel = FileChannel.open(Paths.get("/Users/danny/data/file/file.txt"), StandardOpenOption.WRITE, StandardOpenOption.READ);
        // 通过FileChannel从文件中读数据
        ByteBuffer readBuffer=ByteBuffer.allocate(10);
        while (fileChannel.read(readBuffer) != -1){
            while (readBuffer.hasRemaining()){
                byte b=readBuffer.get();
            }
        }
        // 通过FileChannel向文件中写数据
        ByteBuffer writeBuffer=ByteBuffer.allocate(10);
        writeBuffer.put("Data".getBytes());
        writeBuffer.flip();
        while (writeBuffer.hasRemaining()){
            fileChannel.write(writeBuffer);
        }
    }
}

ServerSocketChannel 主要用于服务器接收客户端请求,SocketChannel 主要用户服务器和客户端直接的数据读写,跟BIO中ServerSocket和Socket通信差不多: 服务端:

代码语言:javascript复制
public class ServerSocketChannelTest {
    public static void main(String[] args) throws IOException {
        ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
        serverSocketChannel.configureBlocking(true); // 设置阻塞模式为阻塞,默认就是true
        serverSocketChannel.bind(new InetSocketAddress(8080));
        ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
        SocketChannel socketChannel = serverSocketChannel.accept(); // 如果没有接收到新的客户端连接,这里会阻塞
        System.out.println("收到到客户端连接");
        int length = socketChannel.read(byteBuffer); // 如果读不到数据,这里会阻塞,无法处理其他Channel的读操作和连接请求
        System.out.println("读取到客户端数据:"   new String(byteBuffer.array(), 0, length));
    }
}

客户端:

代码语言:javascript复制
public class SocketChannelTest {
    public static void main(String[] args) throws IOException {
    	Socket socket = new Socket();
    	socket.connect(new InetSocketAddress("127.0.0.1", 8080));
    	System.out.println("连接服务端完成");
    	socket.getOutputStream().write(Constant.MESSAGE_128B.getBytes());
    	System.out.println("向服务端发送数据完成");
    	socket.close();
	}
}

Selector

选择器Selector相当于管家,管理所有的IO事件,通过Selector可以使一个线程管理多个Channel(也就是多个网络连接),当一个或多个注册到Selector上的Channel发生可读/可写事件时,Selector能够感知到并返回这些事件。

一个Channel可以注册到多个不同的Selector上,多个Channel也可以注册到同一个Selector上。当某个Channel注册到Selector上时,会包装一个SelectionKey(包含一对一的Selector和Channel)放到该Selector中,这些后面看源码的时候再仔细画图分析。

根据理解画了一张Selector在整个服务端和客户端交互中的作用的图,大致如下:

Selector可以作为一个观察者,可以把已知的Channel(无论是服务端用来监听客户端连接的ServerSocketChannel,还是服务端和客户端用来读写数据的SocketChannel)及其感兴趣的事件(READ、WRITE、CONNECT、ACCEPT)包装成一个SelectionKey,注册到Selector上,Selector就会监听这些Channel注册的事件(监听的时候如果没有事件就绪,Selector所在线程会被阻塞),一旦有事件就绪,就会返回这些事件的列表,继而服务端线程可以依次处理这些事件。

NIO使用了Selector,IO模型就是属于IO多路复用(同步非阻塞),可以同事检测多个IO事件,即使某一个IO事件尚未就绪,可以处理其他就绪的IO事件。同步体现在在Selector监听IO事件(Selector.select()方法)时,如果没有就绪事件,就会等待,不能做其他事;非阻塞体现在当某一个IO事件尚未就绪时,可以处理其他就绪的IO事件,比如在上图中,如果客户端2一直不发送数据,服务端也可以正常处理其他客户端的请求,而在BIO中(单线程环境),如果某个客户端连接到了服务端而迟迟不写数据,那么服务器端就会一直等待而无法及时接收其他客户端的请求。正是因为Selector,才可以让NIO在单线程的环境就能处理多个网络连接,为高并发编程打下基础。


转载请注明出处——胡玉洋 《Java网络编程——NIO三大组件Buffer、Channel、Selector》

0 人点赞