1. 程式人生 > 其它 >Java 多執行緒基礎(上)

Java 多執行緒基礎(上)

技術標籤:java多執行緒java多執行緒thread併發程式設計

多執行緒一直是Java 面試中常考的基礎知識,之前一直沒有系統的學習過,這段時間對著廖雪峰大師的講義從新把該知識內容整理一遍,該文章系列內容全部來源於廖雪峰官方網站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

小結

  1. 對目標執行緒呼叫interrupt()方法可以請求中斷一個執行緒,目標執行緒通過檢測isInterrupted()標誌獲取自身是否已中斷。如果目標執行緒處於等待狀態,該執行緒會捕獲到InterruptedException;
  2. 目標執行緒檢測到isInterrupted()為true或者捕獲了InterruptedException都應該立刻結束自身執行緒;
  3. 通過標誌位判斷需要正確使用volatile關鍵字;
  4. volatile關鍵字解決了共享變數線上程間的可見性問題。

4、守護執行緒

Java程式入口就是由JVM啟動main執行緒,main執行緒又可以啟動其他執行緒。當所有執行緒都執行結束時,JVM退出,程序結束。如果有一個執行緒沒有退出,JVM程序就不會退出。所以,必須保證所有執行緒都能及時結束。

守護執行緒是指為其他執行緒服務的執行緒。在JVM中,所有非守護執行緒都執行完畢後,無論有沒有守護執行緒,虛擬機器都會自動退出。

建立守護執行緒

Thread t = new TestDaemon();
        t.setDaemon(true);
        System.out.println(t.isDaemon());
        t.start();

小結

  1. 守護執行緒是為其他執行緒服務的執行緒;
  2. 所有非守護執行緒都執行完畢後,虛擬機器退出;
  3. 守護執行緒不能持有需要關閉的資源(如開啟檔案等)。

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;
            }

        }
    }
}
//結果:
...9930994099509960997099809990
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是方法內部定義的區域性變數,每個執行緒都會有各自的區域性變數,互不影響,並且互不可見,並不需要同步。

小結

  1. 多執行緒同時讀寫共享變數時,會造成邏輯錯誤,因此需要通過synchronized同步;

  2. 同步的本質就是給指定物件加鎖,加鎖後才能繼續執行後續程式碼;

  3. 注意加鎖物件必須是同一個例項;

  4. 對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();
    }
}

小結

  1. wait和notify用於多執行緒協調執行:
  2. 在synchronized內部可以呼叫wait()使執行緒進入等待狀態;
  3. 必須在已獲得的鎖物件上呼叫wait()方法;
  4. 在synchronized內部可以呼叫notify()或notifyAll()喚醒其他等待執行緒;
  5. 必須在已獲得的鎖物件上呼叫notify()或notifyAll()方法;
  6. 已喚醒的執行緒還需要重新獲得鎖後才能繼續執行。