在前公司时参与了一个编码竞赛,虽然只拿到一个中游成绩,但在参赛过程中学习到很多其他人优秀的思考方式,也接受了前辈的指点,尤其是在参赛时的一些知识面拓展对我帮助不小。其中一些平常很少接触到的知识对于之后的工作会有所帮助。
题目很简单,大概是这样:
具体过程及结果不细说,在这里简单介绍其中用到的部分NIO技术,这些技术无论在各种框架如Netty等,以及各种中间件如RocketMQ等都有用到。
我们都知道,JVM需要申请一块内存用于进程的使用,类、对象、方法等数据均保存在JVM堆栈也就是申请的这块内存之中,JVM也会负责帮我们管理和回收再利用这块内存。
相对的,堆外内存就是直接调用系统malloc分配的内存,这部分内存不属于JVM直接管理,也不受JVM最大内存的限制,通过引用指向这段内存。
应用程序是不能直接访问内存、硬盘等资源,而是通过操作系统提供的接口调用。而操作系统为保证安全,将系统进行权限分级,分为权限高的内核态和权限低的用户态,用户态的很多操作需要借用内核态代为进行系统调度,即状态转换。
Unix系统架构
操作系统提供了将一段磁盘文件内容映射到内存的方法,对这段内存数据的修改操作会直接由操作系统保证异步刷盘到硬盘的文件中;在内存映射的过程中可以省略中间的很多IO环节,而这个刷盘过程即使应用程序崩溃也能够完成,这就是内存映射。
使用内存映射文件处理存储于磁盘上的文件时,将不必再对文件执行I/O操作,使得内存映射文件在处理大数据量的文件时能起到相当重要的作用。 ——搜狗百科
ByteBuffer是一个缓冲区,NIO中的所有数据都是经过缓冲区处理的,其底层一般是一个byte array,可以说ByteBuffer是一个带有多个游标的array包装类。简言之ByteBuffer是一块逻辑上连续的内存,用于NIO的读写中转,合理的设计可以实现数据 零拷贝(Zero-Copy) ,也可以理解为减少不必要的数据复制过程。
Zero-Copy: 通常一次发送/复制文件读写处理需要经过如下过程:
可以看到数据在流转过程中读/写都复制了两次,主要问题在于内核态和用户态缓存间的复制。而如果可以合理利用内核提供的能力直接不经过用户态和内核态的来回复制,直接从内核态缓存复制到内核态的目标缓存位置,将会明显减少不必要的复制过程,也就是所谓的Zero-Copy。
方法名 | 描述 | 用途 |
---|---|---|
array | 获取内部array | 数据读写,对array操作等效于对ByteBuffer的操作 |
get系列方法 | 获取本Buffer中的数据 | 数据读写 |
put系列方法 | 数据写入本Buffer | 数据读写 |
as系列方法 | 传出至WritableByteChannel | 将ByteBuffer包装成其他类型的Buffer |
put(ByteBuffer src) | 将src ByteBuffer的内容写入自身 | Channel间数据复制 |
为了复用Buffer实现零拷贝,Buffer内置了很多游标,这些游标的使用是Buffer最核心也是最不好理解的内容:
方法名 | 描述 | 用途 |
---|---|---|
mark | 在当前位置设置mark | mark=position; |
reset | 从当前位置回退到mark处 | position=mark; |
rewind | 倒带,即回到初始状态(回到起点)并清空mark,一般用于再次读 | position=0; mark=-1; |
clear | 将整个Buffer游标重置但不清理数据,新数据直接覆盖,一般用于再次写入 | position=0; limit=capacity; mark=-1; |
flip | 特殊的“倒带”,可用数据变为0~position并回退到起点,通常在写完Buffer后flip供读取 | limit=position; position=0; mark=-1; |
remaining | 返回还剩多少数据用于读/写 | return limit-position; |
limit | 返回limit | return limit; |
capacity | 返回capacity | return capacity; |
这些操作并没有真正区分读/写使用,一旦理解出现偏差将很难实现正确的处理逻辑,也许调一下午才能调通,血的教训
DirectByteBuffer是一个特殊的ByteBuffer,底层同样需要一块连续的内存,操作模式与普通的ByteBuffer一致,但这块内存是调用unsafe的native方法分配的堆外内存。
直接缓冲区的内存释放也是由unsafe的native方法完成的,DirectByteBuffer指向的内存通过PhantomReference持有,由JVM自行回收。但如果DirectByteBuffer经过数次GC后进入老年代,就很可能由于Full GC间隔较长而长期存活,进而导致指向的堆外内存也无法回收。当需要手动回收时,需要通过反射调用DirectByteBuffer内部的Cleaner的clean私有方法。
Java应用一般能够操作的是JVM管理的堆内内存,一段数据从应用中发送至网络需要经过多次复制:
考虑到Java内存模型,可能还存在工作内存/主内存之间的复制;
考虑到GC,可能还存在堆内内存之间的复制;
而如果使用堆外内存,则少了一步从堆内到堆外的复制过程。
使用直接缓冲区的优点:
缺点:
参考资料 指出在BIO中,native读写文件前会先在堆外分配一块内存将堆内数据复制到堆外内存中:
MappedByteBuffer与其他ByteBuffer一样底层是一段连续内存,区别在于这段内存使用的是内存映射的那段内存,也就是说对于这块缓冲区的数据修改会同步到对应的文件中。
NIO的Channel类型是一个通道,本身不能访问数据,而是与Buffer交互。
Channel类的作用主要是操作数据、数据传输、实现内存映射。
几类Channel:
方法名 | 描述 | 用途 |
---|---|---|
transferFrom | 从ReadableByteChannel传入 | Channel间数据复制 |
transferTo | 传出至WritableByteChannel | Channel间数据复制 |
read | 写到ByteBuffer中 | Channel与ByteBuffer间数据复制 |
write | 从ByteBuffer中读 | Channel与ByteBuffer间数据复制 |
position | 游标当前位置 | |
size | Channel内容长度 | |
map | 映射出一个MappedByteBuffer | 从Channel映射出可操作的ByteBuffer |
FileChannel优点:
缺点:
一些Channel可以使用读/读写等模式操作
public class UnitTest1 { private static final String prefix = "~/path/to/"; public static void main(String[] args) throws Exception { streamCopy("input", "output1"); bufferCopy("input", "output2"); directBufferCopy("input", "output3"); mappedByteBufferCopy("input", "output4"); mappedByteBufferCopyByPart("input", "output5"); channelCopy("input", "output6"); } /** * 使用stream */ private static void streamCopy(String from, String to) throws IOException { long startTime = System.currentTimeMillis(); File inputFile = new File(prefix + from); File outputFile = new File(prefix + to); FileInputStream fis = new FileInputStream(inputFile); FileOutputStream fos = new FileOutputStream(outputFile); byte[] bytes = new byte[1024]; int len; while ((len = fis.read(bytes)) != -1) { fos.write(bytes, 0, len); } fos.flush(); fis.close(); fos.close(); long endTime = System.currentTimeMillis(); System.out.println("streamCopy cost:" + (endTime - startTime)); } /** * 使用buffer */ private static void bufferCopy(String from, String to) throws IOException { long startTime = System.currentTimeMillis(); RandomAccessFile inputFile = new RandomAccessFile(prefix + from, "r"); RandomAccessFile outputFile = new RandomAccessFile(prefix + to, "rw"); FileChannel inputChannel = inputFile.getChannel(); FileChannel outputChannel = outputFile.getChannel(); ByteBuffer byteBuffer = ByteBuffer.allocate(1024); while (inputChannel.read(byteBuffer) != -1) { byteBuffer.flip(); outputChannel.write(byteBuffer); byteBuffer.clear(); } inputChannel.close(); outputChannel.close(); long endTime = System.currentTimeMillis(); System.out.println("bufferCopy cost:" + (endTime - startTime)); } /** * 使用堆外内存 */ private static void directBufferCopy(String from, String to) throws IOException { long startTime = System.currentTimeMillis(); RandomAccessFile inputFile = new RandomAccessFile(prefix + from, "r"); RandomAccessFile outputFile = new RandomAccessFile(prefix + to, "rw"); FileChannel inputChannel = inputFile.getChannel(); FileChannel outputChannel = outputFile.getChannel(); ByteBuffer byteBuffer = ByteBuffer.allocateDirect(1024); while (inputChannel.read(byteBuffer) != -1) { byteBuffer.flip(); outputChannel.write(byteBuffer); byteBuffer.clear(); } inputChannel.close(); outputChannel.close(); long endTime = System.currentTimeMillis(); System.out.println("directBufferCopy cost:" + (endTime - startTime)); } /** * 内存映射全量 */ private static void mappedByteBufferCopy(String from, String to) throws IOException { long startTime = System.currentTimeMillis(); RandomAccessFile inputFile = new RandomAccessFile(prefix + from, "r"); RandomAccessFile outputFile = new RandomAccessFile(prefix + to, "rw"); FileChannel inputChannel = inputFile.getChannel(); FileChannel outputChannel = outputFile.getChannel(); MappedByteBuffer iBuffer = inputChannel.map(MapMode.READ_ONLY, 0, inputFile.length()); MappedByteBuffer oBuffer = outputChannel.map(MapMode.READ_WRITE, 0, inputFile.length()); // 直接操作buffer,没有其他IO操作 oBuffer.put(iBuffer); inputChannel.close(); outputChannel.close(); long endTime = System.currentTimeMillis(); System.out.println("mappedByteBufferCopy cost:" + (endTime - startTime)); } /** * 内存映射部分 */ private static void mappedByteBufferCopyByPart(String from, String to) throws IOException { long startTime = System.currentTimeMillis(); RandomAccessFile inputFile = new RandomAccessFile(prefix + from, "r"); RandomAccessFile outputFile = new RandomAccessFile(prefix + to, "rw"); FileChannel inputChannel = inputFile.getChannel(); FileChannel outputChannel = outputFile.getChannel(); for (long i = 0; i < inputFile.length(); i += 1024) { long size = 1024; // 避免文件产生间隙 if (i + size > inputFile.length()) { size = inputFile.length() - i; } MappedByteBuffer iBuffer = inputChannel.map(MapMode.READ_ONLY, i, size); MappedByteBuffer oBuffer = outputChannel.map(MapMode.READ_WRITE, i, size); oBuffer.put(iBuffer); } inputChannel.close(); outputChannel.close(); long endTime = System.currentTimeMillis(); System.out.println("mappedByteBufferCopyByPart cost:" + (endTime - startTime)); } /** * zero copy */ private static void channelCopy(String from, String to) throws IOException { long startTime = System.currentTimeMillis(); RandomAccessFile inputFile = new RandomAccessFile(prefix + from, "r"); RandomAccessFile outputFile = new RandomAccessFile(prefix + to, "rw"); FileChannel inputChannel = inputFile.getChannel(); FileChannel outputChannel = outputFile.getChannel(); inputChannel.transferTo(0, inputFile.length(), outputChannel); inputChannel.close(); outputChannel.close(); long endTime = System.currentTimeMillis(); System.out.println("channelCopy cost:" + (endTime - startTime)); } } 复制代码
input文件大小为360MB,其实算是小文件,大文件暂时没找到,效果会更明显。
这段代码在我的开发机器上输出结果为:
streamCopy cost:2718 bufferCopy cost:2604 directBufferCopy cost:2420 mappedByteBufferCopy cost:541 mappedByteBufferCopyByPart cost:11232 channelCopy cost:330 复制代码