源碼分析: PipedInputStream和PipedOutputStream

場景

假設我們需要上傳一組動態增加的數據, 輸入端可以看作inputSteam, 輸入端是outputSteam, 但是輸入和輸出端不能直接對接, 那么我們要怎樣實現呢?

我希望的解決方案時, 輸入和輸出通過一個"數據池"間接連接, 輸入端把數據寫到數據池中, 輸出端從數據池中讀數據, 這里要求數據池有"阻塞"功能, 即數據池滿了阻塞輸入端, 數據池空了, 阻塞輸出端.

以上效果可以使用PipedInputStreamPipedOutputStream實現.

前言

  1. 這兩個類需要配套使用, 可以實現管道(pipe)傳輸數據
  2. 默認的使用方式是, 通過管道連接線程A和B, 在A線程使用PipedOutputStream寫數據, 數據緩存到"管道"后, B線程使用PipedInputStream讀取數據, 以此完成數據傳輸, 如果在同一個線程使用這兩個類, 可能導致死鎖

PipedOutputStream

PipedOutputStream是管道的發送端. 寫線程通過它來往"管道"填充數據.

我們先看看它有哪幾個方法, 從命名和注釋基本就能知道每個方法的作用

// 關聯PipedInputStream
public void connect(PipedInputStream snk)

// 寫一個數據
public void write(int b)

// 寫一段數據
public void write(byte b[], int off, int len)

// 通知讀線程, 管道中有數據等待讀取
public void flush()

// 關閉發送端, 不再發送數據
public void close()

以上注釋已經大致說明了這個類的功能了, 接著我們逐個方法分析

connect

public synchronized void connect(PipedInputStream snk) throws IOException {
    // 先確保
    // 1. 連接對象(輸入的snk)不能為空
    // 2. 不能重復連接
    sink = snk;
    snk.in = -1;
    snk.out = 0;
    snk.connected = true;
}

從上可以看出, connect方法就是修改連接的PipedInputStream的成員變量, 使其處于已連接狀態.

write

public void write(int b)  throws IOException {
    // 確保sink不為空, 即確保已經連接
    sink.receive(b);
}

public void write(byte b[], int off, int len) throws IOException {
    // 先確保
    // 1. 已經連接
    // 2. 輸出數組b不為空
    // 3. off和len不會導致數組越界
    if (sink == null) {
        // ...
    } else if (len == 0) {
        // 如果len == 0, 表示不讀取數據, 所以可以直接返回
        return;
    }
        
    sink.receive(b, off, len);
}

從上可以看出, 兩個write方法, 最后都調用了響應的PipedInputStream#receive方法, 這表明

數據存儲的地方寫數據的具體邏輯都在PipedInputStream

后面我們再詳細分析.

flush

public synchronized void flush() throws IOException {
    if (sink != null) {
        synchronized (sink) {
            sink.notifyAll();
        }
    }
}

這個方法先嘗試獲取sink的鎖, 然后通過notifyAll()來調度線程, 在這里, 具體就是使讀線程開始讀取數據, 這里涉及讀寫線程間的溝通調度問題, 在了解完PipedInputStream之后我們再重新看這個問題.

close

public void close() throws IOException {
    if (sink != null) {
        sink.receivedLast();
    }
}

這個方法就是簡單的調用了PipedInputStream#receivedLast()方法, 從方法名可以判斷出, 這個方法就是通知PipedInputStream, 數據已經填充完畢.

總結

從上面的分析可以看出, PipedOutputStream基本就是一個"接口"類, 不會對數據進行實際的操作, 也不承擔具體的職責, 只負責把數據交給PipedInputStream處理.

下面我們接著分析最關鍵的PipedInputStream的源碼

PipedInputStream

成員變量

我們先看下關鍵的幾個變量

// 緩存數組, "管道"數據的存儲的地方
protected byte buffer[];

// 寫下一個數據時, 保存到緩存數組的位置
// 小于0表示無可讀數據, 緩存數組為空
// in == out時表示緩存數組已滿
protected int in = -1;

// 下一個被讀數據在緩存數組的位置
protected int out = 0;

看上面3個成員變量我們基本可以知道

"管道"內部使用了數組來緩存寫入的數據, 等待讀取. 通過inout兩個值來記錄數組的寫位置和讀位置

其余變量都是一些狀態標識

// 寫數據端(輸入端)是否已經關閉
boolean closedByWriter = false;
// 讀數據端(輸出端)是否已經關閉
volatile boolean closedByReader = false;
// 是否處于已連接狀態
boolean connected = false;
// 記錄讀線程
Thread readSide;
// 記錄寫線程
Thread writeSide;

這些變量都是用于判斷當前"管道"的狀態

其中readSidewriteSide是一種簡單的標記讀寫線程的方式, 源碼注釋中也有說明這種方式并不可靠, 這種方式針對的應該是兩條線程的情況, 所以我們使用的時候應該盡量按照設計意圖來使用

在兩條線程中建立"管道"傳遞數據, 寫線程寫數據, 讀線程讀數據.

構造函數

它包含了好幾個構造函數, 我們只看參數最多的那個

public PipedInputStream(PipedOutputStream src, int pipeSize) throws IOException {
    initPipe(pipeSize);
    connect(src);
}

最終都會要求我們調用上面的兩個方法, 都比較簡單就不貼代碼了

  1. initPipe()里面對byte數組buffer變量進行賦值, 也就是初始化緩沖區域
  2. connect()方法直接調用了PipedOutputStream#connect, 上面已經分析過了, 最終效果就是指明PipedOutputStream的連接對象, 改變connected變量的值, 使得PipedInputStream處于連接狀態.

receive

通過上面PipedOutputStream的分析可以知道, 寫數據的方法會調用PipedInputStreamreveive方法, 所以我們首先分析這個方法, 了解寫數據的邏輯. 注意閱讀注釋!

// 寫單個數據
protected synchronized void receive(int b) throws IOException {
    // 檢查當前"管道"狀態, 確保能夠讀寫數據
    checkStateForReceive();
    // 本方法由PipedOutputStream調用, 所以線程是寫線程, 記錄該線程
    writeSide = Thread.currentThread();
    if (in == out)
        // in == out表示緩存數組已經滿了, 阻塞線程等待
        // 這里確保了未讀的緩存數據不會丟失
        awaitSpace();
    // 當檢測到緩存數組有空間, 等待結束后, 會繼續執行以下代碼
    if (in < 0) {
        // in小于0表示當前無數據, 設置讀, 寫位置都是0
        in = 0;
        out = 0;
    }
    // 寫操作
    // 1. 把數據寫到目標位置(in)
    // 2. 后移in, 指明下一個寫數據的位置
    buffer[in++] = (byte)(b & 0xFF);
    // 如果in超出緩存長度, 回到0, 循環利用緩存數組
    if (in >= buffer.length) {
        in = 0;
    }
}

注意該方法帶有synchronized關鍵字, 表明在該方法內, 會持有對象鎖, 我們留到最后再分析各個環節中, 對象鎖的歸屬問題.

在寫數據前會先通過checkStateForReceive檢查"管道"狀態, 確保

  1. 當前處于連接狀態
  2. 管道讀寫兩端都沒有被關閉
  3. 讀線程狀態正常

接著用writeSide記錄當前線程為寫線程, 用來后續判斷線程狀態;

然后判斷目標位置(in), 如果in == out表明當前緩存數組已經滿了, 不能再寫數據了, 所以會通過awaitSpace()方法阻塞寫線程;

// 此時寫線程持有鎖
private void awaitSpace() throws IOException {
    // 只有緩存數組已滿才需要等待
    while (in == out) {
        // 檢查管道狀態, 防止在等待的過程中狀態發生變化 
        checkStateForReceive();
        // 標準用法中僅涉及兩條線程, 所以這里可以認為是通知讀線程讀數據
        notifyAll();
        try {
            // 釋放對象鎖, 等待讀線程讀數據, 調用后就會阻塞寫線程
            // 1s后取消等待是為了再次檢查管道狀態
            // 注意等待結束后, 鎖仍然在寫線程
            wait(1000);
        } catch (InterruptedException ex) {
            // 直接拋出異常
            IoUtils.throwInterruptedIoException();
        }
    }
}

以上基本可以概括為

緩存數組有空間時直接寫數據, 無空間時阻塞寫線程, 直至有空間可以寫數據

接著分析寫一段數據的receive(byte[], int, int)方法, 注意閱讀注釋!

synchronized void receive(byte b[], int off, int len)  throws IOException {
    checkStateForReceive();
    writeSide = Thread.currentThread();
    // len是需要寫進緩存數據的總長度
    // bytesToTransfer用來記錄剩余個數
    int bytesToTransfer = len;
    // 循環寫數據過程, 直至需要寫的數據全部處理完畢
    while (bytesToTransfer > 0) {
        if (in == out)
            // in == out表示緩存區域已經滿了, 阻塞線程等待
            awaitSpace();
        // nextTransferAmount用來記錄本次過程寫進緩存的個數
        int nextTransferAmount = 0;
        if (out < in) {
            // 因為out必然大于等于0, 所以這里 0 <= out < int
            // out < in 表示[in, buffer.length)和[0, out)兩個區間可以寫數據
            // 先寫數據進[in, buffer.length)區間, 避免處理頭尾連接的邏輯, 如果還有數據剩余, 留到下一個循環處理
            nextTransferAmount = buffer.length - in;
        } else if (in < out) {
            // 注意in有可能為-1, 所以特殊判斷下
            if (in == -1) {
                // in == -1表示緩存數組為空, 整個數組都可以寫數據
                // 從這里可知, 單次寫數據最大長度就是緩存數組的長度
                in = out = 0;
                nextTransferAmount = buffer.length - in;
            } else {
                // in < out 表示[in, out)區間可以寫數據
                nextTransferAmount = out - in;
            }
        }
        // 到這里nextTransferAmount表示本次過程**最多**可以寫的數據
        if (nextTransferAmount > bytesToTransfer)
            // 位置比需要的多, 所以修改nextTransferAmount
            nextTransferAmount = bytesToTransfer;
        // 經過上面的判斷, nextTransferAmount表示本次過程可以寫進緩存的個數
        assert(nextTransferAmount > 0);
        // 把數據寫進緩存
        System.arraycopy(b, off, buffer, in, nextTransferAmount);
        // 計算剩余個數
        bytesToTransfer -= nextTransferAmount;
        // 移動數據起點
        off += nextTransferAmount;
        // 后移in
        in += nextTransferAmount;
        // 如果in超出緩存長度, 回到0
        if (in >= buffer.length) {
            in = 0;
        }
    }
}

代碼邏輯注釋已經說明得很清楚了, 當你需要處理頭尾相連的數組時, 可以學習上面循環處理數據的方法, 邏輯清晰, 不需要太多的邊界判斷.

receiveLast

當輸入端關閉時(調用PipedOutputStream#close()), 會調用receivedLast()

synchronized void receivedLast() {
    // 標記輸入端關閉
    closedByWriter = true;
    // 通知讀線程讀數據
    notifyAll();
}

該方法使用變量標記輸入端已經關閉, 表示不會有新數據寫入了.

read

分析完寫數據, 接下來該分析讀數據了.

public synchronized int read()  throws IOException {
    // synchronized關鍵字, 讀線程需要持有鎖才能讀數據
    
    // 先檢查管道狀態
    if (!connected) {
        throw new IOException("Pipe not connected");
    } else if (closedByReader) {
        throw new IOException("Pipe closed");
    } else if (writeSide != null && !writeSide.isAlive()
        && !closedByWriter && (in < 0)) {
        // 只要in >= 0, 表示還有數據沒有讀, 所以不拋出異常
        // 這個判斷表明了, 即使輸入端已經調用了close, 也能繼續讀已經寫入的數據
        throw new IOException("Write end dead");
    }
    
    // 記錄讀線程
    readSide = Thread.currentThread();
    int trials = 2;
    while (in < 0) {
        // in<0表示緩存區域為空, 只要輸入端沒有被關閉, 阻塞線程等待數據寫入, 即等待in >= 0
        if (closedByWriter) {
            // 輸入端關閉了, 同時in < 0, 表示數據傳輸完畢了, 返回-1 
            return -1;
        }
        // 檢查寫線程的狀態, 線程狀態異常則認為管道異常, 檢查2次
        if ((writeSide != null) && (!writeSide.isAlive()) && (--trials < 0)) {
            throw new IOException("Pipe broken");
        }
        // 這里可以認為是通知寫線程寫數據
        notifyAll();
        try {
            // 阻塞線程, 等待1s, 這里會釋放鎖, 給機會寫線程獲取鎖, 寫數據
            wait(1000);
        } catch (InterruptedException ex) {
            IoUtils.throwInterruptedIoException();
        }
    }

    // 執行到這里證明in >= 0, 即緩存數組中有數據
    // 關鍵的讀操作
    // 1. 讀取out指向的byte數據
    // 2. 后移out
    // 3. 把byte轉成int, 高位補0
    int ret = buffer[out++] & 0xFF;
    // out超出長度則回到位置0
    if (out >= buffer.length) {
        out = 0;
    }
    if (in == out) {
        // 讀取的數據追上了輸入的數據, 則當前緩存區域為空, 所以設置in = -1
        in = -1;
    }

    return ret;
}

從上面的注釋分析可以知道

  1. 即使調用了PipedOutputStream#close(), 只要管道中還有數據, 仍可以讀數據, 所以實際使用時, 輸入端輸入完畢后可以直接close輸入端.
  2. 當管道中沒有數據時, 會阻塞讀線程, 直至管道被關閉, 線程異常或者數據被寫入到管道中.

接著看看讀取一段數據的方法

public synchronized int read(byte b[], int off, int len)  throws IOException {
    // 參數byte[](下面稱輸出數組)是數據讀取后存放的地方, 所以要先檢查該數組
    if (b == null) {
        // 確保輸出數組不為null, 否則讀出的數據不能寫入
        throw new NullPointerException();
    } else if (off < 0 || len < 0 || len > b.length - off) {
        // 確保下標不會越界
        throw new IndexOutOfBoundsException();
    } else if (len == 0) {
        // len參數表示需要讀取的長度, 等于0時相當于不讀數據, 所以直接返回
        return 0;
    }

    // 先單獨讀一個數據是為了確保已經有數據寫入, 因為如果當前無數據, 則會阻塞當前的讀線程
    int c = read();
    // 返回值小于0(實際上只能是-1), 表示管道已經沒有數據了, 所以這里也直接返回-1
    if (c < 0) {
        return -1;
    }
    // 把讀取到的第一個數據放到輸出數組, 看后面的代碼時緊記這里已經讀了1個數據
    b[off] = (byte) c;
    // 記錄讀取到的數據長度
    int rlen = 1;
    // 循環條件:
    // in >= 0確保還有數據可以讀
    // len > 1確保只讀取外部請求的數據長度, 因為上面已經讀了1個數據, 所以是大于1, 而不是大于0
    while ((in >= 0) && (len > 1)) {

        // available用來記錄當前可以讀取的數據
        int available;

        if (in > out) {
            // in > out表示[out, in)區間數據可讀
            // in的值正常情況下是不會大于buffer.length的, 因為當 in == buffer.length時, in就會賦值0
            // 這里的Math.min顯得有點多余, 可能是為了以防萬一吧
            available = Math.min((buffer.length - out), (in - out));
        } else {
            // 首先in是不會等于out的, 因為如果相等, 在上面讀第一個數據的時候就會把in賦值-1, 也就不會進入這個循環
            // 當in < out表示[out, buffer.length)和[0, in)兩個區間的數據可讀
            // 和receive方法類似, 為了不處理跨邊界的情況, 先讀[out, buffer.length)區間數據
            available = buffer.length - out;
        }

        // 外部已經讀了一個數據, 所以只需要讀(len - 1)個數據了
        if (available > (len - 1)) {
            available = len - 1;
        }
        // 經過上面的判斷, available表示本次需要讀的數據長度
        // 復制數據到輸出數組
        System.arraycopy(buffer, out, b, off + rlen, available);
        // 后移out變量
        out += available;
        // 記錄已經讀到的數據量
        rlen += available;
        // 計算剩余需要讀的數據
        len -= available;

        // 如果已經讀到緩存數組的尾部, 回到開頭
        if (out >= buffer.length) {
            out = 0;
        }
        if (in == out) {
            // in == out表示已經沒有數據可以讀了, 所以in賦值-1
            in = -1;
        }
    }
    return rlen;
}

上面的方法我們需要注意:

while方法體內是不會阻塞讀線程的! while方法體內是不會阻塞讀線程的! while方法體內是不會阻塞讀線程的! 重要的事情說3遍~ 所以如果管道內只有1個數據, 那么讀取到輸出數組的就只有這1個數據, read方法返回值會是1, 在讀取數據后處理輸出數組時需要特別注意這點.

available

我們在讀數據前可以利用available()先看看管道中的數據個數.

public synchronized int available() throws IOException {
    if(in < 0)
        // 管道中無數據
        return 0;
    else if(in == out)
        // 緩存數組已滿
        return buffer.length;
    else if (in > out)
        // [out, in)區間內為有效數據
        return in - out;
    else
        // in < out
        // [in, out)區間為無效數據, 其余為有效數據, 所以長度為 buffer.length - (out - in)
        return in + buffer.length - out;
}

到這里我們已經把所有PipedOutputStreamPipedInputStream的所有方法分析完畢了~ 接著我們再分析下讀寫過程中對象鎖的歸屬問題.

分析這部分我們先要了解下waitnotifyAll的作用, 可以參考知乎上這個回答java中的notify和notifyAll有什么區別? - 文龍的回答 - 知乎, 本文不再說明了, 重點理解鎖池等待池概念

  • 鎖池:假設線程A已經擁有了某個對象(注意:不是類)的鎖,而其它的線程想要調用這個對象的某個synchronized方法(或者synchronized塊),由于這些線程在進入對象的synchronized方法之前必須先獲得該對象的鎖的擁有權,但是該對象的鎖目前正被線程A擁有,所以這些線程就進入了該對象的鎖池中。

  • 等待池:假設一個線程A調用了某個對象的wait()方法,線程A就會釋放該對象的鎖后,進入到了該對象的等待池中

首先, 需要注意, PipedOutputStream中, 兩個write方法都沒有synchronized關鍵字, 所以我們不需要關心PipedOutputStream的對象鎖.

我們重點分析PipedInputStream里面, readreceive方法.

假設我們先調用receive寫數據, 后調用read讀數據

當我們寫數據時, 進入了receive方法, 因為synchronized關鍵字, 此時寫線程會獲取到了對象鎖, 然后寫數據到管道中, 注意, 在這個過程中, 讀線程是不能通過read方法讀取數據的, 因為讀線程獲取不了對象鎖, 如果這次寫操作中, 管道中的緩存數組滿了, 此時寫線程會進入awaitSpace()方法, 在該方法內, 寫線程先調用了notifyAll方法, 使讀線程進入鎖池準備競爭對象鎖, 然后調用wait(1000)方法, 在這1s內, 寫線程釋放了對象鎖, 然后進入等待池.

寫線程釋放對象鎖后, 讀線程就能夠獲取對象鎖, 進入read方法內了, 然后讀數據, 只要管道中存在至少一個數據, 就不會阻塞線程, 讀取數據后直接退出方法, 釋放對象鎖, 如果這次讀操作中, 管道中的緩存數組沒有任何數據, 此時讀線程就會調用notifyAll方法, 使寫線程從等待池移到鎖池, 準備競爭對象鎖, 然后再調用wait(1000)方法, 在這1s內, 讀線程釋放對象鎖, 自己進入等待池.

以上就是一次讀寫中, 對象鎖的轉移過程, 但是在實際過程中, 我們都是兩個線程在各自的循環體內一直讀數據和一直寫數據的, 所以每一次循環的時候都會競爭鎖, 可能先讀后寫, 或者先寫后讀.

總結

分析這兩個類的源碼我們應該可以學習到

  1. InputSteamOutputSteam的接口含義
  2. 使用數組緩存數據的方法, 使用while循環避免處理邊界問題
  3. waitnotifyAll協調讀寫線程的邏輯
  4. 使用這兩個類實現傳輸數據流
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,443評論 6 532
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,530評論 3 416
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,407評論 0 375
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,981評論 1 312
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,759評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,204評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,263評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,415評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,955評論 1 336
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,782評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,983評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,528評論 5 359
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,222評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,650評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,892評論 1 286
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,675評論 3 392
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,967評論 2 374

推薦閱讀更多精彩內容