Java深度歷險(八)Java I/O



Java語言提供了多個層次不同的概念來對I/O操作進行抽象。Java I/O中最早的概念是流,包括輸入流和輸出流,早在JDK 1.0中就存在了。簡單的來說,流是一個連續的字節的序列。輸入流是用來讀取這個序列,而輸出流則構建這個序列。InputStream和OutputStream所操縱的基本單元就是字節。每次讀取和寫入單個字節或是字節數組。如果從字節的層次來處理數據類型的話,操作會非常繁瑣。可以用更易使用的流實現來包裝基本的字節流。如果想讀取或輸出Java的基本數據類型,可以使用DataInputStream和DataOutputStream。它們所提供的類似readFloat和writeDouble這樣的方法,會讓處理基本數據類型變得很簡單。如果希望讀取或寫入的是Java中的對象的話,可以使用ObjectInputStream和ObjectOutputStream。它們與對象的序列化機制一起,可以實現Java對象狀態的持久化和數據傳遞。基本流所提供的對於輸入和輸出的控制比較弱。InputStream只提供了順序讀取、跳過部分字節和標記/重置的支持,而OutputStream則只能順序輸出。


流的使用


由於I/O操作所對應的實體在系統中都是有限的資源,需要妥善的進行管理。每個打開的流都需要被正確的關閉以釋放資源。所遵循的原則是誰打開誰釋放。如果一個流只在某個方法體內使用,則通過finally語句或是JDK 7中的try-with-resources語句來確保在方法返回之前,流被正確的關閉。如果一個方法只是作爲流的使用者,就不需要考慮流的關閉問題。典型的情況是在servlet實現中並不需要關閉HttpServletResponse中的輸出流。如果你的代碼需要負責打開一個流,並且需要在不同的對象之間進行傳遞的話,可以考慮使用Execute Around Method模式。如下面的代碼所示:


public void use(StreamUser user) {
InputStream input = null;
try {
input = open();
user.use(input);
} catch(IOException e) {
user.onError(e);
} finally {
if (input != null) {
try { 
input.close();
} catch (IOException e) {
user.onError(e);
}
}
}
} 


如上述代碼中所看到的一樣,由專門的類負責流的打開和關閉。流的使用者StreamUser並不需要關心資源釋放的細節,只需要對流進行操作即可。


在使用輸入流的過程中,經常會遇到需要複用一個輸入流的情況,即多次讀取一個輸入流中的內容。比如通過URL.openConnection方法打開了一個遠端站點連接的輸入流,希望對其中的內容進行多次處理。這就需要把一個InputStream對象在多個對象中傳遞。爲了保證每個使用流的對象都能獲取到正確的內容,需要對流進行一定的處理。通常有兩種解決的辦法,一種是利用InputStream的標記支持。如果一個流支持標記的話(通過markSupported方法判斷),就可以在流開始的地方通過mark方法添加一個標記,當完成一次對流的使用之後,通過reset方法就可以把流的讀取位置重置到上次標記的位置,即流開始的地方。如此反覆,就可以複用這個輸入流。大部分輸入流的實現是不支持標記的。可以通過BufferedInputStream進行包裝來支持標記。

private InputStream prepareStream(InputStream ins) {
BufferedInputStream buffered = new BufferedInputStream(ins);
buffered.mark(Integer.MAX_VALUE);
return buffered;
} 
private void resetStream(InputStream ins) throws IOException {
ins.reset();
ins.mark(Integer.MAX_VALUE);
} 


如上面的代碼所示,通過prepareStream方法可以用一個BufferedInputStream來包裝基本的InputStream。通過 mark方法在流開始的時候添加一個標記,允許讀入Integer.MAX_VALUE個字節。每次流使用完成之後,通過resetStream方法重置即可。


另外一種做法是把輸入流的內容轉換成字節數組,進而轉換成輸入流的另外一個實現ByteArrayInputStream。這樣做的好處是使用字節數組作爲參數傳遞的格式要比輸入流簡單很多,可以不需要考慮資源相關的問題。另外也可以儘早的關閉原始的輸入流,而無需等待所有使用流的操作完成。這兩種做法的思路其實是相似的。BufferedInputStream在內部也創建了一個字節數組來保存從原始輸入流中讀入的內容。


private byte[] saveStream(InputStream input) throws IOException {
ByteBuffer buffer = ByteBuffer.allocate(1024);
ReadableByteChannel readChannel = Channels.newChannel(input);
ByteArrayOutputStream output = new ByteArrayOutputStream(32 * 1024);
WritableByteChannel writeChannel = Channels.newChannel(output);
while ((readChannel.read(buffer)) > 0 || buffer.position() != 0) {
buffer.flip();
writeChannel.write(buffer);
buffer.compact();
}
return output.toByteArray();
} 


上面的代碼中saveStream方法把一個InputStream保存爲字節數組。


緩衝區


由於流背後的數據有可能比較大,在實際的操作中,通常會使用緩衝區來提高性能。傳統的緩衝區的實現是使用數組來完成。比如經典的從InputStream到OutputStream的複製的實現,就是使用一個字節數組作爲中間的緩衝區。NIO中引入的Buffer類及其子類,可以很方便的用來創建各種基本數據類型的緩衝區。相對於數組而言,Buffer類及其子類提供了更加豐富的方法來對其中的數據進行操作。後面會提到的通道也使用Buffer類進行數據傳遞。


在Buffer上進行的元素添加和刪除操作,都圍繞3個屬性position、limit和capacity展開,分別表示Buffer當前的讀寫位置、可用的讀寫範圍和容量限制。容量限制是在創建的時候指定的。Buffer提供的get/put方法都有相對和絕對兩種形式。相對讀寫時的位置是相對於position的值,而絕對讀寫則需要指定起始的序號。在使用Buffer的常見錯誤就是在讀寫操作時沒有考慮到這3個元素的值,因爲大多數時候都是使用的是相對讀寫操作,而position的值可能早就發生了變化。一些應該注意的地方包括:將數據讀入緩衝區之前,需要調用clear方法;將緩衝區中的數據輸出之前,需要調用flip方法。

ByteBuffer buffer = ByteBuffer.allocate(32);
CharBuffer charBuffer = buffer.asCharBuffer();
String content = charBuffer.put("Hello ").put("World").flip().toString();
System.out.println(content); 


上面的代碼展示了Buffer子類的使用。首先可以在已有的ByteBuffer上面創建出其它數據類型的緩衝區視圖,其次Buffer子類的很多方法是可以級聯的,最後是要注意flip方法的使用。


字符與編碼


在程序中,總是免不了與字符打交道,畢竟字符是用戶直接可見的信息。而與字符處理直接相關的就是編碼。相信不少人都曾經爲了程序中的亂碼問題而困擾。要弄清楚這個問題,就需要理解字符集和編碼的概念。字符集,顧名思義,就是字符的集合。一個字符集中所包含的字符通常與地區和語言有關。字符集中的每個字符通常會有一個整數編碼與其對應。常見的字符集有ASCII、ISO-8859-1和Unicode等。對於字符集中的每個字符,爲了在計算機中表示,都需要轉換某種字節的序列,即該字符的編碼。同一個字符集可以有不同的編碼方式。如果某種編碼格式產生的字節序列,用另外一種編碼格式來解碼的話,就可能會得到錯誤的字符,從而產生亂碼的情況。所以將一個字節序列轉換成字符串的時候,需要知道正確的編碼格式。


NIO中的java.nio.charset包提供了與字符集相關的類,可以用來進行編碼和解碼。其中的CharsetEncoder和CharsetDecoder允許對編碼和解碼過程進行精細的控制,如處理非法的輸入以及字符集中無法識別的字符等。通過這兩個類可以實現字符內容的過濾。比如應用程序在設計的時候就只支持某種字符集,如果用戶輸入了其它字符集中的內容,在界面顯示的時候就是亂碼。對於這種情況,可以在解碼的時候忽略掉無法識別的內容。


String input = "你123好";
Charset charset = Charset.forName("ISO-8859-1");
CharsetEncoder encoder = charset.newEncoder();
encoder.onUnmappableCharacter(CodingErrorAction.IGNORE);
CharsetDecoder decoder = charset.newDecoder();
CharBuffer buffer = CharBuffer.allocate(32);
buffer.put(input);
buffer.flip();
try {
ByteBuffer byteBuffer = encoder.encode(buffer);
CharBuffer cbuf = decoder.decode(byteBuffer);
System.out.println(cbuf); //輸出123
} catch (CharacterCodingException e) {
e.printStackTrace();
} 




上面的代碼中,通過使用ISO-8859-1字符集的編碼和解碼器,就可以過濾掉字符串中不在此字符集中的字符。


Java I/O在處理字節流字之外,還提供了處理字符流的類,即Reader/Writer類及其子類,它們所操縱的基本單位是char類型。在字節和字符之間的橋樑就是編碼格式。通過編碼器來完成這兩者之間的轉換。在創建Reader/Writer子類實例的時候,總是應該使用兩個參數的構造方法,即顯式指定使用的字符集或編碼解碼器。如果不顯式指定,使用的是JVM的默認字符集,有可能在其它平臺上產生錯誤。


通道


通道作爲NIO中的核心概念,在設計上比之前的流要好不少。通道相關的很多實現都是接口而不是抽象類。通道本身的抽象層次也更加合理。通道表示的是對支持I/O操作的實體的一個連接。一旦通道被打開之後,就可以執行讀取和寫入操作,而不需要像流那樣由輸入流或輸出流來分別進行處理。與流相比,通道的操作使用的是Buffer而不是數組,使用更加方便靈活。通道的引入提升了I/O操作的靈活性和性能,主要體現在文件操作和網絡操作上。


文件通道


對文件操作方面,文件通道FileChannel提供了與其它通道之間高效傳輸數據的能力,比傳統的基於流和字節數組作爲緩衝區的做法,要來得簡單和快速。比如下面的把一個網頁的內容保存到本地文件的實現。
FileOutputStream output = new FileOutputStream("baidu.txt");
FileChannel channel = output.getChannel();
URL url = new URL("http://www.baidu.com");
InputStream input = url.openStream();
ReadableByteChannel readChannel = Channels.newChannel(input);
channel.transferFrom(readChannel, 0, Integer.MAX_VALUE); 

文件通道的另外一個功能是對文件的部分片段進行加鎖。當在一個文件上的某個片段加上了排它鎖之後,其它進程必須等待這個鎖釋放之後,才能訪問該文件的這個片段。文件通道上的鎖是由JVM所持有的,因此適合於與其它應用程序協同時使用。比如當多個應用程序共享某個配置文件的時候,如果Java程序需要更新此文件,則可以首先獲取該文件上的一個排它鎖,接着進行更新操作,再釋放鎖即可。這樣可以保證文件更新過程中不會受到其它程序的影響。


另外一個在性能方面有很大提升的功能是內存映射文件的支持。通過FileChannel的map方法可以創建出一個MappedByteBuffer對象,對這個緩衝區的操作都會直接反映到文件內容上。這點尤其適合對大文件進行讀寫操作。

套接字通道
在套接字通道方面的改進是提供了對非阻塞I/O和多路複用I/O的支持。傳統的流的I/O操作是阻塞式的。在進行I/O操作的時候,線程會處於阻塞狀態等待操作完成。NIO中引入了非阻塞I/O的支持,不過只限於套接字I/O操作。所有繼承自SelectableChannel的通道類都可以通過configureBlocking方法來設置是否採用非阻塞模式。在非阻塞模式下,程序可以在適當的時候查詢是否有數據可供讀取。一般是通過定期的輪詢來實現的。
多路複用I/O是一種新的I/O編程模型。傳統的套接字服務器的處理方式是對於每一個客戶端套接字連接,都新創建一個線程來進行處理。創建線程是很耗時的操作,而有的實現會採用線程池。不過一個請求一個線程的處理模型並不是很理想。原因在於耗費時間創建的線程,在大部分時間可能處於等待的狀態。而多路複用I/O的基本做法是由一個線程來管理多個套接字連接。該線程會負責根據連接的狀態,來進行相應的處理。多路複用I/O依靠操作系統提供的select或相似系統調用的支持,選擇那些已經就緒的套接字連接來處理。可以把多個非阻塞I/O通道註冊在某個Selector上,並聲明所感興趣的操作類型。每次調用Selector的select方法,就可以選擇到某些感興趣的操作已經就緒的通道的集合,從而可以進行相應的處理。如果要執行的處理比較複雜,可以把處理轉發給其它的線程來執行。


下面是一個簡單的使用多路複用I/O的服務器實現。當有客戶端連接上的時候,服務器會返回一個Hello World作爲響應。

private static class IOWorker implements Runnable {
public void run() {
try {
Selector selector = Selector.open();
ServerSocketChannel channel = ServerSocketChannel.open();
channel.configureBlocking(false);
ServerSocket socket = channel.socket();
socket.bind(new InetSocketAddress("localhost", 10800));
channel.register(selector, channel.validOps());
while (true) {
selector.select();
Iterator iterator = selector.selectedKeys().iterator();
while (iterator.hasNext()) {
SelectionKey key = iterator.next();
iterator.remove();
if (!key.isValid()) {
continue;
}
if (key.isAcceptable()) {
ServerSocketChannel ssc = (ServerSocketChannel) key.channel();
SocketChannel sc = ssc.accept();
sc.configureBlocking(false);
sc.register(selector, sc.validOps()); 
}
if (key.isWritable()) {
SocketChannel client = (SocketChannel) key.channel();
Charset charset = Charset.forName("UTF-8");
CharsetEncoder encoder = charset.newEncoder();
CharBuffer charBuffer = CharBuffer.allocate(32);
charBuffer.put("Hello World");
charBuffer.flip();
ByteBuffer content = encoder.encode(charBuffer);
client.write(content);
key.cancel();
}
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
}


上面的代碼給出的只是非常簡單的示例程序,只是展示了多路複用I/O的基本使用方式。在開發複雜網絡應用程序的時候,使用一些Java NIO網絡應用框架會讓你事半功倍。目前來說最流行的兩個框架是Apache MINA和Netty。在使用了Netty之後,Twitter的搜索功能速度提升達到了3倍之多。網絡應用開發人員都可以使用這兩個開源的優秀框架。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章