本文已收录到 AndroidFamily,技能和职场问题,请重视公众号 [彭旭锐] 提问。

前语

大家好,我是小彭。

今日,咱们来讨论一个 Square 开源的 I/O 结构 Okio,咱们最开始接触到 Okio 结构仍是源于 Square 家的 OkHttp 网络结构。那么,OkHttp 为什么要运用 Okio,它相比于 Java 原生 IO 有什么区别和优势?今日咱们就围绕这些问题展开。

本文源码根据 Okio v3.2.0。


思想导图

Android IO 框架 Okio 的实现原理,到底哪里 OK?


1. 说一下 Okio 的优势?

相比于 Java 原生 IO 结构,我认为 Okio 的优势主要体现在 3 个方面:

  • 1、精简且全面的 API: 原生 IO 运用装修模式,例如运用 BufferedInputStream 装修 FileInputStream 文件输入流,能够增强流的缓冲功用。可是原生 IO 的装修器过于巨大,需求区别字节、字符流、字节数组、字符数组、缓冲等多种装修器,而这些恰恰又是最常用的根底装修器。相较之下,Okio 直接在 BufferedSource 和 BufferedSink 中聚合了原生 IO 中所有根底的装修器,使得结构愈加精简;

  • 2、根据同享的缓冲区规划: 因为 IO 体系调用存在上下文切换的功用损耗,为了削减体系调用次数,应用层往往会采用缓冲区战略。可是缓冲区又会存在副作用,当数据从一个缓冲区转移到另一个缓冲区时需求复制数据,这种内存中的复制显得没有必要。而 Okio 采用了根据同享的缓冲区规划,在缓冲区间转移数据只是同享 Segment 的引证,而削减了内存复制。一起 Segment 也采用了目标池规划,削减了内存分配和收回的开支;

  • 3、超时机制: Okio 弥补了部分 IO 操作不支撑超时检测的缺陷,并且 Okio 不只支撑单次 IO 操作的超时检测,还支撑包括屡次 IO 操作的复合使命超时检测。

下面,咱们将从这三个优势展开剖析:


2. 精简的 Okio 结构

先用一个表格总结 Okio 结构中主要的类型:

类型 描绘
Source 输入流
Sink 输出流
BufferedSource 缓存输入流接口,完结类是 RealBufferedSource
BufferedSink 缓冲输出流接口,完结类是 RealBufferedSink
Buffer 缓冲区,由 Segment 链表组成
Segment 数据片段,多个片段组成逻辑上接连数据
ByteString String 类
Timeout 超时操控

2.1 Source 输入流 与 Sink 输出流

在 Java 原生 IO 中有四个根底接口,分别是:

  • 字节省: InputStream 输入流和 OutputStream 输出流;
  • 字符流: Reader 输入流和 Writer 输出流。

而在 Okio 愈加精简,只有两个根底接口,分别是:

  • 流: Source 输入流和 Sink 输出流。

Source.kt

interface Source : Closeable {
    // 从输入流读取数据到 Buffer 中(Buffer 等价于 byte[] 字节数组)
    // 返回值:-1:输入内容结束
    @Throws(IOException::class)
    fun read(sink: Buffer, byteCount: Long): Long
    // 超时操控(详细剖析见后续文章)
    fun timeout(): Timeout
    // 封闭流
    @Throws(IOException::class)
    override fun close()
}

Sink.java

actual interface Sink : Closeable, Flushable {
    // 将 Buffer 的数据写入到输出流中(Buffer 等价于 byte[] 字节数组)
    @Throws(IOException::class)
    actual fun write(source: Buffer, byteCount: Long)
    // 清空输出缓冲区
    @Throws(IOException::class)
    actual override fun flush()
    // 超时操控(详细剖析见后续文章)
    actual fun timeout(): Timeout
    // 封闭流
    @Throws(IOException::class)
    actual override fun close()
}

2.2 InputStream / OutputStream 与 Source / Sink 互转

在功用上,InputStream – Source 和 OutputStream – Sink 分别是等价的,并且是相互兼容的。结合 Kotlin 扩展函数,两种接口之间的转换会非常便利:

  • source(): InputStream 转 Source,完结类是 InputStreamSource;
  • sink(): OutputStream 转 Sink,完结类是 OutputStreamSink;

比较不理解的是: Okio 没有供给 InputStreamSource 和 OutputStreamSink 转回 InputStream 和 OutputStream 的方法,而是需求先转换为 BufferSource 与 BufferSink,再转回 InputStream 和 OutputStream。

  • buffer(): Source 转 BufferedSource,Sink 转 BufferedSink,完结类分别是 RealBufferedSource 和 RealBufferedSink。

示例代码

// 原生 IO -> Okio
val source = FileInputStream(File("")).source()
val bufferSource = FileInputStream(File("")).source().buffer()
val sink = FileOutputStream(File("")).sink()
val bufferSink = FileOutputStream(File("")).sink().buffer()
// Okio -> 原生 IO
val inputStream = bufferSource.inputStream()
val outputStream = bufferSink.outputStream()

JvmOkio.kt

// InputStream -> Source
fun InputStream.source(): Source = InputStreamSource(this, Timeout())
// OutputStream -> Sink
fun OutputStream.sink(): Sink = OutputStreamSink(this, Timeout())
private class InputStreamSource(
    private val input: InputStream,
    private val timeout: Timeout
) : Source {
    override fun read(sink: Buffer, byteCount: Long): Long {
        if (byteCount == 0L) return 0
        require(byteCount >= 0) { "byteCount < 0: $byteCount" }
        try {
            // 同步超时监控(详细剖析见后续文章)
            timeout.throwIfReached()
            // 读入 Buffer
            val tail = sink.writableSegment(1)
            val maxToCopy = minOf(byteCount, Segment.SIZE - tail.limit).toInt()
            val bytesRead = input.read(tail.data, tail.limit, maxToCopy)
            if (bytesRead == -1) {
                if (tail.pos == tail.limit) {
                    // We allocated a tail segment, but didn't end up needing it. Recycle!
                    sink.head = tail.pop()
                    SegmentPool.recycle(tail)
                }
                return -1
            }
            tail.limit += bytesRead
            sink.size += bytesRead
            return bytesRead.toLong()
        } catch (e: AssertionError) {
            if (e.isAndroidGetsocknameError) throw IOException(e)
            throw e
        }
  }
  override fun close() = input.close()
  override fun timeout() = timeout
  override fun toString() = "source($input)"
}
private class OutputStreamSink(
    private val out: OutputStream,
    private val timeout: Timeout
) : Sink {
    override fun write(source: Buffer, byteCount: Long) {
        checkOffsetAndCount(source.size, 0, byteCount)
        var remaining = byteCount
        // 写出 Buffer
        while (remaining > 0) {
            // 同步超时监控(详细剖析见后续文章)
            timeout.throwIfReached()
            // 取有用数据量和剩下输出量的较小值
            val head = source.head!!
            val toCopy = minOf(remaining, head.limit - head.pos).toInt()
            out.write(head.data, head.pos, toCopy)
            head.pos += toCopy
            remaining -= toCopy
            source.size -= toCopy
            // 指向下一个 Segment
            if (head.pos == head.limit) {
                source.head = head.pop()
                SegmentPool.recycle(head)
            }
        }
    }
    override fun flush() = out.flush()
    override fun close() = out.close()
    override fun timeout() = timeout
    override fun toString() = "sink($out)"
}

Okio.kt

// Source -> BufferedSource
fun Source.buffer(): BufferedSource = RealBufferedSource(this)
// Sink -> BufferedSink
fun Sink.buffer(): BufferedSink = RealBufferedSink(this)

2.3 BufferSource 与 BufferSink

在 Java 原生 IO 中,为了削减体系调用次数,咱们一般不会直接调用 InputStream 和 OutputStream,而是会运用 BufferedInputStreamBufferedOutputStream 包装类添加缓冲功用。

例如,咱们希望采用带缓冲的方法读取字符格局的文件,则需求先将文件输入流包装为字符流,再包装为缓冲流:

Java 原生 IO 示例

// 第一层包装
FileInputStream fis = new FileInputStream(file);
// 第二层包装
InputStreamReader isr = new InputStreamReader(new FileInputStream(file), "UTF-8");
// 第三层包装
BufferedReader br = new BufferedReader(isr);
String line;
while ((line = br.readLine()) != null) {
    ...
}
// 省掉 close

同理,咱们在 Okio 中一般也不会直接调用 Source 和 Sink,而是会运用 BufferedSourceBufferedSink 包装类添加缓冲功用:

Okio 示例

val bufferedSource = file.source()/*第一层包装*/.buffer()/*第二层包装*/
while (!bufferedSource.exhausted()) {
    val line = bufferedSource.readUtf8Line();
    ...
}
// 省掉 close

网上有材料说 Okio 没有运用装修器模式,所以类结构更简略。 这么说其实不太准确,装修器模式本身并不是缺陷,并且从 BufferedSource 和 BufferSink 能够看出 Okio 也运用了装修器模式。 严格来说是原生 IO 的装修器过于巨大,而 Okio 的装修器愈加精简。

比方原生 IO 常用的流就有这么多:

  • 原始流: FileInputStream / FileOutputStream 与 SocketInputStream / SocketOutputStream;

  • 根底接口(区别字节省和字符流): InputStream / OutputStream 与 Reader / Writer;

  • 缓存流: BufferedInputStream / BufferedOutputStream 与 BufferedReader / BufferedWriter;

  • 根本类型: DataInputStream / DataOutputStream;

  • 字节数组和字符数组: ByteArrayInputStream / ByteArrayOutputStream 与 CharArrayReader / CharArrayWriter;

  • 此处省掉一万个字。

原生 IO 结构

Android IO 框架 Okio 的实现原理,到底哪里 OK?

而这么多种流在 Okio 里还剩下多少呢?

  • 原始流: FileInputStream / FileOutputStream 与 SocketInputStream / SocketOutputStream;
  • 根底接口: Source / Sink;
  • 缓存流: BufferedSource / BufferedSink。

Okio 结构

Android IO 框架 Okio 的实现原理,到底哪里 OK?

就问你服不服?

并且你看哈,这些都是平常业务开发中最常见的根本类型,原生 IO 把它们都拆分开了,让问题复杂化了。反观 Okio 直接在 BufferedSource 和 BufferedSink 中聚合了原生 IO 中根本的功用,而不再需求区别字节、字符、字节数组、字符数组、根底类型等等装修器,的确让结构愈加精简。

BufferedSource.kt

actual interface BufferedSource : Source, ReadableByteChannel {
    actual val buffer: Buffer
    // 读取 Int
    @Throws(IOException::class)
    actual fun readInt(): Int
    // 读取 String
    @Throws(IOException::class)
    fun readString(charset: Charset): String
    ...
    fun inputStream(): InputStream
}

BufferedSink.kt

actual interface BufferedSink : Sink, WritableByteChannel {
    actual val buffer: Buffer
    // 写入 Int
    @Throws(IOException::class)
    actual fun writeInt(i: Int): BufferedSink
    // 写入 String
    @Throws(IOException::class)
    fun writeString(string: String, charset: Charset): BufferedSink
    ...
    fun outputStream(): OutputStream
}

2.4 RealBufferedSink 与 RealBufferedSource

BufferedSource 和 BufferedSink 仍是接口,它们的真实的完结类是 RealBufferedSource 和 RealBufferedSink。能够看到,在完结类中会创立一个 Buffer 缓冲区,在输入和输出的时候,都会凭借 “Buffer 缓冲区” 削减体系调用次数。

RealBufferedSource.kt

internal actual class RealBufferedSource actual constructor(
    // 装修器模式
    @JvmField actual val source: Source
) : BufferedSource {
    // 创立输入缓冲区
    @JvmField val bufferField = Buffer()
    // 带缓冲地读取(悉数数据)
    override fun readString(charset: Charset): String {
        buffer.writeAll(source)
        return buffer.readString(charset)
    }
    // 带缓冲地读取(byteCount)
    override fun readString(byteCount: Long, charset: Charset): String {
        require(byteCount)
        return buffer.readString(byteCount, charset)
    }
}

RealBufferedSink.kt

internal actual class RealBufferedSink actual constructor(
    // 装修器模式
    @JvmField actual val sink: Sink
) : BufferedSink {
    // 创立输出缓冲区
    @JvmField val bufferField = Buffer()
    // 带缓冲地写入(悉数数据)
    override fun writeString(string: String, charset: Charset): BufferedSink {
        buffer.writeString(string, charset)
        return emitCompleteSegments()
    }
    // 带缓冲地写入(beginIndex - endIndex)
    override fun writeString(
        string: String,
        beginIndex: Int,
        endIndex: Int,
        charset: Charset
    ): BufferedSink {
        buffer.writeString(string, beginIndex, endIndex, charset)
        return emitCompleteSegments()
    }
}

至此,Okio 根本结构剖析结束,用一张图总结:

Okio 结构

Android IO 框架 Okio 的实现原理,到底哪里 OK?


3. Okio 的缓冲区规划

3.1 运用缓冲区削减体系调用次数

在操作体系中,拜访磁盘和网卡等 IO 操作需求通过体系调用来履行。体系调用本质上是一种软中止,进程会从用户态堕入内核态履行中止处理程序,完结 IO 操作后再从内核态切换回用户态。

能够看到,体系调用存在上下文切换的功用损耗。为了削减体系调用次数,应用层往往会采用缓冲区战略:

以 Java 原生 IO BufferedInputStream 为例,会通过一个 byte[] 数组作为数据源的输入缓冲,每次读取数据时会读取更多数据到缓冲区中:

  • 假如缓冲区中存在有用数据,则直接从缓冲区数据读取;
  • 假如缓冲区不存在有用数据,则先履行体系调用填充缓冲区(fill),再从缓冲区读取数据;
  • 假如要读取的数据量大于缓冲区容量,就会跳过缓冲区直接履行体系调用。

输出流 BufferedOutputStream 也相似,输出数据时会优先写到缓冲区,当缓冲区满或者手动调用 flush() 时,再履行体系调用写出数据。

伪代码

// 1. 输入
fun read(byte[] dst, int len) : Int {
    // 缓冲区有用数据量
    int avail = count - pos
    if(avail <= 0) {
        if(len >= 缓冲区容量) {
            // 直接从输入流读取
            read(输入流 in, dst, len)
        }
        // 填充缓冲区
        fill(数据源 in, 缓冲区)
    }
    // 本次读取数据量,不超过可用容量
    int cnt = (avail < len) ? avail : len?
    read(缓冲区, dst, cnt)
    // 更新缓冲区索引
    pos += cnt
    return cnt
}
// 2. 输出
fun write(byte[] src, len) {
    if(len > 缓冲区容量) {
        // 先将缓冲区写出
        flush(缓冲区)
        // 直接写出数据
        write(输出流 out, src, len)
    }
    // 缓冲区剩下容量
    int left = 缓冲区容量 - count
    if(len > 缓冲区剩下容量) {
        // 先将缓冲区写出
        flush(缓冲区)
    }
    // 将数据写入缓冲区
    write(缓冲区, src, len)
    // 更新缓冲区已添加数据容量
    count += len
}

3.2 缓冲区的副作用

的确,缓冲区战略能有用地削减体系调用次数,不至于读取一个字节都需求履行一次体系调用,大多数情况下表现杰出。 但考虑一种 “双流操作” 场景,即从一个输入流读取,再写入到一个输出流。回忆方才讲的缓存战略,此时的数据转移过程为:

  • 1、从输入流读取到缓冲区;
  • 2、从输入流缓冲区复制到 byte[](复制)
  • 3、将 byte[] copy 到输出流缓冲区(复制);
  • 4、将输出流缓冲区写入到输出流。

假如这两个流都运用了缓冲区规划,那么数据在这两个内存缓冲区之间相互复制,就显得没有必要。

3.3 Okio 的 Buffer 缓冲区

Okio 当然也有缓冲区战略,假如没有就会存在频繁体系调用的问题。

Buffer 是 RealBufferedSource 和 RealBufferedSink 的数据缓冲区。虽然在完结上与原生 BufferedInputStream 和 BufferedOutputStream 不相同,但在功用上是相同的。区别在于:

  • 1、BufferedInputStream 中的缓冲区是 “一个固定长度的字节数组” ,数据从一个缓冲区转移到另一个缓冲区需求复制;

  • 2、Buffer 中的缓冲区是 “一个 Segment 双向循环链表” ,每个 Segment 目标是一小段字节数组,依托 Segment 链表的顺序组成逻辑上的接连数据。这个 Segment 片段是 Okio 高效的关键。

Buffer.kt

actual class Buffer : BufferedSource, BufferedSink, Cloneable, ByteChannel {
    // 缓冲区(Segment 双向链表)
    @JvmField internal actual var head: Segment? = null
    // 缓冲区数据量
    @get:JvmName("size")
    actual var size: Long = 0L
        internal set
    override fun buffer() = this
    actual override val buffer get() = this
}

对比 BufferedInputStream:

BufferedInputStream.java

public class BufferedInputStream extends FilterInputStream {
    // 缓冲区的默许巨细(8KB)
    private static int DEFAULT_BUFFER_SIZE = 8192;
    // 输入缓冲区(固定长度的数组)
    protected volatile byte buf[];
    // 有用数据开始位,也是读数据的开始位
    protected int pos;
    // 有用数据量,pos + count 是写数据的开始位
    protected int count;
    ...
}

3.4 Segment 片段与 SegmentPool 目标池

Segment 中的字节数组是能够 “同享” 的,当数据从一个缓冲区转移到另一个缓冲区时,能够同享数据引证,而不一定需求复制数据。

Segment.kt

internal class Segment {
    companion object {
        // 片段的默许巨细(8KB)
        const val SIZE = 8192
        // 最小同享阈值,超过 1KB 的数据才会同享
        const val SHARE_MINIMUM = 1024
    }
    // 底层数组
    @JvmField val data: ByteArra
    // 有用数据的开始位,也是读数据的开始位
    @JvmField var pos: Int = 0
    // 有用数据的结束位,也是写数据的开始位
    @JvmField var limit: Int = 0
    // 同享符号位
    @JvmField var shared: Boolean = false
    // 宿主符号位
    @JvmField var owner: Boolean = false
    // 后续指针
    @JvmField var next: Segment? = null
    // 前驱指针
    @JvmField var prev: Segment? = null
    constructor() {
        // 默许结构 8KB 数组(为什么默许长度是 8KB)
        this.data = ByteArray(SIZE)
        // 宿主符号位
        this.owner = true
        // 同享符号位
        this.shared = false
    }
}

别的,Segment 还运用了目标池规划,被收回的 Segment 目标会缓存在 SegmentPool 中。SegmentPool 内部保护了一个被收回的 Segment 目标单链表,缓存容量的最大值是 MAX_SIZE = 64 * 1024,也就相当于 8 个默许 Segment 的长度:

SegmentPool.kt

// object:全局单例
internal actual object SegmentPool {
    // 缓存容量
    actual val MAX_SIZE = 64 * 1024
    // 头节点
    private val LOCK = Segment(ByteArray(0), pos = 0, limit = 0, shared = false, owner = false)
    ...
}

Segment 示意图

Android IO 框架 Okio 的实现原理,到底哪里 OK?


4. 总结

  • 1、Okio 将原生 IO 多种根底装修器聚合在 BufferedSource 和 BufferedSink,使得结构愈加精简;
  • 2、为了削减体系调用次数的一起,应用层 IO 结构会运用缓存区规划。而 Okio 运用了根据同享 Segment 的缓冲区规划,削减了在缓冲区间转移数据的内存复制;
  • 3、Okio 弥补了部分 IO 操作不支撑超时检测的缺陷,并且 Okio 不只支撑单次 IO 操作的超时检测,还支撑包括屡次 IO 操作的复合使命超时检测。

关于 Okio 超时机制的详细剖析,咱们在 下一篇文章 里讨论。请重视。


版权声明

本文为稀土技能社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!

参考材料

  • Github Okio
  • Okio 官网
  • Okio 源码学习剖析 —— 川峰 著
  • Okio 好在哪?—— MxsQ 著

Android IO 框架 Okio 的实现原理,到底哪里 OK?