【廖雪峯官方網站/Java教程】多線程(1)

多線程是Java最基本的一種併發模型,本章我們將詳細介紹Java多線程編程。

1.多線程基礎

1.1.進程

在計算機中,我們把一個任務稱爲一個進程,瀏覽器就是一個進程,視頻播放器是另一個進程,類似的,音樂播放器和Word都是進程。
某些進程內部還需要同時執行多個子任務。例如,我們在使用Word時,Word可以讓我們一邊打字,一邊進行拼寫檢查,同時還可以在後臺進行打印,我們把子任務稱爲線程
進程和線程的關係就是:一個進程可以包含一個或多個線程,但至少會有一個線程
在這裏插入圖片描述
操作系統調度的最小任務單位其實不是進程,而是線程
因爲同一個應用程序,既可以有多個進程,也可以有多個線程,因此,實現多任務的方法,有以下幾種:

1.1.1.多進程模式(每個進程只有一個線程)

在這裏插入圖片描述

1.1.2.多線程模式(一個進程有多個線程)

在這裏插入圖片描述

1.1.3.多進程+多線程模式(複雜度最高)

在這裏插入圖片描述

1.2.進程 vs 線程

進程和線程是包含關係,但是多任務既可以由多進程實現,也可以由單進程內的多線程實現,還可以混合多進程+多線程。
具體採用哪種方式,要考慮到進程和線程的特點。
和多線程相比,多進程的缺點在於:

  • 創建進程比創建線程開銷大,尤其是在Windows系統上;
  • 進程間通信比線程間通信要慢,因爲線程間通信就是讀寫同一個變量,速度很快。

多進程的優點在於:

  • 多進程穩定性比多線程高,因爲在多進程的情況下,一個進程崩潰不會影響其他進程,而在多線程的情況下,任何一個線程崩潰會直接導致整個進程崩潰。

1.3.多線程

Java語言內置了多線程支持:一個Java程序實際上是一個JVM進程,JVM進程用一個主線程來執行main()方法,在main()方法內部,我們又可以啓動多個線程。此外,JVM還有負責垃圾回收的其他工作線程等。
因此,對於大多數Java程序來說,我們說多任務,實際上是說如何使用多線程實現多任務
和單線程相比,多線程編程的特點在於:多線程經常需要讀寫共享數據,並且需要同步。例如,播放電影時,就必須由一個線程播放視頻,另一個線程播放音頻,兩個線程需要協調運行,否則畫面和聲音就不同步。因此,多線程編程的複雜度高,調試更困難。
Java多線程編程的特點又在於:

  • 多線程模型是Java程序最基本的併發模型;
  • 後續讀寫網絡、數據庫、Web開發等都依賴Java多線程模型。

因此,必須掌握Java多線程編程才能繼續深入學習其他內容。

2.創建新線程

Java語言內置了多線程支持。當Java程序啓動的時候,實際上是啓動了一個JVM進程,然後,JVM啓動主線程來執行main()方法。在main()方法中,我們又可以啓動其他線程。
要創建一個新線程非常容易,我們需要實例化一個Thread實例,然後調用它的start()方法:

// 多線程
public class Main {
    public static void main(String[] args) {
        Thread t = new Thread();
        t.start(); // 啓動新線程
    }
}

但是這個線程啓動後實際上什麼也不做就立刻結束了。我們希望新線程能執行指定的代碼,有以下幾種方法。

2.1.新線程執行指定代碼的方法

2.1.1.在Thread的派生類中,覆寫run()

// 多線程
public class Main {
    public static void main(String[] args) {
        Thread t = new MyThread();
        t.start(); // 啓動新線程
    }
}

class MyThread extends Thread {
    @Override
    public void run() {
        System.out.println("start new thread!");
    }
}

執行上述代碼,注意到start()方法會在內部自動調用實例的run()方法。

2.1.2.創建Thread實例,傳入Runnable實例

public class Main {
    public static void main(String[] args) {
        Thread t = new Thread(new MyRunnable());
        t.start(); // 啓動新線程
    }
}

class MyRunnable implements Runnable {
    @Override
    public void run() {
        System.out.println("start new thread!");
    }
}

或者用Java8引入的lambda語法進一步簡寫爲:

public class Main {
    public static void main(String[] args) {
        Thread t = new Thread(() -> {
            System.out.println("start new thread!");
        });
        t.start(); // 啓動新線程
    }
}

2.2.線程執行語句和main()方法執行的區別

有童鞋會問,使用線程執行的打印語句,和直接在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()方法結束時,主線程也結束了。
我們再來看線程的執行順序:

  1. main線程肯定是先打印main start,再打印main end;
  2. t線程肯定是先打印thread run,再打印thread end。

但是,除了可以肯定,main start會先打印外,main end打印在thread run之前、thread end之後或者之間,都無法確定。因爲從t線程開始運行以後,兩個線程就開始同時運行了,並且由操作系統調度,程序本身無法確定線程的調度順序。
要模擬併發執行的效果,我們可以在線程中調用Thread.sleep(),強迫當前線程暫停一段時間:

public class Main {
    public static void main(String[] args) {
        System.out.println("main start...");
        Thread t = new Thread() {
            public void run() {
                System.out.println("thread run...");
                try {
                    Thread.sleep(10);
                } catch (InterruptedException e) {}
                System.out.println("thread end.");
            }
        };
        t.start();
        try {
            Thread.sleep(20);
        } catch (InterruptedException e) {}
        System.out.println("main end...");
    }
}

sleep()傳入的參數是毫秒。調整暫停時間的大小,我們可以看到main線程和t線程執行的先後順序。
要特別注意:直接調用Thread實例的run()方法是無效的

public class Main {
    public static void main(String[] args) {
        Thread t = new MyThread();
        t.run();
    }
}

class MyThread extends Thread {
    public void run() {
        System.out.println("hello");
    }
}

直接調用run()方法,相當於調用了一個普通的Java方法,當前線程並沒有任何改變,也不會啓動新線程。上述代碼實際上是在main()方法內部又調用了run()方法,打印hello語句是在main線程中執行的,沒有任何新線程被創建。
必須調用Thread實例的start()方法才能啓動新線程,如果我們查看Thread類的源代碼,會看到start()方法內部調用了一個private native void start0()方法,native修飾符表示這個方法是由JVM虛擬機內部的C代碼實現的,不是由Java代碼實現的。

2.3.線程的優先級

可以對線程設定優先級,設定優先級的方法是:

Thread.setPriority(int n) // 1~10, 默認值5

優先級高的線程被操作系統調度的優先級較高,操作系統對高優先級線程可能調度更頻繁,但我們決不能通過設置優先級來確保高優先級的線程一定會先執行。

3.線程的狀態

3.1.線程狀態

在Java程序中,一個線程對象只能調用一次start()方法啓動新線程,並在新線程中執行run()方法。一旦run()方法執行完畢,線程就結束了。因此,Java線程的狀態有以下幾種:

  • New:新創建的線程,尚未執行;
  • Runnable:運行中的線程,正在執行run()方法的Java代碼;
  • Blocked:運行中的線程,因爲某些操作被阻塞而掛起;
  • Waiting:運行中的線程,因爲某些操作在等待中;
  • Timed Waiting:運行中的線程,因爲執行sleep()方法正在計時等待;
  • Terminated:線程已終止,因爲run()方法執行完畢。

用一個狀態轉移圖表示如下:
在這裏插入圖片描述
當線程啓動後,它可以在Runnable、Blocked、Waiting和Timed Waiting這幾個狀態之間切換,直到最後變成Terminated狀態,線程終止。

3.2.線程終止的原因

  • 線程正常終止:run()方法執行到return語句返回;
  • 線程意外終止:run()方法因爲未捕獲的異常導致線程終止;
  • 對某個線程的Thread實例調用stop()方法強制終止(強烈不推薦使用)。

一個線程還可以等待另一個線程直到其運行結束。例如,main線程在啓動t線程後,可以通過t.join()等待t線程結束後再繼續運行:

public class Main {
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(() -> {
            System.out.println("hello");
        });
        System.out.println("start");
        t.start();
        t.join();
        System.out.println("end");
    }
}

當main線程對線程對象t調用join()方法時,主線程將等待變量t表示的線程運行結束,即join就是指等待該線程結束,然後才繼續往下執行自身線程。所以,上述代碼打印順序可以肯定是main線程先打印start,t線程再打印hello,main線程最後再打印end。
如果t線程已經結束,對實例t調用join()會立刻返回。此外,join(long)的重載方法也可以指定一個等待時間,超過等待時間後就不再繼續等待。

3.3.小結

  1. Java線程對象Thread的狀態包括:New、Runnable、Blocked、Waiting、Timed Waiting和Terminated;
  2. 通過對另一個線程對象調用join()方法可以等待其執行結束;
  3. 可以指定等待時間,超過等待時間線程仍然沒有結束就不再等待;
  4. 對已經運行結束的線程調用join()方法會立刻返回。

4.中斷線程

中斷線程就是其他線程給該線程發一個信號,該線程收到信號後結束正在執行的run()方法,使得自身線程能立刻結束運行。

4.1.調用interrupt()方法

中斷一個線程非常簡單,只需要在其他線程中對目標線程調用interrupt()方法,目標線程需要反覆檢測自身狀態是否是interrupted狀態,如果是,就立刻結束運行。
我們還是看示例代碼:

public class Main {
    public static void main(String[] args) throws InterruptedException {
        Thread t = new MyThread();
        t.start();
        Thread.sleep(1000);
        t.interrupt(); // 中斷t線程
        t.join(); // 等待t線程結束
        System.out.println("end");
    }
}

class MyThread extends Thread {
    public void run() {
        Thread hello = new HelloThread();
        hello.start(); // 啓動hello線程
        try {
            hello.join(); // 等待hello線程結束
        } catch (InterruptedException e) {
            System.out.println("interrupted!");
        }
        hello.interrupt();
    }
}

class HelloThread extends Thread {
    public void run() {
        int n = 0;
        while (!isInterrupted()) {
            n++;
            System.out.println(n + " hello!");
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                break;
            }
        }
    }
}

main線程通過調用t.interrupt()從而通知t線程中斷,而此時t線程正位於hello.join()的等待中,此方法會立刻結束等待並拋出InterruptedException。由於我們在t線程中捕獲了InterruptedException,因此,就可以準備結束該線程。在t線程結束前,對hello線程也進行了interrupt()調用通知其中斷。如果去掉這一行代碼,可以發現hello線程仍然會繼續運行,且JVM不會退出。

4.2.設置running標誌位

另一個常用的中斷線程的方法是設置標誌位。我們通常會用一個running標誌位來標識線程是否應該繼續運行,在外部線程中,通過把HelloThread.running置爲false,就可以讓線程結束:

public class Main {
    public static void main(String[] args)  throws InterruptedException {
        HelloThread t = new HelloThread();
        t.start();
        Thread.sleep(1);
        t.running = false; // 標誌位置爲false
    }
}

class HelloThread extends Thread {
    public volatile boolean running = true;
    public void run() {
        int n = 0;
        while (running) {
            n ++;
            System.out.println(n + " hello!");
        }
        System.out.println("end!");
    }
}

注意到HelloThread的標誌位boolean running是一個線程間共享的變量。線程間共享變量需要使用volatile關鍵字標記,確保每個線程都能讀取到更新後的變量值。
爲什麼要對線程間共享的變量用關鍵字volatile聲明?這涉及到Java的內存模型。在Java虛擬機中,變量的值保存在主內存中,但是,當線程訪問變量時,它會先獲取一個副本,並保存在自己的工作內存中。如果線程修改了變量的值,虛擬機會在某個時刻把修改後的值回寫到主內存,但是,這個時間是不確定的!
在這裏插入圖片描述
這會導致如果一個線程更新了某個變量,另一個線程讀取的值可能還是更新前的。例如,主內存的變量a = true,線程1執行a = false時,它在此刻僅僅是把變量a的副本變成了false,主內存的變量a還是true,在JVM把修改後的a回寫到主內存之前,其他線程讀取到的a的值仍然是true,這就造成了多線程之間共享的變量不一致。
因此,volatile關鍵字的目的是告訴虛擬機:

  • 每次訪問變量時,總是獲取主內存的最新值;
  • 每次修改變量後,立刻回寫到主內存。

volatile關鍵字解決的是可見性問題:當一個線程修改了某個共享變量的值,其他線程能夠立刻看到修改後的值。
如果我們去掉volatile關鍵字,運行上述程序,發現效果和帶volatile差不多,這是因爲在x86的架構下,JVM回寫主內存的速度非常快,但是,換成ARM的架構,就會有顯著的延遲。

4.3.小結

  1. 對目標線程調用interrupt()方法可以請求中斷一個線程,目標線程通過檢測isInterrupted()標誌獲取自身是否已中斷。如果目標線程處於等待狀態,該線程會捕獲到InterruptedException;

  2. 目標線程檢測到isInterrupted()爲true或者捕獲了InterruptedException都應該立刻結束自身線程;

  3. 通過標誌位判斷需要正確使用volatile關鍵字

  4. volatile關鍵字解決了共享變量在線程間的可見性問題

5.守護線程

5.1.爲何需要守護線程

Java程序入口就是由JVM啓動main線程,main線程又可以啓動其他線程。當所有線程都運行結束時,JVM退出,進程結束。
如果有一個線程沒有退出,JVM進程就不會退出。所以,必須保證所有線程都能及時結束。
但是有一種線程的目的就是無限循環,例如,一個定時觸發任務的線程:

class TimerThread extends Thread {
    @Override
    public void run() {
        while (true) {
            System.out.println(LocalTime.now());
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                break;
            }
        }
    }
}

如果這個線程不結束,JVM進程就無法結束。問題是,由誰負責結束這個線程?
然而這類線程經常沒有負責人來負責結束它們。但是,當其他線程結束時,JVM進程又必須要結束,怎麼辦?
答案是使用守護線程(Daemon Thread)。

5.2.守護線程的概念及創建

守護線程是指爲其他線程服務的線程。在JVM中,所有非守護線程都執行完畢後,無論有沒有守護線程,虛擬機都會自動退出
因此,JVM退出時,不必關心守護線程是否已結束。
如何創建守護線程呢?方法和普通線程一樣,只是在調用start()方法前,調用setDaemon(true)把該線程標記爲守護線程

Thread t = new MyThread();
t.setDaemon(true);
t.start();

在守護線程中,編寫代碼要注意:守護線程不能持有任何需要關閉的資源,例如打開文件等,因爲虛擬機退出時,守護線程沒有任何機會來關閉文件,這會導致數據丟失

5.3.小結

  1. 守護線程是爲其他線程服務的線程;
  2. 所有非守護線程都執行完畢後,虛擬機退出;
  3. 守護線程不能持有需要關閉的資源(如打開文件等)。

6.線程同步

6.1.線程同步背景

當多個線程同時運行時,線程的調度由操作系統決定,程序本身無法決定。因此,任何一個線程都有可能在任何指令處被操作系統暫停,然後在某個時間段後繼續執行。
這個時候,有個單線程模型下不存在的問題就來了:如果多個線程同時讀寫共享變量,會出現數據不一致的問題。
看個例子:

public class Main {
    public static void main(String[] args) throws Exception {
        var add = new AddThread();
        var dec = new DecThread();
        add.start();
        dec.start();
        add.join();
        dec.join();
        System.out.println(Counter.count);
    }
}

class Counter {
    public static int count = 0;
}

class AddThread extends Thread {
    public void run() {
        for (int i=0; i<10000; i++) { Counter.count += 1; }
    }
}

class DecThread extends Thread {
    public void run() {
        for (int i=0; i<10000; i++) { Counter.count -= 1; }
    }
}

上面的代碼很簡單,兩個線程同時對一個int變量進行操作,一個加10000次,一個減10000次,最後結果應該是0,但是,每次運行,結果實際上都是不一樣的。
這是因爲對變量進行讀取和寫入時,結果要正確,必須保證是原子操作。原子操作是指不能被中斷的一個或一系列操作

6.2.synchronized關鍵字

例如,對於語句:

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

synchronized保證了代碼塊在任意時刻最多隻有一個線程能執行。我們把上面的代碼用synchronized改寫如下:

public class Main {
    public static void main(String[] args) throws Exception {
        var add = new AddThread();
        var dec = new DecThread();
        add.start();
        dec.start();
        add.join();
        dec.join();
        System.out.println(Counter.count);
    }
}

class Counter {
    public static final Object lock = new Object();
    public static int count = 0;
}

class AddThread extends Thread {
    public void run() {
        for (int i = 0; i < 10000; i++) {
            synchronized(Counter.lock) {
                Counter.count += 1;
            }
        }
    }
}

class DecThread extends Thread {
    public void run() {
        for (int i = 0; i < 10000; i++) {
            synchronized(Counter.lock) {
                Counter.count -= 1;
            }
        }
    }
}

注意到代碼:

synchronized(Counter.lock) { // 獲取鎖
    ...
} // 釋放鎖

它表示用Counter.lock實例作爲鎖,兩個線程在執行各自的synchronized(Counter.lock) { … }代碼塊時,必須先獲得鎖,才能進入代碼塊進行。執行結束後,在synchronized語句塊結束會自動釋放鎖。這樣一來,對Counter.count變量進行讀寫就不可能同時進行。上述代碼無論運行多少次,最終結果都是0。
使用synchronized解決了多線程同步訪問共享變量的正確性問題。但是,它的缺點是帶來了性能下降。因爲synchronized代碼塊無法併發執行。此外,加鎖和解鎖需要消耗一定的時間,所以,synchronized會降低程序的執行效率。

6.3.如何使用synchronized

  1. 找出修改共享變量的線程代碼塊;
  2. 選擇一個共享實例作爲鎖;
  3. 使用synchronized(lockObject) { … };

在使用synchronized的時候,不必擔心拋出異常。因爲無論是否有異常,都會在synchronized結束處正確釋放鎖:

public void add(int m) {
    synchronized (obj) {
        if (m < 0) {
            throw new RuntimeException();
        }
        this.value += m;
    } // 無論有無異常,都會在此釋放鎖
}

因此,使用synchronized的時候,獲取到的是哪個鎖非常重要。鎖對象如果不對,代碼邏輯就不對。

6.4.不需要synchronized的操作

JVM規範定義了幾種原子操作:

  1. 基本類型(long和double除外)賦值,例如:int n = m;
  2. 引用類型賦值,例如:List<String> 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是方法內部定義的局部變量,每個線程都會有各自的局部變量,互不影響,並且互不可見,並不需要同步。

6.5.小結

  1. 多線程同時讀寫共享變量時,會造成邏輯錯誤,因此需要通過synchronized同步;
  2. 同步的本質就是給指定對象加鎖,加鎖後才能繼續執行後續代碼;
  3. 注意加鎖對象必須是同一個實例;
  4. 對JVM定義的單個原子操作不需要同步。

7.同步方法

7.1.對synchronized的邏輯封裝

我們知道Java程序依靠synchronized對線程進行同步,使用synchronized的時候,鎖住的是哪個對象非常重要。
讓線程自己選擇鎖對象往往會使得代碼邏輯混亂,也不利於封裝。更好的方法是把synchronized邏輯封裝起來。例如,我們編寫一個計數器如下:

public class Counter {
    private int count = 0;

    public void add(int n) {
        synchronized(this) {
            count += n;
        }
    }

    public void dec(int n) {
        synchronized(this) {
            count -= n;
        }
    }

    public int get() {
        return count;
    }
}

這樣一來,線程調用add()、dec()方法時,它不必關心同步邏輯,因爲synchronized代碼塊在add()、dec()方法內部。並且,我們注意到,synchronized鎖住的對象是this,即當前實例,這又使得創建多個Counter實例的時候,它們之間互不影響,可以併發執行:

var c1 = Counter();
var c2 = Counter();

// 對c1進行操作的線程:
new Thread(() -> {
    c1.add();
}).start();
new Thread(() -> {
    c1.dec();
}).start();

// 對c2進行操作的線程:
new Thread(() -> {
    c2.add();
}).start();
new Thread(() -> {
    c2.dec();
}).start();

現在,對於Counter類,多線程可以正確調用。

7.2.線程安全(thread-safe)

如果一個類被設計爲允許多線程正確訪問,我們就說這個類就是“線程安全”的(thread-safe),上面的Counter類就是線程安全的。Java標準庫的java.lang.StringBuffer也是線程安全的。
還有一些不變類,例如String,Integer,LocalDate,它們的所有成員變量都是final多線程同時訪問時只能讀不能寫,這些不變類也是線程安全的。
最後,類似Math這些只提供靜態方法,沒有成員變量的類,也是線程安全的。
除了上述幾種少數情況,大部分類,例如ArrayList,都是非線程安全的類,我們不能在多線程中修改它們。但是,如果所有線程都只讀取,不寫入,那麼ArrayList是可以安全地在線程間共享的。
沒有特殊說明時,一個類默認是非線程安全的
我們再觀察Counter的代碼:

public class Counter {
    public void add(int n) {
        synchronized(this) {
            count += n;
        }
    }
    ...
}

當我們鎖住的是this實例時,實際上可以用synchronized修飾這個方法。下面兩種寫法是等價的:

public void add(int n) {
    synchronized(this) { // 鎖住this
        count += n;
    } // 解鎖
}
public synchronized void add(int n) { // 鎖住this
    count += n;
} // 解鎖

因此,用synchronized修飾的方法就是同步方法,它表示整個方法都必須用this實例加鎖。

7.3.synchronized修飾的static方法

我們再思考一下,如果對一個靜態方法添加synchronized修飾符,它鎖住的是哪個對象?

public synchronized static void test(int n) {
    ...
}

對於static方法,是沒有this實例的,因爲static方法是針對類而不是實例。但是我們注意到任何一個類都有一個由JVM自動創建的Class實例,因此,對static方法添加synchronized,鎖住的是該類的class實例。上述synchronized static方法實際上相當於:

public class Counter {
    public static void test(int n) {
        synchronized(Counter.class) {
            ...
        }
    }
}

我們再考察Counter的get()方法:

public class Counter {
    private int count;

    public int get() {
        return count;
    }
    ...
}

它沒有同步,因爲讀一個int變量不需要同步。
然而,如果我們把代碼稍微改一下,返回一個包含兩個int的對象:

public class Counter {
    private int first;
    private int last;

    public Pair get() {
        Pair p = new Pair();
        p.first = first;
        p.last = last;
        return p;
    }
    ...
}

就必須要同步了。

7.4.小結

  1. 用synchronized修飾方法可以把整個方法變爲同步代碼塊,synchronized方法加鎖對象是this;
  2. 通過合理的設計和數據封裝可以讓一個類變爲“線程安全”;
  3. 一個類沒有特殊說明,默認不是thread-safe;
  4. 多線程能否安全訪問某個非線程安全的實例,需要具體問題具體分析。

8.死鎖

8.1.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的時候,纔會真正釋放鎖。

8.2.死鎖

一個線程可以獲取一個鎖後,再繼續獲取另一個鎖。例如:

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的鎖
}

在獲取多個鎖的時候,不同線程獲取多個不同對象的鎖可能導致死鎖。對於上述代碼,線程1和線程2如果分別執行add()和dec()方法時:

  • 線程1:進入add(),獲得lockA;
  • 線程2:進入dec(),獲得lockB。

隨後:

  • 線程1:準備獲得lockB,失敗,等待中;
  • 線程2:準備獲得lockA,失敗,等待中。

此時,兩個線程各自持有不同的鎖,然後各自試圖獲取對方手裏的鎖,造成了雙方無限等待下去,這就是死鎖。
死鎖發生後,沒有任何機制能解除死鎖,只能強制結束JVM進程。
因此,在編寫多線程應用時,要特別注意防止死鎖。因爲死鎖一旦形成,就只能強制結束進程。
那麼我們應該如何避免死鎖呢?答案是:線程獲取鎖的順序要一致。即嚴格按照先獲取lockA,再獲取lockB的順序,改寫dec()方法如下:

public void dec(int m) {
    synchronized(lockA) { // 獲得lockA的鎖
        this.value -= m;
        synchronized(lockB) { // 獲得lockB的鎖
            this.another -= m;
        } // 釋放lockB的鎖
    } // 釋放lockA的鎖
}

8.3.小結

  1. Java的synchronized鎖是可重入鎖;
  2. 死鎖產生的條件是多線程各自持有不同的鎖,並互相試圖獲取對方已持有的鎖,導致無限等待;
  3. 避免死鎖的方法是多線程獲取鎖的順序要一致。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章