多線程詳解
1. 多線程快速入門
1.1 進程與線程
-
什麼是進程?
CPU從硬盤中讀取一段程序到內存中,該執行程序的實例就叫做進程。
一個程序如果被CPU多次讀取到內存中,則變成多個獨立的進程。
-
什麼是線程?
線程是程序執行的最小單位,在一個進程中可以有多個不同的線程同時執行。
-
為什麼在進程中還需要線程呢?
例如,一個文本編輯器進程,在編輯器中,需要同時做很多事情:監聽用戶按下的鍵盤事件、將文本渲染到屏幕上,將文本內容持久化到硬盤,這三件事就是三個線程。線程是最小的並行單位。
-
為什麼需要使用多線程?
採用多線程的形式執行代碼,目的就是為了提高程序的效率。
比如:一個項目只有一個程序員開發,需要開發的模塊需求有會員模塊、支付模塊、訂單模塊等,該程序員要按順序依次將各個模塊完成。而當有三個程序員同時完成不同的模塊,那麼就可以大大提高開發效率了。
-
串行與並行的區別
串行也就是單線程執行,代碼執行效率非常低,代碼從上到下執行。
並行就是多個線程一起執行,效率比較高。
-
多線程的應用場景有哪些?
- 客戶端(/移動App)開發
- 異步發送短訊/郵件
- 將執行比較耗時的代碼改用多線程異步執行
- 異步寫入日誌 日誌框架底層
- 多線程下載
-
同步與異步的區別
同步:代碼從頭到尾執行
異步:單獨分支執行,相互之間沒有任何影響
1.2 繼承Thread類創建線程
public class ThreadTest01 extends Thread {
/**
* 線程執行的代碼在run方法
*/
@Override
public void run() {
//獲取當前線程名稱
System.out.print(Thread.currentThread().getName());
System.out.println("子線程執行...");
}
public static void main(String[] args) {
//獲取當前線程名稱
System.out.println(Thread.currentThread().getName());
//啟動線程 調用start方法而不是run方法
//調用start()線程不是立即被CPU調度執行。
new ThreadTest01().start();
new ThreadTest01().start();
}
}
1.3 實現Runnable接口創建線程
public class ThreadTest02 implements Runnable {
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + "子線程執行...");
}
public static void main(String[] args) {
//啟動線程
new Thread(new ThreadTest02()).start();
//使用匿名內部類的形式創建線程
new Thread(new Runnable() {
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + "子線程執行...");
}
}).start();
//使用Lambda創建多線程
new Thread(() -> System.out.println(Thread.currentThread().getName() + "子線程執行...")).start();
}
}
1.4 使用Callable和Future創建線程
Callable和Future線程可以獲取到返回結果,拋出異常,底層基於LockSupport
從Java1.5開始,Java提供了Callable接口,該接口是Runnable接口的增強版,Callable提供了一個call()方法,可以看作是線程的執行體,但call()方法比run()方法更強大。
假設有三個連續的代碼塊(代碼塊1,2,3),本屬於單線程(線程1)執行是從頭到尾依次執行,此時要求代碼2使用Callable模式(線程2),也就是使用異步執行且帶返回結果。線程2就會是一個單獨的線程執行:線程1在執行完代碼1執行到代碼2的時候,會單獨創建一個線程,執行代碼2,線程1需要拿到代碼2整個執行的返回結果,在拿到以後線程1繼續執行。
-
call()方法可以有返回值
-
all()方法可以聲明拋出異常
public class ThreadTest03 implements Callable<Integer> { /** * 當前線程需要執行的代碼 返回結果 * * @return * @throws Exception */ @Override public Integer call() throws Exception { System.out.println(Thread.currentThread().getName()+"子線程開始執行..."); try { Thread.sleep(3000); }catch (Exception e){ } System.out.println(Thread.currentThread().getName()+"返回1"); return 1; } }
public class ThreadTest04 { public static void main(String[] args) throws ExecutionException, InterruptedException { ThreadTest03 threadCallable = new ThreadTest03(); FutureTask<Integer> futureTask = new FutureTask<>(threadCallable); new Thread(futureTask).start(); //調用get方法時 主線程阻塞 子線程執行完畢 再喚醒主線程 Integer result = futureTask.get(); System.out.println(Thread.currentThread().getName()+" "+result); } }
1.5 使用線程池創建線程
public static void main(String[] args) {
ExecutorService executorService = Executors.newCachedThreadPool();
executorService.execute(new Runnable() {
@Override
public void run() {
System.out.println(Thread.currentThread().getName()+"開始執行子線程...");
}
});
}
JUC並發中會詳細說明
1.6 @Async異步註解創建線程
項目中會使用Spring的@Async註解和線程池來實現多線程
在方法上添加@Async
註解,當調用此方法時,就會創建新的線程來異步執行此方法。若沒有添加異步註解,順序執行程序,調用到該方法時,如果該方法有sleep,會一直等到該方法執行完畢才會繼續執行。
因此,一般將比較耗時的代碼添加@Async註解。
1.7 線程同步/線程安全性問題
線程如何實現同步?(如何保證線程安全性問題)
核心思想:上鎖。當多個線程共享同一個全局變量時,將可能會發生線程安全的代碼上鎖,最終只能有一個線程能夠獲取到鎖,保證只有拿到鎖的線程才可以執行該代碼,沒有拿到鎖的線程不可以執行,需要經歷鎖的升級過程,如果一直沒有獲取到鎖,則會一直阻塞等待。
如果線程A獲取鎖,但是線程A一直不釋放鎖,線程B就一直獲取不到鎖,會一直阻塞等待。
- 使用synchronized鎖
- 使用Lock鎖(屬於JUC並發包)。底層基於aqs+cas實現
- 使用Threadlocal
- 原子類CAS非阻塞式
2. synchronized鎖
2.1 概述
什麼是線程安全問題?
當多個線程共享同一個全局變量,做寫的操作時,可能會受到其他線程的干擾,就會發生線程安全問題。
public class ThreadCount implements Runnable {
private int count = 100;
@Override
public void run() {
while (true){
if (count > 1) {
try {
//運行狀態->休眠狀態——CPU的執行權讓給其他線程
Thread.sleep(30);
} catch (Exception e) {
e.printStackTrace();
}
count--;
System.out.println(Thread.currentThread().getName() + ":" + count);
}else{
break;
}
}
}
public static void main(String[] args) {
ThreadCount threadCount = new ThreadCount();
//開啟線程
new Thread(threadCount).start();
new Thread(threadCount).start();
}
}
在這個程序中,兩個線程很大概率會同時對count進行操作。
上synchronized鎖:那麼代碼的哪一塊需要上鎖?——可能發生線程安全性問題的代碼需要上鎖
如果將synchronized鎖加在run方法上,那麼就會變成單線程,因為兩個線程有非公平鎖的特性,即誰拿到鎖/搶到鎖,誰就可以執行run方法,誰搶不到,誰就會一直阻塞等待。又因為run方法有死循環,不會釋放鎖,另一個線程就會一直阻塞等待
public class ThreadCount implements Runnable {
private int count = 100;
@Override
public synchronized void run() {
...
}
public static void main(String[] args) {
ThreadCount threadCount = new ThreadCount();
//開啟線程
new Thread(threadCount).start();
new Thread(threadCount).start();
}
}
因此在加鎖的時候並不是一次將整塊代碼都上鎖,可能會使線程變為單線程,而且加鎖後,可能會影響程序的執行效率,因為執行該代碼前要競爭鎖的資源。
正確加鎖:
public class ThreadCount implements Runnable {
private int count = 100;
@Override
public void run() {
while (true){
if (count > 1) {
...
synchronized (this) {
count--;
System.out.println(Thread.currentThread().getName() + ":" + count);
}
}else{
break;
}
}
}
public static void main(String[] args) {
ThreadCount threadCount = new ThreadCount();
//開啟線程
new Thread(threadCount).start(); //線程0
new Thread(threadCount).start(); //線程0
}
}
線程0、線程1同時獲取this鎖,假設線程0獲取到this鎖,意味着線程1沒有獲取到鎖,則會阻塞等待。等線程0執行完count–,釋放鎖之後,就會喚醒線程1重新競爭鎖資源。
synchronized獲取鎖和釋放鎖底層已經由虛擬機實現,會自動獲取鎖、釋放鎖並喚醒其他阻塞線程競爭鎖資源。
2.2 synchronized鎖的基本用法
-
修飾代碼塊,指定加鎖對象,對給定對象加鎖,進入同步代碼塊前要獲得給定對象的鎖
synchronized(對象鎖){ 需要保證線程安全的代碼 }
對象鎖需要保證是同一個對象
比如:
ThreadCount threadCount1 = new ThreadCount(); ThreadCount threadCount2 = new ThreadCount(); //開啟線程 new Thread(threadCount1).start(); new Thread(threadCount2).start();
兩個線程並不是同一個對象鎖,這時也會出現線程安全問題
@Override public void run() { while (true){ cal(); } } public void cal(){ if (count > 1) { try { //運行狀態->休眠狀態——CPU的執行權讓給其他線程 Thread.sleep(30); } catch (Exception e) { e.printStackTrace(); } synchronized (this) { count--; System.out.println(Thread.currentThread().getName() + ":" + count); } } } public static void main(String[] args) { ThreadCount threadCount = new ThreadCount(); //開啟線程 new Thread(threadCount).start(); new Thread(threadCount).start(); }
-
修飾實例方法,作用與當前實例加鎖,進入同步代碼前要獲得當前實例的鎖
@Override public void run() { while (true) { if (count > 1) { try { //運行狀態->休眠狀態——CPU的執行權讓給其他線程 Thread.sleep(30); } catch (Exception e) { e.printStackTrace(); } cal(); } else { break; } } } public synchronized void cal() { count--; System.out.println(Thread.currentThread().getName() + ":" + count); }
將synchronized加在實例方法上,則默認使用的是this鎖
-
修飾靜態方法,作用於當前類對象(當前類.class)加鎖,進入同步代碼前要獲得當前類對象的鎖
2.3 synchronized死鎖問題
我們如果在使用synchronized 需要注意 synchronized鎖嵌套的問題,避免死鎖的問題發生。
案例:
public class DeadlockThread implements Runnable {
private int count = 1;
private String lock = "lock";
@Override
public void run() {
while (true) {
count++;
if (count % 2 == 0) {
// 線程1需要獲取lock鎖 再獲取a方法this鎖
// 線程2需要獲取this鎖 再獲取b方法lock鎖
synchronized (lock) {
a();
}
} else {
synchronized (this) {
b();
}
}
}
}
public synchronized void a() {
System.out.println(Thread.currentThread().getName() + ",a方法...");
}
public void b() {
synchronized (lock) {
System.out.println(Thread.currentThread().getName() + ",b方法...");
}
}
public static void main(String[] args) {
DeadlockThread deadlockThread = new DeadlockThread();
Thread thread1 = new Thread(deadlockThread);
Thread thread2 = new Thread(deadlockThread);
thread1.start();
thread2.start();
}
}
線程1先獲取自定義對象的lock鎖,進入a方法需要獲取this鎖
線程2先獲取this鎖,進入b方法需要獲取自定義對象的lock鎖
當兩個線程同時執行,開始線程1和線程2分別拿到了lock鎖和this鎖,之後兩個線程都需要對方已經持有的鎖,最終出現死鎖問題。
如何排查synchronized死鎖問題
使用synchronized 死鎖診斷工具:JDK安裝目錄\jdk\jdk8\bin\jconsole.exe
3. 線程之間通訊
等待/通知機制
等待/通知的相關方法是任意Java對象都具備的,因為這些方法被定義在所有對象的超類java.lang.Object上,方法如下:
- notify() :通知一個在對象上等待的線程,使其從main()方法返回,而返回的前提是該線程獲取到了對象的鎖
- notifyAll():通知所有等待在該對象的線程
- wait():調用該方法的線程進入WAITING狀態,只有等待其他線程的通知或者被中斷,才會返回。需要注意調用wait()方法後,會釋放對象的鎖 。
注意:wait,notify和notifyAll要與synchronized一起使用
wait/notify的簡單用法
public class Thread03 extends Thread {
@Override
public void run() {
try {
synchronized (this) {
System.out.println(Thread.currentThread().getName() + ">>當前線程阻塞,同時釋放鎖!<<");
this.wait();
}
System.out.println(">>run()<<");
} catch (InterruptedException e) {
}
}
public static void main(String[] args) {
Thread03 thread = new Thread03();
thread.start();
try {
Thread.sleep(3000);
//3s後喚醒子線程
} catch (Exception e) {
}
synchronized (thread) {
// 喚醒正在阻塞的線程
thread.notify();
}
}
}
多線程通訊實現生產者與消費者
看以下案例:
package com.mark.sunchronized;
/**
* @author Mark
* @version 1.0
* @className Thread
* @date 2022/11/6 18:41
*/
public class Thread04 {
/**
* 共享對象Res
*/
class Res {
/**
* 姓名
*/
private String userName;
/**
* 性別
*/
private char sex;
}
/**
* 輸入線程
*/
class InputThread extends Thread {
private Res res;
public InputThread(Res res) {
this.res = res;
}
@Override
public void run() {
int count = 0;
while (true) {
if (count == 0) {
res.userName = "張三";
res.sex = '男';
} else {
res.userName = "李四";
res.sex = '女';
}
count = (count + 1) % 2;
}
}
}
/**
* 輸出線程
*/
class OutPutThread extends Thread {
private Res res;
public OutPutThread(Res res) {
this.res = res;
}
@Override
public void run() {
while (true) {
System.out.println(res.userName + "," + res.sex);
}
}
}
public static void main(String[] args) {
new Thread04().print();
}
private void print() {
//全局對象
Res res = new Res();
//輸入線程
InputThread inputThread = new InputThread(res);
//輸出線程
OutPutThread outPutThread = new OutPutThread(res);
inputThread.start();
outPutThread.start();
}
}
可以發現,輸入輸出線程公用Res對象,該程序存在線程安全問題。
修改:加synchronized鎖
/**
* 輸入線程
*/
class InputThread extends Thread {
private Res res;
public InputThread(Res res) {
this.res = res;
}
@Override
public void run() {
int count = 0;
while (true) {
synchronized (res) {
if (count == 0) {
res.userName = "張三";
res.sex = '男';
} else {
res.userName = "李四";
res.sex = '女';
}
}
count = (count + 1) % 2;
}
}
}
/**
* 輸出線程
*/
class OutPutThread extends Thread {
private Res res;
public OutPutThread(Res res) {
this.res = res;
}
@Override
public void run() {
while (true) {
synchronized (res) {
System.out.println(res.userName + "," + res.sex);
}
}
}
}
那麼如何實現交替進行輸出,而不是一直在一段時間裏輸出相同的姓名性別?
在Res中添加一個flag標記,輸入線程為false,輸出線程為true
/**
* 輸入線程
*/
class InputThread extends Thread {
private Res res;
public InputThread(Res res) {
this.res = res;
}
@Override
public void run() {
int count = 0;
while (true) {
synchronized (res) {
if (res.flag) {
try {
res.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
if (count == 0) {
res.userName = "張三";
res.sex = '男';
} else {
res.userName = "李四";
res.sex = '女';
}
res.flag = true;
//喚醒輸出線程
res.notify();
}
count = (count + 1) % 2;
}
}
}
/**
* 輸出線程
*/
class OutPutThread extends Thread {
private Res res;
public OutPutThread(Res res) {
this.res = res;
}
@Override
public void run() {
while (true) {
synchronized (res) {
//如果 res.flag = false 則輸出的線程主動釋放鎖 也就是讓輸出線程進入WAITING狀態,阻塞輸出線程
if (!res.flag) {
try {
res.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println(res.userName + "," + res.sex);
//輸出完畢,改變狀態
res.flag = false;
res.notify();
}
}
}
}
}
4. 多線程核心API
4.1 Join的底層原理
public static void main(String[] args){
Thread t1 = new Thread(() -> System.out.println(Thread.currentThread().getName() + ",線程執行"), "t1");
Thread t2 = new Thread(() -> System.out.println(Thread.currentThread().getName() + ",線程執行"), "t2");
Thread t3 = new Thread(() -> System.out.println(Thread.currentThread().getName() + ",線程執行"), "t3");
t1.start();
t2.start();
t3.start();
}
執行上述代碼發現,三個進程並不是按start的先後順序啟動。那麼如何實現三個線程按期望的順序去執行呢?
public static void main(String[] args) {
Thread t1 = new Thread(() -> System.out.println(Thread.currentThread().getName() + ",線程執行"), "t1");
Thread t2 = new Thread(() -> {
try {
//t1執行完才執行t2
t1.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + ",線程執行");
}, "t2");
Thread t3 = new Thread(() -> {
try {
//t2執行完才執行t3
t2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + ",線程執行");
}, "t3");
t1.start();
t2.start();
t3.start();
}
Join底層原理是基於wait封裝的,喚醒的代碼在jvm Hotspot 源碼中。jvm在關閉線程之前會檢測線阻塞在t1線程對象上的線程,然後執行notfyAll(),這樣t2就被喚醒了。
4.2 多線程的七種執行狀態
- 初始化狀態
- 就緒狀態
- 運行狀態
- 死亡狀態
- 阻塞狀態
- 等待狀態
- 超時等待
start()
:調用start()方法會使得該線程開始執行,正確啟動線程的方式。、wait()
:調用wait()方法,進入等待狀態,釋放資源,讓出CPU。需要在同步快中調用。sleep()
:調用sleep()方法,進入超時等待,不釋放資源,讓出CPUstop()
:調用sleep()方法,線程停止,線程不安全,不釋放鎖導致死鎖,過時。join()
:調用sleep()方法,線程是同步,它可以使得線程之間的並行執行變為串行執行。yield()
:暫停當前正在執行的線程對象,並執行其他線程,讓出CPU資源可能立刻獲得資源執行。yield()的目的是讓相同優先級的線程之間能適當的輪轉執行notify()
:在鎖池隨機喚醒一個線程。需要在同步快中調用。notifyAll()
:喚醒鎖池裡所有的線程。需要在同步快中調用。
使用sleep方法避免cpu空轉 防止cpu佔用100%
sleep(long millis) 線程睡眠 millis 毫秒
sleep(long millis, int nanos) 線程睡眠 millis 毫秒 + nanos 納秒
public static void main(String[] args) {
new Thread(() -> {
while (true) {
try {
//線程每隔30ms休眠一次
Thread.sleep(30);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();
}
wait/join和sleep之間的區別
sleep(long)方法在睡眠時不釋放對象鎖
Wait(long)方法在等待的過程中釋放對象鎖
join(long)方法先執行另外的一個線程,在等待的過程中釋放對象鎖底層是基於wait封裝的
4.3 守護線程與用戶線程
java中線程分為兩種類型:用戶線程和守護線程。通過Thread.setDaemon(false)
設置為用戶線程;通過Thread.setDaemon(true)
設置為守護線程。如果不設置屬性,默認為用戶線程。
- 守護線程依賴於用戶線程,用戶線程退出了,守護線程就會退出,典型的守護線程如垃圾回收線程。
- 用戶線程是獨立存在的,不會因為其他用戶線程退出而退出。
4.4 安全停止線程
-
調用stop方法(不推薦)
stop:中止線程,並且清除監控器鎖的信息,但是可能導致線程安全問題,JDK不建議用。
destroy: JDK未實現該方法。
-
Interrupt
Interrupt 打斷正在運行或者正在阻塞的線程。
-
如果目標線程在調用Object class的wait()、wait(long)或wait(long, int)、join()、join(long, int)或sleep(long, int)方法時被阻塞,那麼Interrupt會生效,該線程的中斷狀態將被清除,拋出InterruptedException異常。
public class Thread02 extends Thread { @Override public void run() { while (true) { try { System.out.println("1"); Thread.sleep(1000000); System.out.println("2"); } catch (InterruptedException e) { e.printStackTrace(); } } } public static void main(String[] args) { Thread02 thread02 = new Thread02(); thread02.start(); try { Thread.sleep(3000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("中斷..."); thread02.interrupt(); } }
-
如果目標線程是被I/O或者NIO中的Channel所阻塞,同樣,I/O操作會被中斷或者返回特殊異常值。達到終止線程的目的。
如果以上條件都不滿足,則會設置此線程的中斷狀態。
-
-
標誌位
在代碼邏輯中,增加一個判斷,用來控制線程執行的中止。
private volatile boolean isFlag = true; @Override public void run() { while (isFlag) { } } public static void main(String[] args) { Thread07 thread07 = new Thread07(); thread07.start(); // thread07.isFlag = false; }
4.5 多線程優先級
-
在java語言中,每個線程都有一個優先級,當線程調控器有機會選擇新的線程時,線程的優先級越高越有可能先被選擇執行,線程的優先級可以設置1-10,數字越大代表優先級越高
注意:Oracle為Linux提供的java虛擬機中,線程的優先級將被忽略,即所有線程具有相同的優先級。
所以,不要過度依賴優先級。
-
線程的優先級用數字來表示,默認範圍是1到10,即Thread.MIN_PRIORITY到Thread.MAX_PRIORTY.一個線程的默認優先級是5,即Thread.NORM_PRIORTY
-
如果cpu非常繁忙時,優先級越高的線程獲得更多的時間片,但是cpu空閑時,設置優先級幾乎沒有任何作用。
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
int count = 0;
for (; ; ) {
System.out.println(Thread.currentThread().getName() + "," + count++);
}
}, "t1線程:");
Thread t2 = new Thread(() -> {
int count = 0;
for (; ; ) {
System.out.println(Thread.currentThread().getName() + "," + count++);
}
}, "t2線程:");
t1.setPriority(Thread.MIN_PRIORITY);
t1.setPriority(Thread.MAX_PRIORITY);
t1.start();
t2.start();
}
5. Lock鎖的使用
在jdk1.5後新增的ReentrantLock類同樣可達到鎖的效果,且在使用上比synchronized更加靈活。
相關API:
- 使用ReentrantLock實現同步
- lock()方法:上鎖
- unlock()方法:釋放鎖
- 使用Condition實現等待/通知,類似於 wait()和notify()及notifyAll()
- Lock鎖底層基於AQS實現,需要自己封裝實現自旋鎖。
Synchronized屬於JDK關鍵字,底層通過C++JVM虛擬機底層實現
Lock鎖底層基於AQS實現,變為重量級鎖
Synchronized底層原理:鎖的升級過程。推薦使用Synchronized鎖
使用Lock鎖過程中要注意獲取鎖、釋放鎖
5.1 ReentrantLock用法
使用synchronized獲取鎖和釋放鎖全部由虛擬機來完成
而使用Lock鎖需要手動獲取鎖和釋放鎖,需要開發者自己定義
public class Thread04 {
/**
* 定義鎖
*/
private Lock lock = new ReentrantLock();
public static void main(String[] args) {
Thread04 thread04 = new Thread04();
thread04.print1();
try {
Thread.sleep(500);
System.out.println("開始執行線程2搶鎖");
} catch (InterruptedException e) {
e.printStackTrace();
}
thread04.print2();
}
private void print1() {
new Thread((() -> {
//獲取鎖
lock.lock();
System.out.println(Thread.currentThread().getName() + "獲取鎖成功");
}), "t1").start();
}
public void print2() {
new Thread((() -> {
System.out.println("1");
lock.lock();
System.out.println(Thread.currentThread().getName() + "獲取鎖成功");
}), "t2").start();
}
}
/*
t1獲取鎖成功
開始執行線程2搶鎖
1
*/
上述程序中,t1未釋放鎖,則t2無法獲取鎖,阻塞。
因此在獲取鎖後要釋放鎖。
private void print1() {
new Thread((() -> {
try {
//獲取鎖
lock.lock();
System.out.println(Thread.currentThread().getName() + "獲取鎖成功");
} catch (Exception e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}), "t1").start();
}
5.2 Condition用法
Condition
接口提供了與Object阻塞(wait())與喚醒(notify()或notifyAll())相似的功能,只不過Condition
接口提供了更為豐富的功能,如:限定等待時長等
public class Thread05 {
private Lock lock = new ReentrantLock();
/**
* 定義
*/
private Condition condition = lock.newCondition();
public static void main(String[] args) {
Thread05 thread05 = new Thread05();
thread05.cal();
try {
Thread.sleep(3000);
} catch (Exception e) {
}
//釋放鎖
thread05.signal();
}
public void signal() {
try {
//獲取鎖
lock.lock();
//喚醒線程
condition.signal();
} catch (Exception e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
public void cal() {
//喚醒線程
new Thread(() -> {
try {
lock.lock();
System.out.println("1");
//釋放鎖,變為阻塞狀態
condition.await();
System.out.println("2");
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
//釋放鎖
lock.unlock();
}
}).start();
}
}
6.多線程綜合案例實戰
6.1 線程安全性問題分析
分析線程安全性問題需要站在下面幾個維度考慮:
-
位元組碼角度
JVM已經把底層封裝得很好,很難了解底層,因此需要從位元組碼彙編指令分析線程安全性問題
-
上下文切換
單核CPU上的多線程,並不是真正意義上的多線程,而是線程切換實現多線程
-
JMM java內存模型
public class Run extends Thread{
private static int sum = 0;
@Override
public void run() {
sum();
}
public void sum(){
for (int i = 0 ; i <10000; i++){
sum ++;
}
}
public static void main(String[] args) throws InterruptedException {
Run run1 = new Run();
Run run2 = new Run();
run1.start();
run2.start();
run1.join();
run2.join();
System.out.println(sum);
}
}
不考慮線程安全問題,上述代碼應當輸出20000,然而,輸出的卻比20000小。
通過反編譯來查看過程:
- target中找到Run.class文件
- 打開Terminal,將Run.class所在目錄拖到Terminal
- 輸入命令:
javap -p -v Run.class
分析:
共享變量值 sum=0
假設現CPU執行到t1線程,t1線程執行完++但是還沒有保存sum,就切換到t2線程執行,t2線程將靜態變量sum=0改成sum=1,CPU又切換到t1線程,使用之前的sum++ 得到的sum=1賦值給共享變量sum,導致最終結果為sum1,然而現在sum++實際上已經執行了兩次,最終結果卻為1。
6.2 Callable和FutureTask原理分析
public interface MarkCallable<V> {
/**
* 當前線程執行完畢返回的結果
* @return
* @throws Exception
*/
V call();
}
public class MarkFutureTask<V> implements Runnable {
private MarkCallable<V> markCallable;
private Object lock = new Object();
private V result;
public MarkFutureTask(MarkCallable<V> markCallable) {
this.markCallable = markCallable;
}
@Override
public void run() {
//線程需要執行代碼
result = markCallable.call();
//如果子線程執行完畢,喚醒主線程,可以拿到返回結果
synchronized (lock) {
lock.notify();
}
}
public V get() {
//獲取子線程異步執行完畢後的返回結果
//主線程阻塞
synchronized (lock) {
try {
lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
return result;
}
}
public class MarkCallableImpl implements MarkCallable<Integer>{
@Override
public Integer call(){
try {
System.out.println(Thread.currentThread().getName()+",子線程執行");
Thread.sleep(3000);
}catch (Exception e){
}
//耗時代碼執行完畢,返回1
return 1;
}
}
public static void main(String[] args) {
MarkCallableImpl markCallable = new MarkCallableImpl();
MarkFutureTask<Integer> markFutureTask = new MarkFutureTask<Integer>(markCallable);
new Thread(markFutureTask).start();
Integer result = markFutureTask.get();
System.out.println(result);
}
使用LockSupport實現:
LockSupport:不需要實現synchronized即可實現wait和notify相似的操作
public class MarkFutureTask<V> implements Runnable {
private MarkCallable<V> markCallable;
private Object lock = new Object();
private V result;
private Thread currentThread;
public MarkFutureTask(MarkCallable<V> markCallable) {
this.markCallable = markCallable;
}
@Override
public void run() {
//線程需要執行代碼
result = markCallable.call();
if (currentThread != null) {
LockSupport.unpark(currentThread);
}
}
00 public V get() {
//獲取子線程異步執行完畢後的返回結果
//主線程阻塞
currentThread = Thread.currentThread();
LockSupport.park();
return result;
}
}
7. ConcurrentHashMap
7.1 HashTable與HashMap的區別
- 在多線程情況下,同時對一個共享HashMap使用put方法做寫操作,底層會共享一個table數組,發生線程安全問題,在多線程操作中,需要使用synchronized關鍵字。而HashTable線程是安全的,在每個公共方法上都使用了synchronized。
- HashMap是允許key和value為null的,key為null的hash值為0,存在index=0的位置,而HashTable不允許key和value為空
- HashMap需要重新計算hash值作為hashCode,而HashTable直接使用對象的hashCode
- HashMap繼承了AbstractMap類,而HashTable繼承了Didtionary類
7.2 Hashtable集合的缺陷
- 使用傳統的Hashtable保證線程問題,是採用synchronized鎖將整個Hashtable中的數組鎖住,在多線程中只允許一個線程訪問put或get,效率非常低,但是能夠保證線程安全問題。當多個線程對Hashtable在get或put時,會發生this鎖的競爭,多個線程競爭鎖,最終只會有一個線程獲取到this鎖,獲取不到的阻塞等待,最終只能單線程get/put。所以在多線程並不推薦使用Hashtable,因為其效率非常低。
7.3 ConcurrentHashMap1.7實現原理
數據結構實現:數組+Segments分段鎖+HashEntry鏈表實現
鎖的實現:Lock鎖+CAS樂觀鎖+UNSAFE類
擴容實現:支持多個Segment同時擴容
原理就是將大的Hashtable拆分成n多個小的Hashtable集合,默認16個。——分段鎖
分段鎖的核心思想是減少多個線程對鎖的競爭:不會再訪問到同一個Hashtable(每個小的HashTable都有一個獨立鎖,多個線程訪問大的Hashtable,會先根據key計算存放具體小的Hashtable的位置,然後進行操作)
ConcurrentHashMap get()方法沒有鎖的競爭,而Hashtable get()方法有鎖的競爭
而在JDK1.8取消了分段鎖。
在多線程情況下訪問ConcurrentHashMap1.7版本進行操作,如果多個線程操作的key最終計算落地到不同的小的Hashtable集合中,就可以實現多線程同時操作Hashtable而不會發生鎖的競爭。但是如果多個線程操作的key最終計算落地到同一個小的Hashtable集合中就會發生鎖的競爭。
(實際在ConcurrentHashMap中,並不是叫HashTable,而是叫Segments和Segment)
7.4 ConcurrentHashMap的使用
使用方法與HashMap一樣
7.5 手寫ConcurrentHashMap
- 提前創建固定數組容量大小的小的Hashtable集合
- 通過構造函數初始化Hashtable數組
public class MarkConcuurentHashMap<K, V> {
/**
* 創建一個存放小的HashTable集合
*/
private Hashtable<K, V>[] hashTables;
public MarkConcuurentHashMap() {
//默認情況下 初始化16個小的HashTable
hashTables = new Hashtable[16];
for (int i = 0; i < hashTables.length; i++) {
hashTables[i] = new Hashtable<>();
}
}
public void put(K k, V v) {
//先計算key存放到哪個具體小的HashTable集合中
int hashTableIndex = k.hashCode() % hashTables.length;
//將key存入到具體小的HashTable集合中
hashTables[hashTableIndex].put(k, v);
}
public void get(K k) {
//先計算key存放到了哪個具體小的HashTable集合中
int hashTableIndex = k.hashCode() % hashTables.length;
//根據key從具體小的HashTable集合中get
hashTables[hashTableIndex].get(k);
}
}
7.6 分段鎖設計概念
ConcurrentHashMap底層採用分段鎖設計,將一個大的HashTable線程安全的集合拆封成n多個小的HashTable集合,默認初始化16個小的HashTable集合。如果多個線程最終根據key計算出的index值落地到不同的小的HashTable集合,不會發生鎖的競爭,同時支持多個線程訪問ConcurrentHashMap進行寫的操作,效率非常高。
ConcurrentHashMap會計算兩次index值:
- 第一次計算index的值,計算key具體存放到哪個小的HashTable
- 第二次計算index的值,計算key存放到具體小的HashTable對應具體數組index的哪個位置(HashTable底層也是通過數組+鏈表實現的)