Loading

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实现类的三个特性的实现:

  1. 实现了该接口的Channel是异步的:如果一个线程阻塞在一个InterruptibleChannel的IO操作上,这时其他线程调用了该Channel的close方法,这个阻塞线程将收到AsynchronousCloseException。
  2. 实现了该接口的Channel是可中断的:如果一个线程阻塞在一个InterruptibleChannel的IO操作上,这时其他线程调用了该阻塞线程的interrupt方法,这将导致Channel关闭,阻塞线程将收到ClosedByInterruptException,并且阻塞线程的interrupt状态将被设置。
  3. 如果一个线程的interrupt状态已经设置,然后它调用了一个阻塞IO操作,那么这个通道将关闭,并且线程接收到一个ClosedByInterruptException,线程的interrupt状态保持。

实现这三个特性需要挺复杂的操作,AbstractInterruptibleChannel提供了一个模板供简化这个操作。当子Channel需要做一个阻塞的IO操作时,遵循以下模板就会获得这三个特性:

boolean completed = false;
try {
    begin();
    completed = ...;    // 执行阻塞IO操作
    return ...;         // 返回结果
} finally {
    end(completed);
}

关键在于beginend方法,它们是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();
}

至此,如果遵照模板调用beginend的话,InterruptibleChannel的三个特性已经全部满足。

blockedOn

这个设计很丑,但是还是说下原理,下面是blockedOn的源码:

static void blockedOn(Interruptible intr) {         // package-private
    SharedSecrets.getJavaLangAccess().blockedOn(intr);
}

它调用了SharedSecrets中的blockedOn同名方法,这个类是因为某些类想访问JavaSDK中没有开放访问权限的某些方法还不想使用反射时用的。

这个javaLangAccessSystem类初始化时被设置:

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

从它实现的接口上来看,该类提供如下功能:

  1. 基本读写(基于ByteBuffer),来自于SeekableByteChannel
  2. 获取和改变Channel当前读写位置,来自于SeekableByteChannel
  3. 获取文件大小以及截断文件,来自于SeekableByteChannel
  4. 将文件中的内容读取到多个ByteBuffer,来自于GatheringByteChannel
  5. 将多个ByteBuffer中的内容写入到文件,来自于ScatteringByteChannel

除了基础功能外,该类还提供了一些文件特有的附加功能:

  1. 可以以绝对位置读写文件并且不会影响通道的当前position
  2. 文件的一块区域可以被直接映射到内存,对于大文件来说这比通常的readwrite调用更加高效
  3. 对文件所做的更新可以被强制发送到底层存储设备(通过force方法?),以确保系统崩溃时不会发生数据丢失
  4. 文件中的字节可以被传送到一些其他的通道中(通过transferTo方法),反之亦然。这种方式可以被许多操作系统优化为直接与文件系统缓存之间非常快的传输
  5. 可以锁定文件的某些区域,以防止其他程序访问

可以使用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级别的,用于进程间同步的,不适用于同一个进程间的线程对文件的同步。

锁定时不要求文件中真的有positionsize指定的位置

该方法的调用将阻塞,直到这个区域可以被锁定或通道关闭或调用线程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是映射模式,分为:

  1. 只读:只能对返回的Buffer进行读操作
  2. 读取/写入:对缓冲区的写入最终将传播到文件(不保证其他程序能看到)
  3. 专用:缓冲区可以读写,但是对缓冲区的修改完全不会影响到文件,文件的修改也完全不会影响到缓冲区,相当于缓冲区是一个专用的文件副本。

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();
}

参考

posted @ 2022-03-19 20:39  yudoge  阅读(132)  评论(0编辑  收藏  举报