BIO、NIO编程深入理解与直接内存、零拷贝

发布于:2024-07-01 ⋅ 阅读:(19) ⋅ 点赞:(0)

网路编程基本常识

一. Socket

什么是Socket

  • Socket是对网络中不同主机上的应用进程之间进行双向通信的端点的抽象。
  • 它提供了应用层进程利用网络协议交换数据的机制,是应用程序与网络协议栈进行交互的接口。

说白了,Socket就是把TCP/IP协议族进行封装,然后暴露出简单的接口给应用进程使用。它一般是操作系统提供的功能,让应用程序可方便快捷的使用网络协议交换数据。用户使用的过程中,无需关注TCP/IP协议族过多的细节,Socket会组织数据,以符合指定的协议。

短链接和长连接

短链接和长连接是计算机网络通信中的两种不同连接方式,它们的主要区别在于连接的持久性和数据传输的方式。以下是关于短链接和长连接的详细解释:

短链接(Short Connection)

定义
-短链接是指在数据传送过程中,只在需要发送数据时,才去建立一个连接,数据发送完成后,则断开此连接。也就是说,每次连接只完成一项业务的发送。

特点

  1. 需要时建立连接:只有当通信双方有数据需要交互时,才会建立连接。
  2. 单次数据传输:每次连接只用于完成一项业务的数据传输,传输完成后立即断开连接。
  3. 节省通道:对于业务频率不高的场合,能节省通道的使用。
  4. 连接开销大:由于每次发送业务时都需要建立一次连接,因此连接建立的过程开销较大。

示例

  • 银行系统通常使用短链接,因为银行业务的交互频率相对较低,且每次交互的数据量不大。
长连接(Long Connection)

定义

  • 长连接是计算机网络通信中的一种连接方式,指的是在客户端与服务器之间建立的持久性连接。这种连接在建立后可以保持一段时间,使得客户端与服务器之间可以持续交换数据,而无需频繁地重新建立连接。

特点

  1. 保持连接状态:长连接的服务器可以跟踪客户端的连接状态,无需频繁重新建立连接,从而减少了连接的开销。
  2. 持久通信:长连接可以使得客户端与服务器之间持续进行数据传输,避免了每次传输数据都需要重新建立连接的过程。
  3. 双向通信:长连接可以实现双向通信,服务器可以主动向客户端推送数据,使得实时通信等应用得以实现。
  4. 节省带宽:由于长连接可以保持连接状态,避免了频繁连接和断开的过程,从而节省了通信中携带的控制信息的带宽开销。

示例

  • 实时通信应用(如微信、QQ等)通常使用长连接,以保持用户之间的实时通信和数据交换。
总结

短链接和长连接各有其特点和应用场景。短链接适用于业务频率不高、单次数据传输量小的场合,能够节省通道资源;而长连接则适用于需要持续通信、实时数据传输的场合,能够提高通信效率和节省带宽资源。

二. 网络编程的基本模型

首先看一个生活中的场景,比如某个了解快乐的人,为了给更多人提供快乐。开了一家足浴按摩店。地址是快乐大街666栋,门牌号是666号。当有顾客通过地址和门牌号进店的时候,前台发现有人进门了,马上上去接待,但前台肯定是不会具体技术的,然后就通知某个技师为顾客服务。之后就是顾客和技师一对一自行交流了。如下面的图:
image.png
上面的场景就和网络编程中的场景很相似。
我们知道网络通讯中,提供服务的一方叫做服务端。当看见一个类包含Server或者ServerSocket一般就是给服务端网络服务用的,而若只是包含Socket一般是客户端用的。
对于服务端来说,所谓的ServerSocket其实就是一个暴露出地址的场所。它必须绑定一个IP,如上面的地址:快乐大街666栋。并且需要监控一个端口,像上面的门牌号666。以保证有连接通过IP和端口连接过来时能够及时响应,如上面场景中顾客上门。
当客户上门了,真正提供服务的还是技师。就像上面说的前台并不会具体的技术,所以会通知到技师进行服务。在编程方面来说,就是ServerSocket并不负责具体的网络读写,ServerSocket就只是负责接收客户端连接后,新启一个socket来和客户端进行沟通。这一点对所有模式的通信编程都是适用的
从上面的说明我们知道,在网络通信编程里,我们最关系的无非就是三件事:连接,读数据和写数据。其中连接可以分为客户端连接服务器,服务器等待和接收连接。服务器向外提供IP和监听端口,客户端通过连接操作向服务端的地址和端口发送连接请求,如果成功连接,双方就可以进行网络通讯。

Java原生网络编程

一. BIO

什么是BIO

BIO,即Blocking I/O。意为阻塞I/O。
BIO基本就是上面示例的基本实现。ServerSocket绑定IP监听端口。通过accept()方法得到连接请求,然后生成一个新的Socket和客户端通讯。因为阻塞的原因,所以想支持多个客户端同时请求,一般会在客户端访问过来后,将生成的Socket放于线程中执行。如图:
image.png
这么说可能并不直观。这里写一份BIO的代码,看一下这个阻塞到底阻塞在什么位置。

代码演示

建立客户端代码如下:

public class ServerTest {
    public static void main(String[] args) throws IOException {
        // 服务端实例化一个ServerSocket
        ServerSocket serverSocket = new ServerSocket();
        // 绑定本机IP,并监听 8888 端口;
        serverSocket.bind(new InetSocketAddress(8888));
        System.out.println("服务端启动成功");
        int connectCount = 0;

        try {
            // 循环监听客户请求
            while (true) {
                Socket socket = serverSocket.accept();

                System.out.println("连接数:" + connectCount++);

                try (ObjectInputStream inputStream = new ObjectInputStream(socket.getInputStream());
                     ObjectOutputStream outputStream = new ObjectOutputStream(socket.getOutputStream())) {
                    String msg = inputStream.readUTF();
                    System.out.println("收到客户端消息:" + msg);

                    String response = "服务器收到消息:" + msg;

                    outputStream.writeUTF(response);
                    outputStream.flush();

                } catch (Exception e) {
                    e.printStackTrace();
                } finally {
                    try {
                        socket.close();
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                }
            }
        } finally {
            serverSocket.close();
        }

    }
}

客户端如下:

public class Client {

    public static void main(String[] args) throws IOException {
        // 客户端创建Socket实例
        Socket socket = null;

        //实例化与服务端通信的输入输出流
        ObjectOutputStream output = null;
        ObjectInputStream input = null;

        //服务器的通信地址
        InetSocketAddress addr = new InetSocketAddress("127.0.0.1",8888);

        try{
            socket = new Socket();
            socket.connect(addr);//连接服务器

            System.out.println("客户端:连接服务器成功");
            output = new ObjectOutputStream(socket.getOutputStream());
            input = new ObjectInputStream(socket.getInputStream());
            System.out.println("准备发送消息.....");

            /*向服务器输出请求*/
            output.writeUTF("我是客户端");
            output.flush();

            //接收服务器的输出
            System.out.println(input.readUTF());
        }finally{
            if (socket!=null) socket.close();
            if (output!=null) output.close();
            if (input!=null) input.close();

        }
    }
}

首先启动服务端,控制台打印信息如下:
image.png
由此可以知道,服务端启动后,代码阻塞在Socket socket = serverSocket.accept();,等待客户端连接。
此时启动客户端,可以看到服务端和客户端的控制台打印消息分别如下:
image.png
此时有一个客户端连接到服务端,服务端不再阻塞在accept,继续向下执行,打印相关信息,并回复客户端信息。客户端发送消息并受到回复后关闭连接,此时服务端又重新阻塞与accept,等待新的连接。

接下来在进行另外一次实验,服务端依然正常启动,但启动客户端的时候,在发送消息前打一个断点,即让客户端阻塞住,先不发送消息。此时再启动一个客户端,可以看到如下效果:
第一个客户端启动后阻塞于断点处:
image.png
此时的服务端打印为:
image.png
即连接已经建立。此时再启动一个客户端,可以看到新启动的客户端和服务端打印信息如下:
image.png
注意:新启的客户端时没有打印断点的,但发现也并没有发送数据到服务端。而服务端对第二个启动的客户端更是毫无知觉。看到上面的打印信息,可能会有一个疑惑的点,新启的客户端明明打印出“连接服务器成功”的字样了,为什么服务端没有感知,连接数依然为1。这个是因为,客户端判断连接成功,是只TCP的三次握手已经通过了,但服务端此时还在阻塞着。socket的accept还没有感知到。
好的,此时我们放开第一个客户端的断点,看看结果:
image.png
结果是,两个客户端都正常发送消息,并接收到服务端的消息,最后关闭了连接。

总结一下

我们说BIO是阻塞的,那么它到底阻塞在了什么位置。通过上面的代码演示,不难总结出:
在Java网络编程中,BIO(Blocking IO,阻塞IO)的阻塞主要体现在两个关键位置:

  1. 服务器启动后的等待连接阶段
  • 当一个服务器启动并处于就绪状态时,主线程会一直等待客户端的连接。在这个过程中,主线程是处于阻塞状态的,也就是说,它不会执行其他任务,而是会持续监听是否有新的客户端连接请求。
  1. 连接建立后的数据传输阶段
  • 一旦客户端与服务器建立了连接,在读取socket信息之前,也是处于阻塞状态的。换句话说,线程会一直等待客户端发送数据,直到接收到数据或连接断开为止。

这两个阻塞阶段共同构成了Java BIO的基本工作模式,也就是同步阻塞的网络模型。值得注意的是,虽然代码中可能会采用连接池等技术来优化线程的使用,减少线程重复创建的开销,但这种优化对于解决BIO本身的阻塞特性是有限的。

基于线程的BIO

为了解决上面第二次实验的情况,即必须等待上一个客户端完成数据传输后,后面的客户端才能正常连接到服务器交互数据。一般在服务端会启动一个Acceptor线程只负责建立连接,当连接建立后,新启一个线程用来处理客户端的连接。即一请求一应答模式,当处理完和客户端的通讯后,线程销毁。
看下面的例子,我们将服务端代码更新如下:

public class ServerThreadTest {

    static int connectCount = 0;
    public static void main(String[] args) throws IOException {
        // 服务端实例化一个ServerSocket
        ServerSocket serverSocket = new ServerSocket();
        // 绑定本机IP,并监听 8888 端口;
        serverSocket.bind(new InetSocketAddress(8888));
        System.out.println("服务端启动成功");

        try {
            // 循环监听客户请求
            while (true) {
                new ServerThread(serverSocket.accept()).start();
            }
        } finally {
            serverSocket.close();
        }

    }

    public static class ServerThread extends Thread {


        Socket socket;
        ServerThread(Socket socket){
            this.socket = socket;
            connectCount++;
            System.out.println("连接数:" + connectCount);
        }
        @Override
        public void run(){
            try (ObjectInputStream inputStream = new ObjectInputStream(socket.getInputStream());
                 ObjectOutputStream outputStream = new ObjectOutputStream(socket.getOutputStream())) {
                String msg = inputStream.readUTF();
                System.out.println("收到客户端消息:" + msg);

                String response = "服务器收到消息:" + msg;

                outputStream.writeUTF(response);
                outputStream.flush();

            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                try {
                    socket.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}

再如上面第二次实验一样运行,此时发现客户端之前并未阻塞。
但思考一下,这种每个客户端对应一个线程的方式有什么问题?
其最大的问题就是资源的消耗,如果连接数很少的情况下还可以。但如果服务有上万甚至几十万上百万的并发访问。那线程的创建和销毁对服务器是一个极大的负担。此时大概率会导致服务端宕掉。
有没有什么方法可以避免这种线程反复创建和销毁导致的资源消耗?看下面的例子。

基于线程池的BIO

为了改进上面那种一连接一线程的模式,减少系统的资源消耗。我们可以用线程池的方式,实现一个线程管理多个客户端的请求。但底层还是同步阻塞I/O模式,这种方式通常被称为“伪异步I/O模型”。接下来看代码。
改进后的Server端代码如下:

public class ServerThreadPoolTest {

    private static ExecutorService executorService = Executors.newFixedThreadPool( 2);

    static int connectCount = 0;
    public static void main(String[] args) throws IOException {
        // 服务端实例化一个ServerSocket
        ServerSocket serverSocket = new ServerSocket();
        // 绑定本机IP,并监听 8888 端口;
        serverSocket.bind(new InetSocketAddress(8888));
        System.out.println("服务端启动成功");

        try {
            // 循环监听客户请求
            while (true) {
                executorService.execute(new ServerThread(serverSocket.accept()));
            }
        } finally {
            serverSocket.close();
        }

    }

    public static class ServerThread extends Thread {


        Socket socket;
        ServerThread(Socket socket){
            this.socket = socket;
            connectCount++;
            System.out.println("连接数:" + connectCount);
        }
        @Override
        public void run(){
            System.out.println("线程:" + Thread.currentThread().getName());
            try (ObjectInputStream inputStream = new ObjectInputStream(socket.getInputStream());
                 ObjectOutputStream outputStream = new ObjectOutputStream(socket.getOutputStream())) {
                String msg = inputStream.readUTF();
                System.out.println("收到客户端消息:" + msg);

                String response = "服务器收到消息:" + msg;

                outputStream.writeUTF(response);
                outputStream.flush();

            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                try {
                    socket.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}

这里用到了newFixedThreadPool线程池,并设置线程池线程数为2。
为什么不使用CachedThreadPool线程池?因为其不限制线程数量,如果使用的话和直接使用线程效果差不多了,依然是一连接一线程。而使用FixedThreadPool我们就有效的控制了线程的最大数量,保证了系统有限的资源的控制。
此时运行服务端后,再多次运行客户端,服务端控制台打印如下信息:
image.png
可以看到,现在线程是会服用的,当一个新的客户端连接进来,线程池中若有空闲线程则直接使用空闲线程,需要注意的是,若连接数过多,怎会到队列中等待。
关于线程池的详细信息这里就不多介绍了,感兴趣的朋友可以自己查询相关资料。

二. NIO

什么是NIO

NIO,全称Non-blocking I/O(非阻塞I/O),也被称为New I/O,是Java领域中的一种同步非阻塞的I/O模型。以下是关于NIO的大概解释:

  1. 定义与特性
    • NIO是一种基于事件驱动的异步I/O模型,它允许一个线程处理多个连接的I/O操作。
    • 传统的阻塞I/O模型中,每个I/O操作都会阻塞当前线程,直到操作完成。而NIO通过非阻塞I/O操作,避免了为每个连接分配一个线程的开销,大大提高了系统的并发能力。
  2. 核心组成部分
    • Channel(通道):通道是数据的载体,可以是文件、网络连接等。在NIO中,通道是双向的,既可以读取数据也可以写入数据。
    • Buffer(缓冲区):缓冲区是一个固定大小的内存块,用于存储要读取或写入的数据。在NIO中,所有的数据都是通过缓冲区来处理的,读写操作都是通过操作缓冲区来实现的。
    • Selector(选择器):选择器是用来监听通道事件的对象,它可以注册一个或多个通道,并监听这些通道上的特定事件,比如读取数据、写入数据等。一旦某个事件到达,选择器就会通知应用程序进行处理。
  3. 工作原理
    • 创建一个Selector,并将其注册到某个线程上。
    • 通过Selector注册Channel,并指定Channel上的I/O操作(如读、写、连接、接受等)。
    • 当某个Channel上的I/O操作就绪时,Selector会通知相应的线程进行处理。
    • 线程通过操作Buffer来进行数据的读写。
  4. 应用场景
    • NIO被广泛应用于大型应用服务器,特别是在需要解决高并发与大量连接、I/O处理问题的场景中。

NIO于BIO的差别

NIO和BIO的主要区别可以简单总结如下:

  1. 阻塞与非阻塞
    • BIO:阻塞式I/O,一个线程只能处理一个I/O操作,等待数据准备好期间线程会被阻塞。
    • NIO:非阻塞式I/O,一个线程可以处理多个I/O操作,当某个I/O操作未准备好时,线程不会被阻塞,可以继续做其他事情。
  2. 通道与流
    • BIO:使用流(Stream)进行数据传输,流是单向的。
    • NIO:使用通道(Channel)进行数据传输,通道是双向的,既可以读也可以写。
  3. 缓冲区
    • NIO:引入了缓冲区(Buffer)的概念,数据先读入缓冲区再进行处理,提高了效率。
    • BIO:没有缓冲区,数据直接读写。
  4. 选择器
    • NIO:提供了选择器(Selector),允许一个线程同时检查多个通道的状态,实现I/O的多路复用。
    • BIO:没有选择器,一个线程只能处理一个I/O操作。
  5. 性能
    • NIO:在处理大量并发连接时,使用较少的线程就可以完成I/O操作,性能更高。
    • BIO:在处理大量并发连接时,需要为每个连接创建一个线程,可能导致系统资源消耗过多。

简而言之,NIO相比BIO更加高效,能够更好地处理高并发场景。

Reactor模式

说到NIO就不得不提Reactor模式,因为NIO正式通过该模式实现了同步非阻塞的I/O操作。

什么是Reactor

Reactor模式是一种事件驱动的处理模式,主要用于处理大量的I/O事件,如网络请求或文件读写等。在Reactor模式中,所有的事件处理都由一个或多个Reactor(反应堆)线程来负责,这些线程监听事件的发生,然后分发给相应的处理程序来处理。
简单说,Reactor模式就像是一个大型宴会的大堂经理(Reactor线程),它负责监听客人的需求(事件),一旦有客人举手示意(事件发生),服务员就会立即上前询问(接收事件),并根据客人的需求(事件类型)安排相应的人员(事件处理器)来提供服务。像下面的示意图:
image.png
就比如,当服务员上班打卡,即通知大堂经理,我是负责上酒水、上菜的。大堂经理就记录下服务员的职责,知道了服务员对上酒水和上菜这两个时间感兴趣。当有客人需要酒水或上菜时就通知服务员,此时服务员接到通知,执行具体任务。
具体来说,Reactor模式包含以下几个关键部分:

  1. 事件(Event):表示某个动作或状态的变化,如网络数据的到达、文件可读等。
  2. 事件源(Event Source):产生事件的源头,如网络套接字、文件句柄等。
  3. 事件处理器(Event Handler):负责处理事件的程序或函数,根据事件类型执行相应的操作。
  4. Reactor(反应堆):负责监听事件源,接收并分发事件到相应的事件处理器。Reactor通常是一个或多个线程,它们使用I/O多路复用技术(如select、poll、epoll等)来高效地监听多个事件源。

工作流程

  1. 初始化:注册事件源到Reactor,并指定相应的事件处理器。
  2. 监听:Reactor开始监听事件源,等待事件的发生。
  3. 分发:当事件发生时,Reactor接收事件,并根据注册信息找到对应的事件处理器。
  4. 处理:事件处理器执行具体的处理逻辑。

优势

  • 高效:通过I/O多路复用技术,Reactor可以高效地监听多个事件源,减少了线程的使用和上下文切换的开销。
  • 可扩展:可以方便地添加或删除事件源和事件处理器,支持动态扩展。
  • 灵活性:可以根据需要选择不同的I/O多路复用技术来实现Reactor。
Reactor模式种类

在Reactor模式中,主要有三种不同的线程模型,每种模型都有其独特的特点和适用场景。以下是这三种Reactor模式的详细介绍:

  1. 单Reactor单线程模型
    • 定义:所有的I/O操作都在同一个NIO线程上完成。这个线程既是服务端接收客户端连接的线程,也是处理I/O操作的线程。
    • 工作原理
      • Reactor线程监听所有事件源。
      • 当某个事件源就绪(如有新的连接请求或者已有连接的数据可读),Reactor线程就会读取事件,并调用相应的处理器(Handler)来处理这个事件。
      • 所有的事件处理和业务逻辑都在这一个Reactor线程中执行。
    • 优点:模型简单,没有多线程、进程通信、竞争的问题。
    • 缺点
      • 性能问题:只有一个线程,无法发挥多核CPU的性能。
      • 可靠性问题:线程一旦终止或者进入死循环,会导致整个系统不可用。
    • 适用场景:适用于处理器链中业务处理组件能快速完成的场景,如Redis的多路复用模型。
  2. 单Reactor多线程模型
    • 定义:采用专门的Acceptor线程来监听服务端,接收客户端的请求。I/O操作由一个NIO线程池负责,这些NIO线程负责消息的读取、解码、编码和发送。
    • 工作原理
      • Reactor线程监听所有事件源。
      • 当某个事件源就绪时,Reactor线程将事件分发给一个可用的工作线程来处理。
      • 工作线程执行事件处理逻辑,并将结果返回给客户端或者进行后续处理。
    • 优点:可以充分利用多核资源,提高系统的并发性能。
    • 缺点:如果处理器链中业务处理组件不能快速完成,可能会导致性能瓶颈。
    • 适用场景:适用于大多数需要处理大量并发I/O操作的场景。
  3. 主从Reactor多线程模型
    • 定义:一种混合模型,结合了上述两种模型的优点。它采用一个独立的NIO线程池来接受客户端的连接,将创建的Channel注册到IO线程池。Acceptor线程池仅仅只用于客户端的登陆、握手和安全认证。一旦链路建立成功,就将链路注册到后端SubReactor线程池的IO线程上,由IO线程负责后续的IO操作。
    • 工作原理
      • 主Reactor线程监听所有的连接请求。
      • 当有新的连接请求时,主Reactor线程接受连接,并将这个连接分配给一个子Reactor线程。
      • 子Reactor线程负责监听已分配的连接上的I/O事件,并调用相应的工作线程来处理这些事件。
    • 优点:可以根据业务需求灵活选择处理器链的执行方式,提高系统的并发性能和吞吐量。
    • 缺点:实现相对复杂,需要维护多个线程池和Reactor对象。
    • 适用场景:适用于对性能要求较高,需要处理大量并发连接和I/O操作的场景,如Web服务器、聊天服务器等。

以上三种Reactor模式各有优缺点,在实际应用中需要根据具体业务需求和系统环境来选择合适的模型。

NIO三大核心组件

NIO(New I/O)在Java中提供了高效的非阻塞I/O操作,其核心组件包括三个:Buffer(缓冲区)、Channel(通道)和Selector(选择器)。以下是这三个核心组件的详细解析:

Buffer(缓冲区)
  • 本质上是一个内存块,用于数据的存储和传输。
  • 提供了一组更加有效的方法,用于数据的写入和读取的交替访问。
  • 在NIO中,所有的数据都是用缓冲区来处理的。读数据时,会将Channel中的数据填充到Buffer中;写数据时,会将Buffer中的数据写入到Channel中。
  • Buffer包含一些重要的属性和方法,如capacity(容量)、limit(界限)、position(位置)等,以及flip()、clear()等重要的方法。
Channel(通道)
  • 是一个双向的、可读可写的数据传输通道,用于数据的输入输出。
  • 在NIO中,同一个网络连接使用一个通道表示,所有的IO操作都是从通道开始的。
  • 常见的Channel类型有FileChannel(文件通道)、SocketChannel(套接字通道)和DatagramChannel(数据报通道)。
  • Channel和传统的IO中的流(Stream)不同,流是单向的,要么只能读,要么只能写;而Channel既可以读,也可以写。
Selector(选择器)
  • 是NIO中实现非阻塞I/O的核心对象,用于处理多个Channel的I/O操作。
  • 它允许一个线程处理多个Channel,提高了系统的性能和可扩展性。
  • 所有的Channel都可以注册到Selector上,由Selector来分配线程去处理事件。
  • Selector通过注册感兴趣的I/O事件(如可读、可写等),当这些事件发生时,Selector会通知相应的线程去处理。

这三个核心组件相互协作,共同实现了NIO的高效非阻塞I/O操作。其中,Buffer用于数据的存储和传输,Channel用于数据的输入输出,而Selector则用于处理多个Channel的I/O操作,实现了多线程下的高效并发处理。

整体的大概示意图如下:
image.png

实现代码

接下来通过代码实现NIO,如下
创建NioServerHandler,代码如下:

public class NioServerHandler implements Runnable{

    private ServerSocketChannel serverSocketChannel;
    private Selector selector;

    public NioServerHandler() {
        try {
            // 通过Selector.open()创建实例。
            selector = Selector.open();
            // 获取serverSocketChannel实例
            serverSocketChannel = ServerSocketChannel.open();
            // 设置为非阻塞模式
            serverSocketChannel.configureBlocking(false);
            // 绑定本机IP指定端口号
            serverSocketChannel.socket().bind(new InetSocketAddress(8888));
            // 设置对连接事件感兴趣
            serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);

            System.out.println("服务端已启动......");
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    @Override
    public void run() {
        while(true){
            try {
                // 查看是否有事件发生,阻塞1秒
                selector.select(1000);
                // 获取事件的集合
                Set<SelectionKey> selectionKeys = selector.selectedKeys();
                Iterator<SelectionKey> iterator = selectionKeys.iterator();
                while(iterator.hasNext()){
                    SelectionKey key = iterator.next();
                    /*必须首先将处理过的 SelectionKey 从选定的键集合中删除。
                    如果没有删除处理过的键,那么它仍然会在主集合中以一个激活
                    的键出现,这会导致再次处理它。*/
                    iterator.remove();
                    handleInput(key);
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }

    private void handleInput(SelectionKey selectionKey) throws IOException {

        // 判断是否有效
        if (selectionKey.isValid()){
            if (selectionKey.isAcceptable()){
                // 连接事件

                // 获取感兴趣的SocketChannel
                ServerSocketChannel serverSocketChannel = (ServerSocketChannel) selectionKey.channel();

                // 接收连接,成功后会创建一个SocketChannel负责和客户端通讯
                SocketChannel socketChannel = serverSocketChannel.accept();

                // 设置非阻塞
                socketChannel.configureBlocking(false);

                // 注册对读取感兴趣
                socketChannel.register(selector, SelectionKey.OP_READ);

            }
            if (selectionKey.isReadable()){
                // 读事件

                // 获取对读事件感兴趣的SocketChannel
                SocketChannel socketChannel = (SocketChannel) selectionKey.channel();

                // 新建一个ByteBuffer,用读取Channel中的数据
                ByteBuffer buffer = ByteBuffer.allocate(1024);

                // 读取channel中数据,写入到buffer
                int readByteSize = socketChannel.read(buffer);

                if (readByteSize > 0){
                    // 读取到了数据

                    // 反转buffer,从写模式变为读模式,即将limit设为position,position给置为零。
                    buffer.flip();

                    // 将数据读取到一个Byte数组
                    byte[] bytes = new byte[buffer.remaining()];
                    buffer.get(bytes);

                    // 打印收到的消息
                    System.out.println("收到客户端消息" + new String(bytes,"UTF-8"));

                    // 回复数据
                    String respond = new java.util.Date(System.currentTimeMillis()).toString();
                    doWrite(socketChannel, respond);
                }else if (readByteSize < 0 ){
                    // 取消特定的注册关系
                    selectionKey.channel();
                    // 关闭通道
                    socketChannel.close();
                }
            }
        }
    }

    private void doWrite(SocketChannel socketChannel, String respond) throws IOException {
        ByteBuffer buffer = ByteBuffer.allocate(respond.length());
        buffer.put(respond.getBytes());
        buffer.flip();
        socketChannel.write(buffer);
    }
}

上面构造方法中代码含义:

  1. 创建 Selector 实例:
selector = Selector.open();

Selector 是 NIO 的核心组件之一,它允许单个线程监控多个 Channel(通道)的状态变化。这里,通过 Selector.open() 方法创建了一个 Selector 实例。

  1. 获取 ServerSocketChannel 实例:
serverSocketChannel = ServerSocketChannel.open();

ServerSocketChannel 是用于监听进入的连接并接受它们的通道。这行代码创建了一个新的ServerSocketChannel 实例。

  1. 设置为非阻塞模式:
serverSocketChannel.configureBlocking(false);

将 ServerSocketChannel 设置为非阻塞模式意味着如果某个 I/O 操作(如 accept)不能立即完成,它不会阻塞当前线程,而是立即返回。这是 NIO 的关键特性之一。

  1. 绑定本机 IP 指定端口号:
serverSocketChannel.socket().bind(new InetSocketAddress(8888));

这行代码将 ServerSocketChannel 绑定到本机的 8888 端口。InetSocketAddress 用于指定 IP 地址和端口号。在这里,由于没有指定 IP 地址,所以它默认绑定到本机的所有 IP 地址。

  1. 设置对连接事件感兴趣:
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);

这行代码将 ServerSocketChannel 注册到之前创建的 Selector 上,并指定对连接事件(即新的连接请求)感兴趣。当一个新的连接到达时,Selector 就会被通知。

  1. 打印启动信息:
System.out.println("服务端已启动......");

这行代码在控制台打印一条消息,表明服务器已经启动。

其他代码可以看注解,就不一一解释了。

然后写一个NioServer启动服务端,代码如下:

public class NioServer {

    private static NioServerHandler nioServerHandler;
    public static void main(String[] args) throws IOException {
        nioServerHandler = new NioServerHandler();
        new Thread(nioServerHandler,"Server").start();

    }
}

接着写客户端代码,首先创建一个NioClientHandler,如下:

public class NioClientHandler implements Runnable {

    private SocketChannel socketChannel;
    private Selector selector;

    public NioClientHandler() {
        try {
            // 通过Selector.open()创建实例。
            selector = Selector.open();
            // 获取serverSocketChannel实例
            socketChannel = SocketChannel.open();
            // 设置为非阻塞模式
            socketChannel.configureBlocking(false);

        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    @Override
    public void run() {
        try {
            doConnect();
        } catch (IOException e) {
            e.printStackTrace();
            System.exit(1);
        }

        //循环遍历selector
        while (true) {
            try {
                //无论是否有读写事件发生,selector每隔1s被唤醒一次
                selector.select(1000);

                //获取当前有哪些事件可以使用
                Set<SelectionKey> keys = selector.selectedKeys();

                //转换为迭代器
                Iterator<SelectionKey> it = keys.iterator();
                SelectionKey key = null;

                while (it.hasNext()) {
                    key = it.next();

                    it.remove();
                    try {
                        handleInput(key);
                    } catch (Exception e) {
                        if (key != null) {
                            key.cancel();
                            if (key.channel() != null) {
                                key.channel().close();
                            }
                        }
                    }
                }
            } catch (Exception e) {
                e.printStackTrace();
                break;
            }
        }
        //selector关闭后会自动释放里面管理的资源
        if (selector != null) {
            try {
                selector.close();
                System.exit(1);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }

    private void handleInput(SelectionKey key) throws IOException {
        if (key.isValid()) {
            //获得关心当前事件的channel
            SocketChannel sc = (SocketChannel) key.channel();
            //连接事件
            if (key.isConnectable()) {
                if (sc.finishConnect()) {
                    socketChannel.register(selector, SelectionKey.OP_READ);
                } else {
                    System.exit(1);
                }
            }

            if (key.isReadable()) {
                //读事件

                //创建ByteBuffer,一个1M的缓冲区
                ByteBuffer buffer = ByteBuffer.allocate(1024);

                //读取请求码流,返回读取到的字节数
                int readBytes = sc.read(buffer);

                //读取到字节,对字节进行编解码
                if (readBytes > 0) {

                    //将缓冲区当前的limit设置为position,position=0,
                    buffer.flip();

                    //根据缓冲区可读字节数创建字节数组
                    byte[] bytes = new byte[buffer.remaining()];

                    //将缓冲区可读字节数组复制到新建的数组中
                    buffer.get(bytes);
                    String result = new String(bytes, "UTF-8");
                    System.out.println("客户端收到消息:" + result);
                } else if (readBytes < 0) {
                    key.cancel();
                    sc.close();
                }
            }
        }
    }

    private void doConnect() throws IOException {
        /*非阻塞的连接*/
        if (socketChannel.connect(new InetSocketAddress(8888))) {
            socketChannel.register(selector, SelectionKey.OP_READ);
        } else {
            socketChannel.register(selector, SelectionKey.OP_CONNECT);
        }
    }

    public void doWrite(String request) throws IOException {

        //将消息编码为字节数组
        byte[] bytes = request.getBytes();

        //根据数组容量创建ByteBuffer
        ByteBuffer writeBuffer = ByteBuffer.allocate(bytes.length);

        //将字节数组复制到缓冲区
        writeBuffer.put(bytes);

        //flip操作
        writeBuffer.flip();

        //发送缓冲区的字节数组
        socketChannel.write(writeBuffer);
    }

}

然后创建NioClient启动类:

public class NioClient {

    private static NioClientHandler nioClientHandler;
    public static void main(String[] args) throws Exception {
        nioClientHandler = new NioClientHandler();
        new Thread(nioClientHandler,"Server").start();
        Scanner scanner = new Scanner(System.in);
        while (NioClient.sendMsg(scanner.next())) {
            ;
        }
    }

    //向服务器发送消息
    public static boolean sendMsg(String msg) throws Exception{
        nioClientHandler.doWrite(msg);
        return true;
    }
}

全部创建完成后,先启动服务端,然后启动客户端,并在客户端控制台打印信息,回车结束可以看到控制台打印信息如下:
image.png
至此NIO简单服务搭建完毕。

SelectionKey

SelectionKey是Java NIO编程中的一个关键类,它主要用于表示通道(Channel)与选择器(Selector)之间的关联关系,以及通道在选择器上注册的状态和事件。以下是关于SelectionKey的详细解释:

  1. 定义与作用
    • SelectionKey是java.nio.channels包下的一个类,它作为选择器和通道之间的桥梁,允许程序员获取和操作通道在选择器中的注册状态。
    • 在网络编程中,多路复用器(如Selector)被用来监控多个通道的状态,而SelectionKey提供了一种机制,使得程序员能够方便地处理通道的各种事件(如可读、可写、连接、接受等)。
  2. 主要属性与方法
    • Selector绑定:SelectionKey对象可以通过selector()方法返回创建它的Selector。
    • 事件类型:SelectionKey定义了四种主要的事件类型,通过位运算的整数常量表示:
      • OP_READ(值为1):数据读取完成触发的事件。
      • OP_WRITE(值为4):数据写入完成触发的事件。
      • OP_CONNECT(值为8):连接成功触发的事件。
      • OP_ACCEPT(值为16):有新客户端进来触发的事件,这是ServerSocketChannel特有的事件
    • 状态检查:SelectionKey提供了多个方法用于检查通道的状态,如isValid()(检查SelectionKey是否有效)、isReadable()(检查通道是否可读)、isWritable()(检查通道是否可写)等。
    • 事件注册与取消:可以使用interestOps(int ops)方法修改Selector监听该SelectionKey的事件类型,使用cancel()方法取消注册。
    • 数据关联:SelectionKey支持将单个任意对象附加到某个键上,通过attach(Object ob)方法附加对象,并通过attachment()方法获取该对象。
  3. 使用场景
    • 当一个通道被注册到Selector上时,会创建一个与该通道和Selector都关联的SelectionKey对象。
    • Selector的select()方法会返回已就绪的SelectionKey集合,程序员可以通过遍历这个集合,检查每个SelectionKey的事件类型,并对相应的通道进行读写等操作。
  4. 并发安全性:多个并发线程可以安全地使用SelectionKey。

Buffer

在Java NIO(New I/O)中,Buffer是一个用于处理数据的核心抽象类。它代表了一个内存块,可以写入数据(生产者),然后从中读取数据(消费者)。Buffer提供了一种处理原始数据(如字节、字符、整数等)的灵活方式,使得数据的读写操作更加高效。以下是关于Buffer的详细解释:

Buffer的基本概念
  • 定义:Buffer是Java NIO中的一个核心组件,用于在内存中存储数据。它是NIO中定义的抽象类,为所有类型的缓冲区提供了统一的接口。
  • 类型:针对七种基本数据类型,Java NIO提供了对应的Buffer实现类,包括ByteBuffer、CharBuffer、DoubleBuffer、FloatBuffer、IntBuffer、LongBuffer和ShortBuffer。
Buffer的属性
  • 容量(Capacity):Buffer的容量是指其可以容纳的数据元素的最大数量。这个值在创建Buffer时设定,之后不可更改。容量可以通过capacity()方法获取。
  • 限制(Limit):Limit是第一个不应该被读或写的元素。换句话说,Buffer的Limit之后是已经写入的数据,或者可以读取的数据。Limit的值可以通过limit(int newLimit)方法设置,也可以通过limit()方法获取。
  • 位置(Position):Position是下一个要被读或写的元素的索引。Position的自动由相应的get( )和put( )函数更新。可以通过position()方法获取当前位置,也可以通过position(int newPosition)方法设置新的位置。
  • 标记(Mark):Mark是一个索引,通过mark()方法设置,之后可以通过reset()方法恢复到该位置。

这四个属性之间的关系可以用以下不等式表示:0 <= mark <= position <= limit <= capacity

Buffer的操作
  • 写入数据:通过put()方法将数据写入Buffer。
  • 读取数据:通过get()方法从Buffer中读取数据。
  • 清空(Clear)clear()方法将position设回0,limit被设置成capacity的值。换句话说,Buffer被清空了。但实际上数据并没有被清除,只是这些标记告诉我们可以从哪里开始往里写数据。如果Buffer中有一些未读的数据,调用clear()方法后,数据还是存在于Buffer中,只是下次写数据之前先覆盖它。
  • 翻转(Flip)flip()方法将limit设置为当前position,然后将position设回0。换句话说,Flip是为从Buffer把数据写到通道(Channel)做准备的。
  • 压缩(Compact)compact()方法将所有未读的数据拷贝到Buffer起始处。然后将position设到最后一个未读元素正后面。limit属性依然像flip()一样,设置成capacity。现在Buffer准备好写数据了,但是不会覆盖未读的数据。
Buffer的常用方法
  • allocate(int capacity):创建一个新的Buffer,容量为指定的capacity。
  • buffer.capacity():返回Buffer的容量。
  • buffer.limit(int newLimit):设置Buffer的limit。
  • buffer.position(int newPosition):设置Buffer的position。
  • buffer.mark():设置Buffer的mark。
  • buffer.reset():将Buffer的position设置为之前设置的mark。
Buffer的使用场景

Buffer在网络编程、文件读写以及数据操作中都有广泛的应用场景。例如,ByteBuffer通常用于处理字节数据,CharBuffer则常用于文本处理或字符编码转换等场景。ShortBuffer则适合于处理像音频数据、图像数据等以short为基本数据单元的场景。

零拷贝和直接内存

这部分展开说的话,文字上很不好表达(作者表达能力有限),所以就只是大概说明一下(大部分是AI代工的~~)。

一. 零拷贝

零拷贝(Zero-Copy)技术是一种在数据传输过程中减少或避免不必要的数据拷贝操作的技术,从而显著提高数据传输的效率。以下是关于零拷贝技术的详细解释:

定义

  • 零拷贝并不是指在数据的传输过程中发生拷贝的次数为零,而是指数据在传输过程中从内核空间到用户空间之间的数据拷贝次数为零。这意味着数据可以直接从内核缓冲区拷贝到应用程序中,或者从应用程序直接传输到网络,避免了数据的多次拷贝。

工作原理

  • 传统的数据传输方式中,数据需要从用户空间拷贝到内核空间,然后再从内核空间拷贝到用户空间或者网络,这样的拷贝过程需要消耗大量的CPU时间和内存带宽。
  • 零拷贝技术通过优化系统调用和内存管理,将数据直接在内核空间和用户空间之间传输,或者直接从内核空间发送到网络,从而避免了不必要的数据拷贝。

实现方式

  1. 使用DMA(直接内存访问)技术:DMA允许硬件子系统与主内存直接进行数据传输,而不需要CPU的参与。通过使用DMA,数据可以直接从磁盘读取到内核缓冲区,然后通过网络接口卡(NIC)发送到网络,减少了CPU的拷贝工作。
  2. 使用mmap()系统调用:mmap()系统调用可以将文件或者设备映射到进程的地址空间中,使得应用程序可以直接访问这些资源,而不需要将数据从内核空间拷贝到用户空间。通过这种方式,数据可以直接从磁盘读取到用户空间,然后通过网络发送出去。
  3. 使用sendfile()系统调用:sendfile()系统调用可以在内核级别实现文件到网络的传输,避免了数据的多次拷贝。当使用sendfile()发送文件时,数据会直接从磁盘读取到内核缓冲区,然后通过网络接口卡发送到网络,而不需要将数据拷贝到用户空间。

优点

  • 提高数据传输效率:通过减少或避免不必要的数据拷贝操作,零拷贝技术可以显著提高数据传输的效率。
  • 降低CPU使用率:由于减少了CPU的拷贝工作,零拷贝技术可以降低CPU的使用率,使得系统更加高效。
  • 减少内存带宽消耗:通过减少数据拷贝操作,零拷贝技术可以减少内存带宽的消耗,提高系统的整体性能。

应用场景

  • 网络数据传输:在网络数据传输中,零拷贝技术可以提高数据传输的速度和效率。
  • 文件传输:在文件传输过程中,零拷贝技术可以减少数据在内存和文件系统之间的拷贝次数,提高文件传输效率。
  • 数据库操作、磁盘读写、多媒体处理、图像处理:在这些场景中,零拷贝技术同样可以提高数据传输和处理的效率。

总结

零拷贝技术通过优化数据传输过程中的拷贝操作,提高了数据传输的效率。它的实现方式包括使用DMA技术、mmap()系统调用和sendfile()系统调用等。零拷贝技术在网络数据传输、文件传输、数据库操作等场景中都有广泛的应用。

二. 直接内存

直接内存(Direct Memory)是Java虚拟机(JVM)管理的一种非堆内存区域,不属于Java堆内存的一部分。以下是对直接内存的详细解释:

定义

  • 直接内存是操作系统内存中的一部分,通过Java NIO(New Input/Output)的java.nio.ByteBuffer类(特指DirectByteBuffer)直接分配和使用的内存。
  • 它允许Java程序直接访问本机(Native)内存区域,绕过了Java堆内存,从而在某些场景下提供了更高的性能。

特点

  • 性能提升:直接内存的分配和释放效率更高,操作速度比Java堆内存更快。对于需要高性能的应用,使用直接内存能有效减少垃圾收集(GC)的次数,相应地提升了系统的响应速度。
  • 避免堆内存限制:Java堆内存的大小是有限的,当堆内存不足时,会发生OutOfMemoryError错误。而直接内存并不受Java堆大小的限制,可以充分利用系统的物理内存。
  • 直接I/O操作:使用直接内存可以直接进行零拷贝的I/O操作,避免了数据在Java堆和内核空间之间的复制,提高了数据操作效率。
  • 分配回收成本:虽然直接内存的读写性能高,但其分配和回收成本也相对较高。这是因为直接内存的分配和回收需要通过操作系统的本地接口完成,而不是由JVM的垃圾收集器管理。

使用场景

  • 大数据存储:当需要存储大量数据,且数据的生命周期较长时,可以使用直接内存来存储这些数据。
  • 频繁的I/O操作:对于网络并发、文件读写等频繁进行I/O操作的场景,使用直接内存可以显著提高性能。

分配与回收

  • 直接内存的分配和回收是通过Unsafe类来完成的,而不是通过JVM的垃圾收集器。因此,在使用直接内存时,需要手动管理内存的释放,以避免内存泄漏。
  • 在ByteBuffer的实现类内部,使用了Cleaner(虚引用)来监测ByteBuffer对象。一旦ByteBuffer对象被垃圾收集器回收,那么就会由ReferenceHandler线程通过Cleaner的clean方法调用freeMemory来释放直接内存。

配置与限制

  • 直接内存的大小不受Java堆大小的限制,而是受到本机总内存大小的限制。因此,在配置虚拟机参数时,需要考虑到直接内存的使用情况,以防止出现OutOfMemoryError异常。
  • 可以通过-XX:MaxDirectMemorySize参数来限制JVM直接内存的最大使用量。当超过这个限制时,JVM会抛出OutOfMemoryError异常。