微信公衆号【黃小斜】作者是螞蟻金服 JAVA 工程師,目前在螞蟻财富負責後端開發工作,專注于 JAVA 後端技術棧,同時也懂點投資理财,堅持學習和寫作,用大廠程式員的視角解讀技術與網際網路,我的世界裡不隻有 coding!關注公衆号後回複”架構師“即可領取 Java基礎、進階、項目和架構師等免費學習資料,更有資料庫、分布式、微服務等熱門技術學習視訊,内容豐富,兼顧原理和實踐,另外也将贈送作者原創的Java學習指南、Java程式員面試指南等幹貨資源
Linux epoll實作原理詳解
在linux 沒有實作epoll事件驅動機制之前,我們一般選擇用select或者poll等IO多路複用的方法來實作并發服務程式。在大資料、高并發、叢集等一些名詞唱得火熱之年代,select和poll的用武之地越來越有限,風頭已經被epoll占盡。
本文便來介紹epoll的實作機制,并附帶講解一下select和poll。通過對比其不同的實作機制,真正了解為何epoll能實作高并發。
這部分轉自https://jeff.wtf/2017/02/IO-multiplexing/
為什麼要 I/O 多路複用
當需要從一個叫
r_fd
的描述符不停地讀取資料,并把讀到的資料寫入一個叫
w_fd
的描述符時,我們可以用循環使用阻塞 I/O :
| |
---|
但是,如果要從兩個地方讀取資料呢?這時,不能再使用會把程式阻塞住的
read
函數。因為可能在阻塞地等待
r_fd1
的資料時,來不及處理
r_fd2
,已經到達的
r_fd2
的資料可能會丢失掉。
這個情況下需要使用非阻塞 I/O。
隻要做個标記,把檔案描述符标記為非阻塞的,以後再對它使用
read
函數:如果它還沒有資料可讀,函數會立即傳回并把 errorno 這個變量的值設定為 35,于是我們知道它沒有資料可讀,然後可以立馬去對其他描述符使用
read
;如果它有資料可讀,我們就讀取它資料。對所有要讀的描述符都調用了一遍
read
之後,我們可以等一個較長的時間(比如幾秒),然後再從第一個檔案描述符開始調用
read
。這種循環就叫做輪詢(polling)。
這樣,不會像使用阻塞 I/O 時那樣因為一個描述符
read
長時間處于等待資料而使程式阻塞。
輪詢的缺點是浪費太多 CPU 時間。大多數時候我們沒有資料可讀,但是還是用了
read
這個系統調用,使用系統調用時會從使用者态切換到核心态。而大多數情況下我們調用
read
,然後陷入核心态,核心發現這個描述符沒有準備好,然後切換回使用者态并且隻得到 EAGAIN (errorno 被設定為 35),做的是無用功。描述符非常多的時候,每次的切換過程就是巨大的浪費。
是以,需要 I/O 多路複用。I/O 多路複用通過使用一個系統函數,同時等待多個描述符的可讀、可寫狀态。
為了達到這個目的,我們需要做的是:建立一個描述符清單,以及我們分别關心它們的什麼事件(可讀還是可寫還是發生例外情況);調用一個系統函數,直到這個描述符清單裡有至少一個描述符關聯的事件發生時,這個函數才會傳回。
select, poll, epoll 就是這樣的系統函數。
select,poll,epoll 源碼分析
select
我們可以在所有 POSIX 相容的系統裡使用 select 函數來進行 I/O 多路複用。我們需要通過 select 函數的參數傳遞給核心的資訊有:
- 我們關心哪些描述符
- 我們關心它們的什麼事件
- 我們希望等待多長時間
select 的傳回時,核心會告訴我們:
- 可讀的描述符的個數
- 哪些描述符發生了哪些事件
| |
---|
maxfdp1
意思是 “max file descriptor plus 1” ,就是把你要監視的所有檔案描述符裡最大的那個加上 1 。(它實際上決定了核心要周遊檔案描述符的次數,比如你監視了檔案描述符 5 和 20 并把
maxfdp1
設定為 21 ,核心每次都會從描述符 0 依次檢查到 20。)
中間的三個參數是你想監視的檔案描述符的集合。可以把 fd_set 類型視為 1024 位的二進制數,這意味着 select 隻能監視小于 1024 的檔案描述符(1024 是由 Linux 的 sys/select.h 裡
FD_SETSIZE
宏設定的值)。在 select 傳回後我們通過
FD_ISSET
來判斷代表該位的描述符是否是已準備好的狀态。
最後一個參數是等待逾時的時長:到達這個時長但是沒有任一描述符可用時,函數會傳回 0 。
用一個代碼片段來展示 select 的用法:
| |
---|
可以看到,select 的缺點有:
- 預設能監視的檔案描述符不能大于 1024,也代表監視的總數不超過1024。即使你因為需要監視的描述符大于 1024 而改動核心的
值,但由于 select 是每次都會線性掃描整個fd_set,集合越大速度越慢,是以性能會比較差。FD_SETSIZE
- select 函數傳回時隻能看見已準備好的描述符數量,至于是哪個描述符準備好了需要循環用
來檢查,當未準備好的描述符很多而準備好的很少時,效率比較低。FD_ISSET
- select 函數每次執行的時候,都把參數裡傳入的三個 fd_set 從使用者空間複制到核心空間。而每次 fd_set 裡要監視的描述符變化不大時,全部重新複制一遍并不劃算。同樣在每次都是未準備好的描述符很多而準備好的很少時,調用 select 會很頻繁,使用者/核心間的的資料複制就成了一個大的開銷。
還有一個問題是在代碼的寫法上給我一些困擾的,就是每次調用 select 前必須重新設定三個 fd_set。 fd_set 類型隻是 1024 位的二進制數(實際上結構體裡是幾個 long 變量的數組;比如 64 位機器上 long 是 64 bit,那麼 fd_set 裡就是 16 個 long 變量的數組),由一位的 1 和 0 代表一個檔案描述符的狀态,但是其實調用 select 前後位的 1/0 狀态意義是不一樣的。
先講一下幾個對 fd_set 操作的函數的作用:
FD_ZERO
把 fd_set 所有位設定為 0 ;
FD_SET
把一個位設定為 1 ;
FD_ISSET
判斷一個位是否為 1 。
調用 select 前:我們用
FD_ZERO
把 fd_set 先全部初始化,然後用
FD_SET
把我們關心的代表描述符的位設定為 1 。我們這時可以用
FD_ISSET
判斷這個位是否被我們設定,這時的含義是我們想要監視的描述符是否被設定為被監視的狀态。
調用 select 時:核心判斷 fd_set 裡的位并把各個 fd_set 裡所有值為 1 的位記錄下來,然後把 fd_set 全部設定成 0 ;一個描述符上有對應的事件發生時,把對應 fd_set 裡代表這個描述符的位設定為 1 。
在 select 傳回之後:我們同樣用
FD_ISSET
判斷各個我們關心的位是 0 還是 1 ,這時的含義是,這個位是否是發生了我們關心的事件。
是以,在下一次調用 select 前,我們不得不把已經被核心改掉的 fd_set 全部重新設定一下。
select 在監視大量描述符尤其是更多的描述符未準備好的情況時性能很差。《Unix 進階程式設計》裡寫,用 select 的程式通常隻使用 3 到 10 個描述符。
poll
poll 和 select 是相似的,隻是給的接口不同。
| |
---|
fdarray
是
pollfd
的數組。
pollfd
結構體是這樣的:
| |
---|
nfds
fdarray
的長度,也就是 pollfd 的個數。
timeout
代表等待逾時的毫秒數。
相比 select ,poll 有這些優點:由于 poll 在 pollfd 裡用
int fd
來表示檔案描述符而不像 select 裡用的 fd_set 來分别表示描述符,是以沒有必須小于 1024 的限制,也沒有數量限制;由于 poll 用
events
表示期待的事件,通過修改
revents
來表示發生的事件,是以不需要像 select 在每次調用前重新設定描述符和期待的事件。
除此之外,poll 和 select 幾乎相同。在 poll 傳回後,需要周遊
fdarray
來檢查各個
pollfd
裡的
revents
是否發生了期待的事件;每次調用 poll 時,把
fdarray
複制到核心空間。在描述符太多而每次準備好的較少時,poll 有同樣的性能問題。
epoll
epoll 是在 Linux 2.5.44 中首度登場的。不像 select 和 poll ,它提供了三個系統函數而不是一個。
epoll_create 用來建立一個 epoll 描述符:
| |
---|
size
用來告訴核心你想監視的檔案描述符的數目,但是它并不是限制了能監視的描述符的最大個數,而是給核心最初配置設定的空間一個建議。然後系統會在核心中配置設定一個空間來存放事件表,并傳回一個 epoll 描述符,用來操作這個事件表。
epoll_ctl 用來增/删/改核心中的事件表:
| |
---|
epfd
是 epoll 描述符。
op
是操作類型(增加/删除/修改)。
fd
是希望監視的檔案描述符。
event
是一個 epoll_event 結構體的指針。epoll_event 的定義是這樣的:
| |
---|
這個結構體裡,除了期待的事件外,還有一個
data
,是一個 union,它是用來讓我們在得到下面第三個函數的傳回值以後友善的定位檔案描述符的。
epoll_wait 用來等待事件
| |
---|
epfd
result_events
是 epoll_event 結構體的指針,它将指向的是所有已經準備好的事件描述符相關聯的 epoll_event(在上個步驟裡調用 epoll_ctl 時關聯起來的)。下面的例子可以讓你知道這個參數的意義。
maxevents
是傳回的最大事件個數,也就是你能通過 result_events 指針周遊到的最大的次數。
timeout
是等待逾時的毫秒數。
用一個代碼片段來展示 epoll 的用法:
| |
---|
是以 epoll 解決了 poll 和 select 的問題:
- 隻在 epoll_ctl 的時候把資料複制到核心空間,這保證了每個描述符和事件一定隻會被複制到核心空間一次;每次調用 epoll_wait 都不會複制新資料到核心空間。相比之下,select 每次調用都會把三個 fd_set 複制一遍;poll 每次調用都會把
複制一遍。fdarray
- epoll_wait 傳回 n ,那麼隻需要做 n 次循環,可以保證周遊的每一次都是有意義的。相比之下,select 需要做至少 n 次至多
次循環;poll 需要周遊完 fdarray 即做maxfdp1
次循環。nfds
- 在内部實作上,epoll 使用了回調的方法。調用 epoll_ctl 時,就是注冊了一個事件:在集合中放入檔案描述符以及事件資料,并且加上一個回調函數。一旦檔案描述符上的對應事件發生,就會調用回調函數,這個函數會把這個檔案描述符加入到就緒隊列上。當你調用 epoll_wait 時,它隻是在檢視就緒隊列上是否有内容,有的話就傳回給你的程式。
select()
三個函數在作業系統看來,都是睡眠一會兒然後判斷一會兒的循環,但是 select 和 poll 在醒着的時候要周遊整個檔案描述符集合,而 epoll_wait 隻是看看就緒隊列是否為空而已。這是 epoll 高性能的理由,使得其 I/O 的效率不會像使用輪詢的 select/poll 随着描述符增加而大大降低。poll()``epoll_wait()
注 1 :select/poll/epoll_wait 三個函數的等待逾時時間都有一樣的特性:等待時間設定為 0 時函數不阻塞而是立即傳回,不論是否有檔案描述符已準備好;poll/epoll_wait 中的 timeout 為 -1,select 中的 timeout 為 NULL 時,則無限等待,直到有描述符已準備好才會傳回。
注 2 :有的新手會把檔案描述符是否标記為阻塞 I/O 等同于 I/O 多路複用函數是否阻塞。其實檔案描述符是否标記為阻塞,決定了你
或
read
write
它時如果它未準備好是阻塞等待,還是立即傳回 EAGAIN ;而 I/O 多路複用函數除非你把 timeout 設定為 0 ,否則它總是會阻塞住你的程式。
注 3 :上面的例子隻是入門,可能是不準确或不全面的:一是資料要立即處理防止丢失;二是 EPOLLIN/EPOLLOUT 不完全等同于可讀可寫事件,具體要去搜尋 poll/epoll 的事件具體有哪些;三是大多數實際例子裡,比如一個 tcp server ,都會在運作中不斷增加/删除的檔案描述符而不是記住固定的 3 4 5 幾個描述符(用這種例子更能看出 epoll 的優勢);四是 epoll 的優勢更多的展現在處理大量閑連接配接的情況,如果場景是處理少量短連接配接,用 select 反而更好,而且用 select 的代碼能運作在所有平台上。
Epoll資料結構:
select()和poll() IO多路複用模型
select的缺點:
- 單個程序能夠監視的檔案描述符的數量存在最大限制,通常是1024,當然可以更改數量,但由于select采用輪詢的方式掃描檔案描述符,檔案描述符數量越多,性能越差;(在linux核心頭檔案中,有這樣的定義:#define __FD_SETSIZE 1024)
- 核心 / 使用者空間記憶體拷貝問題,select需要複制大量的句柄資料結構,産生巨大的開銷;
- select傳回的是含有整個句柄的數組,應用程式需要周遊整個數組才能發現哪些句柄發生了事件;
- select的觸發方式是水準觸發,應用程式如果沒有完成對一個已經就緒的檔案描述符進行IO操作,那麼之後每次select調用還是會将這些檔案描述符通知程序。
相比select模型,poll使用連結清單儲存檔案描述符,是以沒有了監視檔案數量的限制,但其他三個缺點依然存在。
拿select模型為例,假設我們的伺服器需要支援100萬的并發連接配接,則在__FD_SETSIZE 為1024的情況下,則我們至少需要開辟1k個程序才能實作100萬的并發連接配接。除了程序間上下文切換的時間消耗外,從核心/使用者空間大量的無腦記憶體拷貝、數組輪詢等,是系統難以承受的。是以,基于select模型的伺服器程式,要達到10萬級别的并發通路,是一個很難完成的任務。
是以,該epoll上場了。
epoll IO多路複用模型實作機制
由于epoll的實作機制與select/poll機制完全不同,上面所說的 select的缺點在epoll上不複存在。
設想一下如下場景:有100萬個用戶端同時與一個伺服器程序保持着TCP連接配接。而每一時刻,通常隻有幾百上千個TCP連接配接是活躍的(事實上大部分場景都是這種情況)。如何實作這樣的高并發?
在select/poll時代,伺服器程序每次都把這100萬個連接配接告訴作業系統(從使用者态複制句柄資料結構到核心态),讓作業系統核心去查詢這些套接字上是否有事件發生,輪詢完後,再将句柄資料複制到使用者态,讓伺服器應用程式輪詢處理已發生的網絡事件,這一過程資源消耗較大,是以,select/poll一般隻能處理幾千的并發連接配接。
epoll的設計和實作與select完全不同。epoll通過在Linux核心中申請一個簡易的檔案系統(檔案系統一般用什麼資料結構實作?B+樹)。把原先的select/poll調用分成了3個部分:
1)調用epoll_create()建立一個epoll對象(在epoll檔案系統中為這個句柄對象配置設定資源)
2)調用epoll_ctl向epoll對象中添加這100萬個連接配接的套接字
3)調用epoll_wait收集發生的事件的連接配接
如此一來,要實作上面說是的場景,隻需要在程序啟動時建立一個epoll對象,然後在需要的時候向這個epoll對象中添加或者删除連接配接。同時,epoll_wait的效率也非常高,因為調用epoll_wait時,并沒有一股腦的向作業系統複制這100萬個連接配接的句柄資料,核心也不需要去周遊全部的連接配接。
下面來看看Linux核心具體的epoll機制實作思路。
當某一程序調用epoll_create方法時,Linux核心會建立一個eventpoll結構體,這個結構體中有兩個成員與epoll的使用方式密切相關。eventpoll結構體如下所示:
[cpp] view plain copy
- struct eventpoll{
- ....
- /紅黑樹的根節點,這顆樹中存儲着所有添加到epoll中的需要監控的事件/
- struct rb_root rbr;
- /雙連結清單中則存放着将要通過epoll_wait傳回給使用者的滿足條件的事件/
- struct list_head rdlist;
- };
每一個epoll對象都有一個獨立的eventpoll結構體,用于存放通過epoll_ctl方法向epoll對象中添加進來的事件。這些事件都會挂載在紅黑樹中,如此,重複添加的事件就可以通過紅黑樹而高效的識别出來(紅黑樹的插入時間效率是lgn,其中n為樹的高度)。
而所有添加到epoll中的事件都會與裝置(網卡)驅動程式建立回調關系,也就是說,當相應的事件發生時會調用這個回調方法。這個回調方法在核心中叫ep_poll_callback,它會将發生的事件添加到rdlist雙連結清單中。
在epoll中,對于每一個事件,都會建立一個epitem結構體,如下所示:
- struct epitem{
- struct rb_node rbn;//紅黑樹節點
- struct list_head rdllink;//雙向連結清單節點
- struct epoll_filefd ffd; //事件句柄資訊
- struct eventpoll *ep; //指向其所屬的eventpoll對象
- struct epoll_event event; //期待發生的事件類型
- }
當調用epoll_wait檢查是否有事件發生時,隻需要檢查eventpoll對象中的rdlist雙連結清單中是否有epitem元素即可。如果rdlist不為空,則把發生的事件複制到使用者态,同時将事件數量傳回給使用者。
epoll資料結構示意圖
從上面的講解可知:通過紅黑樹和雙連結清單資料結構,并結合回調機制,造就了epoll的高效。
OK,講解完了Epoll的機理,我們便能很容易掌握epoll的用法了。一句話描述就是:三步曲。
第一步:epoll_create()系統調用。此調用傳回一個句柄,之後所有的使用都依靠這個句柄來辨別。
第二步:epoll_ctl()系統調用。通過此調用向epoll對象中添加、删除、修改感興趣的事件,傳回0辨別成功,傳回-1表示失敗。
第三部:epoll_wait()系統調用。通過此調用收集收集在epoll監控中已經發生的事件。
epoll執行個體
最後,附上一個epoll程式設計執行個體。
幾乎所有的epoll程式都使用下面的架構:
[cpp] view plaincopyprint?
- for( ; ; )
- {
- nfds = epoll_wait(epfd,events,20,500);
- for(i=0;i<nfds;++i) < span="" >
- if(events[i].data.fd==listenfd) //有新的連接配接
- connfd = accept(listenfd,(sockaddr *)&clientaddr, &clilen); //accept這個連接配接
- ev.data.fd=connfd;
- ev.events=EPOLLIN|EPOLLET;
- epoll_ctl(epfd,EPOLL_CTL_ADD,connfd,&ev); //将新的fd添加到epoll的監聽隊列中
- else if( events[i].events&EPOLLIN ) //接收到資料,讀socket
- n = read(sockfd, line, MAXLINE)) < 0 //讀
- ev.data.ptr = md; //md為自定義類型,添加資料
- ev.events=EPOLLOUT|EPOLLET;
- epoll_ctl(epfd,EPOLL_CTL_MOD,sockfd,&ev);//修改辨別符,等待下一個循環時發送資料,異步處理的精髓
- else if(events[i].events&EPOLLOUT) //有資料待發送,寫socket
- struct myepoll_data* md = (myepoll_data*)events[i].data.ptr; //取資料
- sockfd = md->fd;
- send( sockfd, md->ptr, strlen((char*)md->ptr), 0 ); //發送資料
- ev.data.fd=sockfd;
- epoll_ctl(epfd,EPOLL_CTL_MOD,sockfd,&ev); //修改辨別符,等待下一個循環時接收資料
- else
- //其他的處理
epoll的程式執行個體
- #include
-
include
-
-
-
-
-
-
-
-
define MAXEVENTS 64
- //函數:
- //功能:建立和綁定一個TCP socket
- //參數:端口
- //傳回值:建立的socket
- static int
- create_and_bind (char *port)
- struct addrinfo hints;
- struct addrinfo *result, *rp;
- int s, sfd;
- memset (&hints, 0, sizeof (struct addrinfo));
- hints.ai_family = AF_UNSPEC; /* Return IPv4 and IPv6 choices */
- hints.ai_socktype = SOCK_STREAM; /* We want a TCP socket */
- hints.ai_flags = AI_PASSIVE; /* All interfaces */
- s = getaddrinfo (NULL, port, &hints, &result);
- if (s != 0)
- fprintf (stderr, "getaddrinfo: %s\n", gai_strerror (s));
- return -1;
- for (rp = result; rp != NULL; rp = rp->ai_next)
- sfd = socket (rp->ai_family, rp->ai_socktype, rp->ai_protocol);
- if (sfd == -1)
- continue;
- s = bind (sfd, rp->ai_addr, rp->ai_addrlen);
- if (s == 0)
- /* We managed to bind successfully! */
- break;
- close (sfd);
- if (rp == NULL)
- fprintf (stderr, "Could not bind\n");
- freeaddrinfo (result);
- return sfd;
- //函數
- //功能:設定socket為非阻塞的
- make_socket_non_blocking (int sfd)
- int flags, s;
- //得到檔案狀态标志
- flags = fcntl (sfd, F_GETFL, 0);
- if (flags == -1)
- perror ("fcntl");
- //設定檔案狀态标志
- flags |= O_NONBLOCK;
- s = fcntl (sfd, F_SETFL, flags);
- if (s == -1)
- return 0;
- //端口由參數argv[1]指定
- int
- main (int argc, char *argv[])
- int sfd, s;
- int efd;
- struct epoll_event event;
- struct epoll_event *events;
- if (argc != 2)
- fprintf (stderr, "Usage: %s [port]\n", argv[0]);
- exit (EXIT_FAILURE);
- sfd = create_and_bind (argv[1]);
- abort ();
- s = make_socket_non_blocking (sfd);
- s = listen (sfd, SOMAXCONN);
- perror ("listen");
- //除了參數size被忽略外,此函數和epoll_create完全相同
- efd = epoll_create1 (0);
- if (efd == -1)
- perror ("epoll_create");
- event.data.fd = sfd;
- event.events = EPOLLIN | EPOLLET;//讀入,邊緣觸發方式
- s = epoll_ctl (efd, EPOLL_CTL_ADD, sfd, &event);
- perror ("epoll_ctl");
- /* Buffer where events are returned */
- events = calloc (MAXEVENTS, sizeof event);
- /* The event loop */
- while (1)
- int n, i;
- n = epoll_wait (efd, events, MAXEVENTS, -1);
- for (i = 0; i < n; i++)
- if ((events[i].events & EPOLLERR) ||
- (events[i].events & EPOLLHUP) ||
- (!(events[i].events & EPOLLIN)))
- /* An error has occured on this fd, or the socket is not
- ready for reading (why were we notified then?) */
- fprintf (stderr, "epoll error\n");
- close (events[i].data.fd);
- else if (sfd == events[i].data.fd)
- /* We have a notification on the listening socket, which
- means one or more incoming connections. */
- struct sockaddr in_addr;
- socklen_t in_len;
- int infd;
- char hbuf[NI_MAXHOST], sbuf[NI_MAXSERV];
- in_len = sizeof in_addr;
- infd = accept (sfd, &in_addr, &in_len);
- if (infd == -1)
- if ((errno == EAGAIN) ||
- (errno == EWOULDBLOCK))
- /* We have processed all incoming
- connections. */
- perror ("accept");
- //将位址轉化為主機名或者服務名
- s = getnameinfo (&in_addr, in_len,
- hbuf, sizeof hbuf,
- sbuf, sizeof sbuf,
- NI_NUMERICHOST | NI_NUMERICSERV);//flag參數:以數字名傳回
- //主機位址和服務位址
- printf("Accepted connection on descriptor %d "
- "(host=%s, port=%s)\n", infd, hbuf, sbuf);
- /* Make the incoming socket non-blocking and add it to the
- list of fds to monitor. */
- s = make_socket_non_blocking (infd);
- event.data.fd = infd;
- event.events = EPOLLIN | EPOLLET;
- s = epoll_ctl (efd, EPOLL_CTL_ADD, infd, &event);
- /* We have data on the fd waiting to be read. Read and
- display it. We must read whatever data is available
- completely, as we are running in edge-triggered mode
- and won't get a notification again for the same
- data. */
- int done = 0;
- ssize_t count;
- char buf[512];
- count = read (events[i].data.fd, buf, sizeof(buf));
- if (count == -1)
- /* If errno == EAGAIN, that means we have read all
- data. So go back to the main loop. */
- if (errno != EAGAIN)
- perror ("read");
- done = 1;
- else if (count == 0)
- /* End of file. The remote has closed the
- connection. */
- /* Write the buffer to standard output */
- s = write (1, buf, count);
- perror ("write");
- if (done)
- printf ("Closed connection on descriptor %d\n",
- events[i].data.fd);
- /* Closing the descriptor will make epoll remove it
- from the set of descriptors which are monitored. */
- free (events);
- return EXIT_SUCCESS;
微信公衆号【Java技術江湖】一位阿裡 Java 工程師的技術小站。(關注公衆号後回複”Java“即可領取 Java基礎、進階、項目和架構師等免費學習資料,更有資料庫、分布式、微服務等熱門技術學習視訊,内容豐富,兼顧原理和實踐,另外也将贈送作者原創的Java學習指南、Java程式員面試指南等幹貨資源)