1. 程式人生 > >深入學習JAVA I/O工作機制

深入學習JAVA I/O工作機制

我們知道,讀取和寫入檔案I/O操作都呼叫作業系統提供的介面,因為磁碟裝置是由作業系統管理的,應用程式要訪問物理裝置只能通過系統呼叫的方式來工作。讀和寫分別對應read()和write()兩個系統呼叫。而只要是系統呼叫就可能存在核心空間地址和使用者空間地址切換的問題,這是作業系統為了保護系統本身的執行安全,而將核心程式執行使用的記憶體空間和使用者程式執行的記憶體空間進行隔離造成的。但是這樣雖然保證了核心程式執行的安全性,但是也必然存在資料可能需要從核心空間向用戶空間複製的問題。如果遇到非常耗時的操作,如磁碟I/O,資料從磁碟複製到核心空間,然後又從核心空間複製到使用者空間,將會非常緩慢。這時作業系統為了加速I/O訪問,在核心空間使用快取機制,也就是將從磁碟讀取的檔案按照一定的組織方式進行快取,如果使用者程式訪問的是同一段磁碟地址的空間資料,那麼作業系統從核心快取中直接取出返回給使用者程式,這樣可以減少I/O的響應時間。

1、標準訪問檔案的方式:
當應用程式呼叫read()介面時,作業系統檢查在核心的快取記憶體中有沒有需要的資料,如果已經快取了,那麼直接從快取中返回,如果沒有,則從磁碟中讀取,然後快取在作業系統的快取中。寫入的方式是,使用者的應用程式呼叫write()介面將資料從使用者地址空間複製到核心地址空間的快取中。這時對於使用者程式來說寫操作就已經完成,至於什麼時候再寫入磁碟中由作業系統決定,除非顯式地呼叫了sync同步命令。
這裡寫圖片描述

2、直接I/O的方式
應用程式直接訪問磁碟資料,而不經過作業系統核心資料緩衝區,這樣做的目的就是減少一次從核心緩衝區到使用者快取的資料複製。這種訪問檔案的方式通常實在對資料的快取管理由應用程式實現的資料庫管理系統中。如在資料庫管理系統中,系統明確知道應該快取哪些資料,應該失效哪些資料,還可以對一些熱點資料做預載入,提前將熱點資料載入到記憶體,可以加速資料的訪問效率。如果由作業系統進行快取,則很難做到,因為作業系統不知道哪些是熱點資料。但直接I/O也有負面影響,如果訪問的資料不再應用程式快取中,那麼每次資料都會直接從磁碟進行載入。通常直接I/O與非同步I/O結合使用會得到比較好的效能。
這裡寫圖片描述

3、記憶體對映的方式
記憶體對映的方式是指作業系統記憶體中的某一塊區域與磁碟中的檔案關聯起來,當要訪問記憶體中的一段資料時,轉換為訪問檔案的某一段資料。這種方式的目的同樣是減少資料從核心空間快取到使用者空間快取的資料複製操作,因為這兩個空間的資料是共享的。
這裡寫圖片描述

上面介紹瞭如何操作資料,基於位元組、字元、磁碟、網路的I/O。還有一個關鍵的問題就是資料寫到何處,其中一種主要的方式就是將資料持久化到物理磁碟。
我們知道,資料在磁碟中的唯一最小描述就是檔案,也就是說上層應用程式只能通過檔案來操作磁碟的資料,檔案也是作業系統和磁碟驅動器互動的最小單元。 值得注意的是,在JAVA中通常的File並不代表一個真實存在的檔案物件,當你指定一個路徑描述符時,他就會返回一個帶有路徑的虛擬檔案物件,這可能是一個真實存在的檔案或者是一個包含多個檔案的目錄。為什麼這樣設計呢?因為大多數情況下,我們並不關心這個檔案是否真的存在,而是關心對這個檔案到底如何操作。
FileInputStream類都是操作一個檔案的介面,注意在建立一個FileInputStream物件時會建立一個FileDescriptor物件,其實這個物件就是真正代表一個存在的檔案物件的描述。當我們在操作一個檔案物件時,可以通過getFD()方法獲取真正操作的與底層作業系統相關的檔案描述。例如,可以呼叫FileDescriptor.sync()方法將作業系統快取中的資料強制重新整理到物理磁碟中。


下面以前面讀取檔案的程式為例介紹如何從磁碟讀取一段文字字元。
當傳入一個檔案路徑時,將會根據這個路徑建立一個File物件來標識這個檔案,然後根據這個File物件建立真正讀取檔案的操作物件,這時將會真正建立一個關聯真實存在的磁碟檔案的檔案描述符FileDescriptor,通過這個物件可以直接控制整個磁碟檔案。由於我們讀取的字元格式,所以需要StreamDecoder類將byte解碼為char格式。至於如何從磁碟驅動器上讀取一段資料,作業系統會幫我們完成。至於作業系統是如何將數持久化到磁碟及如何建立資料結構的,需要根據當前作業系統使用何種檔案系統來回答。

這裡寫圖片描述

Java序列化就是將一個物件轉化成一串二進位制表示的位元組陣列,通過儲存或轉移這些位元組資料來達到持久化的目的。需要持久化,物件必須繼承java.io.Serializable介面。反序列化則是相反的過程,將這個位元組資料再重新構造物件。我們知道反序列化時,必須有原始類作為模板,才能將這個物件還原,從這個過程我們猜測,序列化的資料並不想class檔案那樣儲存類的完整的資訊結構資訊。具體二進位制流儲存哪些資訊在此不敘述。

Java Socket工作機制:
Socket這個概念沒有對應到一個具體的實體,他描述計算機之間完成相互通訊的一種抽象功能。打比方,可以把Socket比作兩個城市之間的交通工具,有了它,就可以在城市之間來回穿梭了。交通工具有多種,每種交通工具也有相應的交通規則。Socket也一樣,也有多種。大部分情況下我們使用的都是基於TCP/IP的流套接字。主機A的應用程式要能和主機B的應用程式通訊,必須通過Socket建立連線,而建立Socket連線必須由底層TCP/IP建立TCP連線。建立TCP連線需要底層IP啦定址網路中的主機。我們知道網路層使用的IP可以幫助我們根據IP地址來找到目標主機,但是一臺主機上可能執行著多個應用程式,如果才能與制定的應用程式通訊就要通過TCP或者UDP的地址也就是埠號來指定。這樣就可以通過一個Socket例項來唯一代表一個主機上的應用程式的通訊鏈路了。

建立通訊鏈路:
當客戶端要與服務端通訊時,客戶端首先要建立一個Socket例項,作業系統將為這個Socket例項分配一個沒有被使用的本地埠號,並建立一個包含本地地址、遠端地址和埠號的套接字資料結構,這個資料結構將一直儲存在系統中直到這個連結關閉。在建立Socket例項的建構函式正確返回之前,將要進行TCP的3次握手協議,TCP握手協議完成後,Socket例項物件將建立完成,否則將丟擲IOException。
與之對應的服務端將建立一個ServerSocket例項,建立ServerSocket比較簡單,只要指定埠號沒有被佔用,一般例項建立都會成功。同時作業系統也會為ServerSocket例項建立一個底層資料結構,在這個資料結構中包含指定監聽的埠號和包含監聽地址的萬用字元,通常情況下都是“*”,即監聽所有地址。之後當呼叫accept()方法時,將進入阻塞狀態,等待客戶端請求。當一個新的請求到來時,將為這個連線建立一個新的套接字資料結構,該套接字資料的資訊包含的地址和埠資訊正是請求源地址和埠。這個新建立的資料結構將會被關聯到ServerSocket例項的一個未完成的連線資料結構列表中。注意,這時服務端的與之對應的Socket例項並沒有返回建立,而是等到與客戶端的3次握手完成後,這個服務端的Socket例項才會返回,並將這個Socket例項對應的資料結構從未完成列表中移到已完成列表中。所以與ServerSocket所關聯的列表中每個資料結構都代表與一個客戶端建立的TCP連線。

傳輸資料使我們建立連線的目的。
當連線已經建立成功時,服務端和客戶端都會擁有一個Socket例項,每個Socket例項都有一個InputStream和OutputStream,並通過這兩個物件交換資料。同時我們也知道網路I/O都是以位元組流傳輸的,當建立Socket物件時,作業系統會為InputStream和OutputStream分別分配一定大小的快取區,資料的寫入和讀取都是通過這個快取區完成的。寫入端將資料寫到OutputStream對應的SendQ佇列中,當佇列滿了,資料將被轉移到另一端InputStream的RecvQ佇列中,如果這時RecvQ已經滿了,那麼OutputStream的write方法將會阻塞,直到RecvQ佇列有足夠的空間容納SendQ傳送的資料。特別值得注意的是,這個緩衝區的大小及寫入端的速度和讀取端的速度非常影響整個連線的資料傳輸效率,由於可能發生阻塞,所以網路I/O與磁碟I/O不同的是資料寫入和讀取還要有一個協調的過程,如果兩邊同時傳送資料可能會產生死鎖。

BIO帶來的挑戰:
阻塞I/O,不論是磁碟I/O,還是網路I/O都有可能出現阻塞,一旦有阻塞,執行緒將會失去CPU的使用權。雖然當前的網路I/O有一些解決辦法,如一個客戶端對應一個處理執行緒,出現阻塞時只是一個執行緒阻塞而不會影響其他執行緒工作。還有為了減少系統執行緒的開銷,採用執行緒池的辦法來減少執行緒建立和回收的成本,但有些場景依然無法解決,比如需要大量HTTP長連結的情況,服務端需要同時保持幾百萬的HTTP連線,但並不是每時每刻這些連線都在傳輸資料,在這種情況下不可能同時建立這麼多執行緒來保持連線。

NIO例子:

public void selector() throws Exception{
        ByteBuffer buffer = ByteBuffer.allocate(1024);
        //建立一個靜態的選擇器
        Selector selector = Selector.open();
        ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
        //設定為非阻塞方式
        serverSocketChannel.configureBlocking(false);
        //建立服務端Channel,繫結到Socket
        serverSocketChannel.socket().accept().bind(new InetSocketAddress(8080));
        //註冊監聽事件
        serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
        while(true){
            Set selectedKeys = selector.selectedKeys();
            Iterator iterator = selectedKeys.iterator();
            //遍歷返回的key,檢視是否有事件發生
            while (iterator.hasNext()){
                SelectionKey key = (SelectionKey) iterator.next();
                if ((key.readyOps()&SelectionKey.OP_ACCEPT)==SelectionKey.OP_ACCEPT){
                    ServerSocketChannel ssChannel = (ServerSocketChannel)key.channel();
                    //接受到服務端的請求
                    SocketChannel sc = ssChannel.accept();
                    sc.configureBlocking(false);
                    sc.register(selector,SelectionKey.OP_READ);
                    iterator.remove();
                }else if ((key.readyOps()&SelectionKey.OP_READ)==SelectionKey.OP_READ){
                    SocketChannel sc =(SocketChannel) key.channel();
                    while (true){
                        buffer.clear();
                        //讀取資料
                        int  n = sc.read(buffer);
                        if (n<=0){
                            break;
                        }
                        buffer.flip();
                    }
                    iterator.remove();
                }
            }
        }
    }