一、檔案讀寫的用戶程式、作業系統、磁盤互動原理
最近為了徹底搞懂檔案讀寫原理,我特意查詢了很多資料,包括Java讀寫檔案的API代碼、作業系統處理檔案以及磁盤硬體知識等,由于網上現存技術文章,幾乎沒有找到一篇能夠徹底綜合講明白這個原理的文章,心中還是有很多疑問,且有不少文章包括書籍所闡述的隨機/順序讀寫原理講述的都是錯誤或誤導性的,所以我綜合了一下我能查閱到的所有資料,深入細節知識,給大家徹底講明白這事,原創文章,轉發請保留第一作者著作權,謝謝! 如下圖所示,我們撰寫的用戶程式讀寫檔案時必須經過的OS和硬體互動的記憶體模型,
1、讀檔案
用戶程式通過編程語言提供的讀取檔案api發起對某個檔案讀取,此時程式切換到內核態,用戶程式處于阻塞狀態,由于讀取的內容還不在內核緩沖區中,導致觸發OS缺頁中斷例外,然后由OS負責發起對磁盤檔案的資料讀取,讀取到資料后,先存放在OS內核的主存空間,叫PageCache,然后OS再將資料拷貝一份至用戶行程空間的主存ByteBuffer中,此時程式由內核態切換至用戶態繼續運行程式,程式將ByteBuffer中的內容讀取到本地變數中,即完成檔案資料讀取作業,2、寫檔案
用戶程式通過編程語言提供的寫入檔案api發起對某個檔案寫入磁盤,此時程式切換到內核態用戶程式處于阻塞狀態,由OS負責發起對磁盤檔案的資料寫入,用戶寫入資料后,并不是直接寫到磁盤的,而是先寫到ByteBuffer中,然后再提交到PageCache中,最后由作業系統決定何時寫入磁盤,資料寫入PageCache中后,此時程式由內核態切換至用戶態繼續運行, 用戶程式將資料寫入內核的PageCache緩沖區后,即認為寫入成功了,程式由內核態切換回用于態,可以繼續后續的作業了,PageCache中的資料最終寫入磁盤是由作業系統異步提交至磁盤的,一般是定時或PageCache滿了的時候寫入,如果用戶程式通過呼叫flush方法強制寫入,則作業系統也會服從這個命令,立即將資料寫入磁盤然后由內核態切換回用戶態繼續運行程式,但是這樣做會損失性能,但可以確切的知道資料是否已經寫入磁盤了,一、檔案讀寫詳細程序
1、讀檔案
如下所示為一典型Java讀取某檔案內容的用戶編程代碼,接下來我們詳細解說讀取檔案程序,
// 一次讀多個位元組 byte[] tempbytes = new byte[100]; int byteread = 0; in = new FileInputStream(fileName);//① ReadFromFile.showAvailableBytes(in); // 讀入多個位元組到位元組陣列中,byteread為一次讀入的位元組數 while ((byteread = in.read(tempbytes)) != -1) { //② System.out.write(tempbytes, 0, byteread); }View Code 首先通過位置①的代碼發起一個open的系統呼叫,程式由用戶態切換到內核態,作業系統通過檔案全路徑名在檔案目錄中找到目標檔案名對應的檔案iNode標識ID,然后用這個iNode標識ID在iNode索引檔案找到目標檔案iNode節點資料并加載到內核空間中,這個iNode節點包含了檔案的各種屬性(創建時間,大小以及磁盤塊空間占用資訊等等),然后再由內核態切換回用戶態,這樣程式就獲得了操作這個檔案的檔案描述,接下來就可以正式開始讀取檔案內容了, 然后再通位置②,回圈數次獲取固定大小的資料,通過發起read系統呼叫,作業系統通過檔案iNode檔案屬性中的磁盤塊空間占用資訊得到檔案起始位的磁盤物理地址,再從磁盤中將要取得資料拷貝到PageCache內核緩沖區,然后將資料拷貝至用戶行程空間,程式由內核態切換回用戶態,從而可以讀取到資料,并放入上面代碼中的臨時變數tempbytes中, 整個程序如下圖所示,
①根據檔案路徑從檔案目錄中找到iNode ID,
用戶讀取一個檔案,首先需要呼叫OS中檔案系統的open方法,該方法會回傳一個檔案描述符給用戶程式,OS首先根據用戶傳過來的檔案全路徑名在目錄索引資料結構中找到檔案對應的iNode標識ID,目錄資料是存在于磁盤上的,在OS初始化時就會加載到記憶體中,由于目錄資料結構并不會很龐大,一次性加載駐留到記憶體也不是不可以或者部分加載,等需要的時候在從磁盤上調度進記憶體也可以,根據檔案路徑在目錄中查找效率應該是很高的,因為目錄本身就是一棵樹,應該也是類似資料庫的樹形索引結構,所以它的查找演算法時間復雜度就是O(logN),具體細節我暫時還沒弄清楚,這不是重點, iNode就是檔案屬性索引資料了,磁盤格式化時OS就會把磁盤磁區成iNode區和資料區,iNode節點就包含了檔案的一些屬性資訊,比如檔案大小、創建修改時間、作者等等,其中最重要的是還存有整個檔案資料在磁盤上的分布情況(檔案占用了哪些磁盤塊),②根據iNode ID從Inode索引中找到檔案屬性,
得到iNode標識的ID后,就可以去iNode資料中查找到對應的檔案屬性了,并加載到記憶體,方便后續讀寫檔案時快速獲得磁盤定位,iNode資料結構應該類似哈希結構了,key就是iNode標識ID,value就是具體某個檔案的屬性資料物件了,所以它的演算法時間復雜度就是O(1),具體細節我暫時還沒弄清楚,這不是重點, 我們系統中的檔案它的檔案屬性(iNode)和它的資料正文是分開存盤的,檔案屬性中有檔案資料所在磁盤塊的位置資訊,③根據檔案屬性中的磁盤空間塊資訊找到需要讀取的資料所在的磁盤塊的物理位置
檔案屬性也就是iNode節點這個資料結構,里面包含了檔案正文資料在磁盤物理位置上的分布情況,磁盤讀寫都是以塊為單位的,所以這個位置資訊其實也就是一個指向磁盤塊的物理地址指標, 其結構圖如下,
RandomAccessFile raf=new RandomAccessFile(new File("D:\\3\\test.txt"), "r"); //獲取RandomAccessFile物件檔案指標的位置,初始位置是0 System.out.println("RandomAccessFile檔案指標的初始位置:"+raf.getFilePointer()); raf.seek(pointe);//移動檔案指標位置 byte[] buff=new byte[1024]; //用于保存實際讀取的位元組數 int hasRead=0; //回圈讀取 while((hasRead=raf.read(buff))>0){ //列印讀取的內容,并將位元組轉為字串輸入 System.out.println(new String(buff,0,hasRead)); }View Code 程式代碼呼叫seek方法直接定位到某個檔案相對位置開始讀取內容,實際上就是呼叫了OS管理檔案的系統呼叫seek函式,這個系統呼叫需要傳遞一個檔案相對位置也就是偏移量,不是指磁盤的物理位置,檔案的相對位置偏移量是從0開始的,結束位置和檔案的大小位元組數相等,作業系統拿到這個偏移量后,就可以計算出檔案所屬的邏輯塊編號,因為每個塊是固定大小的,所以能計算出來,通過檔案屬性的邏輯磁盤塊資訊就能得到磁盤塊的物理位置,從而可以快速直接定位到磁盤物理塊讀取到需要的資料,這里說的邏輯塊和物理塊的概念是有區別的,邏輯塊屬于當前的檔案從0開始編號,物理塊才是磁盤真正的存放資料的區域,屬于全域的,編號自然不是從0開始的,
2、寫檔案
寫檔案的程序和前面闡述的差不多,相關的知識點也在讀檔案中已經順帶描述了,就不在贅述了,這里就說些特別需要注意的點就行,
③根據空閑塊索引找到可以寫入的物理位置并寫入
如上圖所示,OS寫檔案內容時首先要訪問磁盤空閑塊索引表,這是個什么東西呢?由于磁盤很大,不可能每次寫資料時,都讓磁頭從頭到尾遍歷一次才能找到空閑位置,這樣效率可想而知的差勁,所以OS會把磁盤上的空閑塊索引起來存放在磁盤某個位置上,后續磁盤存盤和洗掉檔案內容時都通過這個空閑塊索引表快速定位,同時洗掉資料也會更新索引表增加空閑塊, 空閑塊記錄索引的實作常用有兩種,一種是我們熟悉的鏈表結構,還有一種是位圖結構,這里就不詳細討論了,④寫入資料后更新iNode里的磁盤占用塊索引
資料寫入后,那么這個空閑塊就被占用了,自然也就需要更新下iNode檔案屬性里的磁盤占用塊索引資料了, 我們前面說的寫檔案都是只講了尾部追加這種方式,但是實際上我們可以通過RandomAccessFile類實作檔案隨機位置寫功能,但是我們同時也有一些困惑,為啥不能直接在中間某個位置插入我們要寫的內容,而是要先把插入位置后面的內容截取放入臨時檔案中,插入新內容后,再把臨時檔案內容尾部追加到原來的檔案中來實作檔案修改?代碼如下所示,
public static void insert(String fileName, long pos, String insertContent) throws IOException{ File file = File.createTempFile("tmp", null); file.deleteOnExit(); RandomAccessFile raf = new RandomAccessFile(fileName, "rw"); FileInputStream fileInputStream = new FileInputStream(file); FileOutputStream fileOutputStream = new FileOutputStream(file); raf.seek(pos); byte[] buff = new byte[64]; int hasRead = 0; while((hasRead = raf.read(buff)) > 0){ fileOutputStream.write(buff); } raf.seek(pos); raf.write(insertContent.getBytes()); //追加檔案插入點之后的內容 while((hasRead = fileInputStream.read(buff)) > 0){ raf.write(buff, 0, hasRead); } raf.close(); fileInputStream.close(); fileOutputStream.close(); }View Code 按照我們上面的闡述,寫入的檔案內容完全可以存入磁盤上的一個新塊,然后更新下iNode屬性里的占用磁盤塊索引資料即可,也不需要真的去移動磁盤上的所有資料塊,看上去成本也很小,可為啥我們的編程api卻都不支持呢? 我想答案可能是這樣的,假如允許我們上面那種操作,如果一個很大的文本檔案,你現在只是編輯了文本中間某個位置的一個字,即插入了一個文本字符,那么此時這個新增的文本內容就得在磁盤上找到一個新的塊存盤下來,這樣是不是有點浪費空間呢?因為磁盤上的一個塊只能分配給一個檔案使用,塊大小如果是64kb的話,一個字符也就占用了2個位元組的空間,更要命的是這樣一搞,使得原本滿存狀態的塊,出現很多不連續的空洞,這樣就會使得讀取檔案時資料是不連續的,系統需要額外資訊記錄這些中間的存盤空洞,加大了讀取難度,這就是我猜測的原因,實際上作業系統層面也沒有這種操作插入的系統呼叫函式,故編程語言層面也就沒法支持了, 作業系統層面給上層應用程式提供了寫檔案的兩個系統呼叫,write和append,其中append是write的限制形式,即只能在檔案尾部追加,而write雖然提供了隨機位置寫,但是并不是將新內容插入其中,而是覆寫原有的資料,我們平時使用Word文本編輯軟體時,如果對一個很大的檔案進行編輯,然后點擊保存,你會發現很慢,同時你還能看到檔案所在的目錄下生成了一個新的處于隱藏狀態的臨時檔案,這些現象也能說明我們上面的觀點,即編輯檔案時,需要一個成本很高的程序,如下圖所示,
三、常見認知誤區澄清
1、磁盤上檔案存盤資料結構是鏈表,每一塊檔案資料節點里有一個指標指向下一塊資料節點,理解錯誤!
很多人都知道磁盤存盤一個檔案不可能是連續分配空間的,而是東一塊西一塊的存盤在磁盤上的,就誤以為這些分散的資料節點就像鏈表一樣通過其中一個指標指向下一塊資料節點,如下圖所示,
2、append檔案尾部追加方法是順序寫,也就是磁盤會分配連續的空間給檔案存盤,理解錯誤!
這種觀點,包括網上和某些技術書籍里的作者都有這種觀點,實際上是錯誤的,或許是他們根本沒有細究檔案存盤底層OS和磁盤硬體的作業原理導致,我這里就重新總結糾正一下這種誤導性觀點, 前面說過,append系統呼叫是write的限制形式,即總是在檔案末尾追加內容,看上去好像是說順序寫入檔案資料,因為是在尾部追加啊!所以這樣很容易誤導大家以為這就是順序寫,即磁盤存盤時分配連續的空間給到檔案,減少了磁盤尋道時間, 事實上,磁盤從來都不會連續分配空間給哪個檔案,這是我們現代檔案系統的設計方案,前面介紹iNode知識時也給大家詳細說明了,所以就不再贅述,我們用戶程式寫檔案內容時,提交給OS的緩沖區PageCache后就回傳了,實際這個內容存盤在磁盤哪個位置是由OS決定的,OS會根據磁盤未分配空間索引表隨機找一個空塊把內容存盤進去,然后更新檔案iNode里的磁盤占用塊索引資料,這樣就完成了檔案寫入操作,所以append操作不是在磁盤上接著檔案末尾內容所在塊位置連續分配空間的,最多只能說邏輯上是順序的, 那么邏輯上的隨機寫write是不是會慢呢?根據前面介紹的原理,應該是相同的效率,我們可以做如下測驗驗證,使用RandomAccessFile 實作,因為只有這個類支持隨機位置寫入,其他寫檔案類都只提供尾部追加方式,打開一個20M的文本檔案“test.txt”,分別采用如下兩個方法,隨機位置寫入和尾部追加寫入做100000次操作,多次測得大概的平均耗時資料,
// 檔案隨機位置寫入 耗時:1000ms public static void randomWrite1(String path,String content) throws Exception { RandomAccessFile raf=new RandomAccessFile(path,"rw"); Random random=new Random(); for(int i=0;i<100000;i++){ raf.seek(random.nextInt((int)raf.length())); // 在檔案隨機位置寫入覆寫 raf.write((i+content+System.lineSeparator()).getBytes()); } raf.close(); } // 檔案尾部位置寫入 耗時:800ms public static void randomWrite2(String path,String content) throws Exception { RandomAccessFile raf=new RandomAccessFile(path,"rw"); for(int i=0;i<100000;i++){ raf.seek(raf.length()); // 總是在檔案尾部追加 raf.write((i+content+System.lineSeparator()).getBytes()); } raf.close(); }View Code 看上去采用尾部追加性能略高,實際上也相差不大,多出的200ms只是生成亂數消耗的,因為如果說隨機位置寫需要某些人認為的磁盤磁頭來回反復移動,則性能不可能只差這么一丟丟,實際上我去掉亂數生成的代碼,改用固定中間位置寫入,這兩個方法耗時幾乎沒有區別了,這說明無論是尾部追加還是隨機位置寫入方式,性能都是一樣的,因為根據前面介紹的原理,OS通過iNode中的磁盤占用塊哈希表,可以快速定位到目標磁盤物理位置,其演算法時間復雜度是O(1),所以尾部追加也是一樣的定位效率, 可能也有些人想說怎么不試試BufferWriter、FileWriter等寫入類,效率高幾個數量級比RandomAccessFile ,我也的確測驗過,但是這些類都是采用尾部追加模式,無法和其隨機位置寫入做比較,所以沒法拿出來測驗說明,但是這些類寫入性能之所以遠高于直接用RandomAccessFile 尾部追加,我想是因為api方法做了用戶程式層面的優化,比如批量寫入,批量轉化成Byte之類的,而RandomAccessFile 可能就是最原始的直接對接OS系統呼叫層的API了,
3、mmap記憶體映射技術之所以快,是因為直接把磁盤檔案映射到用戶空間記憶體,不走內核態,理解錯誤!
這也是一種常見的認知誤區,實際上這個技術是作業系統給用戶程式提供的一個系統呼叫函式,它把檔案映射到OS內核緩沖區空間,同時共享給用戶行程,也可以共享給多個用戶行程,映射程序中不會產生實際的資料從磁盤真正調取動作,只有用戶程式需要的時候才會調入部分資料,總之也是和普通檔案讀取一樣按需調取,那么mmap技術為什么在讀取資料時會比普通read操作快幾個數量級呢? 上面我們講述了普通讀寫操作的記憶體模型,用戶程式要讀取到磁盤上的資料,要經歷4次內核態切換以及2次資料拷貝操作,那么mmap技術由于是和用戶行程共享內核緩沖區,所以少了一次拷貝操作(資料從內核緩沖區到用戶行程緩沖區),從而大大提高了性能,如下圖所示,
4、mmap記憶體映射技術寫檔案快是因為順序寫磁盤,理解錯誤!
上面的問題基本已經讓我們理解了mmap技術的記憶體模型,同樣的,我們寫檔案時,由于也少了一次資料從用戶緩沖區到內核緩沖區的拷貝操作,使得我們的寫效率非常的高,并不是很多人認為的資料直達磁盤,中間不經過內核態切換,并且連續在磁盤上分配空間寫入,這些理解都是錯誤的,5、隨機讀寫檔案比順序讀寫檔案慢,是因為磁盤移動磁頭來回隨機移動導致,理解錯誤!
這也是一種常見的誤區,我看過很多文章都是這樣認為的,其實所有的寫操作在硬體磁盤層面上都是隨機寫,這是由現代作業系統的檔案系統設計方案決定的,我們用戶程式寫入資料提交給OS緩沖區之后,就與我們沒關系了,作業系統決定何時寫入磁盤中的某個空閑塊,所有的檔案都不是連續分配的,都是以塊為單位分散存盤在磁盤上,原因也很簡單,系統運行一段時間后,我們對檔案的增刪改會導致磁盤上資料無法連續,非常的分散, 當然OS提交PageCache中的寫入資料時,也有一定的優化機制,它會讓本次需要提交給磁盤的資料規劃好磁頭調度的策略,讓寫入成本最小化,這就是磁盤調度演算法中的電梯演算法了,這里就不深入講解了, 至于讀檔案,順序讀也只是邏輯上的順序,也就是按照當前檔案的相對偏移量順序讀取,并非磁盤上連續空間讀取,即便是seek系統呼叫方法隨機定位讀,理論上效率也是差不多的,都是使用iNode的磁盤占用塊索引檔案快速定位物理塊,
轉載請註明出處,本文鏈接:https://www.uj5u.com/houduan/336110.html
標籤:Java
