1、執行緒安全
多個執行緒對同一個共享變數進行讀寫操作時可能產生不可預見的結果,這就是執行緒安全問題,
執行緒安全的核心點就是共享變數,只有在共享變數的情況下才會有執行緒安全問題,這里說的共享變數,是指多個執行緒都能訪問的變數,一般包括成員變數和靜態變數,方法內定義的區域變數不屬于共享變數的范圍,
執行緒安全問題示例:
import lombok.extern.slf4j.Slf4j;
/**
* @Author FengJian
* @Date 2021/1/27 10:59
* @Version 1.0
*/
@Slf4j(topic = "c.ThreadSafeTest")
public class ThreadSafeTest {
static int count = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread("t1"){
@Override
public void run() {
for (int i = 0;i < 5000;i++){
count++;
}
}
};
Thread t2 = new Thread("t2"){
@Override
public void run() {
for (int i = 0;i < 5000;i++){
count--;
}
}
};
t1.start();
t2.start();
/**
* join方法:使main執行緒與t1、t2執行緒同步執行,即t1、t2執行緒都執行完,main執行緒才會繼續執行(但t1、t2之間依然是并行執行的)
* 主要是為了等待兩個執行緒執行完后,在main執行緒列印count的值
*/
t1.join();
t2.join();
log.debug("count的值為:{}",count);
}
}
運行上述代碼三次的結果如下:
[main] DEBUG c.ThreadSafeTest - count的值為:-904
[main] DEBUG c.ThreadSafeTest - count的值為:-2206
[main] DEBUG c.ThreadSafeTest - count的值為:73
在上述代碼中,執行緒t1中count進行5000次自增操作,而執行緒t2中count則進行5000次自減操作,在兩個執行緒都運行結束后,按照預期結果,count的值應為0,但由列印結果可知,count的值并不為0,且每次運行的結果都不一樣,這就是多執行緒對共享變數進行操作出現的不可預見的結果,即常說的執行緒安全問題,
而執行緒安全,則指的是在多執行緒環境下,程式可以始終執行正確的行為,符合預期的邏輯,具體到上述代碼,就是不論執行多少次,在t1、t2執行緒執行完畢后,count的值都應該始終符合預期的結果0,上述代碼明顯是執行緒不安全的,
2、出現執行緒安全的原因
執行緒安全是使用多執行緒必定會面臨的問題,導致執行緒不安全的主要原因有以下三點:
①原子性:一個或者多個操作在 CPU 執行的程序中被中斷
②可見性:一個執行緒對共享變數的修改,另外一個執行緒不能立刻看到
③有序性:序執行的順序沒有按照代碼的先后順序執行
2.1、原子性
2.1.1 什么是原子性問題
原子性問題,其實說的是原子性操作,即一個或多個操作,應該是一個不可分的整體,這些操作要么全部執行并且不被打斷,要么就都不執行,
以上述代碼中的count的自增(count++)和自減(count--)為例,
count++和count--看似只有一行代碼,但實際上這一行代碼在編譯后的位元組碼指令以及在JVM執行的對應操作如下:
count++:
getstatic count //獲取靜態變數count的值
iconst_1 //準備常量1
iadd //自增
putstatic count //將修改后的值存入靜態變數count
count--:
getstatic count //獲取靜態變數count的值
iconst_1 //準備常量1
isub //自減
putstatic count //將修改后的值存入靜態變數count
由此可知,count自增或自減的操作,并不是一個原子操作,即中間程序是有可能被打斷的,
count自增自減操作需要四個步驟(指令)才能完成,這意味著如果這執行這四個步驟的某一步時,執行緒發生了背景關系切換,那么自增自減操作將被打斷暫停,
如果使用單執行緒來執行自增自減操作,這實際上并無問題:

上圖為單執行緒執行count自增自減的一次程序,可以看出在沒有執行緒背景關系切換的情況下,即使自增自減不是原子操作,count的最后結果都會是0,
但在多執行緒環境下,就會出現問題了:

可以看到由于自增自減不是原子操作,因此在執行緒t1執行自增程序中,如果進行背景關系切換,則將導致執行緒t1還沒來得及把count = 1 寫入主存,count的值就被t2執行緒讀取,所以在最后,執行緒t2自減得出的值-1寫入主存后,會被執行緒t1覆寫,變為1,
這結果明顯是不符合我們的預期的,實際上,上述圖片展示的只是一種可能的結果,還有可能是t2寫入count的步驟是最后執行的,那么最后count的值將為-1,
這就是由于非原子操作帶來的多執行緒訪問共享變數出現不符合預期的結果,即由于原子性帶來的執行緒安全問題,
上面示例中兩個執行緒t1、t2分別執行count++和count--出現的問題,就是由于原子性帶來的執行緒安全問題,
2.1.2、原子性問題解決辦法
解決辦法就是將count++和count--的操作變為原子操作,Java中的實作方法是:
①上鎖:使用synchronized
只需要創建一個物件作為鎖,并在訪問count時用synchronized進行加鎖即可,
static int count = 0;
static Object lock = new Object(); //鎖物件
synchronized(lock){
count++;
}
synchronized(lock){
count--;
}
上鎖后,執行自增自減的示意圖如下:

由于鎖的存在,則保證了不持有鎖的t2執行緒會被阻塞,直到t1執行緒執行自增完畢,并釋放鎖,在這一程序中,雖然依舊存在執行緒的背景關系切換,但是t2執行緒是無法對共享變數count進行操作的,因此保證了t1執行緒中count++操作的原子性,
因此使用synchronized鎖可以解決原子性帶來的執行緒安全問題,
②、回圈CAS操作
其基本思路就是回圈進行CAS操作(compare and swap,比較并交換),即對共享變數進行計算前,執行緒會先將該共享變數保存一份舊值a,計算完畢后得出結果值b,在將b從執行緒的本地記憶體重繪回主記憶體前,會先比較主記憶體中的值是否和a一致,如果一致,則將b重繪回主記憶體,若不一致,則一直回圈比較,直到主記憶體中的值與a一致,才把共享變數的值設為b,操作才結束,
在Java中,使用CAS操作保證原子性的具體實作就是Lock和原子類(AtomicInteger),它們都是通過使用unsafe的compareAndSwap方法實作CAS操作保證原子性的,
Lock的使用:
static int count = 0;
static Lock lock = new Lock (); //鎖物件
lock.lock(); //加鎖
count++;
lock.unlock(); //解鎖
lock.lock(); //加鎖
count--;
lock.unlock(); //解鎖
原子類的使用:
static AtomicInteger count = new AtomicInteger(0);
count.incrementAndGet(); //自增
count.decrementAndGet(); //自減
以上都是Java中可以保證原子操作的具體方法,它們各有優缺點,要看具體的場景來選擇最佳的使用,以此來解決原子性帶來的執行緒安全問題,
2.2、可見性
2.2.1、什么是可見性問題
可見性實際上指的是記憶體可見性問題,總的來說就是一個執行緒對共享變數的修改,另外一個執行緒不能立刻看到,從而產生的執行緒安全問題,
在上一篇筆記【JAVA并發第三篇】執行緒間通信 中的通過共享記憶體進行通信實際上講的就是記憶體可見性問題,這里再從執行緒安全的角度講述一遍,
我們知道,CPU要從記憶體中讀取出資料來進行計算,但實際上CPU并不總是直接從記憶體中讀取資料,由于CPU和記憶體間(常稱之為主存)的速度不匹配(CPU的速度比主存快得多),為了有效利用CPU,使用多級cache的機制,如圖

上圖所示是一個雙核心的CPU系統架構,每個核心都有自己的控制器和運算器,也都有自己的一級快取,還有可能有所有CPU核心共享的二級快取,每個核心都可以獨立運行執行緒,
因此,CPU讀取資料的順序是:暫存器-高速快取-主存,主存中的部分資料,會先拷貝一份放到cache中,當CPU計算時,會直接從cache中讀取資料,計算完畢后再將計算結果放置到cache中,最后在主存中重繪計算結果,所以每個CPU都會擁有一份拷貝,
以上只是CPU訪問記憶體,進行計算的基本方式,實際上,不同的硬體,訪問程序會存在不同程度的差異,比如,不同的計算機,CPU和主存間可能會存在三級快取、四級快取、五級快取等等的情況,
為了屏蔽掉各種硬體和作業系統的記憶體訪問差異,實作讓 Java 程式在各種平臺下都能達到一致的記憶體訪問效果,定義了Java的記憶體模型(Java Memory Model,JMM),
JMM 的主要目標是定義程式中各個變數的訪問規則,即在虛擬機中將變數存盤到主存和從主存中取出變數這樣的底層細節,這里的變數指的是能夠被多個執行緒共享的變數,它包括了實體欄位、靜態欄位和構成陣列物件的元素,方法內的區域變數和方法的引數為執行緒私有,不受JMM的影響,
Java的記憶體模型如下:

Java記憶體模型中的本地記憶體,對應的就是CPU結構圖中的cache1或者cache2,它實際上并不真實存在,其包含了快取、寫緩沖區、暫存器以及其他的硬體和編譯器的優化,
JMM規定:將所有共享變數放到主記憶體中,當執行緒使用變數時,會把其中的變數復制到自己的本地記憶體,執行緒讀寫時操作的是本地記憶體中的變數副本,一個執行緒不能訪問其他執行緒的本地記憶體,
這樣的情況下,如果有一個變數i在執行緒A、B的本地記憶體中都有一份副本,此時,若執行緒A想修改i的值,在執行緒A將修改后的值放入到本地記憶體,但又未重繪回主記憶體時,如果執行緒B讀取變數i的值,則讀到的是未修改時的值,這就造成了讀寫共享變數出現不可預期的結果,產生執行緒安全問題,
有代碼如下:
/**
* @Author FengJian
* @Date 2021/2/21 23:47
* @Version 1.0
*/
@Slf4j(topic = "c.ThreadSafeTest")
public class ThreadSafe02 {
private static boolean run = true;
public static void main(String[] args) throws InterruptedException {
Thread My_Thread = new Thread(new Runnable() {
@Override
public void run() {
while (run) {
}
}
}, "My_Thread");
My_Thread.start(); //啟動My_Thread執行緒
log.debug(Thread.currentThread().getName()+"正在休眠@"+new SimpleDateFormat("hh:mm:ss").format(new Date())+"--"+run);
Thread.sleep(1000); //主執行緒休眠1s
run = false; //改變My_Thread執行緒運行條件
log.debug(Thread.currentThread().getName()+"正在運行@"+new SimpleDateFormat("hh:mm:ss").format(new Date())+"--"+run);
}
}
從運行結果發現,即使在主執行緒中修改了共享變數run的值,My_Thread執行緒依然在回圈并不會停止:

其原因就是main執行緒對共享變數run的修改,另外一個執行緒My_Thread并不能立刻看到:

這就是由于記憶體可見性帶來的多執行緒訪問共享變數出現不符合預期的結果,即由于可見性帶來的執行緒安全問題,
2.2.2、可見性問題解決辦法
解決辦法就是保證共享變數的可見性,具體實作就是任何對共享變數的訪問都要從共享記憶體(主記憶體)中獲取,在Java中的實作方法是:
①加鎖,synchronized和Lock都可以保證
執行緒在加鎖時,會清空本地記憶體中共享變數的值,共享變數的使用需要從主記憶體中重新獲取,而在釋放鎖資源時,則必須先把此共享變數同步回主記憶體中,
由于鎖的存在,未持有鎖的執行緒并不能操作共享變數,而當阻塞的執行緒獲得鎖時,主記憶體中共享變數的值已經重繪過了,因此執行緒修改共享變數對其他執行緒是可見的,這保證了共享變數的可見性,可以解決記憶體可見性產生的執行緒安全問題,
②使用volatile修飾共享變數
當一個變數被宣告為volitale時,執行緒在寫入變數時,不會把值快取本地記憶體,而是會立即把值重繪回主存,而當要讀取該共享變數時,執行緒則會先清空本地記憶體中的副本值,從主存中重新獲取,這些也都保證了記憶體的可見性,
優先使用volatile關鍵字來解決可見性問題,加鎖消耗的資源更多,
2.3、有序性
2.3.1、什么是有序性問題
有序性,實際上是指令的重排序問題,
我們知道,CPU的執行速度是比記憶體要快出很多個數量級的,CPU為了執行效率,會把CPU指令進行重新排序,即我們撰寫的Java代碼并不一定按照順序一行一行的往下執行,處理器會根據需要重新排序這些指令,稱為指令并行重排序,
同時,JIT編譯器也會在代碼編譯的時候對代碼進行重新整理,最大限度的去優化代碼的執行效率,稱為編譯器的重排序,
而又由于處理器與主存之間會使用快取和讀/寫緩沖機制,因此從主存加載和存盤操作也有可能是經過指令重排序的,稱為記憶體系統重排序,
綜上所述,在執行程式時,為了提高性能,編譯器和處理器常常會對指令進行重排序,再加上主記憶體和處理器的快取,Java原始碼經過層層的重排序,最后才得出最終結果,

由圖可知,從Java原始碼到最后的執行指令,會經歷3種重排序的優化,若有ava代碼如下:
int a = 2; //A
int b = 3; //B
int c = a*b; //C
經過上述3種重排序后,陳述句A和陳述句B的執行順序是可能互換的,并且這種互換并不影響代碼的正確性,但是我們發現陳述句C則不能和A、B互換,否則得出的結果將不正確,因為他們之間存在著資料依賴關系,即陳述句C的資料依賴A和B得出,
由此,我們可以發現,以上3種指令的重排序并不能隨意排序,他們需要遵守一定的規則,以保證程式的正確性,
①as-if-serial語意
as-if-serial語意是指:不管怎么樣重排序,單執行緒程式的執行結果都不能被改變,即不會對存在資料依賴關系的操作進行重排序,
編譯器、處理器進行指令重排序優化時都必須遵守as-if-serial語意,即在單執行緒的情況下,指令重排序只能對不影響處理結果的部分進行重排序,
以上述陳述句A、B、C為例,存在資料依賴關系的陳述句C和A或B不能被重排序:

as-if-serial語意把單執行緒程式保護起來了,遵守該語意的編譯器、處理器等使我們撰寫單執行緒有一個錯覺:單執行緒程式是按照源代碼的順序來執行的,實際上在由于as-if-serial語意的存在,我們撰寫單執行緒時,完全可以認為源代碼是按照順序執行的,因為即使代碼被進行了重排序,其結果也不會改變,同時單執行緒中也無需擔心記憶體可見性問題,
as-if-serial語意的核心思想是:不會對存在資料依賴關系的操作進行重排序,
那么資料依賴型別有哪些呢?如下表所示:
| 型別 | 示例 | 說明 |
|---|---|---|
| 寫后讀 | a = 1; b = a | 寫一個變數后再讀該變數 |
| 寫后寫 | a = 1; a = 2 | 寫一個變數后再寫該變數 |
| 讀后寫 | a = b; b = 2 | 讀一個變數后再寫該變數 |
以上三種依賴關系,一旦重排序兩個操作的執行順序,其結果就會改變,所以依照as-if-serial語意,Java在單執行緒的情況下不會對這三種依賴關系進行重排序(多執行緒情況不符合此情況),
as-if-serial語意是基于資料依賴關系的,但它無法保證多執行緒環境下,重排序之后程式執行結果的正確性,
有代碼如下:
/**
* @Author FengJian
* @Date 2021/2/24 16:44
* @Version 1.0
*/
@Slf4j(topic = "c.HappensBeforeTest")
public class HappensBeforeTest {
static int a = 0;
static boolean finish = false;
public static void main(String[] args) {
Thread t1 = new Thread("t1"){
@Override
public void run() {
if(finish){
log.debug("a*a:"+a*a);
}
}
};
Thread t2 = new Thread("t2"){
@Override
public void run() {
a = 2;
finish = true;
}
};
t2.start();
t1.start();
}
}
關于上述代碼,我們先忽略記憶體可見性的問題(即執行緒t2修改了a和finish,但t1可能看不到的快取問題),在此前提下如果成功列印a*a的值,那么結果應該為4,
但實際上a*a列印的結果還可能為0,這是由于指令重排序的存在導致的,
在執行緒t2中,由于a = 2;和finish = true;沒有資料依賴關系,依照as-if-serial語意,可以對這兩條陳述句進行重排序,因此會出現finish = true;的指令比a = 2;先執行的情況,
如果在先執行finish = true;,而a = 2;沒有執行時發生執行緒背景關系切換,輪到執行緒t1執行,那么t1執行緒中的if陳述句條件為真,而a的值依然為初始值0,則a*a的結果為0,

可以看出,即使在假設沒有記憶體可見性問題的前提下,上述代碼的結果也是不可預期的,因此上述代碼也是執行緒不安全的,其原因就是重排序破壞了多執行緒程式的語意,
②happens-before規則
既然是重排序出現問題,那么解決思路就是禁止重排序,但是也要注意不能全部禁用重排序,重排序的目的是為了提升執行效率,如果全部禁用那么Java程式的性能將會很差,所以,應該做到的是部分禁用,Java的記憶體模型提供了一個可用于多執行緒環境,也適用于單執行緒環境的規則:happens-before規則,
happens-before規則的定義如下:A happens-before B,那么操作A的執行結果對操作B是可見的,且操作A的執行順序排在操作B之前,這里的操作A和操作B可以在同一個執行緒中,也可以在不同執行緒中,
注意:執行順序只是happens-before向開發人員做的保證,實際上在處理器和編譯器上執行時并不一定按照操作A排在操作B之前執行,
如果重排序之后,依然可以保證與先A后B的執行結果一樣,那么進行重排序也是可以的,也就是說,符合happens-before的操作,只要不改變執行結果,處理器和編譯器怎么優化(重排序)都行,
只是我們開發人員可以直接認為操作A的執行順序排在操作B之前,
happens-before保證操作A的執行結果對B可見,依靠這個原則,可以解決多執行緒環境下記憶體可見性和有序性問題,
回到代碼:
/**執行緒t1**/
if(finish){
a*a;
}
/**執行緒t2**/
a = 2;
finish = true;
一共有四個操作a = 2;、finish = true;、if(finish)、a*a;,想要上述代碼達到執行緒安全(即列印都正確輸出4),只需要:

即在t2執行緒計算a*a;和if(finish);之前,需要知道t1執行緒中a = 2;和finish = true;(t2執行緒對t1執行緒的結果可見),
要達到這一目的,就需要上圖中,①和②所示的happens-before關系,
那要如何達到呢?這就需要了解happens-before的六大具體規則了(兩個操作,只需要符合其中任何一條就可以認為是happens-before關系):
- ①程式順序規則:一個執行緒中的每個操作,按照程式順序,前面的操作 happens-before 于該執行緒中的任意后續操作,
以上述代碼為例:
/**執行緒t2**/
a = 2; //操作1
finish = true; //操作2
/**執行緒t1**/
if(finish ); //操作3
a*a; //操作4
操作1 happens-before 操作2
操作3 happens-before 操作4
- ②監視器鎖規則:對一個鎖的解鎖,happens-before于隨后對這個鎖的加鎖,
synchronized (lock) { //加鎖
// x是共享變數,初始值=10
if (x < 12) {
x = 12;
}
} //解鎖
若有兩個執行緒A、B,先后執行這段代碼,則執行緒A執行完畢后X = 12并釋放鎖,而執行緒B獲得鎖后,進入代碼塊,在if中取X值判斷是否小于12,
此時 執行緒A中X=12的操作 happens-before 執行緒B中取X值判斷的操作(即執行緒B能看到執行緒A中執行的X=12的結果)
- ③volatile變數規則:對一個volatile域的寫,happens-before于任意后續對這個volatile域的讀,
volatile int x = 10;
/**執行緒t1**/
x = 11; //操作1
/**執行緒t2**/
int y = x; //操作2
操作1 happens-before 操作2
-
④傳遞性:如果A happens-before B,且B happens-before C,那么A happens-before C,
-
⑤start()規則:如果執行緒A執行操作ThreadB.start()(啟動執行緒B),那么A執行緒的ThreadB.start()操作happens-before于執行緒B中的任意操作,
-
⑥join()規則:如果執行緒A執行操作ThreadB.join()并成功回傳,那么執行緒B中的任意操作happens-before于執行緒A從ThreadB.join()操作成功回傳,
以上就是happens-before的六大常用規則(全部有八種,但后面兩種應該很少用到)
2.3.2、有序性問題解決辦法
解決有序性問題,實際上就是要運用以上提到的兩種規則,as-if-serial語意解決了單執行緒程式的有序性問題,而happens-before關系則能解決多執行緒程式的有序性問題,
再回顧一下原始代碼,這是一段存在有序性問題執行緒不安全的代碼,我們要利用happens-before關系解決有序性問題:
public class HappensBeforeTest {
static int a = 0;
static boolean finish = false;
public static void main(String[] args) {
Thread t1 = new Thread("t1"){
@Override
public void run() {
if(finish){
log.debug("a*a:"+a*a);
}
}
};
Thread t2 = new Thread("t2"){
@Override
public void run() {
a = 2;
finish = true;
}
};
t2.start();
t1.start();
}
}
提取一下關鍵的操作,如下嗷:
/**執行緒t1**/
if(finish){
a*a;
}
/**執行緒t2**/
a = 2;
finish = true;
我們的目標是運用happens-before的六大常用規則達到如下圖的happens-before關系,以實作上訴代碼的執行緒安全

解決辦法如下:
①、方法一:運用volatile修飾變數
使用到happens-before規則中的程式順序規則、volatile變數規則和傳遞性,
首先,按照程式順序規則,可以知道如下的happens-before關系:
| 執行緒t1 | 執行緒t2 |
|---|---|
| if(finish) happens-before a*a; | a = 2; happens-before finish = true; |
這由執行緒中的代碼很容易就能得出,接下來運用volatile變數規則,需要用volatile修飾一個變數,我們選變數finish,即初始化時代碼改為為volatile static boolean finish = false;,
那么根據volatile變數規則,可知對finish的寫要happens-before于對finish的讀,
因此給finish加上volatile關鍵字后,就可以達到如下效果:

volatile關鍵字不僅可以保證記憶體可見性問題,同時依照happens-before的volatile變數規則,對于volatile修飾的變數,要保證對該變數寫的結果要對讀的操作可見,因此volatile禁止對有讀寫操作的volatile修飾的變數進行重排序,
也就是說,volatile關鍵字不僅可以解決可見性問題,還可以解決有序性問題,
最后,通過傳遞性,可知:

可知,圖示的三和五,就是我們的目標,到此,我們利用happens-before關系保證了代碼的可見性和有序性問題,
雖然分析的程序比較長,但是在原代碼中,我們實際上只改動了一行代碼,即將static boolean finish = false;改為volatile static boolean finish = false;而已,就可以使我們的代碼改變執行緒安全的,
這就是運用volatile修飾變數來解決執行緒安全的辦法,volatile直接通過禁止相關的重排序來達到有序性的目的,
②、方法二:加鎖,synchronized
這個應該比較容易理解,對相關代碼加鎖后,同一時刻就只有一個執行緒在執行,也就相當于對相關變數的操作,是保證有序的,
不過synchronized并不像volatile一樣禁止指令重排序,實際上synchronized塊內部的代碼指令依然是可以進行重排序優化的,
3、小結
- 多個執行緒對同一個共享變數進行讀寫操作時就可能產生不可預見的結果,就是執行緒安全問題,其重點是多執行緒對共享變數進行讀和寫,如果只有讀,并不會有執行緒安全問題,
- 執行緒安全的原因有:①執行緒切換帶來的原子性問題②快取帶來的可見性問題③指令重排序帶來的原子性問題,
- 執行緒安全的解決辦法:①對于原子性問題,使用鎖synchronized和Lock、或者使用原子類(AtomicInteger等)②對于可見性問題:使用鎖synchronized和Lock,或者使用volatile關鍵字③對于有序性問題:使用鎖synchronized和Lock,或者使用volatile關鍵字
點個贊吧彥祖,(????)
由于能力有限,可能存在錯誤,感謝并懇請老鐵們指出,以上內容為本人在學習程序中所做的筆記,參考的書籍、文章或博客如下:
[1]方騰飛,魏鵬,程曉明. Java并發編程的藝術[M].機械工業出版社.
[2]霍陸續,薛賓田. Java并發編程之美[M].電子工業出版社.
[3]mg驛站. 多執行緒篇-執行緒安全-原子性、可見性、有序性決議.知乎.https://zhuanlan.zhihu.com/p/142929863
[4]JAVA bx.Java并發的原子性、可見性、有序性.知乎.https://zhuanlan.zhihu.com/p/205335197
[5]程式員七哥.happens-before是什么?JMM最最核心的概念,看完你就懂了.知乎.https://zhuanlan.zhihu.com/p/126275344
轉載請註明出處,本文鏈接:https://www.uj5u.com/houduan/263643.html
標籤:Java
上一篇:你還在用命令看日志?快用 Kibana 吧,一張圖片勝過千萬行日志!
下一篇:java 流程控制學習
