netty基础-JavaIO

121 阅读16分钟
最近在学习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 , SelectorBuffer 等抽象。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:读写网络IOUDP数据的通道抽象类。
  • SocketChannel:读写网络IOTCP数据的通道抽象类。
  • ServerSocketChannel:类似于BIOServerSocket,用于监听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选择器

SelectorNIO的核心组件,它可以负责监控一个或多个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则会决定操作那个通道中的数据。