Okhttp的浅层架构分析
Okhttp的责任链模式和拦截器分析
Okhttp之RetryAndFollowUpInterceptor拦截器分析
Okhttp之BridgeInterceptor拦截器分析
Okhttp之CacheInterceptor拦截器分析
Okhttp之ConnectInterceptor拦截器分析
Okhttp之网络连接相关三大类RealConnection、ConnectionPool、StreamAllocation
最近研究了Okhttp的源码,看到最后发现了okio的身影,之前一直对io流概念模糊,这次决定好好啃一啃这一块的知识点,弄清楚io的整体脉络。
看了okio的评价,很多人都说它比原生的io更高效,代码更简洁,使用起来更方便,缓存设计更合理,带着这些疑惑问题来研究研究它是怎么做到的。
先来看看一般的文件copy操作,原生的io一般用到缓存BufferedInputStream类来实现:
//读取文件(缓存字节流)
val inPutStream = BufferedInputStream(FileInputStream("1.txt"))
//写入相应的文件
val outPutStream = BufferedOutputStream(FileOutputStream("2.txt"))
//读取数据
//一次性取多少字节
val bytes = ByteArray(2048)
//接受读取的内容(n就代表的相关数据,只不过是数字的形式)
var n = -1
//循环取出数据
while (inPutStream.read(bytes, 0, bytes.size).also { n = it } != -1) {
//写入相关文件
outPutStream.write(bytes, 0, n)
}
//flush缓存
outPutStream.flush()
//关闭流
inPutStream.close()
outPutStream.close()
再来看看BufferedInputStream和BufferedOutputStream的源码,他们是怎么实现缓存策略的?
public class BufferedInputStream extends FilterInputStream {
// 默认的缓冲大小是8192字节
// BufferedInputStream 会根据“缓冲区大小”来逐次的填充缓冲区;
// 即,BufferedInputStream填充缓冲区,用户读取缓冲区,读完之后,BufferedInputStream会再次填充缓冲区。如此循环,直到读完数据...
private static int defaultBufferSize = 8192;
// 缓冲数组
protected volatile byte buf[];
// 缓存数组的原子更新器。
// 该成员变量与buf数组的volatile关键字共同组成了buf数组的原子更新功能实现,
// 即,在多线程中操作BufferedInputStream对象时,buf和bufUpdater都具有原子性(不同的线程访问到的数据都是相同的)
private static final
AtomicReferenceFieldUpdater bufUpdater =
AtomicReferenceFieldUpdater.newUpdater
(BufferedInputStream.class, byte[].class, "buf");
// 当前缓冲区的有效字节数。
// 注意,这里是指缓冲区的有效字节数,而不是输入流中的有效字节数。
protected int count;
// 当前缓冲区的位置索引
// 注意,这里是指缓冲区的位置索引,而不是输入流中的位置索引。
protected int pos;
// 当前缓冲区的标记位置
// markpos和reset()配合使用才有意义。操作步骤:
// (01) 通过mark() 函数,保存pos的值到markpos中。
// (02) 通过reset() 函数,会将pos的值重置为markpos。接着通过read()读取数据时,就会从mark()保存的位置开始读取。
protected int markpos = -1;
// marklimit是标记的最大值。
// 关于marklimit的原理,我们在后面的fill()函数分析中会详细说明。这对理解BufferedInputStream相当重要。
protected int marklimit;
// 获取输入流
private InputStream getInIfOpen() throws IOException {
InputStream input = in;
if (input == null)
throw new IOException("Stream closed");
return input;
}
// 获取缓冲
private byte[] getBufIfOpen() throws IOException {
byte[] buffer = buf;
if (buffer == null)
throw new IOException("Stream closed");
return buffer;
}
// 构造函数:新建一个缓冲区大小为8192的BufferedInputStream
public BufferedInputStream(InputStream in) {
this(in, defaultBufferSize);
}
// 构造函数:新建指定缓冲区大小的BufferedInputStream
public BufferedInputStream(InputStream in, int size) {
super(in);
if (size <= 0) {
throw new IllegalArgumentException("Buffer size <= 0");
}
buf = new byte[size];
}
// 从“输入流”中读取数据,并填充到缓冲区中,正是这个方法使得数据得到提前缓存
// 至于它的具体代码实现,这里不深究,先把他理解成一个快速获取缓存数据的填充方法
private void fill() throws IOException {
byte[] buffer = getBufIfOpen();
if (markpos < 0)
pos = 0; /* no mark: throw away the buffer */
else if (pos >= buffer.length) /* no room left in buffer */
if (markpos > 0) { /* can throw away early part of the buffer */
int sz = pos - markpos;
System.arraycopy(buffer, markpos, buffer, 0, sz);
pos = sz;
markpos = 0;
} else if (buffer.length >= marklimit) {
markpos = -1; /* buffer got too big, invalidate mark */
pos = 0; /* drop buffer contents */
} else { /* grow buffer */
int nsz = pos * 2;
if (nsz > marklimit)
nsz = marklimit;
byte nbuf[] = new byte[nsz];
System.arraycopy(buffer, 0, nbuf, 0, pos);
if (!bufUpdater.compareAndSet(this, buffer, nbuf)) {
throw new IOException("Stream closed");
}
buffer = nbuf;
}
count = pos;
int n = getInIfOpen().read(buffer, pos, buffer.length - pos);
if (n > 0)
count = n + pos;
}
// 读取下一个字节
public synchronized int read() throws IOException {
// 若已经读完缓冲区中的数据,则调用fill()从输入流读取下一部分数据来填充缓冲区
if (pos >= count) {
fill();
if (pos >= count)
return -1;
}
// 从缓冲区中读取指定的字节
return getBufIfOpen()[pos++] & 0xff;
}
// 将缓冲区中的数据写入到字节数组b中。off是字节数组b的起始位置,len是写入长度
private int read1(byte[] b, int off, int len) throws IOException {
int avail = count - pos;
if (avail <= 0) {
// 加速机制。
// 如果读取的长度大于缓冲区的长度 并且没有markpos,
// 则直接从原始输入流中进行读取,从而避免无谓的COPY(从原始输入流至缓冲区,读取缓冲区全部数据,清空缓冲区,
// 重新填入原始输入流数据)
if (len >= getBufIfOpen().length && markpos < 0) {
return getInIfOpen().read(b, off, len);
}
// 若已经读完缓冲区中的数据,则调用fill()从输入流读取下一部分数据来填充缓冲区
fill();
avail = count - pos;
if (avail <= 0) return -1;
}
int cnt = (avail < len) ? avail : len;
//走到这里,则只是单纯的从缓存里复制数据
System.arraycopy(getBufIfOpen(), pos, b, off, cnt);
pos += cnt;
return cnt;
}
// 将缓冲区中的数据写入到字节数组b中。off是字节数组b的起始位置,len是写入长度
public synchronized int read(byte b[], int off, int len)
throws IOException
{
getBufIfOpen(); // Check for closed stream
if ((off | len | (off + len) | (b.length - (off + len))) < 0) {
throw new IndexOutOfBoundsException();
} else if (len == 0) {
return 0;
}
// 读取到指定长度的数据才返回
int n = 0;
for (;;) {
int nread = read1(b, off + n, len - n);
if (nread <= 0)
return (n == 0) ? nread : n;
n += nread;
if (n >= len)
return n;
// if not closed but no bytes available, return
InputStream input = in;
if (input != null && input.available() <= 0)
return n;
}
}
// 下一个字节是否存可读
public synchronized int available() throws IOException {
int n = count - pos;
int avail = getInIfOpen().available();
return n > (Integer.MAX_VALUE - avail)
? Integer.MAX_VALUE
: n + avail;
}
// 标记“缓冲区”中当前位置。
// readlimit是marklimit,关于marklimit的作用,参考后面的说明。
public synchronized void mark(int readlimit) {
marklimit = readlimit;
markpos = pos;
}
// 将“缓冲区”中当前位置重置到mark()所标记的位置
public synchronized void reset() throws IOException {
getBufIfOpen(); // Cause exception if closed
if (markpos < 0)
throw new IOException("Resetting to invalid mark");
pos = markpos;
}
public boolean markSupported() {
return true;
}
// 关闭输入流
public void close() throws IOException {
byte[] buffer;
while ( (buffer = buf) != null) {
if (bufUpdater.compareAndSet(this, buffer, null)) {
InputStream input = in;
in = null;
if (input != null)
input.close();
return;
}
// Else retry in case a new buf was CASed in fill()
}
}
}
BufferedInputStream 总结:
继承自FilterInputStream ,用到装饰模式,内部维护了一个InputStream ,实际的读取操作还是这个InputStream ,BufferedInputStream 的主要作用是给他提供缓存机制,维护了一个byte[]数组buf,每次读数据的时候先看看要读的数据的大小,大于缓存的大小则没必要从缓存读,直接从输入流读取,小于缓存大小则看看缓存里有没有数据,有的话直接从缓存里复制,没有的话,先把数据从输入流里填充到缓存,然后再从缓存里复制。
public class BufferedOutputStream extends FilterOutputStream {
//存储数据的内部缓冲区,存储"缓冲输入流"数据的字节数组
protected byte buf[];
//缓冲区中的有效字节数,即缓冲区数据的个数
protected int count;
//构造器:创建了一个新的缓存区为默认字节为8192大小的“缓冲输出流”
public BufferedOutputStream(OutputStream out) {
this(out, 8192);
}
//创建一个新的缓冲输出流,大小为size
public BufferedOutputStream(OutputStream out, int size) {
super(out);
if (size <= 0) { //size<=0会报非法参数异常
throw new IllegalArgumentException("Buffer size <= 0");
}
buf = new byte[size];
}
//冲刷缓存区
private void flushBuffer() throws IOException {
if (count > 0) {
out.write(buf, 0, count);
count = 0;
}
}
//将指定的字节写到这个缓冲的输出流
public synchronized void write(int b) throws IOException {
// 若缓冲已满,则先将缓冲数据写入到输出流中。
if (count >= buf.length) {
flushBuffer();
}
// 将“数据b”写入到缓冲中
buf[count++] = (byte)b;
}
//
public synchronized void write(byte b[], int off, int len) throws IOException {
// 若"写入长度len"大于"缓冲区大小",会先进行冲刷,将缓冲中的数据写入到输出流,
// 然后直接将数组b写入到输出流中
if (len >= buf.length) {
flushBuffer();
out.write(b, off, len);
return;
}
//若"写入的长度len"大于"缓冲区剩余的空间",会先将缓冲区进行冲刷,然后将b[]写入缓冲区
if (len > buf.length - count) {
flushBuffer();
}
System.arraycopy(b, off, buf, count, len);
count += len;
}
//冲刷,调用flushBuffer()方法,将“缓冲数据”写入到输出流中,
//flush()方法可以强迫输出流(或缓冲的流)发送数据,即使此时缓冲区还没有填满,以此来打破这种死
//锁的状态。
public synchronized void flush() throws IOException {
flushBuffer();
out.flush();
}
}
BufferedOutputStream 总结:
继承自FilterOutputStream ,也是用到装饰模式,内部维护了一个OutputStream ,实际的写操作还是这个OutputStream ,BufferedOutputStream 的主要作用是给他提供缓存机制,仍然是维护了一个byte[]数组buf,每次写数据的时候先判断数据大小,超过了缓存最大值就会先把缓存区的冲刷一下(先把缓存写到输出流,因为可能有上次写的时候写在了缓存),然后直接写到输出流里,没有超过则会先复制到缓存里,所以写操作最后一定要记得调用flush()一下,否则数据可能没有真实的写到输出流而只是复制到缓存中去了。
现在来分析之前的那个复制的操作:
//读取文件(缓存字节流)
val inPutStream = BufferedInputStream(FileInputStream("1.txt"))
//写入相应的文件
val outPutStream = BufferedOutputStream(FileOutputStream("2.txt"))
//读取数据
//一次性取多少字节
val bytes = ByteArray(2048)
//接受读取的内容(n就代表的相关数据,只不过是数字的形式)
var n = -1
//循环取出数据
while (inPutStream.read(bytes, 0, bytes.size).also { n = it } != -1) {
//写入相关文件
outPutStream.write(bytes, 0, n)
}
//flush缓存
outPutStream.flush()
//关闭流
inPutStream.close()
outPutStream.close()
来看看它的大概过程,首先从BufferedInputStream读数据,里面有一个缓存的操作,读出来后BufferedOutputStream进行写的操作,里面也设计到了一个缓存操作,里面设计到了两轮的复制和两个缓存的维护。
而这个过程对okio来说就显得繁琐了,现在看看okio是怎么做文件复制的:
/**
* 构造带缓冲的输入流
*/
var source = File("1.txt").source()
var bufferedSource = source.buffer()
/**
* 构造带缓冲的输出流
*/
var sink = File("2.txt").sink()
val bufferedSink = sink.buffer()
val bufferSize = 2 * 1024 // 2kb
// 复制文件
while (!bufferedSource.exhausted()) {
// 从输入流读取数据到输出流缓冲,其实这里只是把缓存做了一个复制
bufferedSource.read(bufferedSink.buffer,bufferSize.toLong())
// 到这里,才是真正的输出流缓冲写出
bufferedSink.emit()
}
source.close()
sink.close()
代码是不是简洁很多?再来分析分析okio的结构:
Source和Sink
对应IO中的输入流和输出流,Source的实现类实现需read(Buffer sink, long byteCount) throws IOException; Sink的实现类实现write(Buffer source, long byteCount)。不难猜测,在Okio中以Buffer作为操作媒介,可以发挥它的最大优势。
BufferedSource 和 BufferedSink
对应IO中输入流缓冲和输出流缓冲,提供对外的API进行读写操作。
Okio
入口类,工厂类,提供source方法可以得到一个Source输入流,提供sink方法得到一个Sink输出流。两种方法可接受的入参都可为 File、Socket、InputStream / OutputStream。对每个对应的方法进行查看,Okio并没有改变各种Java 输入输出流的对应装饰对象的构造,在构造上,对于涉及到的上面说到的入参,构造起来比较方便。也能看出,Okio并没有打算改变底层的IO方式,旨在弥补原声IO框架上的不足。
Segment
这一部分开篇已现对Segment进行了介绍。除了介绍都的内容外,Segment可以以单链、双链的方式存储。提供了pop()将自己从链中删除,返回下一节点;push()将一个Segment加在自己后面,这两个对于链表的操作不做深入。既然提供了split()方法进行分割,自然也提供了compact()方法Segment进行合并,前提是用来做合并的Segment的剩余容量装得下,也不做深入。
SegmentPoll
复用Segment,可以理解为一个Segment池,知道作用就行这里不深入了解。
RealBufferedSource,RealBufferdSink
为BufferedSource 和 BufferedSink的实现类,但其实只是个代理类,里面有个成员相应的输入输出流成员变量,它们才是真正做事的,而且都维护了一个buffer,给他们提供缓存操作。
Buffer
Okio使用了Segment作为数据存储的方式,自然要提供对应的缓冲方式来操作Segment,Segment在Buffer中以双向链表形式存在。Buffer则负责此项事务。Buffer也实现了BufferedSource和BufferedSink,这是因在使用Okio提供的输入/输出缓冲时,都需要进行缓冲处理,均由Buffer来处理,这样使API对应。
TimeOut
提供超时功能,希望IO能在一定时间内进行完毕,否则视为异常。分两种情况,同步超时和异步超时。这里不做重点讲解。
同步超时:在每次读写中判断超时条件,因为处于同步方法,因此当IO发生阻塞时,不能及时响应。
异步超时:用单独的线程监控超时条件,如果IO发生阻塞,并且检测到超时,抛出IO异常,阻塞终止。
现在来分析分析RealBufferedSource的read源码,也就是上面的那个复制过程中的最重要的那行:
bufferedSource.read(bufferedSink.buffer,bufferSize.toLong()) //RealBufferedSource的方法
@Override
public long read(Buffer sink, long byteCount) throws IOException {
// 用来接收数据的Buffer 不能为空
if (sink == null) throw new IllegalArgumentException("sink == null");
// 读取数据不能为负数
if (byteCount < 0) throw new IllegalArgumentException("byteCount < 0: " + byteCount);
if (closed) throw new IllegalStateException("closed");
// 缓冲区没有数据了
if (buffer.size == 0) {
// 从输入流中读取数据,重点关注这个方法
long read = source.read(buffer, Segment.SIZE);
if (read == -1) return -1;
}
// 比较 byteCount 与 缓冲中的数据容量,得到到实际要读取的数据量
long toRead = Math.min(byteCount, buffer.size);
// 从Buffer 中读取数据到另一个buffer里的缓存
return buffer.read(sink, toRead);
}
与Java原生的缓冲方式类似,都先考虑缓冲区中的数据情况,如果缓冲区中没有数据,则先向流读取数据填充缓冲区,再根据所需读取容量与实际缓冲区中存有的数据容量进行读取。这里有一点和Java原生的不同,如果byteCount的数据超出Segment的容量的话,不会直接向流读取。可以看出Okio非常希望以Segment为单位来对流数据进行操作,看接收byte[]为参数的read()的重载方法也接受这个规则。
再看source.read(buffer, Segment.SIZE)。 source是通过之前File("1.txt").source()得来的,它其实就是一个实现了Source的子类:
// 匿名内部类Source
return new Source() {
@Override public long read(Buffer sink, long byteCount) throws IOException {
// 获取的byteCount不能为负数
if (byteCount < 0) throw new IllegalArgumentException("byteCount < 0: " + byteCount);
if (byteCount == 0) return 0;
try {
// 检查是否超时
timeout.throwIfReached();
// 获取尾节点的Segment,尾节点不满足填充新数据条件则拿到新的Segment
// 也是位于尾节点
Segment tail = sink.writableSegment(1);
// 实际从in读取的数据,能看出最大不能超过 Segment.SIZE
int maxToCopy = (int) Math.min(byteCount, Segment.SIZE - tail.limit);
// 从in中读取数据到Segment.data,这个in就是原生的Inputstream,就是File的输入流
int bytesRead = in.read(tail.data, tail.limit, maxToCopy);
// 这里说明没有读到有效数据
if (bytesRead == -1) return -1;
// 更新索引位置
tail.limit += bytesRead;
// 更新buffer容量
sink.size += bytesRead;
return bytesRead;
} catch (AssertionError e) {
if (isAndroidGetsocknameError(e)) throw new IOException(e);
throw e;
}
}
......
};
从输入流中读取数据,数据存于Buffer中,位于尾节点的Segment,与前面说的一样,单次向流读出操作,大小不能超过Segment.SIZE。
回到buffer.read(sink, toRead);,在确认缓冲区有数据之后,从缓冲区中读取数据到sink,即从Buffer中读取数据到另一Buffer.
@Override public long read(Buffer sink, long byteCount) {
// 用来接收数据的sink不能为空
if (sink == null) throw new IllegalArgumentException("sink == null");
// 接收的数据大小不能为负数
if (byteCount < 0) throw new IllegalArgumentException("byteCount < 0: " + byteCount);
if (size == 0) return -1L;
// 读取的数据不超过当前缓冲区的容量
if (byteCount > size) byteCount = size;
// 从当前缓冲区,将数据写入到另一缓冲区,即从 this,写到sink
sink.write(this, byteCount);
return byteCount;
}
@Override public void write(Buffer source, long byteCount) {
if (source == null) throw new IllegalArgumentException("source == null");
if (source == this) throw new IllegalArgumentException("source == this");
checkOffsetAndCount(source.size, 0, byteCount);
/**
缓冲区数据从 缓冲区source 移动到 缓冲区this,
在当前的案例中,缓冲区source代表输入流缓冲数据,缓冲区this代表输出流缓冲数据
此函数源码内部有一大段注释,可以细细品味,我就不贴了
*/
while (byteCount > 0) {
if (byteCount < (source.head.limit - source.head.pos)) {
// 进到这里说明,说明source的头节点有足够的数据
// 获取当前缓冲区尾节点
Segment tail = head != null ? head.prev : null;
if (tail != null && tail.owner
&& (byteCount + tail.limit - (tail.shared ? 0 : tail.pos) <= Segment.SIZE)) {
// 尾节点不为空,并且尾可解是owner状态
// 并且尾节点能够装下byteCount数量的数据
// 将数据从source的头节点 copy 到 当前缓冲区的尾节点
source.head.writeTo(tail, (int) byteCount);
source.size -= byteCount;
size += byteCount;
return;
} else {
// 说明数据当前缓冲区尾节点不能存下byteCount大小的数据
// 将source头节点的Segment分割,byteCount过阈值则共享,否则拷贝
// 共享过程则不用copy
source.head = source.head.split((int) byteCount);
}
}
/**
将source缓冲区的头节点pop,加入到当前缓冲区
*/
Segment segmentToMove = source.head;
long movedByteCount = segmentToMove.limit - segmentToMove.pos;
source.head = segmentToMove.pop();
if (head == null) {
// 进到这里说明当前缓冲区没有数据,将segmentToMove作为头节点
head = segmentToMove;
head.next = head.prev = head;
} else {
// 进到这里则是将segmentToMove放到链表尾部
Segment tail = head.prev;
tail = tail.push(segmentToMove);
tail.compact();
}
/**
更新缓冲区大小,以及还需的byteCount数量
*/
source.size -= movedByteCount;
size += movedByteCount;
byteCount -= movedByteCount;
}
}
Okio最亮眼的操作,就是设计出了Segment存储数据,通过Buffer进行缓冲管理,并在Buffer.write()则里,通过移动引用而不是真实数据,是减少数据copy进而交换数据的关键。
上面分析了RealBufferedSource,而RealBufferedSink也是同样的道理,只是方向相反,缓冲数据存储依然离不开Segment和Buffer,RealBufferedSink先把数据copy到buffer里的Segment,在通过成员sink的write()操作把buffer里的缓存写出,可以通过Okio.sink()拿到的匿名内部类Sink()查看:
/** Returns a sink that writes to {@code out}. */
public static Sink sink(OutputStream out) {
return sink(out, new Timeout());
}
private static Sink sink(final OutputStream out, final Timeout timeout) {
if (out == null) throw new IllegalArgumentException("out == null");
if (timeout == null) throw new IllegalArgumentException("timeout == null");
return new Sink() {
@Override public void write(Buffer source, long byteCount) throws IOException {
checkOffsetAndCount(source.size, 0, byteCount);
while (byteCount > 0) {
timeout.throwIfReached();
Segment head = source.head;
int toCopy = (int) Math.min(byteCount, head.limit - head.pos);
//原生的io操作
out.write(head.data, head.pos, toCopy);
head.pos += toCopy;
byteCount -= toCopy;
source.size -= toCopy;
if (head.pos == head.limit) {
source.head = head.pop();
SegmentPool.recycle(head);
}
}
}
@Override public void flush() throws IOException {
out.flush();
}
@Override public void close() throws IOException {
out.close();
}
@Override public Timeout timeout() {
return timeout;
}
@Override public String toString() {
return "sink(" + out + ")";
}
};
}
想较于Java原生IO的缓冲方案,双流操中,或者说以Buffer来代替 写入/写出 的 byte[],减少了copy的过程,通过Segment的移动达到目的。
此外,Okio的写入/写出操作,也可以像原生那样,接受byte[]参数,或者直接获取下一个数据,这种情况时,则于原生相似,需要时一样依赖copy,不再有减少copy的优势。并且,Okio接口也更友好,如之前说原生实现向文件写入自定义数据时,需要Data的流类型进行转译,自身就封装了这样的操作。
总结
Okio核心竞争力为,增强了流于流之间的互动,使得当数据从一个缓冲区移动到另一个缓冲区时,可以不经过copy能达到,而只是通过引用来直接分享:
以Segment作为存储结构,真实数据以类型为byte[]的成员变量data存在,并用其它变量标记数据状态,在需要时,如果可以,移动Segment引用,而非copy data数据
Segment在Segment线程池中以单链表存在以便复用,在Buffer中以双向链表存在存储数据,head指向头部,是最老的数据,通过head能找到所有的segement。
Segment能通过slipt()进行分割,可实现数据共享,能通过compact()进行合并。由Buffer来进行数据调度,基本遵守 “大块数据移动引用,小块数据进行copy” 的思想
Source 对应输入流,Sink 对应输出流
TimeOut 以达到在期望时间内完成IO操作的目的,同步超时在每次IO操作中检查耗时,异步超时开启另一线程间隔时间检查耗时
Okio并没有打算优化底层IO方式以及替代原生IO方式,Okio优化了缓冲策略以减轻内存压力和性能消耗,并且对于部分IO场景,提供了更友好的API,而更多的IO场景,该记的还得记。