JAVA線程基礎(synchronized、同步與異步、volatile、髒讀)

一、線程安全(synchronized)

線程安全概念:當多個線程訪問某一個類(對象或方法)時,這個類始終都能表現出正確的行爲,那麼這個類(對象或方法)就是線程安全的。

synchronized:可以在任意對象及方法上加鎖,而加鎖的這段代碼稱爲“互斥區”或“臨界區”
下面看一個例子: 
public class MyThread extends Thread {

    private int count = 5;

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

    public static void main(String[] args) {
        MyThread myThread = new MyThread();
        Thread t1 = new Thread(myThread, "t1");
        Thread t2 = new Thread(myThread, "t2");
        Thread t3 = new Thread(myThread, "t3");
        Thread t4 = new Thread(myThread, "t4");
        Thread t5 = new Thread(myThread, "t5");

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

輸出的結果如下:

t3 count = 3 
t2 count = 3 
t1 count = 2 
t5 count = 1 
t4 count = 0
該段代碼就是使用了繼承Thread類,然後實現重寫run方法,run方法中,進行了count–操作,並打印了當前的線程名稱,之後產生了5個線程來調用同一份資源count,這樣,多個線程都去count–操作,那麼如果兩個線程有可能會取到相同的值,這時候打印出來的值就是相同的了。
這個結果呢,有的時候是對的,有的時候就像上面所示的那樣,是錯誤的結果,多試幾次就好了,因爲誰也沒辦法決定cpu到底選擇誰不是,所以,這個按咱們上面線程安全的定義來講,肯定是不安全的,因爲他的結果是不正確的。
那麼如何避免多個線程同時獲取到相同的count值呢,那麼就需要用到synchronized了,將代碼做如下修改:
@Override
public synchronized void run() {
    count--;
    System.out.println(currentThread().getName() + " count = " + count);
}

這樣,加了synchronized 鎖以後,多個線程就不會獲取到相同的值了,運行結果如下:

t1 count = 4 
t5 count = 3 
t4 count = 2 
t2 count = 1 
t3 count = 0

可能大家也注意到了,儘管結果正確,但是每次打印出來的線程名稱並不是按照t1-t5這樣順序執行的,那是爲什麼呢?因爲cpu在選擇線程的時候是隨機的。

當多個線程訪問Thread的run方法時,以排隊的方式進行處理(這裏排隊是按照CPU分配的先後順序而定的),一個線程必要的執行synchronized修飾的方法裏的代碼:

1、如果拿到鎖,執行synchronized代碼體內容;
2、拿不到鎖,這個線程就會不斷的嘗試獲得這把鎖,直到拿到爲止(等待),而且是多個線程同時去競爭這把鎖。(也就是會引起線程競爭的問題)

所以,雖然鎖能夠解決多線程下的線程安全問題,但是會引入新的問題,所以使用的時候要慎重。

二、多個線程多個鎖

多個線程中,如果每個線程操作的對象是不同的,那麼就會爲每個線程產生一把鎖,這個時候,在不同的線程中,鎖是不起作用的。

下面看一個例子:

public class MultiThread {

    private int num = 0;

    public synchronized void printNum(String tag) throws InterruptedException {
        if (tag.equals("a")) {
            num = 100;
            System.out.println("tag a, set num over!");
            Thread.sleep(1000);
        } else {
            num = 200;
            System.out.println("tag b, set num over!");
        }

        System.out.println("tag " + tag + ", num = " + num);
    }

    public static void main(String[] args) {
        MultiThread m1 = new MultiThread();
        MultiThread m2 = new MultiThread();

        Thread t1 = new Thread(() -> {
            try{
                m1.printNum("a");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });

        Thread t2 = new Thread(() -> {
            try{
                m2.printNum("b");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });

        t1.start();
        t2.start();
    }
}

說明:定義了一個printNum方法,並給這個方法加了鎖,這個方法內部是一個簡單的if else判斷而已,很簡單。底下的main方法中,創建了兩個MultiThread對象,分別爲m1和m2,接下來又創建l了兩個線程,分別是t1和t2,這兩個線程的實現方式是直接使用匿名類創建對象的方式,可以直接使用接口,這裏又使用了lamda表達式,所以看起來比較簡略,實際上是創建的Runnable接口的對象,其中括號中的內容是覆寫run方法的內容,可以看到兩個不同的線程分別調用了不同對象的printNum方法,那麼結果會是怎樣的呢?

tag a, set num over! 
tag b, set num over! 
tag b, num = 200 
tag a, num = 100
結果和我們預想的不對嗎?原因是這樣的,因爲我們這兩個線程分別調用的是兩個對象的printNum方法,雖然進行了加鎖,但是他們並不是一個對象,也就是說,這個同步鎖是加在對象上的,並沒有加在類級別上,不同的對象調用這個方法,同步鎖是沒有用的。

那麼如何讓對象方法加鎖執行呢,也就是讓同步鎖起作用呢,那就需要使用static關鍵字,代碼修改爲:

    private static int num = 0;

    public static synchronized void printNum(String tag) throws InterruptedException {
        if (tag.equals("a")) {
            num = 100;
            System.out.println("tag a, set num over!");
            Thread.sleep(1000);
        } else {
            num = 200;
            System.out.println("tag b, set num over!");
        }

        System.out.println("tag " + tag + ", num = " + num);
    }

這樣子再次執行查看效果:

tag a, set num over! 
tag a, num = 100 
tag b, set num over! 
tag b, num = 200
結果就和我們預期的一樣了,那麼爲什麼加上static之後就可以了呢,因爲我們加了static關鍵字之後,這個方法就變成了類方法,也就是說,這個方法是和類相關的,和某個對象是無關的了,所以就可以達到加鎖的效果了。所以我們查看結果,就是a先執行,然後b才執行。
總結:synchronized取得的鎖都是對象鎖,而不是把一段代碼當做鎖,所以示例代碼中那個線程先執行synchronized關鍵字的方法,哪個線程就持有該方法所屬對象的鎖(Lock),兩個對象,線程獲取的就是兩個不同的鎖,他們互不影響。 

有一種情況則是相同的鎖,即在靜態方法上加synchronized關鍵字,表示鎖定.class類,類一級別的鎖(獨佔.class類)。

三、對象鎖的同步和異步

同步:synchronized 
同步的概念就是共享,我們要牢牢記住“共享”這兩個字,如果不是共享的資源,就沒有必要進行同步。 
異步:asynchronized 
異步的概念就是獨立,相互之間不受任何的制約。就好像我們學習http的時候,在頁面發起AJAX請求,我們還可以繼續瀏覽或操作頁面的內容,二者之間沒有任何關係。 
其實這裏講的概念多出來一個,在java中對應着synchronized和不加關鍵字兩個狀態,加了synchronized關鍵字,那麼所有的線程都共享加鎖的代碼,而不加的時候呢,多個線程可以同時去訪問某塊代碼,彼此之間沒有關係,也就是異步的概念了。 
同步的目的就是爲了線程安全,其實對於線程安全來說,需要滿足兩個特性:
原子性(同步)
可見性
下面看一個例子:

public class MyObject {

    public synchronized void method1() {
        System.out.println(Thread.currentThread().getName());
        try{
            Thread.sleep(4000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    public void method2() {
        System.out.println(Thread.currentThread().getName());
    }

    public static void main(String[] args) {
        final MyObject myObject = new MyObject();

        Thread t1 = new Thread(() -> {
            myObject.method1();
        },"t1");

        Thread t2 = new Thread(() -> {
            myObject.method2();
        },"t2");

        t1.start();
        t2.start();
    }

}

結果爲:

t1 
t2
我們雖然訪問了同一個對象,但是method2方法並沒有加鎖,所以任何線程同可以同時訪問這個方法。
現在我們變更一下代碼,將synchronized關鍵字加到method2方法前,來看一下效果,t1已經打印出來了,但是t2要等待4S纔會打印出來,這是爲什麼呢?因爲我們的t1和t2線程都使用了myObject同一個對象,先訪問method1方法後,會給這個對象加鎖,所以其他的帶有synchronized關鍵字的方法都會被阻塞住的,其實最核心的,要理解synchronized他是真是類和對象的就行了。
總結:
A線程先持有object對象的Lock鎖,B線程如果在這個時候調用對象中的同步(synchronized)方法則需等待,也就是同步。

A線程先持有object對象的Lock鎖,B線程可以以異步的方式調用對象中的非synchronized修飾的方法。

四、髒讀

對於對象的同步和異步的方法,我們在設計自己的程序的時候,一定要考慮問題的整體,不然就會出現數據不一致的錯誤,很經典的錯誤就是髒讀(dirtyread) 。

下面看一個例子:

public class DirtyRead {

    private String username = "bjsxt";
    private String password = "123";

    public synchronized void setValue(String username, String password) {
        this.username = username;

        try{
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        this.password = password;

        System.out.println("setValue最終結果;username = " + username + ",password = " + password);
    }

    public void getValue() {
        System.out.println("getValue方法得到:username = " + this.username + ",password = " + this.password);
    }

    public static void main(String[] args) {
        final DirtyRead dirtyRead = new DirtyRead();

        Thread t1 = new Thread(() -> {
            dirtyRead.setValue("z3", "456");
        });

        t1.start();

        try{
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        dirtyRead.getValue();
    }

}
上面的代碼定義了類DirtyRead,其中有兩個私有屬性username和password,同時設置了兩個方法,一個是setValue,一個是getValue,其中setValue就是設置用戶名和密碼的,但是在方法前加了synchronized 關鍵字,而getValue沒有,就像上一節中說到的,在這種情況下,getValue是會和setValue同時請求的,不會產生阻塞,所以,這裏的main方法中,創建了一個DirtyRead的實例,然後創建了一個新的線程,在這個線程中,我們設置了新的用戶名和密碼,然後在main線程中,直接獲取了用戶名和密碼。結果如下所示:
getValue方法得到:username = z3,password = 123 
setValue最終結果;username = z3,password = 456
這個結果說明,在t1線程設置用戶名和密碼的過程中,main線程就已經獲取了用戶名和密碼的值了,因爲getValue的時候username已經是z3了,而密碼還沒有變更。這就是髒讀了,讀取的內容並不是最終的結果,而是中的一個值,如果讓getValue的結果正確,就必須讓setValue之後才能使用getValue方法,那麼,這裏可以使用上一節中講到的思路,由於在同一個對象中使用synchronized 關鍵字的不同方法,因爲加鎖是在對象級別的,所以當一個方法執行的時候,其他加synchronized 鎖的方法也都會被阻塞住。
所以根據上面說的思路,最終做一點小小的修改:
public synchronized void getValue() {
    System.out.println("getValue方法得到:username = " + this.username + ",password = " + this.password);
}
修改之後再來看結果:
setValue最終結果;username = z3,password = 456 
getValue方法得到:username = z3,password = 456

總結:在我們對一個對象的方法加鎖的時候,需要考慮業務的整體性,即爲setValue/getValue方法同時加鎖sychronized同步關鍵字,保證業務(service)的原子性,不然會出現業務錯誤(也從側面保證業務的一致性)。

五、鎖重入

synchronized鎖重入:指的是當一個線程得到一個對象鎖之後,再次請求該對象鎖時候,可以再次得到該對象的鎖。(獲得此對象的鎖後,可以訪問這個對象中所有帶鎖的方法)

下面看一個例子:

public class SynchronizedAgainService {

    public synchronized void methodA() {
        System.out.println("methodA runs .......");
        methodB();
    }
    public synchronized void methodB() {
        System.out.println("methodB runs ========");
        methodC();
    }
    public synchronized void methodC() {
        System.out.println("methodC runs +++++++++");
    }
}

public class SynchronizedAgainThread extends Thread {

    @Override
    public void run() {
        SynchronizedAgainService service = new SynchronizedAgainService();
        service.methodA();
    }

    public static void main(String[] args) {
        new SynchronizedAgainThread().start();
    }

}
此時執行結果如下: 
methodA runs .......
methodB runs ========
methodC runs +++++++++

可以看到當一個線程獲得了某個對象的鎖,此時這個對象的鎖還沒有釋放,此時依然可以再次獲得該對象的鎖,另外當存在父子類繼承關係時候,子類完全可以通過”可重入鎖”調用父類中的同步方法。

六、volatile關鍵字

volatile關鍵字的主要作用是使變量在多個線程間可見,但是卻不具備同步性(也就是原子性),可以算上是一個輕量級的synchronized,性能要比synchronized強很多,不會造成阻塞(在很多開源的架構裏,比如netty的底層代碼就大量使用Volatile)這裏需要注意,一般volatile用於只針對於多個線程可見的變量操作,並不能代替synchronized的同步功能。

下面看一個例子:

public class RunThread implements Runnable {
    private boolean isRunning = true;

    public boolean isRunning() {
        return isRunning;
    }

    public void setRunning(boolean isRunning) {
        this.isRunning = isRunning;
    }

    @Override
    public void run() {
        System.out.println("進入run了");
        while (isRunning) {

        }
        System.out.println("線程被停止了!");
    }

    public static void main(String[] args) throws InterruptedException {
        RunThread thread = new RunThread();
        new Thread(thread).start();
        Thread.sleep(100);
        thread.setRunning(false);
        System.out.println("已經賦值爲false");
    }
}

結果:

進入run了
已經賦值爲false
實例中的線程有一個通過判斷isRunning的真假實現的循環,默認是true,我們在測試方法中將isRunning改爲了false,但是根據結果顯示循環仍然在繼續,也就是我們賦的值並沒有起作用。 

現在在isRunning的聲明前面加上volatile關鍵字再試試:

volatile private boolean isRunning = true;

結果:

進入run了
已經賦值爲false
線程被停止了!
可以看到這次循環就被停止了,我們的賦值操作起作用了,volatile到底怎麼實現的呢? 
在啓動RunThread.java線程時,變量isRunning存在於公共堆棧和線程的私有堆棧兩個地方,爲了提升效率,線程一直在默認堆棧中取值,所以取得的isRunning一直是true;而代碼thread.setRunning(false);更新的卻是公共堆棧中isRunning的值,因此這就造成了公共堆棧中isRunning的值是false而線程持有的私有堆棧中isRunning的值卻是true,所以沒有volatile關鍵字時線程是死循環狀態。 
這樣的問題其實就是私有堆棧中的值和公共堆棧中的值不同步造成的,解決這樣的問題就要用volatile關鍵字了,它主要的作用就是當線程訪問isRunning這個變量時,強制性從公共堆棧中取值。 

volatile關鍵字只具有可見性,沒有原子性。要實現原子性建議使用atomic類的系列對象,支持原子性操作(注意atomic類只保證本身方法原子性,並不保證多次操作的原子性)。

關鍵字synchronized可以使多個線程訪問同一個資源時具有同步性,而且它還具有將線程工作內存中的私有變量與公共內存中的變量同步的功能,從而可以實現類似於volatile的功能。

下面看一個例子:

public class RunThread implements Runnable {
    private boolean isRunning = true;

    public boolean isRunning() {
        return isRunning;
    }

    public void setRunning(boolean isRunning) {
        this.isRunning = isRunning;
    }

    @Override
    public void run() {
        System.out.println("進入run了");
        while (isRunning) {
            // 在這添加同步代碼塊
            synchronized ("任意字符") {

            }
        }
        System.out.println("線程被停止了!");
    }

    public static void main(String[] args) throws InterruptedException {
        RunThread thread = new RunThread();
        new Thread(thread).start();
        Thread.sleep(100);
        thread.setRunning(false);
        System.out.println("已經賦值爲false");
    }
}

結果爲:

進入run了
已經賦值爲false
線程被停止了!

關鍵字synchronized可以保證在同一時刻,只有一個線程可以執行某一個方法或者某一個代碼塊。它包含兩個特徵:互斥性和可見性。同步synchronized不僅可以解決一個線程看到對象處於不一致的狀態,還可以保證進入同步方法或者同步代碼塊的每個線程,都看到由同一個鎖保護之前所有的修改效果。

synchronized和volatile的區別:

1、關鍵字volatile是線程同步的輕量級實現,所有volatile性能肯定比synchronized要好,並且volatile只能修飾於變量,而synchronized可以修飾方法以及代碼塊。隨着JDK新版本的發佈,synchronized在執行效率上得到了很大的提升,在開發中使用synchronized的比率還是比較大的。
2、多線程訪問volatile不會發生阻塞,而synchronized會出現阻塞。
3、volatile能保證數據的可見性,但不能保證原子性;而synchronized可以保證原子性,也可以間接保證可見性,以爲它會將私有內存和公共內存中的數據做同步。
4、最後再次強調,volatile解決的是變量在多個線程之間的可見性;而synchronized解決的是多個線程之間訪問資源的同步性。

七、其它

1、對於web應用程序,異常釋放鎖的情況,不果不及時處理,很可能對你的應用程序業務邏輯產生嚴重的錯誤,比如你現在執行整體上隊列任務,很多對象都去在等待第一個對象正確執行完畢再去釋放鎖,但是第一個對象由於異常的出現,導致業務邏輯沒有正常執行完畢,就釋放了鎖,那麼可想而知後續的對象執行的全都是錯誤的邏輯。

2、不要使用String的常量加鎖,會出現死循環問題。

3、鎖對象的改變問題,當使用一個對象進行加鎖的時候,要注意對象本身發生改變的時候,那麼持有的鎖就不同。如果對象本身不發生改變,那麼依然是同步的,即使是對象的屬性。


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