Java高並行學習筆記(二):執行緒安全與ThreadGroup

2021-05-10 21:01:33

1 來源

  • 來源:《Java高並行程式設計詳解 多執行緒與架構設計》,汪文君著
  • 章節:第四、六章

本文是兩章的筆記整理。

2 概述

本文主要講述了synchronized以及ThreadGroup的基本用法。

3 synchronized

3.1 簡介

synchronized可以防止執行緒干擾和記憶體一致性錯誤,具體表現如下:

  • synchronized提供了一種鎖機制,能夠確保共用變數的互斥存取,從而防止資料不一致的問題
  • synchronized包括monitor entermonitor exit兩個JVM指令,能保證在任何時候任何執行緒執行到monitor enter成功之前都必須從主記憶體獲取資料,而不是從快取中,在monitor exit執行成功之後,共用變數被更新後的值必須刷入主記憶體而不是僅僅在快取中
  • synchronized指令嚴格遵循Happens-Beofre規則,一個monitor exit指令之前必定要有一個monitor enter

3.2 基本用法

synchronized的基本用法可以用於對程式碼塊或方法進行修飾,比如:

private final Object MUTEX = new Object();
    
public void sync1(){
    synchronized (MUTEX){
    }
}

public synchronized void sync2(){
}

3.3 位元組碼簡單分析

一個簡單的例子如下:

public class Main {
    private static final Object MUTEX = new Object();

    public static void main(String[] args) throws InterruptedException {
        final Main m = new Main();
        for (int i = 0; i < 5; i++) {
            new Thread(m::access).start();
        }
    }

    public void access(){
        synchronized (MUTEX){
            try{
                TimeUnit.SECONDS.sleep(20);
            }catch (InterruptedException e){
                e.printStackTrace();
            }
        }
    }
}

編譯後檢視位元組碼:

javap -v -c -s -l Main.class

access()位元組碼擷取如下:

stack=3, locals=4, args_size=1
 0: getstatic     #9                  // Field MUTEX:Ljava/lang/Object;  獲取MUTEX
 3: dup
 4: astore_1
 5: monitorenter                      // 執行monitor enter指令
 6: getstatic     #10                 // Field java/util/concurrent/TimeUnit.SECONDS:Ljava/util/concurrent/TimeUnit;
 9: ldc2_w        #11                 // long 20l
12: invokevirtual #13                 // Method java/util/concurrent/TimeUnit.sleep:(J)V
15: goto          23                  // 正常退出,跳轉到位元組碼偏移量23的地方
18: astore_2
19: aload_2
20: invokevirtual #15                 // Method java/lang/InterruptedException.printStackTrace:()V
23: aload_1
24: monitorexit                          // monitor exit指令
25: goto          33
28: astore_3
29: aload_1
30: monitorexit
31: aload_3
32: athrow
33: return

關於monitorentermonitorexit說明如下:

  • monitorenter:每一個物件與一個monitor相對應,一個執行緒嘗試獲取與物件關聯的monitor的時候,如果monitor的計數器為0,會獲得之後立即對計數器加1,如果一個已經擁有monitor所有權的執行緒重入,將導致計數器再次累加,而如果其他執行緒嘗試獲取時,會一直阻塞直到monitor的計數器變為0,才能再次嘗試獲取對monitor的所有權
  • monitorexit:釋放對monitor的所有權,將monitor的計數器減1,如果計數器為0,意味著該執行緒不再擁有對monitor的所有權

3.4 注意事項

3.4.1 非空物件

monitor關聯的物件不能為空:

private Object MUTEX = null;
private void sync(){
    synchronized (MUTEX){

    }
}

會直接丟擲空指標異常。

3.4.2 作用域不當

由於synchronized關鍵字存在排它性,作用域越大,往往意味著效率越低,甚至喪失並行優勢,比如:

private synchronized void sync(){
    method1();
    syncMethod();
    method2();
}

其中只有第二個方法是並行操作,那麼可以修改為

private Object MUTEX = new Object();
private void sync(){
    method1();
    synchronized (MUTEX){
        syncMethod();
    }
    method2();
}

3.4.3 使用不同的物件

因為一個物件與一個monitor相關聯,如果使用不同的物件,這樣就失去了同步的意義,例子如下:

public class Main {
    public static class Task implements Runnable{
        private final Object MUTEX = new Object();

        @Override
        public void run(){
            synchronized (MUTEX){
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 20; i++) {
            new Thread(new Task()).start();
        }
    }
}

每一個執行緒爭奪的monitor都是互相獨立的,這樣就失去了同步的意義,起不到互斥的作用。

3.5 死鎖

另外,使用synchronized還需要注意的是有可能造成死鎖的問題,先來看一下造成死鎖可能的原因。

3.5.1 死鎖成因

  • 交叉鎖導致程式死鎖:比如執行緒A持有R1的鎖等待R2的鎖,執行緒B持有R2的鎖等待R1的鎖
  • 記憶體不足:比如兩個執行緒T1和T2,T1已獲取10MB記憶體,T2獲取了15MB記憶體,T1和T2都需要獲取30MB記憶體才能工作,但是剩餘可用的記憶體為10MB,這樣兩個執行緒都在等待彼此釋放記憶體資源
  • 一問一答式的資料交換:伺服器開啟某個埠,等待使用者端存取,使用者端傳送請求後,伺服器因某些原因錯過了使用者端請求,導致使用者端等待伺服器迴應,而伺服器等待使用者端傳送請求
  • 死迴圈引起的死鎖:比較常見,使用jstack等工具看不到死鎖,但是程式不工作,CPU佔有率高,這種死鎖也叫系統假死,難以排查和重現

3.5.2 例子

public class Main {
    private final Object MUTEX_READ = new Object();
    private final Object MUTEX_WRITE = new Object();

    public void read(){
        synchronized (MUTEX_READ){
            synchronized (MUTEX_WRITE){
            }
        }
    }

    public void write(){
        synchronized (MUTEX_WRITE){
            synchronized (MUTEX_READ){
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Main m = new Main();
        new Thread(()->{
            while (true){
                m.read();
            }
        }).start();
        new Thread(()->{
            while (true){
                m.write();
            }
        }).start();
    }
}

兩個執行緒分別佔有MUTEX_READ/MUTEX_WRITE,同時等待另一個執行緒釋放MUTEX_WRITE/MUTEX_READ,這就是交叉鎖造成的死鎖。

3.5.3 排查

使用jps找到程序後,通過jstack檢視:

在這裡插入圖片描述

可以看到明確的提示找到了1個死鎖,Thread-0等待被Thread-1佔有的monitor,而Thread-1等待被Thread-0佔有的monitor

3.6 兩個特殊的monitor

這裡介紹兩個特殊的monitor

  • this monitor
  • class monitor

3.6.1 this monitor

先上一段程式碼:

public class Main {
    public synchronized void method1(){
        System.out.println(Thread.currentThread().getName()+" method1");
        try{
            TimeUnit.MINUTES.sleep(5);
        }catch (InterruptedException e){
            e.printStackTrace();
        }
    }

    public synchronized void method2(){
        System.out.println(Thread.currentThread().getName()+" method2");
        try{
            TimeUnit.MINUTES.sleep(5);
        }catch (InterruptedException e){
            e.printStackTrace();
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Main m = new Main();
        new Thread(m::method1).start();
        new Thread(m::method2).start();
    }
}

執行之後可以發現,只有一行輸出,也就是說,只是執行了其中一個方法,另一個方法根本沒有執行,使用jstack可以發現:

在這裡插入圖片描述

一個執行緒處於休眠中,而另一個執行緒處於阻塞中。而如果將method2()修改如下:

public void method2(){
    synchronized (this) {
        System.out.println(Thread.currentThread().getName() + " method2");
        try {
            TimeUnit.MINUTES.sleep(5);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

效果是一樣的。也就是說,在方法上使用synchronized,等價於synchronized(this)

3.6.2 class monitor

把上面的程式碼中的方法修改為靜態方法:

public class Main {
    public static synchronized void method1() {
        System.out.println(Thread.currentThread().getName() + " method1");
        try {
            TimeUnit.MINUTES.sleep(5);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    public static synchronized void method2() {
        System.out.println(Thread.currentThread().getName() + " method2");
        try {
            TimeUnit.MINUTES.sleep(5);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    public static void main(String[] args) throws InterruptedException {
        new Thread(Main::method1).start();
        new Thread(Main::method2).start();
    }
}

執行之後可以發現輸出還是隻有一行,也就是說只執行了其中一個方法,jstack分析也類似:

在這裡插入圖片描述

而如果將method2()修改如下:

public static void method2() {
    synchronized (Main.class) {
        System.out.println(Thread.currentThread().getName() + " method2");
        try {
            TimeUnit.MINUTES.sleep(5);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

可以發現輸出還是一致,也就是說,在靜態方法上的synchronized,等價於synchronized(XXX.class)

3.6.3 總結

  • this monitor:在成員方法上的synchronized,就是this monitor,等價於在方法中使用synchronized(this)
  • class monitor:在靜態方法上的synchronized,就是class monitor,等價於在靜態方法中使用synchronized(XXX.class)

4 ThreadGroup

4.1 簡介

無論什麼情況下,一個新建立的執行緒都會加入某個ThreadGroup中:

  • 如果新建執行緒沒有指定ThreadGroup,預設就是main執行緒所在的ThreadGroup
  • 如果指定了ThreadGroup,那麼就加入該ThreadGroup

ThreadGroup中存在父子關係,一個ThreadGroup可以存在子ThreadGroup

4.2 建立

建立ThreadGroup可以直接通過構造方法建立,構造方法有兩個,一個是直接指定名字(ThreadGroupmain執行緒的ThreadGroup),一個是帶有父ThreadGroup與名字的構造方法:

ThreadGroup group1 = new ThreadGroup("name");
ThreadGroup group2 = new ThreadGroup(group1,"name2");

完整例子:

public static void main(String[] args) throws InterruptedException {
    ThreadGroup group1 = new ThreadGroup("name");
    ThreadGroup group2 = new ThreadGroup(group1,"name2");
    System.out.println(group2.getParent() == group1);
    System.out.println(group1.getParent().getName());
}

輸出結果:

true
main

4.3 enumerate()

enumerate()可用於ThreadThreadGroup的複製,因為一個ThreadGroup可以加入若干個Thread以及若干個子ThreadGroup,使用該方法可以方便地進行復制。方法描述如下:

  • public int enumerate(Thread [] list)
  • public int enumerate(Thread [] list, boolean recurse)
  • public int enumerate(ThreadGroup [] list)
  • public int enumerate(ThreadGroup [] list, boolean recurse)

上述方法會將ThreadGroup中的活躍執行緒/ThreadGroup複製到Thread/ThreadGroup陣列中,布林參數列示是否開啟遞迴複製。

例子如下:

public static void main(String[] args) throws InterruptedException {
    ThreadGroup myGroup = new ThreadGroup("MyGroup");
    Thread thread = new Thread(myGroup,()->{
        while (true){
            try{
                TimeUnit.SECONDS.sleep(1);
            }catch (InterruptedException e){
                e.printStackTrace();
            }
        }
    },"MyThread");
    thread.start();
    TimeUnit.MILLISECONDS.sleep(1);
    ThreadGroup mainGroup = currentThread().getThreadGroup();
    Thread[] list = new Thread[mainGroup.activeCount()];
    int recurseSize = mainGroup.enumerate(list);
    System.out.println(recurseSize);
    recurseSize = mainGroup.enumerate(list,false);
    System.out.println(recurseSize);
}

後一個輸出比前一個少1,因為不包含myGroup中的執行緒(遞迴設定為false)。需要注意的是,enumerate()獲取的執行緒僅僅是一個預估值,並不能百分百地保證當前group的活躍執行緒,比如呼叫複製之後,某個執行緒結束了生命週期或者新的執行緒加入進來,都會導致資料不準確。另外,返回的int值相較起Thread[]的長度更為真實,因為enumerate僅僅將當前活躍的執行緒分別放進陣列中,而返回值int代表的是真實的數量而不是陣列的長度。

4.4 其他API

  • activeCount():獲取group中活躍的執行緒,估計值
  • activeGroupCount():獲取group中活躍的子group,也是一個近似值,會遞迴獲取所有的子group
  • getMaxPriority():用於獲取group的優先順序,預設情況下,group的優先順序為10,且所有執行緒的優先順序不得大於執行緒所在group的優先順序
  • getName():獲取group名字
  • getParent():獲取父group,如果不存在返回null
  • list():一個輸出方法,遞迴輸出所有活躍執行緒資訊到控制檯
  • parentOf(ThreadGroup g):判斷當前group是不是給定group的父group,如果給定的group是自己本身,也會返回true
  • setMaxPriority(int pri):指定group的最大優先順序,設定後也會改變所有子group的最大優先順序,另外,修改優先順序後會出現執行緒優先順序大於group優先順序的情況,比如執行緒優先順序為10,設定group優先順序為5後,執行緒優先順序就大於group優先順序,但是新加入的執行緒優先順序必須不能大於group優先順序
  • interrupt():導致所有的活躍執行緒被中斷,遞迴呼叫執行緒的interrupt()
  • destroy():如果沒有任何活躍執行緒,呼叫後在父group中將自己移除
  • setDaemon(boolean daemon):設定為守護ThreadGroup後,如果該ThreadGroup沒有任何活躍執行緒,自動被銷燬