细说 Java NIO

前言:本篇主要用于梳理NIO的相关知识,诸如缓冲区、通道、文件锁、选择器,附带的会说一下IO的知识,因为在某些地方NIO会用到它们。鉴于NIO已经出来甚久,本文旨在总结知识与交流学习,同时若能给他人带来一点帮助,那也是一份意外收获。

1、IO (java.io.*)

在前面的两篇博客Java之IO流—字节流、Java之IO流—字符流,我们详细的梳理了字节流与字符流的体系与使用细节,并没有对他们两者做一个详细的比较,在写NIO之前,我将再梳理下它们二者的关系与异同,同时也是给NIO打个基础。

1.1 字节流与字符流的比较

1.1.1 字节流与字符流对比

如下,总结为一张表,直观的比较一下

说明字符流字节流
操作数据类型字符数据(2 byte)字节数据 (1 byte)
适用范围处理文本文件(不适用音频、视频文件等非纯文本文件)任意文件
高效缓冲BufferedReader、BufferedWriterBufferedInputStream、BufferedOutputStream
打印流PrintWriterPrintStream (常见有System.out)
编解码字符数据编码成字节数据, 字符 –> 字节字节数据解密成字符数据,字节 –> 字符
转换流InputStreamReader,字节 –> 字符 指定charset读取字节并将其解码为字符OutputStreamWriter, 字符 –> 字节 指定的charset将要写入流中的字符编码成字节
基类Reader、WriterInputStream、OutputStream

1.1.2使用原则

在使用流对象时不能盲目,得有选择的使用适当的流进行操作,才能达到最佳结果。以下分情况梳理了可能的情况以及对应的要使用的流。

1 文件类型
  • 二进制文件 - 使用字节流(InputStream、OutputStream及其子类)
    • 输入 InputStream及其子类
    • 输出 OutputStream及其子类
  • 纯文本文件 - 适宜使用字符流(Reader、Writer及其子类),也可以使用字节流
    • 输入 Reader及其子类
    • 输出Writer及其子类
2 源与目的

明确源与目的,源表示从哪里读入,目的表示往哪里写出

  • 操作文件
    • 源读入 - FileReader、FileInputStream; 目的写出- FileWriter、FileOutputStream
    • 若为文本文件FileReader、FileWriter ,否则FileInputStream、FileOutputStream
  • 控制台
    • 输入 InputStream (可以通过转换流转换成Reader来操作)
    • 输出 OutputStream (可以通过转换流转换成Writer来操作)
  • 操作byte[]数组
    • 源读入 - ByteArrayInputStream;目的写出- ByteArrayOutputStream (字节流)
  • 操作char[]数组:

    • 源读入 -CharArrayReader;目的写出- CharArrayWriter( 字符流 )
  • 操作String

    • StringReader, StringWriter( 字符流 )

3 其他需求
  • 需要转换
    • InputStreamReader 字节 –> 字符, 指定charset读取字节并将其解码为字符。
    • OutputStreamWriter 字符 –> 字节 指定的charset将要写入流中的字符编码成字节。
  • 需要缓冲
    • 对字节流缓冲 BufferedInputStream, BufferedOutputStream
    • 对字符流缓冲 BufferedReader, BufferedWriter
    • -
  • 对象序列化
    • ObjectInputStream、ObjectOutputStream
  • 格式化打印输出
    • PrintWriter、 PrintStream
  • 线程间通信
    • PipedInputStream, PipedOutputStream 传送的字节流。
    • PipedReader, PipedWriter 传送的字符流。
  • 合并输入
    • SequenceInputStream
  • 生成行号
    • LineNumberReader (BufferedReader子类)
    • LineNumberInputStream (已过时)

1.2 两张图看懂IO

1.2.1 继承体系图

这里写图片描述

1.2.2 字节流与字符流使用与交互

图仅作示意使用,不必纠结画图规范问题~
这里写图片描述


2、NIO

2.1 NIO引入

  • NIO概念

    NIO是在JDK1.4中被引入的,它旨在提供功能与开始的IO(java.io.* 包下,后文皆简称IO)相似但更加高效、可执行异步的、非阻塞(non-blocking)IO操作的输入输出库,它采用了面向块(block-oriented)的概念,使得相比于面向流(stream-oriented)的IO(java.io.*)更加高效,可以发挥更大性能。由于操作是建立在块上的,因此也相应的牺牲了使用的简单性。

  • 集成IO
    不过由于IO与NIO的集成,(java.io.* )IO 已经以 NIO 为基础重新实现了,所以现在它可以利用 NIO 的一些特性。诸如以块的形式来读写数据,像我们常见的BufferedRead、BufferedWriter等,使得相对之前的按字节或字符读取已经有很大的性能提升,速度相比也更快了。所以IO的读写性能相对于NIO没有太明显的差别。

  • 非阻塞IO(non-blocking)
    虽然IO与NIO在读写上的性能已经相差不大,而且我们都已经很熟练的使用IO了,为什么我要再花时间去学一个没有太大提升的NIO呢?促使我们使用NIO的是它的一个很重要的特性,那就是异步IO操作了,我们可以实现非阻塞(non-blocking)的Socket通信。我们可以使用选择器与通道来实现非阻塞的服务端,只需一个线程即可管理多个通道,以与客户端通信,这样的实现是不是很cool呢?在后面会详细讲述如何来创建这样的一个服务端。

  • NIO与IO的比较

NIO (java.nio.*)IO (java.io.*)
面向块(block-oriented)面向流(stream-oriented)
非阻塞(non-blocking)阻塞(blocking)
选择器(Selector)-

- NIO包含的新特性

    缓冲区(Buffer)
    通道 (Channel)
    文件锁 (FileLock)
    内存映射 (MappedByteBuffer)
    字符集 (Charset)
    通道选择器 (Selector)
    等

下面我们就开始探索这些特性吧。

2.2 缓冲区(Buffer)

前面我们提到过,NIO是面向块的,而Buffer则是它操作块的基础。

Buffer对象实质上一个缓冲数组,存储着要写入或者读出的数据,不过它提供了更多的操作,诸如flip()、reset()方法等,以方便我们来结构化管理Buffer里的数据,同时它提供的position、limit、mark可以对缓冲区进行更加细致的操作。

Buffer通常是ByteBuffer,对于每个非 boolean 基本类型,此类都有一个子类与之对应,来一张图直观的看看先。

这里写图片描述

2.2.1、了解capacity、limit、position、mark

在学习Buffer之前,有几个名词需要了解下,让我们能更好的理解Buffer的工作原理
capacity

缓冲区所包含的元素的数量,通过capacity()获得。缓冲区的容量不能为负并且不能更改。

limit

缓冲区的限制 ,通过limit()方法获取, 从limit的索引开始读取或写入元素是被禁止的。缓冲区的限制不能为负,并且不能大于其容量。

position

缓冲区的位置 ,通过position()获取, 是下一个要读取或写入的元素的索引。缓冲区的位置不能为负,并且不能大于其限制。初始时为0

mark

缓冲区标记,通过mark()设置,在此缓冲区的位置position设置标记,初始时是一个未定义的标记。若设置position或limit小于此mark索引,则此mark将失效(被丢弃)

上述名词所代表的值表示在Buffer中的索引,标记、位置、限制和容量值遵守以下不变式:

0 <= mark <= position <= limit <= capacity

这几个参数简单来说如下:

position是缓冲数组遍历的索引下标,limit就是添加或遍历的索引下标的限制值,capacity表示缓冲数组的大小。mark就相对复杂点,可以理解它是记录position的值的,在
reset 方法调用时,mark记录的值会赋给position,即重置position的值为上次记录的值。

无图无真相,来一张图瞅瞅,这是一张初始状态图。
这里写图片描述

这里写图片描述

注:图中mark未定义指的是没有设置mark,一般无效的mark会被置为-1。后面不再赘述。


2.2.2、理解clear()、flip()、rewind()、reset()

在学习了上面几个名词后,就可以很轻松的掌握如下几个方法了,它们是操作缓冲区读取与写入的重要方法。
clear()

使缓冲区为一系列新的通道读取或相对放置操作做好准备:它将限制设置为容量大小,将位置设置为 0(即limit=capacity,position=0)。并丢弃标记。

此方法不能实际清除缓冲区中的数据,但从名称来看它似乎能够这样做,这样命名是因为它多数情况下确实是在清除数据时使用。

flip()

使缓冲区为一系列新的通道写入或相对获取操作做好准备:它将限制设置为当前位置,然后将位置设置为0(即limit=position,position=0)

rewind()

使缓冲区为重新读取已包含的数据做好准备:它使限制保持不变,将位置设置为 0。

reset()

将此缓冲区的位置重置为以前标记的位置。 调用此方法不更改也不丢弃标记的值。

在调用 reset 方法时会将缓冲区的位置重置为该mark索引,如果未定义标记,那么调用 reset 方法将导致抛出InvalidMarkException。

写了这么多,我们还是可以瞅瞅它们的源码,看看它们是如何实现的。

Buffer类中clear()方法的实现
 public final Buffer clear() {
        position = 0;
        limit = capacity;
        mark = -1;
        return this;
    }
Buffer类中flip()的方法实现
 public final Buffer flip() {
        limit = position;
        position = 0;
        mark = -1;
        return this;
    }
Buffer类中rewind()的方法实现
 public final Buffer rewind() {
        position = 0;
        mark = -1;
        return this;
    }
Buffer类中reset()的方法实现
public final Buffer reset() {
        int m = mark;
        if (m < 0)
            throw new InvalidMarkException();
        position = m;
        return this;
    }

通过源码我们验证了上面的结论。

2.2.3 创建缓冲区

在了解上面内容后,或许还是不够清晰,那就来点示例吧。
创建新缓冲区
以ByteBuffer为例,它有allocate与wrap方法可以创建一个新的缓冲区,对应着四个方法:

  • allocate(int capacity) 分配一个新的字节缓冲区
  • allocateDirect(int capacity) 分配新的直接字节缓冲区。
  • wrap(byte[] array) 将 byte 数组包装到缓冲区中。
  • wrap(byte[] array, int offset, int length) 将 byte 数组包装到缓冲区中。

缓冲区分为直接与非直接两种。如果为直接字节缓冲区,则 Java虚拟机会尽最大努力直接在此缓冲区上执行本机 I/O 操作。也就是说,在每次调用基础操作系统的一个本机 I/O操作之前(或之后),虚拟机都会尽量避免将缓冲区的内容复制到中间缓冲区中(或从中间缓冲区中复制内容)。

  • 直接字节缓冲区可以通过allocateDirect(int capacity)方法来创建。此方法返回的缓冲区进行分配和取消分配所需成本通常高于非直接缓冲区

  • 直接缓冲区的内容可以驻留在常规的垃圾回收堆之外,因此,它们对应用程序的内存需求量造成的影响可能并不明显

  • 建议将直接缓冲区主要分配给那些易受基础系统的本机 I/O 操作影响的大型、持久的缓冲区。一般情况下,最好仅在直接缓冲区能在程序性能方面带来明显好处时分配它们

创建使用示例,创建一个大小为10的缓冲区

ByteBuffer buf=ByteBuffer.allocate(10);  

或者:

byte[] bs=new byte[]{'0','1','2','3','4','5','6','7','8'};
ByteBuffer buf=ByteBuffer.wrap(bs, 0, bs.length);//将指定长度的数组包含到缓冲区,创建新缓冲区

2.2.4 操作缓冲区

通过allocate方法创建了缓冲区后,就可以操作缓冲区了,通过一系列put与get方法来完成:
诸如向缓冲区添加8个元素

for(int i=0;i<buf.capacity()-2;i++){  // 只填充8个元素
            buf.put((byte) i);  
} 

此时缓冲区的position、limit是怎么得呢?以图说话吧,

这里写图片描述

上图明显看到,添加元素后,只有position发生了改变,并且指向8,实际元素只有0~7。现在我们先调用buf.mark()标记下当前position,然后调用buf.flip()方法,则会出现如下情景

这里写图片描述

调用了flip方法后,会将原来的position赋给limit,然后将position置0,即limit=position,position=0. 此时它已经是处于可读状态,读取范围在[ position,limit ) , 包左不包右。

现在我们先调用buf.limit(10);buf.position(9);设置当前limit=10,position=9,然后我们再调用buf.reset()方法,出现了和第一张position=8,limit=10相同的状态,即将position重置为之前mark标记的值8了。

此时如果我们在调用clear方法,则会出现下图情况,即将状态还原为最初态,使position=0,limit=capacity,不同的是此时的缓冲区还有元素0~7。
这里写图片描述

上图对flip、reset、clear方法进行了深入分析。到此这些操作方法相信我们都已经能够很好的理解了。

我们还可以对缓冲区做更多操作,诸如设置position、limit的值,向缓冲区添加一个数组、将缓冲区转化为数组、 通过字节缓冲区创建其他缓冲区、创建子缓冲区、创建子只读缓冲区、遍历等,下面详细介绍。

1 设置position、limit的值
提高如下方法来设置

limit(int newLimit)  设置此缓冲区的限制。 
position(int newPosition)  设置此缓冲区的位置。

使用示例

buf.limit(10);   // 设置当前limit=10
buf.position(9); //position=9  

2 向缓冲区添加一个数组

byte[] bs=new byte[]{9,10};
buf.position(8); // 设置position
buf.limit(10);   // 设置limit
buf.put(bs,0,2); // 向缓冲区添加数组,从bs的0索引开始,长度为2 

3 将缓冲区转化为数组
通过array()方法

byte[] array = buf.array(); // 返回缓冲区包含的 数组

通过get方法

get(byte[] dst, int offset, int length此方法将此缓冲区的字节传输到给定的目标数组中

示例

byte[] dst=new byte[buf.capacity()];
buf.get(dst); // 将缓冲区的内容输出到指定数组,

注意:如果缓冲区剩余的长度小于dst的长度,则会抛出BufferUnderflowException

4 通过字节缓冲区创建其他缓冲区
在ByteBuffer类中定义了如下方法,创建字节缓冲区的视图并作为相应的类型缓冲区

asCharBuffer() 
          创建此字节缓冲区的视图,作为 char 缓冲区。 
abstract  DoubleBuffer asDoubleBuffer() 
          创建此字节缓冲区的视图,作为 double 缓冲区。 
abstract  FloatBuffer asFloatBuffer() 
          创建此字节缓冲区的视图,作为 float 缓冲区。 
abstract  IntBuffer asIntBuffer() 
          创建此字节缓冲区的视图,作为 int 缓冲区。 
abstract  LongBuffer asLongBuffer() 
          创建此字节缓冲区的视图,作为 long 缓冲区。 
abstract  ByteBuffer asReadOnlyBuffer() 
          创建共享此缓冲区内容的新的只读字节缓冲区。 
abstract  ShortBuffer asShortBuffer() 
          创建此字节缓冲区的视图,作为 short 缓冲区。 

示例

// 通过buf创建一个int缓冲区
IntBuffer intBuffer = buf.asIntBuffer(); // 创建此字节缓冲区的视图,作为 int 缓冲区。

5 创建可读写子缓冲区
通过slice方法来创建子缓冲区,子缓冲区的改变将直接影响原缓冲区,反之亦然。子缓冲区的默认位置为0

slice()   创建新的字节缓冲区,其内容是此缓冲区内容的共享子序列。

示例:

buf.position(4);
buf.limit(7);
ByteBuffer sub = buf.slice(); // 创建子缓冲区,从当前position=4到limit=7 (不包含)

无图不真相,来看看吧
这里写图片描述

6 创建子只读缓冲区
通过asReadOnlyBuffer() 方法来创建只读缓冲区,与slice方法不同的是此子缓冲区只可读不可写
示例:

// 创建一个只读子缓冲区,不可修改内容
buf.position(4);
buf.limit(7);
ByteBuffer readOnlyBuffer = buf.asReadOnlyBuffer();// 创建子缓冲区,从当前position=4到limit=7 (不包含)

7、遍历输出
第一种方式

for (int i = 0; i < sub.capacity(); i++) {
    byte curr=sub.get(i);
    System.out.println(curr);
    sub.put((byte) (curr+1));  // 对子缓冲区的修改即是对原缓冲区的修改
}

第二种方式

// hasRemaining判断是否还有元素
while(readOnlyBuffer.hasRemaining()){
    System.out.println(readOnlyBuffer.get());
}

8、判断功能

remaining() 
          返回当前位置与限制之间的元素数。
isDirect() 
          告知此缓冲区是否为直接缓冲区。 
isReadOnly() 
          告知此缓冲区是否为只读缓冲区。 
hasArray() 
          告知此缓冲区是否具有可访问的底层实现数组。 
hasRemaining() 
          告知在当前位置和限制之间是否有元素。 

更多细节可参考api文档。对于其他类型的缓冲区,大抵与ByteBuffer类似,这里不再赘述。

2.25 注意事项

  • 不要因为直接缓冲区效率高而总是使用它,建议将直接缓冲区主要分配给那些易受基础系统的本机 I/O
    操作影响的大型、持久的缓冲区。一般情况下,最好仅在直接缓冲区能在程序性能方面带来明显好处时分配它们。
  • 不要滥用flip、clear、rewind方法,只有正确合理的使用,才能达到操作缓冲区的目的。一般调用flip方法后,进入可读就绪,范围在[
    position,limit ) , 包左不包右; 调用clear方法后,可以进行数据的从新写入; 调用rewind方法可以重新读取。
  • 在没有设置mark或者mark的索引大于当前position的情况下,调用reset方法会导致异常InvalidMarkException的抛出。在clear方法后,标记失效
  • 明白position的含义,它表示下一个待写入或读取的元素,切记在读写数据时,要注意确保0 <= mark <= position <= limit <= capacity ,否则会抛出异常。
  • 若操作的position到达目标缓冲区限制limit时,当使用相关put方法会抛出此未经检查的异常BufferOverflowException,当使用相关get方法会抛出异常IndexOutOfBoundsException。
  • 对于其他类型缓冲区的创建,与ByteBuffer是类似的,还可以通过ByteBuffer的asXXXBuffer来创建,当ByteBuffer为直接缓冲区时则新创建的缓冲区为直接的,反则反之。同时要注意非ByteBuffer的缓冲区无法直接创建直接缓冲区。

更多细节可以下载源码看看。

2.26 Buffer继承体系图

这里写图片描述


2.3 通道(Channel)

2.3.1 通道概述

在NIO中的通道是一种类似与原IO的流的东西,与流的单向读写不同的是,通道是双向的,可读可写的。通道表示到实体,如硬件设备、文件、网络套接字或可以执行一个或多个不同 I/O 操作(如读取或写入)的程序组件的开放的连接。

读写缓冲区
有了通道这个连接,我们就可以进行读写操作了。但我们不会直接将字节从通道获取或写入,一般我们将数据读入或写到缓冲区中,然后进行字节操作,此时上面我们将的缓冲区就开始发挥作用了。

用一张图来描述这个关系

这里写图片描述

通道状态
通道可处于打开或关闭状态。创建通道时它处于打开状态,一旦将其关闭,则保持关闭状态。一旦关闭了某个通道,试图对其调用 I/O 操作就会导致 ClosedChannelException 被抛出。通过调用通道的 isOpen 方法可测试通道是否处于打开状态。

正如扩展和实现此接口的各个接口和类规范中所描述的,一般情况下通道对于多线程的访问是安全的。

常用通道

  • FileChannel (用于读取、写入、映射和操作文件的通道。)
  • DatagramChannel (用于UDP通信)
  • ServerSocketChannel, SocketChannel (用于TCP通信)

下面我们以FileChannel为例来看看如何使用通道。其他两种会在选择器那节讲到。

2.3.2 FileChannel

用于读取、写入、映射和操作文件的通道。

文件通道在其文件中有一个当前 position,可对其进行查询和修改。该文件本身包含一个可读写的长度可变的字节序列,并且可以查询该文件的当前大小。写入的字节超出文件的当前大小时,则增加文件的大小;截取该文件时,则减小文件的大小。文件可能还有某个相关联的元数据,如访问权限、内容类型和最后的修改时间;此类未定义访问元数据的方法。

除了字节通道中常见的读取、写入和关闭操作外,此类还定义了下列特定于文件的操作:

  • 将文件中的某个区域直接映射到内存中;对于较大的文件,这通常比调用普通的 read 或 write 方法更为高效。 (map方法)
  • 可以锁定某个文件区域,以阻止其他程序对其进行访问(lock与tryLock方法)。
  • 以不影响通道当前位置的方式,对文件中绝对位置的字节进行读取或写入(read(dst, position) 、write(src,
    position) )。
  • 强制对底层存储设备进行文件的更新,确保在系统崩溃时不丢失数据( force(metaData))。
  • 以一种可被很多操作系统优化为直接向文件系统缓存发送或从中读取的高速传输方法,将字节从文件传输到某个其他通道中,反之亦然。(transferFrom(src, position, count)、transferTo(position, count,target) )

我们先看一下通过FileChannel 读写文件来复制文件的示例

private static void test2() throws IOException {
        // 创建文件输入输出流
        FileInputStream fis=new FileInputStream("out.txt"); // 读源文件
        FileOutputStream fos=new FileOutputStream("fout.txt"); // 目标

        // 获取输入通道
        FileChannel cIn=fis.getChannel();
        // 获取输出通道
        FileChannel cOut= fos.getChannel();
        ByteBuffer buf=ByteBuffer.allocate(1024);

        while (cIn.read(buf)!=-1) {  // 读取
            buf.flip(); //  limit=position,position=0,写入就绪
            cOut.write(buf);    // 写入 [position,limit)
            buf.clear(); // position=0,limit=capacity,可复写状态
        }

        fis.close();
        fos.close();
        cIn.close();
        cOut.close();
    }

大致步骤:

通过文件打开文件输入输出流
通过文件输入输出流获得文件通道
通过输入输出通道进行读写
关闭流与通道

以上使用了两个方法,标注一下

read(ByteBuffer dst) 
          将字节序列从此通道读入给定的缓冲区。读取的字节数,可能为零,如果该通道已到达流的末尾,则返回 -1
write(ByteBuffer[] srcs) 
          将字节序列从给定的缓冲区写入此通道。

2.3.3 内存映射

内存映射是将文件内容直接送入内存,因此它的效率比普通的IO操作效率高的多。内存映射是由FileChannel.map来完成的,返回一个MappedByteBuffer对象,直接字节缓冲区,其内容是文件的内存映射区域。

有效性

映射的字节缓冲区和它所表示的文件映射关系在该缓冲区本身成为垃圾回收缓冲区之前一直保持有效。

部分映射

只有文件中实际读取或者写入的部分才会映射送入到内存中,现代操作系统一般根据需要将文件的部分映射为内存的部分,从而实现文件系统。Java内存映射机制是建立在底层操作系统这种机制的基础上,提供了对该机制的访问。

写入的风险性

通过内存映射机制,我们知道文件被映射入内存中,对内存的操作会直接影响原文件,所以它的写入是有风险的,因此不建议使用它来写入文件的操作。仅只是改变数组的单个元素这样的简单操作,就可能会直接修改磁盘上的文件。

因此建议使用内存映射时,只适宜读,不推荐写操作。

通过FileChannel的map方法创建MappedByteBuffer实现内存映射,map方法如下

map(FileChannel.MapMode mode, long position, long size)   将此通道的文件区域直接映射到内存中。 

方法参数:

  • mode - 根据是按只读、读取/写入或专用(写入时拷贝)来映射文件,分别为 FileChannel.MapMode 类中所定义的READ_ONLY、READ_WRITE 或 PRIVATE 之一
  • position - 文件中的位置,映射区域从此位置开始;必须为非负数
  • size - 要映射的区域大小;必须为非负数且不大于 Integer.MAX_VALUE

如下示例:

private static void test() throws IOException {
        // 创建文件输入流
        FileInputStream fis=new FileInputStream("out.txt");
        // 获取输入通道
        FileChannel cIn = fis.getChannel();
        // 将文件的指定内容映射到内存,并返回一个MappedByteBuffer对象,指定模式为只读
        MappedByteBuffer map = cIn.map(FileChannel.MapMode.READ_ONLY, 0, cIn.size());
        // 创建缓冲区存储
        ByteBuffer buf=ByteBuffer.allocate((int) cIn.size());
        // 读取
        while(map.hasRemaining()){
            buf.put(map.get());
        }
        // 输出
        System.out.println(new String(buf.array()));
        fis.close();
        cIn.close();
    }

2.3.4 文件锁(FileLock)

每次通过 FileChannel 类的 lock 或 tryLock 方法获取文件上的锁定时,就会创建一个文件锁定对象。

锁的生命周期
通过调用 release 方法、关闭用于获取该锁定的通道,或者终止 Java 虚拟机(以先到者为准)来释放锁定之前,该对象一直是有效的。可通过调用锁定的 isValid 方法来测试锁定的有效性。

独占锁与共享锁

独占锁

也称排它锁,如果一个线程获得一个文件的独占锁,那么其它线程就不能再获得同一文件的独占锁或共享锁,直到独占锁被释放。

共享锁

如果一个线程获得一个文件的共享锁,那么其它线程可以获得同一文件的共享锁或同一文件部分内容的共享锁,但不能获取排它锁 

当文件被加独占锁时, 其他线程不可读也不可写。
当文件被加共享锁时 ,其他线程可读但不可写。

lock与tryLock区别

lock
    lock()获取独占锁,阻塞方法,锁定范围可以随着文件的增大而增加。

    lock(long position, long size, boolean shared) 设置isShare=true为共享

    position - 锁定区域开始的位置;必须为非负数
    size - 锁定区域的大小;必须为非负数,并且 position + size 的和必须为非负数
    shared - 要请求共享锁定,则为 true,在这种情况下此通道必须允许进行读取(可能是写入)操作;要请求独占锁定,则为 false,在这种情况下此通道必须允许进行写入(可能是读取)操作 

tryLock
    tryLock()默认独占,非阻塞,当未获得锁时,返回null。

    tryLock(position,size,isShare)设置isShare=true为共享

    position - 锁定区域开始的位置;必须为非负数
    size - 锁定区域的大小;必须为非负数,并且 position + size 的和必须为非负数
    shared - 要请求共享锁定,则为 true;要请求独占锁定,则为 false 

注意事项

  • 可以对写文件操作加锁,而且必须是可写文件,否则抛出:java.nio.channels.NonWritableChannelException异常
  • 加的锁为进程锁,用于进程间并发,控制不同程序(JVM)对同一文件的并发访问,可以保证进程间顺序访问该文件,这样可以保证只有同一个进程才能拿到锁对文件访问。其他进程无法访问该文件,或者删除该文件的目录。
  • FileLock的lock和tryLock只能调用一次,释放后才能继续获得锁,否则会抛OverlappingFileLockException异常
  • java.io.File.deleteOnExit()
    在FileLock生命周期结束时,文件被删除,一般会用于临时文件的删除。强制关闭虚拟机,是不会删除文件的。

下面给出一个使用示例

public class FileLockDemo {
    public static void main(String[] args) throws IOException, InterruptedException {
        FileOutputStream fos=new FileOutputStream("out.txt");
        FileChannel fcOut = fos.getChannel();  // 获得文件通道

        System.out.println("尝试获得锁。。。");
        FileLock lock = fcOut.tryLock(); // 获得此通道的锁
        if (lock!=null) {
            System.out.println("获得锁。。。");
            Thread.sleep(3000);  // 休眠3秒
            lock.release();
            System.out.println("释放锁。。。");
        }
        fcOut.close();
        fos.close();
    }
}

输出:

尝试获得锁。。。
获得锁。。。
释放锁。。。

2.4 字符集

16 位的 Unicode 代码单元序列和字节序列之间的指定映射关系。引自api文档

此类定义了用于创建解码器和编码器以及获取与 charset 关联的各种名称的方法。此类的实例是不可变的。字符集是我们极具移植性的读写字符序列。

编码器与解码器
读写文件我们要用到 CharsetDecoder与CharsetEncoder,如下方法创建

CharsetDecoder newDecoder() 
          为此 charset 构造新的解码器。 
CharsetEncoder newEncoder() 
          为此 charset 构造新的编码器。 

标准 charset
Java 平台的每一种实现都需要支持以下标准 charset。如下表所示。

Charset描述
US-ASCII7 位 ASCII 字符,也叫作 ISO646-US、Unicode 字符集的基本拉丁块
ISO-8859-1ISO 拉丁字母表 No.1,也叫作 ISO-LATIN-1
UTF-88 位 UCS 转换格式
UTF-16BE16 位 UCS 转换格式,Big Endian(最低地址存放高位字节)字节顺序
UTF-16LE16 位 UCS 转换格式,Little-endian(最高地址存放低位字节)字节顺序
UTF-1616 位 UCS 转换格式,字节顺序由可选的字节顺序标记来标识

使用CharsetEncoder与CharsetDencoder示例

private static void test() throws CharacterCodingException {
        // 获得一个指定名称的charset对象
        Charset charset=Charset.forName("gbk");
        CharsetEncoder encoder = charset.newEncoder(); //获得编码器
        CharsetDecoder decoder = charset.newDecoder(); // 获得解码器
        // 创建字符缓冲区
        CharBuffer cb=CharBuffer.wrap("今天天气不错~心情挺爽的".toCharArray());

        // 通过编码器编码
        ByteBuffer encode = encoder.encode(cb);
        System.out.println(new String(encode.array(), 0, encode.limit()));
        // 通过解码器解码
        CharBuffer decode = decoder.decode(encode);
        System.out.println(new String(decode.array(), 0, decode.limit()));
    }

以下列出一些常用方法

abstract  CharsetDecoder newDecoder() 
          为此 charset 构造新的解码器。 
abstract  CharsetEncoder newEncoder() 
          为此 charset 构造新的编码器。 
static Charset forName(String charsetName) 
          返回指定 charset 的 charset 对象。  
static SortedMap<String,Charset> availableCharsets() 
          构造从规范 charset 名称到 charset 对象的有序映射。 
 CharBuffer decode(ByteBuffer bb) 
          将此 charset 中的字节解码成 Unicode 字符的便捷方法。  
 ByteBuffer encode(CharBuffer cb) 
          将此 charset 中的 Unicode 字符编码成字节的便捷方法。  

2.5 选择器 (Selector)

早在 Java多线程这篇博文中,我们说到中断线程小节时,有三种情况可以来中断一个线程,第三种若是线程在一个 Selector 中受阻,它将立即从选择操作返回,从而提早地终结被阻塞状态。那么Selector 到底是什么呢?

Selector是SelectableChannel 对象的多路复用器。 Selector是实现异步非阻塞IO的关键。我们可以通过SelectableChannel通道注册我们感兴趣的事件到Selector中,并获得一个SelectionKey,SelectionKey是我们判断到底发生的是那个事件的关键。当我们感兴趣的事件发生时,Selector就会根据相应的SelectionKey来对事件类型做出判断,然后我们就可以进行事件的处理了。

注:SelectableChannel的实现类有DatagramChannel, ServerSocketChannel, SocketChannel ,它们是实现非阻塞式socket通信的基础。

下面我们来实现一个非阻塞的TCP通信。我将服务端大致步骤列了出来:

1、开启一个Selector,并创建一个selector对象
2、打开一个 ServerSocketChannel,并进行阻塞配置与服务绑定
3、注册感兴趣的事件到selector,并获得SelectionKey
4、循环进行事件侦听,通过SelectionKey辨别事件类型
5、通过SelectionKey获取对应通道,接收新连接,并注册读写事件到selector
6、相应的事件发生时,与客户端交互
7、移除已处理过的SelectionKey

既然需要使用Selecotor,我们得先创建一个。通过它的open方法来打开一个Selecotor

Selector selector = Selector.open();

开启服务端socket通道ServerSocketChannel ,设置此通道的非阻塞性。然后将此通道绑定到对应的端口。对于每一个需要监听的端口,都需要一个ServerSocketChannel。

ServerSocketChannel ssc = ServerSocketChannel.open();
ssc.configureBlocking(false); // 配置通道为非阻塞
ServerSocket socket = ssc.socket(); // 获得服务端通道对应的socket
InetSocketAddress address = new InetSocketAddress(ports[i]);
socket.bind(address); // 绑定服务

然后我们就可以将感兴趣的事件注册到select,同时我们要指明注册事件的类型SelectionKey.OP_ACCEPT,表示是accept事件。

SelectionKey selectionKey = ssc.register(selector,SelectionKey.OP_ACCEPT);

事件类型有如下几种,它们被定义在SelectionKey 这个类中:

static int OP_ACCEPT 
          用于套接字接受操作的操作集位。 
static int OP_CONNECT 
          用于套接字连接操作的操作集位。 
static int OP_READ 
          用于读取操作的操作集位。 
static int OP_WRITE 
          用于写入操作的操作集位。 

在注册完成后,我们就可以通过Selector来侦听感兴趣的事件了。通过取出准备好的通道所对象的SelectionKey,与所有SelectionKey集合比较来判断事件的类型。我们可以通过select() ,select(long timeout) 、selectNow() 来获取SelectionKey。前两个方法为阻塞式选择,第三个是非阻塞式。

// 选择一组key,与它对应的通道已经准备好了io操作
    int select = selector.select();
    // 获取此选择器上所有的键集
    Set<SelectionKey> selectedKeys = selector.selectedKeys();
        Iterator<SelectionKey> iterator = selectedKeys.iterator();
        while (iterator.hasNext()) { // 遍历所有键集
            SelectionKey selectionKey = iterator.next();
                // 根据selectionKey 来判断事件类型
        }
    }

事件类型有如下几种,被定义在SelectionKey 这个类中

 boolean isAcceptable() 
          测试此键的通道是否已准备好接受新的套接字连接。 
 boolean isConnectable() 
          测试此键的通道是否已完成其套接字连接操作。 
 boolean isReadable() 
          测试此键的通道是否已准备好进行读取。 
 boolean isWritable() 
          测试此键的通道是否已准备好进行写入。 

下面就开始匹配对应的事件了。当为accept事件时,我们还可以为SocketChannel 注册一个读的事件 client.register(selector, SelectionKey.OP_READ);。在事件处理完后,通过iterator.remove(); 来移除已处理的selectionKey,防止已处理的事件被重复处理。

if (selectionKey.isAcceptable()) { // 如果此键的通道已经准备好建立一个socket连接,accept事件
                    // 通过selectionKey获得通道
ServerSocketChannel server = (ServerSocketChannel) selectionKey
            .channel();
    SocketChannel client = server.accept();// 接收一个新连接
    client.configureBlocking(false); // 配置为非阻塞
    client.register(selector, SelectionKey.OP_READ); // 注册为read事件       
    ...
    iterator.remove(); // 移除已处理的selectionKey
}else if(selectionKey.isReadable()){ // 可读
    ...
    iterator.remove(); // 移除已处理的selectionKey
}

到此服务端的流程基本完成了,下面给出一个完整的代码:

public class SelectorServer {
    public static void main(String[] args) throws IOException {
        // 要监听的端口号数组
        int[] ports = { 9999, 9998, 9997, 9996 };
        // 开启一个Selector
        Selector selector = Selector.open();
        // 为每个端口都开启事件监听
        for (int i = 0; i < ports.length; i++) {
            // 打开一个ServerSocketChannel服务端通道
            ServerSocketChannel ssc = ServerSocketChannel.open();
            ssc.configureBlocking(false); // 配置通道为非阻塞
            ServerSocket socket = ssc.socket(); // 获得服务端通道对应的socket
            InetSocketAddress address = new InetSocketAddress(ports[i]);
            socket.bind(address); // 绑定服务
            // 注册想要监听的事件,此处是accept事件
            SelectionKey selectionKey = ssc.register(selector,
                    SelectionKey.OP_ACCEPT);
            System.out.println("正在侦听端口" + ports[i]);
        }

        while (true) {
            // 选择一组key,与它对应的通道已经准备好了io操作
            int select = selector.select();
            // 获取此选择器上所有的键集
            Set<SelectionKey> selectedKeys = selector.selectedKeys();
            Iterator<SelectionKey> iterator = selectedKeys.iterator();
            while (iterator.hasNext()) { // 遍历所有键集
                SelectionKey selectionKey = iterator.next();
                if (selectionKey.isAcceptable()) { // 如果此键的通道已经准备好建立一个socket连接,accept事件
                    // 通过selectionKey获得通道
                    ServerSocketChannel server = (ServerSocketChannel) selectionKey
                            .channel();
                    SocketChannel client = server.accept();// 接收一个新连接
                    client.configureBlocking(false); // 配置为非阻塞
                    client.register(selector, SelectionKey.OP_READ); // 注册为read事件
                    System.out.println("新建连接"+client.getLocalAddress());

                    iterator.remove(); // 移除已处理的selectionKey
                }else if(selectionKey.isReadable()){ // 可读事件
                    // 通过selectionKey获得通道
                    SocketChannel client = (SocketChannel) selectionKey
                            .channel(); // 获取客户端通道
                    client.configureBlocking(false); // 设置非阻塞
                    ByteBuffer fromClient = ByteBuffer.allocate(1024);
                    int len = client.read(fromClient); // 读取客户端发过来的信息
                    System.out.println("From Client:"
                            + new String(fromClient.array(), 0, len));

                    iterator.remove();
                }
            }

        }

    }
}

再写一个客户端来测试,每隔2秒向服务端发送信息:

public class Client {
    public static void main(String[] args) throws IOException, InterruptedException {
        // 获得socket通道
        SocketChannel channel = SocketChannel.open();
        // 通过主机名和端口创建一个socket地址
        int port=9999;
        InetSocketAddress address = new InetSocketAddress("localhost", port);
        // 连接远程服务
        channel.socket().connect(address);

        ByteBuffer src= ByteBuffer.allocate(1024);
        while (true) {
            src.clear();
            src.put(new Date().toString().getBytes());
            src.put( ("  哈喽,来自高大上的客户端的问候,目标"+port).getBytes());
            src.flip();
            channel.write(src); // 向服务端 发送数据
            Thread.sleep(2000);

        }
}

先运行服务端,打印输出:

正在侦听端口9999
正在侦听端口9998
正在侦听端口9997
正在侦听端口9996

在运行客户端,服务端继续输出:

新建连接/127.0.0.1:9999
From Client:Mon Oct 10 15:13:01 CST 2016  哈喽,来自高大上的客户端的问候,目标9999
From Client:Mon Oct 10 15:13:02 CST 2016  哈喽,来自高大上的客户端的问候,目标9999
...

当我们将客户端远程连接服务端口改为9998时,服务端有如下输出:

新建连接/127.0.0.1:9998
From Client:Mon Oct 10 15:12:58 CST 2016  哈喽,来自高大上的客户端的问候,目标9999
From Client:Mon Oct 10 15:12:59 CST 2016  哈喽,来自高大上的客户端的问候,目标9998
From Client:Mon Oct 10 15:13:00 CST 2016  哈喽,来自高大上的客户端的问候,目标9999
...

到此一个非阻塞的TCP通信的案例就已经完成了。我们使用Selector就用了一个线程,来管理多个ServerSocketChannel通道,实现了多个客户端同时连接服务的目标。如果使用原IO的阻塞流来完成TCP通信,我们每次有客户端连接时,则需开启一个新线程来处理。相比之下,NIO在非阻塞的优势下,的确比原IO更加的灵活高效,不过相应的我们的业务代码比之以前复杂了不少。

wakeup
如果另一个线程目前正阻塞在 select() 或 select(long) 方法的调用中,我们可以在其他线程通过那个selector的wakeup方法将此阻塞中断,调用立即返回。如果当前未进行选择操作,那么在没有同时调用 selectNow() 方法的情况下,对上述方法的下一次调用将立即返回。

close
关闭此选择器。如果某个线程目前正阻塞在此选择器的某个选择方法中,则中断该线程,如同调用该选择器的 wakeup 方法那样。

  • 所有仍与此选择器关联的未取消键已无效、其通道已注销,并且与此选择器关联的所有其他资源已释放。 如果此选择器已经关闭,则调用此方法无效。
  • 关闭选择器后,除了调用此方法或 wakeup 方法外,以任何其他方式继续使用它都将导致抛出ClosedSelectorException。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值