首頁 > 軟體

Android進階之從IO到NIO的模型機制演進

2023-02-01 18:00:16

引言

其實IO操作相較於伺服器端,使用者端做的並不多,基本的場景就是讀寫檔案的時候會使用到InputStream或者OutputStream,然而使用者端能做的就是發起一個讀寫的指令,真正的操作是核心層通過ioctl指令執行讀寫操作,因為每次的IO操作都涉及到了執行緒的操作,因此會有效能上的損耗,那麼從本篇文章開始,我們將進入IO的世界,瞭解IO到NIO機制的演進,從底層關注序列化的原理。

1 Basic IO模型

那麼在Java(Kotlin)中,IO主要分為兩種:Basic IO 和 Net IO;Basic IO是我們在開發當中常用的一些IO流,例如:

FileInputStream://檔案輸入流
FileOutputStream://檔案輸出流
BufferedInputStream://快取位元組輸入流
BufferedOutputStream://快取位元組輸入流,此類資料流為了提高讀寫效率,可以快取資料到buffer,通過flush一起寫入;核心分配記憶體為一頁4K,但是Java緩衝區預設是8K
ObjectInputStream
ObjectOutputStream:// 將資料序列化處理
RandomAccessFile://提供位移資料插入

對於前面的幾個資料流,我就不介紹用法了,對於最後一個RandomAccessFile,我想簡單介紹一下,因為很多夥伴們可能不知道RandomAccessFile的存在,這裡曾經有個面試題:

假設有一個5G的檔案,我想在文章的末尾追加一段話,我該怎麼處理?或者我指定任意位置新增一部分文字內容,該怎麼處理?

很多夥伴看到這個問題之後,一拍腦門說:先通過FileInputStream把檔案讀寫進來,然後再在末尾追加一部分內容組合成新的位元組流,然後再通過FileOutputStream寫入到新的檔案中。

完蛋,直接pass掉!因為前提這裡已經是5G的檔案了,如果通過FileInputStream讀寫,大概率就會直接OOM! 所以如果知道RandomAccessFile的存在,這些就不是問題了。

fun testAccessFile() {
    //file檔案
    val file = File("/storage/emulated/0/NewTextFile.txt")
    val accessFile = RandomAccessFile(file, "rw")
    //先寫一段
    val text = "IO主要分為兩種:Basic IO 和 Net IO;"
    accessFile.write(text.toByteArray())
    //再等5s
    Thread.sleep(5000)
    accessFile.seek(5)
    accessFile.write("seek to pos 5".toByteArray())
    accessFile.close()
}

首先我們常見一個RandomAccessFile,傳入要讀寫的檔案,首先寫入一段話,然後等到5s後,呼叫RandomAccessFile的seek方法,此時指標就是移動到了檔案第五個字元的位置,然後又寫入了一些文字。

所以按照這種思想,回到前面的問題,即便是5G的檔案,也不需要進行讀寫操作獲取之前的全部資料就能夠實現零記憶體追加;當然還有一個場景也會經常用到,就是斷點續傳。

1.1 RandomAccessFile的緩衝區和BufferedInputStream緩衝區的區別

首先我先簡單介紹下BufferedInputStream的快取區效果,系統核心快取區預設為4K,當快取區滿4K之後會進行磁碟的寫入;那麼在Java中是對其做了優化處理,將快取區變為8K,當快取區超過8K之後,會將資料複製給到核心快取。

fun testBuffer() {
        val file = File("/storage/emulated/0/NewTextFile.txt")
        val bis = BufferedOutputStream(FileOutputStream(file))
        val text = "8888888888888888".toByteArray()
        bis.write(text, 0, text.size)
//        bis.flush()
    }

例如上面的案例,此時App的記憶體快取區沒有滿,那麼如果不呼叫flush,那麼資料不會寫到磁碟檔案中,只有當緩衝區滿了之後,才會複製到核心空間快取區。

fun testAccessFile() {
    //file檔案
    val file = File("/storage/emulated/0/NewTextFile.txt")
    val accessFile = RandomAccessFile(file, "rw")
    //先寫一段
    val text = "IO主要分為兩種:Basic IO 和 Net IO;"
    accessFile.write(text.toByteArray())
    //再等5s
    Thread.sleep(5000)
    accessFile.seek(5)
    val channel = accessFile.channel
    val mapper = channel.map(FileChannel.MapMode.READ_WRITE, channel.position(), channel.size())
    mapper.put("seek to pos 5".toByteArray())
}

如果按照BufferedOutputStream的思想,我們往緩衝區寫資料,沒有flush就不會有複製的操作,那麼我們實際看到的是資料還是寫進去了。

其實MappedByteBuffer,是提供了一個類似於mmap性質的能力,實現了App緩衝區與核心緩衝區的橋接或者對映。

當App寫入快取資料的時候,直接對映到了核心快取區,完成了磁碟的讀寫操作。

1.2 Basic IO模型底層原理

其實對於基礎的IO模型,也就是Basic IO的實現是阻塞的,其實我們也可以自己驗證,在主執行緒中進行讀寫操作就是阻塞的。

那麼對於IO來說,主要分為兩個階段:

(1)資料準備階段;這裡是由Java實現的,寫入到JVM中;

(2)複製階段;核心空間複製使用者空間快取資料,這部分需要呼叫核心函數(ioctl、sync),完成複製的工作。

剩下的磁碟寫入操作就完全是由核心完成的,如果對於讀寫操作有疑問的,可以去看看下面這篇對於Binder底層原理的介紹。

Android Framework原理 -- Binder驅動原始碼分析

對於傳統的Socket來說,這種屬於Net IO,本質也是阻塞性質的,例如App程序想要獲取一些資料,

上圖展示了read操作的整個排程過程:

(1)當App呼叫系統方法想要獲取某些資料的時候,首先系統核心會等待資料從網路中到達,這個過程核心處於阻塞的狀態

(2)等到資料到達之後,就會將網路資料複製到使用者空間的緩衝區中,並通知App程序複製資料成功,此時App中其他業務才能夠繼續執行。

所以整個過程中,App處於阻塞狀態,而在高並行的場景中(使用者端很少,這裡拿伺服器端來舉例),例如10000QPS(每秒10000次查詢操作),此時如果採用IO阻塞模型,帶來的後果就是CPU極速拉滿最終可能導致熔斷,所以針對這種情況,出現了NIO模型。

2 NIO模型

相對於IO模型來說,NIO模型做的優化是通過輪詢機制獲取核心的資料等待狀態,看下圖:

當一次詢問發出之後,如果當前核心還是資料等待狀態,那麼核心空間會被”掛起“,此時App程序可以做其他的事情,等到下一次輪詢時間到了之後,再次發起詢問,如果此時已經拿到了資料,那麼就會進行復制操作,將資料放入使用者程序緩衝區。

那麼對此,java.nio包下提供了很多非阻塞IO的API,例如我們前面提到的MappedByteBuffer。其實還是前面我們探討的一個問題,在Android的場景下,很難碰到高並行的場景,所以基本上也很難用到這個,但是對於NIO模型的原理我們需要掌握透徹,在面試中可能會涉及到這些問題。

3 OKIO

最後介紹一個IO模型---OKIO,如果使用到OkHttp的夥伴們應該已經見到過這個,但是沒有實際地去研究,為啥要引入這個okio三方庫。

首先okio是OkHttp團隊基於Basic IO研發的一套自己的IO體系,為啥要搞一個這個玩意出來呢?通過前面我們分析Basic IO存在的一些問題,首先 Basic IO是阻塞的,而且在使用者端端如果頻繁地進行網路請求,而且網路請求是雙向的,從使用者端發出請求,伺服器端返回響應,那麼這個過程必定會使用到InputStream和OutputStream。

因為OkHttp是有自己的快取策略的,如果使用到快取,那麼對於InputStream就需要一個buffer,對於OutputStream也需要一個buffer,每次讀寫操作都需要兩個buffer來做支撐,因此針對這種場景,okio在底層做了處理。

具體的處理就是不再使用byte[]陣列儲存資料,而是採用Segment資料結構。有熟悉Segment的夥伴應該知道,它是一個陣列的雙向連結串列,其中data就是一個byte陣列,其中有next和pre兩個指標。

internal class Segment {
  @JvmField val data: ByteArray
  /** The next byte of application data byte to read in this segment.  */
  @JvmField var pos: Int = 0
  /** The first byte of available data ready to be written to.  */
  @JvmField var limit: Int = 0
  /** True if other segments or byte strings use the same byte array.  */
  @JvmField var shared: Boolean = false
  /** True if this segment owns the byte array and can append to it, extending `limit`.  */
  @JvmField var owner: Boolean = false
  /** Next segment in a linked or circularly-linked list.  */
  @JvmField var next: Segment? = null
  /** Previous segment in a circularly-linked list.  */
  @JvmField var prev: Segment? = null

當進行讀寫操作的時候,都會往Segment中寫入,就是將InputStream和OutputStream需要建立的緩衝區合併。

這裡需要說明一點,okio屬於OkHttp內部核心IO框架,並不是單獨拿出來任意業務方可以使用,所以對於okio的具體實現原理,後續會放在OkHttp框架原理中做詳細的介紹。

以上就是Android進階之從IO到NIO的模型機制演進的詳細內容,更多關於Android模型從IO到NIO機制的資料請關注it145.com其它相關文章!


IT145.com E-mail:sddin#qq.com