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

相關文章
相關標籤/搜索