簡介
在Java的Socket編程中,若使用阻塞式(BIO),則往往通過ServerSocket的accept()方法獲取到客戶端Socket之后,再使用客戶端Socket的InputStream和OutputStream進行讀寫。Socket.getInputstream.read(byte[] b)和Socket.getOutputStream.write(byte[] b)的方法中的參數都是字節數組。這種阻塞式的Socket編程顯然已經遠遠不能滿足目前的並發式訪問需求。
所以最近在項目中學習使用了Java原生NIO,這時則需要通過ServerSocketChannel的accept()方法獲取到客戶端的SocketChannel,再使用客戶端SocketChannel直接進行讀寫。但SocketChannel.read(ByteBuffer dst)和SocketChannel.write(ByteBuffer src)的方法中的參數則都變為了java.nio.ByteBuffer,該類型就是JavaNIO對byte數組的一種封裝,其中包括了很多基本的操作,在此記錄一下備忘。
ByteBuffer包含幾個基本的屬性:
position:當前的下標位置,表示進行下一個讀寫操作時的起始位置;
limit:結束標記下標,表示進行下一個讀寫操作時的(最大)結束位置;
capacity:該ByteBuffer容量;
mark: 自定義的標記位置;
無論如何,這4個屬性總會滿足如下關系:mark <= position <= limit <= capacity。目前對mark屬性了解的不多,故在此暫不做討論。其余3個屬性可以分別通過ByteBuffer.position()、ByteBuffer.limit()、ByteBuffer.capacity()獲取;其中position和limit屬性也可以分別通過ByteBuffer.position(int newPos)、ByteBuffer.limit(int newLim)進行設置,但由於ByteBuffer在讀取和寫出時是非阻塞的,讀寫數據的字節數往往不確定,故通常不會使用這兩個方法直接進行修改。
初始化
首先無論讀寫,均需要初始化一個ByteBuffer容器。如上所述,ByteBuffer其實就是對byte數組的一種封裝,所以可以使用靜態方法wrap(byte[] data)手動封裝數組,也可以通過另一個靜態的allocate(int size)方法初始化指定長度的ByteBuffer。初始化后,ByteBuffer的position就是0;其中的數據就是初始化為0的字節數組;limit = capacity = 字節數組的長度;用戶還未自定義標記位置,所以mark = -1,即undefined狀態。下圖就表示初始化了一個容量為16個字節的ByteBuffer,其中每個字節用兩位16進制數表示:
向ByteBuffer寫數據
手動寫入數據
可以手動通過put(byte b)或put(byte[] b)方法向ByteBuffer中添加一個字節或一個字節數組。ByteBuffer也方便地提供了幾種寫入基本類型的put方法:putChar(char val)、putShort(short val)、putInt(int val)、putFloat(float val)、putLong(long val)、putDouble(double val)。執行這些寫入方法之后,就會以當前的position位置作為起始位置,寫入對應長度的數據,並在寫入完畢之后將position向后移動對應的長度。下圖就表示了分別向ByteBuffer中寫入1個字節的byte數據和4個字節的Integer數據的結果:
但是當想要寫入的數據長度大於ByteBuffer當前剩余的長度時,則會拋出BufferOverflowException異常,剩余長度的定義即為limit與position之間的差值(即 limit - position)。如上述例子中,若再執行buffer.put(new byte[12]);就會拋出BufferOverflowException異常,因為剩余長度為11。可以通過調用ByteBuffer.remaining();查看該ByteBuffer當前的剩余可用長度。
從SocketChannel中讀入數據至ByteBuffer
在實際應用中,往往是調用SocketChannel.read(ByteBuffer dst),從SocketChannel中讀入數據至指定的ByteBuffer中。由於ByteBuffer常常是非阻塞的,所以該方法的返回值即為實際讀取到的字節長度。假設實際讀取到的字節長度為 n,ByteBuffer剩余可用長度為 r,則二者的關系一定滿足:0 <= n <= r。繼續接上述的例子,假設調用read方法,從SocketChannel中讀入了4個字節的數據,則buffer的情況如下:
從ByteBuffer中讀數據
復位position
現在ByteBuffer容器中已經存有數據,那么現在就要從ByteBuffer中將這些數據取出來解析。由於position就是下一個讀寫操作的起始位置,故在讀取數據后直接寫出數據肯定是不正確的,要先把position復位到想要讀取的位置。
首先看一個rewind()方法,該方法僅僅是簡單粗暴地將position直接復原到0,limit不變。這樣進行讀取操作的話,就是從第一個字節開始讀取了。如下圖:
該方法雖然復位了position,可以從頭開始讀取數據,但是並未標記處有效數據的結束位置。如本例所述,ByteBuffer總容量為16字節,但實際上只讀取了9個字節的數據,因此最后的7個字節是無效的數據。故rewind()方法常常用於字節數組的完整拷貝。
實際應用中更常用的是flip()方法,該方法不僅將position復位為0,同時也將limit的位置放置在了position之前所在的位置上,這樣position和limit之間即為新讀取到的有效數據。如下圖:
讀取數據
在將position復位之后,我們便可以從ByteBuffer中讀取有效數據了。類似put()方法,ByteBuffer同樣提供了一系列get方法,從position開始讀取數據。get()方法讀取1個字節,getChar()、getShort()、getInt()、getFloat()、getLong()、getDouble()則讀取相應字節數的數據,並轉換成對應的數據類型。如getInt()即為讀取4個字節,返回一個Int。在調用這些方法讀取數據之后,ByteBuffer還會將position向后移動讀取的長度,以便繼續調用get類方法讀取之后的數據。
這一系列get方法也都有對應的接收一個int參數的重載方法,參數值表示從指定的位置讀取對應長度的數據。如getDouble(2)則表示從下標為2的位置開始讀取8個字節的數據,轉換為double返回。不過實際應用中往往對指定位置的數據並不那么確定,所以帶int參數的方法也不是很常用。get()方法則有兩個重載方法:
get(byte[] dst, int offset, int length):表示嘗試從 position 開始讀取 length 長度的數據拷貝到 dst 目標數組 offset 到 offset + length 位置,相當於執行了
1 for (int i = off; i < off + len; i++)
2 dst[i] = buffer.get();
get(byte[] dst):嘗試讀取 dst 目標數組長度的數據,拷貝至目標數組,相當於執行了
1 buffer.get(dst, 0, dst.length);
此處應注意讀取數據后,已讀取的數據也不會被清零。下圖即為從例子中連續讀取1個字節的byte和4個字節的int數據:
此處同樣要注意,當想要讀取的數據長度大於ByteBuffer剩余的長度時,則會拋出 BufferUnderflowException 異常。如上例中,若再調用buffer.getLong()就會拋出 BufferUnderflowException 異常,因為 remaining 僅為4。
確保數據長度
為了防止出現上述的 BufferUnderflowException 異常,最好要在讀取數據之前確保 ByteBuffer 中的有效數據長度足夠。在此記錄一下我的做法:
1 private void checkReadLen( 2 long reqLen, 3 ByteBuffer buffer, 4 SocketChannel dataSrc 5 ) throws IOException { 6 int readLen; 7 if (buffer.remaining() < reqLen) { // 剩余長度不夠,重新讀取
8 buffer.compact(); // 准備繼續讀取
9 System.out.println("Buffer remaining is less than" + reqLen + ". Read Again..."); 10 while (true) { 11 readLen = dataSrc.read(buffer); 12 System.out.println("Read Again Length: " + readLen + "; Buffer Position: " + buffer.position()); 13 if (buffer.position() >= reqLen) { // 可讀的字節數超過要求字節數
14 break; 15 } 16 } 17 buffer.flip(); 18 System.out.println("Read Enough Data. Remaining bytes in buffer: " + buffer.remaining()); 19 } 20 }
字節序處理
基本類型的值在內存中的存儲形式還有字節序的問題,這種問題在不同CPU的機器之間進行網絡通信時尤其應該注意。同時在調用ByteBuffer的各種get方法獲取對應類型的數值時,ByteBuffer也會使用自己的字節序進行轉換。因此若ByteBuffer的字節序與數據的字節序不一致,就會返回不正確的值。如對於int類型的數值8848,用16進制表示,大字節序為:0x 00 00 22 90;小字節序為:0x 90 22 00 00。若接收到的是小字節序的數據,但是卻使用大字節序的方式進行解析,獲取的就不是8848,而是-1876819968,也就是大字節序表示的有符號int類型的 0x 90 22 00 00。
JavaNIO提供了java.nio.ByteOrder枚舉類來表示機器的字節序,同時提供了靜態方法ByteOrder.nativeOrder()可以獲取到當前機器使用的字節序,使用ByteBuffer中的order()方法即可獲取該buffer所使用的字節序。同時也可以在該方法中傳遞一個ByteOrder枚舉類型來為ByteBuffer指定相應的字節序。如調用buffer.order(ByteOrder.LITTLE_ENDIAN)則將buffer的字節序更改為小字節序。
一開始並不知道還可以這樣操作,比較愚蠢地手動將讀取到的數據進行字節序的轉換。不過覺得還是可以記下來,也許在別的地方用得到。JDK中的 Integer 和 Long 都提供了一個靜態方法reverseBytes()來將對應的 int 或 long 數值的字節序進行翻轉。而若想讀取 float 或 double,也可以先讀取 int 或 long,然后調用 Float.intBitsToFloat(int val) 或 Double.longBitsToDouble(long val) 方法將對應的 int 值或 long 值進行轉換。當ByteBuffer中的字節序與解析的字節序相反時,可以使用如下方法讀取:
int i = Integer.reverseBytes(buffer.getInt()); float f = Float.intBitsToFloat(Integer.reverseBytes(buffer.getInt())); long l = Long.reverseBytes(buffer.getLong()); double d = Double.longBitsToDouble(buffer.getLong());
繼續寫入數據
由於ByteBuffer往往是非阻塞式的,故不能確定新的數據是否已經讀完,但這時候依然可以調用ByteBuffer的compact()方法切換到讀取模式。該方法就是將 position 到 limit 之間還未讀取的數據拷貝到 ByteBuffer 中數組的最前面,然后再將 position 移動至這些數據之后的一位,將 limit 移動至 capacity。這樣 position 和 limit 之間就是已經讀取過的老的數據或初始化的數據,就可以放心大膽地繼續寫入覆蓋了。仍然使用之前的例子,調用 compact() 方法后狀態如下:
總結
總之ByteBuffer的基本用法就是:
初始化(allocate)–> 寫入數據(read / put)–> 轉換為寫出模式(flip)–> 寫出數據(get)–> 轉換為寫入模式(compact)–> 寫入數據(read / put)…
參考資料
java字節序、主機字節序和網絡字節序掃盲貼:https://blog.csdn.net/aitangyong/article/details/23204817
原文鏈接:https://blog.csdn.net/mrliuzhao/java/article/details/89453082