NIO之路2--Java中NIO源碼解析


一、IO多路復用

傳統的BIO服務器處理客戶端IO請求時會為每一個客戶端請求都分配一個線程去處理,當客戶端數量增加時會導致服務端線程數過多而帶來性能隱患,所以迫不得已需要一個線程處理多個客戶端請求,也就衍生了多路復用IO模型,Java中的NIO核心就是使用到了操作系統的多路復用IO。

IO多路復用的本質是內核緩沖IO數據,應用程序開啟線程監控多個文件描述符,一個IO鏈接對於一個文件描述符,一旦某個文件描述符就緒,會通知應用程序執行對應的讀操作或者寫操作。

操作系統提供了三種多路復用API給應用程序使用,分別是select、poll和epoll

1.1、select機制

select函數有一個參數為fd_set,表示監控的描述符集合,數據結構為long類型數組,每一個數組元素都與一個打開的文件句柄相關聯,文件句柄可以是socket描述符、文件描述符等;當調用select函數之后,內核根據IO的狀態更新fd_set的值,由此來通知調用select函數的應用程序那個IO就緒了。從流程上看select和同步阻塞IO差不多,而且select還額外多了select操作,但是select的優勢是可以同時監控多個IO操作,而同步阻塞IO想要做到同時監控多個IO操作必須采用多線程的方式。很明顯從線程消耗上來說,selec更合適一個線程同時和多個IO操作交互的場景。

select的問題:

1、每次調用select,都需要將fd_set從用戶空間復制到內核空間,當fd_set比較大時,復制消耗較大

2、每次調用select,內核都需要遍歷一次fd_set,當fd_set比較大時,遍歷消耗較大

3、內核對於fd_set作了大小限制,最大值為1024或2048

4、當描述符數量較多,而活躍的較少時性能浪費較大,甚至還不如同步阻塞IO模型

1.2、poll機制

poll機制的實現邏輯和select基本上一致,只是沒有對描述符的數量做限制,存儲描述符的數據結構為鏈表結構poll相當於是select的改良吧,但是也只是在同時監控的描述符數量上改良了,其他實現邏輯並沒有變,所以還是會有select遇到的問題select和poll都只適合文件描述符數量和活躍數都多時,如果文件描述符數量較多而活躍的較少時並不適合。

1.3、epoll機制

select和poll機制最大的問題是每次調用函數都需要將文件描述符集合從用戶空間復制到內核空間,另外每次都需要線性遍歷所有的文件描述符判斷狀態。而epoll的實現方式是通過注冊事件回調通知的方式實現,另外通過一個文件描述符來管控多個文件描述符,同樣也沒有文件描述符數量的上限。epoll的實現流程為通過調用內核的epoll_ctl函數注冊需要監聽的文件描述符以及對應的事件,內核一旦發現該文件描述符狀態就緒,就會將所有的已經就緒的文件描述符保存到ready集合中,應用程序通過函數epoll_wait函數不停從ready集合中獲取已經就緒的文件描述符即可,所以epoll不需要對所有的文件描述符進行遍歷,而只需要對已經就緒的文件描述符進行遍歷即可。

當然如果文件描述符全部是活躍狀態的,那么epoll機制的性能可能還沒有select和poll的高,但是大多數情況下都是文件描述符數量較多,而活躍數較少的場景。

 

另外select、poll和epoll處理IO事件時都默認是水平觸發,也就是每次查下文件描述符狀態都是獲取到所有就緒的文件描述符,如果對於就緒的文件描述符不進行處理,那么每次調用時都會返回該文件描述符;

而epoll除了支持水平觸發之外,還支持邊緣觸發模式,邊緣觸發模式是每次只會返回上一次調用之后到目前為止的就緒文件描述符,也就是說一個文件描述符就緒事件只會通過epoll_wait方法返回一次,所以需要應用程序立即處理,如果不處理那么就需要等到下一次文件描述符就緒。邊緣觸發模式相比於水平觸發模式來說大量減少了就緒事件觸發的次數,所以效率更高,但是需要應用程序緩存事件就緒狀態並且立即處理,否則可能會丟失數據。

 

總結select、poll、epoll的對比

  select poll epoll
獲取FD狀態的方式 線性遍歷所有FD 線性遍歷所有的FD 注冊FD就緒的事件,回調通知
FD數量限制 1024或2048 無上限 無上限
FD存儲數據結構 數組 鏈表 紅黑樹
IO效率 線性遍歷,時間復雜度o(n) 線性遍歷,時間復雜度o(n) 遍歷已經就緒的事件集合,時間復雜度o(1)
FD復制到內核 每次調用都需要復制一次 每次調用都需要復制一次 epoll_ctl注冊時復制一次,epoll_wait不需要復制
IO觸發模式 僅支持水平觸發 僅支持水平觸發 支持水平觸發和邊緣觸發

 

 

二、NIO理論淺析

BIO:同步阻塞IO,服務器會為每個IO連接分配一個線程處理IO操作,如果沒有IO操作,那么線程就一直處於阻塞狀態,直到能夠讀寫IO數據操作。

BIO的弊端:

1、客戶端連接數和服務器線程數1:1,隨着客戶端數量增加,服務器會有較大的創建銷毀線程的性能消耗

2、IO操作的線程使用率較低,因為大部分場景下客戶端連接之后並不是一直處於IO操作狀態,所以大部分情況下會導致線程處於空閑狀態

 

NIO:同步非阻塞IO,對於客戶端的連接服務器並不會立即分配線程處理IO操作,而是先進行注冊,注冊每個客戶端連接以及客戶端需要監聽的事件類型,一旦事件就緒(如可讀事件、可寫事件)那么才會通過服務器分配線程處理具體的IO操作

NIO相對於BIO的優點:

1、只需要一個注冊線程就可以管理所有的客戶端注冊鏈接操作

2、只有客戶端存在有效的IO操作時服務器才會分配線程去處理,大幅度提高線程的使用率

3、服務器采用采用輪詢的方式監聽客戶端鏈接的事件就緒狀態,而具體的IO操作線程不會被阻塞

 

2.1、NIO的三大核心

提到NIO就不得不先了解NIO相比於BIO的三大核心模塊,分別是多路復用選擇器(Selector)、緩沖區(Buffer)和通道(Channel)

2.1.1、Channel(通道)

Channel可以理解為是通信管道,客戶端和服務端之前通過channel互相發送數據。通常情況下流的讀寫是單向的,從發送端到接收端。而通道支持雙向同時通信,客戶端和服務端可以同時在通道中發送和接收數據

另外通道中的數據不可以直接讀寫,而是必須和緩沖區進行交互,通道中的數據會寫到緩沖區,另外發送的數據也必須先到緩沖區才能通過通道發送。

2.1.2、Buffer(緩沖區)

緩沖區本質上是一塊可以讀寫數據的內存,通道中的數據讀數據必須從緩沖區讀,寫數據也必須要寫到緩沖區。而想要讀寫緩沖區的數據必須調用緩沖區提供的API進行讀寫數據。

緩沖區的好處是讀寫兩端不需要關心彼此的狀態,而只需要和緩沖區交互即可。類似於MQ的消息隊列,發送方只需要把消息發送到隊列中即可,而消費者不需要關心發送方的狀態,只需要不停從隊列中讀取數據即可。

而針對IO操作也是一樣,客戶端把數據發送給服務端的緩沖區之后就結束了發送數據的任務,而具體數據何時使用完全看服務端何時從緩沖區去讀數據。

2.1.3、Selector(多路復用選擇器)

 Selector主要負責和channel交互,多個channel將自己感興趣的IO事件和自己綁定在一起注冊到Selector上,Selector可以同時監控多個Channel的狀態,如果發生了channel感興趣的事件,那么就通知channel進行數據的讀寫操作。

如果沒有Selector,就需要每個channel鏈接成功就需要分配一個線程去負責channel數據的讀寫,而使用了Selector之后,只需要一個線程監控多個channel的狀態,只有有了真正的IO操作之后才會分配線程去處理真正的IO操作。

Selector的底層是通過操作系統的select、poll和epoll機制來實現的。

 

2.2、NIO的使用案例

NIO服務端案例代碼如下:

 1 public class NioServer {
 2 
 3     /** 服務端通道 */
 4     private static ServerSocketChannel serverSocketChannel;
 5 
 6     /** 多路復用選擇器*/
 7     private static Selector selector;
 8 
 9     public static void main(String[] args) {
10         try {
11             //1.初始化服務器
12             initServer();
13             //2.啟動服務器
14             startServer();
15         }catch (Exception e){
16             e.printStackTrace();
17         }
18     }
19 
20     private static void initServer() throws IOException {
21         /** 1.創建服務端通道 */
22         serverSocketChannel = ServerSocketChannel.open();
23 
24         //設置通道為非阻塞類型
25         serverSocketChannel.configureBlocking(false);
26 
27         /** 2. 綁定監聽端口號 */
28         serverSocketChannel.socket().bind(new InetSocketAddress(8000));
29 
30         /** 3. 創建多路復用選擇器 */
31         selector = Selector.open();
32 
33         /** 4. 注冊通道監聽的事件 */
34         serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
35     }
36 
37     /**  啟動服務器 */
38     private static void startServer() throws IOException {
39         System.out.println("Start Server...");
40         while (true){
41             /** 1.不停輪訓獲取所有的channel的狀態 */
42  selector.select(); //阻塞當前線程,直到至少有一個通道觸發了對應的事件 43             /** 2.獲取所有觸發了注冊事件的channel及事件集合 */
44             Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
45             /** 3.遍歷處理所有channel的事件 */
46             while(iterator.hasNext()){
47                 SelectionKey key = iterator.next();
48                 /** 4.根據不同的事件類型處理不同的業務邏輯 */
49                 if(key.isAcceptable()){
50                     //表示當前通道接收連接成功,主要用於服務端接收到客戶端連接成功
51                     SocketChannel channel = serverSocketChannel.accept();
52                     channel.configureBlocking(false);
53                     channel.register(selector, SelectionKey.OP_READ);
54                 }else if(key.isConnectable()){
55                     //表示當前通道連接成功,主要用於客戶端請求服務器連接成功
56                 }else if(key.isReadable()){
57                     //表示當前通道有可讀的數據
58                     receiveMsg(key);
59                 }else if(key.isWritable()){
60                     //表示當前通道可以寫入數據,(網絡不阻塞的情況下,基本上一直處於可寫狀態,除非緩沖區滿了)
61                 }
62                 iterator.remove();
63             }
64         }
65     }
66 
67     /** 讀取數據,當事件為可讀事件時調用 */
68     private static void receiveMsg(SelectionKey key) throws IOException {
69         SocketChannel channel = (SocketChannel) key.channel();
70         /** 1.分配2048大小的緩存區大小 */
71         ByteBuffer buffer = ByteBuffer.allocate(2048);
72         /** 2.將channel中數據讀到緩沖區,並返回數據大小 */
73         int i = channel.read(buffer);
74         if(i != -1){
75             /** 3.從緩沖區獲取所有數據,解析成字符串*/
76             String msg = new String(buffer.array()).trim();
77             System.out.println("服務器接收到消息:" + msg);
78             /** 4.調用write方法向channel中發送數據 */
79             channel.write(ByteBuffer.wrap(("reply : " + msg).getBytes()));
80         }else {
81             channel.close();
82         }
83     }
84 }

 

NIO客戶端案例代碼如下:

 1 public class NioClient {
 2 
 3     private SocketChannel channel;
 4 
 5     public static void main(String[] args) throws Exception {
 6         NioClient client = new NioClient();
 7         client.initClient();
 8         client.sendMsg("Hello NIO Server");
 9         client.receiveMsg();
10     }
11 
12     /** 初始化客戶端*/
13     private void initClient() throws Exception{
14         /** 1.創建客戶端通道,並綁定服務器IP和端口號 */
15         channel = SocketChannel.open(new InetSocketAddress("localhost", 8000));
16     }
17 
18     /** 發送數據到服務端*/
19     private void sendMsg(String msg) throws IOException {
20         byte[] bytes = msg.getBytes();
21         ByteBuffer buffer = ByteBuffer.wrap(bytes);
22         /** 向通道發送數據 */
23         channel.write(buffer);
24         buffer.clear();
25     }
26 
27     /** 從服務器接收數據*/
28     private void receiveMsg() throws IOException {
29         ByteBuffer buffer = ByteBuffer.allocate(2048);
30         channel.read(buffer);
31         System.out.println(new String(buffer.array()));
32         channel.close();
33     }
34 }

 

 

2.3、NIO的工作流程

服務端:

1、創建ServerSocketChannel通道對象,是所有客戶端通道的父通道,專門用於負責處理客戶端的連接請求

2、綁定服務器監聽端口,設置是否阻塞模式

3、創建多路復用選擇器Selector對象,可以創建一個或者多個

4、將ServerSocketChannel以及監聽的客戶端連接事件(ACCEPT事件)一起注冊到Selector上(只需要監聽ACCEPT事件即可,專門用於處理客戶端的連接請求,至於和客戶端讀寫數據的交互再另外創建通道實現)

5、死循環不停的輪訓Selector上注冊的所有的通道是否觸發了注冊的事件

5.1、通過調用Selector的select()方法,該方法會阻塞當前線程,直到至少有一個注冊的通道觸發了對應的事件才會取消阻塞,然后通過SelectedKeys方法獲取所有觸發了事件的通道

5.2、遍歷所有的SelectionKey,根據觸發的事件的類型,進行不同的處理

6、當監聽到客戶端連接事件之后,為客戶端創建SocketChannel用於TCP數據通信,並且將該通道和可讀事件(ON_READ)注冊到Selector上

7、當監聽到客戶端可讀事件之后,表示客戶端向服務器發送數據,那么為該通道創建一定大小的緩沖區,將通道中的數據寫入到緩沖區

8、業務處理邏輯從緩沖區讀取客戶端發送來的數據,進行解析和業務處理

9、服務器通過調用channel的write方法回寫數據存入buffer中,(不需要關閉channel,channel是客戶端斷開了連接之后,服務端會接收到ON_READ事件,然后報錯就知道channel斷開了)

 

客戶端:

1、創建SocketChannel通道對象,並綁定服務器IP和端口信息進行連接請求

2、直接通過緩沖區向服務器發送數據

3、直接嘗試從通道中讀取數據發到緩沖區

 

三、NIO源碼解析

3.1、服務器的初始化

服務器的初始化包括創建ServerSocketChannel通道的初始化和多路復用選擇器Selector的初始化

3.1.1、ServerSocketChannel初始化

ServerSocketChannel是通道接口Channel的實現類,主要用於服務器客戶端連接的通信,通過靜態方法open()方法創建,源碼如下:

1 /** 創建 ServerSocketChannel對象 */
2     public static ServerSocketChannel open() throws IOException {
3         /** 通過SelectorProvider的openServerSocketChannel方法創建*/
4         return SelectorProvider.provider().openServerSocketChannel();
5     }

 

這里是通過SelectorProvider的provider方法先獲取SelectorProvider對象,然后再調用SelectorProvidor的openServerSocketChannel方法創建,該方法代碼如下:

1 public ServerSocketChannel openServerSocketChannel() throws IOException {
2         /** 直接創建ServerSocketChannel接口的實現類 ServerSocketChanelImpl對象 */
3         return new ServerSocketChannelImpl(this);
4     }

 

可以看出最終創建的ServerSocketChannel對象實際就是創建了一個ServerSocketChannelImpl對象

3.1.2、Selector初始化

Selector的初始化也是調用了Selector類的靜態方法open方法創建的,代碼如下

1 public static Selector open() throws IOException {
2         /** 調用具體的SelectorProvider的openSelector方法 */
3         return SelectorProvider.provider().openSelector();
4     }

 

可以發現和ServerSocketChannel的open邏輯基本上一直,都是先獲取SelectorProvider對象,然后調用對應的openSelector方法來創建,只不過openSelector的實現這里有多個子類都實現了該方法,因為Selector是完全依賴於底層操作系統的支持的,所以openSelector方法會根據當前操作系統的不同返回不同的Selector對象,Windows系統就會返回WindowsSelectorImpl對象,而Linux系統就可以根據具體使用哪一種多路復用機制來選用哪種Selector實現類,可以使用SelectorImpl、PollSelectorImpl、KQueueSelectorImpl(mac系統)等。不同電腦查看openSelector方法的實現可能會不一樣,因為不同操作系統的JDK包不一樣,所以源碼也不同,主要看當前操作系統支持哪一種Selector,所以openSelector的功能就是根據當前操作系統創建一個Selector對象

 

3.2、Selector的工作原理

Selector的工作流程主要步驟如下:

1、調用Selector的select()方法阻塞當前線程,直到有channel觸發了注冊的事件

2、調用Selector的selectedKeys()方法獲取所有通道和事件,事件和通道一起封裝成了SelectionKey對象

3、遍歷所有的SelectionKey集合,分別判斷事件的類型,執行對應的處理

 

 

SelectionKey類

SelectionKey類可以看作是channel和事件的封裝類,當一個channel觸發了對應的事件之后,就會將事件類型和Channel一起封裝成一個SelectionKey對象,而事件類型主要有以下四種:

int OP_READ = 1<<0 = 1 : 可讀事件,表示當前通道中有數據可以讀取(服務端和客戶端公用)

int OP_WRITE = 1<<2 = 4 :可寫事件,表示當前可以向通道中寫入數據(服務端和客戶端公用)

int OP_CONNECT= 1<<3 = 8 :連接事件,表示客戶端向服務端連接成功(用戶客戶端)

int OP_ACCEPT = 1<<4 = 16 :接收連接事件,表示服務端接收到客戶端連接成功(用於服務端)

SelectionKey針對不同事件提高了不同的方法,判斷是否觸發了對應的事件,方法如下:

 1  /** 是否觸發可讀事件 */
 2     public final boolean isReadable() {
 3         /** 獲取當前狀態 和 可讀事件值進行與運算 */
 4         return (readyOps() & OP_READ) != 0;
 5     }
 6 
 7     /** 是否觸發可寫事件 */
 8     public final boolean isWritable() {
 9         /** 獲取當前狀態 和 可寫事件進行與運算 */
10         return (readyOps() & OP_WRITE) != 0;
11     }
12 
13     /** 是否觸發連接成功事件 */
14     public final boolean isConnectable() {
15         /** 獲取當前狀態 和 連接成功事件進行與運算*/
16         return (readyOps() & OP_CONNECT) != 0;
17     }
18 
19     /** 是否接收連接成功事件 */
20     public final boolean isAcceptable() {
21         /** 獲取當前狀態 和 接收連接成功事件進行與運算*/
22         return (readyOps() & OP_ACCEPT) != 0;
23     }
24 
25     /** 獲取當前通道就緒的狀態值,由子類實現 */
26     public abstract int readyOps();

 

3.3、Selector的regist方法源碼解析

Selector的regist方法用來注冊Channel和感興趣的事件,將channel和事件封裝成SelectionKey對象保存在Selector中,源碼如下:

 1 /**
 2      * 注冊通道和事件
 3      * @param var1 : 通道
 4      * @param var2 : 事件
 5      * */
 6     protected final SelectionKey register(AbstractSelectableChannel var1, int var2, Object var3) {
 7         if (!(var1 instanceof SelChImpl)) {
 8             throw new IllegalSelectorException();
 9         } else {
10             /** 構建SelectionKeyImpl對象 */
11             SelectionKeyImpl var4 = new SelectionKeyImpl((SelChImpl)var1, this);
12             var4.attach(var3);
13             synchronized(this.publicKeys) {
14                 /** 注冊SelectionKey */
15                 this.implRegister(var4);
16             }
17             /** 設置SelectionKey對象感興趣的事件 */
18             var4.interestOps(var2);
19             return var4;
20         }
21     }

 

3.4、Selector的select方法源碼解析

 1 public int select() throws IOException {
 2         //調用內部重載方法select方法
 3         return this.select(0L);
 4     }
 5 
 6     public int select(long var1) throws IOException {
 7         if (var1 < 0L) {
 8             throw new IllegalArgumentException("Negative timeout");
 9         } else {
10             //調用內部的lockAndDoSelect方法
11             return this.lockAndDoSelect(var1 == 0L ? -1L : var1);
12         }
13     }
14 
15     private Set<SelectionKey> publicKeys;
16     private Set<SelectionKey> publicSelectedKeys;
17 
18     private int lockAndDoSelect(long var1) throws IOException {
19         /** 將當前Selector對象鎖住*/
20         synchronized(this) {
21             //1.判斷當前Selector是否是開啟狀態
22             if (!this.isOpen()) {
23                 throw new ClosedSelectorException();
24             } else {
25                 int var10000;
26                 /**
27                  * 鎖住publicKeys對象
28                  * */
29                 synchronized(this.publicKeys) {
30                     /**
31                      * 所以publicSelectedKeys對象
32                      * */
33                     synchronized(this.publicSelectedKeys) {
34                         /** 調用內部 doSelect方法
35                          *  doSelect方法的具體是否由子類實現,根據不同的Selector類型調用本地方法,
36                          *  而本地方法的實現就是調用操作系統的多路復用技術select、poll或epoll機制返回結果
37                          * */
38                         var10000 = this.doSelect(var1);
39                     }
40                 }
41                 return var10000;
42             }
43         }
44     }
45 
46     /** SelectionKey數組 */
47     protected SelectionKeyImpl[] channelArray;
48 
49     /** 以PollSelectorImpl實現類為例*/
50     protected int doSelect(long var1) throws IOException {
51         if (this.channelArray == null) {
52             throw new ClosedSelectorException();
53         } else {
54             //清理已經無效的SelectionKey
55             this.processDeregisterQueue();
56 
57             try {
58                 /** 調用begin方法使得線程進入阻塞狀態,直到有SelectionKey觸發了事件*/
59                 this.begin();
60                 /** 調用本地方法poll方法,本質是調用操作系統的poll方法*/
61                 this.pollWrapper.poll(this.totalChannels, 0, var1);
62             } finally {
63                 this.end();
64             }
65 
66             this.processDeregisterQueue();
67             /** 統計觸發了事件的SelectionKey個數,並添加到Set<SelectionKey>集合中*/
68             int var3 = this.updateSelectedKeys();
69             if (this.pollWrapper.getReventOps(0) != 0) {
70                 this.pollWrapper.putReventOps(0, 0);
71                 synchronized(this.interruptLock) {
72                     IOUtil.drain(this.fd0);
73                     this.interruptTriggered = false;
74                 }
75             }
76 
77             return var3;
78         }
79     }
有三個大小為1025大小的數組,1位存放發生事件的socket的總數,后面存放發生事件的socket句柄個數,分別是readFds、writeFds和exceptFds,分別對應讀事件、寫事件、異常事件,然后調用本地的poll方法,如果有事件發生統計數量封裝成SelectKey返回,如果沒有數據就一直阻塞知道有數據返回或者是達到超時時間返回。

Tips:

1、遍歷SelectionKey集合之后,需要將SelectionKey從集合中刪除,否則下一次調用select方法時還會返回

2、調用select()方法之后主線程會一直被阻塞,直到有channel觸發了事件,通過調用wakeup方法喚醒主線程

 

3.3、Buffer的源碼解析

Buffer是NIO中IO數據存儲的緩沖區,數據從channel中寫入到Buffer中或者數據由channel從Buffer中讀取數據。而緩沖區的本質就是一個數組,如果是ByteBuffer那么就是byte數組,如果是CharBuffer,那么就是char數組。

3.3.1、Buffer的核心屬性

而Buffer的使用又離不開數組位置的標記,用來標記Buffer數組的核心變量分別如下:

capacity:數組容量,數組的總大小,初始化Buffer時設置,固定不變

position:當前的位置,初始化值為0,每向數組中寫1位數據,position的值就向后移動一位,所以position的取值范圍為 0 ~ capacity-1;當Buffer從寫模式切換到讀模式之后,position值置位0,每讀1位數據,position向后移動一位。

limit:表示當前可讀或可寫數據的上限,寫模式下默認為capacity;讀模式下值為position的值

mark:標記位置,調用mark方法之后將mark值設置為position值用來標記,當調用reset方法之后position值再恢復到mark值,默認為-1

四個屬性之間的大小關系為 : 0 <= mark <= position <= limit <= capacity

 

3.3.2、Buffer的核心方法

put方法向數組中插入數據,position值隨着插入數據而變化,假設插入5個數據,那么position值為5,其他變量值不變

flip方法將緩沖區數據由寫模式切換成讀模式,此時limit值設置為position,position重新置為0,其他變量值不變

rewind方法當讀取數據讀到一半時發現數據有問題,需要從頭開始讀起,此時可以調用rewind方法,該方法和flip方法類似,但是不影響limit屬性,而只是將position置為0,mark設置為-1,相當於讀模式下從頭開始讀數據;寫模式下從頭開始寫數據

compact方法當讀取數據讀到一半時,又需要重新切換到寫模式時,此時已讀部分數據的空間就無法再寫入數據,那么就會造成空間的浪費,此時可以調用compact方法將剩余的空間進行壓縮,實現邏輯就是將當前剩余未讀的數據復制到數組的0的位置,而position設置為下一個可以寫的位置,limit重新設置為最大值capacity。

mark方法:用來標記當前的position位置,將mark設置為position值

reset方法:將position重新設置上一次標記的值,也就是position=mark值

clear方法:清空緩沖區,設置position=0,mark=-1,limit=capacity,這里的clear只是設置各個位置屬性的值,而數組內的數據並不會真的被清空

 

以ByteBuffer為例分配內存是可以分配堆內內存和堆外內存

ByteBuffer.allocation(int catacity):分配堆內內存

ByteBuffer.allocationDirect(int catacity):分配堆外內存(直接內存)

 

3.3.3、圖解Buffer的核心方法邏輯

1、初始化容量為10的數組,如下圖:

初始化時容量為10,此時mark為默認值-1,position為默認值0,limit和capacity都是容量的值為10

 

2、調用put方法向數組中寫入5個數據,此時position值為5,limit、capacity和mark值不變,如下圖:

 

3、此時不需要再寫數據,而是需要從緩沖區讀數據時,調用flip方法將寫模式切換成讀模式,此時position值為0,limit值為當前position值為5,capacity和mark值不變,如下圖:

 

4、當讀取數據讀到第2個數據時,為了防止后面的數據讀取失敗,可以標記當前的位置,調用mark方法將mark設為position的值,而其他變量不變,如下圖:

 

5、當讀數據到位置2之后,發現又需要切換到寫模式,那么此時就需要重新向數據中寫入數據,此時0-4的位置已經有了數據,就需要從5的位置開始寫入,而已經讀取的0-2兩個位置已經被浪費了,所以為了避免空間的浪費,可以調用compact方法進行空間壓縮,

壓縮的邏輯為將剩余所有未讀的數據復制到數組下標為0的位置,然后將position設置為下一個可以寫的位置i,limit設置為capacity的值。如下圖示:

 

將數據“CDE”移動數組為0的位置,此時position值為3,雖然3-4位置已經有了數據"DE",但是馬上就會被新寫入的數據覆蓋掉,所以不會有重復數據的問題。

Extra:

ByteBuffer創建的時候會在內存中申請一塊區域,而分配的內存可以分為堆內內存和堆外內存(直接內存)

ByteBuffer的兩個子類HeapByteBuffer和DirectByteBuffer分別就是使用的堆內內存和堆外內存

堆內內存是JVM堆內存中分配,所以堆內內存的分配和釋放以及垃圾回收都是由JVM來控制的, 而堆外內存(直接內存)是直接在系統內存中分配和釋放的,所以不會受到JVM的控制。

堆外內存的分配是通過本地類Unsafe的本地方法allocationMemory方法來分配的,釋放內存是Unsafe的freeMemory方法來釋放的。不過DirectByteBuffer本身的引用還是存在堆內內存中的,只不過持有堆外內存地址的引用。

由於DirectByteBuffer本身在堆中占用內存較少,但是持有直接內存的空間可能比較大,所以可能會出現不停創建比較大的DirectByteBuffer,就會出現堆內空間充足而堆外內存不足的情況。所以僅僅靠JVM的GC來回收DirectByteBuffer間接的回收

堆外內存肯定是不靠譜的,所以需要通過DirectByteBuuffer的Cleaner對象調用Clean方法來釋放堆外內存。

 

相比於堆內內存,堆外內存的優缺點分明:

優點:

1、方便創建大對象,伸縮性更好,很少會遇到創建對象時出現內存不足的情況

2、進程之間共享內存,避免了將內存復制到虛擬機的過程

缺點:

1、垃圾回收不可控,容易出現內存泄露的問題

2、需要顯式的調用System.gc()來釋放堆外內存

DirectByteBuffer內部有一個Cleaner對象,Cleaner本身持有一個Cleaner對象的鏈表,當執行GC時會先通過Cleaner對象調用Unsafe的freeMemory方法進行堆外內存的釋放。

當程序中需要用到將數據從堆外復制到堆內時,可以使用堆外內存,避免數據的復制過程,如常見的IO操作等。


免責聲明!

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



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