OK, IO

前文 一些「流与管道」的小事 - Gemini’s Story 介绍了一些关于流的概念问题,今天咱们来看看一个也许是 Android 程序员很是熟悉的库 —— okiojava

什么是 okio

先看一段官方简介:程序员

Okio is a library that complements java.io and java.nio to make it much easier to access, store, and process your data.
它的定位是对 java.io 和 java.nio 包作了一个补足,使得开发者能更轻松地处理数据。

咱们在操做 InputStream 和 OutputStream 的时候,若是想自动地把字节流里的数据转成 java 基础类型的话,没有特别轻松的方式办到; 若是想对 java 中的流作一些基于流加密或者压缩的操做的时候,就要引用一些第三方的库了; 若是咱们还想对流的写入和读取作一些基于 CPU 和内存上的优化的话,就只能本身去折腾了。算法

okio 为咱们提供了一整套的解决方案,能够很方便的作以上的工做。为此它提供了不少工具类 —— Sink/Source/Buffer/ByteString/Segment 以及他们的衍伸子类等等。数组

Sink and Source

SinkSource 分别是用来替代OutputStreamInputStream类的。同时,把操做的对象从 byte[]变成了Buffer,咱们可使用Okio这个类的一些静态方法,把 OutputStream/InputStream 转成 SinkSource,可是直接操做Buffer对于咱们想要写入一些基本数据类型数据的开发者来讲仍是成本过高。所以咱们可使用 Okio.buffer 这个方法把 SinkSource buffer 化(本质上是为咱们的输入输出增长一个缓冲区,有了缓冲区,咱们才能缓冲一部分字节,转成咱们要的一些数据,如 int/long/String 等等)。缓存

BufferedSource source = Okio.buffer(Okio.source(inputStream));

转成 BufferedSource/BufferedSink后,咱们可使用 readInt或者writeInt等简便方法进行对数据的读写了。性能优化

ByteString

okio 提供一个工具类,方便把字节数组转成 String 存储,这个类就是ByteString。使用ByteString.of(byte[])来构造它。那么它能够拿来作什么呢?微信

ByteString

它提供了把一串字节转成md5/base64/hmacSha1等等的功能,还可使用indexOfsubstring等方法构造出一个新的ByteString,就像String那样,同时BufferedSinkBufferedSource也是支持直接操做ByteString的,这使咱们对须要写入预先转换的数据变得很是方便。app

装饰器模式的衣钵

咱们都知道InputStreamOutputStream在 jdk 中有不少装饰器,那么 okio 也提供了两个装饰器。有Gzip/Hashing两个装饰器,故名思义,一个提供了 GZIP的功能,一个提供了简单 hash 功能,在构造方法里能写明 hash 算法,就能对流进行 hash。咱们能够添加本身的装饰器,只要继承FowardingSinkFowardingSource便可。函数

它实现了GzipSinkHashingSink等类并非简单的为后来者的继承举一个例子,而是同时也作了一些 CPU 和内存方面的优化,比GzipInputStream等实现效率要提高很多,接下来咱们就讲讲 okio 中的这块优化工具

CPU 和内存优化

okio 若是只是提供几个变体 API,那么未免写这个库的代价过高了,它最重要的事情,是对 InputStream 和 OutputStream 的交互和缓冲区作了一些性能优化的封装。咱们熟悉一个对字节数组操做的 ByteArrayInputStream 和 ByteArrayOutputStream,咱们能够先看看 ByteArrayOutputStream 的实现:

ByteArrayOutputStream

能够看见,ByteArrayOutputStream 对数据的写入是直接使用 System.arraycopy 的方式直接把外部数据写入到本地数据里的,它只保证了本地数据足够大就行。okio 使用了一个类叫 Segment,牺牲的了部分随机读写的性能来得到。若是你使用流式处理数据的话,你不多有随机读写的需求,所以这种收益是很是可观的。首先咱们看一下它对于策略介绍的注释源码:

// Move bytes from the head of the source buffer to the tail of this buffer
    // while balancing two conflicting goals: don't waste CPU and don't waste
    // memory.
    //
    //
    // Don't waste CPU (ie. don't copy data around).
    //
    // Copying large amounts of data is expensive. Instead, we prefer to
    // reassign entire segments from one buffer to the other.
    //
    //
    // Don't waste memory.
    //
    // As an invariant, adjacent pairs of segments in a buffer should be at
    // least 50% full, except for the head segment and the tail segment.
    //
    // The head segment cannot maintain the invariant because the application is
    // consuming bytes from this segment, decreasing its level.
    //
    // The tail segment cannot maintain the invariant because the application is
    // producing bytes, which may require new nearly-empty tail segments to be
    // appended.
    //
    //
    // Moving segments between buffers
    //
    // When writing one buffer to another, we prefer to reassign entire segments
    // over copying bytes into their most compact form. Suppose we have a buffer
    // with these segment levels [91%, 61%]. If we append a buffer with a
    // single [72%] segment, that yields [91%, 61%, 72%]. No bytes are copied.
    //
    // Or suppose we have a buffer with these segment levels: [100%, 2%], and we
    // want to append it to a buffer with these segment levels [99%, 3%]. This
    // operation will yield the following segments: [100%, 2%, 99%, 3%]. That
    // is, we do not spend time copying bytes around to achieve more efficient
    // memory use like [100%, 100%, 4%].
    //
    // When combining buffers, we will compact adjacent buffers when their
    // combined level doesn't exceed 100%. For example, when we start with
    // [100%, 40%] and append [30%, 80%], the result is [100%, 70%, 80%].
    //
    //
    // Splitting segments
    //
    // Occasionally we write only part of a source buffer to a sink buffer. For
    // example, given a sink [51%, 91%], we may want to write the first 30% of
    // a source [92%, 82%] to it. To simplify, we first transform the source to
    // an equivalent buffer [30%, 62%, 82%] and then move the head segment,
    // yielding sink [51%, 91%, 30%] and source [62%, 82%].

这段注释在 Buffer 这个类 write 函数下面,大约在 1223 行的位置。它把基本原则说的很清楚了,他要平衡两个冲突的目标:不浪费 CPU 和 不浪费内存。那么平衡 CPU 的方式是,尽可能减小使用System.arraycopy,取而代之让 Segement 从一个 Buffer 移动到另一个 Buffer 的方式达到目的。一个 Segment 自己管理了最多达 8k 字节的数据缓冲区,多个 Segment 经过环形链表的方式组织起来,若是从 A Buffer 复制到 B Buffer,合适的状况下,咱们只须要把 A Buffer 中的 Segment 脱离出它原先的环形链表而后插入到 B Buffer 的环形链表中便可,这样就省去了一次大内存拷贝,若是字节足够多,这个收益很是可观。

内存拷贝策略

每一个 Segment 管理了 8k 左右的缓冲区,若是 Segment 过多,负载不高的话(即内存使用率低)会形成内存的浪费,为了解决这个问题,Buffer 里面对于 Segment 作了一些分类, Segment 被分红两种类型:可写和只读,若是可变的话意味着内部的字节可能会用 System.arraycopy 进行写入; 不可变意味着它只读,在 Buffer 间的拷贝只能经过从新 assign 的方式从一个 Buffer 移动到另一个 Buffer 里。如何肯定一个 Segment 是变量仍是不可变量呢?它的策略以下:

  1. 非头尾节点且 Segment 内部缓冲区利用率到达 50% 以上,或者不是内部缓冲区的 owner,那么这个 Segment 必定是只读的
  2. 首先,这个 Segment 必定是内部缓冲区的 owner,若是它是头尾节点且内部缓冲区利用率不足 50%,那么这个 Segment 就是可写的。

基于以上对于 Segment 类别的划分,咱们在移动 Segement 上的策略也不太同样,咱们能够来解释下注释里的几个例子:

例子1:
若是要往一个 Buffer 写入数据, 它包含两个 Segment,使用率为 [91%, 61%],为了简单化,直接表示为 [91%, 61%]。这时被写入的buffer为 [72%],那么咱们会把这个 Segment 从它原先的链表中移除,直接加入到写入的 Buffer 中。这个过程内存拷贝的操做为0。

Move Segment1

例子 2:
往一个 Buffer [100%, 2%] 中写入另外一个 Buffer [99%, 3%],那么先使用第一步的操做,使得内存结构为 [100%, 2%, 99%,],这时候若是把 99% 的 Segment 拷贝到 2% 上,会致使其负荷超过 100%,所以 okio 就不在拷贝内存上花费过多的时间,这时候把 [3%] 再移动一下,使得 Segement 序列变成 [100%, 2%, 99%, 3%]

例子 3:
往一个 Buffer [100%, 40%] 中写入另外一个 Buffer [30%, 80%],那么会先把 30% 的那部分移动到前面一个 Buffer 中,变成 [100%, 40%, 30%],而后作归集操做的时候,发现 30% 部分的 Segment 能够合并到前面的 40% 中,内存分布会变成 [100%, 70%],再写入 80% 的数据的时候,由于 80% + 70% > 100%,所以再也不作归集,最终的内存分布就是 [100%, 70%, 80%]。

例子 4:
若是咱们的须要移动的数据少于第一个 Segment 拥有的数据的话,咱们首先会作一个分割操做 (split),在头部分出一个新的 Segment,而后再移动到目标 Segment 中,而后作归集操做。

使用这样的策略方式,在时间和空间的优化点上找到了合理的最优解。
除这几个策略意外,咱们的 Segment 里面记录了是否底层共享内存缓存的标志位(shared),和对内存的控制权标志位 (owner)。

final class Segment {
  final byte[] data;

  /** The next byte of application data byte to read in this segment. */
  int pos;

  /** The first byte of available data ready to be written to. */
  int limit;

  /** True if other segments or byte strings use the same byte array. */
  boolean shared;

  /** True if this segment owns the byte array and can append to it, extending {@code limit}. */
  boolean owner;

  /** Next segment in a linked or circularly-linked list. */
  Segment next;

  /** Previous segment in a circularly-linked list. */
  Segment prev;

    ...
}

由于咱们在进行大内存的拷贝时,是使用浅拷贝的方式,这种拷贝方式并无拷贝底层的数据,只是新生成一个 Segment 对象,这个 Segment 对象是只读的。目标 若是得到了对该 Segment 的引用,就能够直接读取它内部的数据。

共享内存和读写权限

咱们能够在源码 (Segment.java) 中找到两个拷贝函数shadowCopyunshadowCopy,分别是浅拷贝和深拷贝,浅拷贝出来的 Segment ,由于共享了一块内存,因此它是只读的。可是读游标 (pos) 的变化没有影响,能够独立。同时原来的 Segment 会被标记为 shared。
浅拷贝和深拷贝对于底层数据的读写也有几条策略,这里的策略以下,第一条是最简单的策略:

若是是一个只读的 Segment,那么它是不能进行写操做的。
第二条是调用了浅拷贝以后的原 Segment,它是可写的,如今又变成共享的
若是是一个可写的共享 Segment (shared == true && owner == true),那么它能够追加数据,可是不能覆盖游标 pos 之前的数据,具体详情能够看图

Shared and Owner
其中,蓝色为只读部分,红色为可写部分。

若是是一个独立的 Segment (shared == false && owner == true),那么能够对它进行追加数据,同时由于已经有 pos 来指定已读部分,所以已读部分以前的数据可被覆盖。

Owner but not Shared

Segment 池

经过查看源码,咱们知道,每一个 Segment 在构造的时候,会分配 8k 的内存

Segment() {
    this.data = new byte[SIZE];
    this.owner = true;
    this.shared = false;
  }

分配内存的动做很是消耗 CPU,所以,咱们应该把已经分配的内存管理起来。SegmentPool这个类是一个灵活的内存池,代码不多,咱们能够贴上来看一下。

/**
 * A collection of unused segments, necessary to avoid GC churn and zero-fill.
 * This pool is a thread-safe static singleton.
 */
final class SegmentPool {
  /** The maximum number of bytes to pool. */
  // TODO: Is 64 KiB a good maximum size? Do we ever have that many idle segments?
  static final long MAX_SIZE = 64 * 1024; // 64 KiB.

  /** Singly-linked list of segments. */
  static @Nullable Segment next;

  /** Total bytes in this pool. */
  static long byteCount;

  private SegmentPool() {
  }

  static Segment take() {
    synchronized (SegmentPool.class) {
      if (next != null) {
        Segment result = next;
        next = result.next;
        result.next = null;
        byteCount -= Segment.SIZE;
        return result;
      }
    }
    return new Segment(); // Pool is empty. Don't zero-fill while holding a lock.
  }

  static void recycle(Segment segment) {
    if (segment.next != null || segment.prev != null) throw new IllegalArgumentException();
    if (segment.shared) return; // This segment cannot be recycled.
    synchronized (SegmentPool.class) {
      if (byteCount + Segment.SIZE > MAX_SIZE) return; // Pool is full.
      byteCount += Segment.SIZE;
      segment.next = next;
      segment.pos = segment.limit = 0;
      next = segment;
    }
  }
}

若是池子里面缓存的内存足够大(64KB)就不会继续缓存了,注意,池子里的内存所有都是空闲内存 (idle)

随机读写

咱们花了很大篇幅讲了 okio 的内存和 CPU 优化的策略,以前说了,这是牺牲了随机读写性能来达到目标的,那么随机读写有那些功能呢?咱们能够在 Buffer 类中找到答案。
indexOf Buffer

Buffer 类中有几个 indexOf 开头的方法,故名思义,是查询某个字节(或者某段字节)在这个 Buffer 中的索引的。由于使用了 Segment 来管理缓冲区,所以每次的随机读写都要进行内存寻址,重复劳动不少,效率也不是特别高,各位若是有兴趣的话能够自行查阅。

总结

okio 为咱们提供了方便访问流的 API 接口同时也为咱们对内存的读写作了许多的优化,可是开发者时常不知道这个库诞生的目的是什么,但愿经过本文的介绍,开发者能明白 jakewharton 的初衷,好好地利用好这个库。

最后,欢迎关注微信公众号「TalkWithMobile」
TalkWithMobile

相关文章
相关标签/搜索