Java多執行緒之執行緒同步【synchronized、Lock、volatitle】

airl發表於2022-03-24

image

執行緒同步

執行緒同步:當有一個執行緒在對記憶體進行操作時,其他執行緒都不可以對這個記憶體地址進行操作,直到該執行緒完成操作, 其他執行緒才能對該記憶體地址進行操作,而其他執行緒又處於等待狀態,實現執行緒同步的方法有很多。

為什麼要建立多執行緒?

在一般情況下,建立一個執行緒是不能提高程式的執行效率的,所以要建立多個執行緒。

  • 為什麼要執行緒同步

    • 多個執行緒同時執行的時候可能呼叫執行緒函式,在多個執行緒同時對同一個記憶體地址進行寫入,由於CPU時間排程上的問題,寫入資料會被多次的覆蓋,所以就要使執行緒同步。

    • 例如:我們去銀行存錢,那肯定是我們銀行卡里原本的錢加上要存入的錢。但是在你存錢的同時你的朋友在給你的銀行卡轉錢,這是兩個執行緒,這兩個執行緒同時拿到了銀行卡的本金,那麼這兩個執行緒最後都會返回一個總金額,那這兩個總金額都是不正確的,只有這兩次交易有一個先後順序才行,這就是執行緒同步的一個原因。

執行緒同步是意思

  • 同步就是協同步調,按預定的先後次序進行執行。如:你做完,我再做
    • 錯誤理解:“同”字從字面上容易理解為一起動作,其實不是,“同”字應是指協同、協助、互相配合。
    • 正確理解: 所謂同步,就是在發出一個功能呼叫時,在沒有得到結果之前,該呼叫就不返回,同時其它執行緒也不能呼叫這個方法。按照這個定義,其實絕大多數函式都是同步呼叫。但是一般而言,我們在說同步、非同步的時候,特指那些需要其他部件協作或者需要一定時間完成的任務。
    • 在多執行緒程式設計裡面,一些敏感資料不允許被多個執行緒同時訪問,此時就使用同步訪問技術,保證資料在任何時刻,最多有一個執行緒訪問,以保證資料的完整性。

執行緒同步作用

  • 執行緒有可能和其他執行緒共享一些資源,比如,記憶體,檔案,資料庫等。
  • 當多個執行緒同時讀寫同一份共享資源的時候,可能會引起衝突。這時候,我們需要引入執行緒“同步”機制,即各位執行緒之間要有個先來後到,不能一窩蜂擠上去搶作一團。
  • 執行緒同步的真實意思和字面意思恰好相反。執行緒同步的真實意思,其實是“排隊”:幾個執行緒之間要排隊,一個一個對共享資源進行操作,而不是同時進行操作

基本上所有解決執行緒安全問題的方式都是採用“序列化臨界資源訪問”的方式,即在同一時刻只有一個執行緒操作臨界資源,操作完了才能讓其他執行緒進行操作,也稱作同步互斥訪問。

在Java中一般採用synchronized和Lock來實現同步互斥訪問。

常用方法

Synchronized關鍵字

首先我們先來了解一下互斥鎖,
互斥鎖:就是能達到互斥訪問目的的鎖。

如果對一個變數加上互斥鎖,那麼在同一時刻,該變數只能有一個執行緒能訪問,即當一個執行緒訪問臨界資源時,其他執行緒只能等待。

在Java中,每一個物件都有一個鎖標記(monitor),也被稱為監視器,當多個執行緒訪問物件時,只有獲取了物件的鎖才能訪問。

在我們編寫程式碼的時候,可以使用synchronized修飾物件的方法或者程式碼塊,當某個執行緒訪問這個物件synchronized方法或者程式碼塊時,就獲取到了這個物件的鎖,這個時候其他物件是不能訪問的,只能等待獲取到鎖的這個執行緒執行完該方法或者程式碼塊之後,才能執行該物件的方法。

synchronized新增到程式碼塊位置

我們先寫一個不加Synchronized的多執行緒程式碼,這段程式碼是建立兩個執行緒,這段程式碼是建立兩個執行緒分別輸出五個數。
多次執行可以發現結果每次不一樣,這就導致了不確定性。我們給他加上同步方法會發現一個輸出完之後另一個才會輸出,這就可以空值共享資源不能同時被兩個執行緒得到。

package hello;

public class Hello {

    public static void main(String[] args) throws Exception {
        MyThread myThread = new MyThread();
        Thread thread = new Thread(myThread);
        Thread thread1 = new Thread(myThread);
        thread.start();
        thread1.start();
    }
}

class OutputData{
    //定義輸出方法
    public void output(Thread thread){
        for (int i=0;i<5;i++){
            System.out.println(thread.getName()+":"+"輸出"+i);
        }
    }
}

class MyThread implements Runnable{
    OutputData inserData = new OutputData();

    public void run(){
        inserData.output(Thread.currentThread());
    }
}
直接新增到方法上

我們在OutputData類裡面加入synchronized之後在執行就可以看到結果每次都是0123401234

class OutputData{
    //定義輸出方法
    public synchronized void output(Thread thread){
        for (int i=0;i<5;i++){
            System.out.println(thread.getName()+":"+"輸出"+i);
        }
    }
}
新增在程式碼塊

其實上面的程式碼還可以這樣加,這個裡面和上面的原理是一樣的。

class OutputData{
    //定義輸出方法
    public void output(Thread thread){
        //this就是當前物件
        synchronized (this) {
            for (int i = 0; i < 5; i++) {
                System.out.println(thread.getName() + ":" + "輸出" + i);
            }
        }
    }
}

釋放鎖時機

如果一個方法或者程式碼塊被synchronized關鍵字修飾,當執行緒獲取到該方法或程式碼塊的鎖,其他執行緒是不能繼續訪問該方法或程式碼塊的。
而其他執行緒要能訪問該方法或程式碼塊,就必須要等待獲取到鎖的執行緒釋放這個鎖,而在這裡釋放鎖只有兩種情況:

  • 執行緒執行完程式碼塊,自動釋放鎖;
  • 程式報錯,jvm讓執行緒自動釋放鎖。

Lock鎖同步

上面我們已經說了synchronized鎖釋放的時機
但是可能會有一種情況,當一個執行緒獲取到物件的鎖,然後在執行過程中因為一些原因(等待IO,呼叫sleep方法)被阻塞了,這個時候鎖還在被阻塞的執行緒手中,而其他執行緒這個時候除了等之外,沒有任何辦法,我們想一想這樣子會有多影響程式的效率。因此就需要有一種機制可以不讓等待的執行緒一直無期限地等待下去(比如只等待一定的時間或者能夠響應中斷),通過Lock就可以辦到。

在比如,當多個執行緒操作同一個檔案的時候,同時讀寫是會衝突的,同時寫也是會衝突的,但是同時讀是不會發生衝突的,而我們如果用synchronized來實現同步,就會出現一個問題:

如果多個執行緒都只是進行讀操作,所以當一個執行緒在進行讀操作時,其他執行緒只能等待無法進行讀操作。
因此就需要一種機制來使得多個執行緒都只是進行讀操作時,執行緒之間不會發生衝突,而通過Lock就可以辦到。
總的來說Lock要比synchronized提供的功能更多,可定製化的程度也更高,Lock不是Java語言內建的,而是一個類。

方法解釋

先看一下Lock介面的方法
image

  • lock()、tryLock()和lockInterruptibly()方法是用來獲取鎖的
  • unlock()方法是用來釋放鎖的。
  • tryLock()顧名思義,是用來嘗試獲取鎖的,並且該方法有返回值,表示獲取成功與否,獲取成功返回true,失敗返回false,從方法可以發現,該方法如果沒有獲取到鎖時不會繼續等待的,而是會直接返回值。
  • tryLock()的過載方法tryLock(long time, TimeUnit unit)功能類似,只是這個方法會等待一段時間獲取鎖,如果過了等待時間還未獲取到鎖就會返回false,如果在等待時間之內拿到鎖則返回true。

首先lock()方法是平常使用得最多的一個方法,就是用來獲取鎖。如果鎖已被其他執行緒獲取,則進行等待。

由於在前面講到如果採用Lock,必須主動去釋放鎖,並且在發生異常時,不會自動釋放鎖。因此一般來說,使用Lock必須在try{}catch{}塊中進行,並且將釋放鎖的操作放在finally塊中進行,以保證鎖一定被被釋放,防止死鎖的發生。

一般格式:

        Lock lock = ...;
        lock.lock();
        try {
            //處理任務
        }catch (Exception e){
            //捕捉異常
        }finally{
            lock.unlock();
        }

使用Lock.lock()獲取鎖

package hello;

import javax.sound.sampled.FloatControl;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class Hello {


    public static void main(String[] args) {
        Lock lock = new ReentrantLock();

        OutputData outputData = new OutputData();
        new Thread(){
            public void run(){
                outputData.output(Thread.currentThread(),lock);
            };
        }.start();


        new Thread(){
            public void run(){
                outputData.output(Thread.currentThread(),lock);
            };
        }.start();


    }
}


class OutputData{
    public void output(Thread thread,Lock lock){
        lock.lock();
        try {
            System.out.println(thread.getName()+"得到了鎖");
            Thread.sleep(100);//加這個睡眠是為了結果效果明顯
        }catch (Exception e){
            e.printStackTrace();
        }finally {
            System.out.println(thread.getName()+"釋放了鎖");
            lock.unlock();
        }

    }
}

使用tryLock()獲取鎖

把OutPutData類裡面的output方法修改一下就可以了

class OutputData{
    public void output(Thread thread,Lock lock){
        if(lock.tryLock()){
            try {
                System.out.println(thread.getName()+"得到了鎖");
                Thread.sleep(100);//加這個睡眠是為了結果效果明顯
            }catch (Exception e){
                e.printStackTrace();
            }finally {
                System.out.println(thread.getName()+"釋放了鎖");
                lock.unlock();
            }
        }else{
            System.out.println(thread.getName()+"獲取鎖失敗");
        }

    }
}

volatile同步實現

volatile含義和特點
我們知道每個執行緒執行的時候都有自己的工作記憶體,會把變數拷貝到自己的快取中去,一般情況下你在自己快取修改的變數不會立即重新寫入主記憶體,這就導致類多執行緒同步問題,那麼volatile關鍵字的特點是:

  • 當一個共享變數被volatile修飾時,它就具備了“可見性”,即這個變數被一個執行緒修改時,這個改變會立即被其他執行緒知道。就是你在這個執行緒修改了這個變數,另外的執行緒會立刻知道,也改變。
  • 當一個共享變數被volatile修飾時,會禁止“指令重排序”

volatile關鍵字會產生什麼效果

  • 使用volatile關鍵字會強制將變數的修改的值立即寫至主記憶體;
  • 使用volatile關鍵字,當執行緒對某個變數(這個變數定義為V1)修改時,會強制將所有用到變數V1的執行緒對應的快取中V1的快取行置為無效。
  • 由於執行緒的V1快取行無效,所以在執行時執行緒會讀取主存中V1變數的值。
  • 所以到最後執行緒讀取到的就是V1最新的值

volatile應用示例

這段程式碼可以看出我們並沒給有加鎖,但是這個int變數sum還是按順序加的,說明他在改變之後立即就把主記憶體裡的變數改變了。也算是一種同步方式吧。

package hello;

public class Hello {


    public static void main(String[] args) {
        AddClass addClass = new AddClass();
        new Thread() {
            public void run(){
                for (int j=0;j<100;j++){
                    addClass.add();
                    System.out.println(Thread.currentThread().getName()+":"+addClass.sum);
                }
            }
        }.start();
        new Thread(){
            public void run(){
                for (int j=0;j<100;j++){
                    addClass.add();
                    System.out.println(Thread.currentThread().getName()+":"+addClass.sum);
                }
            }
        }.start();
    }
}

class  AddClass{
    public volatile  int sum = 0;
    public void add(){
        sum++;
    }
}

多執行緒同步小應用

火車站買票

package hello;

public class Hello {


    public static void main(String[] args) {
            //例項化站臺物件,
            Station station1=new Station();
            Station station2=new Station();
            Station station3=new Station();

            // 讓每一個站臺物件各自開始工作
            station1.start();
            station2.start();
            station3.start();
        }
    }


class Station extends Thread  {
    static volatile int p = 20;
    static Object ob = new Object();
    public void run() {
        while (p > 0) {
            synchronized (ob) {
                if (p > 0) {
                    System.out.println("賣出了第" + p + "張票");

                    p = p - 1;
                }
            }
            synchronized (this) {
                if (p == 0) {
                    System.out.println("票賣完了");
                }
            }
            try {
                Thread.sleep(100);
            } catch (Exception e) {

            }
        }

    }
}


相關文章