20200225 Java 多線程(1)-廖雪峯

Java 多線程(1)-廖雪峯

多線程基礎

進程和線程的關係就是:一個進程能夠包含一個或多個線程,但至少會有一個線程。java

操做系統調度的最小任務單位其實不是進程,而是線程。經常使用的Windows、Linux等操做系統都採用搶佔式多任務,如何調度線程徹底由操做系統決定,程序本身不能決定何時執行,以及執行多長時間。數據庫

Java語言內置了多線程支持:一個Java程序其實是一個JVM進程,JVM進程用一個主線程來執行main()方法,在main()方法內部,咱們又能夠啓動多個線程。此外,JVM還有負責垃圾回收的其餘工做線程等編程

所以,對於大多數Java程序來講,咱們說多任務,其實是說如何使用多線程實現多任務。安全

和單線程相比,多線程編程的特色在於:多線程常常須要讀寫共享數據,而且須要同步。例如,播放電影時,就必須由一個線程播放視頻,另外一個線程播放音頻,兩個線程須要協調運行,不然畫面和聲音就不一樣步。所以,多線程編程的複雜度高,調試更困難。網絡

Java多線程編程的特色又在於:多線程

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

建立新線程

咱們但願新線程能執行指定的代碼,有如下幾種方法:架構

  • 方法一:從Thread派生一個自定義類,而後覆寫run()方法
  • 方法二:建立Thread實例時,傳入一個Runnable實例

直接調用run()方法,至關於調用了一個普通的Java方法,當前線程並無任何改變,也不會啓動新線程。併發

必須調用Thread實例的start()方法才能啓動新線程,若是咱們查看Thread類的源代碼,會看到start()方法內部調用了一個private native void start0()方法,native修飾符表示這個方法是由JVM虛擬機內部的C代碼實現的,不是由Java代碼實現的。性能

線程的優先級

能夠對線程設定優先級,設定優先級的方法是:網站

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

優先級高的線程被操做系統調度的優先級較高,操做系統對高優先級線程可能調度更頻繁,但咱們決不能經過設置優先級來確保高優先級的線程必定會先執行

小結

Java用Thread對象表示一個線程,經過調用start()啓動一個新線程;

一個線程對象只能調用一次start()方法;

線程的執行代碼寫在run()方法中;

線程調度由操做系統決定,程序自己沒法決定調度順序;

Thread.sleep()能夠把當前線程暫停一段時間。

線程的狀態

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

  • New:新建立的線程,還沒有執行;
  • Runnable:運行中的線程,正在執行run()方法的Java代碼;
  • Blocked:運行中的線程,由於某些操做被阻塞而掛起;
  • Waiting:運行中的線程,由於某些操做在等待中;
  • Timed Waiting:運行中的線程,由於執行sleep()方法正在計時等待;
  • Terminated:線程已終止,由於run()方法執行完畢。

用一個狀態轉移圖表示以下:

┌─────────────┐
         │     New     │
         └─────────────┘
                │
                ▼
┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
 ┌─────────────┐ ┌─────────────┐
││  Runnable   │ │   Blocked   ││
 └─────────────┘ └─────────────┘
│┌─────────────┐ ┌─────────────┐│
 │   Waiting   │ │Timed Waiting│
│└─────────────┘ └─────────────┘│
 ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
                │
                ▼
         ┌─────────────┐
         │ Terminated  │
         └─────────────┘

當線程啓動後,它能夠在RunnableBlockedWaitingTimed Waiting這幾個狀態之間切換,直到最後變成Terminated狀態,線程終止。

線程終止的緣由有:

  • 線程正常終止: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線程先打印startt線程再打印hellomain線程最後再打印end

若是t線程已經結束,對實例t調用join()會馬上返回。此外,join(long)的重載方法也能夠指定一個等待時間,超過等待時間後就再也不繼續等待。

小結

Java線程對象Thread的狀態包括:NewRunnableBlockedWaitingTimed WaitingTerminated

經過對另外一個線程對象調用join()方法能夠等待其執行結束;

能夠指定等待時間,超過等待時間線程仍然沒有結束就再也不等待;

對已經運行結束的線程調用join()方法會馬上返回。

中斷線程

若是線程須要執行一個長時間任務,就可能須要能中斷線程。中斷線程就是其餘線程給該線程發一個信號,該線程收到信號後結束執行run()方法,使得自身線程能馬上結束運行。

咱們舉個栗子:假設從網絡下載一個100M的文件,若是網速很慢,用戶等得不耐煩,就可能在下載過程當中點「取消」,這時,程序就須要中斷下載線程的執行。

中斷一個線程很是簡單,只須要在其餘線程中對目標線程調用interrupt()方法,目標線程須要反覆檢測自身狀態是不是interrupted狀態,若是是,就馬上結束運行。

@Slf4j
public class Main {
    public static void main(String[] args) throws InterruptedException {
        Thread t = new MyThread();
        t.start();
        Thread.sleep(10); // 暫停1毫秒
        t.interrupt(); // 中斷t線程
        t.join(); // 等待t線程結束
        log.info("end");
    }
}

@Slf4j
class MyThread extends Thread {
    public void run() {
        int n = 0;
        while (!isInterrupted()) {
            n++;
            log.info(n + " hello!");
        }
    }
}

仔細看上述代碼,main線程經過調用t.interrupt()方法中斷t線程,可是要注意,interrupt()方法僅僅向t線程發出了「中斷請求」,至於t線程是否能馬上響應,要看具體代碼。而t線程的while循環會檢測isInterrupted(),因此上述代碼能正確響應interrupt()請求,使得自身馬上結束運行run()方法。

若是線程處於等待狀態,例如,t.join()會讓main線程進入等待狀態,此時,若是對main線程調用interrupt()join()方法會馬上拋出InterruptedException,所以,目標線程只要捕獲到join()方法拋出的InterruptedException,就說明有其餘線程對其調用了interrupt()方法,一般狀況下該線程應該馬上結束運行。

咱們來看下面的示例代碼:

@Slf4j
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線程結束
        log.info("end");
    }
}

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

@Slf4j
class HelloThread extends Thread {
    public void run() {
        int n = 0;
        while (!isInterrupted()) {
            n++;
            log.info(n + " hello!");
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                log.info("HelloThread interrupted!");
                break;
            }
        }
    }
}

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

另外一個經常使用的中斷線程的方法是設置標誌位。咱們一般會用一個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虛擬機中,變量的值保存在主內存中,可是,當線程訪問變量時,它會先獲取一個副本,並保存在本身的工做內存中。若是線程修改了變量的值,虛擬機會在某個時刻把修改後的值回寫到主內存,可是,這個時間是不肯定的!

┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
           Main Memory
│                               │
   ┌───────┐┌───────┐┌───────┐
│  │ var A ││ var B ││ var C │  │
   └───────┘└───────┘└───────┘
│     │ ▲               │ ▲     │
 ─ ─ ─│─│─ ─ ─ ─ ─ ─ ─ ─│─│─ ─ ─
      │ │               │ │
┌ ─ ─ ┼ ┼ ─ ─ ┐   ┌ ─ ─ ┼ ┼ ─ ─ ┐
      ▼ │               ▼ │
│  ┌───────┐  │   │  ┌───────┐  │
   │ var A │         │ var C │
│  └───────┘  │   │  └───────┘  │
   Thread 1          Thread 2
└ ─ ─ ─ ─ ─ ─ ┘   └ ─ ─ ─ ─ ─ ─ ┘

這會致使若是一個線程更新了某個變量,另外一個線程讀取的值可能仍是更新前的。例如,主內存的變量a = true,線程1執行a = false時,它在此刻僅僅是把變量a的副本變成了false,主內存的變量a仍是true,在JVM把修改後的a回寫到主內存以前,其餘線程讀取到的a的值仍然是true,這就形成了多線程之間共享的變量不一致。

所以,volatile關鍵字的目的是告訴虛擬機:

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

volatile關鍵字解決的是可見性問題:當一個線程修改了某個共享變量的值,其餘線程可以馬上看到修改後的值。

若是咱們去掉volatile關鍵字,運行上述程序,發現效果和帶volatile差很少,這是由於在x86的架構下,JVM回寫主內存的速度很是快,可是,換成ARM的架構,就會有顯著的延遲。

小結

對目標線程調用interrupt()方法能夠請求中斷一個線程,目標線程經過檢測isInterrupted()標誌獲取自身是否已中斷。若是目標線程處於等待狀態,該線程會捕獲到InterruptedException

目標線程檢測到isInterrupted()true或者捕獲了InterruptedException都應該馬上結束自身線程;

經過標誌位判斷須要正確使用volatile關鍵字;

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

守護線程

若是有一個線程沒有退出,JVM進程就不會退出。因此,必須保證全部線程都能及時結束。

然而這類線程常常沒有負責人來負責結束它們。可是,當其餘線程結束時,JVM進程又必需要結束,怎麼辦?

答案是使用守護線程(Daemon Thread)。

守護線程是指爲其餘線程服務的線程。在JVM中,全部非守護線程都執行完畢後,不管有沒有守護線程,虛擬機都會自動退出。

所以,JVM退出時,沒必要關心守護線程是否已結束。

如何建立守護線程呢?方法和普通線程同樣,只是在調用start()方法前,調用setDaemon(true)把該線程標記爲守護線程:

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

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

小結

守護線程是爲其餘線程服務的線程;

全部非守護線程都執行完畢後,虛擬機退出;

守護線程不能持有須要關閉的資源(如打開文件等)。

線程同步

當多個線程同時運行時,線程的調度由操做系統決定,程序自己沒法決定。所以,任何一個線程都有可能在任何指令處被操做系統暫停,而後在某個時間段後繼續執行。

這個時候,有個單線程模型下不存在的問題就來了:若是多個線程同時讀寫共享變量,會出現數據不一致的問題。

咱們來看一個例子:

public class Main {
    public static void main(String[] args) throws Exception {
        AddThread add = new AddThread();
        DecThread 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,可是,每次運行,結果實際上都是不同的。

這是由於對變量進行讀取和寫入時,結果要正確,必須保證是原子操做。原子操做是指不能被中斷的一個或一系列操做。

例如,對於語句:

n = n + 1;

看上去是一行語句,實際上對應了3條指令:

ILOAD
IADD
ISTORE

咱們假設n的值是100,若是兩個線程同時執行n = n + 1,獲得的結果極可能不是102,而是101,緣由在於:

┌───────┐    ┌───────┐
│Thread1│    │Thread2│
└───┬───┘    └───┬───┘
    │            │
    │ILOAD (100) │
    │            │ILOAD (100)
    │            │IADD
    │            │ISTORE (101)
    │IADD        │
    │ISTORE (101)│
    ▼            ▼

若是線程1在執行ILOAD後被操做系統中斷,此刻若是線程2被調度執行,它執行ILOAD後獲取的值仍然是100,最終結果被兩個線程的ISTORE寫入後變成了101,而不是期待的102

這說明多線程模型下,要保證邏輯正確,對共享變量進行讀寫時,必須保證一組指令以原子方式執行:即某一個線程執行時,其餘線程必須等待:

┌───────┐     ┌───────┐
│Thread1│     │Thread2│
└───┬───┘     └───┬───┘
    │             │
    │-- lock --   │
    │ILOAD (100)  │
    │IADD         │
    │ISTORE (101) │
    │-- unlock -- │
    │             │-- lock --
    │             │ILOAD (101)
    │             │IADD
    │             │ISTORE (102)
    │             │-- unlock --
    ▼             ▼

經過加鎖和解鎖的操做,就能保證3條指令老是在一個線程執行期間,不會有其餘線程會進入此指令區間。即便在執行期線程被操做系統中斷執行,其餘線程也會由於沒法得到鎖致使沒法進入此指令區間。只有執行線程將鎖釋放後,其餘線程纔有機會得到鎖並執行。這種加鎖和解鎖之間的代碼塊咱們稱之爲臨界區(Critical Section),任什麼時候候臨界區最多隻有一個線程能執行。

可見,保證一段代碼的原子性就是經過加鎖和解鎖實現的。Java程序使用synchronized關鍵字對一個對象進行加鎖:

synchronized(lock) {
    n = n + 1;
}

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

public class Main {
    public static void main(String[] args) throws Exception {
        AddThread add = new AddThread();
        DecThread 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會下降程序的執行效率。

咱們來歸納一下如何使用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的例子:

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

class Counter {
    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(Counter.lock1) {
                Counter.count += 1;
            }
        }
    }
}

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

結果並非0,這是由於兩個線程各自的synchronized鎖住的不是同一個對象!這使得兩個線程各自均可以同時得到鎖:由於JVM只保證同一個鎖在任意時刻只能被一個線程獲取,但兩個不一樣的鎖在同一時刻能夠被兩個線程分別獲取。

所以,使用synchronized的時候,獲取到的是哪一個鎖很是重要。鎖對象若是不對,代碼邏輯就不對。

咱們再看一個例子:

public class Main {
    public static void main(String[] args) throws Exception {
        Thread[] ts = new Thread[]{new AddStudentThread(), new DecStudentThread(), new AddTeacherThread(), new DecTeacherThread()};
        for (Thread t : ts) {
            t.start();
        }
        for (Thread t : ts) {
            t.join();
        }
        System.out.println(Counter.studentCount);
        System.out.println(Counter.teacherCount);
    }
}

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

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

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

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

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

上述代碼的4個線程對兩個共享變量分別進行讀寫操做,可是使用的鎖都是Counter.lock這一個對象,這就形成了本來能夠併發執行的Counter.studentCount += 1Counter.teacherCount += 1,如今沒法併發執行了,執行效率大大下降。實際上,須要同步的線程能夠分紅兩組:AddStudentThreadDecStudentThreadAddTeacherThreadDecTeacherThread,組之間不存在競爭,所以,應該使用兩個不一樣的鎖,即:

AddStudentThreadDecStudentThread使用lockStudent鎖:

synchronized(Counter.lockStudent) {
    ...
}

AddTeacherThreadDecTeacherThread使用lockTeacher鎖:

synchronized(Counter.lockTeacher) {
    ...
}

這樣才能最大化地提升執行效率。

不須要synchronized的操做

JVM規範定義了幾種原子操做

  • 基本類型(longdouble除外)賦值,例如:int n = m
  • 引用類型賦值,例如:List list = anotherList

longdouble是64位數據,JVM沒有明確規定64位賦值操做是否是一個原子操做,不過在x64平臺的JVM是把longdouble的賦值做爲原子操做實現的。

單條原子操做的語句不須要同步。例如:

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定義的單個原子操做不須要同步。

同步方法

若是一個類被設計爲容許多線程正確訪問,咱們就說這個類就是「線程安全」的(thread-safe),上面的Counter類就是線程安全的。Java標準庫的java.lang.StringBuffer也是線程安全的。

還有一些不變類,例如StringIntegerLocalDate,它們的全部成員變量都是final,多線程同時訪問時只能讀不能寫,這些不變類也是線程安全的。

最後,相似Math這些只提供靜態方法,沒有成員變量的類,也是線程安全的。

除了上述幾種少數狀況,大部分類,例如ArrayList,都是非線程安全的類,咱們不能在多線程中修改它們。可是,若是全部線程都只讀取,不寫入,那麼ArrayList是能夠安全地在線程間共享的。

沒有特殊說明時,一個類默認是非線程安全的。

下面兩種寫法是等價的:

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

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

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

對於static方法,是沒有this實例的,由於static方法是針對類而不是實例。可是咱們注意到任何一個類都有一個由JVM自動建立的Class實例,所以,對static方法添加synchronized,鎖住的是該類的class實例。下面兩種寫法是等價的:

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

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

考察Counterget()方法:

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

就必需要同步了。

小結

synchronized修飾方法能夠把整個方法變爲同步代碼塊,synchronized方法加鎖對象是this

經過合理的設計和數據封裝可讓一個類變爲「線程安全」;

一個類沒有特殊說明,默認不是thread-safe;

多線程可否安全訪問某個非線程安全的實例,須要具體問題具體分析。

死鎖

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

在獲取多個鎖的時候,不一樣線程獲取多個不一樣對象的鎖可能致使死鎖。對於上述代碼,線程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的鎖
}

小結

Java的synchronized鎖是可重入鎖;

死鎖產生的條件是多線程各自持有不一樣的鎖,並互相試圖獲取對方已持有的鎖,致使無限等待;

避免死鎖的方法是多線程獲取鎖的順序要一致。

參考

相關文章
相關標籤/搜索