本文已收录到 AndroidFamily,技能和职场问题,请重视公众号 [彭旭锐] 提问。
前语
大家好,我是小彭。
今日,咱们来讨论一个 Square 开源的 I/O 结构 Okio,咱们最开始接触到 Okio 结构仍是源于 Square 家的 OkHttp 网络结构。那么,OkHttp 为什么要运用 Okio,它相比于 Java 原生 IO 有什么区别和优势?今日咱们就围绕这些问题展开。
本文源码根据 Okio v3.2.0。
思想导图
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,而是会运用 BufferedInputStream
和 BufferedOutputStream
包装类添加缓冲功用。
例如,咱们希望采用带缓冲的方法读取字符格局的文件,则需求先将文件输入流包装为字符流,再包装为缓冲流:
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,而是会运用 BufferedSource
和 BufferedSink
包装类添加缓冲功用:
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 结构
而这么多种流在 Okio 里还剩下多少呢?
- 原始流: FileInputStream / FileOutputStream 与 SocketInputStream / SocketOutputStream;
- 根底接口: Source / Sink;
- 缓存流: BufferedSource / BufferedSink。
Okio 结构
就问你服不服?
并且你看哈,这些都是平常业务开发中最常见的根本类型,原生 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 结构
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 示意图
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 著