1. 程式人生 > >高效易用的okio(三)

高效易用的okio(三)

在上篇 高效易用的 okio(二) 的結尾提到了 Segment ,這是一個記憶體緩衝區, IO 讀寫操作能如此高效,都是通過這個 Segement

Segement 本質是一個位元組陣列,同時也是一個迴圈雙向連結串列,同時為了提高效率,okio 還提供了一個 SegmentPool 用於儲存空閒狀態的 Segment 用於複用,它們構成了 okio 高效的記憶體使用政策,它們的關係如下圖:

在這裡插入圖片描述

SegmentPool

SegmentPool 程式碼量不多,它負責管理處於空閒狀態的 Segment (迴圈雙向連結串列),SegmentPool 最大容量是64KB,而一個 Segment

佔用 8KB,因此最多儲存 8 個空閒的 Segment,它的核心只有兩個,用於新增和回收 Segment

/**
 * 當池子裡面有空閒的 Segment 就直接複用,否則就建立一個新的 Segment
 */
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(); } /** * 回收 segment 進行復用,提高效率 */ static void recycle(Segment segment) { if (segment.next != null || segment.prev != null) throw new IllegalArgumentException(); // 共享的 segment 不能被回收 if (segment.shared) return
; synchronized (SegmentPool.class) { // 池子滿了,無法不能回收新的 segment if (byteCount + Segment.SIZE > MAX_SIZE) return; byteCount += Segment.SIZE; segment.next = next; segment.pos = segment.limit = 0; next = segment; } }

從這裡就可以看出 okio 的一個記憶體策略,就是在不影響記憶體效率的情況下,儘可能去重複使用現有的資源,這個策略用的好,帶來的收益是很可觀的

Segment

現在來看這個 Segment,可以說這個是最核心的底層結構了,我們先來看下它的一些引數和構造方法:

    /**
     * segment 中位元組陣列的大小
     */
    static final int SIZE = 8192;

    /**
     * segment 拆分時,當資料量少於這個值時不做資料共享
     */
    static final int SHARE_MINIMUM = 1024;

    /**
     * segment 中實際存放資料的地方
     */
    final byte[] data;

    /**
     * data陣列中下一個讀取的資料的位置,也就是一個數據讀取的位置index
     */
    int pos;

    /**
     * data陣列中下一個寫入的資料的位置,也就是一個數據寫入的位置index
     */
    int limit;

    /**
     * 為 true 表示有其他 segment 和當前 segment 共享 data 陣列
     */
    boolean shared;

    /**
     * 為 true 時表示當前 segment 是 data 陣列的實際擁有者,並能夠進行資料的寫入
     */
    boolean owner;

    /**
     * 當前節點在雙向連結串列中的指向的下一個 Segment
     */
    Segment next;

    /**
     * 當前節點在雙向連結串列中的指向的前一個 Segment
     */
    Segment prev;

    /**
     * 建立一個新的 Segment
     */
    Segment() {
        this.data = new byte[SIZE];
        this.owner = true;
        this.shared = false;
    }

    /**
     * 建立一個共享 data陣列 的 Segment 或克隆一個 Segment
     */
    Segment(byte[] data, int pos, int limit, boolean shared, boolean owner) {
        this.data = data;
        this.pos = pos;
        this.limit = limit;
        this.shared = shared;
        this.owner = owner;
    }

上面有幾個重要的概念:

  • SIZE: 這個說明了一個 Segment 佔據的位元組大小

  • data :顯而易見,這個就是 Segment 儲存的資料

  • shared :它用於標明當前 Segment 是否一個共享 data 陣列 的 Segment

  • owner:它用於區分當前 Segment 是否它擁有的 data 陣列 的實際擁有者 ,一份 data 陣列只能有一個擁有者,其他的都是使用者 ;只有 data 陣列 的實際擁有者才可以對 data 陣列 進行寫入操作,其他 Segment 僅可以對這份 data 陣列進行讀取操作而已

  • next :當前節點在雙向連結串列中的指向的下一個 Segment

  • prev : 當前節點在雙向連結串列中的指向的前一個 Segment

既然 Segment 是連結串列,那麼它自然是有相關的連結串列操作的:

 /**
  * 當前節點的位置插入一個新的 Segment
  */
public Segment push(Segment segment) {
        segment.prev = this;
        segment.next = next;
        next.prev = segment;
        next = segment;
        return segment;
}

   /**
     * 在雙向迴圈連結串列中刪除當前節點並返回當前節點的next節點
     * 操作完成後,next節點去到了當前節點的位置
     */
    public @Nullable
    Segment pop() {
        Segment result = next != this ? next : null;
        prev.next = next;
        next.prev = prev;
        next = null;
        prev = null;
        return result;
    }

這裡都是較為簡單的資料結構的操作,下面我們來寫 Segment 的核心操作:合併與拆分

Segment 的合併

現在我們知道每個 Segment 佔據著 8KB 的空間,為了達到高效利用的目標, okio 會合並一些鄰近的 Segment ,具體怎麼做,我們看下圖:

在這裡插入圖片描述

上面簡單說明了 okio 的合併操作,也就是鄰近壓縮的方法:當鄰近的資料合併起來並沒有超出大小(8192位元組)的限制,就把它們合併起來,並回收一個空閒的 Segment ,供下次使用

具體操作的方法,我們還是看程式碼吧:

   /**
     * 合併鄰近的 Segment 並回收
     */
    public void compact() {
        // 前一個 segment 不能跟當前 segment 是同一個 segment
        if (prev == this) throw new IllegalStateException();
        // 前一個 segment 必須是data位元組陣列的擁有者,這樣才能寫入資料
        if (!prev.owner) return;
        // 計算當前 segment 還未讀取的資料位元組數
        int byteCount = limit - pos;
        // 計算前一個 segment 剩餘可用空間
        int availableByteCount = SIZE - prev.limit + (prev.shared ? 0 : prev.pos);
        // 只有當前一個 segment 剩餘空間大於等於當前 segment 未讀取的資料大小時,才能寫入
        if (byteCount > availableByteCount) return; 
        // 將當前segment的資料合併到前一個segment中
        writeTo(prev, byteCount);
        // 將當前 segment 從連結串列中移除
        pop();
        // 將當前 segment 在 segment 池中進行資源回收
        SegmentPool.recycle(this);
    }

這裡可以看出,合併的核心程式碼在於 writeTo 方法,前面都是一些判斷操作,接著來看下 writeTo 方法好了:

   /**
     * 進行資料合併的寫入操作
     *
     * @param sink      前一個 Segment , 也就是資料寫入的目標
     * @param byteCount 寫入的資料的位元組數
     */
    public void writeTo(Segment sink, int byteCount) {
        // 前一個 Segment 必須是data位元組陣列的擁有者
        if (!sink.owner) throw new IllegalArgumentException();
        // 大塊的空間不夠,進行騰挪操作,也就是覆蓋部分用於讀取的資料
        if (sink.limit + byteCount > SIZE) {
            //共享 Segment 無法進行寫入操作
            if (sink.shared) throw new IllegalArgumentException();
            //如果當前寫入位置和寫入資料的位元組數減去當前可讀的位元組數還是超出了尺寸大小限制
            if (sink.limit + byteCount - sink.pos > SIZE) throw new IllegalArgumentException();
            System.arraycopy(sink.data, sink.pos, sink.data, 0, sink.limit - sink.pos);
            sink.limit -= sink.pos;
            sink.pos = 0;
        }
        // 有足夠的空間,直接進行記憶體拷貝操作
        System.arraycopy(data, pos, sink.data, sink.limit, byteCount);
        sink.limit += byteCount;
        pos += byteCount;
    }

程式碼並不複雜,具體看註釋就好了,上面重新提到了一個概念:共享

要理解什麼是 Segment 的共享,那麼就要知道 Segment 的拆分了

Segment 的拆分

為了高效的利用記憶體, okio 不僅會合並壓縮鄰近的 Segment ,還會拆分 Segment

什麼情況下會拆分 Segment 呢?具體看下下面的程式碼:

	@Override
	public void write(Buffer source, long byteCount) {
        ........
        while (byteCount > 0) { 
          //判斷是否調整 Segment   
          if (byteCount < (source.head.limit - source.head.pos)) {
             if (tail != null && tail.owner
                 && (byteCount + tail.limit - (tail.shared ? 0 : tail.pos) <= Segment.SIZE)) {
                       //Segment 空間充足,直接寫入資料
                        source.head.writeTo(tail, (int) byteCount);
                        source.size -= byteCount;
                        size += byteCount;
                        return;
                    } else {
                        //Segment 空間不足,拆分為兩個 Segment,再寫入資料
                        source.head = source.head.split((int) byteCount);
                    }
               }     
        ........
        }     
	}

okio 會在單個 Segment 空間不足以儲存寫入的資料時,就會嘗試拆分為 兩個 Segment

拆分的程式碼如下:

public Segment split(int byteCount) {
        // 引數範圍校驗
        if (byteCount <= 0 || byteCount > limit - pos) throw new IllegalArgumentException();
        // 拆分出來的新 Segment
        Segment prefix;

        //只有資料大於SHARE_MINIMUM才考慮使用共享
        if (byteCount >= SHARE_MINIMUM) {
            // 共享 Segment
            prefix = sharedCopy();
        } else {
            // 非共享 Segment 的邏輯
            prefix = SegmentPool.take();
            // 直接分為兩個資料一樣的 Segment,但是是兩個 data 陣列了
            //和直接得到一個全新的 Segment 物件沒區別
            System.arraycopy(data, pos, prefix.data, 0, byteCount);
        }
        //設定新 Segment 寫入資料的 index
        prefix.limit = prefix.pos + byteCount;
        //設定當前節點已經讀取到的 index
        pos += byteCount;
        //插入新的 Segment
        prev.push(prefix);
        return prefix;
    }

    /**
     * 返回一個與此共享 data陣列 的 Segment
     * 用於共享資料的 Segment 無法寫入資料和合並
     */
    Segment sharedCopy() {
        shared = true;
        return new Segment(data, pos, limit, true, false);
    }

可以看到,拆分走了兩個邏輯,如果走的是共享 Segment 的邏輯,那麼結果如下圖:
在這裡插入圖片描述

最終目標還是多出一個用於寫入資料的空間,以便達到更好的記憶體使用效果

如果走的不是共享 Segment 的邏輯,那麼結果如下圖:

在這裡插入圖片描述

結語

到此相大家已經充分理解了 okioSegment 了,可以看到為了達到最好的記憶體優化效果, okio 下了非常大的力氣, 希望大家在日常開發中,能把 okio 用起來,它遠比 Java 的原生 IO 更加好用

下一篇 okio 的文章就輪到 okiotimeOut 機制了,敬請期待