平时写业务代码不一定会直接涉及到底层IO的使用,但是作为一个有点思想的码农,就算用不到,至少也应该知道。所以准备系统地学习下Java NIO,为日后看框架源码打下点基础。本系列博客学习的依据是前人翻译的《Java NIO》中文版,网友对该书褒贬不一,可能各个人群的需求不一样,确实重口难调。但是,没有经历过,怎么知道对自己是否合适。废话不多说了,摆好姿势,开始学习了。先建个项目,用于存放测试用力,本人的项目位于Github。
Buffer类是java.nio的构造基础,简单来说,Buffer对象就是用来存放数据的。先看下,Buffer的直接继承关系:
从图中可以看出,Byffer的子类基本上是跟Java基本数据类型密切相关的。打开IntBuffer.java看下源码,其中有一个成员变量final int[] hb;
,这就是存放数据的数组。所以Buffer类的底层实现可以认为就是数组,但是同时做了一些扩展。
属性
所有的缓冲区都包含四个重要的属性。
- 容量(capacity):缓冲区能够容纳的数据元素的最大数量,这一容量在缓冲区创建时被设定,并且永远不能被改变
- 上限(limit):缓冲区的第一个不能被读或写的元素,或者说,缓冲区存储元素个数的上限
- 位置(position):下一个要被读或写的元素的索引,初始值为0
- 标记(mark):备忘位置,初始值为-1,表示未定义。通过调用某些方法给该标记设值
这四个属性之间的关系是:mark <= position <= limit <= capacity,下面展示一个新创建的容量为10的ByteBuffer:
从图中可以看出,初始时mark未被定义,position被设成0,limit和capacity被设为10,由于数组下标从0开始,所以该buffer可访问的地址为0~9,limit和capacity指向的10号位是不可访问的。
缓冲区API
Buffer类定义了一些公共的API,看了下源码,这些API基本上都是对属性的设值。可想而知,对位置的操作是使用Buffer类的关键,下面挑一个来看下:
从上述API可以看出两个特点:
- 对于缓冲区的某些操作不是直接操作数据,而是通过标志位来实现。比如clear()方法,不是清空数组中的数据,而是将position设成0等,来表示该缓冲区已经清空。
- API的返回值为Buffer引用,这样可以通过级联调用的方式来使用,能产生简洁、优美、易读的代码。比如:buffer.mark().position(5).reset( )
另外一些常用的API如下:
存取
存取方法不在Buffer类中,因为涉及到数据类型,所以get()、put()方法被置于Buffer的子类中。比如说IntBuffer的get()方法最终是在HeapIntBuffer中实现:
填充
我们先看一个例子,在项目中新建个类,该示例位于HelloBuffer.java中。我们将代表“Hello”字符串的ASCII码载入一个名为buffer的ByteBuffer对象中:
这里有两点点需要注意下:
- 上例中每个字符都必须强转成byte
- 该缓冲区存的单位是字节,而char在java中占2个字节,这里强转会丢掉前八位,仅做测试用。
5次调用put()之后缓冲区的状态如下图:
这类图以后就不画了,太烦了。通过如下代码可以修改buffer中的值:
翻转
要读取写完的buffer,需要先对buffer进行翻转动作,Buffer类提供flip()函数正是提供该功能,我们先来看下该函数实现代码:
经过翻转之后,表示该缓冲区从0~limit-1是可读的。
释放
Buffer类提供hasRemaining()函数,用于查看缓冲区是否已达上限,该函数实现很简单,只是判断下position是否小于上限:
综上所述,一个简单的、完整的填充、读取缓冲区的代码如下:
压缩
有时候我们可能只想从缓冲区中释放一部分数据,然后接着填充,为了实现这一功能,未读的数据元素的第一个的下标要移到第0位,看源码是通过复制的方式实现的:
标记
也就是mark属性起作用的时候,通过reset()方法,可以将buffer重置到标记位,我们来看下reset()方法:
比较
通过equals()函数可以测试两个缓冲区是否相等,下面是达成两个缓冲区相等的条件:
- 两个缓冲区中的对象类型相同。
- 两个缓冲区都剩余相同数量的元素。Buffer的容量不需要相同,而且缓冲区中剩余元素的索引也不必相同。
- 在每个缓冲区应被get()函数返回的剩余数据元素序列必须一致。
还提供compareTo()函数让两个缓冲区进行比较,这里不多说了,后面如果用到再详细看。
批量移动
缓冲区的设计目的就是为了能够高效地传输数据,所以buffer API提供了向缓冲区内外批量移动数据元素的函数。get()和put()都有提供,我们先了解下有这个功能,用的时候知道尽量调用高效API就是了。
创建缓冲区
上面的一个小例子中,我们已经知道了如何创建缓冲区,这里我们以CharBuffer为例,再来详细说明下。创建缓冲区有两种方式:
- 通过静态工厂方法allocate()来创建实例。比如要分配一个容量为100个char变量的Charbuffer:
CharBuffer charBuffer = CharBuffer.allocate(100)
,这段代码隐含地从堆空间中分配了一个char型数组作为备份存储器来存储100个char变量。 - 通过wrap()函数来创建实例。比如
CharBuffer charBuffer = CharBuffer.wrap(myArray);
,warp()函数中没有对myArray进行深拷贝,所以程序对myArray的操作和对缓冲去的操作会相互影响。
通过allocate()和wrap()函数创建的缓冲区都是间接的,是JVM托管的。从CharBuffer的子类HeapCharBuffer的名字也可以猜出来,是分配在JVM堆中的。
复制缓冲区
缓冲区提供duplicate()函数,来复制一个缓冲区。需要注意的是,复制一个缓冲区会创建一个新的Buffer对象,但不复制数据。原始缓冲区和副本都会操作同样的数据元素。
字节缓冲区
字节是操作系统及其I/O设备使用的基本数据类型,当在JVM和操作系统间传递数据时,将其他的数据类型拆分成字节是很有必要的。所以我们着重来看下字节缓冲区。
字节序
非字节类型的基本数据,都是以连续字节序列的形式存储在内存中的。例如32位int值0x037fb4c7,可能以下面两种方式存储在内存中(假设内存从左往右地址递增):
- 大端字节序:c7 b4 7f 03,高位存储在内存地址高位。
- 小端字节序:03 7f b4 c7,高位存储在内存地址低位。
在java.nio中,字节顺序由ByteOrder类封装,Java默认的字节序是ByteOrder.BIG_ENDIAN,也就是大端序。
直接缓冲区
简单来说,直接缓冲区使用的内存是通过调用本地操作系统方面的代码分配的,绕过了标准的JVM堆栈。直接缓冲区是I/O的最佳选择,但可能比创建非直接缓冲区要花费更高的成本。直接缓冲区的内存区域不受GC支配,因为它们位于标准的JVM堆栈之外。使用直接缓冲区或间接缓冲区的性能权衡会因JVM,操作系统,以及代码设计而产生巨大差异。关于两者的选择,作者给出了一条建议:先使其工作,再加快其运行。言外之意就是,没事儿别瞎折腾,先使用间接缓冲区就可以了。但还是要知道下直接缓冲区是怎么创建的,通过ByteBuffer.allocateDirect()创建即可。
视图缓冲区
视图缓冲区通过已存在的缓冲区对象实例的工厂方法来创建。这种视图对象维护它自己的属性、容量、位置、上界和标记,但是和原来的缓冲区共享数据元素。但是ByteBuffer类允许创建视图来将byte型缓冲区字节数据映射为其它原始数据类型。例如,asLongBuffer()函数创建一个将八个字节(64位)数据当成一个long型数据来存取的视图缓冲区。我们用一个小例子来演示下:
数据元素视图
ByteBuffer类提供一个不太重要的机制来以多字节数据类型的形式存取byte数据组。比如说getInt()函数被调用,从当前位置开始的四个字节会被包装成一个int类型的变量,然后作为函数的返回值返回。
内存映射缓冲区
映射缓冲区是带有存储在文件,通过内存映射来存取数据元素的字节缓冲区。映射缓冲区通常是直接存取内存的,只能通过 FileChannel 类创建。映射缓冲区的用法和直接缓冲区类似,但是MappedByteBuffer对象可以处理独立于文件存取形式的的许多特定字符。(MappedByteBuffer是ByteBuffer专门用于内存映射文件的一种特例,是ByteBuffer的子类)。这个具体后面再看。
缓冲区到这儿就算学完了,当然有些API并没有完全列出来,但是我们已经知道了其实现原理,针对性地去看源码也就不是什么事儿了。