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

什么是 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的,这使我们对需要写入预先转换的数据变得非常方便。

装饰器模式的衣钵

我们都知道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

标签: Android

仅有一条评论

  1. 开发者头条 开发者头条

    感谢分享!已推荐到《开发者头条》:https://toutiao.io/posts/7xwx1s 欢迎点赞支持!
    使用开发者头条 App 搜索 8242 即可订阅《TalkWithMobile》

添加新评论