其实IO操作相较于服务端,客户端做的并不多,基本的场景就是读写文件的时候会使用到InputStream或者OutputStream,然而客户端能做的就是发起一个读写的指令,真正的操作是内核层通过ioctl指令执行读写操作,因为每次的IO操作都涉及到了线程的操作,因此会有性能上的损耗,那么从本篇文章开始,我们将进入IO的世界,了解IO到NIO机制的演进,从底层关注序列化的原理。
1 Basic IO模型
那么在Java(Kotlin)中,IO主要分为两种:Basic IO 和 Net IO;Basic IO是我们在开发当中常用的一些IO流,例如:
FileInputStream://文件输入流
FileOutputStream://文件输出流
BufferedInputStream://缓存字节输入流
BufferedOutputStream://缓存字节输入流,此类数据流为了提高读写效率,可以缓存数据到buffer,通过flush一起写入;内核分配内存为一页4K,但是Java缓冲区默认是8K
ObjectInputStream
ObjectOutputStream:// 将数据序列化处理
RandomAccessFile://提供位移数据插入
复制代码
对于前面的几个数据流,我就不介绍用法了,对于最后一个RandomAccessFile,我想简单介绍一下,因为很多伙伴们可能不知道RandomAccessFile的存在,这里曾经有个面试题:
假设有一个5G的文件,我想在文章的末尾追加一段话,我该怎么处理?或者我指定任意位置添加一部分文字内容,该怎么处理?
很多伙伴看到这个问题之后,一拍脑门说:先通过FileInputStream把文件读写进来,然后再在末尾追加一部分内容组合成新的字节流,然后再通过FileOutputStream写入到新的文件中。
完蛋,直接pass掉!因为前提这里已经是5G的文件了,如果通过FileInputStream读写,大概率就会直接OOM! 所以如果知道RandomAccessFile的存在,这些就不是问题了。
fun testAccessFile() {
//file文件
val file = File("/storage/emulated/0/NewTextFile.txt")
val accessFile = RandomAccessFile(file, "rw")
//先写一段
val text = "IO主要分为两种:Basic IO 和 Net IO;"
accessFile.write(text.toByteArray())
//再等5s
Thread.sleep(5000)
accessFile.seek(5)
accessFile.write("seek to pos 5".toByteArray())
accessFile.close()
}
首先我们常见一个RandomAccessFile,传入要读写的文件,首先写入一段话,然后等到5s后,调用RandomAccessFile的seek方法,此时指针就是移动到了文件第五个字符的位置,然后又写入了一些文字。
所以按照这种思想,回到前面的问题,即便是5G的文件,也不需要进行读写操作获取之前的全部数据就能够实现零内存追加;当然还有一个场景也会经常用到,就是断点续传。
1.1 RandomAccessFile的缓冲区和BufferedInputStream缓冲区的区别
首先我先简单介绍下BufferedInputStream的缓存区效果,系统内核缓存区默认为4K,当缓存区满4K之后会进行磁盘的写入;那么在Java中是对其做了优化处理,将缓存区变为8K,当缓存区超过8K之后,会将数据复制给到内核缓存。
fun testBuffer() {
val file = File("/storage/emulated/0/NewTextFile.txt")
val bis = BufferedOutputStream(FileOutputStream(file))
val text = "8888888888888888".toByteArray()
bis.write(text, 0, text.size)
// bis.flush()
}
复制代码
例如上面的案例,此时App的内存缓存区没有满,那么如果不调用flush,那么数据不会写到磁盘文件中,只有当缓冲区满了之后,才会复制到内核空间缓存区。
fun testAccessFile() {
//file文件
val file = File("/storage/emulated/0/NewTextFile.txt")
val accessFile = RandomAccessFile(file, "rw")
//先写一段
val text = "IO主要分为两种:Basic IO 和 Net IO;"
accessFile.write(text.toByteArray())
//再等5s
Thread.sleep(5000)
accessFile.seek(5)
val channel = accessFile.channel
val mapper = channel.map(FileChannel.MapMode.READ_WRITE, channel.position(), channel.size())
mapper.put("seek to pos 5".toByteArray())
}
复制代码
如果按照BufferedOutputStream的思想,我们往缓冲区写数据,没有flush就不会有复制的操作,那么我们实际看到的是数据还是写进去了。
其实MappedByteBuffer,是提供了一个类似于mmap性质的能力,实现了App缓冲区与内核缓冲区的桥接或者映射。
当App写入缓存数据的时候,直接映射到了内核缓存区,完成了磁盘的读写操作。
1.2 Basic IO模型底层原理
其实对于基础的IO模型,也就是Basic IO的实现是阻塞的,其实我们也可以自己验证,在主线程中进行读写操作就是阻塞的。
那么对于IO来说,主要分为两个阶段:
(1)数据准备阶段;这里是由Java实现的,写入到JVM中;
(2)复制阶段;内核空间复制用户空间缓存数据,这部分需要调用内核函数(ioctl、sync),完成复制的工作。
剩下的磁盘写入操作就完全是由内核完成的,如果对于读写操作有疑问的,可以去看看下面这篇对于Binder底层原理的介绍。
Android Framework原理 -- Binder驱动源码分析
对于传统的Socket来说,这种属于Net IO,本质也是阻塞性质的,例如App进程想要获取一些数据,
上图展示了read操作的整个调度过程:
(1)当App调用系统方法想要获取某些数据的时候,首先系统内核会等待数据从网络中到达,这个过程内核处于阻塞的状态;
(2)等到数据到达之后,就会将网络数据复制到用户空间的缓冲区中,并通知App进程复制数据成功,此时App中其他业务才能够继续执行。
所以整个过程中,App处于阻塞状态,而在高并发的场景中(客户端很少,这里拿服务端来举例),例如10000QPS(每秒10000次查询操作),此时如果采用IO阻塞模型,带来的后果就是CPU极速拉满最终可能导致熔断,所以针对这种情况,出现了NIO模型。
2 NIO模型
相对于IO模型来说,NIO模型做的优化是通过轮询机制获取内核的数据等待状态,看下图:
当一次询问发出之后,如果当前内核还是数据等待状态,那么内核空间会被”挂起“,此时App进程可以做其他的事情,等到下一次轮询时间到了之后,再次发起询问,如果此时已经拿到了数据,那么就会进行复制操作,将数据放入用户进程缓冲区。
那么对此,java.nio包下提供了很多非阻塞IO的API,例如我们前面提到的MappedByteBuffer。其实还是前面我们探讨的一个问题,在Android的场景下,很难碰到高并发的场景,所以基本上也很难用到这个,但是对于NIO模型的原理我们需要掌握透彻,在面试中可能会涉及到这些问题。
3 OKIO
最后介绍一个IO模型---OKIO,如果使用到OkHttp的伙伴们应该已经见到过这个,但是没有实际地去研究,为啥要引入这个okio三方库。
首先okio是OkHttp团队基于Basic IO研发的一套自己的IO体系,为啥要搞一个这个玩意出来呢?通过前面我们分析Basic IO存在的一些问题,首先 Basic IO是阻塞的,而且在客户端端如果频繁地进行网络请求,而且网络请求是双向的,从客户端发出请求,服务端返回响应,那么这个过程必定会使用到InputStream和OutputStream。
因为OkHttp是有自己的缓存策略的,如果使用到缓存,那么对于InputStream就需要一个buffer,对于OutputStream也需要一个buffer,每次读写操作都需要两个buffer来做支撑,因此针对这种场景,okio在底层做了处理。
具体的处理就是不再使用byte[]数组存储数据,而是采用Segment数据结构。有熟悉Segment的伙伴应该知道,它是一个数组的双向链表,其中data就是一个byte数组,其中有next和pre两个指针。
internal class Segment {
@JvmField val data: ByteArray
/** The next byte of application data byte to read in this segment. */
@JvmField var pos: Int = 0
/** The first byte of available data ready to be written to. */
@JvmField var limit: Int = 0
/** True if other segments or byte strings use the same byte array. */
@JvmField var shared: Boolean = false
/** True if this segment owns the byte array and can append to it, extending `limit`. */
@JvmField var owner: Boolean = false
/** Next segment in a linked or circularly-linked list. */
@JvmField var next: Segment? = null
/** Previous segment in a circularly-linked list. */
@JvmField var prev: Segment? = null
当进行读写操作的时候,都会往Segment中写入,就是将InputStream和OutputStream需要创建的缓冲区合并。
这里需要说明一点,okio属于OkHttp内部核心IO框架,并不是单独拿出来任意业务方可以使用,所以对于okio的具体实现原理,后续会放在OkHttp框架原理中做详细的介绍。