前言
java多執行緒我個人覺得是javaSe中最難的一部分,我以前也是感覺學會了,但是真正有多執行緒的需求卻不知道怎么下手,實際上還是對多執行緒這塊知識了解不深刻,不知道多執行緒api的應用場景,不知道多執行緒的運行流程等等,本篇文章將使用實體+圖解+原始碼的方式來決議java多執行緒,
文章篇幅較長,大家也可以有選擇的看具體章節,建議多執行緒的代碼全部手敲,永遠不要相信你看到的結論,自己編碼后運行出來的,才是自己的,
什么是java多執行緒?
行程與執行緒
行程
- 當一個程式被運行,就開啟了一個行程, 比如啟動了qq,word
- 程式由指令和資料組成,指令要運行,資料要加載,指令被cpu加載運行,資料被加載到記憶體,指令運行時可由cpu調度硬碟、網路等設備
執行緒
- 一個行程內可分為多個執行緒
- 一個執行緒就是一個指令流,cpu調度的最小單位,由cpu一條一條執行指令
并行與并發
并發:單核cpu運行多執行緒時,時間片進行很快的切換,執行緒輪流執行cpu
并行:多核cpu運行 多執行緒時,真正的在同一時刻運行
java提供了豐富的api來支持多執行緒,
為什么用多執行緒?
多執行緒能實作的都可以用單執行緒來完成,那單執行緒運行的好好的,為什么java要引入多執行緒的概念呢?
多執行緒的好處:
-
程式運行的更快!快!快!
-
充分利用cpu資源,目前幾乎沒有線上的cpu是單核的,發揮多核cpu強大的能力
多執行緒難在哪里?
單執行緒只有一條執行線,程序容易理解,可以在大腦中清晰的勾勒出代碼的執行流程
多執行緒卻是多條線,而且一般多條線之間有互動,多條線之間需要通信,一般難點有以下幾點
- 多執行緒的執行結果不確定,受到cpu調度的影響
- 多執行緒的安全問題
- 執行緒資源寶貴,依賴執行緒池操作執行緒,執行緒池的引數設定問題
- 多執行緒執行是動態的,同時的,難以追蹤程序
- 多執行緒的底層是作業系統層面的,原始碼難度大
有時候希望自己變成一個位元組穿梭于服務器中,搞清楚來龍去脈,就像無敵破壞王一樣(沒看過這部電影的可以看下,腦洞大開),
java多執行緒的基本使用
定義任務、創建和運行執行緒
任務: 執行緒的執行體,也就是我們的核心代碼邏輯
定義任務
- 繼承Thread類 (可以說是 將任務和執行緒合并在一起)
- 實作Runnable介面 (可以說是 將任務和執行緒分開了)
- 實作Callable介面 (利用FutureTask執行任務)
Thread實作任務的局限性
- 任務邏輯寫在Thread類的run方法中,有單繼承的局限性
- 創建多執行緒時,每個任務有成員變數時不共享,必須加static才能做到共享
Runnable和Callable解決了Thread的局限性
但是Runbale相比Callable有以下的局限性
- 任務沒有回傳值
- 任務無法拋例外給呼叫方
如下代碼 幾種定義執行緒的方式
@Slf4j
class T extends Thread {
@Override
public void run() {
log.info("我是繼承Thread的任務");
}
}
@Slf4j
class R implements Runnable {
@Override
public void run() {
log.info("我是實作Runnable的任務");
}
}
@Slf4j
class C implements Callable<String> {
@Override
public String call() throws Exception {
log.info("我是實作Callable的任務");
return "success";
}
}
創建執行緒的方式
- 通過Thread類直接創建執行緒
- 利用執行緒池內部創建執行緒
啟動執行緒的方式
- 呼叫執行緒的start()方法
// 啟動繼承Thread類的任務
new T().start();
// 啟動繼承Thread匿名內部類的任務 可用lambda優化
Thread t = new Thread(){
@Override
public void run() {
log.info("我是Thread匿名內部類的任務");
}
};
// 啟動實作Runnable介面的任務
new Thread(new R()).start();
// 啟動實作Runnable匿名實作類的任務
new Thread(new Runnable() {
@Override
public void run() {
log.info("我是Runnable匿名內部類的任務");
}
}).start();
// 啟動實作Runnable的lambda簡化后的任務
new Thread(() -> log.info("我是Runnable的lambda簡化后的任務")).start();
// 啟動實作了Callable介面的任務 結合FutureTask 可以獲取執行緒執行的結果
FutureTask<String> target = new FutureTask<>(new C());
new Thread(target).start();
log.info(target.get());
以上各個執行緒相關的類的類圖如下
背景關系切換
多核cpu下,多執行緒是并行作業的,如果執行緒數多,單個核又會并發的調度執行緒,運行時會有背景關系切換的概念
cpu執行執行緒的任務時,會為執行緒分配時間片,以下幾種情況會發生背景關系切換,
- 執行緒的cpu時間片用完
- 垃圾回收
- 執行緒自己呼叫了 sleep、yield、wait、join、park、synchronized、lock 等方法
當發生背景關系切換時,作業系統會保存當前執行緒的狀態,并恢復另一個執行緒的狀態,jvm中有塊記憶體地址叫程式計數器,用于記錄執行緒執行到哪一行代碼,是執行緒私有的,
idea打斷點的時候可以設定為Thread模式,idea的debug模式可以看出堆疊幀的變化
執行緒的禮讓-yield()&執行緒的優先級
yield()方法會讓運行中的執行緒切換到就緒狀態,重新爭搶cpu的時間片,爭搶時是否獲取到時間片看cpu的分配,
代碼如下
// 方法的定義
public static native void yield();
Runnable r1 = () -> {
int count = 0;
for (;;){
log.info("---- 1>" + count++);
}
};
Runnable r2 = () -> {
int count = 0;
for (;;){
Thread.yield();
log.info(" ---- 2>" + count++);
}
};
Thread t1 = new Thread(r1,"t1");
Thread t2 = new Thread(r2,"t2");
t1.start();
t2.start();
// 運行結果
11:49:15.796 [t1] INFO thread.TestYield - ---- 1>129504
11:49:15.796 [t1] INFO thread.TestYield - ---- 1>129505
11:49:15.796 [t1] INFO thread.TestYield - ---- 1>129506
11:49:15.796 [t1] INFO thread.TestYield - ---- 1>129507
11:49:15.796 [t1] INFO thread.TestYield - ---- 1>129508
11:49:15.796 [t1] INFO thread.TestYield - ---- 1>129509
11:49:15.796 [t1] INFO thread.TestYield - ---- 1>129510
11:49:15.796 [t1] INFO thread.TestYield - ---- 1>129511
11:49:15.796 [t1] INFO thread.TestYield - ---- 1>129512
11:49:15.798 [t2] INFO thread.TestYield - ---- 2>293
11:49:15.798 [t1] INFO thread.TestYield - ---- 1>129513
11:49:15.798 [t1] INFO thread.TestYield - ---- 1>129514
11:49:15.798 [t1] INFO thread.TestYield - ---- 1>129515
11:49:15.798 [t1] INFO thread.TestYield - ---- 1>129516
11:49:15.798 [t1] INFO thread.TestYield - ---- 1>129517
11:49:15.798 [t1] INFO thread.TestYield - ---- 1>129518
如上述結果所示,t2執行緒每次執行時進行了yield(),執行緒1執行的機會明顯比執行緒2要多,
執行緒的優先級
? 執行緒內部用1~10的數來調整執行緒的優先級,默認的執行緒優先級為NORM_PRIORITY:5
? cpu比較忙時,優先級高的執行緒獲取更多的時間片
? cpu比較閑時,優先級設定基本沒用
public final static int MIN_PRIORITY = 1;
public final static int NORM_PRIORITY = 5;
public final static int MAX_PRIORITY = 10;
// 方法的定義
public final void setPriority(int newPriority) {
}
cpu比較忙時
Runnable r1 = () -> {
int count = 0;
for (;;){
log.info("---- 1>" + count++);
}
};
Runnable r2 = () -> {
int count = 0;
for (;;){
log.info(" ---- 2>" + count++);
}
};
Thread t1 = new Thread(r1,"t1");
Thread t2 = new Thread(r2,"t2");
t1.setPriority(Thread.NORM_PRIORITY);
t2.setPriority(Thread.MAX_PRIORITY);
t1.start();
t2.start();
// 可能的運行結果
11:59:00.696 [t1] INFO thread.TestYieldPriority - ---- 1>44102
11:59:00.696 [t2] INFO thread.TestYieldPriority - ---- 2>135903
11:59:00.696 [t2] INFO thread.TestYieldPriority - ---- 2>135904
11:59:00.696 [t2] INFO thread.TestYieldPriority - ---- 2>135905
11:59:00.696 [t2] INFO thread.TestYieldPriority - ---- 2>135906
cpu比較閑時
Runnable r1 = () -> {
int count = 0;
for (int i = 0; i < 10; i++) {
log.info("---- 1>" + count++);
}
};
Runnable r2 = () -> {
int count = 0;
for (int i = 0; i < 10; i++) {
log.info(" ---- 2>" + count++);
}
};
Thread t1 = new Thread(r1,"t1");
Thread t2 = new Thread(r2,"t2");
t1.setPriority(Thread.MIN_PRIORITY);
t2.setPriority(Thread.MAX_PRIORITY);
t1.start();
t2.start();
// 可能的運行結果 執行緒1優先級低 卻先運行完
12:01:09.916 [t1] INFO thread.TestYieldPriority - ---- 1>7
12:01:09.916 [t1] INFO thread.TestYieldPriority - ---- 1>8
12:01:09.916 [t1] INFO thread.TestYieldPriority - ---- 1>9
12:01:09.916 [t2] INFO thread.TestYieldPriority - ---- 2>2
12:01:09.916 [t2] INFO thread.TestYieldPriority - ---- 2>3
12:01:09.916 [t2] INFO thread.TestYieldPriority - ---- 2>4
12:01:09.916 [t2] INFO thread.TestYieldPriority - ---- 2>5
12:01:09.916 [t2] INFO thread.TestYieldPriority - ---- 2>6
12:01:09.916 [t2] INFO thread.TestYieldPriority - ---- 2>7
12:01:09.916 [t2] INFO thread.TestYieldPriority - ---- 2>8
12:01:09.916 [t2] INFO thread.TestYieldPriority - ---- 2>9
守護執行緒
默認情況下,java行程需要等待所有執行緒都運行結束,才會結束,有一種特殊執行緒叫守護執行緒,當所有的非守護執行緒都結束后,即使它沒有執行完,也會強制結束,
默認的執行緒都是非守護執行緒,
垃圾回收執行緒就是典型的守護執行緒
// 方法的定義
public final void setDaemon(boolean on) {
}
Thread thread = new Thread(() -> {
while (true) {
}
});
// 具體的api,設為true表示未守護執行緒,當主執行緒結束后,守護執行緒也結束,
// 默認是false,當主執行緒結束后,thread繼續運行,程式不停止
thread.setDaemon(true);
thread.start();
log.info("結束");
執行緒的阻塞
執行緒的阻塞可以分為好多種,從作業系統層面和java層面阻塞的定義可能不同,但是廣義上使得執行緒阻塞的方式有下面幾種
- BIO阻塞,即使用了阻塞式的io流
- sleep(long time) 讓執行緒休眠進入阻塞狀態
- a.join() 呼叫該方法的執行緒進入阻塞,等待a執行緒執行完恢復運行
- sychronized或ReentrantLock 造成執行緒未獲得鎖進入阻塞狀態 (同步鎖章節細說)
- 獲得鎖之后呼叫wait()方法 也會讓執行緒進入阻塞狀態 (同步鎖章節細說)
- LockSupport.park() 讓執行緒進入阻塞狀態 (同步鎖章節細說)
sleep()
? 使執行緒休眠,會將運行中的執行緒進入阻塞狀態,當休眠時間結束后,重新爭搶cpu的時間片繼續運行
// 方法的定義 native方法
public static native void sleep(long millis) throws InterruptedException;
try {
// 休眠2秒
// 該方法會拋出 InterruptedException例外 即休眠程序中可被中斷,被中斷后拋出例外
Thread.sleep(2000);
} catch (InterruptedException例外 e) {
}
try {
// 使用TimeUnit的api可替代 Thread.sleep
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
}
join()
? join是指呼叫該方法的執行緒進入阻塞狀態,等待某執行緒執行完成后恢復運行
// 方法的定義 有多載
// 等待執行緒執行完才恢復運行
public final void join() throws InterruptedException {
}
// 指定join的時間,指定時間內 執行緒還未執行完 呼叫方執行緒不繼續等待就恢復運行
public final synchronized void join(long millis)
throws InterruptedException{}
Thread t = new Thread(() -> {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
r = 10;
});
t.start();
// 讓主執行緒阻塞 等待t執行緒執行完才繼續執行
// 去除該行,執行結果為0,加上該行 執行結果為10
t.join();
log.info("r:{}", r);
// 運行結果
13:09:13.892 [main] INFO thread.TestJoin - r:10
執行緒的打斷-interrupt()
// 相關方法的定義
public void interrupt() {
}
public boolean isInterrupted() {
}
public static boolean interrupted() {
}
打斷標記:執行緒是否被打斷,true表示被打斷了,false表示沒有
isInterrupted() 獲取執行緒的打斷標記 ,呼叫后不會修改執行緒的打斷標記
interrupt()方法用于中斷執行緒
- 可以打斷sleep,wait,join等顯式的拋出InterruptedException方法的執行緒,但是打斷后,執行緒的打斷標記還是false
- 打斷正常執行緒 ,執行緒不會真正被中斷,但是執行緒的打斷標記為true
interrupted() 獲取執行緒的打斷標記,呼叫后清空打斷標記 即如果獲取為true 呼叫后打斷標記為false (不常用)
interrupt實體: 有個后臺監控執行緒不停的監控,當外界打斷它時,就結束運行,代碼如下
@Slf4j
class TwoPhaseTerminal{
// 監控執行緒
private Thread monitor;
public void start(){
monitor = new Thread(() ->{
// 不停的監控
while (true){
Thread thread = Thread.currentThread();
// 判斷當前執行緒是否被打斷
if (thread.isInterrupted()){
log.info("當前執行緒被打斷,結束運行");
break;
}
try {
Thread.sleep(1000);
// 監控邏輯中被打斷后,打斷標記為true
log.info("監控");
} catch (InterruptedException e) {
// 睡眠時被打斷時拋出例外 在該處捕獲到 此時打斷標記還是false
// 在呼叫一次中斷 使得中斷標記為true
thread.interrupt();
}
}
});
monitor.start();
}
public void stop(){
monitor.interrupt();
}
}
執行緒的狀態
上面說了一些基本的api的使用,呼叫上面的方法后都會使得執行緒有對應的狀態,
執行緒的狀態可從 作業系統層面分為五種狀態 從java api層面分為六種狀態,
五種狀態
- 初始狀態:創建執行緒物件時的狀態
- 可運行狀態(就緒狀態):呼叫start()方法后進入就緒狀態,也就是準備好被cpu調度執行
- 運行狀態:執行緒獲取到cpu的時間片,執行run()方法的邏輯
- 阻塞狀態: 執行緒被阻塞,放棄cpu的時間片,等待解除阻塞重新回到就緒狀態爭搶時間片
- 終止狀態: 執行緒執行完成或拋出例外后的狀態
六種狀態
Thread類中的內部列舉State
public enum State {
NEW,
RUNNABLE,
BLOCKED,
WAITING,
TIMED_WAITING,
TERMINATED;
}
- NEW 執行緒物件被創建
- Runnable 執行緒呼叫了start()方法后進入該狀態,該狀態包含了三種情況
- 就緒狀態 :等待cpu分配時間片
- 運行狀態:進入Runnable方法執行任務
- 阻塞狀態:BIO 執行阻塞式io流時的狀態
- Blocked 沒獲取到鎖時的阻塞狀態(同步鎖章節會細說)
- WAITING 呼叫wait()、join()等方法后的狀態
- TIMED_WAITING 呼叫 sleep(time)、wait(time)、join(time)等方法后的狀態
- TERMINATED 執行緒執行完成或拋出例外后的狀態
六種執行緒狀態和方法的對應關系
執行緒的相關方法總結
主要總結Thread類中的核心方法
| 方法名稱 | 是否static | 方法說明 |
|---|---|---|
| start() | 否 | 讓執行緒啟動,進入就緒狀態,等待cpu分配時間片 |
| run() | 否 | 重寫Runnable介面的方法,執行緒獲取到cpu時間片時執行的具體邏輯 |
| yield() | 是 | 執行緒的禮讓,使得獲取到cpu時間片的執行緒進入就緒狀態,重新爭搶時間片 |
| sleep(time) | 是 | 執行緒休眠固定時間,進入阻塞狀態,休眠時間完成后重新爭搶時間片,休眠可被打斷 |
| join()/join(time) | 否 | 呼叫執行緒物件的join方法,呼叫者執行緒進入阻塞,等待執行緒物件執行完或者到達指定時間才恢復,重新爭搶時間片 |
| isInterrupted() | 否 | 獲取執行緒的打斷標記,true:被打斷,false:沒有被打斷,呼叫后不會修改打斷標記 |
| interrupt() | 否 | 打斷執行緒,拋出InterruptedException例外的方法均可被打斷,但是打斷后不會修改打斷標記,正常執行的執行緒被打斷后會修改打斷標記 |
| interrupted() | 否 | 獲取執行緒的打斷標記,呼叫后會清空打斷標記 |
| stop() | 否 | 停止執行緒運行 不推薦 |
| suspend() | 否 | 掛起執行緒 不推薦 |
| resume() | 否 | 恢復執行緒運行 不推薦 |
| currentThread() | 是 | 獲取當前執行緒 |
Object中與執行緒相關方法
| 方法名稱 | 方法說明 |
|---|---|
| wait()/wait(long timeout) | 獲取到鎖的執行緒進入阻塞狀態 |
| notify() | 隨機喚醒被wait()的一個執行緒 |
| notifyAll(); | 喚醒被wait()的所有執行緒,重新爭搶時間片 |
同步鎖
執行緒安全
- 一個程式運行多個執行緒本身是沒有問題的
- 問題有可能出現在多個執行緒訪問共享資源
- 多個執行緒都是讀共享資源也是沒有問題的
- 當多個執行緒讀寫共享資源時,如果發生指令交錯,就會出現問題
臨界區: 一段代碼如果對共享資源的多執行緒讀寫操作,這段代碼就被稱為臨界區,
注意的是 指令交錯指的是 java代碼在決議成位元組碼檔案時,java代碼的一行代碼在位元組碼中可能有多行,在執行緒背景關系切換時就有可能交錯,
執行緒安全指的是多執行緒呼叫同一個物件的臨界區的方法時,物件的屬性值一定不會發生錯誤,這就是保證了執行緒安全,
如下面不安全的代碼
// 物件的成員變數
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
// t1執行緒對變數+5000次
Thread t1 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
count++;
}
});
// t2執行緒對變數-5000次
Thread t2 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
count--;
}
});
t1.start();
t2.start();
// 讓t1 t2都執行完
t1.join();
t2.join();
System.out.println(count);
}
// 運行結果
-1399
上面的代碼 兩個執行緒,一個+5000次,一個-5000次,如果執行緒安全,count的值應該還是0,
但是運行很多次,每次的結果不同,且都不是0,所以是執行緒不安全的,
執行緒安全的類一定所有的操作都執行緒安全嗎?
開發中經常會說到一些執行緒安全的類,如ConcurrentHashMap,執行緒安全指的是類里每一個獨立的方法是執行緒安全的,但是方法的組合就不一定是執行緒安全的,
成員變數和靜態變數是否執行緒安全?
- 如果沒有多執行緒共享,則執行緒安全
- 如果存在多執行緒共享
- 多執行緒只有讀操作,則執行緒安全
- 多執行緒存在寫操作,寫操作的代碼又是臨界區,則執行緒不安全
區域變數是否執行緒安全?
- 區域變數是執行緒安全的
- 區域變數參考的物件未必是執行緒安全的
- 如果該物件沒有逃離該方法的作用范圍,則執行緒安全
- 如果該物件逃離了該方法的作用范圍,比如:方法的回傳值,需要考慮執行緒安全
synchronized
同步鎖也叫物件鎖,是鎖在物件上的,不同的物件就是不同的鎖,
該關鍵字是用于保證執行緒安全的,是阻塞式的解決方案,
讓同一個時刻最多只有一個執行緒能持有物件鎖,其他執行緒在想獲取這個物件鎖就會被阻塞,不用擔心背景關系切換的問題,
注意: 不要理解為一個執行緒加了鎖 ,進入 synchronized代碼塊中就會一直執行下去,如果時間片切換了,也會執行其他執行緒,再切換回來會緊接著執行,只是不會執行到有競爭鎖的資源,因為當前執行緒還未釋放鎖,
當一個執行緒執行完synchronized的代碼塊后 會喚醒正在等待的執行緒
synchronized實際上使用物件鎖保證臨界區的原子性 臨界區的代碼是不可分割的 不會因為執行緒切換所打斷
基本使用
// 加在方法上 實際是對this物件加鎖
private synchronized void a() {
}
// 同步代碼塊,鎖物件可以是任意的,加在this上 和a()方法作用相同
private void b(){
synchronized (this){
}
}
// 加在靜態方法上 實際是對類物件加鎖
private synchronized static void c() {
}
// 同步代碼塊 實際是對類物件加鎖 和c()方法作用相同
private void d(){
synchronized (TestSynchronized.class){
}
}
// 上述b方法對應的位元組碼原始碼 其中monitorenter就是加鎖的地方
0 aload_0
1 dup
2 astore_1
3 monitorenter
4 aload_1
5 monitorexit
6 goto 14 (+8)
9 astore_2
10 aload_1
11 monitorexit
12 aload_2
13 athrow
14 return
執行緒安全的代碼
private static int count = 0;
private static Object lock = new Object();
private static Object lock2 = new Object();
// t1執行緒和t2物件都是對同一物件加鎖,保證了執行緒安全,此段代碼無論執行多少次,結果都是0
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
synchronized (lock) {
count++;
}
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
synchronized (lock) {
count--;
}
}
});
t1.start();
t2.start();
// 讓t1 t2都執行完
t1.join();
t2.join();
System.out.println(count);
}
重點:加鎖是加在物件上,一定要保證是同一物件,加鎖才能生效
執行緒通信
wait+notify
執行緒間通信可以通過共享變數+wait()¬ify()來實作
wait()將執行緒進入阻塞狀態,notify()將執行緒喚醒
當多執行緒競爭訪問物件的同步方法時,鎖物件會關聯一個底層的Monitor物件(重量級鎖的實作)
如下圖所示 Thread0,1先競爭到鎖執行了代碼后,2,3,4,5執行緒同時來執行臨界區的代碼,開始競爭鎖
- Thread-0先獲取到物件的鎖,關聯到monitor的owner,同步代碼塊內呼叫了鎖物件的wait()方法,呼叫后會進入waitSet等待,Thread-1同樣如此,此時Thread-0的狀態為Waitting
- Thread2、3、4、5同時競爭,2獲取到鎖后,關聯了monitor的owner,3、4、5只能進入EntryList中等待,此時2執行緒狀態為 Runnable,3、4、5狀態為Blocked
- 2執行后,喚醒entryList中的執行緒,3、4、5進行競爭鎖,獲取到的執行緒即會關聯monitor的owner
- 3、4、5執行緒在執行程序中,呼叫了鎖物件的notify()或notifyAll()時,會喚醒waitSet的執行緒,喚醒的執行緒進入entryList等待重新競爭鎖
注意:
-
Blocked狀態和Waitting狀態都是阻塞狀態
-
Blocked執行緒會在owner執行緒釋放鎖時喚醒
-
wait和notify使用場景是必須要有同步,且必須獲得物件的鎖才能呼叫,使用鎖物件去呼叫,否則會拋例外
- wait() 釋放鎖 進入 waitSet 可傳入時間,如果指定時間內未被喚醒 則自動喚醒
- notify()隨機喚醒一個waitSet里的執行緒
- notifyAll()喚醒waitSet中所有的執行緒
static final Object lock = new Object();
new Thread(() -> {
synchronized (lock) {
log.info("開始執行");
try {
// 同步代碼內部才能呼叫
lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
log.info("繼續執行核心邏輯");
}
}, "t1").start();
new Thread(() -> {
synchronized (lock) {
log.info("開始執行");
try {
lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
log.info("繼續執行核心邏輯");
}
}, "t2").start();
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
log.info("開始喚醒");
synchronized (lock) {
// 同步代碼內部才能呼叫
lock.notifyAll();
}
// 執行結果
14:29:47.138 [t1] INFO TestWaitNotify - 開始執行
14:29:47.141 [t2] INFO TestWaitNotify - 開始執行
14:29:49.136 [main] INFO TestWaitNotify - 開始喚醒
14:29:49.136 [t2] INFO TestWaitNotify - 繼續執行核心邏輯
14:29:49.136 [t1] INFO TestWaitNotify - 繼續執行核心邏輯
wait 和 sleep的區別?
二者都會讓執行緒進入阻塞狀態,有以下區別
- wait是Object的方法 sleep是Thread的方法
- wait會立即釋放鎖 sleep不會釋放鎖
- wait后執行緒的狀態是Watting sleep后執行緒的狀態為 Time_Waiting
park&unpark
LockSupport是juc下的工具類,提供了park和unpark方法,可以實作執行緒通信
與wait和notity相比的不同點
- wait 和notify需要獲取物件鎖 park unpark不要
- unpark 可以指定喚醒執行緒 notify隨機喚醒
- park和unpark的順序可以先unpark wait和notify的順序不能顛倒
生產者消費者模型
指的是有生產者來生產資料,消費者來消費資料,生產者生產滿了就不生產了,通知消費者取,等消費了再進行生產,
消費者消費不到了就不消費了,通知生產者生產,生產到了再繼續消費,
public static void main(String[] args) throws InterruptedException {
MessageQueue queue = new MessageQueue(2);
// 三個生產者向佇列里存值
for (int i = 0; i < 3; i++) {
int id = i;
new Thread(() -> {
queue.put(new Message(id, "值" + id));
}, "生產者" + i).start();
}
Thread.sleep(1000);
// 一個消費者不停的從佇列里取值
new Thread(() -> {
while (true) {
queue.take();
}
}, "消費者").start();
}
}
// 訊息佇列被生產者和消費者持有
class MessageQueue {
private LinkedList<Message> list = new LinkedList<>();
// 容量
private int capacity;
public MessageQueue(int capacity) {
this.capacity = capacity;
}
/**
* 生產
*/
public void put(Message message) {
synchronized (list) {
while (list.size() == capacity) {
log.info("佇列已滿,生產者等待");
try {
list.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
list.addLast(message);
log.info("生產訊息:{}", message);
// 生產后通知消費者
list.notifyAll();
}
}
public Message take() {
synchronized (list) {
while (list.isEmpty()) {
log.info("佇列已空,消費者等待");
try {
list.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
Message message = list.removeFirst();
log.info("消費訊息:{}", message);
// 消費后通知生產者
list.notifyAll();
return message;
}
}
}
// 訊息
class Message {
private int id;
private Object value;
}
同步鎖案例
為了更形象的表達加同步鎖的概念,這里舉一個生活中的例子,盡量把以上的概念具體化出來,
這里舉一個每個人非常感興趣的一件東西, 錢!!!(馬老師除外),
現實中,我們去銀行門口的自動取款機取錢,取款機的錢就是共享變數,為了保障安全,不可能兩個陌生人同時進入同一個取款機內取錢,所以只能一個人進入取錢,然后鎖上取款機的門,其他人只能在取款機門口等待,
取款機有多個,里面的錢互不影響,鎖也有多個(多個物件鎖),取錢人在多個取款機里同時取錢也沒有安全問題,
假如每個取錢的陌生人都是執行緒,當取錢人進入取款機鎖了門后(執行緒獲得鎖),取到錢后出門(執行緒釋放鎖),下一個人競爭到鎖來取錢,
假設作業人員也是一個執行緒,如果取錢人進入后發現取款機錢不足了,這時通知作業人員來向取款機里加錢(呼叫notifyAll方法),取錢人暫停取錢,進入銀行大堂阻塞等待(呼叫wait方法),
銀行大堂里的作業人員和取錢人都被喚醒,重新競爭鎖,進入后如果是取錢人,由于取款機沒錢,還得進入銀行大堂等待,
當作業人員獲得取款機的鎖進入后,加了錢后會通知大廳里的人來取錢(呼叫notifyAll方法),自己暫停加錢,進入銀行大堂等待喚醒加錢(呼叫wait方法),
這時大堂里等待的人都來競爭鎖,誰獲取到誰進入繼續取錢,
和現實中不同的就是這里沒有排隊的概念,誰搶到鎖誰進去取,
ReentrantLock
可重入鎖 : 一個執行緒獲取到物件的鎖后,執行方法內部在需要獲取鎖的時候是可以獲取到的,如以下代碼
private static final ReentrantLock LOCK = new ReentrantLock();
private static void m() {
LOCK.lock();
try {
log.info("begin");
// 呼叫m1()
m1();
} finally {
// 注意鎖的釋放
LOCK.unlock();
}
}
public static void m1() {
LOCK.lock();
try {
log.info("m1");
m2();
} finally {
// 注意鎖的釋放
LOCK.unlock();
}
}
synchronized 也是可重入鎖,ReentrantLock有以下優點
- 支持獲取鎖的超時時間
- 獲取鎖時可被打斷
- 可設為公平鎖
- 可以有不同的條件變數,即有多個waitSet,可以指定喚醒
api
// 默認非公平鎖,引數傳true 表示未公平鎖
ReentrantLock lock = new ReentrantLock(false);
// 嘗試獲取鎖
lock()
// 釋放鎖 應放在finally塊中 必須執行到
unlock()
try {
// 獲取鎖時可被打斷,阻塞中的執行緒可被打斷
LOCK.lockInterruptibly();
} catch (InterruptedException e) {
return;
}
// 嘗試獲取鎖 獲取不到就回傳false
LOCK.tryLock()
// 支持超時時間 一段時間沒獲取到就回傳false
tryLock(long timeout, TimeUnit unit)
// 指定條件變數 休息室 一個鎖可以創建多個休息室
Condition waitSet = ROOM.newCondition();
// 釋放鎖 進入waitSet等待 釋放后其他執行緒可以搶鎖
yanWaitSet.await()
// 喚醒具體休息室的執行緒 喚醒后 重寫競爭鎖
yanWaitSet.signal()
實體:一個執行緒輸出a,一個執行緒輸出b,一個執行緒輸出c,abc按照順序輸出,連續輸出5次
這個考的就是執行緒的通信,利用 wait()/notify()和控制變數可以實作,此處使用ReentrantLock即可實作該功能,
public static void main(String[] args) {
AwaitSignal awaitSignal = new AwaitSignal(5);
// 構建三個條件變數
Condition a = awaitSignal.newCondition();
Condition b = awaitSignal.newCondition();
Condition c = awaitSignal.newCondition();
// 開啟三個執行緒
new Thread(() -> {
awaitSignal.print("a", a, b);
}).start();
new Thread(() -> {
awaitSignal.print("b", b, c);
}).start();
new Thread(() -> {
awaitSignal.print("c", c, a);
}).start();
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
awaitSignal.lock();
try {
// 先喚醒a
a.signal();
} finally {
awaitSignal.unlock();
}
}
}
class AwaitSignal extends ReentrantLock {
// 回圈次數
private int loopNumber;
public AwaitSignal(int loopNumber) {
this.loopNumber = loopNumber;
}
/**
* @param print 輸出的字符
* @param current 當前條件變數
* @param next 下一個條件變數
*/
public void print(String print, Condition current, Condition next) {
for (int i = 0; i < loopNumber; i++) {
lock();
try {
try {
// 獲取鎖之后等待
current.await();
System.out.print(print);
} catch (InterruptedException e) {
}
next.signal();
} finally {
unlock();
}
}
}
死鎖
說到死鎖,先舉個例子,
下面是代碼實作
static Beer beer = new Beer();
static Story story = new Story();
public static void main(String[] args) {
new Thread(() ->{
synchronized (beer){
log.info("我有酒,給我故事");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (story){
log.info("小王開始喝酒講故事");
}
}
},"小王").start();
new Thread(() ->{
synchronized (story){
log.info("我有故事,給我酒");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (beer){
log.info("老王開始喝酒講故事");
}
}
},"老王").start();
}
class Beer {
}
class Story{
}
死鎖導致程式無法正常運行下去
檢測工具可以檢查到死鎖資訊
java記憶體模型(JMM)
jmm 體現在以下三個方面
- 原子性 保證指令不會受到背景關系切換的影響
- 可見性 保證指令不會受到cpu快取的影響
- 有序性 保證指令不會受并行優化的影響
可見性
停不下來的程式
static boolean run = true;
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(() -> {
while (run) {
// ....
}
});
t.start();
Thread.sleep(1000);
// 執行緒t不會如預想的停下來
run = false;
}
如上圖所示,執行緒有自己的作業快取,當主執行緒修改了變數并同步到主記憶體時,t執行緒沒有讀取到,所以程式停不下來
有序性
JVM在不影響程式正確性的情況下可能會調整陳述句的執行順序,該情況也稱為 指令重排序
static int i;
static int j;
// 在某個執行緒內執行如下賦值操作
i = ...;
j = ...;
有可能將j先賦值
原子性
原子性大家應該比較熟悉,上述同步鎖的synchronized代碼塊就是保證了原子性,就是一段代碼是一個整體,原子性保證了執行緒安全,不會受到背景關系切換的影響,
volatile
該關鍵字解決了可見性和有序性,volatile通過記憶體屏障來實作的
- 寫屏障
會在物件寫操作之后加寫屏障,會對寫屏障的之前的資料都同步到主存,并且保證寫屏障的執行順序在寫屏障之前
- 讀屏障
會在物件讀操作之前加讀屏障,會在讀屏障之后的陳述句都從主存讀,并保證讀屏障之后的代碼執行在讀屏障之后
注意: volatile不能解決原子性,即不能通過該關鍵字實作執行緒安全,
volatile應用場景:一個執行緒讀取變數,另外的執行緒操作變數,加了該關鍵字后保證寫變數后,讀變數的執行緒可以及時感知,
無鎖-cas
cas (compare and swap) 比較并交換
為變數賦值時,從記憶體中讀取到的值v,獲取到要交換的新值n,執行 compareAndSwap()方法時,比較v和當前記憶體中的值是否一致,如果一致則將n和v交換,如果不一致,則自旋重試,
cas底層是cpu層面的,即不使用同步鎖也可以保證操作的原子性,
private AtomicInteger balance;
// 模擬cas的具體操作
@Override
public void withdraw(Integer amount) {
while (true) {
// 獲取當前值
int pre = balance.get();
// 進行操作后得到新值
int next = pre - amount;
// 比較并設定成功 則中斷 否則自旋重試
if (balance.compareAndSet(pre, next)) {
break;
}
}
}
無鎖的效率是要高于之前的鎖的,由于無鎖不會涉及執行緒的背景關系切換
cas是樂觀鎖的思想,sychronized是悲觀鎖的思想
cas適合很少有執行緒競爭的場景,如果競爭很強,重試經常發生,反而降低效率
juc并發包下包含了實作了cas的原子類
- AtomicInteger/AtomicBoolean/AtomicLong
- AtomicIntegerArray/AtomicLongArray/AtomicReferenceArray
- AtomicReference/AtomicStampedReference/AtomicMarkableReference
AtomicInteger
常用api
new AtomicInteger(balance)
get()
compareAndSet(pre, next)
// i.incrementAndGet() ++i
// i.decrementAndGet() --i
// i.getAndIncrement() i++
// i.getAndDecrement() ++i
i.addAndGet()
// 傳入函式式介面 修改i
int getAndUpdate(IntUnaryOperator updateFunction)
// cas 的核心方法
compareAndSet(int expect, int update)
ABA問題
cas存在ABA問題,即比較并交換時,如果原值為A,有其他執行緒將其修改為B,在有其他執行緒將其修改為A,
此時實際發生過交換,但是比較和交換由于值沒改變可以交換成功
解決方式
AtomicStampedReference/AtomicMarkableReference
上面兩個類解決ABA問題,原理就是為物件增加版本號,每次修改時增加版本號,就可以避免ABA問題
或者增加個布爾變數標識,修改后調整布爾變數值,也可以避免ABA問題
執行緒池
執行緒池的介紹
執行緒池是java并發最重要的一個知識點,也是難點,是實際應用最廣泛的,
執行緒的資源很寶貴,不可能無限的創建,必須要有管理執行緒的工具,執行緒池就是一種管理執行緒的工具,java開發中經常有池化的思想,如 資料庫連接池、Redis連接池等,
預先創建好一些執行緒,任務提交時直接執行,既可以節約創建執行緒的時間,又可以控制執行緒的數量,
執行緒池的好處
- 降低資源消耗,通過池化思想,減少創建執行緒和銷毀執行緒的消耗,控制資源
- 提高回應速度,任務到達時,無需創建執行緒即可運行
- 提供更多更強大的功能,可擴展性高
執行緒池的構造方法
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler) {
}
構造器引數的意義
| 引數名 | 引數意義 |
|---|---|
| corePoolSize | 核心執行緒數 |
| maximumPoolSize | 最大執行緒數 |
| keepAliveTime | 救急執行緒的空閑時間 |
| unit | 救急執行緒的空閑時間單位 |
| workQueue | 阻塞佇列 |
| threadFactory | 創建執行緒的工廠,主要定義執行緒名 |
| handler | 拒絕策略 |
執行緒池案例
下面 我們通過一個實體來理解執行緒池的引數以及執行緒池的接收任務的程序
如上圖 銀行辦理業務,
- 客戶到銀行時,開啟柜臺進行辦理,柜臺相當于執行緒,客戶相當于任務,有兩個是常開的柜臺,三個是臨時柜臺,2就是核心執行緒數,5是最大執行緒數,即有兩個核心執行緒
- 當柜臺開到第二個后,都還在處理業務,客戶再來就到排隊大廳排隊,排隊大廳只有三個座位,
- 排隊大廳坐滿時,再來客戶就繼續開柜臺處理,目前最大有三個臨時柜臺,也就是三個救急執行緒
- 此時再來客戶,就無法正常為其 提供業務,采用拒絕策略來處理它們
- 當柜臺處理完業務,就會從排隊大廳取任務,當柜臺隔一段空閑時間都取不到任務時,如果當前執行緒數大于核心執行緒數時,就會回收執行緒,即撤銷該柜臺,
執行緒池的狀態
執行緒池通過一個int變數的高3位來表示執行緒池的狀態,低29位來存盤執行緒池的數量
| 狀態名稱 | 高三位 | 接收新任務 | 處理阻塞佇列任務 | 說明 |
|---|---|---|---|---|
| Running | 111 | Y | Y | 正常接收任務,正常處理任務 |
| Shutdown | 000 | N | Y | 不會接收任務,會執行完正在執行的任務,也會處理阻塞佇列里的任務 |
| stop | 001 | N | N | 不會接收任務,會中斷正在執行的任務,會放棄處理阻塞佇列里的任務 |
| Tidying | 010 | N | N | 任務全部執行完畢,當前活動執行緒是0,即將進入終結 |
| Termitted | 011 | N | N | 終結狀態 |
// runState is stored in the high-order bits
private static final int RUNNING = -1 << COUNT_BITS;
private static final int SHUTDOWN = 0 << COUNT_BITS;
private static final int STOP = 1 << COUNT_BITS;
private static final int TIDYING = 2 << COUNT_BITS;
private static final int TERMINATED = 3 << COUNT_BITS;
執行緒池的主要流程
執行緒池創建、接收任務、執行任務、回收執行緒的步驟
- 創建執行緒池后,執行緒池的狀態是Running,該狀態下才能有下面的步驟
- 提交任務時,執行緒池會創建執行緒去處理任務
- 當執行緒池的作業執行緒數達到corePoolSize時,繼續提交任務會進入阻塞佇列
- 當阻塞佇列裝滿時,繼續提交任務,會創建救急執行緒來處理
- 當執行緒池中的作業執行緒數達到maximumPoolSize時,會執行拒絕策略
- 當執行緒取任務的時間達到keepAliveTime還沒有取到任務,作業執行緒數大于corePoolSize時,會回收該執行緒
注意: 不是剛創建的執行緒是核心執行緒,后面創建的執行緒是非核心執行緒,執行緒是沒有核心非核心的概念的,這是我長期以來的誤解,
拒絕策略
- 呼叫者拋出RejectedExecutionException (默認策略)
- 讓呼叫者運行任務
- 丟棄此次任務
- 丟棄阻塞佇列中最早的任務,加入該任務
提交任務的方法
// 執行Runnable
public void execute(Runnable command) {
if (command == null)
throw new NullPointerException();
int c = ctl.get();
if (workerCountOf(c) < corePoolSize) {
if (addWorker(command, true))
return;
c = ctl.get();
}
if (isRunning(c) && workQueue.offer(command)) {
int recheck = ctl.get();
if (! isRunning(recheck) && remove(command))
reject(command);
else if (workerCountOf(recheck) == 0)
addWorker(null, false);
}
else if (!addWorker(command, false))
reject(command);
}
// 提交Callable
public <T> Future<T> submit(Callable<T> task) {
if (task == null) throw new NullPointerException();
// 內部構建FutureTask
RunnableFuture<T> ftask = newTaskFor(task);
execute(ftask);
return ftask;
}
// 提交Runnable,指定回傳值
public Future<?> submit(Runnable task) {
if (task == null) throw new NullPointerException();
// 內部構建FutureTask
RunnableFuture<Void> ftask = newTaskFor(task, null);
execute(ftask);
return ftask;
}
// 提交Runnable,指定回傳值
public <T> Future<T> submit(Runnable task, T result) {
if (task == null) throw new NullPointerException();
// 內部構建FutureTask
RunnableFuture<T> ftask = newTaskFor(task, result);
execute(ftask);
return ftask;
}
protected <T> RunnableFuture<T> newTaskFor(Runnable runnable, T value) {
return new FutureTask<T>(runnable, value);
}
Execetors創建執行緒池
注意: 下面幾種方式都不推薦使用
1.newFixedThreadPool
public static ExecutorService newFixedThreadPool(int nThreads) {
return new ThreadPoolExecutor(nThreads, nThreads,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>());
}
- 核心執行緒數 = 最大執行緒數 沒有救急執行緒
- 阻塞佇列無界 可能導致oom
2.newCachedThreadPool
public static ExecutorService newCachedThreadPool() {
return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
60L, TimeUnit.SECONDS,
new SynchronousQueue<Runnable>());
}
- 核心執行緒數是0,最大執行緒數無限制 ,救急執行緒60秒回收
- 佇列采用 SynchronousQueue 實作 沒有容量,即放入佇列后沒有執行緒來取就放不進去
- 可能導致執行緒數過多,cpu負擔太大
3.newSingleThreadExecutor
public static ExecutorService newSingleThreadExecutor() {
return new FinalizableDelegatedExecutorService
(new ThreadPoolExecutor(1, 1,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>()));
}
- 核心執行緒數和最大執行緒數都是1,沒有救急執行緒,無界佇列 可以不停的接收任務
- 將任務串行化 一個個執行, 使用包裝類是為了屏蔽修改執行緒池的一些引數 比如 corePoolSize
- 如果某執行緒拋出例外了,會重新創建一個執行緒繼續執行
- 可能造成oom
4.newScheduledThreadPool
public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) {
return new ScheduledThreadPoolExecutor(corePoolSize);
}
- 任務調度的執行緒池 可以指定延遲時間呼叫,可以指定隔一段時間呼叫
執行緒池的關閉
shutdown()
會讓執行緒池狀態為shutdown,不能接收任務,但是會將作業執行緒和阻塞佇列里的任務執行完 相當于優雅關閉
public void shutdown() {
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
checkShutdownAccess();
advanceRunState(SHUTDOWN);
interruptIdleWorkers();
onShutdown(); // hook for ScheduledThreadPoolExecutor
} finally {
mainLock.unlock();
}
tryTerminate();
}
shutdownNow()
會讓執行緒池狀態為stop, 不能接收任務,會立即中斷執行中的作業執行緒,并且不會執行阻塞佇列里的任務, 會回傳阻塞佇列的任務串列
public List<Runnable> shutdownNow() {
List<Runnable> tasks;
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
checkShutdownAccess();
advanceRunState(STOP);
interruptWorkers();
tasks = drainQueue();
} finally {
mainLock.unlock();
}
tryTerminate();
return tasks;
}
執行緒池的正確使用姿勢
執行緒池難就難在引數的配置,有一套理論配置引數
cpu密集型 : 指的是程式主要發生cpu的運算
? 核心執行緒數: CPU核心數+1
IO密集型: 遠程呼叫RPC,操作資料庫等,不需要使用cpu進行大量的運算, 大多數應用的場景
? 核心執行緒數=核數*cpu期望利用率 *總時間/cpu運算時間
但是基于以上理論還是很難去配置,因為cpu運算時間不好估算
實際配置大小可參考下表
| cpu密集型 | io密集型 | |
|---|---|---|
| 執行緒數數量 | 核數<=x<=核數*2 | 核心數*50<=x<=核心數 *100 |
| 佇列長度 | y>=100 | 1<=y<=10 |
1.執行緒池引數通過分布式配置,修改配置無需重啟應用
執行緒池引數是根據線上的請求數變化而變化的,最好的方式是 核心執行緒數、最大執行緒數 佇列大小都是可配置的
主要配置 corePoolSize maxPoolSize queueSize
java提供了可方法覆寫引數,執行緒池內部會處理好引數 進行平滑的修改
public void setCorePoolSize(int corePoolSize) {
}
2.增加執行緒池的監控
3.io密集型可調整為先新增任務到最大執行緒數后再將任務放到阻塞佇列
代碼 主要可重寫阻塞佇列 加入任務的方法
public boolean offer(Runnable runnable) {
if (executor == null) {
throw new RejectedExecutionException("The task queue does not have executor!");
}
final ReentrantLock lock = this.lock;
lock.lock();
try {
int currentPoolThreadSize = executor.getPoolSize();
// 如果提交任務數小于當前創建的執行緒數, 說明還有空閑執行緒,
if (executor.getTaskCount() < currentPoolThreadSize) {
// 將任務放入佇列中,讓執行緒去處理任務
return super.offer(runnable);
}
// 核心改動
// 如果當前執行緒數小于最大執行緒數,則回傳 false ,讓執行緒池去創建新的執行緒
if (currentPoolThreadSize < executor.getMaximumPoolSize()) {
return false;
}
// 否則,就將任務放入佇列中
return super.offer(runnable);
} finally {
lock.unlock();
}
}
3.拒絕策略 建議使用tomcat的拒絕策略(給一次機會)
// tomcat的原始碼
@Override
public void execute(Runnable command) {
if ( executor != null ) {
try {
executor.execute(command);
} catch (RejectedExecutionException rx) {
// 捕獲到例外后 在從佇列獲取,相當于重試1取不到任務 在執行拒絕任務
if ( !( (TaskQueue) executor.getQueue()).force(command) ) throw new RejectedExecutionException("Work queue full.");
}
} else throw new IllegalStateException("StandardThreadPool not started.");
}
建議修改從佇列取任務的方式: 增加超時時間,超時1分鐘取不到在進行回傳
public boolean offer(E e, long timeout, TimeUnit unit){}
結語
作業三四年了,還沒有正式的寫過博客,自學一直都是通過筆記的方式積累,最近重新學了一下java多執行緒,想著周末把這部分內容認真的寫篇博客分享出去,
文章篇幅較長,給看到這里的小伙伴點個大大的贊!由于作者水平有限,加之第一次寫博客,文章中難免會有錯誤之處,歡迎小伙伴們反饋指正,
如果覺得文章對你有幫助,麻煩 點贊、評論、轉發、在看 走起
你的支持是我最大的動力!!!
轉載請註明出處,本文鏈接:https://www.uj5u.com/qita/11712.html
標籤:其他
上一篇:你說啥什么?注解你還不會?
