Java NIO(一)

约定

有很多人会将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的网络编程。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 210,914评论 6 490
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 89,935评论 2 383
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 156,531评论 0 345
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,309评论 1 282
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,381评论 5 384
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,730评论 1 289
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,882评论 3 404
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,643评论 0 266
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,095评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,448评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,566评论 1 339
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,253评论 4 328
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,829评论 3 312
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,715评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,945评论 1 264
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,248评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,440评论 2 348

推荐阅读更多精彩内容