1. 程式人生 > >4. synchronized關鍵字(一)

4. synchronized關鍵字(一)

一、執行緒安全和不安全

  • 非執行緒安全:在多個執行緒對同一個物件的例項變數進行併發訪問時會出現值被更改、值不同步的情況
  • 執行緒安全:獲得的例項變數的值是經過同步處理的,按照順序執行,不會出現髒讀情況

舉個例子:5個銷售員, 賣同一堆貨物,每個銷售員在賣出一個貨品後,並不是立即知道當前貨物剩餘數量的,因為在他賣出的同時,可能其他銷售員已經賣出好幾個貨品了,如果這個時候就減1,那麼就會產生資料的不同步,可能售貨員1計算剩餘20個,售貨員2計算剩餘25個。因此,需要使5個賣貨品的過程進行同步,即按順序的方式進行減1,也就是每賣一次貨物,要在當前剩餘的數量上減1,即售貨員1計算剩餘20個,此時售貨員2賣出一個,計算剩餘數量20-1=19個

先看個執行緒不安全的例子

public class MyThread6_1 implements Runnable {

    private int count = 5;

    @Override
    public void run() {
        count--;
        System.out.println("由 " + Thread.currentThread().getName() +
                " 計算, count=" + count);
    }

    public static void main(String[] args)
{ MyThread6_1 thread = new MyThread6_1(); // 5個執行緒共同呼叫執行緒 Thread6_1,同時共同修改變數 count Thread t1 = new Thread(thread); Thread t2 = new Thread(thread); Thread t3 = new Thread(thread); Thread t4 = new Thread(thread); Thread t5 = new Thread(thread); t1.
start(); t2.start(); t3.start(); t4.start(); t5.start(); } }

結果是:

由 Thread-0 計算, count=3
由 Thread-2 計算, count=2
由 Thread-1 計算, count=3
由 Thread-4 計算, count=0
由 Thread-3 計算, count=1

可以看到,Thread-0Thread-1 同時列印的 count 都是3,說明兩個執行緒是一起執行 run 方法的,即同時減1,會產生非執行緒安全的問題,而我們理想的結果是,每個結果都是依次遞減的,如果要解決這個問題,可以使用 synchronized 關鍵字來修飾方法

二、什麼是synchronized同步方法

如果一個方法用 synchronized 修飾符來修飾,那麼該方法稱為同步方法,即如果有多個執行緒在同一時刻呼叫該方法,那麼同一時刻只能有一個執行緒執行該方法,其他執行緒只能等待,等待前一個執行緒執行完同步方法之後再去執行該方法

與之對應的是非同步方法,如果多個執行緒在同一個時刻呼叫該方法,那麼同一時刻會有很多執行緒來呼叫該方法,不會存在後面的執行緒等待前面的執行緒執行完再執行的情況

鎖機制

在 java 中,每個物件都擁有一個鎖標記,也稱為監視器,多執行緒同時訪問某個物件時,執行緒只有獲得了該物件的鎖才能訪問其中的方法

synchronized 可以在任意物件和方法上加鎖,加鎖的這段程式碼稱為 互斥區 或者 臨界區,當一個執行緒呼叫指定物件的同步方法時,執行緒首先要嘗試去拿到呼叫物件的物件鎖,如果能夠拿到這把物件鎖,那麼這個執行緒就可以執行 synchronized 裡面的程式碼;其他執行緒,因為沒有拿到物件鎖,因此不能訪問呼叫物件裡的同步方法,只能等待,只有等前一個執行緒執行完,它才會釋放持有的物件鎖,其他執行緒拿到鎖後,才能呼叫物件的同步方法

我們再來看一個執行緒安全的例子,在 run 方法的前面加上 synchronized 關鍵字

public class MyThread6_1 implements Runnable {

    private int count = 5;

    //在 run 方法前面加上 synchronized 關鍵字
    @Override
    public synchronized void run() {
        count--;
        System.out.println("由 " + Thread.currentThread().getName() +
                " 計算, count=" + count);
    }

    public static void main(String[] args) {
        MyThread6_1 thread = new MyThread6_1();
		// 5個執行緒共同呼叫執行緒 Thread6_1,同時共同修改變數 count
        Thread t1 = new Thread(thread);
        Thread t2 = new Thread(thread);
        Thread t3 = new Thread(thread);
        Thread t4 = new Thread(thread);
        Thread t5 = new Thread(thread);

        t1.start();
        t2.start();
        t3.start();
        t4.start();
        t5.start();
    }
}

結果是:

由 Thread-0 計算, count=4
由 Thread-1 計算, count=3
由 Thread-3 計算, count=2
由 Thread-2 計算, count=1
由 Thread-4 計算, count=0

可以看到,此時的結果沒有在出現相同的 count 值的問題了。

當我們在方法前面加上 synchronized 關鍵字的時候,使得多個執行緒在執行 run 方法的時候,以排隊的方式進行處理。這個例子中,5個執行緒同時呼叫同一個物件 thread,當第一個執行緒 Thread-0 執行 thread 物件的同步方法時,即持有了物件鎖 thread,其他方法由於沒有物件鎖,只能等待 Thread-0 執行完同步方法並釋放物件鎖之後再執行,之後的4個執行緒都是按照這種模式來執行的

三、synchronized同步方法和執行緒安全

2.1 方法內的變數為執行緒安全

非執行緒安全存在於例項變數中的,如果是方法內部的變數,則是執行緒安全的,看個例子

class HashSelPrivateNum {

    public void addI(String username) throws InterruptedException {
        //該變數是 addI 方法內部的私有變數
        int num;
        
        if (username.equals("thread a")) {
            num = 100;
            System.out.println("a set over" + " " + System.currentTimeMillis());
            //使當前執行緒休眠
            Thread.sleep(2000);
        } else {
            num = 200;
            System.out.println("b set over" + " " + System.currentTimeMillis());
        }
        System.out.println(username + " num = " + num + " " + System.currentTimeMillis());
    }
}

建立執行緒 ThreadB 和執行緒 ThreadA

//建立執行緒類 ThreadB
class ThreadB extends Thread {
    private HashSelPrivateNum numRef;

    public ThreadB(HashSelPrivateNum numRef) {
        this.numRef = numRef;
    }

    @Override
    public void run() {
        try {
            numRef.addI("thread b");
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

//建立執行緒類 ThreadA
public class ThreadA extends Thread {

    private HashSelPrivateNum numRef;

    public ThreadA(HashSelPrivateNum numRef) {
        this.numRef = numRef;
    }

    @Override
    public void run() {
        try {
            numRef.addI("thread a");
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    public static void main(String[] args) {
        HashSelPrivateNum hashSelPrivateNum = new HashSelPrivateNum();
        ThreadA threadA = new ThreadA(hashSelPrivateNum);
        threadA.start();

        ThreadB threadB = new ThreadB(hashSelPrivateNum);
        threadB.start();
    }
}

結果是:

a set over 1540024730771
b set over 1540024730771
thread b num = 200 1540024730772
thread a num = 100 1540024732772

可以看到,此時雖然類 HashSelPrivateNum 的方法 addI 不是同步方法,且不是按照程式碼的順序輸出的,但是當執行緒 A 和執行緒 B 呼叫的時候,因為此時的變數 num 是方法內部的變數,每個執行緒都有自己的一個變數 num,因此,每個執行緒只能對自己對應的那個變數 num 賦值,所以不會造成數值的覆蓋或者重複等,是執行緒安全的,這是變數 num 是方法的區域性變數這個性質造成的

2.2 例項變數為非執行緒安全

如果多個執行緒同時訪問1個物件中的例項變數,即公共的變數,則會出現非執行緒安全

還是上面的方法,我們將 HashSelPrivateNum 類的 num 變數變為例項變數

class HashSelPrivateNum {

    //變成了公共變數
    private int num = 0;

    public void addI(String username) throws InterruptedException {
        if (username.equals("thread a")) {
            num = 100;
            System.out.println("a set over" + " " + System.currentTimeMillis());
            //使當前執行緒休眠
            Thread.sleep(2000);
        } else {
            num = 200;
            System.out.println("b set over" + " " + System.currentTimeMillis());
        }
        System.out.println(username + " num = " + num + " " + System.currentTimeMillis());
    }
}

執行緒 A 和執行緒 B 的方法和上面一樣,結果如下:

a set over 1540024616286
b set over 1540024616286
thread b num = 200 1540024616286
thread a num = 200 1540024618287

此時變數不再是私有變量了,從輸出可以看到,除了順序不是按照程式碼順序之外,此時的共有 num 變數也出現了重複的情況,究其原因,就是執行緒 thread a 執行完 if 語句還沒有輸出的時候,執行緒 thread b 也進入這個方法,執行 if 語句並且重新為例項變數 num 賦值為 200,然後 thread b 輸出變數的值就是200,然後執行緒 thread a 再執行,因為共有變數 num 已經修改過了,因此執行緒 thread a 輸出變數的值還是 200,此時輸出的是 髒資料,即修改之後的資料,因此出現了 非執行緒安全 的問題

對於這個問題,我們只需要在 addI 方法前面新增 synchronized 關鍵字即可

class HashSelPrivateNum {

    private int num = 0;

    public synchronized void addI(String username) throws InterruptedException {
        if (username.equals("thread a")) {
            num = 100;
            System.out.println("a set over" + " " + System.currentTimeMillis());
            Thread.sleep(2000);
        } else {
            num = 200;
            System.out.println("b set over" + " " + System.currentTimeMillis());
        }
        System.out.println(username + " num = " + num + " " + System.currentTimeMillis());
    }
}

執行緒 A 和執行緒 B 的程式碼同上,main 方法也同上,結果是:

a set over 1540025424653
thread a num = 100 1540025426655
b set over 1540025426655
thread b num = 200 1540025426655

可以看到,執行緒 thread a 先執行同步方法 addI() ,同時擁有了物件鎖 hashSelPrivateNum,那麼執行緒 thread-b 只能等待 thread a 執行完同步方法,釋放物件鎖之後,才可以獲取物件鎖,然後執行該物件的同步方法

此時addI 是同步方法,哪個執行緒先拿到物件鎖,就先執行同步方法,沒有拿到物件鎖的執行緒,只能等待前者執行完同步方法之後才可以執行,此時就不會發生資料的髒讀

得出結論:在兩個執行緒訪問同一個物件中的同步方法時一定是執行緒安全的

三、多個物件多個鎖

建立 HashSelPrivateNum 類,其中的方法 addI 是同步方法

class HashSelPrivateNum {

    //如果變數不是方法的私有變數,此時變成了公共變數,則有可能出現執行緒安全問題,此時需要加 synchronized 關鍵字
    private int num = 0;

    public synchronized void addI(String username) throws InterruptedException {
        //該變數是 addI 方法內部的私有變數,此時不加 synchronized 關鍵字也不會存線上程安全問題
//        int num;

        if (username.equals("thread a")) {
            num = 100;
            System.out.println("a set over" + " " + System.currentTimeMillis());
            //使當前執行緒休眠
            Thread.sleep(2000);
        } else {
            num = 200;
            System.out.println("b set over" + " " + System.currentTimeMillis());
        }
        System.out.println(username + " num = " + num + " " + System.currentTimeMillis());
    }
}

建立執行緒 A1 和執行緒 B1,同時使用這2個執行緒訪問2個不同的物件

class ThreadB1 extends Thread {

    private HashSelPrivateNum numRef;

    public ThreadB1(HashSelPrivateNum numRef) {
        this.numRef = numRef;
    }

    @Override
    public void run() {
        try {
            numRef.addI("thread b");
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

public class ThreadA1 extends Thread {

    private HashSelPrivateNum numRef;

    public ThreadA1(HashSelPrivateNum numRef) {
        this.numRef = numRef;
    }

    @Override
    public void run() {
        try {
            numRef.addI("thread a");
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    public static void main(String[] args) {
        //建立兩個物件
        HashSelPrivateNum numRef1 = new HashSelPrivateNum();
        HashSelPrivateNum numRef2 = new HashSelPrivateNum();
		//兩個執行緒訪問兩個不同的物件
        ThreadA1 threadA1 = new ThreadA1(numRef1);
        threadA1.start();

        ThreadB1 threadB1 = new ThreadB1(numRef2);
        threadB1.start();
    }
}

結果是:

a set over 1540027131988
b set over 1540027131988
thread b num = 200 1540027131989
thread a num = 100 1540027133989

可以看到,多個執行緒訪問多個物件,該例項建立了 2 個 HashSelPrivateNum 類的物件,就產生了 2 個物件鎖,當執行緒 Thread A1 執行 synchronized 方法 addI(),便持有該方法所屬物件 numRef1 的鎖,此時執行緒 B 並不用等待,因為持有物件 numRef2 的鎖,與 numRef1 鎖無關。此時方法是非同步執行的

當用兩個執行緒分別訪問同一個類的不同例項的時候,雖然類中方法是同步方法,但是輸出的結果是 非同步 的,即不是按照正確的順序來執行的

關鍵字 synchronized 取得的鎖都是物件鎖,而不是把一段程式碼或者方法當作鎖,之前的那個例子,是 多個執行緒訪問同一個物件,此時哪個執行緒先執行帶有 synchronized 關鍵字的方法,哪個執行緒就持有了該方法所屬物件的鎖,此時其他執行緒只能等待,等待這個持有物件鎖的執行緒執行完 run 方法之後,在繼續執行。因此這個執行和等待的過程是嚴格按照順序,即 同步 的方式來進行的

該例項建立了 2 個 HashSelPrivateNum 類的物件,就產生了 2 個物件鎖,雖然有鎖,但都是各自的,即自己執行自己的,不需要等待其他執行緒完成才能執行,也不會產生資料的重複

四、髒讀

即兩個執行緒對同一個物件中的資料進行修改時發生的資料交叉或者重複的問題

public class PubVar {

    public String username = "AAA";
    public String password = "123456";

    synchronized public void setValue(String username, String password) {
        try {
            this.username = username;
            System.out.println(Thread.currentThread().getName() + " setValue begin"
                    + " user = " + this.username + " pas = " + this.password + " " + System.currentTimeMillis());
            //在給 password 賦值的時候將當前執行緒睡眠 2s
            Thread.sleep(2000);
            this.password = password;
            System.out.println(Thread.currentThread().getName() + " setValue end"
                    + " user = " + this.username + " pas = " + this.password + " " + System.currentTimeMillis());
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    public void getValue() {
        System.out.println(Thread.currentThread().getName() + " getvalue"
                + " user = " + username + " pas = " + password + " " + System.currentTimeMillis());
    }

}

建立執行緒 ThreadA4

public class ThreadA4 extends Thread {

    private PubVar pubVar;

    public ThreadA4(PubVar pubVar) {
        this.pubVar = pubVar;
    }

    @Override
    public void run() {
        pubVar.setValue("BBB","654321");
    }

    public static void main(String[] args) throws InterruptedException {
        PubVar pubVar = new PubVar();
        ThreadA4 threadA4 = new ThreadA4(pubVar);
        threadA4.start();
        //使得 main 執行緒睡眠 2s
        Thread.sleep(2000);
        pubVar.getValue();
    }
}

結果是:

Thread-0 setValue begin user = BBB pas = 123456 1540030412129
main getvalue user = BBB pas = 123456 1540030414129		//該資料的 pas 是還沒有賦值之前的值
Thread-0 setValue end user = BBB pas = 654321 1540030414129

因為 PubVar 類中的 getValue 方法並不是同步的,索引可以在任意時刻呼叫,因此, setValue 方法還沒有給 password 變數賦完值,就直接執行 getValue 方法了,此時輸出的資料是隻有一半是正確的

如果我們給 getValue 方法加上 synchronized 關鍵字,這個時候 setValue 和 getValue 就依次執行了,結果是:

Thread-0 setValue begin user = BBB pas = 123456 1540040547078
Thread-0 setValue end user = BBB pas = 654321 1540040549080
main getvalue user = BBB pas = 654321 1540040549080

分析一下兩個過程,首先,執行緒 ThreadA4 獲得了物件 pubVar 的鎖,然後線上程 ThreadA4 中執行物件所在類的同步方法 setValue,這個時候,其他執行緒只有等執行緒 ThreadA4 執行完 setValue 後才能執行這個方法

  • 對於第 1 個例子,其中的 getValue 不是同步方法,同時也是執行緒 main 呼叫的,這個時候,因為不是同步方法,所以執行緒 main 可以在任意時刻呼叫這個非同步方法(沒有 synchronized 修飾),也就是說,可能向上面一樣,賦值到一半就輸出了,也有可能全部賦值完再輸出,只是這個時候和物件鎖無關了
  • 對於第 2 個例子,其中 getValue 是同步方法,此時類 PubVar 中就有兩個同步方法,因為 setValue 正在執行,即執行緒 ThreadA4 持有該方法所在物件 pubVar 的物件鎖,而執行緒 main 也要呼叫該物件的另一個同步方法 getValue,所以執行緒 main 必須等待執行緒 ThreadA4 執行完 setValue 方法並且釋放物件鎖之後才能呼叫 getValue 方法。這時執行緒 ThreadA4 已經按照程式碼執行順序對變數 username 和 password 進行了賦值,最好執行緒 main 再呼叫方法進行輸出,這個時候不存在髒讀的情況

簡單的說

  1. 一個物件裡面,如果只有一個同步方法 X,如果它被一個執行緒 A 呼叫,即執行緒 A 獲取了 X 所在物件的鎖,那麼其他執行緒必須等到執行緒 A 執行完方法 X 之後才能呼叫方法 X,但是其他執行緒卻可以隨意呼叫物件裡的非同步方法,與物件鎖無關,這個時候會出現髒讀
  2. 一個物件裡面,如果有一個同步方法 X,它被一個執行緒 A 呼叫,即執行緒 A 獲取了 X 所在物件的鎖,同時還有一個同步方法 Y,它被另一個執行緒 B 呼叫,此時執行緒 B 不能隨意執行方法 Y 了,必須等到執行緒 A 將方法 X 執行完,釋放物件鎖之後才能執行方法 Y,這個時候與物件鎖有關,不會出現髒讀