【Java并發學習】之線程的同步

【Java并發學習】之線程的同步

前言

在前面一個小節中,我們學習了線程的概念以及在Java中創建任務的方式,并且將任務委托給對應的線程進行執行,本小節我們主要來學習線程之間的關系之一的同步,包含臨界區、臨界資源、線程同步的兩種主要方法

線程的關系

從廣義上來講,線程之間有三種關系

  • 沒有關系:多個線程之間相互獨立,既不競爭資源,也沒有任何的合作關系,只是各自完成自己的任務
  • 競爭關系:兩個及以上的線程之間存在對某個或者某些資源的競爭
  • 合作關系:兩個及以上的線程共同合作,完成某項任務

臨界區及臨界資源

學習線程之間的同步,必不可少會接觸到臨界區以及臨界資源這兩個概念,而線程之間存在競爭關系本質上就是由于臨界資源的存在,而解決的方式就是使得多個線程之間能夠序列化訪問臨界資源

  • 臨界資源:臨界資源指的是程序中會被多個線程共享的某個或者某些資源,可以是軟件資源也可以是硬件資源,比如某個變量,某個數組,某個容器,打印機等等
  • 臨界區:臨界區指的是訪問臨界資源的代碼,同步操作的主要對象

線程的同步

線程同步是一個非常重要的概念,也是在并發編程中比不可少的關鍵操作,需要進行同步的本質原因在于,資源的有限,由于資源的數量少于線程的數量,于是線程在訪問這些資源的時候需要進行同步處理,如果沒有進行同步處理,或者同步處理時不恰當,輕則會導致數據出錯,重則會出現嚴重的并發問題

首先我們來看下沒有進行同步處理所帶來的后果

情景:假設現在一個公園有三個門,我們需要統計某個時刻公園里的人的總數,由于三個門的統計方式一樣,所以我們可以直接采用相同的三個線程來進行統計即可


/**
 * 公園類,包含一個計數器,進入以及離開記錄的操作
 */
class Park{
    private static int counter = 0;
    public void enter(){
        counter++;
    }
    public void leave(){
        counter--;
    }
    public int getCounter(){
        return counter;
    }
}

/**
 * 公園的進出登記
 */
class DoorWatcher implements Runnable{

    private Park park;

    public DoorWatcher(Park park) {
        this.park = park;
    }

    @Override
    public void run() {
        while (true){
            park.enter(); // 進入公園
            try {
                Thread.sleep(1000);// 模式人留在公園中的操作
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            park.leave(); // 離開公園
        }
    }
}

從上面的操作可以看出,如果程序正常執行,那么每個時刻公園中的人數應該是總體上保持穩定的,畢竟每個人進入公園之后會離開公園

對應的測試類如下


 public static void main(String[] args) throws InterruptedException {
        Park park = new Park();

        // 模擬公園的門的計數器
        int doorNumber = 3;
        Runnable jobs[] = new Runnable[doorNumber];
        for (int i = 0; i < doorNumber; i++){
            jobs[i] = new DoorWatcher(park);
        }
        // 執行對應的任務
        ExecutorService executor = Executors.newCachedThreadPool();
        for (int i = 0; i < doorNumber; i++) {
            executor.submit(jobs[i]);
        }
        // 定時檢查公園中的人數
        while (true){
            System.out.println("current number in the park is " + park.getCounter());
            Thread.sleep(3000); // 每隔三秒檢查一次
        }
    }

測試的可能結果


current number in the park is 0
current number in the park is 2
current number in the park is 2
current number in the park is 2
current number in the park is 3
current number in the park is 3
....
current number in the park is 2
current number in the park is 1
current number in the park is 1
....

執行測試代碼之后,可能你會發現實際上程序的運行并不是想象中那樣,而且不同次的運行可能結果還不一樣,出問題的地方在于counter++以及counter-- 這兩個操作,這兩個操作在Java中并不是原子操作,關于原子操作,我們會在后面進行深入的學習,這兩個操作都包含了取出數據,修改數據,寫入數據這三個步驟,而如果沒有進行同步處理,則在進行其中任何一個步驟的時候,當前線程可能被掛起,其他線程對counter進行修改,從而導致了數據的不一致,類似的情況還有很多,這里就不進行具體的分析。

由于出現問題的部分是對變量counter的操作,也就是說,這里的counter就是我們所說到的臨界資源,而對應的enter以及leave方法則是對應的臨界區,或者更詳細的說counter++,counter--就是我們所指的臨界區

解決線程同步問題的方法從廣義上來講只有一個,那就是序列化訪問臨界資源,也就是說,同一時刻只允許一個線程來對臨界資源進行操作,這種方式有效地解決了同步問題,而具體的操作就是對臨界區進行加鎖處理

加鎖的原理可以簡單的理解為,某個線程要進入臨界區之間,先申請對應的鎖,如果獲得該鎖,則可以進入,并且將該鎖上鎖,離開臨界區之后就將鎖解開;如果沒能申請到鎖,說明當前時刻臨界資源被其他線程占用,則自己進行阻塞,等待鎖可以使用

同步方法之使用synchronized

synchronized時Java提供的一個重量級鎖,或者稱之為監視器,也稱之為對象鎖,可以用于修飾方法或者代碼塊,默認鎖定的對象是this,也就是當前對象,也可以顯示指定所要鎖定的對象

修飾方法


class Park{
    private static int counter = 0;
    public synchronized void enter(){
        counter++;
        // ...
    }
    public synchronized void leave(){
        counter--;
        // ...
    }
    // ...
}

修飾代碼塊


class Park{
    private static int counter = 0;
    public void enter(){
        synchronized(this){
            counter++;
        }
        // ... 
    }
    public void leave(){
        synchronized(this){
            counter--;
        }
        // ...
    }
    // ...
}

synchronizd的使用比較簡單,只需要在需要進行同步的方法或者代碼塊加上該關鍵字即可,當然,synchronized還有一些比較復雜的原理,這個我們將在后面學習到

同步方法之使用locks

synchronized是在比較舊的JDK中所提供的用于同步的工具,在JDK5之后,還提供了另外的工具用于進行同步,即JUC中的Lock


import java.util.concurrent.locks.ReentrantLock;

class Park{
    private static int counter = 0;

    // 申請一個鎖
    private static Lock lock = new ReentrantLock();

    public  void enter(){
        lock.lock();// 加鎖
        try {
            counter++;
        }finally {
            lock.unlock();//解鎖
        }
    }
    public  void leave(){
        lock.lock();// 加鎖
        try {
            counter--;
        }finally {
            lock.unlock();//解鎖
        }
    }
    public int getCounter(){
        return counter;
    }
}

從上面的代碼中可以看到,使用Lock的操作比較繁瑣,我們需要自己申請鎖,并且在需要加鎖的時候手動加鎖,然后在離開的時候進行解鎖,可能你會注意到使用時的try...finally代碼塊,強烈建立在使用Lock的時候采用這種方式,因為在進行資源操作的時候,可能會發生異常,采用這種方式可以保證無論在什么時候都能將鎖進行解鎖,還記得finally的作用嗎?_

Lock的使用雖然比較繁瑣,而且還需要自己手動加鎖、解鎖,但是Lock也有synchronized所不具備的特點,那就是靈活,關于這兩者的具體區別,我們將在后面的內容中學習到

總結

本小節我們主要學習了線程同步的概念,臨界資源、臨界區的概念,沒有加鎖的可能帶來的危害,以及常見的同步方式,synchronized的使用以及Lock使用

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容