Java 8 併發篇 - 冷靜分析 Synchronized(上)

魔王不造反發表於2018-04-04

1.Java的鎖

1.1 鎖的記憶體語義

  • 鎖可以讓臨界區互斥執行,還可以讓釋放鎖的執行緒向同一個鎖的執行緒傳送訊息
  • 鎖的釋放要遵循Happens-before原則(鎖規則:解鎖必然發生在隨後的加鎖之前
  • 鎖在Java中的具體表現是 SynchronizedLock

1.2 鎖的釋放

執行緒A釋放鎖後,會將共享變更操作重新整理到主記憶體中
Java 8 併發篇 - 冷靜分析 Synchronized(上)

1.3 鎖的獲取

執行緒B獲取鎖時,JMM會將該執行緒的本地記憶體置為無效,被監視器保護的臨界區程式碼必須從主記憶體中讀取共享變數
Java 8 併發篇 - 冷靜分析 Synchronized(上)

1.4 鎖的釋放與獲取

  • 鎖獲取與volatile讀有相同的記憶體語義,讀者可參見筆者的 併發番@Java記憶體模型&Volatile一文通(1.7版)
  • 執行緒A釋放一個鎖,實質是執行緒A告知下一個獲取到該鎖的某個執行緒其已變更該共享變數
  • 執行緒B獲取一個鎖,實質是執行緒B得到了執行緒A告知其(在釋放鎖之前)變更共享變數的訊息
  • 執行緒A釋放鎖,隨後執行緒B競爭到該鎖,實質是執行緒A通過主記憶體向執行緒B發訊息告知其變更了共享變數

2.Synchronized的綜述

  • 同步機制: synchronized是Java同步機制的一種實現,即互斥鎖機制,它所獲得的鎖叫做互斥鎖
  • 互斥鎖: 指的是每個物件的鎖一次只能分配給一個執行緒,同一時間只能由一個執行緒佔用
  • 作用: synchronized用於保證同一時刻只能由一個執行緒進入到臨界區,同時保證共享變數的可見性、原子性和有序性
  • 使用: 當一個執行緒試圖訪問同步程式碼方法(塊)時,它首先必須得到鎖,退出或丟擲異常時必須釋放鎖

3.Synchronized的使用

3.1 Synchronized的三種應用方式

Java 8 併發篇 - 冷靜分析 Synchronized(上)
補充: 使用同步程式碼塊的好處在於其他執行緒仍可以訪問非synchronized(this)的同步程式碼塊

3.2 Synchronized的使用規則

/**
  * 先定義一個測試模板類
  *     這裡補充一個知識點:Thread.sleep(long)不會釋放鎖
  *     讀者可參見筆者的`併發番@Thread一文通`
  */ 
public class SynchronizedDemo {
    public static synchronized void staticMethod(){
        System.out.println(Thread.currentThread().getName() + "訪問了靜態同步方法staticMethod");
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(Thread.currentThread().getName() + "結束訪問靜態同步方法staticMethod");
    }
    public static void staticMethod2(){
        System.out.println(Thread.currentThread().getName() + "訪問了靜態同步方法staticMethod2");
        synchronized (SynchronizedDemo.class){
            System.out.println(Thread.currentThread().getName() + "在staticMethod2方法中獲取了SynchronizedDemo.class");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
    public synchronized void synMethod(){
        System.out.println(Thread.currentThread().getName() + "訪問了同步方法synMethod");
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(Thread.currentThread().getName() + "結束訪問同步方法synMethod");
    }
    public synchronized void synMethod2(){
        System.out.println(Thread.currentThread().getName() + "訪問了同步方法synMethod2");
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(Thread.currentThread().getName() + "結束訪問同步方法synMethod2");
    }
    public void method(){
        System.out.println(Thread.currentThread().getName() + "訪問了普通方法method");
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(Thread.currentThread().getName() + "結束訪問普通方法method");
    }
    private Object lock = new Object();
    public void chunkMethod(){
        System.out.println(Thread.currentThread().getName() + "訪問了chunkMethod方法");
        synchronized (lock){
            System.out.println(Thread.currentThread().getName() + "在chunkMethod方法中獲取了lock");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
    public void chunkMethod2(){
        System.out.println(Thread.currentThread().getName() + "訪問了chunkMethod2方法");
        synchronized (lock){
            System.out.println(Thread.currentThread().getName() + "在chunkMethod2方法中獲取了lock");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
    public void chunkMethod3(){
        System.out.println(Thread.currentThread().getName() + "訪問了chunkMethod3方法");
        //同步程式碼塊
        synchronized (this){
            System.out.println(Thread.currentThread().getName() + "在chunkMethod3方法中獲取了this");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
    public void stringMethod(String lock){
        synchronized (lock){
            while (true){
                System.out.println(Thread.currentThread().getName());
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}
複製程式碼

3.2.1 普通方法與同步方法呼叫互不關聯

當一個執行緒進入同步方法時,其他執行緒可以正常訪問其他非同步方法
public static void main(String[] args) {
    SynchronizedDemo synDemo = new SynchronizedDemo();
    Thread thread1 = new Thread(() -> {
        //呼叫普通方法
        synDemo.method();
    });
    Thread thread2 = new Thread(() -> {
        //呼叫同步方法
        synDemo.synMethod();
    });
    thread1.start();
    thread2.start();
}
---------------------
//輸出:
Thread-1訪問了同步方法synMethod
Thread-0訪問了普通方法method
Thread-0結束訪問普通方法method
Thread-1結束訪問同步方法synMethod
//分析:通過結果可知,普通方法和同步方法是非阻塞執行的
複製程式碼

3.2.2 所有同步方法只能被一個執行緒訪問

當一個執行緒執行同步方法時,其他執行緒不能訪問任何同步方法
public static void main(String[] args) {
    SynchronizedDemo synDemo = new SynchronizedDemo();
    Thread thread1 = new Thread(() -> {
        synDemo.synMethod();
        synDemo.synMethod2();
    });
    Thread thread2 = new Thread(() -> {
        synDemo.synMethod2();
        synDemo.synMethod();
    });
    thread1.start();
    thread2.start();
}
---------------------
//輸出:
Thread-0訪問了同步方法synMethod
Thread-0結束訪問同步方法synMethod
Thread-0訪問了同步方法synMethod2
Thread-0結束訪問同步方法synMethod2
Thread-1訪問了同步方法synMethod2
Thread-1結束訪問同步方法synMethod2
Thread-1訪問了同步方法synMethod
Thread-1結束訪問同步方法synMethod
//分析:通過結果可知,任務的執行是阻塞的,顯然Thread-1必須等待Thread-0執行完畢之後才能繼續執行
複製程式碼

3.2.3 同一個鎖的同步程式碼塊同一時刻只能被一個執行緒訪問

當同步程式碼塊都是同一個鎖時,方法可以被所有執行緒訪問,但同一個鎖的同步程式碼塊同一時刻只能被一個執行緒訪問
public static void main(String[] args) {
    SynchronizedDemo synDemo = new SynchronizedDemo();
    Thread thread1 = new Thread(() -> {
        //呼叫同步塊方法
        synDemo.chunkMethod();
        synDemo.chunkMethod2();
    });
    Thread thread2 = new Thread(() -> {
        //呼叫同步塊方法
        synDemo.chunkMethod();
        synDemo.synMethod2();
    });
    thread1.start();
    thread2.start();
}
---------------------
//輸出:
Thread-0訪問了chunkMethod方法
Thread-1訪問了chunkMethod方法
Thread-0在chunkMethod方法中獲取了lock  
...停頓等待...
Thread-1在chunkMethod方法中獲取了lock
...停頓等待...
Thread-0訪問了chunkMethod2方法
Thread-0在chunkMethod2方法中獲取了lock
...停頓等待...
Thread-1訪問了chunkMethod2方法
Thread-1在chunkMethod2方法中獲取了lock
//分析可知:
//1.對比18行和19行可知,即使普通方法有同步程式碼塊,但方法的訪問是非阻塞的,任何執行緒都可以自由進入
//2.對比20行、22行以及25行和27行可知,對於同一個鎖的同步程式碼塊的訪問一定是阻塞的
複製程式碼


3.2.4 執行緒間同時訪問同一個鎖的多個同步程式碼的執行順序不定

  • 執行緒間同時訪問同一個鎖多個同步程式碼的執行順序不定,即使是使用同一個物件鎖,這點跟同步方法有很大差異
  • ??讀者可以先思考為什麼會出現這樣的問題??
public static void main(String[] args) {
    SynchronizedDemo synDemo = new SynchronizedDemo();
    Thread thread1 = new Thread(() -> {
        //呼叫同步塊方法
        synDemo.chunkMethod();
        synDemo.chunkMethod2();
    });
    Thread thread2 = new Thread(() -> {
        //呼叫同步塊方法
        synDemo.chunkMethod2();
        synDemo.chunkMethod();
    });
    thread1.start();
    thread2.start();
}
---------------------
//輸出:
Thread-0訪問了chunkMethod方法
Thread-1訪問了chunkMethod2方法
Thread-0在chunkMethod方法中獲取了lock
...停頓等待...
Thread-0訪問了chunkMethod2方法
Thread-1在chunkMethod2方法中獲取了lock
...停頓等待...
Thread-1訪問了chunkMethod方法
Thread-0在chunkMethod2方法中獲取了lock
...停頓等待...
Thread-1在chunkMethod方法中獲取了lock

//分析可知:
//現象:對比20行、22行和24行、25行可知,雖然是同一個lock物件,但其不同程式碼塊的訪問是非阻塞的
//原因:根源在於鎖的釋放和重新競爭,當Thread-0訪問完chunkMethod方法後會先釋放鎖,這時Thread-1就有機會能獲取到鎖從而優先執行,依次類推到24行、25行時,Thread-0又重新獲取到鎖優先執行了
//注意:但有一點是必須的,對於同一個鎖的同步程式碼塊的訪問一定是阻塞的
//補充:同步方法之所有會被全部阻塞,是因為synDemo物件一直被執行緒在內部把持住就沒釋放過,論把持住的重要性!
複製程式碼

3.2.5 不同鎖之間訪問非阻塞

  • 由於三種使用方式的鎖物件都不一樣,因此相互之間不會有任何影響
  • 但有兩種情況除外:
    • 1.當同步程式碼塊使用的Class物件和類物件一致時屬於同一個鎖,遵循上面的3.2.3原則
    • 2.當同步程式碼塊使用的是this,即與同步方法使用鎖屬於同一個鎖,遵循上面的3.2.23.2.3原則
public static void main(String[] args) {
    SynchronizedDemo synDemo = new SynchronizedDemo();
    Thread thread1 = new Thread(() -> synDemo.chunkMethod() );
    Thread thread2 = new Thread(() -> synDemo.chunkMethod3());
    Thread thread3 = new Thread(() -> staticMethod());
    Thread thread4 = new Thread(() -> staticMethod2());
    thread1.start();
    thread2.start();
    thread3.start();
    thread4.start();
}
---------------------
//輸出:
Thread-1訪問了chunkMethod3方法
Thread-1在chunkMethod3方法中獲取了this
Thread-2訪問了靜態同步方法staticMethod
Thread-0訪問了chunkMethod方法
Thread-0在chunkMethod方法中獲取了lock
Thread-3訪問了靜態同步方法staticMethod2
...停頓等待...
Thread-2結束訪問靜態同步方法staticMethod
Thread-3在staticMethod2方法中獲取了SynchronizedDemo.class
//分析可知:
//現象:對比16行、18行和24行、25行可知,雖然是同一個lock物件,但其不同程式碼塊的訪問是非阻塞的
//原因:根源在於鎖的釋放和重新競爭,當Thread-0訪問完chunkMethod方法後會先釋放鎖,這時Thread-1就有機會能獲取到鎖從而優先執行,依次類推到24行、25行時,Thread-0又重新獲取到鎖優先執行了
複製程式碼

3.3 Synchronized的可重入性

  • 重入鎖:當一個執行緒再次請求自己持有物件鎖的臨界資源時,這種情況屬於重入鎖,請求將會成功
  • 實現:一個執行緒得到一個物件鎖後再次請求該物件鎖,是允許的,每重入一次,monitor進入次數+1
public static void main(String[] args) {
    SynchronizedDemo synDemo = new SynchronizedDemo();
    Thread thread1 = new Thread(() -> {
        synDemo.synMethod();
        synDemo.synMethod2();
    });
    Thread thread2 = new Thread(() -> {
        synDemo.synMethod2();
        synDemo.synMethod();
    });
    thread1.start();
    thread2.start();
}
---------------------
//輸出:
Thread-0訪問了同步方法synMethod
Thread-0結束訪問同步方法synMethod
Thread-0訪問了同步方法synMethod2
Thread-0結束訪問同步方法synMethod2
Thread-1訪問了同步方法synMethod2
Thread-1結束訪問同步方法synMethod2
Thread-1訪問了同步方法synMethod
Thread-1結束訪問同步方法synMethod
//分析:對比16行和18行可知,在程式碼塊中繼續呼叫了當前例項物件的另外一個同步方法,再次請求當前例項鎖時,將被允許,進而執行方法體程式碼,這就是重入鎖最直接的體現
複製程式碼

3.4 Synchronized與String鎖

  • 隱患:由於在JVM中具有String常量池快取的功能,因此相同字面量是同一個鎖!!!
  • 注意:嚴重不推薦將String作為鎖物件,而應該改用其他非快取物件
  • 提示:對字面量有疑問的話請先回顧一下String的基礎,這裡不加以解釋
public static void main(String[] args) {
    SynchronizedDemo synDemo = new SynchronizedDemo();
    Thread thread1 = new Thread(() -> synDemo.stringMethod("sally"));
    Thread thread2 = new Thread(() -> synDemo.stringMethod("sally"));
    thread1.start();
    thread2.start();
}
---------------------
//輸出:
Thread-0
Thread-0
Thread-0
Thread-0
...死迴圈...
//分析:輸出結果永遠都是Thread-0的死迴圈,也就是說另一個執行緒,即Thread-1執行緒根本不會執行
//原因:同步塊中的鎖是同一個字面量
複製程式碼

3.5 Synchronized與不可變鎖

  • 隱患:當使用不可變類物件(final Class)作為物件鎖時,使用synchronized同樣會有併發問題
  • 原因:由於不可變特性,當作為鎖但同步塊內部仍然有計算操作,會生成一個新的鎖物件
  • 注意:嚴重不推薦將final Class作為鎖物件時仍對其有計算操作
  • 補充:雖然String也是final Class,但它的原因卻是字面量常量池
public class SynchronizedDemo {
    static Integer i = 0;   //Integer是final Class
    public static void main(String[] args) throws InterruptedException {
        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                for (int j = 0;j<10000;j++){
                    synchronized (i){
                        i++;
                    }
                }
            }
        };
        Thread thread1 = new Thread(runnable);
        Thread thread2 = new Thread(runnable);
        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
        System.out.println(i);
    }
}
---------------------
//輸出:
14134
//分析:跟預想中的20000不一致,當使用Integer作為物件鎖時但還有計算操作就會出現併發問題
複製程式碼

我們通過反編譯發現執行i++操作相當於執行了i = Integer.valueOf(i.intValue()+1)

Java 8 併發篇 - 冷靜分析 Synchronized(上)

通過檢視Integer的valueOf方法實現可知,其每次都new了一個新的Integer物件,鎖變了有木有!!!

public static Integer valueOf(int i) {
    if (i >= IntegerCache.low && i <= IntegerCache.high)
        return IntegerCache.cache[i + (-IntegerCache.low)];
    return new Integer(i);  //每次都new一個新的鎖有木有!!!
}
複製程式碼

3.6 Synchronized與死鎖

  • 死鎖:當執行緒間需要相互等待對方已持有的鎖時,就形成死鎖,進而產生死迴圈
  • 注意: 程式碼中嚴禁出現死鎖!!!
public static void main(String[] args) {
    Object lock = new Object();
    Object lock2 = new Object();
    Thread thread1 = new Thread(() -> {
        synchronized (lock){
            System.out.println(Thread.currentThread().getName() + "獲取到lock鎖");
            try {
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            synchronized (lock2){
                System.out.println(Thread.currentThread().getName() + "獲取到lock2鎖");
            }
        }
    });
    Thread thread2 = new Thread(() -> {
        synchronized (lock2){
            System.out.println(Thread.currentThread().getName() + "獲取到lock2鎖");
            try {
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            synchronized (lock){
                System.out.println(Thread.currentThread().getName() + "獲取到lock鎖");
            }
        }
    });
    thread1.start();
    thread2.start();
}
---------------------
//輸出:
Thread-1獲取到lock2鎖
Thread-0獲取到lock鎖
.....
//分析:執行緒0獲得lock鎖,執行緒1獲得lock2鎖,但之後由於兩個執行緒還要獲取對方已持有的鎖,但已持有的鎖都不會被雙方釋放,執行緒"假死",無法往下執行,從而形成死迴圈,即死鎖,之後一直在做無用的死迴圈,嚴重浪費系統資源
複製程式碼

我們用 jstack 檢視一下這個任務的各個執行緒執行情況,可以發現兩個執行緒都被阻塞 BLOCKED

Java 8 併發篇 - 冷靜分析 Synchronized(上)

我們很明顯的發現,Java-level=deadlock,即死鎖,兩個執行緒相互等待對方的鎖

Java 8 併發篇 - 冷靜分析 Synchronized(上)


Synchronized一文通(1.8版) 黃志鵬kira 創作,採用 知識共享 署名-非商業性使用 4.0 國際 許可協議進行許可。


相關文章