Jack Jiang

我的最新工程MobileIMSDK:http://git.oschina.net/jackjiang/MobileIMSDK
posts - 132, comments - 13, trackbacks - 0, articles - 0

本文原題“《NIO 入門》,作者為“Gregory M. Travis”,他是《JDK 1.4 Tutorial》等書籍的作者。

1、引言

Java NIO是Java 1.4版加入的新特性,雖然Java技術日新月異,但歷經10年,NIO依然為Java技術領域里最為重要的基礎技術棧,而且依據現實的應用趨勢,在可以預見的未來,它仍將繼續在Java技術領域占據重要位置。

網上有關Java NIO的技術文章,雖然寫的也不錯,但通常是看完一篇馬上懵逼。接著再看!然后,會更懵逼。。。 哈哈哈!

本文作者厚積薄發,以遠比一般的技術博客或技術作者更深厚的Java技術儲備,為你由淺入深,從零講解到底什么是Java NIO。本文即使沒有多少 Java 編程經驗的讀者也能很容易地開始學習 NIO。

(本文同步發布于:http://www.52im.net/thread-2640-1-1.html

2、關于作者

Gregory M. Travis:技術顧問、多產的技術作家,現居紐約。他從Java語言發布的第1天起,就已經是Java程序員啦!

Gregory M. Travis是《JDK 1.4 Tutorial》一書的作者,Java程序員應該都清楚,能寫好JDK Tutorial這種書籍或手冊的,除了SUN(現在是Oracle)公司的Java創建者們,余下的也只有各路實打實的Java大牛們才能hold住。

3、在開始之前

3.1 關于本教程

新的輸入/輸出 (NIO) 庫是在 JDK 1.4 中引入的。NIO 彌補了原來的 I/O 的不足,它在標準 Java 代碼中提供了高速的、面向塊的 I/O。通過定義包含數據的類,以及通過以塊的形式處理這些數據,NIO 不用使用本機代碼就可以利用低級優化,這是原來的 I/O 包所無法做到的。

在本教程中,我們將討論 NIO 庫的幾乎所有方面,從高級的概念性內容到底層的編程細節。除了學習諸如緩沖區和通道這樣的關鍵 I/O 元素外,您還有機會看到在更新后的庫中標準 I/O 是如何工作的。您還會了解只能通過 NIO 來完成的工作,如異步 I/O 和直接緩沖區。

在本教程中,我們將使用展示 NIO 庫的不同方面的代碼示例。幾乎每一個代碼示例都是一個大的 Java 程序的一部分,您可以在本文末的附件中下載到這個 Java 程序。在做這些練習時,我們推薦您在自己的系統上下載、編譯和運行這些程序。在您學習了本教程以后,這些代碼將為您的 NIO 編程努力提供一個起點。

本教程是為希望學習更多關于 Java NIO 庫的知識的所有程序員而寫的。為了最大程度地從這里的討論中獲益,您應該理解基本的 Java 編程概念,如類、繼承和使用包。多少熟悉一些原來的 I/O 庫(來自java.io.* 包)也會有所幫助。

雖然本教程要求掌握 Java 語言的工作詞匯和概念,但是不需要有很多實際編程經驗。除了徹底介紹與本教程有關的所有概念外,我還保持代碼示例盡可能短小和簡單。目的是讓即使沒有多少 Java 編程經驗的讀者也能容易地開始學習 NIO。

3.2 如何運行代碼

源代碼歸檔文件(請從本文末的附件下載之)包含了本教程中使用的所有程序。每一個程序都由一個 Java 文件構成。每一個文件都根據名稱來識別,并且可以容易地與它所展示的編程概念相關聯。

教程中的一些程序需要命令行參數才能運行。要從命令行運行一個程序,只需使用最方便的命令行提示符。在 Windows 中,命令行提供符是 “Command” 或者 “command.com” 程序。在 UNIX 中,可以使用任何 shell。

需要安裝 JDK 1.4 并將它包括在路徑中,才能完成本教程中的練習。如果需要安裝和配置 JDK 1.4 的幫助,請參見 參考資料 。

4、輸入/輸出:概念性描述

4.1 I/O 簡介

I/O ? 或者輸入/輸出 ? 指的是計算機與外部世界或者一個程序與計算機的其余部分的之間的接口。它對于任何計算機系統都非常關鍵,因而所有 I/O 的主體實際上是內置在操作系統中的。單獨的程序一般是讓系統為它們完成大部分的工作。

在 Java 編程中,直到最近一直使用 流 的方式完成 I/O。所有 I/O 都被視為單個的字節的移動,通過一個稱為 Stream 的對象一次移動一個字節。流 I/O 用于與外部世界接觸。它也在內部使用,用于將對象轉換為字節,然后再轉換回對象。

NIO 與原來的 I/O 有同樣的作用和目的,但是它使用不同的方式? 塊 I/O。正如您將在本教程中學到的,塊 I/O 的效率可以比流 I/O 高許多。

4.2 為什么要使用 NIO?

NIO 的創建目的是為了讓 Java 程序員可以實現高速 I/O 而無需編寫自定義的本機代碼。NIO 將最耗時的 I/O 操作(即填充和提取緩沖區)轉移回操作系統,因而可以極大地提高速度。

4.3 流與塊的比較

原來的 I/O 庫(在 java.io.*中) 與 NIO 最重要的區別是數據打包和傳輸的方式。正如前面提到的,原來的 I/O 以流的方式處理數據,而 NIO 以塊的方式處理數據。

面向流 的 I/O 系統一次一個字節地處理數據。一個輸入流產生一個字節的數據,一個輸出流消費一個字節的數據。為流式數據創建過濾器非常容易。鏈接幾個過濾器,以便每個過濾器只負責單個復雜處理機制的一部分,這樣也是相對簡單的。不利的一面是,面向流的 I/O 通常相當慢。

一個 面向塊 的 I/O 系統以塊的形式處理數據。每一個操作都在一步中產生或者消費一個數據塊。按塊處理數據比按(流式的)字節處理數據要快得多。但是面向塊的 I/O 缺少一些面向流的 I/O 所具有的優雅性和簡單性。

4.4 集成的 I/O

在 JDK 1.4 中原來的 I/O 包和 NIO 已經很好地集成了。 java.io.* 已經以 NIO 為基礎重新實現了,所以現在它可以利用 NIO 的一些特性。例如, java.io.* 包中的一些類包含以塊的形式讀寫數據的方法,這使得即使在更面向流的系統中,處理速度也會更快。

也可以用 NIO 庫實現標準 I/O 功能。例如,可以容易地使用塊 I/O 一次一個字節地移動數據。但是正如您會看到的,NIO 還提供了原 I/O 包中所沒有的許多好處

5、通道和緩沖區

5.1 概述

通道 和 緩沖區 是 NIO 中的核心對象,幾乎在每一個 I/O 操作中都要使用它們。

通道是對原 I/O 包中的流的模擬。到任何目的地(或來自任何地方)的所有數據都必須通過一個 Channel 對象。一個 Buffer 實質上是一個容器對象。發送給一個通道的所有對象都必須首先放到緩沖區中;同樣地,從通道中讀取的任何數據都要讀到緩沖區中。

在本節中,您會了解到 NIO 中通道和緩沖區是如何工作的。

5.2 什么是緩沖區?

Buffer 是一個對象, 它包含一些要寫入或者剛讀出的數據。 在 NIO 中加入 Buffer 對象,體現了新庫與原 I/O 的一個重要區別。在面向流的 I/O 中,您將數據直接寫入或者將數據直接讀到 Stream 對象中。

在 NIO 庫中,所有數據都是用緩沖區處理的。在讀取數據時,它是直接讀到緩沖區中的。在寫入數據時,它是寫入到緩沖區中的。任何時候訪問 NIO 中的數據,您都是將它放到緩沖區中。

緩沖區實質上是一個數組。通常它是一個字節數組,但是也可以使用其他種類的數組。但是一個緩沖區不 僅僅 是一個數組。緩沖區提供了對數據的結構化訪問,而且還可以跟蹤系統的讀/寫進程。

5.3 緩沖區類型

最常用的緩沖區類型是 ByteBuffer。一個 ByteBuffer 可以在其底層字節數組上進行 get/set 操作(即字節的獲取和設置)。

ByteBuffer 不是 NIO 中唯一的緩沖區類型。

事實上,對于每一種基本 Java 類型都有一種緩沖區類型:

ByteBuffer

CharBuffer

ShortBuffer

IntBuffer

LongBuffer

FloatBuffer

DoubleBuffer

每一個 Buffer 類都是 Buffer 接口的一個實例。 除了 ByteBuffer,每一個 Buffer 類都有完全一樣的操作,只是它們所處理的數據類型不一樣。因為大多數標準 I/O 操作都使用 ByteBuffer,所以它具有所有共享的緩沖區操作以及一些特有的操作。

現在您可以花一點時間運行 UseFloatBuffer.java(請從本文末的附件下載之),它包含了類型化的緩沖區的一個應用例子。

5.4 什么是通道?

Channel是一個對象,可以通過它讀取和寫入數據。拿 NIO 與原來的 I/O 做個比較,通道就像是流。

正如前面提到的,所有數據都通過 Buffer 對象來處理。您永遠不會將字節直接寫入通道中,相反,您是將數據寫入包含一個或者多個字節的緩沖區。同樣,您不會直接從通道中讀取字節,而是將數據從通道讀入緩沖區,再從緩沖區獲取這個字節。

5.5 通道類型

通道與流的不同之處在于通道是雙向的。而流只是在一個方向上移動(一個流必須是 InputStream 或者 OutputStream 的子類), 而 通道 可以用于讀、寫或者同時用于讀寫。

因為它們是雙向的,所以通道可以比流更好地反映底層操作系統的真實情況。特別是在 UNIX 模型中,底層操作系統通道是雙向的。

6、從理論到實踐:NIO 中的讀和寫

6.1 概述

讀和寫是 I/O 的基本過程。從一個通道中讀取很簡單:只需創建一個緩沖區,然后讓通道將數據讀到這個緩沖區中。寫入也相當簡單:創建一個緩沖區,用數據填充它,然后讓通道用這些數據來執行寫入操作。

在本節中,我們將學習有關在 Java 程序中讀取和寫入數據的一些知識。我們將回顧 NIO 的主要組件(緩沖區、通道和一些相關的方法),看看它們是如何交互以進行讀寫的。在接下來的幾節中,我們將更詳細地分析這其中的每個組件以及其交互。

6.2 從文件中讀取

在我們第一個練習中,我們將從一個文件中讀取一些數據。如果使用原來的 I/O,那么我們只需創建一個 FileInputStream 并從它那里讀取。而在 NIO 中,情況稍有不同:我們首先從 FileInputStream 獲取一個 Channel 對象,然后使用這個通道來讀取數據。

在 NIO 系統中,任何時候執行一個讀操作,您都是從通道中讀取,但是您不是 直接 從通道讀取。因為所有數據最終都駐留在緩沖區中,所以您是從通道讀到緩沖區中。

因此讀取文件涉及三個步驟:(1) 從 FileInputStream 獲取 Channel,(2) 創建 Buffer,(3) 將數據從 Channel 讀到 Buffer 中。

現在,讓我們看一下這個過程。

6.3 三個容易的步驟

第一步是獲取通道。我們從 FileInputStream 獲取通道:

FileInputStream fin = newFileInputStream( "readandshow.txt");

FileChannel fc = fin.getChannel();

下一步是創建緩沖區:

ByteBuffer buffer = ByteBuffer.allocate( 1024);

最后,需要將數據從通道讀到緩沖區中,如下所示:

fc.read( buffer );

您會注意到,我們不需要告訴通道要讀 多少數據 到緩沖區中。每一個緩沖區都有復雜的內部統計機制,它會跟蹤已經讀了多少數據以及還有多少空間可以容納更多的數據。我們將在 緩沖區內部細節 中介紹更多關于緩沖區統計機制的內容。

6.4 寫入文件

在 NIO 中寫入文件類似于從文件中讀取。首先從 FileOutputStream 獲取一個通道:

FileOutputStream fout = newFileOutputStream( "writesomebytes.txt");

FileChannel fc = fout.getChannel();

下一步是創建一個緩沖區并在其中放入一些數據 - 在這里,數據將從一個名為 message 的數組中取出,這個數組包含字符串 "Some bytes" 的 ASCII 字節(本教程后面將會解釋 buffer.flip() 和 buffer.put() 調用)。

ByteBuffer buffer = ByteBuffer.allocate( 1024);


for(intii=0; ii<message.length; ++ii) {

     buffer.put( message[ii] );

}

buffer.flip();

最后一步是寫入緩沖區中:

fc.write( buffer );

注意在這里同樣不需要告訴通道要寫入多數據。緩沖區的內部統計機制會跟蹤它包含多少數據以及還有多少數據要寫入。

6.5 讀寫結合

下面我們將看一下在結合讀和寫時會有什么情況。我們以一個名為 CopyFile.java 的簡單程序作為這個練習的基礎,它將一個文件的所有內容拷貝到另一個文件中。CopyFile.java 執行三個基本操作:首先創建一個 Buffer,然后從源文件中將數據讀到這個緩沖區中,然后將緩沖區寫入目標文件。這個程序不斷重復 ― 讀、寫、讀、寫 ― 直到源文件結束。

CopyFile 程序讓您看到我們如何檢查操作的狀態,以及如何使用 clear() 和 flip() 方法重設緩沖區,并準備緩沖區以便將新讀取的數據寫到另一個通道中。

6.6 運行 CopyFile 例子

因為緩沖區會跟蹤它自己的數據,所以 CopyFile 程序的內部循環 (inner loop) 非常簡單,如下所示:

fcin.read( buffer );

fcout.write( buffer );

第一行將數據從輸入通道 fcin 中讀入緩沖區,第二行將這些數據寫到輸出通道 fcout 。

6.7 檢查狀態

下一步是檢查拷貝何時完成。當沒有更多的數據時,拷貝就算完成,并且可以在 read() 方法返回 -1 是判斷這一點,如下所示:

intr = fcin.read( buffer );

if(r==-1) {

     break;

}

6.8 重設緩沖區

最后,在從輸入通道讀入緩沖區之前,我們調用 clear() 方法。同樣,在將緩沖區寫入輸出通道之前,我們調用 flip() 方法,如下所示:

buffer.clear();

intr = fcin.read( buffer );


if(r==-1) {

     break;

}


buffer.flip();

fcout.write( buffer );

clear() 方法重設緩沖區,使它可以接受讀入的數據。 flip() 方法讓緩沖區可以將新讀入的數據寫入另一個通道。

7、緩沖區內部細節

7.1 概述

本節將介紹 NIO 中兩個重要的緩沖區組件:狀態變量和訪問方法 (accessor)。

狀態變量是前一節中提到的"內部統計機制"的關鍵。每一個讀/寫操作都會改變緩沖區的狀態。通過記錄和跟蹤這些變化,緩沖區就可能夠內部地管理自己的資源。

在從通道讀取數據時,數據被放入到緩沖區。在有些情況下,可以將這個緩沖區直接寫入另一個通道,但是在一般情況下,您還需要查看數據。這是使用 訪問方法 get() 來完成的。同樣,如果要將原始數據放入緩沖區中,就要使用訪問方法 put()。

在本節中,您將學習關于 NIO 中的狀態變量和訪問方法的內容。我們將描述每一個組件,并讓您有機會看到它的實際應用。雖然 NIO 的內部統計機制初看起來可能很復雜,但是您很快就會看到大部分的實際工作都已經替您完成了。您可能習慣于通過手工編碼進行簿記 ― 即使用字節數組和索引變量,現在它已在 NIO 中內部地處理了。

7.2 狀態變量

可以用三個值指定緩沖區在任意時刻的狀態:

position

limit

capacity

這三個變量一起可以跟蹤緩沖區的狀態和它所包含的數據。我們將在下面的小節中詳細分析每一個變量,還要介紹它們如何適應典型的讀/寫(輸入/輸出)進程。在這個例子中,我們假定要將數據從一個輸入通道拷貝到一個輸出通道。

7.3 Position

您可以回想一下,緩沖區實際上就是美化了的數組。在從通道讀取時,您將所讀取的數據放到底層的數組中。 position 變量跟蹤已經寫了多少數據。更準確地說,它指定了下一個字節將放到數組的哪一個元素中。因此,如果您從通道中讀三個字節到緩沖區中,那么緩沖區的 position 將會設置為3,指向數組中第四個元素。

同樣,在寫入通道時,您是從緩沖區中獲取數據。 position 值跟蹤從緩沖區中獲取了多少數據。更準確地說,它指定下一個字節來自數組的哪一個元素。因此如果從緩沖區寫了5個字節到通道中,那么緩沖區的 position 將被設置為5,指向數組的第六個元素。

7.4 Limit

limit 變量表明還有多少數據需要取出(在從緩沖區寫入通道時),或者還有多少空間可以放入數據(在從通道讀入緩沖區時)。

position 總是小于或者等于 limit。

7.5 Capacity

緩沖區的 capacity 表明可以儲存在緩沖區中的最大數據容量。實際上,它指定了底層數組的大小 ― 或者至少是指定了準許我們使用的底層數組的容量。

limit 決不能大于 capacity。

7.6 觀察變量

我們首先觀察一個新創建的緩沖區。出于本例子的需要,我們假設這個緩沖區的 總容量 為8個字節。 

Buffer 的狀態如下所示:

回想一下 ,limit 決不能大于 capacity,此例中這兩個值都被設置為 8。我們通過將它們指向數組的尾部之后(如果有第8個槽,則是第8個槽所在的位置)來說明這點。

position 設置為0。如果我們讀一些數據到緩沖區中,那么下一個讀取的數據就進入 slot 0 。如果我們從緩沖區寫一些數據,從緩沖區讀取的下一個字節就來自 slot 0 。

position 設置如下所示:

由于 capacity 不會改變,所以我們在下面的討論中可以忽略它。

7.7 第一次讀取

現在我們可以開始在新創建的緩沖區上進行讀/寫操作。首先從輸入通道中讀一些數據到緩沖區中。第一次讀取得到三個字節。它們被放到數組中從 position 開始的位置,這時 position 被設置為 0。

讀完之后,position 就增加到 3,如下所示:

limit 沒有改變。

7.8 第二次讀取

在第二次讀取時,我們從輸入通道讀取另外兩個字節到緩沖區中。

這兩個字節儲存在由 position 所指定的位置上, position 因而增加 2:

limit 沒有改變。

7.9 flip

現在我們要將數據寫到輸出通道中。在這之前,我們必須調用 flip() 方法。

這個方法做兩件非常重要的事:

1)它將 limit 設置為當前 position;

2)它將 position 設置為 0。

前一小節中的圖顯示了在 flip 之前緩沖區的情況。

下面是在 flip 之后的緩沖區:

我們現在可以將數據從緩沖區寫入通道了。 position 被設置為 0,這意味著我們得到的下一個字節是第一個字節。 limit 已被設置為原來的 position,這意味著它包括以前讀到的所有字節,并且一個字節也不多。

7.10 第一次寫入

在第一次寫入時,我們從緩沖區中取四個字節并將它們寫入輸出通道。

這使得 position 增加到 4,而 limit 不變,如下所示:

7.11 第二次寫入

我們只剩下一個字節可寫了。 limit在我們調用 flip() 時被設置為 5,并且 position 不能超過 limit。所以最后一次寫入操作從緩沖區取出一個字節并將它寫入輸出通道。

這使得 position 增加到 5,并保持 limit 不變,如下所示:

7.12 clear

最后一步是調用緩沖區的 clear() 方法。這個方法重設緩沖區以便接收更多的字節。 

Clear 做兩種非常重要的事情:

1)它將 limit 設置為與 capacity 相同;

2)它設置 position 為 0。

下圖顯示了在調用 clear() 后緩沖區的狀態:

緩沖區現在可以接收新的數據了。

7.13 訪問方法

到目前為止,我們只是使用緩沖區將數據從一個通道轉移到另一個通道。然而,程序經常需要直接處理數據。例如,您可能需要將用戶數據保存到磁盤。在這種情況下,您必須將這些數據直接放入緩沖區,然后用通道將緩沖區寫入磁盤。

或者,您可能想要從磁盤讀取用戶數據。在這種情況下,您要將數據從通道讀到緩沖區中,然后檢查緩沖區中的數據。

在本節的最后,我們將詳細分析如何使用 ByteBuffer 類的 get() 和 put() 方法直接訪問緩沖區中的數據。

7.14 get() 方法

ByteBuffer 類中有四個 get() 方法:

1)byte get();

2)ByteBuffer get( byte dst[] );

3)ByteBuffer get( byte dst[], int offset, int length );

4)byte get( int index );

第一個方法獲取單個字節。第二和第三個方法將一組字節讀到一個數組中。第四個方法從緩沖區中的特定位置獲取字節。那些返回 ByteBuffer 的方法只是返回調用它們的緩沖區的 this 值。

此外,我們認為前三個 get() 方法是相對的,而最后一個方法是絕對的。 相對 意味著 get() 操作服從 limit 和 position 值 ― 更明確地說,字節是從當前 position 讀取的,而 position 在 get 之后會增加。另一方面,一個 絕對 方法會忽略 limit 和 position 值,也不會影響它們。事實上,它完全繞過了緩沖區的統計方法。

上面列出的方法對應于 ByteBuffer 類。其他類有等價的 get() 方法,這些方法除了不是處理字節外,其它方面是是完全一樣的,它們處理的是與該緩沖區類相適應的類型。

7.15 put()方法 

ByteBuffer 類中有五個 put() 方法:

1)ByteBuffer put( byte b );

2)ByteBuffer put( byte src[] );

3)ByteBuffer put( byte src[], int offset, int length );

4)ByteBuffer put( ByteBuffer src );

5)ByteBuffer put( int index, byte b );

第一個方法 寫入(put) 單個字節。第二和第三個方法寫入來自一個數組的一組字節。第四個方法將數據從一個給定的源 ByteBuffer 寫入這個 ByteBuffer。第五個方法將字節寫入緩沖區中特定的 位置 。那些返回 ByteBuffer 的方法只是返回調用它們的緩沖區的 this 值。

與 get() 方法一樣,我們將把 put() 方法劃分為 相對 或者 絕對 的。前四個方法是相對的,而第五個方法是絕對的。

上面顯示的方法對應于 ByteBuffer 類。其他類有等價的 put() 方法,這些方法除了不是處理字節之外,其它方面是完全一樣的。它們處理的是與該緩沖區類相適應的類型。

7.16 類型化的 get() 和 put() 方法

除了前些小節中描述的 get() 和 put() 方法, ByteBuffer 還有用于讀寫不同類型的值的其他方法。

如下所示:

getByte()

getChar()

getShort()

getInt()

getLong()

getFloat()

getDouble()

putByte()

putChar()

putShort()

putInt()

putLong()

putFloat()

putDouble()

事實上,這其中的每個方法都有兩種類型 ― 一種是相對的,另一種是絕對的。它們對于讀取格式化的二進制數據(如圖像文件的頭部)很有用。

您可以在例子程序 TypesInByteBuffer.java 中看到這些方法的實際應用。

7.17 緩沖區的使用:一個內部循環

下面的內部循環概括了使用緩沖區將數據從輸入通道拷貝到輸出通道的過程。

while(true) {

     buffer.clear();

     intr = fcin.read( buffer );


     if(r==-1) {

       break;

     }


     buffer.flip();

     fcout.write( buffer );

}

read() 和 write() 調用得到了極大的簡化,因為許多工作細節都由緩沖區完成了。 clear() 和 flip() 方法用于讓緩沖區在讀和寫之間切換。

8、關于緩沖區的更多內容

8.1 概述

到目前為止,您已經學習了使用緩沖區進行日常工作所需要掌握的大部分內容。我們的例子沒怎么超出標準的讀/寫過程種類,在原來的 I/O 中可以像在 NIO 中一樣容易地實現這樣的標準讀寫過程。

本節將討論使用緩沖區的一些更復雜的方面,比如緩沖區分配、包裝和分片。我們還會討論 NIO 帶給 Java 平臺的一些新功能。您將學到如何創建不同類型的緩沖區以達到不同的目的,如可保護數據不被修改的 只讀 緩沖區,和直接映射到底層操作系統緩沖區的 直接 緩沖區。我們將在本節的最后介紹如何在 NIO 中創建內存映射文件。

8.2 緩沖區分配和包裝

在能夠讀和寫之前,必須有一個緩沖區。要創建緩沖區,您必須 分配 它。我們使用靜態方法 allocate() 來分配緩沖區:

1ByteBuffer buffer = ByteBuffer.allocate( 1024);

allocate() 方法分配一個具有指定大小的底層數組,并將它包裝到一個緩沖區對象中 ― 在本例中是一個 ByteBuffer。

您還可以將一個現有的數組轉換為緩沖區,如下所示:

bytearray[] = newbyte[1024];

ByteBuffer buffer = ByteBuffer.wrap( array );

本例使用了 wrap() 方法將一個數組包裝為緩沖區。必須非常小心地進行這類操作。一旦完成包裝,底層數據就可以通過緩沖區或者直接訪問。

8.3 緩沖區分片

slice() 方法根據現有的緩沖區創建一種 子緩沖區 。也就是說,它創建一個新的緩沖區,新緩沖區與原來的緩沖區的一部分共享數據。

使用例子可以最好地說明這點。讓我們首先創建一個長度為 10 的 ByteBuffer:

ByteBuffer buffer = ByteBuffer.allocate( 10);

然后使用數據來填充這個緩沖區,在第 n 個槽中放入數字 n:

for(inti=0; i<buffer.capacity(); ++i) {

     buffer.put( (byte)i );

}

現在我們對這個緩沖區 分片 ,以創建一個包含槽 3 到槽 6 的子緩沖區。在某種意義上,子緩沖區就像原來的緩沖區中的一個 窗口 。

窗口的起始和結束位置通過設置 position 和 limit 值來指定,然后調用 Buffer 的 slice() 方法:

buffer.position( 3);

buffer.limit( 7);

ByteBuffer slice = buffer.slice();

片 是緩沖區的 子緩沖區 。不過, 片段 和 緩沖區 共享同一個底層數據數組,我們在下一節將會看到這一點。

8.4 緩沖區份片和數據共享

我們已經創建了原緩沖區的子緩沖區,并且我們知道緩沖區和子緩沖區共享同一個底層數據數組。讓我們看看這意味著什么。

我們遍歷子緩沖區,將每一個元素乘以 11 來改變它。例如,5 會變成 55。

for(inti=0; i<slice.capacity(); ++i) {

     byteb = slice.get( i );

     b *= 11;

     slice.put( i, b );

}

最后,再看一下原緩沖區中的內容:

buffer.position( 0);

buffer.limit( buffer.capacity() );


while(buffer.remaining()>0) {

     System.out.println( buffer.get() );

}

結果表明只有在子緩沖區窗口中的元素被改變了:

$ java SliceBuffer

0

1

2

33

44

55

66

7

8

9

緩沖區片對于促進抽象非常有幫助。可以編寫自己的函數處理整個緩沖區,而且如果想要將這個過程應用于子緩沖區上,您只需取主緩沖區的一個片,并將它傳遞給您的函數。這比編寫自己的函數來取額外的參數以指定要對緩沖區的哪一部分進行操作更容易。

8.5 只讀緩沖區

只讀緩沖區非常簡單 ― 您可以讀取它們,但是不能向它們寫入。可以通過調用緩沖區的 asReadOnlyBuffer() 方法,將任何常規緩沖區轉換為只讀緩沖區,這個方法返回一個與原緩沖區完全相同的緩沖區(并與其共享數據),只不過它是只讀的。

只讀緩沖區對于保護數據很有用。在將緩沖區傳遞給某個對象的方法時,您無法知道這個方法是否會修改緩沖區中的數據。創建一個只讀的緩沖區可以 保證 該緩沖區不會被修改。

不能將只讀的緩沖區轉換為可寫的緩沖區。

8.6 直接和間接緩沖區

另一種有用的 ByteBuffer 是直接緩沖區。 直接緩沖區 是為加快 I/O 速度,而以一種特殊的方式分配其內存的緩沖區。

實際上,直接緩沖區的準確定義是與實現相關的。

Sun(現在是Oracle) 的文檔是這樣描述直接緩沖區的:

給定一個直接字節緩沖區,Java 虛擬機將盡最大努力直接對它執行本機 I/O 操作。也就是說,它會在每一次調用底層操作系統的本機 I/O 操作之前(或之后),嘗試避免將緩沖區的內容拷貝到一個中間緩沖區中(或者從一個中間緩沖區中拷貝數據)。

您可以在例子程序 FastCopyFile.java(請從文末附件中下載之) 中看到直接緩沖區的實際應用,這個程序是 CopyFile.java 的另一個版本,它使用了直接緩沖區以提高速度。

還可以用內存映射文件創建直接緩沖區。

8.7 內存映射文件 I/O

內存映射文件 I/O 是一種讀和寫文件數據的方法,它可以比常規的基于流或者基于通道的 I/O 快得多。

內存映射文件 I/O 是通過使文件中的數據神奇般地出現為內存數組的內容來完成的。這其初聽起來似乎不過就是將整個文件讀到內存中,但是事實上并不是這樣。一般來說,只有文件中實際讀取或者寫入的部分才會送入(或者 映射 )到內存中。

內存映射并不真的神奇或者多么不尋常。現代操作系統一般根據需要將文件的部分映射為內存的部分,從而實現文件系統。Java 內存映射機制不過是在底層操作系統中可以采用這種機制時,提供了對該機制的訪問。

盡管創建內存映射文件相當簡單,但是向它寫入可能是危險的。僅只是改變數組的單個元素這樣的簡單操作,就可能會直接修改磁盤上的文件。修改數據與將數據保存到磁盤是沒有分開的。

8.8 將文件映射到內存

了解內存映射的最好方法是使用例子。在下面的例子中,我們要將一個 FileChannel (它的全部或者部分)映射到內存中。為此我們將使用 FileChannel.map() 方法。

下面代碼行將文件的前 1024 個字節映射到內存中:

MappedByteBuffer mbb = fc.map( FileChannel.MapMode.READ_WRITE, 0, 1024);

map() 方法返回一個 MappedByteBuffer,它是 ByteBuffer 的子類。因此,您可以像使用其他任何 ByteBuffer 一樣使用新映射的緩沖區,操作系統會在需要時負責執行行映射。

9、分散和聚集

9.1 概述

分散/聚集 I/O 是使用多個而不是單個緩沖區來保存數據的讀寫方法。

一個分散的讀取就像一個常規通道讀取,只不過它是將數據讀到一個緩沖區數組中而不是讀到單個緩沖區中。同樣地,一個聚集寫入是向緩沖區數組而不是向單個緩沖區寫入數據。

分散/聚集 I/O 對于將數據流劃分為單獨的部分很有用,這有助于實現復雜的數據格式。

9.2 分散/聚集 I/O

通道可以有選擇地實現兩個新的接口: ScatteringByteChannel 和 GatheringByteChannel

一個 ScatteringByteChannel 是一個具有兩個附加讀方法的通道:

longread( ByteBuffer[] dsts );

longread( ByteBuffer[] dsts, intoffset, intlength );

這些 long read() 方法很像標準的 read 方法,只不過它們不是取單個緩沖區而是取一個緩沖區數組。

在 分散讀取 中,通道依次填充每個緩沖區。填滿一個緩沖區后,它就開始填充下一個。在某種意義上,緩沖區數組就像一個大緩沖區。

9.3 分散/聚集的應用

分散/聚集 I/O 對于將數據劃分為幾個部分很有用。例如,您可能在編寫一個使用消息對象的網絡應用程序,每一個消息被劃分為固定長度的頭部和固定長度的正文。您可以創建一個剛好可以容納頭部的緩沖區和另一個剛好可以容難正文的緩沖區。當您將它們放入一個數組中并使用分散讀取來向它們讀入消息時,頭部和正文將整齊地劃分到這兩個緩沖區中。

我們從緩沖區所得到的方便性對于緩沖區數組同樣有效。因為每一個緩沖區都跟蹤自己還可以接受多少數據,所以分散讀取會自動找到有空間接受數據的第一個緩沖區。在這個緩沖區填滿后,它就會移動到下一個緩沖區。

9.4 聚集寫入

聚集寫入 類似于分散讀取,只不過是用來寫入。它也有接受緩沖區數組的方法:

longwrite( ByteBuffer[] srcs );

longwrite( ByteBuffer[] srcs, intoffset, intlength );

聚集寫對于把一組單獨的緩沖區中組成單個數據流很有用。為了與上面的消息例子保持一致,您可以使用聚集寫入來自動將網絡消息的各個部分組裝為單個數據流,以便跨越網絡傳輸消息。

從例子程序 UseScatterGather.java(請從文末附件中下載之) 中可以看到分散讀取和聚集寫入的實際應用。

10、文件鎖定

10.1 概述

文件鎖定初看起來可能讓人迷惑。它 似乎 指的是防止程序或者用戶訪問特定文件。事實上,文件鎖就像常規的 Java 對象鎖 ― 它們是 勸告式的(advisory) 鎖。它們不阻止任何形式的數據訪問,相反,它們通過鎖的共享和獲取賴允許系統的不同部分相互協調。

您可以鎖定整個文件或者文件的一部分。如果您獲取一個排它鎖,那么其他人就不能獲得同一個文件或者文件的一部分上的鎖。如果您獲得一個共享鎖,那么其他人可以獲得同一個文件或者文件一部分上的共享鎖,但是不能獲得排它鎖。文件鎖定并不總是出于保護數據的目的。例如,您可能臨時鎖定一個文件以保證特定的寫操作成為原子的,而不會有其他程序的干擾。

大多數操作系統提供了文件系統鎖,但是它們并不都是采用同樣的方式。有些實現提供了共享鎖,而另一些僅提供了排它鎖。事實上,有些實現使得文件的鎖定部分不可訪問,盡管大多數實現不是這樣的。

在本節中,您將學習如何在 NIO 中執行簡單的文件鎖過程,我們還將探討一些保證被鎖定的文件盡可能可移植的方法。

10.2 鎖定文件

要獲取文件的一部分上的鎖,您要調用一個打開的 FileChannel 上的 lock() 方法。注意,如果要獲取一個排它鎖,您必須以寫方式打開文件。

RandomAccessFile raf = newRandomAccessFile( "usefilelocks.txt", "rw");

FileChannel fc = raf.getChannel();

FileLock lock = fc.lock( start, end, false);

在擁有鎖之后,您可以執行需要的任何敏感操作,然后再釋放鎖:

lock.release();

在釋放鎖后,嘗試獲得鎖的其他任何程序都有機會獲得它。

本小節的例子程序 UseFileLocks.java 必須與它自己并行運行。這個程序獲取一個文件上的鎖,持有三秒鐘,然后釋放它。如果同時運行這個程序的多個實例,您會看到每個實例依次獲得鎖。

10.3 文件鎖定和可移植性

文件鎖定可能是一個復雜的操作,特別是考慮到不同的操作系統是以不同的方式實現鎖這一事實。

下面的指導原則將幫助您盡可能保持代碼的可移植性:

1)只使用排它鎖;

2)將所有的鎖視為勸告式的(advisory)。

11、連網和異步 I/O

11.1 概述

連網是學習異步 I/O 的很好基礎,而異步 I/O 對于在 Java 語言中執行任何輸入/輸出過程的人來說,無疑都是必須具備的知識。NIO 中的連網與 NIO 中的其他任何操作沒有什么不同 ― 它依賴通道和緩沖區,而您通常使用 InputStream 和 OutputStream 來獲得通道。

本節首先介紹異步 I/O 的基礎 ― 它是什么以及它不是什么,然后轉向更實用的、程序性的例子。

11.2 異步 I/O

異步 I/O 是一種 沒有阻塞地 讀寫數據的方法。通常,在代碼進行 read() 調用時,代碼會阻塞直至有可供讀取的數據。同樣, write() 調用將會阻塞直至數據能夠寫入。

另一方面,異步 I/O 調用不會阻塞。相反,您將注冊對特定 I/O 事件的興趣 ― 可讀的數據的到達、新的套接字連接,等等,而在發生這樣的事件時,系統將會告訴您。

異步 I/O 的一個優勢在于,它允許您同時根據大量的輸入和輸出執行 I/O。同步程序常常要求助于輪詢,或者創建許許多多的線程以處理大量的連接。使用異步 I/O,您可以監聽任何數量的通道上的事件,不用輪詢,也不用額外的線程。

我們將通過研究一個名為 MultiPortEcho.java(請從文末附件下載之) 的例子程序來查看異步 I/O 的實際應用。這個程序就像傳統的 echo server,它接受網絡連接并向它們回響它們可能發送的數據。不過它有一個附加的特性,就是它能同時監聽多個端口,并處理來自所有這些端口的連接。并且它只在單個線程中完成所有這些工作。

11.3 Selectors

本節的闡述對應于 MultiPortEcho 的源代碼中的 go() 方法的實現,因此應該看一下源代碼,以便對所發生的事情有個更全面的了解。

異步 I/O 中的核心對象名為 Selector。Selector 就是您注冊對各種 I/O 事件的興趣的地方,而且當那些事件發生時,就是這個對象告訴您所發生的事件。

所以,我們需要做的第一件事就是創建一個 Selector:

Selector selector = Selector.open();

然后,我們將對不同的通道對象調用 register() 方法,以便注冊我們對這些對象中發生的 I/O 事件的興趣。register() 的第一個參數總是這個 Selector。

11.4 打開一個 ServerSocketChannel

為了接收連接,我們需要一個 ServerSocketChannel。事實上,我們要監聽的每一個端口都需要有一個 ServerSocketChannel 。

對于每一個端口,我們打開一個 ServerSocketChannel,如下所示:

ServerSocketChannel ssc = ServerSocketChannel.open();

ssc.configureBlocking( false);


ServerSocket ss = ssc.socket();

InetSocketAddress address = newInetSocketAddress( ports[ii] );

ss.bind( address );

第一行創建一個新的 ServerSocketChannel ,最后三行將它綁定到給定的端口。第二行將 ServerSocketChannel 設置為 非阻塞的 。我們必須對每一個要使用的套接字通道調用這個方法,否則異步 I/O 就不能工作。

11.5 選擇鍵

下一步是將新打開的 ServerSocketChannels 注冊到 Selector上。為此我們使用 ServerSocketChannel.register() 方法,如下所示:

SelectionKey key = ssc.register( selector, SelectionKey.OP_ACCEPT );

register() 的第一個參數總是這個 Selector。第二個參數是 OP_ACCEPT,這里它指定我們想要監聽 accept 事件,也就是在新的連接建立時所發生的事件。這是適用于 ServerSocketChannel 的唯一事件類型。

請注意對 register() 的調用的返回值。 SelectionKey 代表這個通道在此 Selector 上的這個注冊。當某個 Selector 通知您某個傳入事件時,它是通過提供對應于該事件的 SelectionKey 來進行的。SelectionKey 還可以用于取消通道的注冊。

11.6 內部循環

現在已經注冊了我們對一些 I/O 事件的興趣,下面將進入主循環。使用 Selectors 的幾乎每個程序都像下面這樣使用內部循環:

intnum = selector.select();


Set selectedKeys = selector.selectedKeys();

Iterator it = selectedKeys.iterator();


while(it.hasNext()) {

     SelectionKey key = (SelectionKey)it.next();

     // ... deal with I/O event ...

}

首先,我們調用 Selector 的 select() 方法。這個方法會阻塞,直到至少有一個已注冊的事件發生。當一個或者更多的事件發生時, select() 方法將返回所發生的事件的數量。

接下來,我們調用 Selector 的 selectedKeys() 方法,它返回發生了事件的 SelectionKey 對象的一個 集合 。

我們通過迭代 SelectionKeys 并依次處理每個 SelectionKey 來處理事件。對于每一個 SelectionKey,您必須確定發生的是什么 I/O 事件,以及這個事件影響哪些 I/O 對象。

11.7 監聽新連接

程序執行到這里,我們僅注冊了 ServerSocketChannel,并且僅注冊它們“接收”事件。為確認這一點,我們對 SelectionKey 調用 readyOps() 方法,并檢查發生了什么類型的事件:

if((key.readyOps() & SelectionKey.OP_ACCEPT)

     == SelectionKey.OP_ACCEPT) {


     // Accept the new connection

     // ...

}

可以肯定地說, readOps() 方法告訴我們該事件是新的連接。

11.8 接受新的連接

因為我們知道這個服務器套接字上有一個傳入連接在等待,所以可以安全地接受它;也就是說,不用擔心 accept() 操作會阻塞:

ServerSocketChannel ssc = (ServerSocketChannel)key.channel();

SocketChannel sc = ssc.accept();

下一步是將新連接的 SocketChannel 配置為非阻塞的。而且由于接受這個連接的目的是為了讀取來自套接字的數據,所以我們還必須將 SocketChannel 注冊到 Selector上,如下所示:

sc.configureBlocking( false);

SelectionKey newKey = sc.register( selector, SelectionKey.OP_READ );

注意我們使用 register() 的 OP_READ 參數,將 SocketChannel 注冊用于 讀取 而不是 接受 新連接。

11.9 刪除處理過的 SelectionKey

在處理 SelectionKey 之后,我們幾乎可以返回主循環了。但是我們必須首先將處理過的 SelectionKey 從選定的鍵集合中刪除。如果我們沒有刪除處理過的鍵,那么它仍然會在主集合中以一個激活的鍵出現,這會導致我們嘗試再次處理它。我們調用迭代器的 remove() 方法來刪除處理過的 SelectionKey:

it.remove();

現在我們可以返回主循環并接受從一個套接字中傳入的數據(或者一個傳入的 I/O 事件)了。

11.10 傳入的 I/O

當來自一個套接字的數據到達時,它會觸發一個 I/O 事件。這會導致在主循環中調用 Selector.select(),并返回一個或者多個 I/O 事件。這一次, SelectionKey 將被標記為 OP_READ 事件,如下所示:

} elseif((key.readyOps() & SelectionKey.OP_READ)

     == SelectionKey.OP_READ) {

     // Read the data

     SocketChannel sc = (SocketChannel)key.channel();

     // ...

}

與以前一樣,我們取得發生 I/O 事件的通道并處理它。在本例中,由于這是一個 echo server,我們只希望從套接字中讀取數據并馬上將它發送回去。關于這個過程的細節,請參見 參考資料 中的源代碼 (MultiPortEcho.java)。

11.11 回到主循環

每次返回主循環,我們都要調用 select 的 Selector()方法,并取得一組 SelectionKey。每個鍵代表一個 I/O 事件。我們處理事件,從選定的鍵集中刪除 SelectionKey,然后返回主循環的頂部。

這個程序有點過于簡單,因為它的目的只是展示異步 I/O 所涉及的技術。在現實的應用程序中,您需要通過將通道從 Selector 中刪除來處理關閉的通道。而且您可能要使用多個線程。這個程序可以僅使用一個線程,因為它只是一個演示,但是在現實場景中,創建一個線程池來負責 I/O 事件處理中的耗時部分會更有意義。

12、字符集

12.1 概述

根據 Sun(現在是Oracle) 的文檔,一個 Charset 是“十六位 Unicode 字符序列與字節序列之間的一個命名的映射”。實際上,一個 Charset 允許您以盡可能最具可移植性的方式讀寫字符序列。

Java 語言被定義為基于 Unicode。然而在實際上,許多人編寫代碼時都假設一個字符在磁盤上或者在網絡流中用一個字節表示。這種假設在許多情況下成立,但是并不是在所有情況下都成立,而且隨著計算機變得對 Unicode 越來越友好,這個假設就日益變得不能成立了。

在本節中,我們將看一下如何使用 Charsets 以適合現代文本格式的方式處理文本數據。這里將使用的示例程序相當簡單,不過,它觸及了使用 Charset 的所有關鍵方面:為給定的字符編碼創建 Charset,以及使用該 Charset 解碼和編碼文本數據。

12.2 編碼/解碼

要讀和寫文本,我們要分別使用 CharsetDecoder 和 CharsetEncoder。將它們稱為 編碼器 和 解碼器 是有道理的。一個 字符 不再表示一個特定的位模式,而是表示字符系統中的一個實體。因此,由某個實際的位模式表示的字符必須以某種特定的 編碼 來表示。

CharsetDecoder 用于將逐位表示的一串字符轉換為具體的 char 值。同樣,一個 CharsetEncoder 用于將字符轉換回位。

在下一個小節中,我們將考察一個使用這些對象來讀寫數據的程序。

12.3 處理文本的正確方式

現在我們將分析這個例子程序 UseCharsets.java。這個程序非常簡單 ― 它從一個文件中讀取一些文本,并將該文本寫入另一個文件。但是它把該數據當作文本數據,并使用 CharBuffer 來將該數句讀入一個 CharsetDecoder 中。同樣,它使用 CharsetEncoder 來寫回該數據。

我們將假設字符以 ISO-8859-1(Latin1) 字符集(這是 ASCII 的標準擴展)的形式儲存在磁盤上。盡管我們必須為使用 Unicode 做好準備,但是也必須認識到不同的文件是以不同的格式儲存的,而 ASCII 無疑是非常普遍的一種格式。

事實上,每種 Java 實現都要求對以下字符編碼提供完全的支持:

US-ASCII

ISO-8859-1

UTF-8

UTF-16BE

UTF-16LE

UTF-16

12.4 示例程序

在打開相應的文件、將輸入數據讀入名為 inputData 的 ByteBuffer 之后,我們的程序必須創建 ISO-8859-1 (Latin1) 字符集的一個實例:

Charset latin1 = Charset.forName( "ISO-8859-1");

然后,創建一個解碼器(用于讀取)和一個編碼器 (用于寫入):

CharsetDecoder decoder = latin1.newDecoder();

CharsetEncoder encoder = latin1.newEncoder();

為了將字節數據解碼為一組字符,我們把 ByteBuffer 傳遞給 CharsetDecoder,結果得到一個 CharBuffer:

CharBuffer cb = decoder.decode( inputData );

如果想要處理字符,我們可以在程序的此處進行。但是我們只想無改變地將它寫回,所以沒有什么要做的。

要寫回數據,我們必須使用 CharsetEncoder 將它轉換回字節:

ByteBuffer outputData = encoder.encode( cb );

在轉換完成之后,我們就可以將數據寫到文件中了。

13、結束語

正如您所看到的, NIO 庫有大量的特性。在一些新特性(例如文件鎖定和字符集)提供新功能的同時,許多特性在優化方面也非常優秀。

在基礎層次上,通道和緩沖區可以做的事情幾乎都可以用原來的面向流的類來完成。但是通道和緩沖區允許以 快得多 的方式完成這些相同的舊操作 ― 事實上接近系統所允許的最大速度。

不過 NIO 最強大的長度之一在于,它提供了一種在 Java 語言中執行進行輸入/輸出的新的(也是迫切需要的)結構化方式。隨諸如緩沖區、通道和異步 I/O 這些概念性(且可實現的)實體而來的,是我們重新思考 Java 程序中的 I/O過程的機會。這樣,NIO 甚至為我們最熟悉的 I/O 過程也帶來了新的活力,同時賦予我們通過和以前不同并且更好的方式執行它們的機會。

14、示例源碼附件下載

(請從鏈接 http://www.52im.net/thread-2640-1-1.html的附件中下載)

附錄:更多NIO相關文章

Java新一代網絡編程模型AIO原理及Linux系統AIO介紹
有關“為何選擇Netty”的11個疑問及解答
開源NIO框架八卦——到底是先有MINA還是先有Netty?
選Netty還是Mina:深入研究與對比(一)
選Netty還是Mina:深入研究與對比(二)
NIO框架入門(一):服務端基于Netty4的UDP雙向通信Demo演示
NIO框架入門(二):服務端基于MINA2的UDP雙向通信Demo演示
NIO框架入門(三):iOS與MINA2、Netty4的跨平臺UDP雙向通信實戰
NIO框架入門(四):Android與MINA2、Netty4的跨平臺UDP雙向通信實戰
Netty 4.x學習(一):ByteBuf詳解
Netty 4.x學習(二):Channel和Pipeline詳解
Netty 4.x學習(三):線程模型詳解
Apache Mina框架高級篇(一):IoFilter詳解
Apache Mina框架高級篇(二):IoHandler詳解
MINA2 線程原理總結(含簡單測試實例)
Apache MINA2.0 開發指南(中文版)[附件下載]
MINA、Netty的源代碼(在線閱讀版)已整理發布
解決MINA數據傳輸中TCP的粘包、缺包問題(有源碼)
解決Mina中多個同類型Filter實例共存的問題
實踐總結:Netty3.x升級Netty4.x遇到的那些坑(線程篇)
實踐總結:Netty3.x VS Netty4.x的線程模型
詳解Netty的安全性:原理介紹、代碼演示(上篇)
詳解Netty的安全性:原理介紹、代碼演示(下篇)
詳解Netty的優雅退出機制和原理
NIO框架詳解:Netty的高性能之道
Twitter:如何使用Netty 4來減少JVM的GC開銷(譯文)
絕對干貨:基于Netty實現海量接入的推送服務技術要點
Netty干貨分享:京東京麥的生產級TCP網關技術實踐總結
新手入門:目前為止最透徹的的Netty高性能原理和框架架構解析
寫給初學者:Java高性能NIO框架Netty的學習方法和進階策略
少啰嗦!一分鐘帶你讀懂Java的NIO和經典IO的區別
史上最強Java NIO入門:擔心從入門到放棄的,請讀這篇!
>> 更多同類文章 ……

(本文同步發布于:http://www.52im.net/thread-2640-1-1.html


只有注冊用戶登錄后才能發表評論。


網站導航:
 
Jack Jiang的 Mail: [email protected], 聯系QQ: 413980957, 微信: hellojackjiang
魔法糖果闯关