Java 网络IO演变史

前置知识

操作系统的几个概念

1.用户态和内核态Linux 操作系统的体系架构分为用户态和内核态,内核本质上是一个特殊的软件程序,它控制着计算机的硬件资源,例如协调 CPU 资源、分配内存资源,并为上层应用程序提供稳定的运行环境。

用户态即为上层应用程序,它的运行依赖于内核。为了使用户程序也能够访问到内核管理的资源,所以内核必须提供通用的访问接口,这些接口被称为「系统调用」。

2.系统调用和软中断系统调用是操作系统提供给应用程序访问的一组通用接口,它使得运行在用户态的进程可以访问内核管理的资源,例如新线程的创建、内存的申请等等。

当应用程序发起一个系统调用时,会导致一次「软中断」,过程如下:

  1. CPU 停止执行当前程序流,将 CPU 寄存器的值保存到栈中。
  2. 找到系统调用的函数地址,执行函数,得到执行结果。
  3. CPU 恢复寄存器的值,继续运行应用程序。


综上所述,系统调用会导致应用程序从用户态切到内核态,发生一次软中断,这需要额外的开销,如果要提高应用程序的性能,应该要尽量减少系统调用的次数。


Bio

Bio 全称「Blocking IO」阻塞 IO,它的 IO 操作如 accept、read、write 都是阻塞的,这也是 Java 网络编程最早的 IO 模型。

当线程在处理 Socket 的 IO 操作时,它是阻塞的,如果服务是单线程运行,它会直接卡死,直到 IO 操作完成。为了避免这种情况,只能给每个连接分配一个线程。

连接数少的话,这么做倒不会有什么太大的问题,一旦面临十万、百万级的客户端连接,Bio 就无能为力了,主要原因如下:

  1. 线程是非常宝贵的资源,线程的创建和销毁成本很高,在 Linux 系统中,线程本质上就是一个进程,创建和销毁线程是一个很重的系统函数。
  2. 线程本身占用内存资源,创建一个线程需要分配 1MB 左右的栈空间,创建一千个线程就已经很可怕了。
  3. 线程间的切换成本高,操作系统需要保存线程运行的上下文环境,将寄存器的值暂存到线程栈,调用系统函数进行线程的切换。如果线程数过多,很可能线程切换的时间比线程运行的时间都多。
Java 网络IO演变史

如下是一个简单的 Bio 版本的 EchoServer:

// Bio版本的Echo服务
public class BioEchoServer {
 public static void main(String[] args) throws IOException {
  ServerSocket serverSocket = new ServerSocket(9999);
  while (true) {
   final Socket accept = serverSocket.accept();
   new Thread(() -> {
    try {
     InputStream inputStream = accept.getInputStream();
     OutputStream outputStream = accept.getOutputStream();
     while (true) {
      byte[] bytes = new byte[1024];
      int size = inputStream.read(bytes);
      if (size <= -1) {
       accept.shutdownOutput();
       accept.close();
       break;
      }
      outputStream.write(bytes);
      outputStream.flush();
     }
    } catch (Exception e) {
     e.printStackTrace();
    }
   }).start();
  }
 }
}

优点

  1. 代码简单

缺点

  1. 一个线程只能处理一个客户端连接。
  2. 线程数量不可控,面对突发流量服务可能会崩溃。
  3. 线程的频繁创建和销毁需要额外的开销。
  4. 大量的线程会导致频繁的线程切换。

Nio

Nio 全称「Non-Blocking IO」非阻塞 IO,它是 JDK1.4 被引入的一套新的 IO 体系。

Nio 使用 Channel 来替代 Stream,Stream 是单向的,它要么是输入流、要么是输出流。而 Channel 是双向的,你可以通过它来同时进行数据的读写。

Nio 加入了数据缓冲区「ByteBuffer」,必须通过 ByteBuffer 来向 Channel 读写数据。ByteBuffer 本身就是个字节数组,它内部有多个指针,随着数据的读取和写入,指针会不断移动。

Nio 还有一个核心组件,就是「Selector」多路复用器,这个下节再细说。

Nio 的特点就是「非阻塞」,例如调用ServerSocketChannel.accept(),线程不会阻塞直到有客户端连接了,它会立即得到结果。如果有客户端连接,则返回 SocketChannel,否则返回 null。对于SocketChannel.read(),如果返回 0 则表示 Channel 当前无数据可读,返回-1 代表客户端连接已断开,只有大于 0 时才代表读到了数据。

调用Channel.configureBlocking(false)将 Channel 设为非阻塞是关键,否则调用还是阻塞的,切记!!!

IO 操作不阻塞,我们就可以在不开启新线程的情况下,合理的利用 CPU 资源了。

例如,不需要将线程阻塞在那里死等客户端的连接了,而是每隔一段时间轮询一次是否有新的客户端接入,如果有,则将其添加到容器中,再轮询容器中的 SocketChannel,是否有数据可读写,没有就跳过,有则进行 IO 读写。这样,即便只有一个线程,也可以处理大量的连接,严格控制了线程的数量。Java 网络IO演变史如下,是一个 Nio 版的 EchoServer,代码明显比 Bio 版的稍复杂:

public class NioEchoServer {
 static List<SocketChannel> channels = new ArrayList<>();

 public static void main(String[] args) throws IOException, InterruptedException {
  ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
  serverSocketChannel.bind(new InetSocketAddress(9999));
  // 将Channel设为非阻塞!!!
  serverSocketChannel.configureBlocking(false);
  while (true) {
   SocketChannel socketChannel = serverSocketChannel.accept();
   if (socketChannel != null) {
    socketChannel.configureBlocking(false);
    channels.add(socketChannel);
   }
   // 处理数据读操作
   Iterator<SocketChannel> iterator = channels.iterator();
   while (iterator.hasNext()) {
    SocketChannel channel = iterator.next();
    ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
    int readSize = channel.read(byteBuffer);
    if (readSize > 0) {
     // 翻转,可读 > 可写
     byteBuffer.flip();
     channel.write(byteBuffer);
    } else if (readSize < 0) {
     iterator.remove();
     channel.close();
    }
   }
   // 避免CPU空转,sleep一会
   ThreadUtil.sleep(10);
  }
 }
}

优点

  1. 单个线程即可处理大量连接。
  2. 线程数量可控。
  3. 无需阻塞,性能比 Bio 更高。


缺点

  1. 每次都需要轮询大量的 SocketChannel,一万个连接就需要轮询一万次,每次轮询都是一个系统调用,会导致一次「软中断」,消耗性能。
  2. 轮询间隔的时间不好控制,设的太长会导致响应延迟,设的太短会消耗 CPU 资源。
  3. 大部分连接不活跃的情况下,无效轮询增多,无意义消耗 CPU。

IO 多路复用

Nio 存在的主要问题是:面对海量连接,我们不知道哪些连接是准备就绪需要处理的,所以只能是每次都遍历所有连接,当只有少部分连接活跃时,每次轮询的效益就太低太低了。

为了解决 Nio 的问题,引入了「IO 多路复用」机制。在 Java 中,「Selector」接口就是多路复用器的抽象表示,在不同的平台它有不同的实现,一般来说select几乎是所有平台都支持的,在 Linux 中用的更多的是epoll

常见的 IO 多路复用实现

select

在 Linux 中,select函数定义为:

int select(int nfds, fd_set *readfds, fd_set *writefds,
                  fd_set *exceptfds, struct timeval *timeout)
;

参数说明:

  • nfds:下面三种 FD 集合中最大值+1。
  • readfds:监听可读事件的 FD 集合。
  • writefds:监听可写事件的 FD 集合。
  • exceptfds:监听异常事件的 FD 集合。
  • timeout:超时时间。

调用select函数可以理解为:应用程序将所有待监听的 SocketChannel 集合传递给内核,由内核来轮询遍历所有 SocketChannel,然后告诉应用程序哪些 SocketChannel 是准备就绪的。

如果有一万个连接,应用程序自己遍历需要发起一万次系统调用,有了select,只需要一次系统调用。前面已经说过了,要想提高程序性能,就要尽量减少系统调用的次数。

select的缺点:

  1. 每次调用都需要将 FD 集合从用户空间拷贝到内核空间。
  2. 内核需要遍历所有的 FD。
  3. 支持的 FD 数量最大只有 1024,太小了。

poll

在 Linux 系统中,poll的函数定义如下:

int poll(struct pollfd *fds, nfds_t nfds, int timeout);

它的实现和select非常类似,只是描述 FD 集合的方式不同,select是 fd_set 结构,poll是 pollfd 结构。另外就是poll支持的 FD 数量没有 1024 的限制。

poll的缺点:

  1. 每次调用都需要将 FD 集合从用户空间拷贝到内核空间。
  2. 内核需要遍历所有的 FD。

epoll

在 Linux 系统中,epoll由三个函数组成,分别是:epoll 的创建:epoll_create

int epoll_create(int size);
int epoll_create1(int flags);

epoll FD 的控制:epoll_ctl

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

等待 epoll 上的 IO 事件:epoll_wait

int epoll_wait(int epfd, struct epoll_event *events,
                      int maxevents, int timeout)
;
int epoll_pwait(int epfd, struct epoll_event *events,
                int maxevents, int timeout,
                const sigset_t *sigmask)
;

使用selectpoll函数时,需要应用程序自己管理要监听的 Socket FD,每次调用都需要将 FD 集合从用户空间拷贝到内核空间。

使用epoll函数时,通过epoll_create创建一个 epoll 实例,调用epoll_ctl添加你要监听的 Socket FD,由内核来帮助我们管理 FD 集合,调用epoll_wait时就无需再拷贝一次 FD 了。

epoll的另一个改进就是:无需每次都遍历所有 Socket FD。调用epoll_ctl添加 FD 时,会为每个 FD 指定一个回调函数,当 FD 准备就绪被唤醒时会触发该回调函数,它会将当前 FD 加入到一个「就绪链表」中,epoll_wait其实就是查看这个就绪链表中是否有 FD。

epoll 的两种触发模式

  1. 水平触发(LT):epoll_wait检测到事件后会通知应用程序,应用程序可以不处理,下次会继续通知。
  2. 边缘触发(ET):epoll_wait检测到事件后会通知应用程序,应用程序必须处理,下次不会再通知。



作为 Java 程序员,你可以不关心上面所述的三种实现,只需要关心Selector接口就行了,Java 中的Selector接口就是多路复用器的一个抽象表示。

如下是一个多路复用器版本的 EchoServer:

public class MultiplexingIOEchoServer {

 public static void main(String[] args) throws IOException {
  ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
  serverSocketChannel.bind(new InetSocketAddress(9999)).configureBlocking(false);
  Selector selector = Selector.open();
  // 订阅ACCEPT事件
  serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
  while (true) {
   // 等待准备就绪的Channel
   if (selector.select() > 0) {
    Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
    while (iterator.hasNext()) {
     SelectionKey key = iterator.next();
     iterator.remove();
     if (key.isAcceptable()) {// 处理新的连接
      SocketChannel sc = ((ServerSocketChannel) key.channel()).accept();
      sc.configureBlocking(false);
      sc.register(selector, SelectionKey.OP_READ);
     } else if (key.isReadable()) {// 有数据可读
      SocketChannel channel = (SocketChannel) key.channel();
      ByteBuffer buffer = ByteBuffer.allocate(1024);
      if (channel.read(buffer) > -1) {
       buffer.flip();
       channel.write(buffer);
      } else {
       channel.close();
      }
     }
    }
   }
  }
 }
}

调用Selector.select()就可以看作是调用了select()epoll()epoll_wait(),没有准备就绪的 Channel 时,该方法会阻塞,所以你大可放心在while(true)中调用。

当有准备就绪的 Channel 时,Selector 会将 Channel 封装成SelectionKeySelector.selectedKeys()方法会返回 Key 的 HashSet 容器,通过遍历这些 Key 来遍历准备就绪的所有 Channel,通过SelectionKey.readyOps()来获取 Channel 的事件类型。

Selector多路复用器解决了 Nio 的问题,即使是面对一万的客户端连接,只需一次系统调用即可知道准备就绪的连接。它还解决了 Nio 轮询间隔时间不好设置的问题,有事件就处理事件,没事件就阻塞在系统调用上等待事件。如果是epoll,还避免了每次轮询都要将 FD 集合从用户空间拷贝到内核空间的额外开销,进一步提升系统性能。


原文始发于微信公众号(程序员小潘):Java 网络IO演变史

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/29393.html

(0)
小半的头像小半

相关推荐

发表回复

登录后才能评论
极客之音——专业性很强的中文编程技术网站,欢迎收藏到浏览器,订阅我们!