NIO——FileChannel
前置知识:NIO——Channel接口关系
AbstractInterruptibleChannel
从上一篇笔记上也可以看出,Java的NIO规定了一堆Channel的接口,它们有很多自己的约定,如果不创建一堆抽象类供后面的Channel实现类使用,那么所有Channel都要自己实现那些约定,所以,Java提供了一系列实现了某些基本行为的抽象Channel。
AbstractInterruptibleChannel
类是FileChannel
的父类,它提供了一个异步可中断Channel的基本行为和一些用于实现非阻塞式IO的基本方法,所以我们有必要先来学它。
public abstract class AbstractInterruptibleChannel
implements Channel, InterruptibleChannel
close方法
首先,Channel接口要求close
的调用是同步的,所以AbstractInterruptibleChannel
使用了一个closeLock
来锁定该方法,volatile变量closed
表示该Channel是否已经关闭。
private final Object closeLock = new Object();
private volatile boolean closed;
public final void close() throws IOException {
synchronized (closeLock) {
if (closed)
return;
closed = true;
implCloseChannel();
}
}
protected abstract void implCloseChannel() throws IOException;
由于AbstractInterruptibleChannel
只是一个底层的抽象,并不代表一个具体的IO资源通道,所以具体关闭时应该做什么是子类需要考虑的,所以它使用模板设计模式,通过方法implCloseChannel
把该操作留给子类,子类可以在这里编写具体类型通道的关闭逻辑。
可中断特性的实现
接下来就是InterruptibleChannel
实现类的三个特性的实现:
- 实现了该接口的Channel是异步的:如果一个线程阻塞在一个InterruptibleChannel的IO操作上,这时其他线程调用了该Channel的close方法,这个阻塞线程将收到AsynchronousCloseException。
- 实现了该接口的Channel是可中断的:如果一个线程阻塞在一个InterruptibleChannel的IO操作上,这时其他线程调用了该阻塞线程的interrupt方法,这将导致Channel关闭,阻塞线程将收到ClosedByInterruptException,并且阻塞线程的interrupt状态将被设置。
- 如果一个线程的interrupt状态已经设置,然后它调用了一个阻塞IO操作,那么这个通道将关闭,并且线程接收到一个ClosedByInterruptException,线程的interrupt状态保持。
实现这三个特性需要挺复杂的操作,AbstractInterruptibleChannel
提供了一个模板供简化这个操作。当子Channel需要做一个阻塞的IO操作时,遵循以下模板就会获得这三个特性:
boolean completed = false;
try {
begin();
completed = ...; // 执行阻塞IO操作
return ...; // 返回结果
} finally {
end(completed);
}
关键在于begin
和end
方法,它们是AbstractInterruptibleChannel
提供的。
begin
首先要知道AbstractInterruptibleChannel
的约定可以说明,调用begin
的都是那些将要发生阻塞IO操作的线程。
private Interruptible interruptor;
private volatile Thread interrupted;
protected final void begin() {
// 初始化IO阻塞线程在`interrupt`方法被调用时的回调
if (interruptor == null) {
interruptor = new Interruptible() {
public void interrupt(Thread target) {
// 关闭当前Channel并设置interrupted为当前线程
synchronized (closeLock) {
if (closed)
return;
closed = true;
interrupted = target;
try {
AbstractInterruptibleChannel.this.implCloseChannel();
} catch (IOException x) { }
}
}};
}
// 先知道,这个方法就是把这个回调设置到调用者线程上
// 这样一来,调用者一旦被interrupt,Channel就会关闭
// 它设置进去的原理后面会说
blockedOn(interruptor);
// 这里主要满足InterruptibleChannel的第三个特性
// 如果调用进来的Thread已经被`interrupte`,直接
// 调用上面的回调,以关闭当前Channel
Thread me = Thread.currentThread();
if (me.isInterrupted())
interruptor.interrupt(me);
}
end
protected final void end(boolean completed)
throws AsynchronousCloseException
{
// 一旦end被调用就可以取消线程上的那个interrupte时回调了,因为这时阻塞IO已经完成(或者被取消)
blockedOn(null);
Thread interrupted = this.interrupted;
// 检查,如果当前线程就是阻塞IO的调用者线程,抛出ClosedByInterruptException
if (interrupted != null && interrupted == Thread.currentThread()) {
this.interrupted = null;
throw new ClosedByInterruptException();
}
// 这里主要是应对那些因为某个调用阻塞IO的线程interrupt而导致Channel关闭后
// 没有执行完的那些其他线程,抛出AsynchronousCloseException
if (!completed && closed)
throw new AsynchronousCloseException();
}
至此,如果遵照模板调用begin
和end
的话,InterruptibleChannel
的三个特性已经全部满足。
blockedOn
这个设计很丑,但是还是说下原理,下面是blockedOn
的源码:
static void blockedOn(Interruptible intr) { // package-private
SharedSecrets.getJavaLangAccess().blockedOn(intr);
}
它调用了SharedSecrets
中的blockedOn
同名方法,这个类是因为某些类想访问JavaSDK中没有开放访问权限的某些方法还不想使用反射时用的。
这个javaLangAccess
在System
类初始化时被设置:
其blockedOn
实现如下,就是调用Thread
类的一个私有静态同名方法:
public void blockedOn(Interruptible b) {
Thread.blockedOn(b);
}
该方法就只是设置了那个类的一个blocker
属性,为我们那个Interruptible
类型的回调,剩下啥也没做,而且从注释上来看,这个方法就是为了NIO设计的。
/* Set the blocker field; invoked via jdk.internal.access.SharedSecrets
* from java.nio code
*/
static void blockedOn(Interruptible b) {
Thread me = Thread.currentThread();
synchronized (me.blockerLock) {
me.blocker = b;
}
}
然后Java直接在线程的interrupt
方法中检测这个回调是否被设置过,如果设置过就先调用原始的interrupt0
中断逻辑,然后再调用回调。。
public void interrupt() {
if (this != Thread.currentThread()) {
checkAccess();
// thread may be blocked in an I/O operation
synchronized (blockerLock) {
Interruptible b = blocker;
if (b != null) {
interrupted = true;
interrupt0(); // inform VM of interrupt
b.interrupt(this);
return;
}
}
}
interrupted = true;
// inform VM of interrupt
interrupt0();
}
这个设计真的。。。。啊啊啊丑死了。
FileChannel
public abstract class FileChannel
extends AbstractInterruptibleChannel
implements SeekableByteChannel, GatheringByteChannel, ScatteringByteChannel
从它实现的接口上来看,该类提供如下功能:
- 基本读写(基于ByteBuffer),来自于
SeekableByteChannel
- 获取和改变Channel当前读写位置,来自于
SeekableByteChannel
- 获取文件大小以及截断文件,来自于
SeekableByteChannel
- 将文件中的内容读取到多个ByteBuffer,来自于
GatheringByteChannel
- 将多个ByteBuffer中的内容写入到文件,来自于
ScatteringByteChannel
除了基础功能外,该类还提供了一些文件特有的附加功能:
- 可以以绝对位置读写文件并且不会影响通道的当前position
- 文件的一块区域可以被直接映射到内存,对于大文件来说这比通常的
read
和write
调用更加高效 - 对文件所做的更新可以被强制发送到底层存储设备(通过force方法?),以确保系统崩溃时不会发生数据丢失
- 文件中的字节可以被传送到一些其他的通道中(通过transferTo方法),反之亦然。这种方式可以被许多操作系统优化为直接与文件系统缓存之间非常快的传输
- 可以锁定文件的某些区域,以防止其他程序访问
可以使用FileChannel.open
来打开一个文件通道,也可以通过FileInputStream.getChannel
获得一个只读的文件通道,通过FileOutputStream.getChannel
获得一个只写的文件通道,可以通过RandomAccessFile.getChannel
,根据其指定模式,获得一个只读或读写的通道。
当打开的文件处于追加模式时,每次进行写入操作会先将position
移动到文件末尾,然后再操作。
write
write
操作会将ByteBuffer
的remaining写入文件,并且从当前的相对位置写入。
@Test
void testWriteRelative() throws URISyntaxException, IOException {
FileChannel channel = FileChannel.open(Path.of(FILE_URI), StandardOpenOption.WRITE);
assertEquals(0, channel.position());
channel.write(ByteBuffer.wrap("Hello".getBytes(StandardCharsets.ISO_8859_1)));
assertEquals(5, channel.position());
channel.position(1); // 改变文件位置
// 再次写入从1开始写
channel.write(ByteBuffer.wrap("ave".getBytes(StandardCharsets.ISO_8859_1)));
assertEquals(4, channel.position());
channel.close(); // 文件内容:haveo
}
根据WritableChannel
规定,写入是同步的:
@Test
void testSyncWrite() throws IOException, InterruptedException {
// 两个线程并发写入时,它们之间是同步的,所以看不到中英文交叉的现象
// 这是WritableByteChannel的规定
FileChannel channel = FileChannel.open(Path.of(FILE_URI), StandardOpenOption.APPEND);
new Thread(()->{
for (int i=0; i<10; i++) {
try {
channel.write(ByteBuffer.wrap("abcdefg\r\n".getBytes(StandardCharsets.UTF_8)));
} catch (IOException e) {
e.printStackTrace();
}
}
}).start();
new Thread(()->{
for (int i=0; i<10; i++) {
try {
channel.write(ByteBuffer.wrap("你好\r\n".getBytes(StandardCharsets.UTF_8)));
} catch (IOException e) {
e.printStackTrace();
}
}
}).start();
Thread.sleep(3000);
channel.close();
}
read
读取和写入基本没啥区别,忽略。
GatheringWrite和ScatteringRead
@Test
void testGatheringWrite() throws IOException {
FileChannel channel = FileChannel.open(Path.of(FILE_URI), StandardOpenOption.APPEND);
channel.write(
new ByteBuffer[] {
ByteBuffer.wrap("buf1".getBytes("ISO-8859-1")),
ByteBuffer.wrap(", ".getBytes("ISO-8859-1")),
ByteBuffer.wrap("buf2".getBytes("ISO-8859-1")),
ByteBuffer.wrap("\r\n".getBytes("ISO-8859-1")),
}
);
channel.close(); // buf1, buf2
}
// 文件内容: abcdefghijklmnopqr
@Test
void testScatteringRead() throws IOException {
ByteBuffer bufs[] = new ByteBuffer[] {
ByteBuffer.allocate(3),
ByteBuffer.allocate(3),
ByteBuffer.allocate(3),
ByteBuffer.allocate(3),
ByteBuffer.allocate(3),
ByteBuffer.allocate(3),
};
FileChannel channel = FileChannel.open(Path.of(FILE_URI), StandardOpenOption.READ);
channel.read(bufs);
channel.close();
for (ByteBuffer buf : bufs) {
buf.flip();
byte bytes[] = new byte[buf.limit()];
buf.get(bytes);
System.out.println(new String(bytes));
}
}
输出:
abc
def
ghi
jkl
mno
pqr
绝对读取与写入
read(ByteBuffer, int position)
和write(ByteBuffer, int position)
,从指定位置开始读写,不改变通道当前位置。
设置位置与获得大小
下面代码说明了,如果你使用position
将位置设置到大于当前文件channel的size的位置后,文件的大小并不会改变,当你使用读操作时,什么也不会发生,什么也不会读到,当你使用写操作时,channel的size才被扩大,设置position之前和之后中间的字符未指定。
@Test
void testSetPositionAndGetSize() throws IOException {
FileChannel channel = FileChannel.open(Path.of(FILE_URI), StandardOpenOption.READ, StandardOpenOption.WRITE);
assertEquals(18, channel.size());
channel.position(22);
ByteBuffer inBuf = ByteBuffer.allocate(1);
channel.read(inBuf);
assertEquals(0, inBuf.position()); // inBuf的position不会改变,因为已到达文件尾部
assertEquals(18, channel.size()); // channel大小不会改变,因为现在尚未写入
channel.write(ByteBuffer.wrap("123".getBytes(StandardCharsets.UTF_8)));
assertEquals(22 + 3, channel.size()); // channel大小改变,因为已经在22后面写入字符
channel.position(0);
ByteBuffer outBuf = ByteBuffer.allocate(22 + 3);
channel.read(outBuf);
outBuf.flip();
byte bytes[] = new byte[outBuf.limit()];
outBuf.get(bytes);
System.out.println(new String(bytes));
channel.close();
}
输出
abcdefghijklmnopqr 123
截断
截断操作,当传入的大小小于当前文件channel的大小,就将剩余部分舍弃,否则,忽略此次阶段。
@Test
void testTruncate() throws IOException {
FileChannel channel = FileChannel.open(Path.of(FILE_URI), StandardOpenOption.READ, StandardOpenOption.WRITE);
assertEquals(22 + 3, channel.size());
channel.truncate(18);
assertEquals(18, channel.size());
channel.close();
}
transferTo/transferFrom
@Test
void testTransferTo() throws IOException, URISyntaxException {
FileChannel fromChannel = FileChannel.open(Path.of(FILE_URI), StandardOpenOption.READ);
FileChannel toChannel = FileChannel.open(Path.of("D:\\tmp\\cp.txt"), StandardOpenOption.CREATE, StandardOpenOption.WRITE);
fromChannel.transferTo(0, fromChannel.size(), toChannel);
fromChannel.close();toChannel.close();
}
transferFrom差不多,略
锁定
public abstract FileLock lock(long position, long size, boolean shared)
throws IOException;
获取该通道文件上指定区域的锁,这个锁是整个JVM级别的,用于进程间同步的,不适用于同一个进程间的线程对文件的同步。
锁定时不要求文件中真的有position
到size
指定的位置。
该方法的调用将阻塞,直到这个区域可以被锁定或通道关闭或调用线程interrupted(先到者为准)。
互斥测试
分别运行将下面两个测试(保证它们是两个独立的进程),先运行的那个会得到文件的那个区域的锁,后来的那个等待先来的释放。
@Test
void testLockBlock() throws IOException, InterruptedException {
FileChannel channel = FileChannel.open(Path.of(FILE_URI), StandardOpenOption.READ, StandardOpenOption.WRITE);
FileLock lock = channel.lock(10, 20, false);
System.out.println("test1 THREAD LOCKED!");
Thread.sleep(10000);
lock.release();
channel.close();
}
@Test
void testLockBlock2() throws IOException, InterruptedException {
FileChannel channel = FileChannel.open(Path.of(FILE_URI), StandardOpenOption.READ, StandardOpenOption.WRITE);
FileLock lock = channel.lock(10, 20, false);
System.out.println("test2 THREAD LOCKED!");
Thread.sleep(10000);
lock.release();
channel.close();
}
共享和互斥
lock
的第三个参数是该锁是否是共享的,如果是共享锁,它就能和其它的共享锁共存,这一般运用在读场景下,我们允许多个进程并发读。而非共享锁无法和其它的任何锁共存,这一般运用在写场景下,因为写操作的同时,无论是进行读写都可能会产生数据不一致的问题。
FileLock.isShared
方法可以获取锁是否是共享的,在一些不支持共享锁定的操作系统上,共享锁会被自动转换成非共享的。
把上面的代码的lock
第三个参数都改成true
,会发现后来的进程不需要等待之前的进程释放锁了,因为共享锁和共享锁兼容。
异常
AsynchronousCloseException
在一个线程阻塞在一个Channel的lock
上时,如果有其他线程关闭了该Channel就会出现这个异常。下面是一个例子,运行该例子之前,必须运行上一个例子里的testLockBlock
确保下面的例子的lock
会阻塞。
// YOU MUST RUN testLockBlock FIRST!!!!
@Test
void testAsynchronousCloseException() throws IOException, InterruptedException {
FileChannel channel = FileChannel.open(Path.of(FILE_URI), StandardOpenOption.READ, StandardOpenOption.WRITE);
// 3秒后关闭Channel
new Thread(() -> {
try {
Thread.sleep(3000);
channel.close();
} catch (IOException | InterruptedException e) {
e.printStackTrace();
}
}).start();
Assertions.assertThrows(AsynchronousCloseException.class, () -> {
FileLock lock = channel.lock(10, 20, false);
System.out.println("test2 THREAD LOCKED!");
lock.release();
});
}
上面的测试,按原理来说,还是极小极小极小几乎不可能的可能性会出现测试失败,这是因为并发编程几乎无法避免的不确定性。
FileLockInterruptionException
如果阻塞在上面的线程被interrupt,发生这个异常,不测试了。
共享锁写
如果你在共享锁上写数据,会引发IOException。
@Test
void testWriteOnSharedLock() throws IOException {
FileChannel channel = FileChannel.open(Path.of(FILE_URI), StandardOpenOption.READ, StandardOpenOption.WRITE);
FileLock lock = channel.lock(1, 2, true);
Assertions.assertThrows(IOException.class, () -> {
channel.write(ByteBuffer.wrap("123456".getBytes(StandardCharsets.UTF_8)));
});
lock.release();
channel.close();
}
另外,当你给该文件添加共享锁时,其它进程也是不能写的,这很自然。共享锁只允许其它进程和上锁进程读。
独占锁读写
独占锁的上锁区域上锁者进程可以读写,其它进程不可以读写。
运行下面的用例之前,必须运行之前的testLockBlock
,保证那个文件被加了非共享锁。
// YOU MUST RUN testLockBlock first!!!
@Test
void testNonSharedLockWriteByOtherProcessCauseException() throws IOException {
FileChannel channel = FileChannel.open(Path.of(FILE_URI), StandardOpenOption.READ, StandardOpenOption.WRITE);
Assertions.assertThrows(IOException.class, () -> {
channel.write(ByteBuffer.wrap("ASDFAdfasdfasdfasdasdfasdff".getBytes(StandardCharsets.UTF_8)));
});
channel.close();
}
锁定整个文件
channel.lock()
非阻塞锁
channel.tryLock(position, size, shared)
、channel.tryLock()
锁重叠
单个Java虚拟机(进程)在一个文件上的锁是不重叠的,可以使用overlaps(position, size)
方法判断是否重叠,重叠的锁会抛出OverlappingFileLockException
同步
FileChannel.force
方法和流式BIO的flush
比较像。
有些操作系统为了获取更好的IO性能,会将我们对文件的写入数据暂时存放在内存缓冲中,稍后再批量的合并到磁盘,force
方法的作用是强制刷回磁盘,以防止尚未写入的数据掉电丢失。
但是断电是一个不可预计的外部事件,所以force
也只能预防,没法完全解决数据丢失问题。另外force
方法的性能低下,频繁调用会极大的减少系统的性能,所以需要在一致性和性能之前找一个平衡点。
map内存映射
map(mode, position, size)
方法将文件的一部分区域直接映射到内存中的缓冲区中,返回MappedByteBuffer
,它是直接缓冲区。许多内存映射文件的细节取决于操作系统,所以该对象的很多行为未指定,如果追求跨平台,请不要过多依赖它们。比如该通道的文件中并没有所请求的区域时;是否将另一个程序对底层文件的修改传播到缓冲区;对缓冲区的更改传播到文件的频率。
方法参数中的mode
是映射模式,分为:
- 只读:只能对返回的Buffer进行读操作
- 读取/写入:对缓冲区的写入最终将传播到文件(不保证其他程序能看到)
- 专用:缓冲区可以读写,但是对缓冲区的修改完全不会影响到文件,文件的修改也完全不会影响到缓冲区,相当于缓冲区是一个专用的文件副本。
MappedByteBuffer
只读映射
@Test
void testReadOnlyMap() throws IOException, InterruptedException {
FileChannel channel = FileChannel.open(Path.of(FILE_URI), StandardOpenOption.READ);
MappedByteBuffer fileBuf = channel.map(FileChannel.MapMode.READ_ONLY, 0, 5);
byte bytes[] = new byte[fileBuf.limit()];
fileBuf.get(bytes);
System.out.println(new String(bytes));
Thread.sleep(1000);
channel.close();
}