從 JDK 8 到 JDK 18,Java 垃圾回收的十次進(jìn)化
譯者 | 彎月
經(jīng)歷了數(shù)千次改進(jìn),Java的垃圾回收在吞吐量、延遲和內(nèi)存大小方面有了巨大的進(jìn)步。
2014年3月JDK 8發(fā)布,自那以來JDK又連續(xù)發(fā)布了許多版本,直到今日的JDK 18是Java的第十個版本。借此機會,我們來回顧一下HotSpot JVM的垃圾回收器的發(fā)展全過程。


關(guān)于垃圾回收、度量和取舍
HotSpot JVM中負(fù)責(zé)管理應(yīng)用程序堆的組件叫做“垃圾回收器”(Garbage Collector,即GC)。GC負(fù)責(zé)管理應(yīng)用程序堆對象的整個生命周期,從應(yīng)用程序分配內(nèi)存到內(nèi)存被回收,都由GC負(fù)責(zé)。
從高層來看,JVM垃圾回收算法的最基本功能如下:
當(dāng)應(yīng)用程序請求分配內(nèi)存時,GC負(fù)責(zé)提供內(nèi)存。提供內(nèi)存的過程應(yīng)盡可能快。
GC檢測應(yīng)用程序不再使用的內(nèi)存。這個操作也應(yīng)當(dāng)十分高效,不應(yīng)消耗太多時間。這種不再使用的內(nèi)存稱為“垃圾”。
GC將同一塊內(nèi)存再次提供給應(yīng)用程序,最好是“實時”,也就是要快。
好的垃圾回收算法還有更多的需求,但這三條是最基本的,也足以支撐本文的討論了。
滿足這些需求有很多方法,但很不幸,我們并沒有一蹴而就的方法,也沒有能一次性解決所有需求的方法。因此,JDK提供了多種垃圾回收算法以供選擇,每種算法適用于不同的場景。這些算法的實現(xiàn)基本上可以根據(jù)吞吐量、延遲和內(nèi)存大小這三個性能度量,以及對應(yīng)用程序的影響進(jìn)行歸類。
吞吐量指的是單位時間內(nèi)能夠完成的工作量。在此語境下,垃圾回收算法的優(yōu)劣取決于能在單位時間內(nèi)完成的回收工作量,這些算法可以讓Java應(yīng)用程序?qū)崿F(xiàn)更高的吞吐量。
延遲指的是單次操作所需時間。垃圾回收算法需要盡可能減小延遲。在垃圾回收的語境下,關(guān)鍵點就是垃圾回收期是否會導(dǎo)致暫停、暫停的范圍,以及暫停的時長。
在垃圾回收的語境下,內(nèi)存大小指的是為了讓垃圾回收期正常工作,需要在正常的應(yīng)用程序堆內(nèi)存之外,再額外占用多少內(nèi)存。如果GC(或更一般地,JVM)需要的內(nèi)存很少,就可以給應(yīng)用程序堆留出更多內(nèi)存。
這三個度量是互相關(guān)聯(lián)的:高吞吐量的垃圾回收器可能會嚴(yán)重影響延遲(但對應(yīng)用程序的影響最小)。為了降低內(nèi)存消耗,我們需要采用在其他度量方面不是那么出色的算法。延遲較低的回收期需要并行進(jìn)行更多工作,或以更小的單位進(jìn)行工作,這就會消耗更多處理器資源。
這些關(guān)系通??梢援嫵梢粋€三角形,如圖1所示。每個垃圾回收算法占據(jù)三角形的一個角。

圖1. GC性能度量三角
提高GC在某方面的表現(xiàn),通常會導(dǎo)致其他方面的表現(xiàn)降低。

JDK 18中的OpenJDK GC
OpenJDK提供了五種GC,分別專注于不同的性能度量。表1列出了GC的名稱、專注領(lǐng)域,以及實現(xiàn)特定特性所使用的核心概念。
表1. OpenJDK的五種GC

Parallel GC是JDK 8以及更早版本的默認(rèn)回收期。它專注于吞吐量,盡快完成工作,而很少考慮延遲(暫停)。
Parallel GC會在STW(全局暫停)期間,以更緊湊的方式,將正在使用中的內(nèi)存移動(復(fù)制)到堆中的其他位置,從而制造出大片的空閑內(nèi)存區(qū)域。當(dāng)內(nèi)存分配請求無法滿足時就會發(fā)生STW暫停,然后JVM完全停止應(yīng)用程序運行,投入盡可能多的處理器線程,讓垃圾回收算法執(zhí)行內(nèi)存壓縮工作,然后分配請求的內(nèi)存,最后恢復(fù)應(yīng)用程序執(zhí)行。
Parallel GC也是一個分代回收器,旨在最大化垃圾回收效率。本文稍后會詳細(xì)討論分代式回收的思想。
G1 GC是JDK 9以后的默認(rèn)回收期。G1試圖平衡吞吐量和延遲。一方面,在STW暫停期間,依然會利用分代繼續(xù)執(zhí)行內(nèi)存回收工作,從而最大化效率,這一點和Parallel GC相同;但是,它還會盡可能避免在暫停期間執(zhí)行需要較長時間的操作。
G1的長時間操作會與應(yīng)用程序并行進(jìn)行,即通過多線程方式,在應(yīng)用程序運行時執(zhí)行。這樣可以大幅度減少暫停,代價是整體的吞吐量會降低一點。
ZGC和Shenandoah GC專注于用吞吐量換延遲。這兩種回收器會嘗試在不進(jìn)行明顯的暫停的前提下,完成所有垃圾回收工作。目前,這兩者都不是分代式的。它們的非實驗性版本分別于JDK 15和JDK 12引入。
Serial GC專注于內(nèi)存大小和啟動時間。這個GC像是更簡單、更慢的Parallel GC,它在STW暫停期間僅使用一個線程完成所有工作。堆也是按照分代組織的。但是Serial GC占用的內(nèi)存更小、啟動速度更快。由于它更簡單,所以更適合小型、短時間運行的應(yīng)用程序。
OpenJDK還提供了另一個名為Epsilon的GC。為什么沒有在表1中列出呢?因為Epsilon只執(zhí)行內(nèi)存分配,從不進(jìn)行內(nèi)存回收,因此不滿足GC的所有條件。但是,Epsilon適合一些非常特殊的應(yīng)用程序。

G1 GC簡介
G1 GC于JDK 6 update 14作為實驗特性引入,從JDK 7 update 4開始正式支持。從JDK 9開始,G1由于其多用性,成了HotSpot JVM的默認(rèn)垃圾回收器:它非常穩(wěn)定、成熟,維護也非?;钴S,而且一直在改進(jìn)。
那么,G1是如何在吞吐量和延遲之間進(jìn)行平衡的呢?
一項關(guān)鍵技術(shù)就是分代垃圾回收。該技術(shù)利用了一個特點:最近分配的對象很可能可以立即回收(即它們“死亡”得更快)。所以G1(以及其他分代式GC)將Java的堆分為兩個區(qū)域:一個叫做“青年代”,用于存放剛剛分配的對象;另一個叫做“老年代”,用于存放經(jīng)歷了幾次垃圾回收后依然存活的對象,從而減少回收時所需的操作。
通常,青年代要比老年代小得多。因此,回收青年代的開銷更小,再加上G1這種跟蹤式的垃圾回收器在回收青年代對象時通常只會處理活躍對象,這就意味著青年代的垃圾回收一般非???,而且能回收大量內(nèi)存。
在某個時間點,長時間存活的對象會被移動到老年代中。
因此,隨著老年代不斷增長,我們也需要對其進(jìn)行垃圾回收。由于老年代一般很大,而且通常包含相當(dāng)多的活躍對象,對其進(jìn)行回收需要花費很長時間。(例如,Parallel GC的完全回收過程通常需要消耗青年代回收數(shù)倍的時間。)
因此,G1將老年代垃圾回收過程分成了兩個階段。
G1首先跟蹤活躍對象,這一操作與Java應(yīng)用程序并行進(jìn)行。這樣,從老年代回收內(nèi)存的大量操作就不需要在垃圾回收暫停期間執(zhí)行了,從而減小延遲。不過,實際的內(nèi)存回收操作如果一次性完成的話,對于大型應(yīng)用程序的堆而言,依然需要大量時間。
因此,G1會增量式地從老年代回收內(nèi)存。在跟蹤了活躍對象之后,在接下來的幾次對青年代進(jìn)行回收的同時,G1會額外對老年代中的一小部分進(jìn)行壓縮,這樣長期即可達(dá)到對年長對象進(jìn)行回收的效果。
增量地對年長對象進(jìn)行回收,比一次性回收(如Parallel GC的做法)的效率略低,因為跟蹤對象關(guān)系圖總會不準(zhǔn)確,而且增量回收所需的數(shù)據(jù)結(jié)構(gòu)的管理也需要額外的時間和空間開銷,但這種方式可以有效減小暫停的時長。大致來看,增量式垃圾回收所需的時長基本上等于只回收青年代的算法在暫停中所花費的時長。
此外,你還可以通過MaxGCPauseMillis命令行選項設(shè)置兩種垃圾回收算法的暫停時長的目標(biāo)。G1會盡可能將暫停時長保持在目標(biāo)以下。默認(rèn)的時長為200毫秒,這個值也許不適合你的應(yīng)用程序,但它只是最大值的目標(biāo)。G1會盡可能將暫停時長控制在該值以下。因此,改善暫停時長的第一步,可以從減小 MaxGCPauseMillis 開始。

從JDK 8到JDK 18的進(jìn)步
介紹完了OpenJDK的GC,我們來進(jìn)一步看看在過去10次JDK發(fā)布中,GC在吞吐量、延遲和內(nèi)存大小三個性能度量方面的進(jìn)步。
G1的吞吐量增長。為了演示吞吐量和延遲方面的進(jìn)步,本文采用了SPECjbb2015基準(zhǔn)測試。SPECjbb2015是一個衡量Java服務(wù)器性能的常用業(yè)界測試,它包含了一系列各種各樣的操作。該測試包含兩個度量:
maxjOPS是系統(tǒng)能夠提供的最大事務(wù)數(shù)量。這是吞吐量的度量指標(biāo)。
criticaljOPS測量在幾個特定的服務(wù)級別協(xié)議(SLA)下的吞吐量,比如從10毫秒到100毫秒的響應(yīng)時間。
本文采用maxjOPS作為比較不同JDK版本的吞吐量的基準(zhǔn),采用實際暫停時長的改進(jìn)量作為比較延遲的基準(zhǔn)。雖然criticaljOPS也表明了暫停時長引起的延遲,但該指標(biāo)還包含其他來源的延遲。直接比較暫停時長可以避免這個問題。
圖2展示了G1在組合模式下在一個16GB的Java堆上的maxjOPS結(jié)果,圖中給出了JDK 8、JDK 11和JDK 18的對比??梢钥闯?,JDK版本越新,吞吐量得分就越高。JDK 11比JDK 8高出了約5%,而JDK 18高出了約18%。簡單來說,JDK版本越新,用于應(yīng)用程序?qū)嶋H工作的資源就越多。

圖2. G1d的吞吐量增長,利用SPECjbb2015的maxjOPS測量
下面,我們著重討論垃圾回收的改進(jìn)對于吞吐量增長的貢獻(xiàn)。但是,其他的一般性改進(jìn)(如代碼編譯)也對垃圾回收的性能——特別是吞吐量的增長——有很大的貢獻(xiàn),所以垃圾回收的改進(jìn)并不是唯一的貢獻(xiàn)者。
JDK 9之前的一個重大改進(jìn)是G1采用了懶惰式老年代回收,它會盡可能推遲回收操作。
在JDK 8中,用戶需要手動設(shè)置G1何時應(yīng)該對老年代回收中的活躍對象進(jìn)行并行跟蹤。如果時機設(shè)置得太早,JVM在回收操作開始之前,并沒有用完所有分配給老年代的堆內(nèi)存,如此老年代中的對象并沒有得到足夠多的時間從而變成可回收的狀態(tài)。因此,G1不僅需要更多的處理資源來分析其活躍狀態(tài)(因為許多數(shù)據(jù)依然處于活躍中),還要做許多額外的工作才能從老年代中釋放內(nèi)存。
另一個問題是,如果開始老年代回收的時機太晚,JVM就可能會耗盡內(nèi)存,從而導(dǎo)致內(nèi)存回收過程極其緩慢。從JDK 9開始,G1會自動決定開始老年代跟蹤的最佳時機,甚至還會自動適配應(yīng)用程序的行為。
JDK 9中實現(xiàn)的另一個思想涉及到G1對于老年代中的大型對象的回收頻率比其他對象高的現(xiàn)象。與分代的思想類似,這是另一個投入產(chǎn)出比很高的想法。畢竟,大型對象所占用的內(nèi)存空間很多。在某些應(yīng)用程序中(盡管不太常見),該方法甚至能大幅度減少垃圾回收的次數(shù),并降低整體的暫停時長,使G1的吞吐量大大超過Parallel GC。
一般來說,每次發(fā)布都會包含一些改進(jìn),減小垃圾回收在執(zhí)行同樣操作時的暫停時長。這樣就會自然地改善吞吐量。還有許多可以寫在本文中的改進(jìn),接下來我們在討論延遲改進(jìn)時會提到一些。
與Parallel GC類似,從JDK 14開始,G1在Java堆上分配內(nèi)存時,可以獨立地感知非統(tǒng)一性內(nèi)存訪問(NUMA)。從那時起,在擁有多內(nèi)存插槽且各個內(nèi)存的訪問時間不一致的機器上(也就是說內(nèi)存訪問與內(nèi)存插槽有關(guān),即某些內(nèi)存訪問更慢),G1會盡可能利用本地性。
有了NUMA感知后,G1 GC會假設(shè)在某個內(nèi)存節(jié)點上(由單個線程或線程組)分配的對象基本上被來自同一個節(jié)點的其他對象引用。因此,當(dāng)對象屬于青年代時,G1會將對象保持在同一節(jié)點上,甚至還會將老年代中的長時間生存的對象分布到不同節(jié)點上,以最小化訪問時間的不一致性。這與Parallel GC的實現(xiàn)類似。
還有一個我想討論的改進(jìn)是關(guān)于一些罕見情況的,比如完整回收。正常情況下,G1會調(diào)整內(nèi)部參數(shù),盡力避免完整回收,但是在一些極端情況下,G1會在暫停期間進(jìn)行完整回收。直到JDK 10之前,該算法都是單線程的,所以非常慢。而目前的實現(xiàn)與Parallel GC的完整回收過程不相上下。它依然很慢,依然應(yīng)當(dāng)盡力避免,但比以前已經(jīng)好多了。
Parallel GC的吞吐量增長。關(guān)于Parallel GC,圖3給出了從JDK 8到JDK 18中maxjOPS的改進(jìn)結(jié)果,堆的設(shè)置與之前的測試相同。同樣,即使是Parallel GC,僅僅替換JVM也可以獲得大約2%的吞吐量提升,最好情況下甚至能提升10%。提升比G1小,因為Parallel GC原本的起點就很高,因此增長較小。

圖3. Parallel GC的吞吐量增長,用SPECjbb2015的maxjOPS度量
G1的延遲改進(jìn)。為了演示HotSpot JVM GC在延遲方面的改進(jìn),本節(jié)采用了SPECjbb2015基準(zhǔn)測試,負(fù)載固定,然后測量其暫停時長。Java堆設(shè)置為16GB。表2總結(jié)了暫停時長的平均值和第99百分位值(P99),以及在200毫秒的默認(rèn)暫停時長目標(biāo)值下,不同JDK的相對暫??倳r長。
表2 默認(rèn)的200毫秒暫停時長下的延遲改進(jìn)

JDK 8的暫停平均時長為124毫秒,P99為176毫秒。JDK 11將平均時長提高到了111毫秒,P99提高到了134毫秒,總體減少了15.8%的暫停時長。JDK 18再次顯著改善,平均時長減少到了89毫秒,P99減小到了104毫秒,總時長減小了34.4%。
我擴展了試驗范圍,增加了JDK 18下暫停時長設(shè)置為50毫秒,因為之前隨意設(shè)置的-XX:MaxGCPauseMillis為200毫秒還是太長了。平均來看,G1達(dá)到了暫停時長的目標(biāo),P99垃圾回收暫停時長為56毫秒(見表3)。總體上,與JDK 8相比,暫?;ㄙM的總時間并沒有增加太多(0.06%)。
換句話說,將JDK 8 JVM替換成JDK 18 JVM,就能獲顯著降低平均暫停時長,同時還有可能在同樣的暫停時長目標(biāo)下提升吞吐量;或者將G1的暫停時長保持在更低的水平(50毫秒),而暫停總時長保持不變,同時保持相同的吞吐量。
表3. 將暫停時長目標(biāo)設(shè)置為50毫秒后的延遲改進(jìn)

表3的結(jié)果是自從JDK 8以來大量改進(jìn)的結(jié)果。下面是最值得一提的改進(jìn)。
降低延遲的許多改進(jìn)都用在了減小收集老年代對象所需的元數(shù)據(jù)上?!坝涀〉募稀保╮emembered sets)的數(shù)據(jù)結(jié)構(gòu)得到了大幅度刪減,部分原因是數(shù)據(jù)結(jié)構(gòu)的精簡,另一部分是不存儲永遠(yuǎn)不會用到的數(shù)據(jù)。在今天的計算機體系架構(gòu)中,減小元數(shù)據(jù)意味著更小的內(nèi)存訪問開銷,能夠帶來性能的提升。
有關(guān)“記住的集合”的另一個方面是,人們改進(jìn)了查找指向堆中當(dāng)前被移動的區(qū)域的引用的算法,使其更容易并行化。G1不再并行遍歷整個數(shù)據(jù)結(jié)構(gòu)并在內(nèi)層循環(huán)中過濾掉重復(fù)數(shù)據(jù),而是分別并行地過濾掉重復(fù)數(shù)據(jù),再并行地處理剩余數(shù)據(jù)。這樣可以讓兩個步驟都更有效、更容易并行化。
進(jìn)一步,處理記住的集合的過程也被仔細(xì)分析,刪減了不必要的代碼,優(yōu)化了常用路徑。
JDK 8之后的另一個焦點是,通過一個暫停來改進(jìn)任務(wù)的并行化。人們嘗試將任務(wù)的多個階段并行化,或?qū)⑤^小的順序階段變成更大的并行階段,以此避免不必要的同步,從而改進(jìn)并行化。人們在這方面投入了大量資源來改進(jìn)并行階段的負(fù)載平衡性,這樣如果某個線程沒有任務(wù)時,它會嘗試從其他線程那里獲取任務(wù)。
此外,后續(xù)的JDK開始著手更罕見的情況,其中之一就是內(nèi)存移動失?。╡vacuation failure)。如果會在垃圾回收時,沒有足夠的空間復(fù)制對象時,就會發(fā)生內(nèi)存移動失敗。
ZGC的垃圾回收暫停。如果你的應(yīng)用程序需要更短的垃圾回收暫停時長,可以參考表4,該表比較了G1與另一個專注于暫停時長的垃圾回收期ZGC。該表采用的負(fù)載與前面相同。最右邊一列給出了ZGC的暫停時長。
表4. ZGC與G1的延遲比較

ZGC實現(xiàn)了亞毫秒級別的暫停時長目標(biāo),它的全部內(nèi)存回收工作都與應(yīng)用程序并行執(zhí)行。只有部分不重要的工作依然需要暫停??梢韵胂?,這些暫停非常短暫,在上述情況下,暫停時長甚至遠(yuǎn)遠(yuǎn)低于ZGC聲稱的毫秒級別。
G1的內(nèi)存占用改進(jìn)。本文的最后一項指標(biāo)就是G1垃圾回收算法的內(nèi)存占用方面的改進(jìn)。此處,算法的內(nèi)存大小指的是垃圾回收算法為了正常工作,在正常的Java堆之外所需的額外內(nèi)存大小。
對于G1來說,除了依賴于Java堆大小的靜態(tài)數(shù)據(jù)(大小大約為Java堆尺寸的3.2%),另一個主要的內(nèi)存消耗來源是“記住的集合”,它負(fù)責(zé)分代垃圾收集,以及老年代的增量垃圾收集處理。
會給G1的記住的集合帶來壓力的應(yīng)用之一是對象緩存。每當(dāng)對象緩存增加或刪除新的緩存項時,都會在堆上的老年代中,不斷生成區(qū)域之間的引用。
圖4展示了從JDK 8到JDK 18中,G1的原生內(nèi)存占用情況,測試應(yīng)用程序?qū)崿F(xiàn)了一個對象緩存:對象表示緩存信息,對象可以被查詢、添加,并以最近最少使用(LRU)的方式從一個更大的堆中刪除。本例中的Java堆為20GB,使用了JVM的原生內(nèi)存跟蹤(NMT)機制來確定內(nèi)存使用情況。

圖4. G1 GC的原生內(nèi)存大小
在JDK 8中,經(jīng)過了短暫的預(yù)熱階段后,G1原生內(nèi)存使用穩(wěn)定在5.8GB左右。JDK 11在此基礎(chǔ)上,將原生內(nèi)存代銷降低到了4GB左右;JDK 17進(jìn)一步改進(jìn)到1.8GB,而JDK 18穩(wěn)定在1.25GB。額外內(nèi)存使用量從JDK時代的30%堆大小降低到了JDK 18時代的6%左右。
如前所示,這些改進(jìn)并沒有造成吞吐量下降或延遲提升。實際上,G1 GC減小元數(shù)據(jù),也給其他度量帶來了提升。
從JDK 8到JDK 18,這些改進(jìn)的主要原則是,將垃圾回收元數(shù)據(jù)嚴(yán)格維持在僅保存必須數(shù)據(jù)的限度。因此,G1會并行地重建并管理內(nèi)存,盡快釋放數(shù)據(jù)。JDK 18對元數(shù)據(jù)的表現(xiàn)方式和存儲也進(jìn)行了改進(jìn),存儲得更緊密,因此有效降低了內(nèi)存大小。
圖4還表明,在新版的JDK中,G1更為積極,會主動查找穩(wěn)態(tài)操作的高峰和低谷中的差異,更積極地將內(nèi)存交還給操作系統(tǒng)。在最新的版本中,G1甚至?xí)⑿袌?zhí)行該操作。

垃圾回收的未來
盡管很難預(yù)測未來會怎樣、以后會有多少垃圾回收方面的項目,但G1很可能會在HotSpot JVM中實現(xiàn)下面這些改進(jìn)。
人們在努力解決的問題之一是,在原生代碼使用Java對象時,會阻止垃圾回收的進(jìn)行。如果有任何區(qū)域引用了原生代碼中使用的Java對象,觸發(fā)垃圾回收的Java線程就必須等待。最糟糕的情況下,原生代碼甚至?xí)柚估厥臻L達(dá)數(shù)分鐘。這會導(dǎo)致開發(fā)人員完全避免使用原生代碼,從而大幅度影響吞吐量。JEP 423給出了解決方案,因此G1 GC很快就能解決該問題。
與Parallel GC相比,G1 GC的另一個已知問題是,它會影響吞吐率。根據(jù)用戶報告,在極端情況下,影響甚至?xí)_(dá)到10%~20%。問題的原因是已知的,人們已經(jīng)提出了幾種在不影響G1 GC其他方面的品質(zhì)的前提下的解決方案。
最近人們還發(fā)現(xiàn),暫停時長和暫停期間的負(fù)載分散的效率依然不是最優(yōu)的。
最近人們的焦點是將G1的最大的輔助數(shù)據(jù)結(jié)構(gòu)標(biāo)記位圖削減一半。G1算法使用了兩個位圖,用于確定哪些對象活躍,可以安全地并行檢查。一項仍在討論的建議表明,這兩個位圖之一可以通過其他方式取代。這就能將G1的元數(shù)據(jù)削減至一半大小,至Java堆大小的1.5%。
ZGC和Shenandoah GC也有很多在積極開發(fā)的項目,著眼于將這兩個垃圾回收器改造成分代式垃圾回收器。在許多應(yīng)用中,這兩個GC的單分代設(shè)計在吞吐量和即時性方面有太多的缺陷,因此需要更大的堆大小來補償。

總結(jié)
本文展示了HotSpot JVM垃圾回收算法從JDK 8到JDK 18的改進(jìn)。這些改進(jìn)非常顯著,所有三個性能指標(biāo),包括吞吐量、延遲和內(nèi)存大小,都得到了顯著提升。每次JDK發(fā)布新版本,都會帶來可見的提升。在可見的未來,這種趨勢仍將繼續(xù),所以請期待這些改進(jìn)吧。
感謝OpenJDK的各位貢獻(xiàn)者們付出的努力。
原文地址:https://blogs.oracle.com/javamagazine/post/java-garbage-collectors-evolution
完
我的新書《深入理解Java核心技術(shù)》已經(jīng)上市了,上市后一直蟬聯(lián)京東暢銷榜中,目前正在6折優(yōu)惠中,想要入手的朋友千萬不要錯過哦~長按二維碼即可購買~
長按掃碼享受6折優(yōu)惠
往期推薦

分布式事務(wù)處理方案大 PK!

面試官:生成訂單30分鐘未支付,則自動取消,該怎么實現(xiàn)?

MySQL 啥時候用表鎖,啥時候用行鎖?
有道無術(shù),術(shù)可成;有術(shù)無道,止于術(shù)
歡迎大家關(guān)注Java之道公眾號
好文章,我在看??
