多線程系列(三) -synchronized 關鍵字使用詳解

一、簡介

在之前的線程系列文章中,我們介紹了線程創建的幾種方式以及常用的方法介紹。

今天我們接着聊聊多線程線程安全的問題,以及解決辦法。

實際上,在多線程環境中,難免會出現多個線程對一個對象的實例變量進行同時訪問和操作,如果編程處理不當,會產生髒讀現象。

二、線程安全問題介紹

我們先來看一個簡單的線程安全問題的例子!

public class DataEntity {

    private int count = 0;

    public void addCount(){
        count++;
    }

    public int getCount(){
        return count;
    }
}
public class MyThread extends Thread {

    private DataEntity entity;

    public MyThread(DataEntity entity) {
        this.entity = entity;
    }

    @Override
    public void run() {
        for (int j = 0; j < 1000000; j++) {
            entity.addCount();
        }
    }
}
public class MyThreadTest {

    public static void main(String[] args) {
        // 初始化數據實體
        DataEntity entity = new DataEntity();
        //使用多線程編程對數據進行計算
        for (int i = 0; i < 10; i++) {
            MyThread thread = new MyThread(entity);
            thread.start();
        }

        try {
            Thread.sleep(500);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("result: " + entity.getCount());
    }
}

多次運行結果如下:

第一次運行:result: 9788554
第二次運行:result: 9861461
第三次運行:result: 6412249
...

上面的代碼中,總共開啓了 10 個線程,每個線程都累加了 1000000 次,如果結果正確的話,自然而然總數就應該是 10 * 1000000 = 10000000。

但是多次運行結果都不是這個數,而且每次運行結果都不一樣,爲什麼會出現這個結果呢

簡單的說,這是主內存和線程的工作內存數據不一致,以及多線程執行時無序,共同造成的結果

我們先簡單的瞭解一下 Java 的內存模型,後期我們在介紹裏面的原理!

如上圖所示,線程 A 和線程 B 之間,如果要完成數據通信的話,需要經歷以下幾個步驟:

  • 1.線程 A 從主內存中將共享變量讀入線程 A 的工作內存後並進行操作,之後將數據重新寫回到主內存中;
  • 2.線程 B 從主存中讀取最新的共享變量,然後存入自己的工作內存中,再進行操作,數據操作完之後再重新寫入到主內存中;

如果線程 A 更新後數據並沒有及時寫回到主存,而此時線程 B 從主內存中讀到的數據,可能就是過期的數據,於是就會出現“髒讀”現象。

因此在多線程環境下,如果不進行一定干預處理,可能就會出現像上文介紹的那樣,採用多線程編程時,程序的實際運行結果與預期會不一致,就會產生非常嚴重的問題。

針對多線程編程中,程序運行不安全的問題,Java 提供了synchronized關鍵字來解決這個問題,當多個線程同時訪問共享資源時,會保證線程依次排隊操作共享變量,從而保證程序的實際運行結果與預期一致。

我們對上面示例中的DataEntity.addCount()方法進行改造,再看看效果如下。

public class DataEntity {

    private int count = 0;

    /**
     * 在方法上加上 synchronized 關鍵字
     */
    public synchronized void addCount(){
        count++;
    }

    public int getCount(){
        return count;
    }
}

多次運行結果如下:

第一次運行:result: 10000000
第二次運行:result: 10000000
第三次運行:result: 10000000
...

運行結果與預期一致!

三、synchronized 使用詳解

synchronized作爲 Java 中的關鍵字,在多線程編程中,有着非常重要的地位,也是新手瞭解併發編程的基礎,從功能角度看,它有以下幾個比較重要的特性:

  • 原子性:即一個或多個操作要麼全部執行成功,要麼全部執行失敗。synchronized關鍵字可以保證只有一個線程拿到鎖,訪問共享資源
  • 可見性:即一個線程對共享變量進行修改後,其他線程可以立刻看到。執行synchronized時,線程獲取鎖之後,一定從主內存中讀取數據,釋放鎖之前,一定會將數據寫回主內存,從而保證內存數據可見性
  • 有序性:即保證程序的執行順序會按照代碼的先後順序執行。synchronized關鍵字,可以保證每個線程依次排隊操作共享變量

synchronized也被稱爲同步鎖,它可以把任意一個非 NULL 的對象當成鎖,只有拿到鎖的線程能進入方法體,並且只有一個線程能進入,其他的線程必須等待鎖釋放了才能進入,它屬於獨佔式的悲觀鎖,同時也屬於可重入鎖。

關於鎖的知識,我們後面在介紹,大家先了解一下就行。

從實際的使用角度來看,synchronized修飾的對象有以下幾種:

  • 修飾一個方法:被修飾的方法稱爲同步方法,其作用的範圍是整個方法,作用的對象是調用這個方法的對象
  • 修飾一個靜態的方法:其作用的範圍是整個靜態方法,作用的對象是這個類的所有對象
  • 修飾一個代碼塊:被修飾的代碼塊稱爲同步語句塊,其作用的範圍是大括號{}括起來的代碼,作用的對象是調用這個代碼塊的對象,使用上比較靈活

下面我們一起來看看它們的具體用法。

3.1、修飾一個方法

synchronized修飾一個方法時,多個線程訪問同一個對象,哪個線程持有該方法所屬對象的鎖,就擁有執行權限,否則就只能等待。

如果多線程訪問的不是同一個對象,不會起到保證線程同步的作用

示例如下:

public class DataEntity {

    private int count;

    /**
     * 在方法上加上 synchronized 關鍵字
     */
    public synchronized void addCount(){
        for (int i = 0; i < 3; i++) {
            try {
                System.out.println(Thread.currentThread().getName() + ":" + (count++));
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    public int getCount() {
        return count;
    }
}
public class MyThreadA extends Thread {

    private DataEntity entity;

    public MyThreadA(DataEntity entity) {
        this.entity = entity;
    }

    @Override
    public void run() {
        entity.addCount();
    }
}
public class MyThreadB extends Thread {

    private DataEntity entity;

    public MyThreadB(DataEntity entity) {
        this.entity = entity;
    }

    @Override
    public void run() {
        entity.addCount();
    }
}
public class MyThreadTest {

    public static void main(String[] args) {
        // 初始化數據實體
        DataEntity entity = new DataEntity();

        MyThreadA threadA = new MyThreadA(entity);
        threadA.start();

        MyThreadB threadB = new MyThreadB(entity);
        threadB.start();


        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("result: " + entity.getCount());
    }
}

運行結果如下:

Thread-0:0
Thread-0:1
Thread-0:2
Thread-1:3
Thread-1:4
Thread-1:5
result: 6

當兩個線程共同操作一個對象時,此時每個線程都會依次排隊執行。

假如兩個線程操作的不是一個對象,此時沒有任何效果,示例如下:

public class MyThreadTest {

    public static void main(String[] args) {
        DataEntity entity1 = new DataEntity();
        MyThreadA threadA = new MyThreadA(entity1);
        threadA.start();

        DataEntity entity2 = new DataEntity();
        MyThreadA threadB = new MyThreadA(entity2);
        threadB.start();


        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("result: " + entity1.getCount());
        System.out.println("result: " + entity2.getCount());
    }
}

運行結果如下:

Thread-0:0
Thread-1:0
Thread-0:1
Thread-1:1
Thread-0:2
Thread-1:2
result: 3
result: 3

從結果上可以看出,當synchronized修飾一個方法,當多個線程訪問同一個對象的方法,每個線程會依次排隊;如果訪問的不是一個對象,線程不會進行排隊,像正常執行一樣。

3.2、修飾一個靜態的方法

synchronized修改一個靜態的方法時,代表的是對當前.java文件對應的 Class 類加鎖,不區分對象實例。

示例如下:

public class DataEntity {

    private static int count;

    /**
     * 在靜態方法上加上 synchronized 關鍵字
     */
    public synchronized static void addCount(){
        for (int i = 0; i < 3; i++) {
            try {
                System.out.println(Thread.currentThread().getName() + ":" + (count++));
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    public static int getCount() {
        return count;
    }
}
public class MyThreadA extends Thread {

    @Override
    public void run() {
        DataEntity.addCount();
    }
}
public class MyThreadB extends Thread {

    @Override
    public void run() {
        DataEntity.addCount();
    }
}
public class MyThreadTest {

    public static void main(String[] args) {

        MyThreadA threadA = new MyThreadA();
        threadA.start();

        MyThreadB threadB = new MyThreadB();
        threadB.start();


        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("result: " + DataEntity.getCount());
    }
}

運行結果如下:

Thread-0:0
Thread-0:1
Thread-0:2
Thread-1:3
Thread-1:4
Thread-1:5
result: 6

靜態同步方法和非靜態同步方法持有的是不同的鎖,前者是類鎖,後者是對象鎖,類鎖可以理解爲這個類的所有對象。

3.3、修飾一個代碼塊

synchronized用於修飾一個代碼塊時,只會控制代碼塊內的執行順序,其他試圖訪問該對象的線程將被阻塞,編程比較靈活,在實際開發中用的應用比較廣泛。

示例如下

public class DataEntity {

    private int count;

    /**
     * 在方法上加上 synchronized 關鍵字
     */
    public void addCount(){
        synchronized (this){
            for (int i = 0; i < 3; i++) {
                try {
                    System.out.println(Thread.currentThread().getName() + ":" + (count++));
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }

    public int getCount() {
        return count;
    }
}
public class MyThreadTest {

    public static void main(String[] args) {
        // 初始化數據實體
        DataEntity entity = new DataEntity();

        MyThreadA threadA = new MyThreadA(entity);
        threadA.start();

        MyThreadB threadB = new MyThreadB(entity);
        threadB.start();


        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("result: " + entity.getCount());
    }
}

運行結果如下:

Thread-0:0
Thread-0:1
Thread-0:2
Thread-1:3
Thread-1:4
Thread-1:5
result: 6

其中synchronized (this)中的this,表示的是當前類實例的對象,效果等同於public synchronized void addCount()

除此之外,synchronized()還可以修飾任意實例對象,作用的範圍就是具體的實例對象。

比如,修飾個自定義的類實例對象,作用的範圍是擁有lock對象,其實也等價於synchronized (this)

public class DataEntity {

    private Object lock = new Object();

    /**
     * synchronized 可以修飾任意實例對象
     */
    public void addCount(){
        synchronized (lock){
            // todo...
        }
    }
}

當然也可以用於修飾類,表示類鎖,效果等同於public synchronized static void addCount()

public class DataEntity {
    
    /**
     * synchronized 可以修飾類,表示類鎖
     */
    public void addCount(){
        synchronized (DataEntity.class){
            // todo...
        }
    }
}

synchronized修飾代碼塊,比較經典的應用案例,就是單例設計模式中的雙重校驗鎖實現。

public class Singleton {  

    private volatile static Singleton singleton;  
    
    private Singleton (){}  
    
    public static Singleton getSingleton() {  
        if (singleton == null) {  
            synchronized (Singleton.class) {  
                if (singleton == null) {  
                    singleton = new Singleton();  
                }  
            }  
        }  
        return singleton;  
    }  
}

採用代碼塊的實現方式,編程會更加靈活,可以顯著的提升併發查詢的效率。

四、synchronized 鎖重入介紹

synchronized關鍵字擁有鎖重入的功能,所謂鎖重入的意思就是:當一個線程得到一個對象鎖後,再次請求此對象鎖時可以再次得到該對象的鎖,而無需等待。

我們看個例子就能明白。

public class DataEntity {

    private int count = 0;

    
    public synchronized void addCount1(){
        System.out.println(Thread.currentThread().getName() + ":" + (count++));
        addCount2();
    }

    public synchronized void addCount2(){
        System.out.println(Thread.currentThread().getName() + ":" + (count++));
        addCount3();
    }

    public synchronized void addCount3(){
        System.out.println(Thread.currentThread().getName() + ":" + (count++));

    }

    public int getCount() {
        return count;
    }
}
public class MyThreadA extends Thread {

    private DataEntity entity;

    public MyThreadA(DataEntity entity) {
        this.entity = entity;
    }

    @Override
    public void run() {
        entity.addCount1();
    }
}
public class MyThreadB extends Thread {

    private DataEntity entity;

    public MyThreadB(DataEntity entity) {
        this.entity = entity;
    }

    @Override
    public void run() {
        entity.addCount1();
    }
}
public class MyThreadTest {

    public static void main(String[] args) {
        // 初始化數據實體
        DataEntity entity = new DataEntity();

        MyThreadA threadA = new MyThreadA(entity);
        threadA.start();

        MyThreadB threadB = new MyThreadB(entity);
        threadB.start();


        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("result: " + entity.getCount());
    }
}

運行結果如下:

Thread-0:0
Thread-0:1
Thread-0:2
Thread-1:3
Thread-1:4
Thread-1:5
result: 6

從結果上看線程沒有交替執行,線程Thread-0獲取到鎖之後,再次調用其它帶有synchronized關鍵字的方法時,可以快速進入,而Thread-1線程需等待對象鎖完全釋放之後再獲取,這就是鎖重入。

五、小結

從上文中我們可以得知,在多線程環境下,恰當的使用synchronized關鍵字可以保證線程同步,使程序的運行結果與預期一致。

  • 1.當synchronized修飾一個方法時,作用的範圍是整個方法,作用的對象是調用這個方法的對象;
  • 2..當synchronized修飾一個靜態方法時,作用的範圍是整個靜態方法,作用的對象是這個類的所有對象;
  • 3.當synchronized修飾一個代碼塊時,作用的範圍是代碼塊,作用的對象是修飾的內容,如果是類,則這個類的所有對象都會受到控制;如果是任意對象實例子,則控制的是具體的對象實例,誰擁有這個對象鎖,就能進入方法體

synchronized是一種同步鎖,屬於獨佔式,使用它進行線程同步,JVM 性能開銷很大,大量的使用未必會帶來好處。

關於更深入的原理知識,我們會在 JVM 系列中進行詳解。文章內容難免有所遺漏,歡迎網友留言指出。

六、參考

1、五月的倉頡 - synchronized鎖機制

2、你聽 - 讓你徹底理解Synchronized

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章