其实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办法,此刻指针便是移动到了文件第五个字符的方位,然后又写入了一些文字。

Android进阶宝典 -- 从IO到NIO机制的演进

所以依照这种思维,回到前面的问题,即便是5G的文件,也不需求进行读写操作获取之前的全部数据就能够完结零内存追加;当然还有一个场景也会经常用到,便是断点续传。

1.1 RandomAccessFile的缓冲区和BufferedInputStream缓冲区的区别

首要我先简单介绍下BufferedInputStream的缓存区作用,系统内核缓存区默以为4K,当缓存区满4K之后会进行磁盘的写入;那么在Java中是对其做了优化处理,将缓存区变为8K,当缓存区超过8K之后,会将数据复制给到内核缓存。

Android进阶宝典 -- 从IO到NIO机制的演进

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就不会有复制的操作,那么咱们实践看到的是数据仍是写进去了。

Android进阶宝典 -- 从IO到NIO机制的演进

其实MappedByteBuffer,是供给了一个类似于mmap性质的才能,完结了App缓冲区与内核缓冲区的桥接或许映射。

Android进阶宝典 -- 从IO到NIO机制的演进

当App写入缓存数据的时分,直接映射到了内核缓存区,完结了磁盘的读写操作。

1.2 Basic IO模型底层原理

其实关于基础的IO模型,也便是Basic IO的完结是堵塞的,其实咱们也能够自己验证,在主线程中进行读写操作便是堵塞的。

那么关于IO来说,首要分为两个阶段:

(1)数据准备阶段;这儿是由Java完结的,写入到JVM中;

(2)复制阶段;内核空间复制用户空间缓存数据,这部分需求调用内核函数(ioctl、sync),完结复制的工作。

剩余的磁盘写入操作就完全是由内核完结的,假如关于读写操作有疑问的,能够去看看下面这篇关于Binder底层原理的介绍。

Android Framework原理 — Binder驱动源码剖析

关于传统的Socket来说,这种属于Net IO,本质也是堵塞性质的,例如App进程想要获取一些数据,

Android进阶宝典 -- 从IO到NIO机制的演进

上图展现了read操作的整个调度进程:

(1)当App调用系统办法想要获取某些数据的时分,首要系统内核会等候数据从网络中到达,这个进程内核处于堵塞的状况

(2)比及数据到达之后,就会将网络数据复制到用户空间的缓冲区中,并通知App进程复制数据成功,此刻App中其他业务才能够持续履行。

所以整个进程中,App处于堵塞状况,而在高并发的场景中(客户端很少,这儿拿服务端来举例),例如10000QPS(每秒10000次查询操作),此刻假如选用IO堵塞模型,带来的结果便是CPU极速拉满终究或许导致熔断,所以针对这种状况,出现了NIO模型。

2 NIO模型

相关于IO模型来说,NIO模型做的优化是经过轮询机制获取内核的数据等候状况,看下图:

Android进阶宝典 -- 从IO到NIO机制的演进

当一次问询宣布之后,假如当前内核仍是数据等候状况,那么内核空间会被”挂起“,此刻App进程能够做其他的事情,比及下一次轮询时间到了之后,再次建议问询,假如此刻现已拿到了数据,那么就会进行复制操作,将数据放入用户进程缓冲区。

Android进阶宝典 -- 从IO到NIO机制的演进

那么对此,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

Android进阶宝典 -- 从IO到NIO机制的演进

当进行读写操作的时分,都会往Segment中写入,便是将InputStream和OutputStream需求创建的缓冲区合并。

这儿需求阐明一点,okio属于OkHttp内部核心IO结构,并不是单独拿出来恣意业务方能够运用,所以关于okio的详细完结原理,后续会放在OkHttp结构原理中做详细的介绍。