NIO的核心三组件

    在NIO中有几个核心组件:选择器(Selector)、缓冲区(Buffer)、通道(Channel),具体了解一下,三者关系大致如下:

1、选择器selector

    在Java io演进的文章中我们知道,传统的 Server/Client 模式会基于TPR(Thread per Request),服务器会为每个客户端请求建立一个线程,由该线程单独负责处理一个客户请求。这种模式带来的一个问题就是线程数量的剧增,大量的线程会增大服务器的开销。大多数的实现为了避免这个问题, 都采用了线程池模型,并设置线程池线程的最大数量,这又带来了新的问题,如果线程池中有10个线程,而有10个用户都在进行大文件读写操作,会导致第11个用户的请求还是无法及时处理,即便第11个用户只是耗时极短的请求。
    所以为例满足高并发的需求,改进升级出了非阻塞NIO通信模型,NIO中非阻塞I/O采用了基于Reactor模式的工作方式,I/O调用不会被阻塞。NIO中实现非阻塞I/O的核心对象就是 Selector,Selector就是注册各种感兴趣的I/O事件SelectionKey地方,相反是注册感兴趣的特定I/O事件,当可读数据准备就绪或者新的套接字连接等发生特定事件时,Selector再通知应用程序去处理,其关系如下:
从图中可以看出,当有读或写等任何注册的事件发生时,可以从Selector中获得相应的SelectionKey,同时从SelectionKey中可以找到发生的事件和该事件所发生的具体的Channel,以获得客户端发送过来的数据。使用NIO中非阻塞I/O编写服务器处理程序,大体上可以分为下面三个步骤:
  • 1. 向Selector对象注册感兴趣的事件; 
  • 2. 从Selector中监听获取感兴趣的事件;
  • 3. 根据不同的事件进行相应的处理Handler;
接下来我们用一个简单的示例来说明这三个执行过程。

1.1、事件的注册

首先是向 Selector 对象注册感兴趣的事件,注册事件具体流程:
  • (1). 创建了ServerSocketChannel对象;
  • (2). 并调用configureBlocking()方法,配置为非阻塞模式;
  • (3). 接下来把通道绑定到指定端口;
  • (4). 最后向Selector中注册事件,此处指定的是参数是OP_ACCEPT,即指定我们想要监听accept事件,也就是新的连接发生时所产生的事件;
事件类型:
public abstract class SelectionKey {
    //读事件
    public static final int OP_READ = 1 << 0;
    //写事件
    public static final int OP_WRITE = 1 << 2;
    //连接事件
    public static final int OP_CONNECT = 1 << 3;
    //接受连接事件
    public static final int OP_ACCEPT = 1 << 4;
}

注:对于 ServerSocketChannel通道来说,我们唯一可以指定的参数就是OP_ACCEPT。

代码示例:
/*
* 注册事件
*
*/
private Selector getSelector() throws IOException {
    // 创建 Selector 对象
    Selector sel = Selector.open();
    // 创建可选择通道,并配置为非阻塞模式
    ServerSocketChannel server = ServerSocketChannel.open();
    //设置通信模式为非阻塞模型;
    server.configureBlocking(false);
    // 绑定通道到指定端口
    ServerSocket socket = server.socket();
    InetSocketAddress address = new InetSocketAddress(port);
    socket.bind(address);
    // 向 Selector 中注册感兴趣的事件
    server.register(sel, SelectionKey.OP_ACCEPT);
    return sel;
}

1.2、事件的监听

从Selector中获取感兴趣的事件,即开始监听,进入内部循环,在非阻塞I/O中,内部循环模式基本都是遵循这种方式。
  • (1). 首先调用select()方法,该方法会阻塞,直到至少有一个事件发生;
  • (2). 然后再使用selectedKeys()方法获取发生事件的SelectionKey;
  • (3). 再使用迭代器进行循环;
代码示例:
/*
* 开始监听
*/
public void listen() {
    System.out.println("listen on " + port);
    try {
        while (true) {
            // 该调用会阻塞,直到至少有一个事件发生
            selector.select();
            Set<SelectionKey> keys = selector.selectedKeys();
            Iterator<SelectionKey> iter = keys.iterator();
            while (iter.hasNext()) {
                SelectionKey key = (SelectionKey) iter.next();
                iter.remove();
                handler(key);
            }
        }
    } catch (IOException e) {
        e.printStackTrace();
    }
}

1.3、事件的处理

    最后一步就是根据不同的事件判断是接受请求、读数据还是写事件,分别作不同的处理。
    
/*
     * 根据不同的事件类型做不同的处理
     */
    private void handler(SelectionKey key) throws IOException {
        // 接收请求
        if (key.isAcceptable()) {
            ServerSocketChannel server = (ServerSocketChannel) key.channel();
            SocketChannel channel = server.accept();
            channel.configureBlocking(false);
            channel.register(selector, SelectionKey.OP_READ);
        }
        // 读信息请求处理
        else if (key.isReadable()) {
            SocketChannel channel = (SocketChannel) key.channel();
            int len = channel.read(buffer);
            if (len > 0) {
                buffer.flip();
                content = new String(buffer.array(), 0, len);
                SelectionKey sKey = channel.register(selector, SelectionKey.OP_WRITE);
                sKey.attach(content);
            } else {
                channel.close();
            }
            buffer.clear();
        }
        // 写事件处理
        else if (key.isWritable()) {
            SocketChannel channel = (SocketChannel) key.channel();
            String content = (String) key.attachment();
            ByteBuffer block = ByteBuffer.wrap(("输出内容:" + content).getBytes());
            if (block != null) {
                channel.write(block);
            } else {
                channel.close();
            }
        }
    }
}
    在Java1.4之前的I/O系统中,提供的都是面向流的I/O系统,系统一次一个字节地处理数据,一个输入流产生一个字节的数据,一个输出流消费一个字节的数据,面向流的I/O速度非常慢,而在 Java 1.4中推出了NIO,这是一个面向块的I/O系统,系统以块的方式处理处理,每一个操作在 一步中产生或者消费一个数据库,按块处理要比按字节处理数据快的多。

2、缓冲区Buffer

     缓冲区实际上是一个容器对象,更直接的说,其实就是一个数组,在NIO库中,所有数据都是用缓冲区处理的。在读 取数据时,它是直接读到缓冲区中的; 在写入数据时,它也是写入到缓冲区中的;任何时候访问NIO中的数据,都 是将它放到缓冲区中。而在面向流I/O系统中,所有数据都是直接写入或者直接将数据读取到Stream对象中。在NIO中,所有的缓冲区类型都继承于抽象类Buffer,最常用的就是 ByteBuffer,对于Java中的基本类型,基本都有 一个具体Buffer类型与之相对应,它们之间的继承关系如下图所示:

2.1、buffer的基本原理

    我们说缓冲区对象本质上是一个数组,但它其实是一个特殊的数组,缓冲区对象内置了一些机制,能够让我们跟踪和记录缓冲区的数据状态变化情况,如果我们使用get()方法从缓冲区获取数据或者使用put()方法把数据写入缓冲区时,都会引起缓冲区状态的变化。在缓冲区中,最重要的属性有三个,它们一起合作完成对缓冲区内部状态的变化跟踪:
  • position:缓冲区当前操作的元素索引位置,指定下一个将要被写入或者读取的元素索引,它的值由 get()/put()方法自动更新,最大可为capacity – 1;
  • limit: 表示缓冲区最多可操作的数据元素大小,指定还有多少数据需要取出(从缓冲区写入通道),或者还有多少空间可以放入数据(从通道读入缓冲区);
  • capacity: 缓冲区的总容量大小;作为一个内存块,Buffer数组有一个固定的大小值,只能往里写capacity个byte、long,char等类型;
Buffer源码如下:
public abstract class Buffer {

    // Invariants: mark <= position <= limit <= capacity
    private int mark = -1;
    private int position = 0;
    private int limit;
    private int capacity;

    // Creates a new buffer with the given mark, position, limit, and capacity,
    // after checking invariants.
    //
    Buffer(int mark, int pos, int lim, int cap) {       // package-private
        if (cap < 0)
            throw new IllegalArgumentException("Negative capacity: " + cap);
        this.capacity = cap;
        limit(lim);
        position(pos);
        if (mark >= 0) {
            if (mark > pos)
                throw new IllegalArgumentException("mark > position: ("
                                                   + mark + " > " + pos + ")");
            this.mark = mark;
        }
    }
}
/*标记(Mark):一个备忘位置。
标记在设定前是未定义的(undefined)。使用场景是,假设缓冲区中有 10 个元素,position 目前的位置为 2(也就是如果get的话是第三个元素),
现在只想发送 6 - 10 之间的缓冲数据,此时我们可以 buffer.mark(buffer.position()),即把当前的 position 记入 mark 中,
然后 buffer.postion(6),此时发送给 channel 的数据就是 6 - 10 的数据。
发送完后,我们可以调用 buffer.reset() 使得 position = mark,因此这里的 mark 只是用于临时记录一下位置用的。
*/

2.2、buffer的操作

    以上三个属性值之间有一些相对大小的关系 :0 <= position <= limit <= capacity。例如如果我们创建一个新的容量大小为8的ByteBuffer对象,在初始化的时候,position设置为0,limit和capacity被设置为8,当写数据到Buffer中时,position表示当前的位置,当一个byte、long等数据写到Buffer后, position会向前移动到下一个可插入数据的Buffer单元。在以后使用ByteBuffer对象过程中,capacity也就是缓冲区总容量大小的值不会再发生变化,而其它两个通过调用flip()随读写的使用而发生变化。下图展示了在缓冲区操作过程中position、limit和capacity这几个值的变化过程:
对缓冲区的操作及三个属性值的变化代码示例:
public class BufferDemo {
    public static void main(String args[]) throws Exception {  
        //读取一个文件,文件IO处理
        FileInputStream fin = new FileInputStream("bufferTest.txt");
        //创建文件的操作管道
        FileChannel fc = fin.getChannel();  
  
        //分配一个8个大小缓冲区,说白了就是分配一个8个大小的byte数组
        ByteBuffer buffer = ByteBuffer.allocate(8);  
        output("初始化", buffer);  
        
        //将文件数据写到缓冲区
        fc.read(buffer);  
        output("调用read()", buffer);  
  
        //准备操作之前,先锁定操作范围,转换为读操作;
        buffer.flip();  
        output("调用flip()", buffer);  
  
        //判断有没有可读数据
        while (buffer.remaining() > 0) {  
            byte b = buffer.get();  
            // System.out.print(((char)b));  
        }  
        output("调用get()", buffer);  
  
        //清空缓冲区
        buffer.clear();  
        output("调用clear()", buffer);  
  
        //最后把管道关闭
        fin.close();  
    }  

    //把这个缓冲里面实时状态给打印出来
    public static void output(String step, ByteBuffer buffer) {
        System.out.println(step + " : ");
        //容量,数组大小
        System.out.print("capacity: " + buffer.capacity() + ", ");
        //当前操作数据所在的位置,也可以叫做游标
        System.out.print("position: " + buffer.position() + ", ");
        //锁定值,flip,数据操作范围索引只能在position - limit 之间
        System.out.println("limit: " + buffer.limit());
        System.out.println();
    }
}

2.3、缓冲区的分配

    在使用的过程中,可以了解到,创建一个缓冲区对象时,会调用静态方法allocate()来指定缓冲区的容量,其实调用allocate()相当于创建了一个指定大小的数组,并把它包装为缓冲区对象,或者我们也可以直接将一个现有的数组,包装为缓冲区对象。
代码如下所示:
/**
* 手动分配缓冲区
*/
public class BufferWrap {  
    public void myMethod() {  
        // 分配指定大小的缓冲区  
        ByteBuffer buffer1 = ByteBuffer.allocate(8);  
          
        // 包装一个现有的数组  
        byte array[] = new byte[8];  
        ByteBuffer buffer2 = ByteBuffer.wrap( array );
    }
}

2.4、缓冲区分片

    在NIO中,除了可以分配或者包装一个缓冲区对象外,还可以根据现有的缓冲区对象来创建一个子缓冲区,即在现有缓冲区上切出一片来作为一个新的缓冲区,但现有的缓冲区与创建的子缓冲区在底层数组层面上是数据共享的,也就是说,子缓冲区相当于是现有缓冲区的一个子视图窗口,我们可以对缓冲区的部分数据进行灵活的操作,调用slice()方法就可以创建一个子缓冲区。
代码示例如下:
/**
* 缓冲区分片
*/
public class BufferSlice {  
    static public void main( String args[] ) throws Exception {  
        ByteBuffer buffer = ByteBuffer.allocate(8);  
          
        // 缓冲区中的数据0-7
        for (int i=0; i<buffer.capacity(); ++i) {  
            buffer.put( (byte)i );  
        }  
          
        // 创建子缓冲区  
        buffer.position( 3 );  
        buffer.limit( 7 );  
        ByteBuffer slice = buffer.slice();  
          
        // 改变子缓冲区的内容  
        for (int i=0; i<slice.capacity(); ++i) {  
            byte b = slice.get( i );  
            b *= 2;  
            slice.put( i, b );  
        }  
          
        buffer.position( 0 );  
        buffer.limit( buffer.capacity() );  
          
        while (buffer.remaining()>0) {  
            System.out.print( buffer.get()+ " " );
        }  
    }  
}

结果:

0 1 2 6 8 10 12 7。//可以看到3-4-5-6,变成了6-8-10-12。

2.5、只读缓冲区

    只读缓冲区非常简单,可以读取它们,但是不能向它们写入数据。可以通过调用缓冲区的asReadOnlyBuffer()方法,将任何常规缓冲区转换为只读缓冲区,这个方法返回一个与原缓冲区完全相同的缓冲区,并与原缓冲区共享数据,只不过它是只读的; 但是如果原缓冲区的内容发生了变化,只读缓冲区的内容也随之发生变化。
代码示例如下:
/**
* 只读缓冲区
*/
public class ReadOnlyBuffer {  
   static public void main( String args[] ) throws Exception {  
      ByteBuffer buffer = ByteBuffer.allocate( 8 );
       
      // 缓冲区中的数据0-7
      for (int i=0; i<buffer.capacity(); ++i) {  
         buffer.put( (byte)i );  
      }  
   
      // 创建只读缓冲区  
      ByteBuffer readonly = buffer.asReadOnlyBuffer();
      readonly.position(0);
      readonly.limit(buffer.capacity());
      // 原缓冲区数据改变之前,只读缓冲区的内容
      System.out.print( "before change:");
      while (readonly.remaining()>0) {
         System.out.print( readonly.get() + " ");
      }
      System.out.print("\n");


      // 改变原缓冲区的内容  
      for (int i=0; i<buffer.capacity(); ++i) {  
         byte b = buffer.get( i );  
         b *= 2;
         buffer.put( i, b );  
      }  
       
      readonly.position(0);  
      readonly.limit(buffer.capacity());  
       
      //原缓冲区的数据改变,只读缓冲区的内容也随之改变
      System.out.print( "after change:");
      while (readonly.remaining()>0) {  
         System.out.print( readonly.get() + " ");
      }
   }
}

结果可以看到对原缓冲区的修改,制度缓冲区也被修改:

before change:0 1 2 3 4 5 6 7 
after change:0 2 4 6 8 10 12 14

    如果尝试修改只读缓冲区的内容,则会报ReadOnlyBufferException异常。只读缓冲区对于保护数据很有用。在将缓冲区传递给某 个 对象的方法时,无法知道这个方法是否会修改缓冲区中的数据。创建一个只读的缓冲区可以保证该缓冲区不会被修改。只可以把常规缓冲区转换为只读缓冲区,而不能将只读的缓冲区转换为可写的缓冲区。

2.6、直接缓冲区

    这里的直接缓冲区也就是我们常说的零拷贝技术,直接缓冲区是为加快I/O速度,使用一种特殊方式为其分配内存的缓冲区。正常的java数据处理都需要将数据从磁盘文件拷贝到JVM的堆内存中进行,但是直接缓冲区避免了这一操作,直接使用堆外内存,对系统的内存进行操作,屏蔽了数据拷贝的性能消耗,jvm中存储的只是数据地址的引用,没有真正的数据。要分配直接缓冲区,需要调用allocateDirect()方法,而不是allocate()方法。  
代码示例如下:
/**
* 直接缓冲区
* Zero-Copy 减少了数据拷贝
*/
public class DirectBuffer {  
    static public void main( String args[] ) throws Exception {  

        //在Java里面存的只是缓冲区的引用地址
       //首先我们从磁盘上读取刚才我们写出的文件内容
        String infile = "DirectBufferTest.txt";
        FileInputStream fin = new FileInputStream( infile );  
        FileChannel fcin = fin.getChannel();

        //把刚刚读取的内容写入到一个新的文件中
        String outfile = String.format("DirectBufferTestCopy.txt");
        FileOutputStream fout = new FileOutputStream(outfile);
        FileChannel fcout = fout.getChannel();  
          
        // 使用allocateDirect,而不是allocate,使用直接缓存不经过jvm的缓冲区内存,直接使用了操作系统的内存,少了中间环节;
        ByteBuffer buffer = ByteBuffer.allocateDirect(1024);  
          
        while (true) {  
            buffer.clear();  
              
            int r = fcin.read(buffer);  
              
            if (r==-1) {  
                break;  
            }
            buffer.flip();
            //写如拷贝文件
            fcout.write(buffer);  
        }
   }  
}
堆外内存的回收
    jvm对堆外内存的申请和释放没有堆内内存管理的效率高, 因为对于堆外内存,jvm存储的只是数据的地址引用,并不是真正的数据。JDK中使用DirectByteBuffer对象来表示堆外内存,可以通过-XX:MaxDirectMemorySize来指定最大的堆外内存,每个DirectByteBuffer对象在初始化时,都会创建一个对应的Cleaner对象,在Cleaner对象回收的时候就会回收这部分堆外内存。这里的Cleaner对象其实是PhantomReference的一个子类,通过ReferenceQueue来保存需要回收的Cleaner对象, 当对象不可达时地址指向的内存通过(allocate-free)进行内存释放回收。

2.7、IO内存映射

    内存映射是一种效率更高的读写文件数据的方法,它可以比常规的基于流或者基于通道的I/O快的多。 它能够将内存中更新操作直接持久化,反应到磁盘上,我们不需要操作文件,内存映射文件I/O是通过将磁盘文件中的数据与操作系统的内存关联,直接操作内存即可,通过操作系统的page缓存来实现物理内存的直接映射,完成映射后,对物理内存的操作修改会直接同步到磁盘文件上,效率很高。
代码示例:
/**
* IO内存映射缓冲区
*/
public class MappedBuffer {

    static private final int start = 0;
    static private final int size = 9;
      
    static public void main( String args[] ) throws Exception {  
        RandomAccessFile raf = new RandomAccessFile( "MapperByteBuffertest.txt", "rw" );
        FileChannel fc = raf.getChannel();
        
        //把缓冲区跟文件系统进行一个映射关联,创建内存映射缓冲区
        MappedByteBuffer mbb = fc.map(FileChannel.MapMode.READ_WRITE,start, size );
        //在缓存中改变前两个字节的内容,磁盘文件MapperByteBuffertest.txt也随之改变,在ASSIC中65-A, 66-B,
        mbb.put( 0, (byte)65 );  //A
        mbb.put( 1, (byte)66 );  //B

        raf.close();  
    }  
}

3、通道channel

     Channel是Java NIO的一个基本构造。它代表一个实体(如一个硬件设备,一个文件,一个网络套接字或者一个能够执行一个或者多个不同的I/O操作的程序组件)的开放连接,如读操作和写操作。它是一个管道,用于连接字节缓冲区Buf和另一端的实体,这个实例可以是Socket,也可以是File, 在Nio网络编程模型中, 服务端和客户端进行IO数据交互(得到彼此推送的信息)的媒介就是Channel。我们不会将字节直接写入通道中,相反是将数据写入包含一个或者多个字节的缓冲区;同样不会直接从通道中读取字节,而是将数据从通道读入缓冲区,再从缓冲区获取这个字节。
    在NIO中,提供了多种通道对象,而所有的通道对象都实现了 Channel 接口。它们之间的继承关系如下图所示:

3.1、NIO读数据

任何时候读取数据,都不是直接从通道读取,而是从通道读取到缓冲区。所以使用NIO读取数据可以分为下面三个步骤:
  • 1. 从FileInputStream获取Channel
  • 2. 创建缓冲区Buffer;
  • 3. 将数据从Channel读取到Buffer中;
代码示例如下:
public class FileInputDemo {
    static public void main( String args[] ) throws Exception {  
        FileInputStream fin = new FileInputStream("input.txt");
        // 1、获取通道  
        FileChannel fc = fin.getChannel();  
        // 2、创建缓冲区  
        ByteBuffer buffer = ByteBuffer.allocate(1024);  
        // 3、读取数据到缓冲区  
        fc.read(buffer);  
        buffer.flip();  
          
        while (buffer.remaining() > 0) {  
            //获取数据
            byte b = buffer.get();  
            System.out.print(((char)b));  
        }  
        fin.close();
    }  
}

3.2、NIO写数据

使用NIO写入数据与读取数据的过程类似,同样数据不是直接写入通道,而是写入缓冲区,可以分为下面四个步骤: 
  • 1. 从FileInputStream获取Channel;
  • 2. 创建 Buffer;
  • 3. 将数据写入到Buffer中;
  • 4. 通过channel写入到文件;
代码实现如下:
public class FileOutputDemo {
    static private final byte message[] = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };
  
    static public void main( String args[] ) throws Exception {  
        FileOutputStream fout = new FileOutputStream( "output.txt" );
        //1、从FileOutputStream中获取channel
        FileChannel fc = fout.getChannel();  
        //2、创建缓冲区buffer;
        ByteBuffer buffer = ByteBuffer.allocate( 1024 );  
        //3、将数据写入到buffer;
        for (int i=0; i<message.length; ++i) {  
            buffer.put( message[i] );  
        }  
        //改变缓冲区position/limit的数据
        buffer.flip();   
        //4、通过channel写入到文件;
        fc.write( buffer );  
          
        fout.close();  
    }  
}

3.3、Netty中的channel

    Netty对Jdk原生的ServerSocketChannel进行了封装和增强封装成了NioXXXChannel,例如客户端和服务端:
  • 服务端: NioServerSocketChannel
  • 客户端: NioSocketChannel
 相对于原生的JdkChannel, Netty的Channel增加了如下的组件信息:
  • id 标识唯一身份信息;
  • 可能存在的parent Channel;
  • 管道pipeline;每个channel有且仅有一个pipeline与其对应,类似责任链模式实现了事件的传播处理;
  • 用于数据读写的unsafe内部类,读写;
  • 关联上与channel相伴终生的NioEventLoop,一个channel的所有请求都只会是一个线程来处理,加上pipeline的串行设计思路,避免了线程安全问题;
源码如下:
public abstract class AbstractChannel extends DefaultAttributeMap implements Channel {
    protected AbstractChannel(Channel parent) {
        this.parent = parent;
        id = newId();
        unsafe = newUnsafe();
        pipeline = newChannelPipeline();
    }
}

每个channel都是唯一的,这里看一个channelId的生成算法,为了保证唯一性过程还是比较严谨的。

//channelid的初始化:id.init();
private void init() {
    int i = 0;

    // machineId
    System.arraycopy(MACHINE_ID, 0, data, i, MACHINE_ID_LEN);
    i += MACHINE_ID_LEN;

    // processId
    i = writeInt(i, PROCESS_ID);

    // sequence
    i = writeInt(i, nextSequence.getAndIncrement());

    // timestamp (kind of)
    i = writeLong(i, Long.reverse(System.nanoTime()) ^ System.currentTimeMillis());

    // random
    int random = ThreadLocalRandom.current().nextInt();
    hashCode = random;
    i = writeInt(i, random);

    assert i == data.length;
}

4、Netty的高性能

4.1、异步非阻塞通信模式

    在IO编程过程中,当需要同时处理多个客户端接入请求时,可以利用多线程或者IO多路复用技术进行处理。IO多路复用技术通过把多个IO的阻塞复用到同一个select的阻塞上,从而使得系统在单线程的情况下可以同时处理多个客户端请求。与传统的多线程/多进程模型比 ,I/O多路复用的最大优势是系统开销小,系统不需要创建新的额外进程或者线程,也不需要维护这些进程和线程的运行,降低了系统的维护工作量,节省了系统资源。
    JDK1.4提供了对非阻塞IO(NIO)的支持,JDK1.5版本使用epoll替代了传统的select/poll,极大的提升了NIO通信的性能。
    Netty的IO线程NioEventLoop聚合了多路复用器 Selector,可以同时并发处理成百上千个客户端Channel,由于读写操作都是非阻塞的,这就可以充分提升IO线程的运行效率,避免由于频繁IO阻塞导致的线程挂起。另外, 由于Netty采用了异步通信模式,一个IO线程可以并发处理 N 个客户端连接和读写操作,这从根本上解决了传统同步阻塞 IO “一个连接一个线程”模型,架构的性能、弹性伸缩能力和可靠性都得到了极大的提升。

4.2、零拷贝

Netty 的“零拷贝”主要体现在如下三个方面:
  • (1). Netty的接收和发送ByteBuffer采用直接缓存Direct Buffer,使用堆外直接内存进行Socket读写,不需要进行字节缓冲区的二次拷贝。如果使用传统的堆内存Heap Buffer进行Socket读写,JVM会将堆内存Buffer拷贝一份到堆内存中,然后再油堆内存写入到Socket中,相比于堆外直接内存,消息在发送过程中多了一次缓冲区的内存拷贝;
  • (2). Netty提供了组合分片Buffer对象,可以聚合多个ByteBuffer对象,用户可以像操作一个Buffer那样方便的对组合Buffer进行操作,避免了传统通过内存拷贝的方式将几个小Buffer合并成一个大的Buffer;
  • (3). Nett 的文件传输采用了transferTo()方法,它可以直接将文件缓冲区的数据发送到目标Channel,避免了传统通过循环write()方式导致的内存拷贝问题;

4.3、高效的Reactor线程模型

  • Reactor单线程模型;
  • Reactor多线程模型;
  • Reactor主从线程模型;
上节在 Java IO演进详细介绍了,这节省略了。

4.4、无锁化串行设计

(1). 串行设计结构-责任链模式
    在大多数场景下,并行多线程处理可以提升系统的并发性能。但是,如果对于共享资源的并发访问处理不当,会带来 严重的锁竞争,这最终会导致性能的下降。为了尽可能的避免锁竞争带来的性能损耗,可以通过串行化设计,即消息的处理尽可能在同一个线程内完成,期间不进行线程切换,这样就避免了多线程竞争和同步锁。 为了尽可能提升性能,Netty采用了串行无锁化设计,利用类似于双向链表的结构使所有的handler处理在IO线程内部进行串行传播完成,采用 责任链模式处理每个请求handler,避免多线程竞争导致的性能下降。其大致运行逻辑结构图如下:
(2). EventLoop的任务调度
    Netty线程模型的卓越性能取决于对于当前提交的任务的执行Thread身份的确定 ,也就是说,确定它是否是分配给当前Channel以及它的EventLoop的那一个线程。 因为EventLoop将负责处理一个 Channel 的整个生命周期内的所有事件。
    如果当前调用线程正是支撑 EventLoop 的线程,那么所提交的代码块将会被直接执行。否则, EventLoop将调度该任务以便稍后执行,并将它放入到内部队列中,当EventLoop下次处理它的事件时,它会执行队列中的那些任务/事件。这也就解释了任何的Thread是如何与Channel直接交互而无需在ChannelHandler中进行额外同步的。
注意,每个 EventLoop 都有它自已的任务队列,独立于任何其他的 EventLoop 。EventLoop调度任务的执行逻辑图如下(图片参考Netty实战第七章):
注意:远不要将一个长时间运行的任务放入到执行队列中,因为它将阻塞需要在同一线程上执行的任何 其他任务。”如果必须要进行阻塞调用或者执行长时间运行的任务,我们建议使用一个专门的EventExecutor 。
(3). EventLoop-线程的分配
    服务于Channel的I/O和事件的EventLoop包含在EventLoopGroup中。根据不同的传输实现,EventLoop的创建和分配方式也不同。
     异步传输实现只使用了少量的EventLoop以及和它们相关联的Thread,而且在当前的线程模型中,它们可能会被多个Channel所共享。这使得可以通过尽可能少量的Thread来支撑大量的Channel,而不是每个Channel分配一个Thread 。非阻塞传输的EventLoop分配方式逻辑图如下(图片参考Netty实战第七章):
    上图展示了一个EventLoopGroup,它具有3个固定大小的EventLoop,每个EventLoop都由一个Thread来支撑,在创建EventLoopGroup时就直接分配了 EventLoop及绑定的Thread,以确保在需要时它们是可用的。EventLoopGroup负责为每个新创建的Channel分配一个EventLoop。在当前实现中,使用 顺序循环(round-robin)的方式进行分配以获取一个均衡的分布,并且相同的EventLoop可能会被分配给多个Channel。
    一旦一个Channel被分配给一个EventLoop,它将在它的整个生命周期中都使用这个EventLoop。这点很重要,因为它可以使你从担忧你的Channel-Handler实现中的线程安全和同步问题中解脱出来。

4.5、高效的序列化

序列化的影响因素:
  • (1). 序列化后的码流大小(网络带宽的占用);
  • (2). 序列化&反序列化的性能(CPU 资源占用);
  • (3). 是否支持跨语言(异构系统的对接和开发语言切换);
Netty默认提供了对Google Protobuf的支持,通过扩展Netty的编解码接口,用户可以实现其它的高性能序列化框架,扩展非常灵活。

4.6、高效的并发编程

Netty的实现也是封装了JUC里的很多并发操作类,好的高效的框架一定是有很多优良细节设计的体现,主要体现在:
  • (1). volatile 的大量、正确使用;
  • (2). CAS 和原子类的广泛使用;
  • (3). 线程安全容器的使用;
  • (4). 通过读写锁及无锁编程设计提升并发性能。

5、小结

    此篇主要介绍了NIO的三个核心的组件selector、buffer、channel的具体使用和实现,这是实现非阻塞io的基石,以及Netty的使用和高性能,简单了解一下。
 
 
 
OK---志不强者智不达,言不信者行不果。
 
水滴石穿,积少成多。学习笔记,内容简单,用于复习,梳理巩固。
 
 
参考资料:
《Netty实战》
 
 
 
 
 
 
 
  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值