JavaNIO编程

小龙 1,012 2021-07-21

简介

Java NIO编程是现在在服务器端开发中使用最为广泛的Java原生技术;NIO提供了一个缓冲区,所有输入和输入都采用缓冲区来完成操作,避免了使用传统阻塞的处理模式。
NIO 非阻塞IO,是JDK1.4的时候增加的新支持,用于提升IO处理性能

与传统IO相比

No类别传统IONIO
1处理形式面向数据流面向缓冲
1延迟状态阻塞IO非阻塞IO
1统一管理-选择器

NIO处理模型

  • Channels and Buffer(通道和缓冲区):标准的IO处理基于字节流和字符流进行操作的,而NIO是基于Channel(通道)和Buffer(缓冲区)进行操作的,数据总是从通道读取到缓存区中,或者从缓冲区中写入到通道中
  • Asynchronous IO(异步IO):Java Nio可以异步的使用IO,例如:当线程从通道读取数据到缓冲区时,线程还可以进行其他事情。当数据被写入到缓冲区时,线程可以继续处理它。从缓冲区写入通道也类似。
  • Selectors(选择器):Java Nio引入了选择器的概念,选择器用于监听多个通道的事件(比如:连接打开,数据到达)。因此单个线程可以监听多个数据通道。

image.png

Buffer缓冲区

在NIO之中所有的读写操作都是通过Buffer进行载体存放的,可以把Buffer想象成一个集装箱,在设计Buffer的时候充分考虑到了其在输入与输出位置上的写入与读取问题;Buffer类定义在java.nio包中,这是一个抽象类

public abstract class Buffer{}

No.方法名称类型描述
01public Buffer clear()方法清空缓冲区
02public Buffer flip()方法重置缓冲区,将输入与输出结构转换
03public final int limit()方法获取Limit标记
04public final int position()方法获取position标记
05public final int capacity()方法获取capacity标记
06public final boolean hasRemaining()方法判断是否还有数据

在缓冲区中为了方便进行读写处理,以及读写处理操作的转换控制,为此提供了三个最为重要的的缓冲区的处理标记:capacity(整个缓冲区的容量)、limit(操作的界限)、position(当前缓冲位置),下面通过一个具体的图形来详细解释一下

  1. 不管是进行数据的读取还是进行数据的写入,实质上都需要一个缓冲区的概念,每当创建了一个缓冲区就会出现:capcaticy、limit、position三种标记信息

image.png

在开辟缓冲区之后,position描述的是当前可以写入数据的位置,而limit和capacity表示的就是最大的容量。

  1. 在向缓冲区中进行数据写入的时候,那么将改变position的位置

image.png

  1. 当缓冲区的数据写入完成时候,肯定要进行数据的读取,要进行数据的读取还需要将缓冲区重置,使用“flip()”方法进行重置,重置后就变成下面的样子

image.png

此时的position作为了数据读取标记位,将回归到索引为0的位置;而limit将指向使用(flip())方法之前position位置,既数据的最后一位;此时position和limit之间的数据就是要读取的数据。缓冲区中是否还存在数据可以使用“hahRemaining()”方法来判断。

Buffer是一个抽象类里面只提供了缓冲区的基本操作形式,对于不同类型的数据提供有不同的缓冲区实现,所以缓冲区的开辟都是依靠Buffer抽象类的子类完成的

下面以字节缓冲区为例,编写代码演示

  • 开辟字节缓冲区使用ByteBuffer类实现,类中有如下一些重要的方法,其他类型的Buffer方法都是类似的
No.方法名称类型描述
01public static ByteBuffer allocate(int capacity)方法开辟字节缓冲区,并设置缓冲区容量
02public final ByteBuffer put(byte[] src)方法向缓冲区追加一组数据
03public ByteBuffer put(ByteBuffer src)方法将一个缓冲区内容追加到另一个缓冲区之中
04public abstract ByteBuffer putXxx(类型 val)方法追加各种数据类型到缓冲区之中
05public abstract byte get()方法获取缓冲区中的单个字节
06public ByteBuffer get(byte[] dst)方法将一组数据保存在字节数组里面

观察缓存区操作

public class NIOBufferDemo {
    public static void main(String[] args) throws InterruptedException {
        String str = "rsthe.com"; // 定义要保存到缓冲区的数据
        ByteBuffer buffer = ByteBuffer.allocate(20);// 开辟20个字节大小的缓冲区;
        System.out.println("1、开辟缓存区:position = " + buffer.position() + "、 limit =  " + buffer.limit() + "、 capacity = " + buffer.capacity());
        buffer.put(str.getBytes());  // 往缓冲区写入数据
        System.out.println("2、写入缓冲区:position = " + buffer.position() + "、 limit =  " + buffer.limit() + "、 capacity = " + buffer.capacity());
        buffer.flip();// 重置缓存区
        System.out.println("3、重置缓存区:position = " + buffer.position() + "、 limit =  " + buffer.limit() + "、 capacity = " + buffer.capacity());
        while (buffer.hasRemaining()){ // 缓冲区中不存在数据,就结束循环
            System.err.print(buffer.get() + "、");
        }
        Thread.sleep(100);
        System.out.println();
        System.out.println("4、缓存区操作完毕:position = " + buffer.position() + "、 limit =  " + buffer.limit() + "、 capacity = " + buffer.capacity());
        buffer.clear(); // 清空缓冲区
        System.out.println("5、清空缓冲区:position = " + buffer.position() + "、 limit =  " + buffer.limit() + "、 capacity = " + buffer.capacity());
    }
}

执行结果

1、开辟缓存区:position = 0、 limit = 20、 capacity = 20
2、写入缓冲区:position = 9、 limit = 20、 capacity = 20
3、重置缓存区:position = 0、 limit = 9、 capacity = 20
114、115、116、104、101、46、99、111、109、
4、缓存区操作完毕:position = 9、 limit = 9、 capacity = 20
5、清空缓冲区:position = 0、 limit = 20、 capacity = 20

通过执行结果可以发现,整个处理过程都是在利用position和limit两个指针进行数据的保证和数据的获取,缓冲区可以利用这个两个指针实现重读数据的写入与读取处理。

Channel通道

Channel描述的是一个通道费的概念,在Java NIO之中认为如果不同的操作,设置不同的读取机制实在太过于复杂了,所有就希望可以提供一种简化设计,于是就有了Channel的概念,Channel是一个接口,而且Channel都是应用在资源之上的,所以实现了Closeable接口。

Channel接口有几个重要的子类:FileChannel(文件通道)、SocketChannel(网络通道)、Pipe(管道通道)

FileChannel

FileChannel明确描述了一个文件通道;想要获取文件通道,必须依靠文件处理类来完成,在FileInputStream和FileOutStream类中提供有一个getChannel()方法,可以直接获取FileChannel实例化对象。
image.png

利用FileChannel读取文件

public class FileChannelDemo {
    public static void main(String[] args) throws IOException {
        File file = new File("F:\\client.log");// 定义要读取的文件
        FileInputStream in= new FileInputStream(file);// 获取文件输入流
        FileChannel channel = in.getChannel(); // 获取文件输入通道
        ByteBuffer buffer = ByteBuffer.allocate(64); // 创建一个字节缓存区,用于存储文件字节数据
        ByteArrayOutputStream out = new ByteArrayOutputStream(); // 保存所有读取的字节数据
        int count = 0;
        while ((count = channel.read(buffer)) != -1){ // channel.read(buffer))将通道的数据写到缓冲区中
            buffer.flip(); // 每次操作前都将缓冲区重置
            while (buffer.hasRemaining()){
                out.write(buffer.get());
            }
            buffer.clear();// 每次操作完成之后,都清空缓冲区
        }
        System.out.println(new String(out.toByteArray()));
        channel.close(); // 通道是资源操作,每次使用完之后都要关闭
    }
}

执行结果

2019-09-09 16:59:27: jidanbox parse error !
文件的操作模式比较固定,所有此时的代码和之前操作缓冲区的代码区别不大。这个操作模型通过Channel实现缓冲区的处理

文件锁

FileChannel在执行操作的时候,通常不需要有其它线程也来操作这个文件,所有提供了一个文件锁的概念,文件锁可以直接通过FileChannel对象来创建,使用:

public abstract FileLock tryLock() throws IOException;

这个操作只有在对文件进行写入时才可用

public class FileTryLockDemo {
    public static void main(String[] args) throws Exception {
        File file = new File("F:\\client.log");// 定义要读取的文件
        FileOutputStream out = new FileOutputStream(file);// 获取文件输入流
        FileChannel channel = out.getChannel(); // 获取文件输入通道
        FileLock fileLock = channel.tryLock(); // 获取文件锁
        ByteBuffer buffer = ByteBuffer.allocate(64); // 创建一个字节缓存区,用于存储文件字节数据
        buffer.put("你好!".getBytes());
        buffer.flip();
        if (fileLock != null){
            System.out.println("获取到了文件锁,当前文件被锁定");
            TimeUnit.SECONDS.sleep(10);
            channel.write(buffer);
            fileLock.release(); // 释放锁
        }
        out.close();
        channel.close(); // 通道是资源操作,每次使用完之后都要关闭
    }
}

执行结果

获取到了文件锁,当前文件被锁定

image.png

Pipe管道

在IO的处理之中,除了以文件或者网络作为终端之外,实际上也可以实现多线程之间的通讯处理,多个线程之间依赖管道通道进行联系,如果要进行线程通讯必须要使用两个操作:

  • 线程管道数据输出通道:Pipe.SinkChannel;
  • 线程管道数据输入通道:Pipe.SourceChannel;
public class PipeDemo {
    public static void main(String[] args) throws IOException {
        Pipe pipe = Pipe.open(); // 打开线程管道流
        new Thread(() -> {
            Pipe.SourceChannel source = pipe.source(); // 获取输入管道流
            ByteBuffer buffer = ByteBuffer.allocate(64);// 定义缓冲区
            try {
                int read = source.read(buffer); // 读取
                buffer.flip(); // 重置缓冲区
                System.out.println("【接收端】接收的内容:" + new String(buffer.array(), 0, read));
            } catch (IOException e) {
                e.printStackTrace();
            }
        }, "接收线程").start();
        new Thread(() -> {
            Pipe.SinkChannel sink = pipe.sink(); // 获取输出管道流
            ByteBuffer buffer = ByteBuffer.allocate(64);
            try {
                buffer.put("Hello NIO".getBytes()); // 定义要发送的数据
                System.out.println("【发送端】发送的内容:Hello NIO");
                buffer.flip();
                int write = sink.write(buffer);// 发送数据
            } catch (IOException e) {
                e.printStackTrace();
            }
        },"发送线程").start();
    }
}

执行结果

【发送端】发送的内容:Hello NIO
【接收端】接收的内容:Hello NIO

Pipe的发送和接收操作标准都是统一的

字符集

在程序开发之中所有的IO交互之中最需要重点处理的就传输的编码问题,如果发送端和接收端使用的编码不相同,一定会出现乱码问题,所以安慰了简化编码处理模式,java.nio包中针对编码设置了一个统一的管理类:Charset;这个类中进行了所有使用到的字符集的编码定义

java.nio.charset.Charset

查看当前系统支持的编码

public class SystemCharset {
    public static void main(String[] args) {
        SortedMap<String, Charset> map = Charset.availableCharsets();
        Set<Map.Entry<String, Charset>> entries = map.entrySet();
        for (Map.Entry<String, Charset> entry : entries){
            System.out.println(entry.getKey() + " - " + entry.getValue());
        }
    }
}

执行结果

Big5 - Big5
Big5-HKSCS - Big5-HKSCS
CESU-8 - CESU-8
EUC-JP - EUC-JP
EUC-KR - EUC-KR
GB18030 - GB18030
GB2312 - GB2312
GBK - GBK
... ...

使用Charset类型最主要的意义在于可以方便的进行编码与解码操作,在Charset类中有两个方法,可以分别获得编码器(CharsetEncoder)和解码器(CharsetDecoder)

CharsetDecoder:public abstract CharsetDecoder newDecoder();
CharsetEncoder: public abstract CharsetEncoder newEncoder();

代码实现编码与解码

public class EncoderAndDecoder {
    public static void main(String[] args) throws Exception {
        Charset charset = Charset.forName("UTF-8"); // 创建一个UTF-8编码器
        CharsetEncoder encoder = charset.newEncoder(); // 获取编码器
        CharsetDecoder decoder = charset.newDecoder(); // 获取解码器
        CharBuffer buffer = CharBuffer.allocate(64); // 定义缓冲区
        buffer.put("好好学习!好好生活!"); // 往缓冲区中存入数据
        buffer.flip();
        ByteBuffer encodeBuffer = encoder.encode(buffer); // 对缓冲区进行编码
        System.out.println(decoder.decode(encodeBuffer)); // 对缓冲区进行解码
    }
}

执行结果

好好学习!好好生活!

selector选择器

Selector选择器,又称为多路复用器,是Java NIO中一个核心组件;用于检查一个或多个Channel的状态是否处于可读、可写。如此可以实现单个线程管理多个channel,也可以管理多个网络连接。

使用Selector的好处在于:使用更少的线程来处理通道,相比使用多个线程,节约了线程上下文切换的开销

Selector是建立在资源上的操作,使用完之后一定要关闭

public abstract class Selector implements Closeable

通过“Selector.open()”方法创建一个Selector对象

Selector selector = Selector.open(); // 打开一个选择器

注册Channel到Selector

channel.configureBlocking(false); // 通道设置为非阻塞
channel.register(selector, SelectionKey.OP_READ); // 通道注册到Selector中

Channel必须是非阻塞的。
所以FileChannel不适用Selector,因为FileChannel不能切换为非阻塞模式,更准确的来说是因为FileChannel没有继承SelectableChannel。SocketChannel可以正常使用。
SelectableChannel抽象类 有一个 configureBlocking() 方法用于使通道处于阻塞模式或非阻塞模式。
abstract SelectableChannel configureBlocking(boolean block)

Channel.register()的第二个参数是一个“interset”集合,意思是在通过Selector监听Channel的时候对什么事件感兴趣,提供了4中不同类型的时间:
|参数|意义|
|-------|-------|
|SelectionKey.OP_READ|读事件|
|SelectionKey.OP_WRITE|写事件|
|SelectionKey.OP_CONNECT|可连接事件|
|SelectionKey.OP_ACCEPT|可接受就绪|

如果你不止对一个时间感兴趣,还可以使用“|”或运算符连结:

int interestSet = SelectionKey.OP_READ | SelectionKey.OP_WRITE;

SelectionKey介绍

一个SelectionKey键表示了一个特定的通道对象和一个特定的选择器对象之间的注册关系。

key.attachment(); //返回SelectionKey的attachment,attachment可以在注册channel的时候指定。
key.channel(); // 返回该SelectionKey对应的channel。
key.selector(); // 返回该SelectionKey对应的Selector。
key.interestOps(); //返回代表需要Selector监控的IO操作的bit mask
key.readyOps(); // 返回一个bit mask,代表在相应channel上可以进行的IO操作。
key.interestOps():

我们可以通过以下方法来判断Selector是否对Channel的某种事件感兴趣

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

ready 集合是通道已经准备就绪的操作的集合。JAVA中定义以下几个方法用来检查这些操作是否就绪.

//创建ready集合的方法
int readySet = selectionKey.readyOps();
//检查这些操作是否就绪的方法
key.isAcceptable();//是否可读,是返回 true
boolean isWritable()://是否可写,是返回 true
boolean isConnectable()://是否可连接,是返回 true
boolean isAcceptable()://是否可接收,是返回 true

从SelectionKey访问Channel和Selector很简单。如下:

Channel channel = key.channel();
Selector selector = key.selector();
key.attachment();

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

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

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

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

从Selector中选择channel(Selecting Channels via a Selector)

选择器维护注册过的通道的集合,并且这种注册关系都被封装在SelectionKey当中.

Selector维护的三种类型SelectionKey集合:

已注册的键的集合(Registered key set)

所有与选择器关联的通道所生成的键的集合称为已经注册的键的集合。并不是所有注册过的键都仍然有效。这个集合通过 keys() 方法返回,并且可能是空的。这个已注册的键的集合不是可以直接修改的;试图这么做的话将引发java.lang.UnsupportedOperationException。

已选择的键的集合(Selected key set)

所有与选择器关联的通道所生成的键的集合称为已经注册的键的集合。并不是所有注册过的键都仍然有效。这个集合通过 keys() 方法返回,并且可能是空的。这个已注册的键的集合不是可以直接修改的;试图这么做的话将引发java.lang.UnsupportedOperationException。

已取消的键的集合(Cancelled key set)

已注册的键的集合的子集,这个集合包含了 cancel() 方法被调用过的键(这个键已经被无效化),但它们还没有被注销。这个集合是选择器对象的私有成员,因而无法直接访问。

注意:

当键被取消( 可以通过isValid( ) 方法来判断)时,它将被放在相关的选择器的已取消的键的集合里。注册不会立即被取消,但键会立即失效。当再次调用 select( ) 方法时(或者一个正在进行的select()调用结束时),已取消的键的集合中的被取消的键将被清理掉,并且相应的注销也将完成。通道会被注销,而新的SelectionKey将被返回。当通道关闭时,所有相关的键会自动取消(记住,一个通道可以被注册到多个选择器上)。当选择器关闭时,所有被注册到该选择器的通道都将被注销,并且相关的键将立即被无效化(取消)。一旦键被无效化,调用它的与选择相关的方法就将抛出CancelledKeyException。

select()方法介绍:

在刚初始化的Selector对象中,这三个集合都是空的。 通过Selector的select()方法可以选择已经准备就绪的通道 (这些通道包含你感兴趣的的事件)。比如你对读就绪的通道感兴趣,那么select()方法就会返回读事件已经就绪的那些通道。下面是Selector几个重载的select()方法:

  • int select():阻塞到至少有一个通道在你注册的事件上就绪了。
  • int select(long timeout):和select()一样,但最长阻塞时间为timeout毫秒。
  • int selectNow():非阻塞,只要有通道就绪就立刻返回。

select()方法返回的int值表示有多少通道已经就绪,是自上次调用select()方法后有多少通道变成就绪状态。之前在select()调用时进入就绪的通道不会在本次调用中被记入,而在前一次select()调用进入就绪但现在已经不在处于就绪的通道也不会被记入。例如:首次调用select()方法,如果有一个通道变成就绪状态,返回了1,若再次调用select()方法,如果另一个通道就绪了,它会再次返回1。如果对第一个就绪的channel没有做任何操作,现在就有两个就绪的通道,但在每次select()方法调用之间,只有一个通道就绪了。

一旦调用select()方法,并且返回值不为0时,则 可以通过调用Selector的selectedKeys()方法来访问已选择键集合 。如下:

Set selectedKeys=selector.selectedKeys();
进而可以放到和某SelectionKey关联的Selector和Channel。如下所示:

Set selectedKeys = selector.selectedKeys();
Iterator keyIterator = selectedKeys.iterator();
while(keyIterator.hasNext()) {
    SelectionKey key = keyIterator.next();
    if(key.isAcceptable()) {
        // a connection was accepted by a ServerSocketChannel.
    } else if (key.isConnectable()) {
        // a connection was established with a remote server.
    } else if (key.isReadable()) {
        // a channel is ready for reading
    } else if (key.isWritable()) {
        // a channel is ready for writing
    }
    keyIterator.remove();
}