在分析線上 JVM 性能問題的時候,咱們可能會碰到下面這些場景:java
1.GC 自己沒有花多長時間,可是 JVM 暫停了好久,例以下面:git
github
緩存
2.JVM 沒有 GC,可是程序暫停了好久,並且這種狀況時不時就出現。性能優化
這些問題通常和 SafePoint 還有 Stop the World 有關。多線程
什麼是 SafePoint?什麼是 Stop the world?他們之間有何關係?
咱們先來設想下以下場景:併發
-
當須要 GC 時,須要知道哪些對象還被使用,或者已經不被使用能夠回收了,這樣就須要每一個線程的對象使用狀況。app
-
對於偏向鎖(Biased Lock),在高併發時想要解除偏置,須要線程狀態還有獲取鎖的線程的精確信息。微服務
-
對方法進行即時編譯優化(OSR棧上替換),或者反優化(bailout棧上反優化),這須要線程究竟運行到方法的哪裏的信息。高併發
對於這些操做,都須要線程的各類信息,例如寄存器中到底有啥,堆使用信息以及棧方法代碼信息等等等等,而且作這些操做的時候,線程須要暫停,等到這些操做完成,不然會有併發問題。這就須要 SafePoint。
Safepoint 能夠理解成是在代碼執行過程當中的一些特殊位置,當線程執行到這些位置的時候,線程能夠暫停。在 SafePoint 保存了其餘位置沒有的一些當前線程的運行信息,供其餘線程讀取。這些信息包括:線程上下文的任何信息,例如對象或者非對象的內部指針等等。咱們通常這麼理解 SafePoint,就是線程只有運行到了 SafePoint 的位置,他的一切狀態信息,纔是肯定的,也只有這個時候,才知道這個線程用了哪些內存,沒有用哪些;而且,只有線程處於 SafePoint 位置,這時候對 JVM 的堆棧信息進行修改,例如回收某一部分不用的內存,線程纔會感知到,以後繼續運行,每一個線程都有一份本身的內存使用快照,這時候其餘線程對於內存使用的修改,線程就不知道了,只有再進行到 SafePoint 的時候,纔會感知。
因此,GC 必定須要全部線程同時進入 SafePoint,並停留在那裏,等待 GC 處理完內存,再讓全部線程繼續執。像這種**全部線程進入 SafePoint **等待的狀況,就是 Stop the world(此時,忽然想起承太郎的:食堂潑辣醬,the world!!!)。
爲何須要 SafePoint 以及 Stop The World?
在 SafePoint 位置保存了線程上下文中的任何東西,包括對象,指向對象或非對象的內部指針,在線程處於 SafePoint 的時候,對這些信息進行修改,線程才能感知到。因此,只有線程處於 SafePoint 的時候,才能針對線程使用的內存進行 GC,以及改變正在執行的代碼,例如 OSR (On Stack Replacement,棧上替換現有代碼爲JIT優化過的代碼)或者 Bailout(棧上替換JIT過優化代碼爲去優化的代碼)。而且,還有一個重要的 Java 線程特性也是基於 SafePoint 實現的,那就是 Thread.interrupt()
,線程只有運行到 SafePoint 才知道是否 interrupted。
爲啥須要 Stop The World,有時候咱們須要全局全部線程進入 SafePoint 這樣才能統計出那些內存還能夠回收用於 GC,,以及回收再也不使用的代碼清理 CodeCache,以及執行某些 Java instrument 命令或者 JDK 工具,例如 jstack 打印堆棧就須要 Stop the world 獲取當前全部線程快照。
SafePoint 如何實現的?
能夠這麼理解,SafePoint 能夠插入到代碼的某些位置,每一個線程運行到 SafePoint 代碼時,主動去檢查是否須要進入 SafePoint,這個主動檢查的過程,被稱爲 Polling
理論上,能夠在每條 Java 編譯後的字節碼的邊界,都放一個檢查 Safepoint 的機器命令。線程執行到這裏的時候,會執行 Polling 詢問 JVM 是否須要進入 SafePoint,這個詢問是會有性能損耗的,因此 JIT 會優化儘可能減小 SafePoint。
通過 JIT 編譯優化的代碼,會在全部方法的返回以前,以及全部非counted loop的循環(無界循環)回跳以前放置一個 SafePoint,爲了防止發生 GC 須要 Stop the world 時,該線程一直不能暫停,可是對於明確有界循環,爲了減小 SafePoint,是不會在回跳以前放置一個 SafePoint,也就是:
for (int i = 0; i < 100000000; i++) { ... }
裏面是不會放置 SafePoint 的,這也致使了後面會提到的一些性能優化的問題。注意,僅針對 int 有界循環,例如裏面的 int i 換成 long i 就仍是會有 SafePoint;
SafePoint 實現相關源代碼: safepoint.cpp
能夠看出,針對 SafePoint,線程有 5 種狀況;假設如今有一個操做觸發了某個 VM 線程全部線程須要進入 SafePoint(例如如今須要 GC),若是其餘線程如今:
-
運行字節碼:運行字節碼時,解釋器會看線程是否被標記爲 poll armed,若是是,VM 線程調用
SafepointSynchronize::block(JavaThread *thread)
進行 block。 -
運行 native 代碼:當運行 native 代碼時,VM 線程略過這個線程,可是給這個線程設置 poll armed,讓它在執行完 native 代碼以後,它會檢查是否 poll armed,若是還須要停在 SafePoint,則直接 block。
-
運行 JIT 編譯好的代碼:因爲運行的是編譯好的機器碼,直接查看本地 local polling page 是否爲髒,若是爲髒則須要 block。這個特性是在 Java 10 引入的 JEP 312: Thread-Local Handshakes 以後,纔是只用檢查本地 local polling page 是否爲髒就能夠了。
-
處於 BLOCK 狀態:在須要全部線程須要進入 SafePoint 的操做完成以前,不準離開 BLOCK 狀態
-
處於線程切換狀態或者處於 VM 運行狀態:會一直輪詢線程狀態直到線程處於阻塞狀態(線程確定會變成上面說的那四種狀態,變成哪一個都會 block 住)。
哪些狀況下會讓全部線程進入 SafePoint, 即發生 Stop the world?
-
定時進入 SafePoint:每通過
-XX:GuaranteedSafepointInterval
配置的時間,都會讓全部線程進入 Safepoint,一旦全部線程都進入,馬上從 Safepoint 恢復。這個定時主要是爲了一些不必馬上 Stop the world 的任務執行,能夠設置-XX:GuaranteedSafepointInterval=0
關閉這個定時,我推薦是關閉。 -
因爲 jstack,jmap 和 jstat 等命令,也就是 Signal Dispatcher 線程要處理的大部分命令,都會致使 Stop the world:這種命令都須要採集堆棧信息,因此須要全部線程進入 Safepoint 並暫停。
-
偏向鎖取消(這個不必定會引起總體的 Stop the world,參考JEP 312: Thread-Local Handshakes):Java 認爲,鎖大部分狀況是沒有競爭的(某個同步塊大多數狀況都不會出現多線程同時競爭鎖),因此能夠經過偏向來提升性能。即在無競爭時,以前得到鎖的線程再次得到鎖時,會判斷是否偏向鎖指向我,那麼該線程將不用再次得到鎖,直接就能夠進入同步塊。可是高併發的狀況下,偏向鎖會常常失效,致使須要取消偏向鎖,取消偏向鎖的時候,須要 Stop the world,由於要獲取每一個線程使用鎖的狀態以及運行狀態。
-
Java Instrument 致使的 Agent 加載以及類的重定義:因爲涉及到類重定義,須要修改棧上和這個類相關的信息,因此須要 Stop the world
-
Java Code Cache相關:當發生 JIT 編譯優化或者去優化,須要 OSR 或者 Bailout 或者清理代碼緩存的時候,因爲須要讀取線程執行的方法以及改變線程執行的方法,因此須要 Stop the world
-
GC:這個因爲須要每一個線程的對象使用信息,以及回收一些對象,釋放某些堆內存或者直接內存,因此須要 Stop the world
-
JFR 的一些事件:若是開啓了 JFR 的 OldObject 採集,這個是定時採集一些存活時間比較久的對象,因此須要 Stop the world。同時,JFR 在 dump 的時候,因爲每一個線程都有一個 JFR 事件的 buffer,須要將 buffer 中的事件採集出來,因此須要 Stop the world。
其餘的事件,不常常遇到,能夠參考源碼 vmOperations.hpp
#define VM_OPS_DO(template) \ template(None) \ template(Cleanup) \ template(ThreadDump) \ template(PrintThreads) \ template(FindDeadlocks) \ template(ClearICs) \ template(ForceSafepoint) \ template(ForceAsyncSafepoint) \ template(DeoptimizeFrame) \ template(DeoptimizeAll) \ template(ZombieAll) \ template(Verify) \ template(PrintJNI) \ template(HeapDumper) \ template(DeoptimizeTheWorld) \ template(CollectForMetadataAllocation) \ template(GC_HeapInspection) \ template(GenCollectFull) \ template(GenCollectFullConcurrent) \ template(GenCollectForAllocation) \ template(ParallelGCFailedAllocation) \ template(ParallelGCSystemGC) \ template(G1CollectForAllocation) \ template(G1CollectFull) \ template(G1Concurrent) \ template(G1TryInitiateConcMark) \ template(ZMarkStart) \ template(ZMarkEnd) \ template(ZRelocateStart) \ template(ZVerify) \ template(HandshakeOneThread) \ template(HandshakeAllThreads) \ template(HandshakeFallback) \ template(EnableBiasedLocking) \ template(BulkRevokeBias) \ template(PopulateDumpSharedSpace) \ template(JNIFunctionTableCopier) \ template(RedefineClasses) \ template(UpdateForPopTopFrame) \ template(SetFramePop) \ template(GetObjectMonitorUsage) \ template(GetAllStackTraces) \ template(GetThreadListStackTraces) \ template(GetFrameCount) \ template(GetFrameLocation) \ template(ChangeBreakpoints) \ template(GetOrSetLocal) \ template(GetCurrentLocation) \ template(ChangeSingleStep) \ template(HeapWalkOperation) \ template(HeapIterateOperation) \ template(ReportJavaOutOfMemory) \ template(JFRCheckpoint) \ template(ShenandoahFullGC) \ template(ShenandoahInitMark) \ template(ShenandoahFinalMarkStartEvac) \ template(ShenandoahInitUpdateRefs) \ template(ShenandoahFinalUpdateRefs) \ template(ShenandoahDegeneratedGC) \ template(Exit) \ template(LinuxDllLoad) \ template(RotateGCLog) \ template(WhiteBoxOperation) \ template(JVMCIResizeCounters) \ template(ClassLoaderStatsOperation) \ template(ClassLoaderHierarchyOperation) \ template(DumpHashtable) \ template(DumpTouchedMethods) \ template(PrintCompileQueue) \ template(PrintClassHierarchy) \ template(ThreadSuspend) \ template(ThreadsSuspendJVMTI) \ template(ICBufferFull) \ template(ScavengeMonitors) \ template(PrintMetadata) \ template(GTestExecuteAtSafepoint) \ template(JFROldObject) \
什麼狀況會致使 Stop the world 時間過長?
Stop the world 階段能夠簡單分爲(這段時間內,JVM 都是出於全部線程進入 Safepoint 就 block 的狀態):
-
某個操做,須要 Stop the world(就是上面提到的哪些狀況下會讓全部線程進入 SafePoint, 即發生 Stop the world 的那些操做)
-
向 Signal Dispatcher 這個 JVM 守護線程發起 Safepoint 同步信號並交給對應的模塊執行。
-
對應的模塊,採集全部線程信息,並對每一個線程根據狀態作不一樣的操做以及標記(根據以前源代碼那一塊的描述,有5種狀況)
-
全部線程都進入 Safepoint 並 block。
-
作須要發起 Stop the world 的操做。
-
操做完成,全部線程從 Safepoint 恢復。
基於這些階段,致使 Stop the world 時間過長的緣由有:
-
階段 4 耗時過長,即等待全部線程中的某些線程進入 Safepoint 的時間過長,這個極可能和有 大有界循環與JIT優化 有關,也極可能是 OpenJDK 11 引入的獲取調用堆棧的類StackWalker的使用致使的,也多是系統 CPU 資源問題或者是系統內存髒頁過多或者發生 swap 致使的。
-
階段 5 耗時過長,須要看看是哪些操做致使的,例如偏向鎖撤銷過多, GC時間過長等等,須要想辦法減小這些操做消耗的時間,或者直接關閉這些事件(例如關閉偏向鎖,關閉 JFR 的 OldObjectSample 事件採集)減小進入,這個和本篇內容無關,這裏不贅述。
-
階段2,階段3耗時過長,因爲 Signal Dispatcher 是單線程的,能夠看看當時 Signal Dispatcher 這個線程在幹什麼,多是 Signal Dispatcher 作其餘操做致使的。也多是系統 CPU 資源問題或者是系統內存髒頁過多或者發生 swap 致使的。
大有界循環與 JIT 優化會給 SafePoint 帶來哪些問題?
已知:只有線程執行到 Safepoint 代碼纔會知道Thread.intterupted()的最新狀態 ,而不是線程的本地緩存。
咱們來看下面一段代碼:
static int algorithm(int n) { int bestSoFar = 0; for (int i=0; i<n; ++i) { if (Thread.interrupted()) { System.out.println("broken by interrupted"); break; } //增長pow計算,增長計算量,防止循環執行不超過1s就結束了 bestSoFar = (int) Math.pow(i, 0.3); } return bestSoFar; } public static void main(String[] args) throws InterruptedException { Runnable task = () -> { Instant start = Instant.now(); int bestSoFar = algorithm(1000000000); double durationInMillis = Duration.between(start, Instant.now()).toMillis(); System.out.println("after "+durationInMillis+" ms, the result is "+bestSoFar); }; //延遲1ms以後interrupt Thread t = new Thread(task); t.start(); Thread.sleep(1); t.interrupt(); //延遲10ms以後interrupt t = new Thread(task); t.start(); Thread.sleep(10); t.interrupt(); //延遲100ms以後interrupt t = new Thread(task); t.start(); Thread.sleep(100); t.interrupt(); //延遲1s以後interrupt //這時候 algorithm 裏面的for循環調用次數應該足夠了,會發生代碼即時編譯優化並 OSR t = new Thread(task); t.start(); Thread.sleep(1000); //發現線程此次不會對 interrupt 有反應了 t.interrupt(); }
以後利用 JVM 參數 -Xlog:jit+compilation=debug:file=jit_compile%t.log:uptime,level,tags:filecount=10,filesize=100M
打印 JIT 編譯日誌到另外一個文件,便於觀察。最後控制檯輸出:
broken by interrupted broken by interrupted after 10.0 ms, the result is 27 after 1.0 ms, the result is 10 broken by interrupted after 99.0 ms, the result is 69 after 29114.0 ms, the result is 501
能夠看出,最後一次循環直接運行結束了,並無看到線程已經 interrupted 了。而且 JIT 編譯日誌能夠看到,在最後一線程執行循環的時候發生了發生代碼即時編譯優化並 OSR:
[0.782s][debug][jit,compilation] 460 % 3 com.test.TypeTest::algorithm @ 4 (44 bytes) [0.784s][debug][jit,compilation] 468 3 com.test.TypeTest::algorithm (44 bytes) [0.794s][debug][jit,compilation] 486 % 4 com.test.TypeTest::algorithm @ 4 (44 bytes) [0.797s][debug][jit,compilation] 460 % 3 com.test.TypeTest::algorithm @ 4 (44 bytes) made not entrant [0.799s][debug][jit,compilation] 503 4 com.test.TypeTest::algorithm (44 bytes)
3 還有 4 表示編譯級別,% 表示是 OSR 棧上替換方法,也就是 for 循環還在執行的時候,進行了執行代碼的機器碼替換。在這以後,線程就看不到線程已經 interrupted 了,這說明,** JIT 優化後的代碼,for 循環裏面的 Safepoint 會被拿掉**。
這樣帶來的問題,也顯而易見了,當須要 Stop the world 的時候,全部線程都會等着這個循環執行完,由於這個線程只有執行完這個大循環,才能進入 Safepoint。
那麼,如何優化呢?
第一種方式是修改代碼,將 for int 的循環變成 for long 類型:
for (long i=0; i<n; ++i) { if (Thread.interrupted()) { System.out.println("broken by interrupted"); break; } //增長pow計算,增長計算量,防止循環執行不超過1s就結束了 bestSoFar = (int) Math.pow(i, 0.3); }
第二種是經過-XX:+UseCountedLoopSafepoints
參數,讓 JIT 優化代碼的時候,不會拿掉有界循環裏面的 SafePoint
用這兩種方式其中一種以後的控制檯輸出:
broken by interrupted broken by interrupted after 0.0 ms, the result is 0 after 10.0 ms, the result is 29 broken by interrupted after 100.0 ms, the result is 73 broken by interrupted after 998.0 ms, the result is 170
如何經過日誌分析 SafePoint?
目前,在 OpenJDK 11 版本,主要有兩種 SafePoint 相關的日誌。一種基本上只在開發時使用,另外一種能夠在線上使用持續採集。
第一個是-XX:+PrintSafepointStatistics -XX:PrintSafepointStatisticsCount=1
,這個會定時採集,可是採集的時候會觸發全部線程進入 Safepoint,因此,線程通常不打開(以前咱們對於定時讓全部線程進入 Safepoint 都要關閉,這個就更不可能打開了)。而且,在 Java 12 中已經被移除,而且接下來的日誌配置基本上能夠替代這個,因此這裏咱們就不贅述這個了。
另外是經過-Xlog:safepoint=trace:stdout:utctime,level,tags
,對於 OpenJDK 的日誌配置,能夠參考個人另外一篇文章詳細解析配置的格式,這裏咱們直接用。
咱們這裏配置了全部的 safepoint 相關的 JVM 日誌都輸出到控制檯,一次 Stop the world 的時候,就會像下面這樣輸出:
[2020-07-14T07:08:26.197+0000][debug][safepoint] Safepoint synchronization initiated. (112 threads) [2020-07-14T07:08:26.197+0000][info ][safepoint] Application time: 12.4565068 seconds [2020-07-14T07:08:26.197+0000][trace][safepoint] Setting thread local yield flag for threads [2020-07-14T07:08:26.197+0000][trace][safepoint] Thread: 0x0000022c7c494b30 [0x61dc] State: _at_safepoint _has_called_back 0 _at_poll_safepoint 0 [2020-07-14T07:08:26.197+0000][trace][safepoint] Thread: 0x0000022c7c497f30 [0x4ff8] State: _at_safepoint _has_called_back 0 _at_poll_safepoint 0 ......省略一些處於 _at_poll_safepoint 的線程 [2020-07-14T07:08:26.197+0000][trace][safepoint] Thread: 0x0000022c10c010b0 [0x5878] State: _call_back _has_called_back 0 _at_poll_safepoint 0 [2020-07-14T07:08:26.348+0000][trace][safepoint] Thread: 0x0000022c10bfe560 [0x5038] State: _at_safepoint _has_called_back 0 _at_poll_safepoint 0 [2020-07-14T07:08:26.197+0000][debug][safepoint] Waiting for 1 thread(s) to block [2020-07-14T07:08:29.348+0000][info ][safepoint] Entering safepoint region: G1CollectForAllocation [2020-07-14T07:08:29.350+0000][info ][safepoint] Leaving safepoint region [2020-07-14T07:08:29.350+0000][info ][safepoint] Total time for which application threads were stopped: 3.1499371 seconds, Stopping threads took: 3.1467255 seconds
首先,階段 1 會打印日誌,這個是 debug 級別的,表明要開始全局全部線程 Safepoint 了,這時候,JVM 就開始沒法響應請求了,也就是 Stop the world 開始:
[2020-07-14T07:08:29.347+0000][debug][safepoint] Safepoint synchronization initiated. (112 threads)
階段 2 不會打印日誌,階段 3 會打印:
[2020-07-14T07:08:26.197+0000][info ][safepoint] Application time: 12.4565068 seconds [2020-07-14T07:08:26.197+0000][trace][safepoint] Setting thread local yield flag for threads [2020-07-14T07:08:26.197+0000][trace][safepoint] Thread: 0x0000022c7c494b30 [0x61dc] State: _at_safepoint _has_called_back 0 _at_poll_safepoint 0 [2020-07-14T07:08:26.197+0000][trace][safepoint] Thread: 0x0000022c7c497f30 [0x4ff8] State: _at_safepoint _has_called_back 0 _at_poll_safepoint 0 ......省略一些處於 _at_poll_safepoint 的線程 [2020-07-14T07:08:26.197+0000][trace][safepoint] Thread: 0x0000022c10c010b0 [0x5878] State: _call_back _has_called_back 0 _at_poll_safepoint 0 [2020-07-14T07:08:26.348+0000][trace][safepoint] Thread: 0x0000022c10bfe560 [0x5038] State: _at_safepoint _has_called_back 0 _at_poll_safepoint 0 [2020-07-14T07:08:26.197+0000][debug][safepoint] Waiting for 1 thread(s) to block
Application time: 12.4565068 seconds 表明上次全局 Safepoint 與此次 Safepoint 間隔了多長時間。後面 trace 的日誌表示每一個線程的狀態,其中沒有處於 Safepoint 的只有一個:
Thread: 0x0000022c10c010b0 [0x5878] State: _call_back _has_called_back 0 _at_poll_safepoint 0
這裏有詳細的線程號,能夠經過 jstack 知道這個線程是幹啥的。
最後的Waiting for 1 thread(s) to block也表明到底須要等待幾個線程走到 Safepoint。
階段 4 執行完,開始階段 5 的時候,會打印:
[2020-07-14T07:08:29.348+0000][info ][safepoint] Entering safepoint region: G1CollectForAllocation
階段 5 執行完以後,會打印:
[2020-07-14T07:08:29.350+0000][info ][safepoint] Leaving safepoint region
最後階段 6 開始的時候,會打印:
[2020-07-14T07:08:29.350+0000][info ][safepoint] Total time for which application threads were stopped: 3.1499371 seconds, Stopping threads took: 3.1467255 seconds
Total time for which application threads were stopped是此次階段1到階段6開始,一共過了多長時間,也就是 Stop the world 多長時間。後面的Stopping threads took是此次等待線程走進 Safepoint 過了多長時間,通常除了 階段 5 執行觸發 Stop the world 之外,都是因爲 等待線程走進 Safepoint 時間長。這是就要看 trace 的線程哪些沒有處於 Safepoint,看他們幹了什麼,是否有大循環,或者是使用了StackWalker這個類.
如何經過 JFR 分析 SafePoint?
JFR 相關的配置以及說明以及如何經過 JFR 分析 SafePoint 相關事件,能夠參考個人另外一個系列JFR全解系列
常見的 SafePoint 調優參數以及講解
建議關閉定時讓全部線程進入 Safepoint
對於微服務高併發應用,不必定時進入 Safepoint,因此關閉 -XX:+UnlockDiagnosticVMOptions -XX:GuaranteedSafepointInterval=0
建議取消偏向鎖
在高併發應用中,偏向鎖並不能帶來性能提高,反而由於偏向鎖取消帶來了不少不必的某些線程進入Safepoint 或者 Stop the world。因此建議關閉:-XX:-UseBiasedLocking
建議打開循環內添加 Safepoint 參數
防止大循環 JIT 編譯致使內部 Safepoint 被優化省略,致使進入 SafePoint 時間變長:-XX:+UseCountedLoopSafepoints
建議打開 debug 級別的 safepoint 日誌(和第五個選一個)
debug 級別雖然看不到每次是哪些線程須要等待進入 Safepoint,可是總體每階段耗時已經很清楚了。若是是 trace 級別,每次都能看到是那些線程,可是這樣每次進入 safepoint 時間就會增長几毫秒。
-Xlog:safepoint=debug:file=safepoint.log:utctime,level,tags:filecount=50,filesize=100M
建議打開 JFR 關於 safepoint 的採集(和第四個選一個)
修改,或者新建 jfc 文件:
<event name="jdk.SafepointBegin"> <setting name="enabled">true</setting> <setting name="threshold">10 ms</setting> </event> <event name="jdk.SafepointStateSynchronization"> <setting name="enabled">true</setting> <setting name="threshold">10 ms</setting> </event> <event name="jdk.SafepointWaitBlocked"> <setting name="enabled">true</setting> <setting name="threshold">10 ms</setting> </event> <event name="jdk.SafepointCleanup"> <setting name="enabled">true</setting> <setting name="threshold">10 ms</setting> </event> <event name="jdk.SafepointCleanupTask"> <setting name="enabled">true</setting> <setting name="threshold">10 ms</setting> </event> <event name="jdk.SafepointEnd"> <setting name="enabled">true</setting> <setting name="threshold">10 ms</setting> </event> <event name="jdk.ExecuteVMOperation"> <setting name="enabled">true</setting> <setting name="threshold">10 ms</setting> </event>
看完三件事❤️
若是你以爲這篇內容對你還蠻有幫助,我想邀請你幫我三個小忙:
-
點贊,轉發,有大家的 『點贊和評論』,纔是我創造的動力。
-
關注公衆號 『 java爛豬皮 』,不按期分享原創知識。
-
同時能夠期待後續文章ing🚀
做者:張哈希
出處:https://club.perfma.com/article/2132225