多线程(11) — NIO

Posted wangyongwen

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了多线程(11) — NIO相关的知识,希望对你有一定的参考价值。

  Java NIO是new IO的简称,是一种可以替代Java IO的一套新的IO机制。它提供了一套不同于Java标准IO的操作机制,严格来说,NIO与并发并无直接关系,但是使用NIO技术可以大大提高线程的使用效率。Java NIO设计的基础内容有通道(Channel)、缓冲区(Buffer)、Selector(选择器)。下面说说这几个内容

1)通道(Channel)

  Channel:Channel是一对象,可以通过它读取和写入数据。可以把它看做是IO中的流,不同的是:

  • Channel是双向的,既可以读又可以写,而流是单向的
  • Channel可以进行异步的读写
  • 对Channel的读写必须通过buffer对象

  正如上面提到的,所有数据都通过Buffer对象处理,所以不会将字节写入到Channel中,而是将数据写入到Buffer中;不会从Channel中读取字节,而是将数据从Channel读入Buffer,再从Buffer获取这个字节。Channel可以比流更好地反映出底层操作系统的真实情况。特别是在Unix模型中,底层操作系统通常都是双向的。在Java NIO中的Channel主要有如下几种类型:

  • FileChannel:从文件读取数据的
  • DatagramChannel:读写UDP网络协议数据
  • SocketChannel:读写TCP网络协议数据
  • ServerSocketChannel:可以监听TCP连接

2)缓冲区(Buffer)

  Buffer是一对象,它包含一些要写入或者读到的Stream对象。应用程序不能直接对 Channel 进行读写操作,而必须通过 Buffer 来进行,即 Channel 是通过 Buffer 来读写数据的。在NIO中,所有的数据都是用Buffer处理的,它是NIO读写数据的中转池。Buffer实质上是一个数组,通常是一个字节数据,但也可以是其他类型的数组。但一个缓冲区不仅仅是一个数组,重要的是它提供了对数据的结构化访问,而且还可以跟踪系统的读写进程。使用 Buffer 读写数据一般遵循以下四个步骤:

  1. 写入数据到 Buffer;
  2. 调用 flip() 方法;
  3. 从 Buffer 中读取数据;
  4. 调用 clear() 方法或者 compact() 方法。

  当向 Buffer 写入数据时,Buffer 会记录下写了多少数据。一旦要读取数据,需要通过 flip() 方法将 Buffer 从写模式切换到读模式。在读模式下,可以读取之前写入到 Buffer 的所有数据。一旦读完了所有的数据,就需要清空缓冲区,让它可以再次被写入。有两种方式能清空缓冲区:调用 clear() 或 compact() 方法。clear() 方法会清空整个缓冲区。compact() 方法只会清除已经读过的数据。任何未读的数据都被移到缓冲区的起始处,新写入的数据将放到缓冲区未读数据的后面。Buffer主要有如下几种:

  • ByteBuffer
  • CharBuffer
  • DoubleBuffer
  • FloatBuffer
  • IntBuffer
  • LongBuffer
  • ShortBuffer

  CopyFile执行三个基本的操作:创建一个Buffer,然后从源文件读取数据到缓冲区,然后再将缓冲区写入目标文件。

public static void copyFileUseNIO(String src,String dst) throws IOException
    //声明源文件和目标文件
    FileInputStream fi=new FileInputStream(new File(src));
    FileOutputStream fo=new FileOutputStream(new File(dst));
    //获得传输通道channel
    FileChannel inChannel=fi.getChannel();
    FileChannel outChannel=fo.getChannel();
    //获得容器buffer
    ByteBuffer buffer=ByteBuffer.allocate(1024);
    while(true)
        //判断是否读完文件
        int eof =inChannel.read(buffer);
        if(eof==-1)
            break;  
        
        //重设一下buffer的position=0,limit=position
        buffer.flip();
        //开始写
        outChannel.write(buffer);
        //写完要重置buffer,重设position=0,limit=capacity
        buffer.clear();
    
    inChannel.close();
    outChannel.close();
    fi.close();
    fo.close();
   

三)Selector(选择器对象)

  Selector是一个对象,它可以注册到很多个Channel上,监听各个Channel上发生的事件,并且能够根据事件情况决定Channel读写。这样,通过一个线程管理多个Channel,就可以处理大量网络连接了。有了Selector,我们就可以利用一个线程来处理所有的channels。线程之间的切换对操作系统来说代价是很高的,并且每个线程也会占用一定的系统资源。所以,对系统来说使用的线程越少越好。Selector 就是注册对各种 I/O 事件的地方,而且当那些事件发生时,就是这个对象告诉您所发生的事件。

Selector selector = Selector.open();

  为了能让Channel和Selector配合使用,我们需要把Channel注册到Selector上。通过调用 channel.register()方法来实现注册:

channel.configureBlocking(false);
SelectionKey key =channel.register(selector,SelectionKey.OP_READ);

  注意,注册的Channel 必须设置成异步模式 才可以,否则异步IO就无法工作,这就意味着我们不能把一个FileChannel注册到Selector,因为FileChannel没有异步模式,但是网络编程中的SocketChannel是可以的。

  register()的调用的返回值是一个SelectionKey,代表这个通道在此 Selector 上注册。当某个 Selector 通知您某个传入事件时,它是通过提供对应于该事件的 SelectionKey 来进行的。SelectionKey 还可以用于取消通道的注册。

SelectionKey中包含如下属性:

(1)interestSet

  把Channel注册到Selector来监听感兴趣的事件,interestSet就是你要选择的感兴趣的事件的集合。可以通过SelectionKey对象来读写interest set:

int interestSet = selectionKey.interestOps();
boolean isInterestedInAccept  = interestSet & SelectionKey.OP_ACCEPT;
boolean isInterestedInConnect = interestSet & SelectionKey.OP_CONNECT;
boolean isInterestedInRead    = interestSet & SelectionKey.OP_READ;
boolean isInterestedInWrite   = interestSet & SelectionKey.OP_WRITE; 

  通过上面例子可以看到,我们可以通过用 & 和 SelectionKey 中的常量做运算,从SelectionKey中找到我们感兴趣的事件。

(2)readySet

  readySet 是通道已经准备就绪进行操作的集合。在一次选Selection之后,你应该会首先访问这个readySet。Selection将在下一小节进行解释。可以这样访问ready集合,也可以用像检测interest集合那样的方法,来检测Channel中什么事件或操作已经就绪:

int readySet = selectionKey.readyOps();
selectionKey.isAcceptable(); selectionKey.isConnectable(); selectionKey.isReadable(); selectionKey.isWritable();

(3)Channel 和 Selector

我们可以通过SelectionKey获得Selector和注册的Channel:

Channel  channel  = selectionKey.channel();
Selector selector = selectionKey.selector(); 

(4)Attach一个对象

  可以将一个对象或者更多信息attach 到SelectionKey上,这样就能识别某个给定的通道。例如,可以附加与通道一起使用的Buffer,或包含聚集数据对象。使用方法如下:

selectionKey.attach(theObject);
Object attachedObj = selectionKey.attachment();

  还可以在用register()方法向Selector注册Channel的时候附加对象。如:

SelectionKey key = channel.register(selector, SelectionKey.OP_READ, theObject);

NIO多路复用

主要步骤和元素:

  • 首先,通过 Selector.open() 创建一个 Selector,作为类似调度员的角色。

  • 然后,创建一个 ServerSocketChannel,并且向 Selector 注册,通过指定 SelectionKey.OP_ACCEPT,告诉调度员,它关注的是新的连接请求。

  • 注意,为什么我们要明确配置非阻塞模式呢?这是因为阻塞模式下,注册操作是不允许的,会抛出 IllegalBlockingModeException 异常。

  • Selector 阻塞在 select 操作,当有 Channel 发生接入请求,就会被唤醒。

  • 在具体的方法中,通过 SocketChannel 和 Buffer 进行数据操作

  IO 都是同步阻塞模式,所以需要多线程以实现多任务处理。而 NIO 则是利用了单线程轮询事件的机制,通过高效地定位就绪的 Channel,来决定做什么,仅仅 select 阶段是阻塞的,可以有效避免大量客户端连接时,频繁线程切换带来的问题,应用的扩展能力有了非常大的提高

 

下面用NIO设计一个Echo服务器:

首先定义一个Selector和线程池

private Selector selector;
private ExecutorService tp = Executors.newCachedThreadPool();

  selector处理所有的网络连接,tp线程池处理每一个客户端请求。为了统计服务器线程在客户端花费的时间,还需要定义一个时间统计有关的变量,用于统计在某一个Socket上花费的时间,time_stat的key为Socket,value为时间戳

public static Map<Socket,Long> time_stat = new HashMap<Socket,Long>(10240);

  下面来看一下NIO服务器的核心代码,startServer()方法用于启动NIO Server。

    private void startServer() throws IOException
        this.selector = SelectorProvider.provider().openSelector();
        ServerSocketChannel ssc = ServerSocketChannel.open();                          // 服务端SocketChannel
        ssc.configureBlocking(false);                                                  // 设置为非阻塞模式
        InetSocketAddress isa = new InetSocketAddress(InetAddress.getLocalHost(),8000);// 使用8000端口
        ssc.socket().bind(isa);
        SelectionKey acceptKey = ssc.register(selector, SelectionKey.OP_ACCEPT);   // 将ServerSocketChannel绑定到Selector上,感兴趣的时间为Accept
        for(;;)                     // 主要任务是等待-分发网络消息
            this.selector.select(); // 阻塞方法,如果当前没有准备好的的数据,就会等待,如果有的话返回已经准备好的SelectionKey数量
            Set<SelectionKey> readyKeys = this.selector.selectedKeys(); // 获取准备好的SelectionKey
            Iterator<SelectionKey> i = readyKeys.iterator();
            long e = 0;
            while(i.hasNext())
                SelectionKey sk = i.next();
                i.remove();// 处理一个删除一个,不然可能重复处理
                if(sk.isAcceptable())
                    doAccept(sk);
                else if(sk.isValid() && sk.isReadable())// 判断是否可以读
                    if(!time_stat.containsKey(((SocketChannel) sk.channel()).socket()))
                        time_stat.put(((SocketChannel) sk.channel()).socket(), System.currentTimeMillis());
                    
                    doRead(sk);
                else if(sk.isValid() && sk.isWritable()) // 判断是否可以写
                    doWrite(sk);
                    e = System.currentTimeMillis();
                    long b = time_stat.remove(((SocketChannel) sk.channel()).socket());
                    System.out.println("spend: "+(b-e)+"ms");
                
            
        
    

  在了解服务端整体框架后,下面从具体的方法中看看几个主要方法的使用:

    private void doAccept(SelectionKey sk) 
        ServerSocketChannel server = (ServerSocketChannel) sk.channel();
        SocketChannel clientChannel;
        try 
            clientChannel = server.accept();
            clientChannel.configureBlocking(false);// 非阻塞
            SelectionKey clientKey = clientChannel.register(selector, SelectionKey.OP_READ);//将Channel注册到Selector上,并告诉Selector对读感兴趣,Channel准备好读时给线程一个通知
            EchoClient ec = new EchoClient();
            clientKey.attach(ec);// 客户端实例作为附件,附加到表示这个连接的SelectionKey上,可以在整个连接过程共享ec
            InetAddress clientAddress  = clientChannel.socket().getInetAddress();
            System.out.println("Accepted connection from "+clientAddress.getHostAddress());
         catch (Exception e) 
    

  EchoClient封装一个队列,保存在需要恢复给这个客户端所有信息上,这样再进行回复,只要outq对象中弹出元素即可。

public class EchoClient 
    private LinkedList<ByteBuffer> outq;
    public EchoClient() 
        this.outq = new LinkedList<ByteBuffer>();
    
    public LinkedList<ByteBuffer> getOutq() 
        return outq;
    
    public void enqueue(ByteBuffer bb) 
        this.outq.addFirst(bb);
    

下面看看doRead()方法的实现。

    private void doRead(SelectionKey sk) 
        SocketChannel c = (SocketChannel) sk.channel();
        ByteBuffer bb = ByteBuffer.allocate(8192);
        int len;
        try 
            len = c.read(bb);// 存放读取的数据
            if(len<0)
                disconnect(sk);
                return;
            
         catch (Exception e) 
            System.out.println("Failed to read from client!");
            e.printStackTrace();
            disconnect(sk);
            return;
        
        bb.flip();
        tp.execute(new HandleMsg(sk,bb)); // 线程池处理数据
    

  HandleMsg的实现很简单:

public class HandleMsg implements Runnable

    SelectionKey sk;
    ByteBuffer bb;
    public HandleMsg(SelectionKey sk,ByteBuffer bb)
        this.sk = sk;
        this.bb = bb;
    
    @Override
    public void run() 
        EchoClient ec = (EchoClient) sk.attachment();
        ec.enqueue(bb);// 将收到的数据压入队列,业务逻辑也可以在这个地方处理了
        sk.interestOps(SelectionKey.OP_READ|SelectionKey.OP_WRITE);
        selector.wakeup();// 强迫Selector立即返回
    

  doWrite()代码如下,这个方法拿到的sk和doread()方法拿到的是同一个,通过这个sk可以操作共享的EchoClient

    private void doWrite(SelectionKey sk) 
        SocketChannel c = (SocketChannel) sk.channel();
        EchoClient ec = (EchoClient) sk.attachment();
        LinkedList<ByteBuffer> outq = ec.getOutq();
        ByteBuffer bb = outq.getLast();// 列表顶部元素,写回客户端
        try 
            int len = c.write(bb);
            if(len == -1)
                disconnect(sk);
                return;
            
            if(bb.remaining()== 0)
                outq.removeLast();// 缓冲区已经完成写,删除它
            
         catch (Exception e) 
            System.out.println("Failed to write to client.");
            e.printStackTrace();
            disconnect(sk);
            return;
        
        if(outq.size()==0)
            sk.interestOps(SelectionKey.OP_READ);
        
    

下面用NIO设计一个客户端

  首先初始化Selector和Channel

private Selector selector;
public void init(String ip,int port) throws IOException
    SocketChannel s = SocketChannel.open();
    s.configureBlocking(false);
    this.selector = SelectorProvider.provider().openSelector();
    s.connect(new InetSocketAddress(ip,port));// 并不定连接成功,需要finishConnect()确认
    s.register(selector, SelectionKey.OP_CONNECT);

  程序的工作执行逻辑,主要两件事,一个是链接就绪的Connect,一个是刻度的read()事件:

    public void working() throws IOException
        while(true)
            if(!this.selector.isOpen())
                break;
            
            this.selector.select();
            Iterator<SelectionKey> i = this.selector.selectedKeys().iterator();
            while(i.hasNext())
                SelectionKey key = i.next();
                i.remove();
                if(key.isConnectable())
                    connect(key);// 判断有没有完成连接,没有的话使用finishConnect()方法完成连接,并向Channel中写入数据及感兴趣的事情
                else if(key.isReadable())
                    read(key);
                
            
        
    

下面是read事件

    private void read(SelectionKey key) throws IOException 
        SocketChannel c = (SocketChannel) key.channel();
        ByteBuffer buffer = ByteBuffer.allocate(100);
        c.read(buffer);
        byte[] bs = buffer.array();
        String msg = new String(bs).trim();
        System.out.println("客户端收到信息:"+msg);
        c.close();
        key.selector().close();
    

 

以上是关于多线程(11) — NIO的主要内容,如果未能解决你的问题,请参考以下文章

网络编程 -- RPC实现原理 -- NIO多线程 -- 迭代版本V2

java nio并发访问问题,我现在利用nio框架制服务器的并发访问,SelectionKey多线程

Netty——NIO三大组件

Netty——NIO三大组件

网络编程 -- RPC实现原理 -- NIO多线程 -- 迭代版本V1

Java 实现一个的基于 NIO 的多线程Web服务器