传统流式IO
传统的Java IO是流式的IO,从诸如类名InputStream
和OutputStream
中就可以看出。流式IO是单向的,分为输入和输出流。在使用输入流或者输出流读写文件时,每次读写操作是以字节为单位,我们需要指定读出或者写入的大小,中间没有任何用户空间的缓存。例如从文件中读取4字节长度的数据,Java会创建一个4字节长度的byte数组,然后通过JNI层经由系统调用read读文件,每次读入一个字节的数据,将数据写入对应的byte数组的正确位置。一共需要进行4次系统调用,因为每次我们只能读入一个字节。随着文件读入的进行,我们没有办法重新访问我们已经读入的数据,因为流是单向的,我们不能seek某个位置,除非我们自己将这些已经读入的数据进行了缓存,才能在以后需要时进行访问。当我们写数据的时候也是如此,我们每次只能写入一个字节的数据,写4个字节的数据就需要4次系统调用。系统调用需要从用户态切换到内核态,然后再切换回来,可想而知,流式的IO的读写性能开销是很大的。如下是Java中流式IO读写的实现,从代码中我们就可以印证上面的事实。
//java.io.InputStream#read(byte[], int, int)
public int read(byte b[], int off, int len) throws IOException {
if (b == null) {
throw new NullPointerException();
} else if (off < 0 || len < 0 || len > b.length - off) {
throw new IndexOutOfBoundsException();
} else if (len == 0) {
return 0;
}
int c = read();
if (c == -1) {
return -1;
}
b[off] = (byte)c;
int i = 1;
try {
for (; i < len ; i++) {
c = read(); //每次只读一个字节
if (c == -1) {
break;
}
b[off + i] = (byte)c;
}
} catch (IOException ee) {
}
return i;
}
//java.io.OutputStream#write(byte[], int, int)
public void write(byte b[], int off, int len) throws IOException {
if (b == null) {
throw new NullPointerException();
} else if ((off < 0) || (off > b.length) || (len < 0) ||
((off + len) > b.length) || ((off + len) < 0)) {
throw new IndexOutOfBoundsException();
} else if (len == 0) {
return;
}
for (int i = 0 ; i < len ; i++) {
write(b[off + i]); //每次只写一个字节
}
}
NIO
JDK 1.4之后,java引入了NIO。NIO以块为单位进行读写,而不是以单字节为单位。Channel在NIO中代表一个通道,我们可以操作通道进行读写,换句话说,通道是双向的。通过NIO读写文件,我们并不是直接操作通道,而是通过Buffer来中转。Buffer代表一块缓冲区,其实就是一个字节数组。当我们要写文件时,首先将数据写入对应的buffer中,然后通过channel将buffer中的数据写入文件。而当我们需要读入数据时,也是首先将数据读入一个Buffer中,然后从buffer中访问。因为每次操作是以块为单位的,因此我们能大大减少系统调用的次数,极大的提高IO性能。同时Buffer作为一个缓冲区也允许我们在之后的某段时间内重新访问之前的数据,Buffer内部会自己维护数据的位置信息,如position
、limit
和capacity
等。
DirectByteBuffer vs HeapByteBuffer
ByteBuffer代表一个字节数组的缓冲区。Java提供了direct和non-direct buffer。java.nio.ByteBuffer#allocate会创建一个HeapByteBuffer,即分配在jvm heap上的一个字节数组。而通过java.nio.ByteBuffer#allocateDirect方法返回一个DirectByteBuffer对象,它也是封装了一个字节数组,但是这个字节数组并不是直接分配在通用的jvm heap上的,而是另外一块单独的内存区域中(人们喜欢将之称为堆外内存),在不同的虚拟机版本可能有不同的实现。例如ART运行时,会有一个heap之外的区域,我理解为大对象区域
,这个区域主要用来分配一些大对象,如Bitmap,DirectByteeBuffer等。我们都知道大对象对jvm的GC会造成一些影响,所以单独开辟这些区域用来存储一些生命周期长的大对象是有道理,可以减少正常GC的次数,提高内存效率。
在进行NIO时,我们可以通过DirectByteBuffer提高IO性能。官方的原话是:
A byte buffer is either <i>direct</i> or <i>non-direct</i>. Given a direct byte buffer, the Java virtual machine will make a best effort to perform native I/O operations directly upon it. That is, it will attempt to avoid copying the buffer's content to (or from) an intermediate buffer before (or after) each invocation of one of the underlying operating system's native I/O operations.
到底是怎么个性能提高法呢?还是看代码更清晰。
//IOUtil.java
static int write(FileDescriptor var0, ByteBuffer var1, long var2, NativeDispatcher var4) throws IOException {
if(var1 instanceof DirectBuffer) {
return writeFromNativeBuffer(var0, var1, var2, var4); //如果是directbytebuffer,直接写
} else {
int var5 = var1.position();
int var6 = var1.limit();
assert var5 <= var6;
int var7 = var5 <= var6?var6 - var5:0;
ByteBuffer var8 = Util.getTemporaryDirectBuffer(var7); //获取一个临时的directbytebuffer
int var10;
try {
var8.put(var1); //复制数据到directbytebuffer之后再写
var8.flip();
var1.position(var5);
int var9 = writeFromNativeBuffer(var0, var8, var2, var4);
if(var9 > 0) {
var1.position(var5 + var9);
}
var10 = var9;
} finally {
Util.offerFirstTemporaryDirectBuffer(var8);
}
return var10;
}
}
static int read(FileDescriptor var0, ByteBuffer var1, long var2, NativeDispatcher var4) throws IOException {
if(var1.isReadOnly()) {
throw new IllegalArgumentException("Read-only buffer");
} else if(var1 instanceof DirectBuffer) { //是directbytebuffer 直接读入
return readIntoNativeBuffer(var0, var1, var2, var4);
} else {
ByteBuffer var5 = Util.getTemporaryDirectBuffer(var1.remaining()); //获取一个临时的directbytebuffer
int var7;
try {
int var6 = readIntoNativeBuffer(var0, var5, var2, var4); //读入数据到directbytebuffer中
var5.flip();
if(var6 > 0) {
var1.put(var5); //拷贝数据到目标buffer中
}
var7 = var6;
} finally {
Util.offerFirstTemporaryDirectBuffer(var5);
}
return var7;
}
}
在使用NIO进行读写的时候,最终会调用IOUtil中的相关read、write方法。可以看到如果是DirectByteBuffer,在IO时直接在该buffer上进行读写。如果不是,则需要获取一个临时的DirectByteBuffer(jvm从directbytebuffer cache中获取),将数据拷贝到directbytebuffer中再写入或者读入directbuffer中在拷贝到目标Buffer中。可以看到,如果是DirectByteBuffer,那么可以省去了很多拷贝的开销。那么jvm为什么需要一个中间的DirectByteBuffer缓冲区呢?我的猜想是普通的buffer是分配在heap上的,可能是内存空间不连续的字节数组,而且随着程序的运行 GC可能会移动对应的字节数组,这就给IO带来了挑战。反观DirectByteBuffer,它是连续的字节数组,不是分配在堆上的,受GC影响小,而且一般而言DirectByteBuffer分配内存都是指定non-movale的
。但是DirectByteBuffer也不是没有任何缺点,因为它不是在堆上的,所以可能造成访问速度慢,并且DirectByteBuffer的分配和释放开销比HeapByteBuffer要大。