Skip to content

Java中的NIO、BIO、AIO

IO模型

  • 就是用什么样的通道进行数据的发送和接收。很大程度解决了程序通信的性能。
  • Java共支持3种网络编程模型/IO模型:BIO、NIO、AIO

BIO(传统阻塞型)

BIO(Blocking IO):同步阻塞IO,原生的JavaIO。服务器实现模式为1个连接1个线程,即客户端有连接请求时服务器端就需要启动1个线程进行处理,如果这个连接不做任何事情会造成不必要的线程开销。可以通过线程池机制改善。BIO相关的类和接口在java.io包下。BIO方式适用于连接数目较少且固定的架构,这种方式对服务器资源要求较高,并发局限于应用中,JDK1.4之前的唯一选择。简单的工作流程如下:

  1. 服务器端启动一个ServerSocket
  2. 客户端启动Socket对服务器进行通信,默认情况下服务器端需要对每个客户 建立一个线程与之通讯
  3. 客户端发出请求后, 先咨询服务器是否有线程响应,如果没有则会等待,或者被拒绝
  4. 如果有响应,客户端线程会等待请求结束后,在继续执行

BIO示例代码

java
import java.io.IOException;
import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class BIOTest {

    public static void main(String[] args) throws Exception{

        //1.创建一个线程池
        ExecutorService executorService = Executors.newCachedThreadPool();

        //创建ServerSocket
        ServerSocket serverSocket = new ServerSocket(12345);

        System.out.println("服务端启动!");

        while (true){

            //监听,等待客户端连接 - accept()方法如果没有得到客户端连接,会阻塞在这里
            final Socket socket = serverSocket.accept();

            System.out.println("有客户端连接进入!");

            //2.如果有客户端连接,就创建一个线程,与之通讯
            executorService.execute(() ->{
                hander(socket);
            });
        }
    }

    //hander方法,和客户端通讯
    public static void hander(Socket socket){
        try {
            byte[] bytes = new byte[1024];

            //通过socket,获取输入流
            InputStream inputStream = socket.getInputStream();
            //循环读取客户端发送的数据
            while (true){
                //read()方法会阻塞,如果没有读到数据,会阻塞在这里
                int read = inputStream.read(bytes);
                //不为-1,代表可以继续读
                if(read != -1){
                   //输出当前线程ID,和线程名
                    System.out.println(Thread.currentThread().getId() + " -> " + Thread.currentThread().getName());
                    //输出接收到的数据
                    System.out.println(new String(bytes,0,read));
                } else {
                    System.out.println("数据读取完毕!");
                    break;
                }
            }
        }catch (Exception e){
            e.printStackTrace();
        }finally {
            System.out.println("关闭client连接");
            try {
                socket.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

BIO示例代码测试及问题分析

代码测试:

  • 打开cmd黑窗口,输入命令:telnet 127.0.0.1 12345
  • 按下快捷键:ctrl键+]键(右大括号) - 进入发送数据send模式
  • 发送数据:send hello world 问题分析:
  1. 每个请求都需要创建独立的线程,与对应的客户端进行数据 Read,业务处理,数据 Write
  2. 当并发数较大时,需要创建大量线程来处理连接,系统资源占用较大
  3. 连接建立后,如果当前线程暂时没有数据可读,则线程就阻塞在 Read 操作上,造成线程资源浪费

NIO(同步非阻塞型)

NIO(Non-Blocking IO,也叫New IO):同步非阻塞IO。从 JDK1.4 开始,Java 提供了一系列改进的输入/输出的新特性。服务器实现模式为1个线程处理多个请求,即客户端发送的连接请求都会注册到多路复用器(Selector)上,多路复用器轮询到有IO请求就进行处理。

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

Buffer简单使用

java
import java.nio.Buffer;
import java.nio.IntBuffer;

public class NIOTest {

    public static void main(String[] args) {
        //buffer的使用
        //创建一个buffer,大小为5,可以存放5个int
        IntBuffer intBuffer = IntBuffer.allocate(5);

        //向buffer中存放数据,intBuffer.capacity()为它的容量
        for (int i = 0; i < intBuffer.capacity(); i++) {
            intBuffer.put(i);
        }

        //从buffer中读取数据,flip()方法将buffer转换,读写切换,
        //也就是在写完数据需要切换一下来读取数据
        Buffer flip = intBuffer.flip();

        //读取数据
        while (intBuffer.hasRemaining()){
            System.out.println(intBuffer.get());
        }
    }
}

缓冲区(Buffer)

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

Buffer类及其子类

java
Buffer类(父类)中包含4个属性:

//标记
private int mark = -1;	
//位置,下一个要被读或写的元素的索引,每次读写缓冲区数据时都会改变改值,为下次读写作准备
private int position = 0;
//表示缓冲区的当前终点,不能对缓冲区超过极限的位置进行读写操作。且极限是可以修改的
private int limit;	
//容量,即可以容纳的最大数据量;在缓冲区创建时被设定并且不能改变
private int capacity; 


举例:
buffer刚创建时,capacity=5,不可变。position=0,limit=5
不能对缓冲区超过极限的位置limit进行读写操作,也就是读的时候只能从0读到5(0,1,2,3,4),不可以超过5。
当执行flip()方法后:
public final Buffer flip() {
    limit = position;//表示之后读数据时,不能超过position
    position = 0;
    mark = -1;
    return this;
}


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( )//重绕此缓冲区
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();//告知此缓冲区是否为直接缓冲区


Buffer子类:每个子类中都有1个数组与之对应,Buffer子类包含:
ByteBuffer,存储字节数据到缓冲区			数组:final byte[] hb;
ShortBuffer,存储字符串数据到缓冲区		数组:final short[] hb;
CharBuffer,存储字符数据到缓冲区			数组:final char[] hb; 
IntBuffer,存储整数数据到缓冲区		数组:final int[] hb;
LongBuffer,存储长整型数据到缓冲区		数组:final long[] hb; 
DoubleBuffer,存储小数到缓冲区			数组:final double[] hb;
FloatBuffer,存储小数到缓冲区			数组:final float[] hb;


常用的ByteBuffer:
对于Java中的基本数据类型(boolean除外),都有一个Buffer类型与之相对应,最常用的自然是ByteBuffer类
(二进制数据),该类的主要方法如下:
public abstract class ByteBuffer {
   //缓冲区创建相关api
   public static ByteBuffer allocateDirect(int capacity)//创建直接缓冲区
   public static ByteBuffer allocate(int capacity)//设置缓冲区的初始容量
   public static ByteBuffer wrap(byte[] array)//把一个数组放到缓冲区中使用
   //构造初始化位置offset和上界length的缓冲区
   public static ByteBuffer wrap(byte[] array,int offset, int length)
    //缓存区存取相关API
   public abstract byte get( );//从当前位置position上get,get之后,position会自动+1
   public abstract byte get (int index);//从绝对位置get
   public abstract ByteBuffer put (byte b);//从当前位置上添加,put之后,position会自动+1
   public abstract ByteBuffer put (int index, byte b);//从绝对位置上put
}

Channel

  1. NIO的通道类似于流,但有些区别如下:
    1. 通道可以同时进行读写,而流只能读或者只能写
    2. 通道可以实现异步读写数据
    3. 通道可以从缓冲读数据,也可以写数据到缓冲
  2. BIO中的stream是单向的,例如FileInputStream对象只能进行读取数据的操作,而NIO中的通道(Channel)是双向的,可以读操作,也可以写操作。
  3. Channel在NIO中是一个接口,public interface Channel extends Closeable{}
  4. 常用的Channel类有:FileChannel、DatagramChannel、ServerSocketChannel和SocketChannel。ServerSocketChanne 类似 ServerSocket , SocketChannel 类似 Socket】
  5. FileChannel用于文件的数据读写,DatagramChannel用于UDP的数据读写,ServerSocketChannel和SocketChannel用于TCP的数据读写。
java
FileChannel类
FileChannel主要用来对本地文件进行 IO 操作,常见的方法有:
//从通道读取数据并放到缓冲区中
1.public int read(ByteBuffer dst)
//把缓冲区的数据写到通道中
2.public int write(ByteBuffer src)
//从目标通道中复制数据到当前通道
3.public long transferFrom(ReadableByteChannel src,long position,long count)
//把数据从当前通道复制给目标通道
4.public long transferTo(long position, long count, WritableByteChannel target)

FileChannel - 本地文件写数据示例代码

java
import java.io.FileOutputStream;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;

public class DemoTest {

    public static void main(String[] args) throws Exception{

        String str = "Hello World";

        //创建一个输出流
        FileOutputStream fileOutputStream = new FileOutputStream("D:\\testfile.txt");

        //通过输出流,获取对应的文件FileChannel
        //FileChannel的真实类型是 FileChannelImpl
        FileChannel fileChannel = fileOutputStream.getChannel();

        //创建一个缓冲区 bytebuffer
        ByteBuffer byteBuffer = ByteBuffer.allocate(1024);

        //将str放入byteBuffer
        byteBuffer.put(str.getBytes());

        //对byteBuffer进行翻转,flip
        byteBuffer.flip();

        //将byteBuffer数据写入到channel
        fileChannel.write(byteBuffer);

        //关闭流
        fileOutputStream.close();

    }
}

FileChannel - 本地文件读数据示例代码

java
import java.io.File;
import java.io.FileInputStream;
import java.nio.*;
import java.nio.channels.FileChannel;

public class NIOTest {

    public static void main(String[] args) throws Exception{

        //创建一个输入流
        File file = new File("D:\\testfile.txt");
        FileInputStream fileInputStream = new FileInputStream(file);

        //通过输入流,获取对应的文件FileChannel,FileChannel的真实类型是 FileChannelImpl
        FileChannel fileChannel = fileInputStream.getChannel();

        //创建一个缓冲区 bytebuffer,根据文件大小,生成buffer长度,避免资源浪费
        ByteBuffer byteBuffer = ByteBuffer.allocate((int) file.length());

        //将channel通道的数据,读到buffer中
        fileChannel.read(byteBuffer);

        //将缓冲区bytebuffer中的字节数据,转为字符串
        System.out.println(new String(byteBuffer.array()));

        //关闭流
        fileInputStream.close();
        
    }
}

FileChannel - 使用一个Buffer完成文件读取

java
package com.cnpc.oms2rtocrtdsocket;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.nio.*;
import java.nio.channels.FileChannel;

public class NIOTest {

    public static void main(String[] args) throws Exception{

        //将 testfile.txt 内容读取后写入到 testfile222.txt 中
        //创建一个输入流
        FileInputStream fileInputStream = new FileInputStream("D:\\testfile.txt");
        //通过输入流,获取对应的文件FileChannel,FileChannel的真实类型是 FileChannelImpl
        FileChannel fileInputChannel = fileInputStream.getChannel();


        //创建一个输出流
        FileOutputStream fileOutputStream = new FileOutputStream("D:\\testfile222.txt");
        //通过输出流,获取对应的文件FileChannel,FileChannel的真实类型是 FileChannelImpl
        FileChannel fileOutputChannel = fileOutputStream.getChannel();

        //创建一个缓冲区 bytebuffer,大小也可根据实际情况设定
        ByteBuffer byteBuffer = ByteBuffer.allocate(512);

        //循环读取
        while (true){

            //复位操作,很重要,clear()将position、limit、capacity、mark重置为初始状态
            byteBuffer.clear();

            int read = fileInputChannel.read(byteBuffer);
            //-1 表示读取完毕,退出循环
            if(read == -1){
                break;
            }

            //将buffer中的数据,写入到fileOutputChannel
            byteBuffer.flip();//写入之前需要反转
            fileOutputChannel.write(byteBuffer);

        }

        //关闭流
        fileInputStream.close();
        fileOutputStream.close();

    }
}

FileChannel - 使用transferFrom()方法拷贝文件示例代码

java
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.nio.channels.FileChannel;

public class NIOTest {

    public static void main(String[] args) throws Exception{

        //将testfile.txt拷贝1份

        //创建一个输入流
        FileInputStream fileInputStream = new FileInputStream("D:\\testfile.txt");
        //通过输入流,获取对应的文件FileChannel,FileChannel的真实类型是 FileChannelImpl
        FileChannel fileInputChannel = fileInputStream.getChannel();


        //创建一个输出流
        FileOutputStream fileOutputStream = new FileOutputStream("D:\\testfile222.txt");
        //通过输出流,获取对应的文件FileChannel,FileChannel的真实类型是 FileChannelImpl
        FileChannel fileOutputChannel = fileOutputStream.getChannel();

        //使用tranferFrom拷贝,将fileInputChannel通道中的数据,拷贝到了fileOutputChannel通道中
        fileOutputChannel.transferFrom(fileInputChannel,0,fileInputChannel.size());

        //关闭流
        fileInputStream.close();
        fileOutputStream.close();

    }
}

Buffer 和 Channel的注意事项和细节

java
1.ByteBuffer支持类型化的put和get,put放入的是什么数据类型,get就应该使用相应的数据类型来取出,
否则可能有BufferUnderflowException异常,例如:
public static void main(String[] args) throws Exception{
    ByteBuffer byteBuffer = ByteBuffer.allocate(64);
    //写入不同类型的数据
    byteBuffer.putInt(100);
    byteBuffer.putLong(100000L);
    byteBuffer.putChar('哈');
    byteBuffer.putShort((short) 3);

    //取出数据,一定要先反转
    byteBuffer.flip();

    //此后的读取,如果类型不能与写入时的类型对应,可能会抛出 BufferUnderflowException 异常
    System.out.println(byteBuffer.getInt());
    System.out.println(byteBuffer.getLong());
    System.out.println(byteBuffer.getChar());
    System.out.println(byteBuffer.getShort());
}


2.可以将一个普通Buffer转成只读Buffer。例如:
public static void main(String[] args) throws Exception{
    ByteBuffer byteBuffer = ByteBuffer.allocate(64);
    //写入数据
    for (int i = 0; i < 64; i++) {
        byteBuffer.put((byte) i);
    }
    byteBuffer.flip();//反转
    //得到一个只读的buffer,该readOnlyBuffer只能用于读取
    ByteBuffer readOnlyBuffer = byteBuffer.asReadOnlyBuffer();
    //读取
    while (true){
        System.out.println(readOnlyBuffer.get());
    }

    //该readOnlyBuffer为只读,不能写入数据
    readOnlyBuffer.put((byte) 1);
}


3.NIO还提供了MappedByteBuffer,可以让文件直接在内存(堆外的内存)中进行修改,
如何同步到文件则由NIO来完成。例如:
//MappedByteBuffer 可以让文件直接在内存(堆外内存)修改,操作系统不需要拷贝
public static void main(String[] args) throws Exception{
    //rw代表读写
    RandomAccessFile randomAccessFile = new RandomAccessFile("D:\\testfile.txt", "rw");

    //获取对应的文件通道
    FileChannel fileChannel = randomAccessFile.getChannel();

    /**
     * 参数1.FileChannel.MapMode.READ_WRITE:表示使用读写模式
     * 参数2. 0:代表起始位置
     * 参数3. 5:表示映射到内存的大小,即将文件的多少个字节映射到内存,5就是5个字节,可以直接修改的范围就是0-5
     * MappedByteBuffer的实际类型是DirectByteBuffer
     */
    MappedByteBuffer mappedByteBuffer = fileChannel.map(FileChannel.MapMode.READ_WRITE, 0, 5);

    mappedByteBuffer.put(0,(byte) 'X');//修改第0个位置为X
    mappedByteBuffer.put(3,(byte) 'Y');//修改第3个位置为Y

    //此处的修改第5个位置,因为上面映射的是5字节,只能修改0,1,2,3,4,
    //如果此处修改5,会抛出“下标越界异常”
    mappedByteBuffer.put(5,(byte) 'Y');

    //关闭
    randomAccessFile.close();
}


4.前面的读写操作,都是通过一个Buffer完成的,NIO还支持通过多个Buffer(即Buffer数组)完成读写操作,
即Scattering和Gathering,例如:
//Scattering:将数据写入到buffer时,可以采用buffer数组依次写入 [分散]
//Gathering:将数据读取到buffer时,可以采用buffer数组依次读取 [聚集]
public static void main(String[] args) throws Exception{

    //服务器端
    ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
    InetSocketAddress inetSocketAddress = new InetSocketAddress(7000);

    //绑定端口到socket,并启动
    serverSocketChannel.socket().bind(inetSocketAddress);

    //创建buffer数组
    ByteBuffer [] byteBuffers = new ByteBuffer[2];
    byteBuffers[0] = ByteBuffer.allocate(5);//第一个给5个字节
    byteBuffers[1] = ByteBuffer.allocate(3);//第二个给3个字节

    //等待客户端连接
    SocketChannel socketChannel = serverSocketChannel.accept();
    //假设从客户端接收8个字节,如果发送更长的数据,需要调整这个值
    int messageLength = 8; 
    //循环读取
    while (true) {
        int byteRead = 0;
        while (byteRead < messageLength){
            long read = socketChannel.read(byteBuffers);//返回读到的字节数
            byteRead += read;//累计读取的字节数
            System.out.println("byteRead = " + byteRead);
            //输出查看buffer情况
            Arrays.asList(byteBuffers).stream().map(byteBuffer -> "position=" + byteBuffer.position() + ",limit=" + byteBuffer.limit()).forEach(System.out::println);
        }
        //将所有的buffer进行反转
        Arrays.asList(byteBuffers).forEach(byteBuffer -> byteBuffer.flip());

        //将数据读出显示到客户端
        long byteWrite = 0;
        while (byteWrite < messageLength){
            long write = socketChannel.write(byteBuffers);
            byteWrite += write;
        }
        //将所有的buffer,复位clear()
        Arrays.asList(byteBuffers).forEach(byteBuffer -> byteBuffer.clear());
    }
}
//程序启动后,通过telnet连接进入:telnet 127.0.0.1 7000
//按下Ctrl+]键,进入send模式发送数据
//发送数据:send helloworld,发送后观察position和limit位置可得出结论:
//buffer数组通过测试发现,写入时是依次写入,先从第1个数组开始写,满了开始写第二个数组,依次写入。
//读取时,也是一样,依次读取。

Selector(选择器)

  1. Java的NIO,用非阻塞的IO方式。可以用一个线程,处理多个的客户端连接,就会使用到Selector(选择器)
  2. Selector能够检测多个注册的通道上是否有事件发生(注意:多个Channel以事件的方式可以注册到同一个Selector),如果有事件发生,便获取事件然后针对每个事件进行相应的处理。这样就可以只用一个单线程去管理多个通道,也就是管理多个连接和请求。
  3. 只有在连接/通道 真正有读写事件发生时,才会进行读写,就大大地减少了系统开销,并且不必为每个连接都创建一个线程,不用去维护多个线程。
  4. 避免了多线程之间的上下文切换导致的开销。

Selector特点

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

Selector类相关方法

java
Selector 类是一个抽象类, 常用方法和说明如下:
public abstract class Selector implements Closeable { 
	public static Selector open();//得到一个选择器对象
	public int select(long timeout);//监控所有注册的通道,当其中有 IO 操作可以进行时,将
	对应的SelectionKey加入到内部集合中并返回,参数用来设置超时时间
  SelectionKey可以理解为与channel一一对应关联。
	public Set<SelectionKey> selectedKeys();//从内部集合中得到所有的 SelectionKey	
}

selector 相关方法说明
selector.select()//阻塞
selector.select(1000);//阻塞1000毫秒,在1000毫秒后返回
selector.wakeup();//唤醒selector
selector.selectNow();//不阻塞,立马返还

NIO非阻塞网络编程相关的(Selector、SelectionKey、ServerScoketChannel和SocketChannel) 关系梳理图

  1. 当客户端连接时,会通过ServerSocketChannel 得到 SocketChannel
  2. Selector进行监听,使用的是select()方法, 这个方法会返回有事件发生的通道的个数
  3. 将socketChannel注册到Selector上, register(Selector sel, int ops),一个selector上可以注册多个SocketChannel ops参数代表事件类型: OP_READ(读事件)、OP_WRITE(写事件)、OP_CONNECT(连接建立)、OP_ACCEPT(新连接进入)
  4. 注册后返回一个SelectionKey, 会和该Selector关联(集合)
  5. 进一步得到各个SelectionKey(有事件发生的)
  6. 在通过SelectionKey反向获取SocketChannel, 使用方法channel()获取
  7. 可以通过得到的channel, 完成业务处理

针对以上说明的代码示例:

服务端:

java
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.*;
import java.util.Iterator;
import java.util.Set;

public class ServerTest {

    public static void main(String[] args) throws Exception{
        //创建ServerSocketChannel
        ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();

        //得到一个Selector对象
        Selector selector = Selector.open();

        //绑定一个端口7777
        serverSocketChannel.socket().bind(new InetSocketAddress(7777));

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

        //把ServerSocketchannel 注册到 Selector,关注事件为OP_ACCEPT(新连接进入事件)
        serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);

        //循环等待客户端连接
        while (true) {
            //select()方法返回值不能是负数:
            // 0:返回为0,说明还没有任何时间在该通道发生
            if(selector.select(1000) == 0){
                //这里等待了1秒,无事件发生,继续循环
                System.out.println("服务器等待了1秒,无连接");
                continue;
            }
            //如果返回的大于0,就获取到相关的SelectionKey集合
            //1.返回>0,表示已经获取到关注的事件
            //2.selector.selectedKeys():关注事件的集合
            //3.通过selectionKeys,可以反向获取通道
            Set<SelectionKey> selectionKeys = selector.selectedKeys();

            //遍历selectionKeys
            Iterator<SelectionKey> keyIterator = selectionKeys.iterator();

            while (keyIterator.hasNext()){
                //获取到selectionkey
                SelectionKey key = keyIterator.next();
                //根据SelectionKey对应的通道,发生的事件,做相应的处理
                if(key.isAcceptable()){//如果是OP_ACCEPT事件,有新的客户端连接。OP_ACCEPT对应isAcceptable()方法
                    //给该客户端生成 1 个socketChannel
                    SocketChannel socketChannel = serverSocketChannel.accept();

                    System.out.println("客户端连接进入,生成了1个socketChannel:" + socketChannel.hashCode());

                    //设置该socketChannel为非阻塞模式
                    socketChannel.configureBlocking(false);

                    //将该socketChannel注册到selector,关注事件为OP_READ读事件
                    //同时给该socketChannel关联1个buffer
                    socketChannel.register(selector,SelectionKey.OP_READ,ByteBuffer.allocate(1024));
                } else if (key.isReadable()){//如果是OP_READ事件
                    //通过key反向获取到对应的channel
                    SocketChannel channel = (SocketChannel) key.channel();
                    //获取到该channel关联的buffer,通过attachment()方法直接获取buffer
                    ByteBuffer buffer = (ByteBuffer) key.attachment();
                    //开始读取数据
                    channel.read(buffer);
                    System.out.println("客户端发来的消息为:" + new String(buffer.array()));
                }
                //手动从集合中移除selectionKey,防止重复操作
                keyIterator.remove();
            }
        }
    }
}

客户端:

java
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SocketChannel;

public class ClientTest {
    public static void main(String[] args) throws Exception{
        //得到1个网络通道
        SocketChannel socketChannel = SocketChannel.open();
        //设置非阻塞模式
        socketChannel.configureBlocking(false);
        //提供服务器端IP和端口
        if(!socketChannel.connect(new InetSocketAddress("127.0.0.1",7777))){
            while (!socketChannel.finishConnect()){
                System.out.println("因为连接需要时间,客户端不会阻塞,可以做其他工作...");
            }
        }
        //如果连接成功,就发送数据
        String sendStr = "Hello World";

        //创建1个buffer。wrap()方法不需要指定大小,根据传入的字节大小来创建buffer,就不需要指定大小了
        ByteBuffer buffer = ByteBuffer.wrap(sendStr.getBytes());

        //发送数据,就是将buffer的数据写入channel
        socketChannel.write(buffer);

        System.in.read();
    }
}

SelectionKey

java
1.SelectionKey,表示Selector和网络通道的注册关系, 共四种:

int OP_ACCEPT:有新的网络连接可以 accept,值为 16
int OP_CONNECT:代表连接已经建立,值为 8
int OP_READ:代表读操作,值为 1 
int OP_WRITE:代表写操作,值为 4
源码中:
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;

2.SelectionKey相关方法
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

java
ServerSocketChannel 在服务器端监听新的客户端 Socket 连接

相关方法如下:
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

java
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();//关闭通道
}

Selector和Channel和Buffer

  1. 每个channel 都会对应一个Buffer
  2. Selector对应一个线程,一个线程对应多个channel(连接)
  3. 程序切换到哪个channel是有事件决定的, Event就是一个重要的概念
  4. Selector会根据不同的事件,在各个通道上切换
  5. Buffer 就是一个内存块,底层是有一个数组
  6. 数据的读取写入是通过Buffer, BIO中要么是输入流或是输出流, 不能双向,但是NIO的Buffer是可以读也可以写, 需要flip方法切换
  7. channel是双向的,可以返回底层操作系统的情况, 比如Linux,底层的操作系统通道就是双向的

NIO与零拷贝

零拷贝是网络编程的关键,很多性能优化都离不开。在Java程序中,常用的零拷贝有mmap(内存映射)和sendFile。

  1. 我们说零拷贝,是从操作系统的角度来说的。因为内核缓冲区之间,没有数据是重复的(只有 kernel buffer 有一份数据)。
  2. 零拷贝不仅仅带来更少的数据复制,还能带来其他的性能优势,例如更少的上下文切换,更少的 CPU 缓存伪共享以及无 CPU 校验和计算。

IO数据读写比较

DMA

DMA(Direct Memory Access)即直接存储器存取,是一种快速传送数据的机制。DMA是指外部设备不通过CPU而直接与系统内存交换数据的接口技术。要把外设的数据读入内存或把内存的数据传送到外设,一般都要通过CPU控制完成,如CPU程序查询或中断方式。利用中断进行数据传送,可以大大提高CPU的利用率。 上图:先进行了DMA拷贝,将硬盘上的数据拷贝到了内核kemel buffer, 然后又拷贝到了用户user buffer,又拷贝到了socket buffer,然后DMA拷贝到协议栈。 所以,它进行了4次拷贝,3次状态切换

java
//传统IO
File file = new File("test.txt");
RandomAccessFile raf = new RandomAccessFile(file, "rw");
byte[] arr = new byte[(int) file.length()];
raf.read(arr);

//网络编程
Socket socket = new ServerSocket(8080).accept();
socket.getOutputStream().write(arr);

mmap优化

mmap通过内存映射,将文件映射到内核缓冲区,同时,用户空间可以共享内核空间的数据。这样,在进行网络传输时,就可以减少内核空间到用户空间的拷贝次数。它的拷贝次数是3次,修改状态次数也是3次。直接从kemel buffer拷贝到socket buffer。

sendFile优化

  1. Linux 2.1版本提供了sendFile函数,其基本原理如下:数据根本不经过用户态,直接从内核缓冲区进入到Socket Buffer,同时,由于和用户态完全无关,就减少了一次上下文切换,sendFile函数,它拷贝了3次,修改状态为2次。但它仍然没有实现真正的零拷贝

提示:零拷贝从操作系统角度,是没有cpu拷贝

  1. Linux 2.4版本,做了一些修改,避免了从内核缓冲区拷贝到Socket buffer的操作,直接拷贝到协议栈,从而再一次减少了数据拷贝。它真正的实现了零拷贝。这里其实有一次cpu拷贝 kernel buffer -> socket buffer但是,拷贝的信息很少,比如lenght,offset, 消耗低,可以忽略。

mmap 和 sendFile 的区别

  1. mmap适合小数据量读写,sendFile适合大文件传输。
  2. mmap需要 4 次上下文切换,3 次数据拷贝;sendFile需要 3 次上下文切换,最少 2 次数据拷贝。
  3. sendFile可以利用 DMA 方式,减少CPU拷贝,mmap则不能(必须从内核拷贝到 Socket 缓冲区)。

NIO和BIO比较

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

AIO(异步非阻塞型)

AIO:异步非阻塞IO。AIO引入异步通道的概念,采用了Proactor模式,简化了程序编写,有效的请求才启动线程,它的特点是先由操作系统完成后才通知服务端程序启动线程去处理,一般适用于连接数较多且连续时间较长的应用。

  1. JDK7引入了Asynchronous I/O,即AIO。在进行I/O编程中,常用到两种模式:Reactor和 Proactor。Java 的 NIO 就是 Reactor,当有事件触发时,服务器端得到通知,进行相应的处理
  2. AIO 即 NIO2.0,叫做异步不阻塞的 IO。AIO 引入异步通道的概念,采用了 Proactor 模式,简化了程序编写,有效的请求才启动线程,它的特点是先由操作系统完成后才通知服务端程序启动线程去处理,一般适用于连接数较多且连接时间较长的应用
  3. 目前 AIO 还没有广泛应用,Netty 也是基于NIO, 而不是AIO, 有兴趣可以参考 《Java新一代网络编程模型AIO原理及Linux系统AIO介绍》  http://www.52im.net/thread-306-1-1.html