2. Netty IO代码详解

346 阅读17分钟

BIO

  • Java BIO 就是传统的java io 编程,其相关的类和接口在 java.io
  • BIO(blocking I/O) : 同步阻塞,服务器实现模式为一个连接一个线程,即客户端有连接请求时服务器端就需要启动一个线程进行处理,如果这个连接不做任何事情会造成不必要的线程开销。
  • BIO方式适用于连接数目比较小且固定的架构,这种方式对服务器资源要求比较高,并发局限于应用中,JDK1.4以前的唯一选择,程序简单易理解

image.png

代码流程

  1. 服务器端启动一个ServerSocket
  2. 客户端启动Socket对服务器进行通信,默认情况下服务器端需要对每个客户建立一个线程与之通讯
  3. 客户端发出请求后, 先咨询服务器是否有线程响应,如果没有则会等待,或者被拒绝
  4. 如果有响应,客户端线程会等待请求结束后,在继续执行
public static void main(String[] args) throws Exception {
  ExecutorService newCachedThreadPool = Executors.newCachedThreadPool();
  //创建ServerSocket
  ServerSocket serverSocket = new ServerSocket(6666);
  System.out.println("服务器启动了");
  while (true) {
    System.out.println(
        "线程信息 id =" + Thread.currentThread().getId() + " 名字=" + Thread.currentThread().getName());
    //监听,等待客户端连接,同步阻塞
    System.out.println("等待连接....");
    final Socket socket = serverSocket.accept();
    System.out.println("连接到一个客户端");
    //就创建一个线程,与之通讯(单独写一个方法)
    newCachedThreadPool.execute(new Runnable() {
      public void run() { //我们重写
        //可以和客户端通讯
        handler(socket);
      }
    });
  }
}

//编写一个handler方法,和客户端通讯
public static void handler(Socket socket) {
  try {
    System.out.println("线程信息 id =" + Thread.currentThread().getId() + " 名字=" + Thread.currentThread().getName());
    byte[] bytes = new byte[1024];
    //通过socket 获取输入流
    InputStream inputStream = socket.getInputStream();
    //循环的读取客户端发送的数据
    while (true) {
      System.out.println("线程信息 id =" + Thread.currentThread().getId() + " 名字=" + Thread.currentThread().getName());
      System.out.println("read....");
      int read = inputStream.read(bytes);
      if (read != -1) {
        System.out.println(new String(bytes, 0, read)); //输出客户端发送的数据
      } else {
        break;
      }
    }
  } catch (Exception e) {
    e.printStackTrace();
  } finally {
    System.out.println("关闭和client的连接");
    try {
      socket.close();
    } catch (Exception e) {
      e.printStackTrace();
    }

  }
}

问题分析

  • 每个请求都需要创建独立的线程,与对应的客户端进行数据交互。
  • 当并发数较大时,需要创建大量线程来处理连接,系统资源占用较大。
  • 连接建立后,如果当前线程暂时没有数据可读,则线程就阻塞在Read操作上,造成线程资源浪费

NIO

  • Java NIO 全称 java non-blocking IO,是JDK提供的新API。从JDK1.4开始,Java提供了一系列改进的输入/输出的新特性,被统称为NIO(即 New IO),是同步非阻塞的
  • NIO 相关类都被放在 java.nio 包及子包下,并且对原 java.io 包中的很多类进行改写
  • NIO 有三大核心部分:**Channel(通道),Buffer(缓冲区), Selector(选择器) **
  • NIO是 面向缓冲区 ,或者面向 块 编程的。数据读取到一个它稍后处理的缓冲区,需要时可在缓冲区中前后移动,这就增加了处理过程中的灵活性,使用它可以提供非阻塞式的高伸缩性网络
  • Java NIO的非阻塞模式,使一个线程从某通道发送请求或者读取数据,但是它仅能得到目前可用的数据,如果目前没有数据可用时,就什么都不会获取,而不是保持线程阻塞,所以直至数据变的可以读取之前,该线程可以继续做其他的事情。 非阻塞写也是如此,一个线程请求写入一些数据到某通道,但不需要等待它完全写入,这个线程同时可以去做别的事情
  • 通俗理解:NIO是可以做到用一个线程来处理多个操作的。假设有10000个请求过来,根据实际情况,可以分配50或者100个线程来处理。不像之前的阻塞IO那样,非得分配10000个。
  • HTTP2.0使用了多路复用的技术,做到同一个连接并发处理多个请求,而且并发请求的数量比HTTP1.1大了好几个数量级。

BIO和NIO比较

  • BIO 以流的方式处理数据,而 NIO 以块的方式处理数据,块 I/O 的效率比流 I/O 高很多
  • BIO 是阻塞的,NIO 则是非阻塞的
  • BIO基于字节流和字符流进行操作,而 NIO 基于 Channel(通道)和 Buffer(缓冲区)进行操作,数据总是从通道读取到缓冲区中,或者从缓冲区写入到通道中。Selector(选择器)用于监听多个通道的事件(比如:连接请求,数据到达等),因此使用单个线程就可以监听多个客户端通道

NIO 三大核心

image.png

  • Selector 对应一个线程, 一个线程对应多个channel(连接)
  • 每个channel 都会对应一个Buffer
  • Selector 会根据不同的事件,在各个通道上切换
  • Buffer 就是一个内存块 , 底层是有一个数组
  • 数据的读取写入是通过Buffer,BIO中要么是输入流或者是输出流,不能双向。但是NIO的Buffer是可以读也可以写, 需要 flip 方法切换
  • channel 是双向的, 可以返回底层操作系统的情况, 比如Linux,底层的操作系统通道就是双向的

缓冲区(Buffer)

缓冲区本质上是一个可以读写数据的内存块,可以理解成是一个容器对象(含数组),该对象提供了一组方法,可以更轻松地使用内存块,缓冲区对象内置了一些机制,能够跟踪和记录缓冲区的状态变化情况。Channel 提供从文件、网络读取数据的渠道,但是读取或写入的数据都必须经由 Buffer

Buffer 是一个顶层父类,它是一个抽象类, 类的层级关系图:

image.png

Buffer类定义了所有的缓冲区都具有的三个属性来提供关于其所包含的数据元素的信息:

image.png image.png - capacity:capacity的大小代表利用allocate初始化时候的大小,代表这个缓存块的容量。当缓存块满了的时候需要将其清空,才能继续往里面写数据
- position写数据到Buffer中时,position表示当前的位置。初始的position值为0.当一个数据写到Buffer后, position会向前移动到下一个可插入数据的Buffer单元。position最大可为capacity – 1。当读取数据时,也是从某个特定位置读。当将Buffer从写模式切换到读模式,position会被重置为0. 当从Buffer的position处读取数据时,position向前移动到下一个可读的位置。
- limit:在写模式下,Buffer的limit表示你最多能往Buffer里写多少数据。 写模式下,limit等于Buffer的capacity。

API

public abstract class Buffer {
    //JDK1.4时,引入的api
    public final int capacity( )//返回此缓冲区的容量
    public final int position( )//返回此缓冲区的位置
    public final Buffer position (int newPositio)//设置此缓冲区的位置
    public final int limit( )//返回此缓冲区的限制
    public final Buffer limit (int newLimit)//设置此缓冲区的限制
    public final Buffer mark( )//在此缓冲区的位置设置标记
    public final Buffer reset( )//将此缓冲区的位置重置为以前标记的位置
    public final Buffer clear( )//清除此缓冲区, 即将各个标记恢复到初始状态,但是数据并没有真正擦除, 后面操作会覆盖
    public final Buffer flip( )//反转此缓冲区
    public final Buffer rewind( )//将position设回0,重读Buffer中的所有数据
    public final int remaining( )//返回当前位置与限制之间的元素数
    public final boolean hasRemaining( )//告知在当前位置和限制之间是否有元素
    public abstract boolean isReadOnly( );//告知此缓冲区是否为只读缓冲区
 
    //JDK1.6时引入的api
    public abstract boolean hasArray();//告知此缓冲区是否具有可访问的底层实现数组
    public abstract Object array();//返回此缓冲区的底层实现数组
    public abstract int arrayOffset();//返回此缓冲区的底层实现数组中第一个缓冲区元素的偏移量
    public abstract boolean isDirect();//告知此缓冲区是否为直接缓冲区
}

Channel

  • NIO的通道类似于流,但有些区别如下:
    • 通道可以同时进行读写,而流只能读或者只能写
    • 通道可以实现异步读写数据
    • 通道可以从缓冲读数据,也可以写数据到缓冲
  • 重要的通道的实现
    • FileChannel 从文件中读写数据。
    • DatagramChannel 能通过UDP读写网络中的数据。
    • SocketChannel 能通过TCP读写网络中的数据。
    • ServerSocketChannel可以监听新进来的TCP连接,像Web服务器那样。对每一个新进来的连接都会创建一个SocketChannel

Selector

Selector(选择器)是Java NIO中能够检测一到多个NIO通道,并能够知晓通道是否为诸如读写事件做好准备的组件。这样,一个单独的线程可以管理多个channel,从而管理多个网络连接。

  • 当线程从某客户端 Socket 通道进行读写数据时,若没有数据可用时,该线程可以进行其他任务
  • 线程通常将非阻塞 IO 的空闲时间用于在其他通道上执行 IO 操作,单独的线程可以管理多个输入和输出通道。
  • 由于读写操作都是非阻塞的,这就可以充分提升 IO 线程的运行效率,避免由于频繁 I/O 阻塞导致的线程挂起。
  • 一个 I/O 线程可以并发处理 N 个客户端连接和读写操作,这从根本上解决了传统同步阻塞 I/O 一连接一线程模型,架构的性能、弹性伸缩能力和可靠性都得到了极大的提升。

常用方法

Selector 类是一个抽象类

image.png

public abstract class Selector implements Closeable { 
    public static Selector open();//得到一个选择器对象
    public int select(long timeout);//监控所有注册的通道,当其中有 IO 操作可以进行时,将对应的 SelectionKey 加入到内部集合中并返回,参数用来设置超时时间
    public Set<SelectionKey> selectedKeys();//从内部集合中得到所有的 SelectionKey
}
selector.select()//阻塞
selector.select(1000);//阻塞1000毫秒,在1000毫秒后返回
selector.wakeup();//唤醒selector
selector.selectNow();//不阻塞,立马返还

demo代码

  • 服务端
public static void main(String[] args) throws Exception {

  // 打开ServerSocketChannel
  ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
  // 打开选择器
  Selector selector = Selector.open();
  // 绑定端口
  serverSocketChannel.socket().bind(new InetSocketAddress(6666));
  // 设置非阻塞
  serverSocketChannel.configureBlocking(false);
  // 选择器注册到serverSocketChannel
  serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
  while (true){
    // 获取选择器中的是否有关注的事件
    int select = selector.select(1000);
    if(select == 0) { //没有事件发生
      System.out.println("服务器等待了1秒,无连接");
      continue;
    }
    // 获取关注事件的集合
    Set<SelectionKey> selectionKeys = selector.selectedKeys();
    //遍历 Set<SelectionKey>
    Iterator<SelectionKey> iterator = selectionKeys.iterator();
    while (iterator.hasNext()){
      //获取到SelectionKey
      SelectionKey key = iterator.next();
      //根据key 对应的通道发生的事件做相应处理
      if (key.isAcceptable()){
        //该该客户端生成一个 SocketChannel
        SocketChannel socketChannel = serverSocketChannel.accept();
        socketChannel.configureBlocking(false);
        //将socketChannel 注册到selector, 关注事件为 OP_READ, 同时给socketChannel关联一个Buffer
        socketChannel.register(selector, SelectionKey.OP_READ, ByteBuffer.allocate(1024));
      }else if (key.isConnectable()){
        System.out.println("服务器连接事件");
      }else if(key.isReadable()){
        //通过key 反向获取到对应channel
        SocketChannel channel = (SocketChannel) key.channel();
        //获取到该channel关联的buffer
        ByteBuffer buffer = (ByteBuffer) key.attachment();
        channel.read(buffer);
        System.out.println("form 客户端 " + new String(buffer.array()));
      }else if (key.isWritable()) {

      }
      //手动从集合中移动当前的selectionKey, 防止重复操作
      iterator.remove();
    }
  }
}
  • 客户端
public static void main(String[] args) throws Exception {
  //得到一个网络通道
  SocketChannel socketChannel = SocketChannel.open();
  // 地址
  InetSocketAddress inetSocketAddress = new InetSocketAddress("127.0.0.1", 6666);
  // 连接
  if (socketChannel.connect(inetSocketAddress)){
    while (!socketChannel.finishConnect()) {
      System.out.println("因为连接需要时间,客户端不会阻塞,可以做其它工作..");
    }
  }
  String str = "hello world";
  ByteBuffer buffer = ByteBuffer.wrap(str.getBytes());
  // 发送数据,将 buffer 数据写入 channel
  socketChannel.write(buffer);
  System.in.read();
}

SelectionKey

  • 四种事件
public static final int OP_READ = 1 << 0; // 读操作,值为 1 
public static final int OP_WRITE = 1 << 2; // 写操作,值为 4
public static final int OP_CONNECT = 1 << 3; // 连接已经建立,值为 8
public static final int OP_ACCEPT = 1 << 4; // 新的网络连接可以,值为 16
  • 相关方法
public abstract class SelectionKey {
     public abstract Selector selector();//得到与之关联的 Selector 对象
     public abstract SelectableChannel channel();//得到与之关联的通道
     public final Object attachment();//得到与之关联的共享数据
     public abstract SelectionKey interestOps(int ops);//设置或改变监听事件
     public final boolean isAcceptable();//是否可以 accept
     public final boolean isReadable();//是否可以读
     public final boolean isWritable();//是否可以写
}

ServerSocketChannel

  • ServerSocketChannel 服务器端监听新的客户端 Socket 连接
  • api
public abstract class ServerSocketChannel extends AbstractSelectableChannel   implements NetworkChannel{
    public static ServerSocketChannel open() //得到一个 ServerSocketChannel 通道
    public final ServerSocketChannel bind(SocketAddress local)//设置服务器端端口号
    public final SelectableChannel configureBlocking(boolean block)//设置阻塞或非阻塞模式,取值 false 表示采用非阻塞模式
    public SocketChannel accept()//接受一个连接,返回代表这个连接的通道对象
    public final SelectionKey register(Selector sel, int ops)//注册一个选择器并设置监听事件
}

SocketChannel

  • SocketChannel,网络 IO 通道,具体负责进行读写操作。NIO 把缓冲区的数据写入通道,或者把通道里的数据读到缓冲区。
public abstract class SocketChannel extends AbstractSelectableChannel implements ByteChannel, ScatteringByteChannel, GatheringByteChannel, NetworkChannel{
    public static SocketChannel open();//得到一个 SocketChannel 通道
    public final SelectableChannel configureBlocking(boolean block);//设置阻塞或非阻塞模式,取值 false 表示采用非阻塞模式
    public boolean connect(SocketAddress remote);//连接服务器
    public boolean finishConnect();//如果上面的方法连接失败,接下来就要通过该方法完成连接操作
    public int write(ByteBuffer src);//往通道里写数据
    public int read(ByteBuffer dst);//从通道里读数据
    public final SelectionKey register(Selector sel, int ops, Object att);//注册一个选择器并设置监听事件,最后一个参数可以设置共享数据
    public final void close();//关闭通道
}

零拷贝

DMA

无DMA技术之前 image.png ps:写操作一样的流程

  • CPU 发出对应的指令给磁盘控制器,然后返回;
  • 磁盘控制器收到指令后,于是就开始准备数据,会把数据放入到磁盘控制器的内部缓冲区中,然后产生一个中断
  • CPU 收到中断信号后,停下手头的工作,接着把磁盘控制器的缓冲区的数据一次一个字节地读进自己的寄存器,然后再把寄存器里的数据写入到内存,而在数据传输的期间 CPU 是无法执行其他任务的。
  • 整个数据的传输过程,都要需要 CPU 亲自参与搬运数据的过程,而且这个过程,CPU 是不能做其他事情的。发了2次上下文切换,和2次数据copy。
    DMA之后 image.png
  • 用户进程调用 read 方法,向操作系统发出 I/O 请求,请求读取数据到自己的内存缓冲区中,进程进入阻塞状态;
  • 操作系统收到请求后,进一步将 I/O 请求发送 DMA,然后让 CPU 执行其他任务;
  • DMA 进一步将 I/O 请求发送给磁盘;
  • 磁盘收到 DMA 的 I/O 请求,把数据从磁盘读取到磁盘控制器的缓冲区中,当磁盘控制器的缓冲区被读满后,向 DMA 发起中断信号,告知自己缓冲区已满;
  • DMA 收到磁盘的信号,将磁盘控制器缓冲区中的数据拷贝到内核缓冲区中,此时不占用 CPU,CPU 可以执行其他任务
  • 当 DMA 读取了足够多的数据,就会发送中断信号给 CPU;
  • CPU 收到 DMA 的信号,知道数据已经准备好,于是将数据从内核拷贝到用户空间,系统调用返回;
    DMA 技术,也就是直接内存访问(Direct Memory Access 技术。简单理解就是,在进行 I/O 设备和内存的数据传输的时候,数据搬运的工作全部交给 DMA 控制器,而 CPU 不再参与任何与数据搬运相关的事情,这样 CPU 就可以去处理别的事务

传统的文件传输

read(file, tmp_buf, len);
write(socket, tmp_buf, len);

image.png 期间共发生了 4 次用户态与内核态的上下文切换,因为发生了两次系统调用,一次是 read() ,一次是 write(),每次系统调用都得先从用户态切换到内核态,等内核完成任务后,再从内核态切换回用户态。
上下文切换到成本并不小,一次切换需要耗时几十纳秒到几微秒,虽然时间看上去很短,但是在高并发的场景下,这类时间容易被累积和放大,从而影响系统的性能。
只是搬运一份数据,结果却搬运了 4 次,过多的数据拷贝无疑会消耗 CPU 资源,大大降低了系统性能。
所以,要想提高文件传输的性能,就需要减少「用户态与内核态的上下文切换」和「内存拷贝」的次数

mmap + write

buf = mmap(file, len);
write(sockfd, buf, len);

mmap() 系统调用函数会直接把内核缓冲区里的数据「映射」到用户空间,这样,操作系统内核与用户空间就不需要再进行任何的数据拷贝操作。 image.png

  • 应用进程调用了 mmap() 后,DMA 会把磁盘的数据拷贝到内核的缓冲区里。接着,应用进程跟操作系统内核「共享」这个缓冲区;
  • 应用进程再调用 write(),操作系统直接将内核缓冲区的数据拷贝到 socket 缓冲区中,这一切都发生在内核态,由 CPU 来搬运数据;
  • 最后,把内核的 socket 缓冲区里的数据,拷贝到网卡的缓冲区里,这个过程是由 DMA 搬运的。

我们可以得知,通过使用 mmap() 来代替 read(), 可以减少一次数据拷贝的过程。
但这还不是最理想的零拷贝,因为仍然需要通过 CPU 把内核缓冲区的数据拷贝到 socket 缓冲区里,而且仍然需要 4 次上下文切换,因为系统调用还是 2 次。

sendfile

Linux 内核版本 2.1 中,提供了一个专门发送文件的系统调用函数 sendfile() 首先,它可以替代前面的 read() 和 write() 这两个系统调用,这样就可以减少一次系统调用,也就减少了 2 次上下文切换的开销。
其次,该系统调用,可以直接把内核缓冲区里的数据拷贝到 socket 缓冲区里,不再拷贝到用户态,这样就只有 2 次上下文切换,和 3 次数据拷贝。如下图:

image.png Linux 内核 2.4 版本开始起,对于支持网卡支持 SG-DMA 技术的情况下, sendfile() 系统调用的过程发生了点变化

  • 第一步,通过 DMA 将磁盘上的数据拷贝到内核缓冲区里;
  • 第二步,缓冲区描述符和数据长度传到 socket 缓冲区,这样网卡的 SG-DMA 控制器就可以直接将内核缓存中的数据拷贝到网卡的缓冲区里,此过程不需要将数据从操作系统内核缓冲区拷贝到 socket 缓冲区中,这样就减少了一次数据拷贝;
    所以,这个过程之中,只进行了 2 次数据拷贝,如下图 image.png 这就是所谓的零拷贝(Zero-copy)技术,因为我们没有在内存层面去拷贝数据,也就是说全程没有通过 CPU 来搬运数据,所有的数据都是通过 DMA 来进行传输的。
    零拷贝技术的文件传输方式相比传统文件传输的方式,减少了 2 次上下文切换和数据拷贝次数,只需要 2 次上下文切换和数据拷贝次数,就可以完成文件的传输,而且 2 次的数据拷贝过程,都不需要通过 CPU,2 次都是由 DMA 来搬运。
    所以,总体来看,零拷贝技术可以把文件传输的性能提高至少一倍以上

Java AIO 基本介绍

  • JDK 7 引入了 Asynchronous I/O,即 AIO。在进行 I/O 编程中,常用到两种模式:Reactor和 Proactor。Java 的 NIO 就是 Reactor,当有事件触发时,服务器端得到通知,进行相应的处理
  • AIO 即 NIO2.0,叫做异步不阻塞的 IO。AIO 引入异步通道的概念,采用了 Proactor 模式,简化了程序编写,有效的请求才启动线程,它的特点是先由操作系统完成后才通知服务端程序启动线程去处理,一般适用于连接数较多且连接时间较长的应用
  • 目前 AIO 还没有广泛应用,Netty 也是基于NIO, 而不是AIO

image.png