Java NIO 内存映射文件
Java NIO 内存映射文件
@author ixenos
文件操作的四大方法
前提:内存的访问速度比磁盘高几个数量级,但是基本的IO操作是直接调用native方法获得驱动和磁盘交互的,IO速度限制在磁盘速度上
由此,就有了缓存的思想,将磁盘内容预先缓存在内存上,这样当供大于求的时候IO速度基本就是以内存的访问速度为主,例如BufferedInput/OutputStream等
而我们知道大多数OS都可以利用虚拟内存实现将一个文件或者文件的一部分映射到内存中,然后,这个文件就可以当作是内存数组一样地访问,我们可以把它看成一种“永久的缓存”
内存映射文件:内存映射文件允许我们创建和修改那些因为太大而不能放入内存的文件,此时就可以假定整个文件都放在内存中,而且可以完全把它当成非常大的数组来访问(随机访问)
以下是四大文件操作对比:
本图使用思维导图软件XMind制作
在Core Java II中进行了这么一个实验:在同一台机器上,对JDK的jre/lib目录中的37MB的rt.jar文件分别用以上四种操作来计算CRC32校验和,记录下了如下时间
方法 | 时间 |
普通输入流 | 110s |
带缓冲的输入流 | 9.9s |
随机访问文件 | 162s |
内存映射文件 | 7.2s |
这个小实验也验证了内存映射文件这个方法的可行性,由于具有随机访问的功能(映射在内存数组),所以常用来替代RandomAccessFile。
当然,对于中等尺寸文件的顺序读入则没有必要使用内存映射以避免占用本就有限的I/O资源,这时应当使用带缓冲的输入流。
内存映射文件
java.nio包使得内存映射变得十分简单
1、首先,从文件中获得一个通道(channel)。通道是用于磁盘文件的一种抽象,它使我们可以访问诸如内存映射、文件加锁机制(下文缓冲区数据结构部分将提到)、文件间快速数据传递等操作系统特性。
1 FileChannel channel = FileChannel.open(path, options);
还能通过在一个打开的 File 对象(RandomAccessFile、FileInputStream 或 FileOutputStream)上调用 getChannel() 方法获取。调用 getChannel() 方法会返回一个连接到相同文件的 FileChannel 对象且该 FileChannel 对象具有与 File 对象相同的访问权限
2、然后,通过调用FileChannel类的map方法进行内存映射,map方法从这个通道中获得一个MappedByteBuffer对象(ByteBuffer的子类)。
你可以指定想要映射的文件区域与映射模式,支持的模式有3种:
- FileChannel.MapMode.READ_ONLY:产生只读缓冲区,对缓冲区的写入操作将导致ReadOnlyBufferException;
- FileChannel.MapMode.READ_WRITE:产生可写缓冲区,任何修改将在某个时刻写回到文件中,而这某个时刻是依赖OS的,其他映射同一个文件的程序可能不能立即看到这些修改,多个程序同时进行文件映射的确切行为是依赖于系统的,但是它是线程安全的
- FileChannel.MapMode.PRIVATE:产生可写缓冲区,但任何修改是缓冲区私有的,不会回到文件中。。。
1 import java.io.*; 2 import java.nio.*; 3 import java.nio.channels.*; 4 import java.nio.file.*; 5 import java.util.zip.*; 6 7 public class MemoryMapTest 8 { 9 10 public static long checksumMappedFile(Path filename) throws IOException 11 { 12 //直接通过传入的Path打开文件通道 13 try (FileChannel channel = FileChannel.open(filename)) 14 { 15 CRC32 crc = new CRC32(); 16 int length = (int) channel.size(); 17 //通过通道的map方法映射内存 18 MappedByteBuffer buffer = channel.map(FileChannel.MapMode.READ_ONLY, 0, length); 19 20 for (int p = 0; p < length; p++) 21 { 22 int c = buffer.get(p); 23 crc.update(c); 24 } 25 return crc.getValue(); 26 } 27 } 28 29 public static void main(String[] args) throws IOException 30 { 31 System.out.println("Mapped File:"); 32 start = System.currentTimeMillis(); 33 crcValue = checksumMappedFile(filename); 34 end = System.currentTimeMillis(); 35 System.out.println(Long.toHexString(crcValue)); 36 System.out.println((end - start) + " milliseconds"); 37 } 38 }
3、一旦有了缓冲区,就可以使用ByteBuffer类和Buffer超类的方法来读写数据
缓冲区支持顺序和随机数据访问:
顺序:有一个可以通过get和put操作来移动的位置
1 while(buffer.hasRemaining()){ 2 byte b = buffer.get(); //get当前位置 3 ... 4 }
随机:可以按内存数组索引访问
1 for(int i=0; i<buffer.limit(); i++){ 2 byte b = buffer.get(i); //这个get能指定索引 3 ... 4 }
可以用下面的方法来读写数据到一个字节数组(destination array):
get(byte[] bytes) /get(byte[] bytes, int offset, int length)
The method transfers bytes from this buffer into the given destination array.
还有下列getXxx方法:getInt, getLong, getShort, getChar, getFloat, getDouble 用来读入在文件中存储为二进制值的基本类型值
关于二进制数据排序机制不同的读取问题:
我们知道,Java对二进制数据使用高位在前的排序机制(比如 0XA就是 0000 1010,高位在前低位在后),
但是,如果需要低位在前的排序方式(0101 0000)处理二进制数字的文件,需调用:
buffer.order(ByteOrder.LITTLE_ENDIAN);
要查询缓冲区内当前的字节顺序,可以调用:
ByteOrder b = buffer.order();
要向缓冲区写数字,使用对应的putXxx方法,在恰当的时机,以及当通道关闭时,会将这些修改写回到文件中的哦。
缓冲区数据结构
在使用内存映射时,我们既可以创建单一的缓冲区横跨整个文件或者感兴趣的文件区域,也可以使用更多的缓冲区来读写大小适度的信息块。
这一小节,就来讲讲缓冲区Buffer对象上的基本操作。
缓冲区是具有相同基本类型的数值构成的数组(数组在内存中创建),Buffer类是一个抽象类,有以下具体的子类:ByteBuffer,CharBuffer,DoubleBuffer,IntBuffer,LongBuffer和ShortBuffer。(注意StringBuffer跟这些人没关系,而且String本质是引用类型)
实践中,最常用的是ByteBuffer和CharBuffer。
每个缓冲区都具有:
1、一个恒定的容量;
2、一个读写位置,下一个值将在此进行读写;
3、一个界限,超过他无法读写;
4、一个可选的标记,用于重复一个读入或写出操作;
0≤标记≤位置≤界限≤容量
1、写:一开始时位置为0,界限等于容量,当我们不断调用put添值到缓冲区中,直至耗尽所有数据或者写出的数据集量达到容量大小时,就该进行读入操作了;
2、读:这时调用 flip 方法将界限设置到当前位置(相当于trim),并把位置复位到0(为了读操作),现在在remaining方法返回(界限 — 位置)正数时,不断调用get;
3、复位:将缓冲区中所有值读入后,调用clear(位置复位到0,界限复位到容量)使缓冲区为下一次写循环做准备;
4、复读:想复读缓冲区,可调用rewind或mark/reset方法;
缓冲区的获得:
A、内存映射时使用的是MappedByteBuffer,这是ByteBuffer的子类,由FileChannel的map()方法调用
B、饿汉要获取缓冲区,可调用ByteBuffer.allocate或ByteBuffer.wrap这样的静态方法,然后用来自某个通道的数据填充缓冲区,或者将缓冲区的内容写出通道中:
1 ByteBuffer buffer = ByteBuffer.allocate(RECORD_SIZE); 2 3 //填充缓冲区 4 channel1.read(buffer); 5 //将Channel位置指定到newpos,作为覆盖文件内容的起点 6 channel1.position(newpos); 7 //将Buffer界限设置到当前位置,准备写出,注意区别Buffer和Channel的position,两者是不同的概念 8 buffer.flip(); 9 //将缓冲区数据写出通道中 10 channel.write(buffer);
这些方法和RandomAccessFile类的方法类似,但性能更高,因此常用以代替随机访问文件。
文件加锁机制
线程安全:我们知道多线程并发修改共享数据会产生安全问题——竞争条件,为了保证对数据的原子性操作——同步存取,我们有了synchronized关键字添加隐式锁以及ReentranLock添加显式锁。但是多进程的同步存取又该怎么实现呢?
进程安全:OS有个文件加锁机制,由于通道是对磁盘的一种抽象,FileChannel因此也实现了文件锁,可以调用其lock或tryLock方法进行锁定。
文件锁示例:锁定一个文件
1 FileChannel channel = FileChannel.open(path); 2 3 //调用lock,阻塞 4 FileLock lock = channel.lock(); 5 6 //调用tryLock,立即响应 7 FileLock lock = channel.tryLock();
1、第一个调用 lock() 会阻塞直至可获得锁,而第二个调用 tryLock() 将立即返回,要么获得锁,要么在锁不可获得的情况家返回null;
2、这个文件将保持锁定状态,直至这个通道关闭,或者在锁上调用了release方法;
3、还可以锁定文件的一部分:FileLock lock(long start, long size, boolean shared) 或 FileLock tryLock(long start, long size, boolean shared)
a)如果shared标志位false,则锁定文件的目的是读写,而如果为true,则这是一个共享锁,允许多个进程从文件中读入,并阻止任何进程获得独占的锁。调用FIleLock的isShared可查询当前持有的文件锁类型。
b)如果锁了文件的尾部,但文件长度随后增长超过了锁定部分,那么超过的任然是不锁定的,此时需要使用 Long.MAX_VALUE 来表示尺寸。
4、要确保在操作完成时释放锁,可用 try-with-resources 语句(FileLock实现了AutoCloseable接口)
1 try(FileLock lock = channel.lock()){ 2 ... 3 }
手动释放锁可调用FileLock对象的close()方法
注意点:
1、文件加锁机制是依赖于操作系统的
2、意外的建议锁:在某些系统中文件锁仅仅是建议性的,可能出现一个应用未能得到锁,它仍旧可以向被另一个进程并发锁定的文件执行写操作;
3、意外的原子性:在某些系统中,不能在锁定一个文件的同时将其映射到内存中,原子性;
4、意外的全释放:在某些系统中,关闭一个通道会释放由JVM持有的底层文件上的所有锁,因此避免在同一个锁定文件上使用多个通道,不然其他通道的锁也可能被释放!
5、不可重入锁:文件锁是由整个JVM持有的,两个由同一VM启动的程序不可能获得在同一个文件上的锁,如果尝试对VM上已加锁的文件再加锁,将抛出OverlappingFileLockException;
(注意:多线程的ReentranLock是可重入的!简称可重入锁,而文件锁是不可重入锁)
6、在网络文件系统上锁定文件是高度依赖于系统的,尽量避免使用文件锁。