就在前两天,用nio做了一个文件的crud,但是在window下删除文件的时候报了一个奇怪的异常,即AccessDeniedException,搭眼一看这不就是没有授予文件的删除权限么,于是我手动删除 这个文件,提示文件被java进程占用,不能删除,于是大概就知道为什么了,第一个想到的是读取文件是不是没有关掉流,于是查阅了代码,发现并不是这个问题导致的,因为我是通过try/resource方式自动关闭了流,因此可以排除这个问题,代码见下:

try (FileChannel fileChannel = FileChannel.open(Paths.get("D:/aaa.txt"),
                StandardOpenOption.READ)){
            //xxx
        }catch (Exception e){ 
}

其次在网上搜阅了大量资料,发现这特喵是jdk的一个bug,从2002年就被开发者提到了官网,到jdk8还没有close掉这个问题,可见这个问题很伤……因此在开发者的回答列表里发现了一个解决方案,即通过反射调用其 sun公司提供的 FileChannelImpl,unmap方法,为什么是FileChannelImpl这个class呢?

看源码,因为我们在打开通道的时候会调用FileChannel.map()方法来进行内存数据传输,因为FileChannel是个抽象类,map()是个抽象方法,其实际调用map的class就是FileChannelImpl,用来从 开启一个文件大小的堆外内存,这个buffer可设置为只读,只写等策略, 在调用map完之后,会对应调用一个unmap的方法来释放jvm引用内存的指针,因此手动调用unmap方法则可以完美解决问题。bug地址在文末 file

unmap在FileChannelImpl的实现是酱紫的,传入一个buffer,然后手动调用cleaner方法来进行释放内存指针。注意这里面调用的是DirectBuffer类的cleaner,和Cleaner的clean方法,这些类都是sun公司提供的,位置是在jdk包下的rt.jar

 private static void unmap(MappedByteBuffer var0) {
        Cleaner var1 = ((DirectBuffer)var0).cleaner();
        if (var1 != null) {
            var1.clean();
        }
}

因为内部都是引用了sun下的包,在代码checkstyle的时候会报错,于是为了追求好的写法,我翻阅了大量的资料来进行代码优化,于是想起来rocketmq内部的mappedfile(commitlog/index/consumeLog)也是通过nio来分配堆外来进行操作文件,或许他们的项目里会有更优解,于是找到了MappedFile类,我copy下来解读

private void init(final String fileName, final int fileSize) throws IOException {
        this.fileName = fileName;
        this.fileSize = fileSize;
        this.file = new File(fileName);
        this.fileFromOffset = Long.parseLong(this.file.getName());
        boolean ok = false; 
		...
        try {
		//创建一个文件通道 读取文件到堆外内存,和我们这里创建通道操作一样的
            this.fileChannel = new RandomAccessFile(this.file, "rw").getChannel();
            this.mappedByteBuffer = this.fileChannel.map(MapMode.READ_WRITE, 0, fileSize);
          ...
        } catch (FileNotFoundException e) {
            log.error("Failed to create file " + this.fileName, e);
            throw e;
        } catch (IOException e) {
            log.error("Failed to map file " + this.fileName, e);
            throw e;
        }
        }
}

在翻阅到它的刷盘操作:

public int flush(final int flushLeastPages) {
        //可刷盘?
        if (this.isAbleToFlush(flushLeastPages)) {
            if (this.hold()) {
                //获取指针的值
                int value = getReadPosition(); 
                try {
                    //刷盘 将buffer的值写入到磁盘
                    //We only append data to fileChannel or mappedByteBuffer, never both.
                    if (writeBuffer != null || this.fileChannel.position() != 0) {
                        this.fileChannel.force(false);
                    } else {
                        this.mappedByteBuffer.force();
                    }
                } catch (Throwable e) {
                    log.error("Error occurred when force data to disk.", e);
                }

                this.flushedPosition.set(value);
				//注意这里》》》最关键的一步,释放内存和锁
                this.release();
            } else {
                log.warn("in flush, hold failed, flush offset = " + this.flushedPosition.get());
                this.flushedPosition.set(getReadPosition());
            }
        }
        return this.getFlushedPosition();
}
	
//贴release方法
public void release() {
	long value = this.refCount.decrementAndGet();
	if (value > 0)
		return; 
	synchronized (this) { 
	//发现又是一层调用  于是继续
		this.cleanupOver = this.cleanup(value);
	}
}
@Override
    public boolean cleanup(final long currentRef) {
	...
	//这里调用了cleanup 是自己封装的释放buffer的方法
        clean(this.mappedByteBuffer);
        log.info("unmap file[REF:" + currentRef + "] " + this.fileName + " OK");
        return true;
}
	

于是,找到clean方法,即下面:

//
public static void clean(final ByteBuffer buffer) {
        if (buffer == null || !buffer.isDirect() || buffer.capacity() == 0)
            return;
			//反射调用buffer的cleaner方法,然后再调用cleaner的clean方法,于是在
			//不用引用sun包的情况下解决了这个问题
        invoke(invoke(viewed(buffer), "cleaner"), "clean");
}

private static Object invoke(final Object target, final String methodName, final Class<?>... args) {
	return AccessController.doPrivileged(new PrivilegedAction<Object>() {
		public Object run() {
			try {
			//这里反射调用
				Method method = method(target, methodName, args);
				method.setAccessible(true);
				return method.invoke(target);
			} catch (Exception e) {
				throw new IllegalStateException(e);
			}
		}
	});
}

private static Method method(Object target, String methodName, Class<?>[] args)
	throws NoSuchMethodException {
	try {
		return target.getClass().getMethod(methodName, args);
	} catch (NoSuchMethodException e) {
		return target.getClass().getDeclaredMethod(methodName, args);
	}
}

private static ByteBuffer viewed(ByteBuffer buffer) {
	String methodName = "viewedBuffer";
	Method[] methods = buffer.getClass().getMethods();
	for (int i = 0; i < methods.length; i++) {
		if (methods[i].getName().equals("attachment")) {
			methodName = "attachment";
			break;
		}
	}

	ByteBuffer viewedBuffer = (ByteBuffer) invoke(buffer, methodName);
	if (viewedBuffer == null)
		return buffer;
	else
		return viewed(viewedBuffer);
}

于是借鉴了它的代码,完成了buffer释放的操作

注:在macoS系统下未出现这个问题,猜测是操作系统的内存管理机制的不同;当然,还有一种情况就是当前账户确实是没有权限,但是这种情况下读取文件应该都会报错的,所以不考虑这种情况

bug源地址