Java 多執行緒基礎(上)
技術標籤:java多執行緒java多執行緒thread併發程式設計
多執行緒一直是Java 面試中常考的基礎知識,之前一直沒有系統的學習過,這段時間對著廖雪峰大師的講義從新把該知識內容整理一遍,該文章系列內容全部來源於廖雪峰官方網站Java 基礎教程。
原文連結:Java 多執行緒基礎(上)
系列目錄如下:
1、 執行緒建立
2、 執行緒的狀態
3、 中斷執行緒
4、 守護執行緒
5、 執行緒同步
6、 死鎖
7、 使用wait和notify
1、執行緒建立
//方法一:從Thread派生一個自定義類,然後覆寫run()方法:
class MyThread extends Thread{
@Override
public void run() {
System.out.println("this is MyThread extends Thread ");
}
}
//方法二:建立Thread例項時,傳入一個Runnable例項:
class MyRunnable implements Runnable{
public void run() {
// synchronized ()
System.out.println("this is MyRunnable implements Runnable" );
}
}
//用Java8引入的lambda語法進一步簡寫為:
Thread t3 = new Thread(() -> {
System.out.println("this is lambda func");
});
System.out.println("main start....");
t3.start();
t3.join();//join()函式作用是main執行緒在啟動t執行緒後,可以通過t.join()等待t執行緒結束後再繼續執行,然後才繼續往下執行自身執行緒
使用執行緒執行的列印語句,和直接在main()方法執行有區別嗎?
區別大了去了。我們看以下程式碼:
public class Main {
public static void main(String[] args) {
System.out.println("main start..."); //Main
Thread t = new Thread() { //Main
public void run() {
System.out.println("thread run..."); //t
System.out.println("thread end."); //t
}
};
t.start(); //Main
System.out.println("main end..."); //Main
}
}
我們用藍色表示主執行緒,也就是main執行緒,main執行緒執行的程式碼有4行,首先列印main start,然後建立Thread物件,緊接著呼叫start()啟動新執行緒。當start()方法被呼叫時,JVM就建立了一個新執行緒,我們通過例項變數t來表示這個新執行緒物件,並開始執行。
接著,main執行緒繼續執行列印main end語句,而t執行緒在main執行緒執行的同時會併發執行,列印thread run和thread end語句。當run()方法結束時,新執行緒就結束了。而main()方法結束時,主執行緒也結束了。
我們再來看執行緒的執行順序:
main執行緒肯定是先列印main start,再列印main end;
t執行緒肯定是先列印thread run,再列印thread end。
但是,除了可以肯定,main start會先列印外,main end列印在thread run之前、thread end之後或者之間,都無法確定。因為從t執行緒開始執行以後,兩個執行緒就開始同時運行了,並且由作業系統排程,程式本身無法確定執行緒的排程順序。
執行緒的優先順序
可以對執行緒設定優先順序,設定優先順序的方法是:
Thread.setPriority(int n) // 1~10, 預設值5
小結:
-
Java用Thread物件表示一個執行緒,通過呼叫start()啟動一個新執行緒;
-
一個執行緒物件只能呼叫一次start()方法;
-
執行緒的執行程式碼寫在run()方法中;
-
執行緒排程由作業系統決定,程式本身無法決定排程順序;
-
Thread.sleep()可以把當前執行緒暫停一段時間。
2、執行緒的狀態
在Java程式中,一個執行緒物件只能呼叫一次start()方法啟動新執行緒,並在新執行緒中執行run()方法。一旦run()方法執行完畢,執行緒就結束了。因此,Java執行緒的狀態有以下幾種:
- New:新建立的執行緒,尚未執行;
- Runnable:執行中的執行緒,正在執行run()方法的Java程式碼;
- Blocked:執行中的執行緒,因為某些操作被阻塞而掛起;
- Waiting:執行中的執行緒,因為某些操作在等待中;
- -Timed Waiting:執行中的執行緒,因為執行sleep()方法正在計時等待;
- -Terminated:執行緒已終止,因為run()方法執行完畢。
Java執行緒物件Thread的狀態包括:New、Runnable、Blocked、Waiting、Timed Waiting和Terminated;
通過對另一個執行緒物件呼叫join()方法可以等待其執行結束;
可以指定等待時間,超過等待時間執行緒仍然沒有結束就不再等待;
對已經執行結束的執行緒呼叫join()方法會立刻返回。
3、中斷執行緒
如果執行緒需要執行一個長時間任務,就可能需要能中斷執行緒。中斷執行緒就是其他執行緒給該執行緒發一個訊號,該執行緒收到訊號後結束執行run()方法,使得自身執行緒能立刻結束執行。
中斷一個執行緒非常簡單,只需要在其他執行緒中對目標執行緒呼叫interrupt()方法,目標執行緒需要反覆檢測自身狀態是否是interrupted狀態,如果是,就立刻結束執行。
package com.sun;
/**
* @Auther Mario
* @Date 2020-11-26 20:47
* @Version 1.0
*/
/**
* 測試執行緒中斷
*/
public class testThreadInterrupt {
public static void main(String[] args) throws InterruptedException{
System.out.println("主執行緒執行開始....");
Thread t = new Thread(){
public void run(){
int i = 0;
while(!isInterrupted()){
i++;
System.out.println(i + " hello");
}
}
};
t.start();
/*
Thread.sleep()和t.sleep() 兩種方式不一樣,Thread.sleep()是讓當前主執行緒main()休眠10ms,而t.sleep(10)是讓t執行緒休眠10ms
本程式中測試執行緒中斷應該是讓主執行緒main()休眠,不在繼續指向下一行程式碼t.interrupt(),讓t.start()先跑一會在中斷。
*/
Thread.sleep(10);
// t.sleep(10);
t.interrupt();
t.join();
System.out.println("end.....");
}
}
package com.sun;
/**
* @Auther Mario
* @Date 2020-11-26 20:47
* @Version 1.0
*/
/**
* 測試執行緒中斷
*/
public class testThreadInterrupt2 {
public static void main(String[] args) throws InterruptedException{
System.out.println("主執行緒執行開始....");
Thread t = new MyThread();
t.start();
Thread.sleep(1000);
t.interrupt();
t.join();//等待t執行緒執行完畢
System.out.println("Main end.....");
}
}
class HelloThread extends Thread{
public void run(){
int i = 0;
while(!isInterrupted()){
i++;
System.out.println(i + " hello");
try {
Thread.sleep(100);
} catch (InterruptedException e) {
System.out.println("這是HelloThread 異常");
break;
}
}
}
}
class MyThread extends Thread{
public void run(){
Thread hello = new HelloThread();
hello.start();
try {
hello.join();
} catch (InterruptedException e) {
// e.printStackTrace();
/*
此時的異常是由MyThread 捕獲的
java.lang.InterruptedException
at java.lang.Object.wait(Native Method)
at java.lang.Thread.join(Thread.java:1252)
at java.lang.Thread.join(Thread.java:1326)
- - - - - - - -- -at com.sun.MyThread.run(testThreadInterrupt2.java:51)- - -- - -- - - - -- -
*/
System.out.println("這是MyThread 捕獲interrupted!");
}
hello.interrupt();//沒有這一行,hello執行緒仍然會繼續執行,且JVM不會退出
System.out.println("已經通知hello 執行中斷 ");
}
}
/*
程式碼執行流程,main()主執行緒執行t.interrupt();中斷,此時t執行緒正在執行hello.join(),等待hello執行緒執行完畢,故此時t執行緒會捕獲
InterruptedException異常,列印"這是MyThread 捕獲interrupted!",t執行緒在執行完畢之前會通知hello執行緒中斷hello.interrupt(),
然後主執行緒會都等待t.join()執行完畢,main()再繼續執行。
*/
執行結果如下:
主執行緒執行開始....
1 hello
2 hello
3 hello
4 hello
5 hello
6 hello
7 hello
8 hello
9 hello
10 hello
這是MyThread 捕獲interrupted!
已經通知hello 執行中斷
這是HelloThread 異常
Main end.....
另一個常用的中斷執行緒的方法是設定標誌位。我們通常會用一個running標誌位來標識執行緒是否應該繼續執行,在外部執行緒中,通過把HelloThread.running置為false,就可以讓執行緒結束:
/**
* 測試執行緒中斷
*/
public class testThreadInterruptFlag {
public static void main(String[] args) throws InterruptedException{
System.out.println("主執行緒執行開始....");
HThread hello1 = new HThread();
hello1.start();
Thread.sleep(100);
hello1.running = false;
hello1.join();
System.out.println("main end.....");
}
}
class HThread extends Thread{
/*
執行緒間共享變數需要使用volatile關鍵字標記,確保每個執行緒都能讀取到更新後的變數值。
*/
public volatile boolean running = true;
@Override
public void run() {
int i = 0;
while (running){
i++;
System.out.println(i+ " + hello");
}
System.out.println("hello end...");
}
}
注意到HelloThread的標誌位boolean running是一個執行緒間共享的變數。執行緒間共享變數需要使用volatile關鍵字標記,確保每個執行緒都能讀取到更新後的變數值。
volatile詳解連結如下網易雲課堂筆記總結1
小結
- 對目標執行緒呼叫interrupt()方法可以請求中斷一個執行緒,目標執行緒通過檢測isInterrupted()標誌獲取自身是否已中斷。如果目標執行緒處於等待狀態,該執行緒會捕獲到InterruptedException;
- 目標執行緒檢測到isInterrupted()為true或者捕獲了InterruptedException都應該立刻結束自身執行緒;
- 通過標誌位判斷需要正確使用volatile關鍵字;
- volatile關鍵字解決了共享變數線上程間的可見性問題。
4、守護執行緒
Java程式入口就是由JVM啟動main執行緒,main執行緒又可以啟動其他執行緒。當所有執行緒都執行結束時,JVM退出,程序結束。如果有一個執行緒沒有退出,JVM程序就不會退出。所以,必須保證所有執行緒都能及時結束。
守護執行緒是指為其他執行緒服務的執行緒。在JVM中,所有非守護執行緒都執行完畢後,無論有沒有守護執行緒,虛擬機器都會自動退出。
建立守護執行緒
Thread t = new TestDaemon();
t.setDaemon(true);
System.out.println(t.isDaemon());
t.start();
小結
- 守護執行緒是為其他執行緒服務的執行緒;
- 所有非守護執行緒都執行完畢後,虛擬機器退出;
- 守護執行緒不能持有需要關閉的資源(如開啟檔案等)。
5、執行緒同步
當多個執行緒同時執行時,執行緒的排程由作業系統決定,程式本身無法決定。因此,任何一個執行緒都有可能在任何指令處被作業系統暫停,然後在某個時間段後繼續執行。這個時候,有個單執行緒模型下不存在的問題就來了:如果多個執行緒同時讀寫共享變數,會出現資料不一致的問題。
public class TestThreadSyn {
public static void main(String[] args) throws InterruptedException{
System.out.println("main start...");
for(int i = 0;i<1000;i++){
Thread add = new ADDThread();
Thread dec = new DECThread();
add.start();
dec.start();
add.join();
dec.join();
System.out.println("第" +i + "次" + Counter.counter);
}
System.out.println("main end...");
}
}
class Counter{
public static final Object lock = new Object();
public static int counter = 0;
}
class ADDThread extends Thread{
public void run(){
for(int i = 0;i<10000;i++){
/*
synchronized (Counter.class) 此時用Counter類物件本身也可以鎖
*/
synchronized (Counter.class){
Counter.counter += 1;
}
/*
synchronized (Counter.lock) 此時用的是Counter類中的例項物件,例項物件可以訪問類的靜態變數
*/
// synchronized (Counter.lock){
// Counter.counter += 1;
// }
}
}
}
class DECThread extends Thread{
public void run(){
for(int i = 0;i<10000;i++){
synchronized (Counter.class){
Counter.counter -= 1;
}
}
}
}
//結果:
...
第993次0
第994次0
第995次0
第996次0
第997次0
第998次0
第999次0
main end...
對變數進行讀取和寫入時,結果要正確,必須保證是原子操作。原子操作是指不能被中斷的一個或一系列操作。
例如,對於語句:
n = n + 1;
看上去是一行語句,實際上對應了3條指令:
ILOAD
IADD
ISTORE
我們假設n的值是100,如果兩個執行緒同時執行n = n + 1,得到的結果很可能不是102,而是101,原因在於:
如果執行緒1在執行ILOAD後被作業系統中斷,此刻如果執行緒2被排程執行,它執行ILOAD後獲取的值仍然是100,最終結果被兩個執行緒的ISTORE寫入後變成了101,而不是期待的102。
這說明多執行緒模型下,要保證邏輯正確,對共享變數進行讀寫時,必須保證一組指令以原子方式執行:即某一個執行緒執行時,其他執行緒必須等待:
通過加鎖和解鎖的操作,就能保證3條指令總是在一個執行緒執行期間,不會有其他執行緒會進入此指令區間。即使在執行期執行緒被作業系統中斷執行,其他執行緒也會因為無法獲得鎖導致無法進入此指令區間。只有執行執行緒將鎖釋放後,其他執行緒才有機會獲得鎖並執行。這種加鎖和解鎖之間的程式碼塊我們稱之為臨界區(Critical Section),任何時候臨界區最多隻有一個執行緒能執行。
可見,保證一段程式碼的原子性就是通過加鎖和解鎖實現的。Java程式使用synchronized關鍵字對一個物件進行加鎖:
synchronized(lock) {
n = n + 1;
}
//鎖的錯誤用法
public class TestSyn5 {
public static void main(String[] args) throws Exception {
Thread add = new AddThread();
Thread dec = new DecThread();
add.start();
dec.start();
add.join();
dec.join();
System.out.println(Counter5.count);
}
}
class Counter5 {
public static final Object lock1 = new Object();
public static final Object lock2 = new Object();
public static int count = 0;
}
class AddThread extends Thread {
public void run() {
for (int i=0; i<10000; i++) {
synchronized(Counter5.lock1) {
Counter5.count += 1;
}
}
}
}
class DecThread extends Thread {
public void run() {
for (int i=0; i<10000; i++) {
synchronized(Counter5.lock2) {
Counter5.count -= 1;
}
}
}
}
/*
結果並不是0,這是因為兩個執行緒各自的synchronized鎖住的不是同一個物件!這使得兩個執行緒各自都可以同時獲得鎖:
因為JVM只保證同一個鎖在任意時刻只能被一個執行緒獲取,但兩個不同的鎖在同一時刻可以被兩個執行緒分別獲取。
兩個執行緒可以同時操作counter,例如下圖
*/
執行緒同步例項
package com.sun;
/**
* @Auther mashang
* @Date 2020-11-27 14:50
* @Version 1.0
*/
public class TestSyn3 implements Runnable{
public static int i =0;
/*
不加static 時,synchronized鎖住的物件是this,即當前例項,獲得類例項物件的鎖就可以訪問當前程式碼塊。
但是多個例項同時訪問該類變數時不是執行緒安全的,add()方法對例項物件加鎖,是例項就可以訪問,
鎖是給兩個例項加的鎖,並沒有達到同步的效果
*/
public static synchronized void add(){
i++;
}
// public synchronized void add(){
// i++;
// }
public void run(){
for(int i = 0;i<100000;i++){
add();
}
}
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(new TestSyn3());
Thread t2 = new Thread(new TestSyn3());
t1.start();
t2.start();
t1.join();
t2.join();
// System.out.println("public synchronized void add() 執行結果:" + i);
System.out.println("public static synchronized void add() 執行結果:" + i);
}
}
不加static執行結果
加static執行結果
不需要synchronized的操作JVM規範定義了幾種原子操作:
基本型別(long和double除外)賦值,例如:int n = m;
引用型別賦值,例如:List list = anotherList。
long和double是64位資料,JVM沒有明確規定64位賦值操作是不是一個原子操作,不過在x64平臺的JVM是把long和double的賦值作為原子操作實現的。
單條原子操作的語句不需要同步。例如:
public void set(int m) {
synchronized(lock) {
this.value = m;
}
}
就不需要同步,對引用也是類似。例如:
public void set(String s) {
this.value = s;
}
上述賦值語句並不需要同步,但是,如果是多行賦值語句,就必須保證是同步操作,例如:
class Pair {
int first;
int last;
public void set(int first, int last) {
synchronized(this) {
this.first = first;
this.last = last;
}
}
}
有些時候,通過一些巧妙的轉換,可以把非原子操作變為原子操作。例如,上述程式碼如果改造成:
class Pair {
int[] pair;
public void set(int first, int last) {
int[] ps = new int[] { first, last };
this.pair = ps;
}
}
就不再需要同步,因為this.pair = ps是引用賦值的原子操作。而語句:
int[] ps = new int[] { first, last };
這裡的ps是方法內部定義的區域性變數,每個執行緒都會有各自的區域性變數,互不影響,並且互不可見,並不需要同步。
小結
-
多執行緒同時讀寫共享變數時,會造成邏輯錯誤,因此需要通過synchronized同步;
-
同步的本質就是給指定物件加鎖,加鎖後才能繼續執行後續程式碼;
-
注意加鎖物件必須是同一個例項;
-
對JVM定義的單個原子操作不需要同步。
6、死鎖
Java的執行緒鎖是可重入的鎖。
public class Counter {
private int count = 0;
public synchronized void add(int n) {
if (n < 0) {
dec(-n);
} else {
count += n;
}
}
public synchronized void dec(int n) {
count += n;
}
}
觀察synchronized修飾的add()方法,一旦執行緒執行到add()方法內部,說明它已經獲取了當前例項的this鎖。如果傳入的n < 0,將在add()方法內部呼叫dec()方法。由於dec()方法也需要獲取this鎖,現在問題來了:
對同一個執行緒,能否在獲取到鎖以後繼續獲取同一個鎖?
答案是肯定的。JVM允許同一個執行緒重複獲取同一個鎖,這種能被同一個執行緒反覆獲取的鎖,就叫做可重入鎖。
由於Java的執行緒鎖是可重入鎖,所以,獲取鎖的時候,不但要判斷是否是第一次獲取,還要記錄這是第幾次獲取。每獲取一次鎖,記錄+1,每退出synchronized塊,記錄-1,減到0的時候,才會真正釋放鎖。
死鎖
一個執行緒可以獲取一個鎖後,再繼續獲取另一個鎖。例如:
public void add(int m) {
synchronized(lockA) { // 獲得lockA的鎖
this.value += m;
synchronized(lockB) { // 獲得lockB的鎖
this.another += m;
} // 釋放lockB的鎖
} // 釋放lockA的鎖
}
public void dec(int m) {
synchronized(lockB) { // 獲得lockB的鎖
this.another -= m;
synchronized(lockA) { // 獲得lockA的鎖
this.value -= m;
} // 釋放lockA的鎖
} // 釋放lockB的鎖
}
如何避免死鎖呢?答案是:執行緒獲取鎖的順序要一致。即嚴格按照先獲取lockA,再獲取lockB的順序,改寫dec()方法如下:
public void dec(int m) {
synchronized(lockA) { // 獲得lockA的鎖
this.value -= m;
synchronized(lockB) { // 獲得lockB的鎖
this.another -= m;
} // 釋放lockB的鎖
} // 釋放lockA的鎖
}
7、使用wait和notify
在Java程式中,synchronized解決了多執行緒競爭的問題。例如,對於一個工作管理員,多個執行緒同時往佇列中新增任務,可以用synchronized加鎖:
多執行緒協調執行的原則就是:當條件不滿足時,執行緒進入等待狀態;當條件滿足時,執行緒被喚醒,繼續執行任務。
wait()方法的執行機制非常複雜。首先,它不是一個普通的Java方法,而是定義在Object類的一個native方法,也就是由JVM的C程式碼實現的。其次,必須在synchronized塊中才能呼叫wait()方法,因為wait()方法呼叫時,會釋放執行緒獲得的鎖,wait()方法返回後,執行緒又會重新試圖獲得鎖。只能在鎖物件上呼叫wait()方法。因為在getTask()中,我們獲得了this鎖,因此,只能在this物件上呼叫wait()方法:
public synchronized String getTask() {
while (queue.isEmpty()) {
// 釋放this鎖:
this.wait();
// 重新獲取this鎖
}
return queue.remove();
}
當一個執行緒在this.wait()等待時,它就會釋放this鎖,從而使得其他執行緒能夠在addTask()方法獲得this鎖。
現在我們面臨第二個問題:如何讓等待的執行緒被重新喚醒,然後從wait()方法返回?答案是在相同的鎖物件上呼叫notify()方法。我們修改addTask()如下:
public synchronized void addTask(String s) {
this.queue.add(s);
this.notify(); // 喚醒在this鎖等待的執行緒
}
注意到在往佇列中添加了任務後,執行緒立刻對this鎖物件呼叫notify()方法,這個方法會喚醒一個正在this鎖等待的執行緒(就是在getTask()中位於this.wait()的執行緒),從而使得等待執行緒從this.wait()方法返回。
//完整例項如下:
class TaskQueen{
Queue<String> queue = new LinkedList<>();
public synchronized void addTask(String s){
this.queue.add(s);
this.notifyAll(); 喚醒在this鎖等待的所有執行緒
}
public synchronized String getTask() throws InterruptedException {
while(queue.isEmpty()) {
this.wait();
}
return queue.remove();
}
}
注意到wait()方法返回時需要重新獲得this鎖。
//完整例子
public class TestNotifyAndWait {
public static void main(String[] args) throws InterruptedException {
TaskQueen q = new TaskQueen();
List<Thread> ts = new ArrayList<>();
//多個執行緒同時執行任務
for(int i = 0;i<5;i++){
Thread t = new Thread(){
public void run(){
//一直執行任務
while(true){
try {
String s = q.getTask();
System.out.println("執行成功" + s);
} catch (InterruptedException e) {
return;
}
}
}
};
t.start();
ts.add(t);
}
//一個執行緒負責增加任務
Thread addTask = new Thread(() -> {
for(int i = 0;i<10;i++){
//放入任務
String s = "t - " + Math.random();
System.out.println("增加任務:" + s);
q.addTask(s);
try {
Thread.sleep(100);
} catch (InterruptedException e) {
}
}
});
addTask.start();
addTask.join();
Thread.sleep(100);
for(Thread t:ts) {
t.interrupt();
}
}
}
class TaskQueen{
Queue<String> queue = new LinkedList<>();
public synchronized void addTask(String s){
this.queue.add(s);
this.notifyAll();
}
public synchronized String getTask() throws InterruptedException {
while(queue.isEmpty()) {
this.wait();
}
return queue.remove();
}
}
小結
- wait和notify用於多執行緒協調執行:
- 在synchronized內部可以呼叫wait()使執行緒進入等待狀態;
- 必須在已獲得的鎖物件上呼叫wait()方法;
- 在synchronized內部可以呼叫notify()或notifyAll()喚醒其他等待執行緒;
- 必須在已獲得的鎖物件上呼叫notify()或notifyAll()方法;
- 已喚醒的執行緒還需要重新獲得鎖後才能繼續執行。