软件开发Thinking in Java

Java NIO(一)

2017-02-25  本文已影响217人  墙角儿的花

约定

有很多人会将Java NIO分为Java NIO和Java NIO2,分别指jdk1.4引入的新IO和jdk1.7引入的新IO,在本人IO系列的文章里,将Java IO编程模型分为三种,Java BIO即Java IO标准库、Java NIO即jdk1.4引入的新IO包、Java AIO即jdk1.7引入的新IO包,尽管这样的描述不是非常的精确,但是这样方便在概念上将其区分开来。

此IO系列文章计划讨论NIO和AIO,本文聚焦NIO。

引言

Java NIO 并不是什么新技术,但对java程序员来说,NIO的概念可能了解了但并没有机会深入研究,因为多数程序员都疲于编写应用层的业务代码,很少触及高效的IO编程,除非你有机会涉及分布式应用且要自己处理通信,比如Hadoop的远程调用,或者编写如jetty这样的http server。但除了分布式应用,我们依然可以在文件处理的相关应用中使用它,因此,好好掌握它是有实际意义的。

NIO在JDK1.4中引进,其本意是new IO,当然,因为其提供了非阻塞IO编程模型,也有人称之为非阻塞IO。
和我们经常用的java 标准IO相比主要有两个区别:

  1. 标准IO面向流处理数据,NIO面向块处理数据

    利用标准IO编程需要和字节流以及字符串流打交道,对于NIO编程需要和管道(channel) 和 数据块(buffer)打交道,数据经常会从channel读到buffer,或者从buffer写到channel;面向流的处理方式是一次处理一个字节,NIO的每次操作将创建或消费一个块,因此处理起来更加快速,但是也失去了面向流编程的简单直接的编程体验。

  2. 标准IO提供的是阻塞的编程模型,而NIO是非阻塞编程模型

    在以前的文章里解释过阻塞和非阻塞的区别,标准IO在读写数据时,在读写未完成前,线程一直无法做其他事情。NIO编程,在将数据从channel读取到buffer里时,线程可以做其他事情,一旦数据被读入buffer,线程可以去处理它。

尽管如此,标准IO和NIO间做了些融合,一些IO包的api可以读写块,NIO也可以按字节操作。另外,这两种IO编程模型并不存在谁取代谁的论调,它们在实际应用中有着各自的特色。BIO和NIO之间最典型的应用区别在于网络编程的应用上,他们之间就是杀鸡刀和宰牛刀的区别。

  1. 标准IO模型概念简单直接,编程方式优雅,但因其是阻塞的IO模型,所以其适合处理并发量较小的场景。如java程序员经常会用到的java远程调试,目标虚拟机只允许一个调试客户端对其进行调试,调试客户端和目标虚拟机的通信完全就是独占模式,这个场景用个小巧的杀鸡刀就可以了。

  2. NIO是非阻塞的IO模型,一个线程就可以同时处理多个请求,提高了单位时间内创建连接数能力,适合高访问量的在线服务。

管道和数据块

小时候邻居家有口压水井,那里承载着无数泛黄的记忆,左邻右舍常常提着桶去打水,用来洗衣做饭,而每到此时,小伙伴们便去争先恐后的压手柄,伴随着卖力的喘气声,井里的水在气压的作用下涌出,看见清澈的凉水流入水桶有种莫名的成就感,那种简简单单的欢呼雀跃简直比复杂的编程舒服多了。
谁都没有想到,若干年后其中一个孩子干起了编程,并把压水井的管道以及水桶比作NIO的管道和数据块了。

NIO中有两个核心概念,即管道(channel)和数据块(buffer),它们将用在NIO的每个IO操作中。

在NIO的世界里,数据流转必须通过channel进行,它代表一个连接对象,连接目标可以是某个硬件设备、一个文件、一个socket、或者一个能进行IO操作的程序组件。NIO中主要的channel包括:FileChannel DatagramChannel SocketChannel ServerSocketChannel,主要涉及UDP TCP网络IO和文件IO。channel类似标准IO的stream,但channel不同于stream,它是双向的,可以通知支持读写,而stream是单向的,要么只能读要么只能写。我们可以读取channel里的数据,也可以往channel里写入数据,但是channel不提供具体的数据操作能力,对channel的读写都必须通过buffer来操作。

buffer是一个数据容器,所有要写入到channel的数据必须先存入buffer,然后告诉channel打算写入管道的数据在哪个buffer里,从channel读取的任务数据时,也要告诉channel,将读取的数据放到哪个buffer里。因此,buffer是从channel读取数据或将数据写入channel的中间载体,其本质是一个数组,提供了结构化的数据访问能力,它会跟踪系统的读写过程。

NIO为每种java基本类型提供了buffer类:ByteBuffer CharBuffer ShortBuffer IntBuffer LongBuffer FloatBuffer DoubleBuffer 它们都继承与抽象的Buffer类。

在本文里我们将熟悉常用的文件管道FileChannel和字节数据块ByteBuffer。

直观的感受

读取文件

首先,从标准IO的文件输入流打开一个文件管道,创建一个容量为1024字节的buffer,然后将文件管道里的数据读取到buffer。最终读取到多少是不确定的,取决于文件管道所剩的数据量和buffer的容量,但是如果read方法一旦返回-1,代表读到了文件末尾。

FileInputStream in = new FileInputStream("/xxx.txt");
FileChannel c = in.getChannel();
ByteBuffer buffer = ByteBuffer.allocate( 1024 );
c.read(buffer);

写文件

首先,从文件输出流打开一个文件管道,创建一个容量为1024字节的buffer,将字符串内容存入buffer,再将buffer内容写入管道。

FileOutputStream out = new FileOutputStream("/xxx.txt");
FileChannel c = in.getChannel();
ByteBuffer buffer = ByteBuffer.allocate( 1024 );
buffer.put('something you like'.getBytes());
buffer.flip();
c.write(buffer);

拷贝文件

拷贝一个文件到另一个文件,其大致过程是不断的读取源管道的内容到buffer,再将buffer的内容写入目标管道。buffer的clear和flip操作将在后面细说。

FileInputStream in = new FileInputStream("/src");
FileChannel fcin = in.getChannel();
FileOutputStream out = new FileOutputStream("/target");
FileChannel fcout = in.getChannel();
ByteBuffer buffer = ByteBuffer.allocate( 1024 );
while(true){
    buffer.clear();
    int rl = fcin.read(buffer);
    if(rl==-1){
        break;
    }
    buffer.flip();
    fcout.write(buffer);
}

ByteBuffer

buffer的读写状态

buffer本质上是一个序号从0开始计数的数组,在每次读写操作后其读写状态都会发生变化,主要体现在position,limit,capacity三个变量上。

positon指向下次应该读取或写入的位置。对于从管道读取内容放到buffer里的场景,position指向下次从管道读取数据时应该写入buffer的位置,如目前从管道里一共读取了10个字节,那么position就指向位置10,它是下一个数据存放位置。对于将buffer里的内容写入到管道的场景,position代表下次向管道写入的数据位于buffer的哪个位置,如目前写入了20个字节,那么position就指向位置20,它是下一个数据读取位置。

limit是buffer的读写边界。对于从管道读取内容放到buffer里的场景,limit代表能够写入buffer的空间长度,它指向buffer最大允许写入位置的下一个位置;对于将buffer里的内容写入到管道的场景,limit是决定了buffer里有多少数据可被写入到管道,具体来说,它指向buffer最大允许读取位置的下一个位置。

capacity是buffer的最大容量,就是buffer底层数组的size。

任何情况下,position<=limit<=capacity。

创建ByteBuffer

通过静态方法ByteBuffer.allocate,或wrap包装方法,通过wrap包装的方式其包装的数组的数据内容会和buffer的数据内容一致。

clear和flip

如上所说,buffer其实就是一块连续的内存,那么申请到一块内存是有系统开销的,对应用程序而言应该充分利用好这块内存,不要频繁创建。例如上面给出的拷贝文件的例子就充分利用了临时申请的buffer。

特别是经常处于读写切换的场景,如从一个管道读内容到buffer,再将buffer写入到其他管道完成数据拷贝。更应该充分利用clear和flip。

clear操作:使limit=capacity position=0,和新创建一个buffer时的状态一致。

flip操作:limit=position position=0。

使用buffer来读写管道内容遵循四个步骤

  1. 从管道读取数据,此时数据写入buffer
  2. 调用buffer.clip方法,将buffer转为待读模式
  3. 读取buffer的内容,将其写入另一个管道
  4. 调用buffer.clear方法,将buffer转为待写模式

读写

get和put操作是读取和写入方法,分绝对位置和相对位置操作,和相对位置操作相比,绝对位置的读写操作不影响buffer的状态。

ByteBuffer.get(int index)可以指定位置读取。ByteBuffer.get()按着当前position指定的位置读取,读取完后position自动后移一位;ByteBuffer.put(int index,byte b)指定具体的写入位置,ByteBuffer.put(byte b)position指定的位置写入,写入后position后移一位。

分片

slice可以创建子buffer,子buffer的内容是从当前position截取到limit-1,子buffer和父buffer各自的读写状态独立,但是数据是共享的。改变了子buffer的数据内容,父buffer对应的数据内容也会改变。

控制只读

asReadOnlyBuffer可以将buffer转为只读buffer,在有些时候你防止其他逻辑修改buffer内容时可以使用

FileChannel

FileChannel对象是一个连接文件的管道,通过该管道可以读写目标文件。另外,要强调的是FileChannel虽然属于NIO范畴,但其并不是非阻塞模式的管道,因此,在读写文件管道时其和被java程序员所熟悉的文件输入输出流一样依然属于阻塞模式,这也是为什么其用在文件读写的概率不高的原因。

创建文件管道

创建文件管道可以通过IO标准库的FileInputStream和FileOutputStream,以及RandomAccessFile的getChannel方法打开一个文件管道。如下代码:

public final FileChannel getChannel()

也可以通过FileChannel提供的静态的open方法

public static FileChannel open(Path path, OpenOption... options)
public static FileChannel open(Path path,
                                   Set<? extends OpenOption> options,
                                   FileAttribute<?>... attrs)

读写管道

通过read方法可以将文件管道中的数据读取到buffer中,如下代码:

ByteBuffer buffer = ByteBuffer.allocate(1024);
int bytesRead = channel.read(buffer);

read方法返回int类型的返回值,表示从管道中读取了多少字节的数据并存入buffer内,如果返回值为-1,表示已经读到了文件末尾。

同样的,通过write方法可以将buffer中的数据写入文件管道,如下代码:

String s = "something to write";
ByteBuffer buffer = ByteBuffer.allocate(1024);
buffer.put(s.getBytes());
buffer.flip();
channel.write(buffer);

分散读和聚集写

可以将channel的数据读到多个buffer中,如channel.read(ByteBuffer[])方法,称之为分散读,当管道填满了第一个buffer,它会自动向下个buffer写入数据。分散读通常用在对管道中的数据进行固定分片,每个片段的数据具有固定意义的场景,这样通过一次读管道即可取出有整体意义的分片数据,如下代码:

ByteBuffer header = ByteBuffer.allocate(128);
ByteBuffer body   = ByteBuffer.allocate(1024);
ByteBuffer[] bufferArray = { header, body };
channel.read(bufferArray);

同样的,也可以将多个buffer的数据写入到管道,如channel.write(ByteBuffer[]),管道会按顺序将buffer内的数据写入管道,称之为聚集写。当然,写入管道的数据是每个buffer的postion至limit之间的数据。如下代码:

ByteBuffer[] bufferArray = { header, body };
channel.write(bufferArray);

管道间数据转移

FileChannel提供了文件管道和其他管道间数据转移的方法,其他管道指的是实现了ReadableByteChannel或WritableByteChannel的管道,主要包括常用的文件管道,和涉及网络编程的socket管道以及数据报管道。因此,如果要真的实现一个文件拷贝的功能,用管道间的数据转移功能是相当简便的。

public abstract long transferFrom(ReadableByteChannel src,long position, long count)
public abstract long transferTo(long position, long count, WritableByteChannel target)

position和size

文件管道提供了获取和设置position的方法,如果设置新的position超过文件末尾,读取动作将返回-1,写入动作会导致先前的文件末尾和新的position之间形成空缺。

long position()
FileChannel position(long newPosition)

size方法可以返回文件的大小

long size()

对于可写的文件管道,通过truncate方法可以以任意大小截取管道,否则会抛出NonWritableChannelException异常。指定的size小于当前文件大小才会重建,否则不会发生变化,另外,重建是从位置0开始截取,截取size个字节。

FileChannel truncate(long size)

强制写入

为了提高性能,操作系统可能会缓存一些数据在内存里且并未写入磁盘,文件管道提供了force方法,强迫所有修改写入磁盘,其boolean类型的参数表示文件的元数据是否一起强制写入。这个方法在防止系统崩溃导致数据丢失时很有用。

void force(boolean metaData)

文件锁

文件管道提供了对文件进行加锁的方法:

FileLock lock()
FileLock lock(long position, long size, boolean shared)
FileLock tryLock()
FileLock tryLock(long position, long size, boolean shared)

lock和tryLock的区别在于前者以阻塞的方式请求文件锁,而第二种方式是尝试获取文件锁,无论是否成功都会立即返回。

带参数的方法给予了文件区域加锁的能力,以及指定是否为共享锁。但是,区域加锁和共享锁的能力取决于操作系统的实现,因此,一般建议使用文件锁时只考虑整个文件的排它锁。另外,文件锁对象的所属者属于java虚拟机进程,并非线程,因此绝对不要用它来实现多线程控制。

另外,用文件锁的典型场景是一个java进程希望独占该文件,不希望其他进程干扰。如一个java应用程序启动时通过tryLock对某文件加排它锁,如果没有获取到锁,则退出进程,通过这样的方式可以控制某java应用程序只会运行一个进程。

小结

本文属于java NIO系列的第一篇,讲述了Java NIO的相关概念,着重理解管道Channel和数据块Buffer的作用,并对文件管道FileChannel和字节数据块ByteBuffer进行较深入的介绍,在此基础上读者深入其他管道和Buffer将不在话下。下期我们将进入NIO的网络编程。

上一篇下一篇

猜你喜欢

热点阅读