Java NIO主要解决了Java IO的效率问题,解决此问题的思路之一是利用硬件和操作系统直接支持的缓冲区、虚拟内存、磁盘控制器直接读写等优化IO的手段;思路之二是提供新的编程架构使得单个线程可以控制多个IO,从而节约线程资源,提高IO性能。
Java IO引入了三个主要概念,即缓冲区(Buffer)、通道(Channel)和选择器(Selector),本文主要介绍缓冲区。
- 缓冲区概念
缓冲区是对Java原生数组的对象封装,它除了包含其数组外,还带有四个描述缓冲区特征的属性以及一组用来操作缓冲区的API。缓冲区的根类是Buffer,其重要的子类包括ByteBuffer、MappedByteBuffer、CharBuffer、IntBuffer、DoubleBuffer、ShortBuffer、LongBuffer、FloatBuffer。从其名称可以看出这些类分别对应了存储不同类型数据的缓冲区。
1.1四个属性
缓冲区由四个属性指明其状态。
容量(Capacity):缓冲区能够容纳的数据元素的最大数量。初始设定后不能更改。
上界(Limit):缓冲区中第一个不能被读或者写的元素位置。或者说,缓冲区内现存元素的上界。
位置(Position):缓冲区内下一个将要被读或写的元素位置。在进行读写缓冲区时,位置会自动更新。
标记(Mark):一个备忘位置。初始时为“未定义”,调用mark时mark=positon,调用reset时position=mark。
这四个属性总是满足如下关系:
mark<=position<=limit<=capacity
如果我们创建一个新的容量大小为10的ByteBuffer对象如下图所示:
在初始化的时候,position设置为0,limit和 capacity被设置为10,在以后使用ByteBuffer对象过程中,capacity的值不会再发生变化,而其它两个个将会随着使用而变化。三个属性值分别如图所示:
现在我们可以从通道中读取一些数据到缓冲区中,注意从通道读取数据,相当于往缓冲区中写入数据。如果读取4个自己的数据,则此时position的值为4,即下一个将要被写入的字节索引为4,而limit仍然是10,如下图所示:
下一步把读取的数据写入到输出通道中,相当于从缓冲区中读取数据,在此之前,必须调用flip()方法,该方法将会完成两件事情:
- 把limit设置为当前的position值
-
把position设置为0
由于position被设置为0,所以可以保证在下一步输出时读取到的是缓冲区中的第一个字节,而limit被设置为当前的position,可以保证读取的数据正好是之前写入到缓冲区中的数据,如下图所示:
现在调用get()方法从缓冲区中读取数据写入到输出通道,这会导致position的增加而limit保持不变,但position不会超过limit的值,所以在读取我们之前写入到缓冲区中的4个自己之后,position和limit的值都为4,如下图所示:
在从缓冲区中读取数据完毕后,limit的值仍然保持在我们调用flip()方法时的值,调用clear()方法能够把所有的状态变化设置为初始化时的值,如下图所示:
下面这个例子可以展示buffer的读写:
public class NioTest1 {
public static void main(String[] args) {
//通过nio生成随机数,然后在打印出来
IntBuffer buffer = IntBuffer.allocate(10);
System.out.println("capacity:"+buffer.capacity());
for (int i = 0;i < 5;i++){
int randomNumber = new SecureRandom().nextInt(20);
//这里相当于把数据写到buffer中
buffer.put(randomNumber);
}
System.out.println("before flip limit:"+buffer.capacity());
//上面是写,下面为读,通过flip()方法进行读写的切换
buffer.flip();
System.out.println("after flip limit:"+buffer.capacity());
System.out.println("enter while loop");
while(buffer.hasRemaining()){
System.out.println("position:" + buffer.position());
System.out.println("limit:" + buffer.limit());
System.out.println("capacity:" + buffer.capacity());
//这里相当于从buffer中读出数据
System.out.println(buffer.get());
}
}
1.3 remaining和hasRemaining
remaining()会返回缓冲区中目前存储的元素个数,在使用参数为数组的get方法中,提前知道缓冲区存储的元素个数是非常有用的。
事实上,由于缓冲区的读或者写模式并不清晰,因此实际上remaining()返回的仅仅是limit – position的值。
而hasRemaining()的含义是查询缓冲区中是否还有元素,这个方法的好处是它是线程安全的。
1.4 Flip翻转
在从缓冲区中读取数据时,get方法会从position的位置开始,依次读取数据,每次读取后position会自动加1,直至position到达limit处为止。因此,在写入数据后,开始读数据前,需要设置position和limit的值,以便get方法能够正确读入前面写入的元素。
这个设置应该是让limit=position,然后position=0,为了方便,Buffer类提供了一个方法flip(),来完成这个设置。其代码如下:
/**
* 测试flip操作,flip就是从写入转为读出前的一个设置buffer属性的操作,其意义是将limit=position,position=0
*/
private static void testFlip() {
CharBuffer buffer = CharBuffer.allocate(10);
buffer.put("abc");
buffer.flip();
char[] chars = new char[buffer.remaining()];
buffer.get(chars);
System.out.println(chars);
//以下操作与flip等同
buffer.clear();
buffer.put("abc");
buffer.limit(buffer.position());
buffer.position(0);
chars = new char[buffer.remaining()];
buffer.get(chars);
System.out.println(chars);
}
1.5compact压缩
压缩compact()方法是为了将读取了一部分的buffer,其剩下的部分整体挪动到buffer的头部(即从0开始的一段位置),便于后续的写入或者读取。其含义为limit=limit-position,position=0,测试代码如下:
private static void testCompact() {
CharBuffer buffer = CharBuffer.allocate(10);
buffer.put("abcde");
buffer.flip();
//先读取两个字符
buffer.get();
buffer.get();
showBuffer(buffer);
//压缩
buffer.compact();
//继续写入
buffer.put("fghi");
buffer.flip();
showBuffer(buffer);
//从头读取后续的字符
char[] chars = new char[buffer.remaining()];
buffer.get(chars);
System.out.println(chars);
}
1.6duplicate复制
复制缓冲区,两个缓冲区对象实际上指向了同一个内部数组,但分别管理各自的属性。
private static void testDuplicate() {
CharBuffer buffer = CharBuffer.allocate(10);
buffer.put("abcde");
CharBuffer buffer1 = buffer.duplicate();
buffer1.clear();
buffer1.put("alex");
showBuffer(buffer);
showBuffer(buffer1);
}
1.7 slice缓冲区切片
缓冲区切片,将一个大缓冲区的一部分切出来,作为一个单独的缓冲区,但是它们公用同一个内部数组。切片从原缓冲区的position位置开始,至limit为止。原缓冲区和切片各自拥有自己的属性,测试代码如下:
/**
* slice Buffer 和原有Buffer共享相同的底层数组
*/
public class NioTest6 {
public static void main(String[] args) {
ByteBuffer buffer = ByteBuffer.allocate(10);
for (int i=0;i
1.8只读Buffer
我们可以随时将一个普通Buffer调用asReadOnlyBuffer方法返回一个只读Buffer,但不能将一个只读Buffer转换成读写Buffer
public class NioTest7 {
public static void main(String[] args) {
ByteBuffer buffer = ByteBuffer.allocate(10);
System.out.println(buffer.getClass());
for (int i=0;i
- 字节缓冲区
为了便于示例,前面的例子都使用了CharBuffer缓冲区,但实际上应用最广,使用频率最高,也是最重要的缓冲区是字节缓冲区ByteBuffer。因为ByteBuffer中直接存储字节,所以在不同的操作系统、硬件平台、文件系统和JDK之间传递数据时不涉及编码、解码和乱码问题,也不涉及Big-Endian和Little-Endian大小端问题,所以它是使用最为便利的一种缓冲区。
2.1视图缓冲区
ByteBuffer中存储的是字节,有时为了方便,可以使用asCharBuffer()等方法将ByteBuffer转换为存储某基本类型的视图,例如CharBuffer、IntBuffer、DoubleBuffer、ShortBuffer、LongBuffer和FloatBuffer。
如此转换后,这两个缓冲区共享同一个内部数组,但是对数组内元素的视角不同。以CharBuffer和ByteBuffer为例,ByteBuffer将其视为一个个的字节(1个字节),而CharBuffer则将其视为一个个的字符(2个字节)。若此ByteBuffer的capacity为12,则对应的CharBuffer的capacity为12/2=6。与duplicate创建的复制缓冲区类似,该CharBuffer和ByteBuffer也各自管理自己的缓冲区属性。
还有一点需要注意的是,在创建视图缓冲区的时候ByteBuffer的position属性的取值很重要,视图会以当前position的值为开头,以limit为结尾。例子如下:
private static void testElementView() {
ByteBuffer buffer =ByteBuffer.allocate(12);
//存入四个字节,0x00000042
buffer.put((byte) 0x00).put((byte)0x00).put((byte) 0x00).put((byte) 0x42);
buffer.position(0);
//转换为IntBuffer,并取出一个int(四个字节)
IntBuffer intBuffer =buffer.asIntBuffer();
int i =intBuffer.get();
System.out.println(Integer.toHexString(i));
}
不同元素需要的字节数不同:char为2字节,short为2字节,int为4字节,float为4字节,long为8字节,double也是8字节。
2.2存取数据元素
也可以不通过视图缓冲区,直接向ByteBuffer中存入和取出不同类型的元素,其方法名为putChar()或者getChar()之类。例子如下:
private static void testPutAndGetElement() {
ByteBuffer buffer =ByteBuffer.allocate(12);
//直接存入一个int
buffer.putInt(0x1234abcd);
//以byte分别取出
buffer.position(0);
byte b1 = buffer.get();
byte b2 = buffer.get();
byte b3 = buffer.get();
byte b4 = buffer.get();
System.out.println(Integer.toHexString(b1&0xff));
System.out.println(Integer.toHexString(b2&0xff));
System.out.println(Integer.toHexString(b3&0xff));
System.out.println(Integer.toHexString(b4&0xff));
}
2.3 字节序
终于又要讲到字节序了,详细参见https://zhuanlan.zhihu.com/p/25435644。
简单说来,当某个元素(char、int、double)的长度超过了1个字节时,则由于种种历史原因,它在内存中的存储方式有两种,一种是Big-Endian,一种是Little-Endian。
Big-Endian就是高位字节排放在内存的低地址端,低位字节排放在内存的高地址端。 简单来说,就是我们人类熟悉的存放方式。
Little-Endian就是低位字节排放在内存的低地址端,高位字节排放在内存的高地址端。
Java默认是使用Big-Endian的,因此上面的代码都是以这种方式来存放元素的。但是,其他的一些硬件(CPU)、操作系统或者语言可能是以Little-Endian的方式来存储元素的。因此NIO提供了相应的API来支持缓冲区设置为不同的字节序,其方法很简单,代码如下:
privatestatic void testByteOrder() {
ByteBuffer buffer =ByteBuffer.allocate(12);
//直接存入一个int
buffer.putInt(0x1234abcd);
buffer.position(0);
intbig_endian= buffer.getInt();
System.out.println(Integer.toHexString(big_endian));
buffer.rewind();
intlittle_endian=buffer.order(ByteOrder.LITTLE_ENDIAN).getInt();
System.out.println(Integer.toHexString(little_endian));
}
输出为:
1234abcd
cdab3412
使用order方法可以随时设置buffer的字节序,其参数取值为ByteOrder.LITTLE_ENDIAN以及ByteOrder.BIG_ENDIAN。
2.4直接缓冲区 DirectByteBuffer
最后一个需要掌握的概念是直接缓冲区,它是以创建时的开销换取了IO时的高效率。另外一点是,直接缓冲区使用的内存是直接调用了操作系统api分配的,绕过了JVM堆栈。
直接缓冲区通过ByteBuffer.allocateDirect()方法创建,并可以调用isDirect()来查询一个缓冲区是否为直接缓冲区。
一般来说,直接缓冲区是最好的IO选择。
public class NioTest8 {
public static void main(String[] args) throws IOException {
FileInputStream inputStream = new FileInputStream("input2.txt");
FileOutputStream outputStream = new FileOutputStream("output2.txt");
FileChannel inputChannel = inputStream.getChannel();
FileChannel outputChannel = outputStream.getChannel();
ByteBuffer buffer = ByteBuffer.allocateDirect(1024);
while(true){
//每一次读取之前都将buffer状态初始化
buffer.clear();
int read = inputChannel.read(buffer);
System.out.println("read:" + read);
if(-1 == read){
break;
}
buffer.flip();
outputChannel.write(buffer);
}
inputChannel.close();
outputChannel.close();
}
}
2.5、MappedByteBuffer 内存映射文件
文件的内容直接映射到内存里面,在内存中任何的信息修改,最终都会被写入到磁盘文件中,即MappedByteBuffer是一种允许java程序直接从内存访问的特殊的文件,可以将整个文件或整个文件的一部分映射到内存中,由操作系统负责将页面请求的内存数据修改写入到文件中。应用程序只需要处理内存的数据,这样可以实现迅速的IO操作,用于内存映射文件的这个内存是堆外内存
public class NioTest9 {
public static void main(String[] args) throws IOException {
RandomAccessFile randomAccessFile = new RandomAccessFile("NioTest9.txt","rw");
FileChannel fileChannel = randomAccessFile.getChannel();
MappedByteBuffer mappedByteBuffer = fileChannel.map(FileChannel.MapMode.READ_WRITE, 0, 6);
mappedByteBuffer.put(0, (byte) 'a');
mappedByteBuffer.put(4, (byte) 'b');
randomAccessFile.close();
}
}
- 小结
与Stream相比,Buffer引入了更多的概念和复杂性,这一切的努力都是为了实现NIO的经典编程模式,即用一个线程来控制多路IO,从而极大的提高服务器端IO效率。Buffer、Channel和Selector共同实现了NIO的编程模式,其中Buffer也可以被独立的使用,用来完成缓冲区的功能。
参考:
Java NIO编程实例之一Buffer
Java NIO通俗编程之缓冲区内部细节状态变量position,limit,capacity(二)
Java NIO:Buffer、Channel 和 Selector