微信公衆號【黃小斜】做者是螞蟻金服 JAVA 工程師,目前在螞蟻財富負責後端開發工做,專一於 JAVA 後端技術棧,同時也懂點投資理財,堅持學習和寫做,用大廠程序員的視角解讀技術與互聯網,個人世界裏不僅有 coding!關注公衆號後回覆」架構師「便可領取 Java基礎、進階、項目和架構師等免費學習資料,更有數據庫、分佈式、微服務等熱門技術學習視頻,內容豐富,兼顧原理和實踐,另外也將贈送做者原創的Java學習指南、Java程序員面試指南等乾貨資源html
轉自知乎:java
epollwait的實現~有關從內核態拷貝到用戶態代碼.能夠看到putuser這個函數就是內核拷貝到用戶空間.分析完整個linux 2.6版本的epoll實現沒有發現使用了mmap系統調用,根本不存在共享內存在epoll的實現node
if (revents) {
/* 將當前的事件和用戶傳入的數據都copy給用戶空間,
* 就是epoll_wait()後應用程序能讀到的那一堆數據. */
if (__put_user(revents, &uevent->events) ||
__put_user(epi->event.data, &uevent->data)) {
/* 若是copy過程當中發生錯誤, 會中斷鏈表的掃描,
* 並把當前發生錯誤的epitem從新插入到ready list.
* 剩下的沒處理的epitem也不會丟棄, 在ep_scan_ready_list()
* 中它們也會被從新插入到ready list */
list_add(&epi->rdllink, head);
return eventcnt ? eventcnt : -EFAULT;
}複製代碼
那麼既然提到了,就讓咱們看看mmap究竟是什麼吧linux
轉自:https://www.cnblogs.com/huxiao-tee/p/4660352.html程序員
回到頂部面試
mmap是一種內存映射文件的方法,即將一個文件或者其它對象映射到進程的地址空間,實現文件磁盤地址和進程虛擬地址空間中一段虛擬地址的一一對映關係。實現這樣的映射關係後,進程就能夠採用指針的方式讀寫操做這一段內存,而系統會自動回寫髒頁面到對應的文件磁盤上,即完成了對文件的操做而沒必要再調用read,write等系統調用函數。相反,內核空間對這段區域的修改也直接反映用戶空間,從而能夠實現不一樣進程間的文件共享。以下圖所示:spring
由上圖能夠看出,進程的虛擬地址空間,由多個虛擬內存區域構成。虛擬內存區域是進程的虛擬地址空間中的一個同質區間,即具備一樣特性的連續地址範圍。上圖中所示的text數據段(代碼段)、初始數據段、BSS數據段、堆、棧和內存映射,都是一個獨立的虛擬內存區域。而爲內存映射服務的地址空間處在堆棧之間的空餘部分。數據庫
linux內核使用vmareastruct結構來表示一個獨立的虛擬內存區域,因爲每一個不一樣質的虛擬內存區域功能和內部機制都不一樣,所以一個進程使用多個vmareastruct結構來分別表示不一樣類型的虛擬內存區域。各個vmareastruct結構使用鏈表或者樹形結構連接,方便進程快速訪問,以下圖所示:後端
vmareastruct結構中包含區域起始和終止地址以及其餘相關信息,同時也包含一個vmops指針,其內部可引出全部針對這個區域可使用的系統調用函數。這樣,進程對某一虛擬內存區域的任何操做須要用要的信息,均可以從vmareastruct中得到。mmap函數就是要建立一個新的vmarea_struct結構,並將其與文件的物理磁盤地址相連。具體步驟請看下一節。緩存
mmap內存映射的實現過程,總的來講能夠分爲三個階段:
(一)進程啓動映射過程,並在虛擬地址空間中爲映射建立虛擬映射區域
一、進程在用戶空間調用庫函數mmap,原型:void mmap(void start, sizet length, int prot, int flags, int fd, offt offset);
二、在當前進程的虛擬地址空間中,尋找一段空閒的知足要求的連續的虛擬地址
三、爲此虛擬區分配一個vmareastruct結構,接着對這個結構的各個域進行了初始化
四、將新建的虛擬區結構(vmareastruct)插入進程的虛擬地址區域鏈表或樹中
(二)調用內核空間的系統調用函數mmap(不一樣於用戶空間函數),實現文件物理地址和進程虛擬地址的一一映射關係
五、爲映射分配了新的虛擬地址區域後,經過待映射的文件指針,在文件描述符表中找到對應的文件描述符,經過文件描述符,連接到內核「已打開文件集」中該文件的文件結構體(struct file),每一個文件結構體維護着和這個已打開文件相關各項信息。
六、經過該文件的文件結構體,連接到fileoperations模塊,調用內核函數mmap,其原型爲:int mmap(struct file *filp, struct vmarea_struct *vma),不一樣於用戶空間庫函數。
七、內核mmap函數經過虛擬文件系統inode模塊定位到文件磁盤物理地址。
八、經過remappfnrange函數創建頁表,即實現了文件地址和虛擬地址區域的映射關係。此時,這片虛擬地址並無任何數據關聯到主存中。
(三)進程發起對這片映射空間的訪問,引起缺頁異常,實現文件內容到物理內存(主存)的拷貝
注:前兩個階段僅在於建立虛擬區間並完成地址映射,可是並無將任何文件數據的拷貝至主存。真正的文件讀取是當進程發起讀或寫操做時。
九、進程的讀或寫操做訪問虛擬地址空間這一段映射地址,經過查詢頁表,發現這一段地址並不在物理頁面上。由於目前只創建了地址映射,真正的硬盤數據尚未拷貝到內存中,所以引起缺頁異常。
十、缺頁異常進行一系列判斷,肯定無非法操做後,內核發起請求調頁過程。
十一、調頁過程先在交換緩存空間(swap cache)中尋找須要訪問的內存頁,若是沒有則調用nopage函數把所缺的頁從磁盤裝入到主存中。
十二、以後進程便可對這片主存進行讀或者寫的操做,若是寫操做改變了其內容,必定時間後系統會自動回寫髒頁面到對應磁盤地址,也即完成了寫入到文件的過程。
注:修改過的髒頁面並不會當即更新迴文件中,而是有一段時間的延遲,能夠調用msync()來強制同步, 這樣所寫的內容就能當即保存到文件裏了。
對linux文件系統不瞭解的朋友,請參閱我以前寫的博文《從內核文件系統看文件讀寫過程》,咱們首先簡單的回顧一下常規文件系統操做(調用read/fread等類函數)中,函數的調用過程:
一、進程發起讀文件請求。
二、內核經過查找進程文件符表,定位到內核已打開文件集上的文件信息,從而找到此文件的inode。
三、inode在address_space上查找要請求的文件頁是否已經緩存在頁緩存中。若是存在,則直接返回這片文件頁的內容。
四、若是不存在,則經過inode定位到文件磁盤地址,將數據從磁盤複製到頁緩存。以後再次發起讀頁面過程,進而將頁緩存中的數據發給用戶進程。
總結來講,常規文件操做爲了提升讀寫效率和保護磁盤,使用了頁緩存機制。這樣形成讀文件時須要先將文件頁從磁盤拷貝到頁緩存中,因爲頁緩存處在內核空間,不能被用戶進程直接尋址,因此還須要將頁緩存中數據頁再次拷貝到內存對應的用戶空間中。這樣,經過了兩次數據拷貝過程,才能完成進程對文件內容的獲取任務。寫操做也是同樣,待寫入的buffer在內核空間不能直接訪問,必需要先拷貝至內核空間對應的主存,再寫回磁盤中(延遲寫回),也是須要兩次數據拷貝。
而使用mmap操做文件中,建立新的虛擬內存區域和創建文件磁盤地址和虛擬內存區域映射這兩步,沒有任何文件拷貝操做。而以後訪問數據時發現內存中並沒有數據而發起的缺頁異常過程,能夠經過已經創建好的映射關係,只使用一次數據拷貝,就從磁盤中將數據傳入內存的用戶空間中,供進程使用。
總而言之,常規文件操做須要從磁盤到頁緩存再到用戶主存的兩次數據拷貝。而mmap操控文件,只須要從磁盤到用戶主存的一次數據拷貝過程。說白了,mmap的關鍵點是實現了用戶空間和內核空間的數據直接交互而省去了空間不一樣數據不通的繁瑣過程。所以mmap效率更高。
由上文討論可知,mmap優勢共有一下幾點:
一、對文件的讀取操做跨過了頁緩存,減小了數據的拷貝次數,用內存讀寫取代I/O讀寫,提升了文件讀取效率。
二、實現了用戶空間和內核空間的高效交互方式。兩空間的各自修改操做能夠直接反映在映射的區域內,從而被對方空間及時捕捉。
三、提供進程間共享內存及相互通訊的方式。無論是父子進程仍是無親緣關係的進程,均可以將自身用戶空間映射到同一個文件或匿名映射到同一片區域。從而經過各自對映射區域的改動,達到進程間通訊和進程間共享的目的。
同時,若是進程A和進程B都映射了區域C,當A第一次讀取C時經過缺頁從磁盤複製文件頁到內存中;但當B再讀C的相同頁面時,雖然也會產生缺頁異常,可是再也不須要從磁盤中複製文件過來,而可直接使用已經保存在內存中的文件數據。
四、可用於實現高效的大規模數據傳輸。內存空間不足,是制約大數據操做的一個方面,解決方案每每是藉助硬盤空間協助操做,補充內存的不足。可是進一步會形成大量的文件I/O操做,極大影響效率。這個問題能夠經過mmap映射很好的解決。換句話說,但凡是須要用磁盤空間代替內存的時候,mmap均可以發揮其功效。
原文出處: tomas家的小撥浪鼓
堆外內存是相對於堆內內存的一個概念。堆內內存是由JVM所管控的Java進程內存,咱們平時在Java中建立的對象都處於堆內內存中,而且它們遵循JVM的內存管理機制,JVM會採用垃圾回收機制統一管理它們的內存。那麼堆外內存就是存在於JVM管控以外的一塊內存區域,所以它是不受JVM的管控。
在講解DirectByteBuffer以前,須要先簡單瞭解兩個知識點。
java引用類型,由於DirectByteBuffer是經過虛引用(Phantom Reference)來實現堆外內存的釋放的。
PhantomReference 是全部「弱引用」中最弱的引用類型。不一樣於軟引用和弱引用,虛引用沒法經過 get() 方法來取得目標對象的強引用從而使用目標對象,觀察源碼能夠發現 get() 被重寫爲永遠返回 null。
那虛引用到底有什麼做用?其實虛引用主要被用來 跟蹤對象被垃圾回收的狀態,經過查看引用隊列中是否包含對象所對應的虛引用來判斷它是否 即將被垃圾回收,從而採起行動。它並不被期待用來取得目標對象的引用,而目標對象被回收前,它的引用會被放入一個 ReferenceQueue 對象中,從而達到跟蹤對象垃圾回收的做用。
關於java引用類型的實現和原理能夠閱讀以前的文章Reference 、ReferenceQueue 詳解 和 Java 引用類型簡述。
![](http://www.importnew.com/?attachment_id=26335)
![](http://www.importnew.com/?attachment_id=26336)
所以咱們能夠得知當咱們經過JNI調用的native方法實際上就是從用戶態切換到了內核態的一種方式。而且經過該系統調用使用操做系統所提供的功能。
Q:爲何須要用戶進程(位於用戶態中)要經過系統調用(Java中即便JNI)來調用內核態中的資源,或者說調用操做系統的服務了?A:intel cpu提供Ring0-Ring3四種級別的運行模式,Ring0級別最高,Ring3最低。Linux使用了Ring3級別運行用戶態,Ring0做爲內核態。Ring3狀態不能訪問Ring0的地址空間,包括代碼和數據。所以用戶態是沒有權限去操做內核態的資源的,它只能經過系統調用外完成用戶態到內核態的切換,而後在完成相關操做後再有內核態切換回用戶態。
DirectByteBuffer是Java用於實現堆外內存的一個重要類,咱們能夠經過該類實現堆外內存的建立、使用和銷燬。
![](http://www.importnew.com/?attachment_id=26337)
DirectByteBuffer該類自己仍是位於Java內存模型的堆中。堆內內存是JVM能夠直接管控、操縱。
而DirectByteBuffer中的unsafe.allocateMemory(size);是個一個native方法,這個方法分配的是堆外內存,經過C的malloc來進行分配的。分配的內存是系統本地的內存,並不在Java的內存中,也不屬於JVM管控範圍,因此在DirectByteBuffer必定會存在某種方式來操縱堆外內存。
在DirectByteBuffer的父類Buffer中有個address屬性:
123 | // Used only by direct buffers // NOTE: hoisted here for speed in JNI GetDirectBufferAddress long address; |
---|
address只會被直接緩存給使用到。之因此將address屬性升級放在Buffer中,是爲了在JNI調用GetDirectBufferAddress時提高它調用的速率。address表示分配的堆外內存的地址。
![](http://www.importnew.com/?attachment_id=26339)
unsafe.allocateMemory(size);分配完堆外內存後就會返回分配的堆外內存基地址,並將這個地址賦值給了address屬性。這樣咱們後面經過JNI對這個堆外內存操做時都是經過這個address來實現的了。
在前面咱們說過,在linux中內核態的權限是最高的,那麼在內核態的場景下,操做系統是能夠訪問任何一個內存區域的,因此操做系統是能夠訪問到Java堆的這個內存區域的。
Q:那爲何操做系統不直接訪問Java堆內的內存區域了?A:這是由於JNI方法訪問的內存區域是一個已經肯定了的內存區域地質,那麼該內存地址指向的是Java堆內內存的話,那麼若是在操做系統正在訪問這個內存地址的時候,Java在這個時候進行了GC操做,而GC操做會涉及到數據的移動操做[GC常常會進行先標誌在壓縮的操做。即,將可回收的空間作標誌,而後清空標誌位置的內存,而後會進行一個壓縮,壓縮就會涉及到對象的移動,移動的目的是爲了騰出一塊更加完整、連續的內存空間,以容納更大的新對象],數據的移動會使JNI調用的數據錯亂。因此JNI調用的內存是不能進行GC操做的。
Q:如上面所說,JNI調用的內存是不能進行GC操做的,那該如何解決了?A:①堆內內存與堆外內存之間數據拷貝的方式(而且在將堆內內存拷貝到堆外內存的過程JVM會保證不會進行GC操做):好比咱們要完成一個從文件中讀數據到堆內內存的操做,即FileChannelImpl.read(HeapByteBuffer)。這裏實際上File I/O會將數據讀到堆外內存中,而後堆外內存再講數據拷貝到堆內內存,這樣咱們就讀到了文件中的內存。
![](http://www.importnew.com/?attachment_id=26340)
12345678910111213141516171819202122232425262728 | static int read(FileDescriptor var0, ByteBuffer var1, long var2, NativeDispatcher var4) throws IOException { if (var1.isReadOnly()) { throw new IllegalArgumentException( "Read-only buffer" ); } else if (var1 instanceof DirectBuffer) { return readIntoNativeBuffer(var0, var1, var2, var4); } else { // 分配臨時的堆外內存 ByteBuffer var5 = Util.getTemporaryDirectBuffer(var1.remaining()); int var7; try { // File I/O 操做會將數據讀入到堆外內存中 int var6 = readIntoNativeBuffer(var0, var5, var2, var4); var5.flip(); if (var6 > 0 ) { // 將堆外內存的數據拷貝到堆外內存中 var1.put(var5); } var7 = var6; } finally { // 裏面會調用DirectBuffer.cleaner().clean()來釋放臨時的堆外內存 Util.offerFirstTemporaryDirectBuffer(var5); } return var7; } } |
---|
而寫操做則反之,咱們會將堆內內存的數據線寫到對堆外內存中,而後操做系統會將堆外內存的數據寫入到文件中。
② 直接使用堆外內存,如DirectByteBuffer:這種方式是直接在堆外分配一個內存(即,native memory)來存儲數據,程序經過JNI直接將數據讀/寫到堆外內存中。由於數據直接寫入到了堆外內存中,因此這種方式就不會再在JVM管控的堆內再分配內存來存儲數據了,也就不存在堆內內存和堆外內存數據拷貝的操做了。這樣在進行I/O操做時,只須要將這個堆外內存地址傳給JNI的I/O的函數就行了。
123456789101112131415161718192021222324252627 | DirectByteBuffer( int cap) { // package-private super (- 1 , 0 , cap, cap); boolean pa = VM.isDirectMemoryPageAligned(); int ps = Bits.pageSize(); long size = Math.max(1L, ( long )cap + (pa ? ps : 0 )); // 保留總分配內存(按頁分配)的大小和實際內存的大小 Bits.reserveMemory(size, cap); long base = 0 ; try { // 經過unsafe.allocateMemory分配堆外內存,並返回堆外內存的基地址 base = unsafe.allocateMemory(size); } catch (OutOfMemoryError x) { Bits.unreserveMemory(size, cap); throw x; } unsafe.setMemory(base, size, ( byte ) 0 ); if (pa && (base % ps != 0 )) { // Round up to page boundary address = base + ps - (base & (ps - 1 )); } else { address = base; } // 構建Cleaner對象用於跟蹤DirectByteBuffer對象的垃圾回收,以實現當DirectByteBuffer被垃圾回收時,堆外內存也會被釋放 cleaner = Cleaner.create( this , new Deallocator(base, size, cap)); att = null ; } |
---|
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960 | static void reserveMemory( long size, int cap) { if (!memoryLimitSet && VM.isBooted()) { maxMemory = VM.maxDirectMemory(); memoryLimitSet = true ; } // optimist! if (tryReserveMemory(size, cap)) { return ; } final JavaLangRefAccess jlra = SharedSecrets.getJavaLangRefAccess(); // retry while helping enqueue pending Reference objects // which includes executing pending Cleaner(s) which includes // Cleaner(s) that free direct buffer memory while (jlra.tryHandlePendingReference()) { if (tryReserveMemory(size, cap)) { return ; } } // trigger VM's Reference processing System.gc(); // a retry loop with exponential back-off delays // (this gives VM some time to do it's job) boolean interrupted = false ; try { long sleepTime = 1 ; int sleeps = 0 ; while ( true ) { if (tryReserveMemory(size, cap)) { return ; } if (sleeps >= MAX_SLEEPS) { break ; } if (!jlra.tryHandlePendingReference()) { try { Thread.sleep(sleepTime); sleepTime <<=< code=""> |
---|
該方法用於在系統中保存總分配內存(按頁分配)的大小和實際內存的大小。
其中,若是系統中內存( 即,堆外內存 )不夠的話:
12345678910 | final JavaLangRefAccess jlra = SharedSecrets.getJavaLangRefAccess(); // retry while helping enqueue pending Reference objects // which includes executing pending Cleaner(s) which includes // Cleaner(s) that free direct buffer memory while (jlra.tryHandlePendingReference()) { if (tryReserveMemory(size, cap)) { return ; } } |
---|
jlra.tryHandlePendingReference()會觸發一次非堵塞的Reference#tryHandlePending(false)。該方法會將已經被JVM垃圾回收的DirectBuffer對象的堆外內存釋放。由於在Reference的靜態代碼塊中定義了:
123456 | SharedSecrets.setJavaLangRefAccess( new JavaLangRefAccess() { @Override public boolean tryHandlePendingReference() { return tryHandlePending( false ); } }); |
---|
若是在進行一次堆外內存資源回收後,還不夠進行本次堆外內存分配的話,則
12 | // trigger VM's Reference processing System.gc(); |
---|
System.gc()會觸發一個full gc,固然前提是你沒有顯示的設置-XX:+DisableExplicitGC來禁用顯式GC。而且你須要知道,調用System.gc()並不可以保證full gc立刻就能被執行。
因此在後面打代碼中,會進行最多9次嘗試,看是否有足夠的可用堆外內存來分配堆外內存。而且每次嘗試以前,都對延遲等待時間,已給JVM足夠的時間去完成full gc操做。若是9次嘗試後依舊沒有足夠的可用堆外內存來分配本次堆外內存,則拋出OutOfMemoryError(「Direct buffer memory」)異常。
![](http://www.importnew.com/?attachment_id=26341)
注意,這裏之因此用使用full gc的很重要的一個緣由是:System.gc()會對新生代的老生代都會進行內存回收,這樣會比較完全地回收DirectByteBuffer對象以及他們關聯的堆外內存.DirectByteBuffer對象自己實際上是很小的,可是它後面可能關聯了一個很是大的堆外內存,所以咱們一般稱之爲冰山對象.
咱們作ygc的時候會將新生代裏的不可達的DirectByteBuffer對象及其堆外內存回收了,可是沒法對old裏的DirectByteBuffer對象及其堆外內存進行回收,這也是咱們一般碰到的最大的問題。( 而且堆外內存多用於生命期中等或較長的對象 )若是有大量的DirectByteBuffer對象移到了old,可是又一直沒有作cms gc或者full gc,而只進行ygc,那麼咱們的物理內存可能被慢慢耗光,可是咱們還不知道發生了什麼,由於heap明明剩餘的內存還不少(前提是咱們禁用了System.gc – JVM參數DisableExplicitGC)。
總的來講,Bits.reserveMemory(size, cap)方法在可用堆外內存不足以分配給當前要建立的堆外內存大小時,會實現如下的步驟來嘗試完成本次堆外內存的建立:
① 觸發一次非堵塞的Reference#tryHandlePending(false)。該方法會將已經被JVM垃圾回收的DirectBuffer對象的堆外內存釋放。② 若是進行一次堆外內存資源回收後,還不夠進行本次堆外內存分配的話,則進行 System.gc()。System.gc()會觸發一個full gc,但你須要知道,調用System.gc()並不可以保證full gc立刻就能被執行。因此在後面打代碼中,會進行最多9次嘗試,看是否有足夠的可用堆外內存來分配堆外內存。而且每次嘗試以前,都對延遲等待時間,已給JVM足夠的時間去完成full gc操做。注意,若是你設置了-XX:+DisableExplicitGC,將會禁用顯示GC,這會使System.gc()調用無效。③ 若是9次嘗試後依舊沒有足夠的可用堆外內存來分配本次堆外內存,則拋出OutOfMemoryError(「Direct buffer memory」)異常。
那麼可用堆外內存究竟是多少了?,即默認堆外存內存有多大:① 若是咱們沒有經過-XX:MaxDirectMemorySize來指定最大的堆外內存。則② 若是咱們沒經過-Dsun.nio.MaxDirectMemorySize指定了這個屬性,且它不等於-1。則③ 那麼最大堆外內存的值來自於directMemory = Runtime.getRuntime().maxMemory(),這是一個native方法
1234567891011 | JNIEXPORT jlong JNICALL Java_java_lang_Runtime_maxMemory(JNIEnv *env, jobject this ) { return JVM_MaxMemory(); } JVM_ENTRY_NO_ENV(jlong, JVM_MaxMemory( void )) JVMWrapper( "JVM_MaxMemory" ); size_t n = Universe::heap()->max_capacity(); return convert_size_t_to_jlong(n); JVM_END |
---|
其中在咱們使用CMS GC的狀況下也就是咱們設置的-Xmx的值裏除去一個survivor的大小就是默認的堆外內存的大小了。
Cleaner是PhantomReference的子類,並經過自身的next和prev字段維護的一個雙向鏈表。PhantomReference的做用在於跟蹤垃圾回收過程,並不會對對象的垃圾回收過程形成任何的影響。因此cleaner = Cleaner.create(this, new Deallocator(base, size, cap)); 用於對當前構造的DirectByteBuffer對象的垃圾回收過程進行跟蹤。當DirectByteBuffer對象從pending狀態 ——> enqueue狀態時,會觸發Cleaner的clean(),而Cleaner的clean()的方法會實現經過unsafe對堆外內存的釋放。
![](http://www.importnew.com/?attachmentid=26342)![](http://www.importnew.com/?attachmentid=26343)
雖然Cleaner不會調用到Reference.clear(),但Cleaner的clean()方法調用了remove(this),即將當前Cleaner從Cleaner鏈表中移除,這樣當clean()執行完後,Cleaner就是一個無引用指向的對象了,也就是可被GC回收的對象。
thunk方法:
![](http://www.importnew.com/?attachment_id=26344)
同時咱們能夠經過-XX:MaxDirectMemorySize來指定最大的堆外內存大小,當使用達到了閾值的時候將調用System.gc()來作一次full gc,以此來回收掉沒有被使用的堆外內存。
由於full gc 意味着完全回收,完全回收時,垃圾收集器會對全部分配的堆內內存進行完整的掃描,這意味着一個重要的事實——這樣一次垃圾收集對Java應用形成的影響,跟堆的大小是成正比的。過大的堆會影響Java應用的性能。若是使用堆外內存的話,堆外內存是直接受操做系統管理( 而不是虛擬機 )。這樣作的結果就是能保持一個較小的堆內內存,以減小垃圾收集對應用的影響。
微信公衆號【Java技術江湖】一位阿里 Java 工程師的技術小站。(關注公衆號後回覆」Java「便可領取 Java基礎、進階、項目和架構師等免費學習資料,更有數據庫、分佈式、微服務等熱門技術學習視頻,內容豐富,兼顧原理和實踐,另外也將贈送做者原創的Java學習指南、Java程序員面試指南等乾貨資源)