細說Java IO相關


概述

  在大部分的行業系統或者功能性需求中,對於程序員來說,接觸到io的機會還是比較少的,其中大多也是簡單的上傳下載、讀寫文件等簡單運用。最近工作中都是網絡通信相關的應用,接觸io、nio等比較多,所以嘗試着深入學習並且描述下來。

  io往往是我們忽略但是卻又非常重要的部分,在這個講究人機交互體驗的年代,io問題漸漸成了核心問題。Java傳統的io是基於流的io,從jdk1.4開始提供基於塊的io,即nio,會在后面的文章介紹。

  流的概念可能比較抽象,可以想象一下水流的樣子。

  io在本質上是單個字節的移動,而流可以說是字節移動的載體和方式,它不停的向目標處移動數據,我們要做的就是根據流的方向從流中讀取數據或者向流中寫入數據。

  想象下倒水的場景:倒一杯水,水是連成一片往地上流動,而不是等杯中的水全部倒出懸浮在空中,然后一起掉落地面。最簡單的Java流的例子就是下載電影,肯定不是等電影全部下載在內存中再保存到磁盤上,本質上是下載一個字節就保存一個字節。

  一個流,必有源和目標,它們可以是計算機內存的某些區域,也可以是磁盤文件,甚至可以是Internet上的某個URL。流的方向是重要的,根據流的方向,流可分為兩類:輸入流和輸出流。我們從輸入流讀取數據,向輸出流寫入數據。

io分類

  Java對io的支持主要集中在io包下,顯然可以分為下面兩類:

  1. 基於字節操作的io接口:InputStream 和 OutputStream
  2. 基於字符操作的io接口:Writer 和 Reader

  不管磁盤還是網絡傳輸,最小的存儲單位都是字節。但是程序中操作的數據大多都是字符形式的,所以Java也提供了字符型的流。io包下的類主要提供了io流本身的支持:流的形態,流里裝的是什么。但是流並不等於io,還有很重要的一點:數據的傳輸方式,也就是數據寫到哪里的問題,主要是以下兩種:

  1. 基於磁盤操作的io接口:File
  2. 基於網絡操作的io接口:Socket

  對此Java的其他一些類庫提供了支持。

字節流、字符流的io接口說明

  字節流包括輸入流InputStream和輸出流OutputStream。字符流包括輸入流Reader,

    InputStream相關類圖如下,只列舉了一級子類:

   

    InputStream提供了一些read方法供子類繼承,用來讀取字節。

    OutputStream相關類圖如下:

   

    OutputStream提供了一些write方法供子類繼承,用來寫入字節。

    Reader相關類圖如下:

   

    Reader提供了一些read方法供子類繼承,用來讀取字符。

    Writer相關類圖如下:

   

    Writer提供了一些write方法供子類繼承,用來寫入字符。

    每個字符流子類幾乎都會有一個相對應的字節流子類,兩者功能一樣,差別只是在於操作的是字節還是字符。例如CharArrayReader和 ByteArrayInputStream,兩者都是在內存中建立數組緩沖區作為輸入流,不同的只是前者數組用來存放字符,每次從數組中讀取一個字符;后 者則是針對字節。

ByteArrayInputStream、CharArrayReader 為多線程的通信提供緩沖區操作功能。常用於讀取網絡中的定長數據包
ByteArrayOutputStream、CharArrayWriter 為多線程的通信提供緩沖區操作功能。常用於接收足夠長度的數據后進行一次性寫入
FileInputStream、FileReader 把文件寫入內存作為輸入流,實現對文件的讀取操作
FileOutputStream、FileWriter 把內存中的數據作為輸出流寫入文件,實現對文件的寫操作
StringReader 讀取String的內容作為輸入流
StringWriter 將數據寫入一個String
SequenceInputStream 將多個輸入流中的數據合並為一個數據流
PipedInputStream、PipedReader、PipedOutputStream、PipedWriter 管道流,主要用於2個線程之間傳遞數據
ObjectInputStream 讀取對象數據作為輸入流對象中的 transient 和 static 類型的成員變量不會被讀取或寫入
ObjectOutputStream 將數據寫入對象
FilterInputStream、FilterOutputStream、FilterReader、FilterWriter 過濾流通常源和目標是其他的輸入輸出流,大家可以看到有眾多的子類,各有用途,就不一一介紹了

字節流和字符流轉換

  任何數據的持久化和網絡傳輸都是以字節形式進行的,所以字節流和字符流之間必然存在轉換問題。字符轉字節是編碼過程,字節轉字符是解碼過程。io包中提供了InputStreamReader和OutputStreamWriter用於字符和字節的轉換。

  來看一個小例子:

char[] charArr = new char[1]; StringBuffer sb = new StringBuffer(); FileReader fr = new FileReader("test.txt"); while(fr.read(charArr) != -1) { sb.append(charArr); } System.out.println("編碼:" + fr.getEncoding()); System.out.println("文件內容:" + sb.toString());

   FileReader類其實就是簡單的包裝一下FileInputStream,但是它繼承InputStreamReader類,當調用read方法時其實調用的是StreamDecoder類的read方法,這個StreamDecoder正是完成字節到字符的解碼的實現類。如下圖:

  

  InputStream 到 Reader 的過程要指定編碼字符集,否則將采用操作系統默認字符集,很可能會出現亂碼問題。上例代碼輸出如下:

編碼:UTF8
文件內容:hello�����Dz����ļ�!

  再來看一個例子,換一個字符集:

char[] charArr = new char[1];
StringBuffer sb = new StringBuffer();
//設置編碼
InputStreamReader isr = new InputStreamReader(
                                          new FileInputStream("D:/test.txt")
                                          , "GBK");
while(isr.read(charArr) != -1)
{
    sb.append(charArr);
}
System.out.println("編碼:" + isr.getEncoding());
System.out.println("文件內容:" + sb.toString());        

  輸出正常:

編碼:GBK
文件內容:hello!我是測試文件!

   編碼過程也是類似的,就不再說了。

io包與設計模式

  對於io包,下面的用法是經常看到的:

InputStream in = new BufferedInputStream(new ObjectInputStream(new FileInputStream(new File("xxx"))));

  很自然的想到了Decorator(裝飾器)模式,Java的io包屬於Decorator模式的經典案例。GOF對於Decorator的適用性是這么描述的:

  • 在不影響其他對象的情況下,以動態、透明的方式給單個對象添加職責。
  • 處理那些可以撤銷的職責。
  • 當不能采用生成子類的方法進行擴充時。一種情況是,可能有大量獨立的擴展,為支持每一種組合將產生大量的子類,使得子類數據呈爆炸性增長。另一種情況可能是因為類定義被隱藏,或類定義被隱藏,或類定義不能生成子類。

  以InputStream為例。假設這么一種情況:現在只有InputStream類,需要根據需求設計它的子類。

  需求1:讀取某個文件到內存中的一個緩沖區的流

  需求2:讀取某個文件並提供行計數器的流

  需求3:讀取某個文件並反序列化為對象的流

  理所應當,我們建立3個子類:FileBufferedInputStream、FileLineNumberInputStreamFileObjectInputStream。但是如果再來N個這樣的需求,那么類圖將會變為下圖這樣:

  

  出現了“類爆炸”的情況,java顯然沒有這樣做,以InputStream為例,實際情況如下圖:

  

  對應Decorator模式的類圖,InputStream的角色是Component,它主要定義了read抽象方法;FileInputStream、ByteArrayInputStream、ObjectInputStream、PipedInputStream、 SequenceInputStream的角色是ConcreteComponent,它們都是具有某種功能的流。其中前四者,它們的源是byte數組、或者String對象、或者文件等,可以看作是真正的數據來源,被稱作原始流。

  FilterInputStream類即是Decorator模式中的Decorator角色,裝飾器,部分代碼如下:

protected volatile InputStream in;

protected FilterInputStream(InputStream in) {
  this.in = in;
}

  它派生出的多個子類即是ConcreteDecorator,用來給輸入流加上不同的功能。它們的源通常都是其他的輸入流,所以也叫它們鏈接流。

  那么java為什么要這么設計呢?前面說的“類爆炸”是一個原因;另外通過子類來擴展基類功能是靜態的,而裝飾器模式是動態的添加組合功能,使用中非常靈活,並且減少了大量的功能重復。

  另一種在io包中普遍存在的設計模式是Adapter(適配器)模式,以InputStream子類FileInputStream為例,部分代碼如下:

/* File Descriptor - handle to the open file */
private FileDescriptor fd;

public FileInputStream(File file) throws FileNotFoundException {
   ...
   fd = new FileDescriptor();
   ...
}

  在FileInputStream繼承了InputStrem類型,同時持有一個對FileDiscriptor的引用。這是將一個FileDiscriptor對象適配成InputStrem類型的對象形式的適配器模式。如下圖:

   

  其他例子就不多說了。

磁盤IO工作機制

  io中數據寫到何處也是重要的一點,其中最主要的就是將數據持久化到磁盤。數據在磁盤上最小的描述就是文件,上層應用對磁盤的讀和寫都是針對文件而言的。在java中,以File類來表示文件,如:

File file = new File("D:/test.txt");

  但是嚴格來說,File並不表示一個真實的存在於磁盤上的文件。就像上面代碼的文件其實並不存在,File做的只是根據你所提供的文件描述符,返回某一路徑的虛擬對象,它並不關心文件或路徑是否存在,可能存在,也可能是捏造的。就好象一張名片,名片的背后代表的是人。為什么要這么設計?在我看來還是要提高訪問磁盤的效率,有點延遲加載的意思。大部分情況下,我們最關心的並不是文件存不存在,而是文件要如何操作。比如你手里有很多名片,你可能更關心的是有沒有某某局長的名片,而只有在需要聯系時,才發現名片是假的。也就是關心名片本身要強過名片的真偽。

  以FileInputStream讀取文件為例,過程是這樣的:當傳入一個文件路徑時,會根據這個路徑創建File對象,作為這個文件的一個“名片”。當我們試圖通過FileInputStream對象去操作文件的時候,將會真正創建一個關聯真實存在的磁盤文件的文件描述符FileDescriptor,通過FileInputStream構造方法可以看出:

fd = new FileDescriptor();

  如果說File是文件的名片,那么FileDescriptor就是真正指向了一個打開的文件,可以操作磁盤文件。例如FileDescriptor.sync()方法可以將緩存中的數據強制刷新到磁盤文件中。如果我們需要讀取的是字符,還需要通過StreamDecoder類將字節解碼成字符。至於如何從物理磁盤上讀取數據,那就是操作系統做的事情了。過程如圖(圖摘自網上):

  圖 7. 從磁盤讀取文件

Socket工作機制

  Socket要說起來並不那么形象,它的中文翻譯是“插座”,至於“套接字”這個翻譯我實在不知道從何而來。可以這樣理解插座的概念,由於本身有電網的存在,如果我們買了一台新電器,我們只要插上插座連接到電網上就能夠使用。Socket就像一個插座,計算機通過Socket就能和網絡或者其他計算機上進行通訊;當有數據通訊的需求時,只需要建立一個Socket“插座”,通過網卡與其他計算機相連獲取數據。

  Socket位於傳輸層和應用層之間,向應用層統一提供編程接口,應用層不必知道傳輸層的協議細節。Java中對Socket的支持主要是以下兩種:

  (1)基於TCP的Socket:提供給應用層可靠的流式數據服務,使用TCP的Socket應用程序協議:BGP,HTTP,FTP,TELNET等。優點:基於數據傳輸的可靠性。

  (2)基於UDP的Socket:適用於數據傳輸可靠性要求不高的場合。基於UDP的Socket應用程序協議:RIP,SNMP,L2TP等。

  大部分情況下我們使用的都是基於TCP/IP協議的流Socket,因為它是一種穩定的通信協議。以此為例:

  一台計算機要和另一台計算機進行通訊,獲取其上應用程序的數據,必須通過Socket建立連接,要知道對方的IP和端口號。建立一個Socket連接需要通過底層TCP/IP協議來建立TCP連接,而建立TCP連接必須通過底層IP協議根據給定的IP在網絡中找到目標主機。目標計算機上可能跑着多個應用,所以我們必須要根據端口號來制定目標應用程序,這樣就可以通過一個 Socket 實例唯一代表一個主機上的一個應用程序的通信鏈路了。

  那么Socket是如何建立通訊鏈路的呢?

  假設有一台計算機作為客戶端,另一台作為服務端。當客戶端需要向服務端通信,客戶端首先要創建一個Socket實例:

Socket socket = new Socket("127.0.0.1",1234);

  若沒有指定端口號,操作系統將為這個Socket實例分配一個沒有被使用的本地端口號。此外創建了一個包含本地和遠程地址和端口號的套接字數據結構,這個數據結構將一直保存在系統中直到這個連接關閉,代碼如下:

public Socket(String host, int port)
    throws UnknownHostException, IOException
{
    this(host != null ? new InetSocketAddress(host, port) :
         new InetSocketAddress(InetAddress.getByName(null), port),
         (SocketAddress) null, true);
}

  客戶端試圖和服務端建立TCP連接,此時會進行三次握手。

  第一次握手:建立連接時,客戶端發送syn包(syn=j)到服務器,並進入SYN_SEND狀態,等待服務器確認;

  第二次握手:服務器收到syn包,必須確認客戶的SYN(ack=j+1),同時自己也發送一個SYN包(syn=k),即SYN+ACK包,此時服務器進入SYN_RECV狀態;

  第三次握手:客戶端收到服務器的SYN+ACK包,向服務器發送確認包ACK(ack=k+1),此包發送完畢,客戶端和服務器進入ESTABLISHED狀態,完成三次握手。

  

  完成三次握手后Socket的構造函數成功返回,Socket實例創建完畢。

  互聯網是一種盡力而為(best-effort)的網絡,客戶端的起始消息或服務器端的回復消息都可能在傳輸過程中丟失。出於這個原因,TCP 協議實現將以遞增的時間間隔重復發送幾次握手消息。如果TCP客戶端在一段時間后還沒有收到服務器的返回消息,則發生超時並放棄連接。這種情況下,構造函數將拋出IOException 異常。

  而服務端也需要創建與之對應的ServerSocket,ServerSocket的創建比較簡單,只需要指定端口號:

ServerSocket serverSocket = new ServerSocket(10001);

  同時操作系統也會為ServerSocket實例創建一個底層數據結構:

bind(new InetSocketAddress(bindAddr, port), backlog);  //見構造方法

  這個數據結構中包含指定監聽的端口號和包含監聽地址的通配符,通常情況下是監聽所有地址,下面是比較典型的ServerSocket代碼:

public void testSocket() throws Exception
{
    ServerSocket serverSocket = new ServerSocket(10002);
    Socket socket = null;
    try
    {
        while (true)
        {
            socket = serverSocket.accept();
            System.out.println("socket連接:" + socket.getRemoteSocketAddress().toString());
            BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream()));
            while(true)
            {
                String readLine = in.readLine();
                System.out.println("收到消息" + readLine);
                if("end".equals(readLine))
                {
                    break;
                }
                //客戶端斷開連接
                socket.sendUrgentData(0xFF);
            }
        }
    }
    catch (SocketException se)
    {
        System.out.println("客戶端斷開連接");
    }
    catch (IOException e)
    {
        e.printStackTrace();
    }
    finally
    {
        System.out.println("socket關閉:" + socket.getRemoteSocketAddress().toString());
        socket.close();
    }
}   

   當調用accept()方法時,服務端將進入阻塞狀態,等待客戶端的請求。當有客戶端請求到來時,將為這個鏈接創建一個套接字數據結構,包括請求客戶端的地址和端口號。該數據結構將被關聯到ServerSocket實例的一個未連接列表里。此時連接並沒有成功建立,處於三次握手階段,Socket構造函數並未成功返回。當三次握手成功后,會將Socket實例對應的數據結構從未完成列表移到完成列表中。所以 ServerSocket 所關聯的列表中每個數據結構,都代表與一個客戶端的建立的 TCP 連接。

  當連接成功創建后,我們要做的就是傳輸數據,這才是主要目的。如上例代碼,在客戶端和服務端都有一個Socket實例,而每個Socket實例都會擁有一個InputStream和OutputStream,我們正是通過它們傳輸數據。當Socket對象創建時,操作系統將會為InputStream和OutputStream分別分配一定大小的緩沖區,數據的寫入和讀取都是通過緩存區完成的。發送端的緩沖區稱之為SendQ,是一個FIFO的隊列,接收端的緩沖區稱之為RecvQ,同樣也是FIFO隊列。

  數據傳輸時,發送端將數據寫入到OutputStream對應的SendQ隊列中,以字節為單位發送到接收端InputStream的RecvQ隊列中。當SendQ隊列填滿時,發送端的write方法將會阻塞住;而當RecvQ隊列中沒有數據時,接收端的read方法也將被阻塞。

  一些情況下,客戶端和服務端之間可能會產生死鎖問題,例如:

  • 如果在連接建立后,客戶端和服務器端都立即嘗試接收數據,顯然將導致死鎖。
  • 客戶端和服務端都嘗試向對方write數據,並且數據長度大於兩端緩沖區的和。此時會導致不管客戶端還是服務端RecvQSendQ都滿了,剩下的數據無法發送,兩個write操作都不能完成,兩個程序都將永遠保持阻塞狀態,產生死鎖。

  死鎖的問題是要注意的,需要對數據的寫入和讀取做一個協調,解決死鎖的方式可以使用多線程,也可以使用非阻塞的io,這里就不再深究了。

  關於Java中IO的內容大概就說這么多了,后面會寫寫NIO的內容。  


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM