併發編程中三個基礎概念(原子性,可見性,有序性)的理解與實踐

並行編程中三個基礎概念(原子性,可見性,有序性)的理解與實踐

在分析線程安全問題時,需要理解在並行編程中的三個基礎概念,即原子性(Atomicity),可見性(Visibility)以及有序性(Ordering)。

原子性

原子性簡介

即一個操作或者多個操作,要麼全部執行並且執行的過程不會被任何因素打斷,要麼就都不執行。

原子性的概念與數據庫中的原子性概念一致,最適合用銀行轉賬的例子來描述。

例如從賬戶A轉出100元到賬戶B,可以分解爲以下操作:

  1. 讀取賬戶A的餘額,300
  2. 計算賬戶A餘額減去100後的餘額,得到200
  3. 更新賬戶A的餘額爲200
  4. 讀取賬戶B的餘額,500
  5. 計算賬戶B的餘額加上100後的餘額,得到600
  6. 更新賬戶B的餘額爲600

以上操作要麼全部成功,要麼全部失敗,不能出現執行到某一個步驟然後停止的情況,例如執行完第3步後忽然停止,那麼賬戶A已經減少了100元,而賬戶B卻沒有任何增加。

原子性問題示例

在Java中典型的原子性問題就是變量的自加自減操作,例如count++操作看起來只是一個操作,但其實包含了三個獨立的操作:讀取count的值,將值加1,然後將計算結果寫入count。

例如我們編寫一個計數器,用來記錄當日登錄系統的用戶數量,如程序清單所示:

public class NonAtomicityDemo {
    public static void main(String[] args) {
        int i = 10;
        while (i-- > 0) {
            new Thread(() -> UnSafeCounter.addCount()).start();
        }
    }
}

class UnSafeCounter {
    private static int counter = 0;

    public static void addCount() {
        counter++;
        System.out.println(counter);
    }

}

/**
 * 輸出
 * 4
 * 10
 * 8
 * 9
 * 8
 * 6
 * 7
 * 4
 * 4
 * 5
 */

我們可以觀察到,開啓10個線程調用UnSafeCounter的addCount()方法,輸出的計數是錯誤的,這是因爲count++操作並不是原子操作的原因,不同的線程可能獲取count值時,count已經被另一個線程進行了賦值,而當前線程輸出的仍然是未被改變的值。這種多個線程多次調用中返回錯誤的值將導致嚴重的數據完整性問題。在併發編程中,這種由於不恰當的執行時序而出現的不正確結果情況有一個正式的名字:竟態條件(Race Condition)。

如何解決原子性問題

在程序有數據完整性問題的時候,一般如何解決呢?其實只要保證操作是符合原子性的,那麼就可以避免數據不一致的情況。

假定有兩個操作A和B,如果從執行A的線程來看,當另一個線程執行B時,要麼將B全部執行完,要麼完全不執行B,那麼A和B對彼此來說是原子的。原子操作是指,對於訪問同一個狀態的所有操作(包括該操作本身)來說,這個操作是一個以原子方式執行的操作。

一般我們可以通過以下幾種方法來保證操作的原子性。

  1. 通過synchronized關鍵字來保證同步

    
    public class AtomicityBySynchronizedDemo {
        public static void main(String[] args) {
            int i = 10;
            while (i-- > 0) {
                new Thread(() -> UnSafeCounter.addCount()).start();
            }
        }
    }
    
    
    class SafeCounter {
        private static int counter = 0;
    
        //synchronized關鍵字修飾
        public static synchronized void addCount() {
            counter++;
            System.out.println(counter);
        }
    
    }
    /**
    * 輸出,有序
    * 1
    * 2
    * 3
    * 4
    * 5
    * 6
    * 7
    * 8
    * 9
    * 10
    */
    
    
    

    通過對addCount()方法用synchronized關鍵字修飾來實現加鎖,這樣線程在爭奪執行權時必須要先獲得鎖,當線程獲得鎖後其他的線程都只能等待鎖的釋放,這樣就保證了addCount操作的原子性。
    例如:

    1. T1時刻,線程A獲得SafeCounter.class的鎖,開始執行addCount方法,這時,其他的線程都進入等待狀態,無法執行addCount()方法
    2. T2時刻,線程A執行addCount()方法結束,釋放SafeCounter.class的鎖,其他的線程爭奪鎖
    3. T3時刻,線程B獲得鎖,並執行addCount()方法,其他線程等待,無法執行addCount()方法
    4. T4時刻,線程B執行addCount()方法結束,釋放SafeCounter.class的鎖,其他的線程爭奪鎖
    5. ....往復上述過程,直到程序正常退出
  2. 通過原子類來保證數據同步
    concurrent包下提供了一些原子類,如:AtomicInteger、AtomicLong、AtomicReference等這些類提供了原子操作。

    例如:

    
    public class AtomicityByAtomicIntegerDemo {
        public static void main(String[] args) throws InterruptedException {
            int i = 10;
            while (i-- > 0) {
                new Thread(() -> SafeCounter.addCount()).start();
            }
            Thread.sleep(3000);//等待子線程執行結束
            System.out.println(SafeCounter.counter);
            //輸出10
        }
    
    
        static class SafeCounter {
            private static AtomicInteger counter = new AtomicInteger(0);
    
            public static void addCount() {
                counter.incrementAndGet();//對比count++,counter.incrementAndGet()是原子操作,而count++不是原子操作
            }
    
        }
    }
    
    
  3. Lock顯式鎖來進行同步
    //TODO

竟態條件(Race Condition)

當某個計算的正確性取決於多個線程的交替執行時序時,那麼就會發生競態條件。例如計數器Demo中的addCount()方法,addCount的正確性取決於對count進行+1操作和輸出count的值必須是順序執行的,但是不同的線程同時執行addCount()方法時,線程爭奪執行權的過程中產生竟態條件。例如:

  1. 在T1時刻線程A讀取了count的值=4,然後交出了執行權
  2. 在T2時刻線程B獲得了執行權也讀取了count的值=4,線程B對count進行+1操作,然後輸出count的值爲5並交出執行權
  3. 在T3時刻,線程A繼續執行將count(值爲4)進行+1操作,輸出count的值爲5並交出執行權
  4. 這就是多個線程交替執行,由於執行的時序,而產生的竟態條件問題。

那麼如何解決竟態條件問題呢?

我們稱導致競態條件發生的代碼區稱作臨界區。在臨界區中使用適當的同步就可以避免競態條件。在臨界區一般通過使用synchronized或者Lock顯式鎖來對代碼進行同步,這樣來解決竟態條件問題。

可見性(Visibility)

線程可見性簡介

線程之間的可見性是指當一個線程修改一個變量,另外一個線程可以馬上得到這個修改值。

假設我們有2個線程:A爲讀線程,讀取一個共享變量的值,並根據讀取到的值來判斷下一步執行邏輯;B爲寫線程,對一個共享變量進行寫入。很有可能B線程寫入的值對於A線程是不可見的。

兩個線程間的不可見性

我們用一個例子來表示這種線程間變量不可見的情況。Nonvisibility中的示例包含兩個共享數據的線程。寫線程將更新標誌,讀線程將等待直到設置標誌:

package com.random.jcp.base.thread.nature.visibility;


public class NonVisibilityDemo {
    public static void main(String[] args) throws InterruptedException {
        new ReadThread().start();
        Thread.sleep(1000);
        new WriteThread().start();
    }
}


class ReadThread extends Thread {

    @Override
    public void run() {
        System.out.println("read-thread start");
        while (true) {
            if (ShareData.flag == 1) {
                System.out.println("read-thread end");
                break;
            }
        }
    }
}

class ShareData {
    public static int flag = -1;
}


class WriteThread extends Thread {
    @Override
    public void run() {
        ShareData.flag = 1;
        System.out.println("write-thread:flag=" + ShareData.flag);
    }
}


這個程序可能會一直循環下去,因爲讀線程可能讀取不到寫線程對於flag的寫入而永遠等待。

如何解決線程間不可見性

爲了保證線程間可見性我們一般有3種選擇:

  • volatile:只保證可見性
  • Atomic相關類:保證可見性和原子性
  • Lock: 保證可見性和原子性

使用volatile關鍵字來解決可見性問題

我們嘗試更改上一個示例,使用volatile關鍵字來修飾共享數據ShareData.flag

public class VisibilityByVolatileDemo {
    public static void main(String[] args) throws InterruptedException {
        new ReadThread().start();
        Thread.sleep(1000);
        new WriteThread().start();
    }

    static class ShareData {
        //使用volatile關鍵字修飾
        public static volatile int flag = -1;
    }

    static class ReadThread extends Thread {

        @Override
        public void run() {
            System.out.println("read-thread start");
            while (true) {
                if (ShareData.flag == 1) {
                    System.out.println("read-thread end");
                    break;
                }
            }
        }
    }

    static class WriteThread extends Thread {
        @Override
        public void run() {
            ShareData.flag = 1;
            System.out.println("write-thread:flag=" + ShareData.flag);
        }
    }
}


由於對ShareData.flag使用了volatile關鍵字進行了修飾,程序可以正常結束,並且讀線程可以正常的訪問到寫線程對共享數據flag的修改從而正常結束。

使用AtomicInteger類來解決可見性問題

我們再嘗試更改上一個示例,使用AtomicInteger類來包裝共享數據ShareData.flag:


import java.util.concurrent.atomic.AtomicInteger;

public class VisibilityByAtomicDemo {
    public static void main(String[] args) throws InterruptedException {
        System.out.println("\nVisibilityByAtomicDemo");
        new ReadThread().start();
        Thread.sleep(1000);
        new WriteThread().start();
    }

    static class ShareData {
        public static AtomicInteger flag = new AtomicInteger(-1);
        private static volatile AtomicBoolean ready = new AtomicBoolean(false);

    }

    static class ReadThread extends Thread {

        @Override
        public void run() {
            System.out.println("read-thread start");
            while (true) {
                if (ShareData.flag.get() == 1) {
                    System.out.println("read-thread end");
                    break;
                }
            }
        }
    }

    static class WriteThread extends Thread {
        @Override
        public void run() {
            ShareData.flag.set(1);
            System.out.println("write-thread:flag=" + ShareData.flag);
        }
    }
}


由於ShareData.flag使用的類型是AtomicInteger,寫線程對flag的修改對於讀線程是可見的,這樣寫線程可以讀取到flag被更新爲1並正常退出。

使用synchronized來解決可見性問題

使用synchronized關鍵字對操作加鎖也可以保證線程間的可見性,並且保證操作的原子性。
我們再構造一個示例來說明synchronized關鍵字所起的作用。首先我們還是需要2個線程,一個讀線程,一個寫線程,然後把讀寫操作封裝到ShareData中,然後觀察在沒有synchronized關鍵字修飾時程序
的運行情況。


public class NonVisibilityDemo2 {

    public static void main(String[] args) throws InterruptedException {
        Thread writeThread = new Thread(() -> {
            System.out.println("start write");
            ShareData.write(100);
            System.out.println("end write");
        });

        Thread readThread = new Thread(() -> {
            while (true) {
                if (ShareData.read() == 100) {
                    System.out.println("read it");
                    break;
                }
            }
        });

        readThread.start();
        Thread.sleep(1000);
        writeThread.start();

    }


    static class ShareData {
        public static int flag = 0;

        public static int read() {
            return flag;
        }

        public static void write(int value) {
            flag = value;
        }

    }
}
/**
 * 輸出:
 * start write
 * end write
 **/

可以觀察到程序並不停止,讀線程沒有讀取到寫線程對ShareData.flag的寫入操作。寫線程對flag的寫入對於讀線程不可見。

我們更改這個示例,使用synchronize關鍵字對ShareData的read()方法和write()方法進行加鎖操作。這樣保證ShareData.flag對於讀寫線程都是可見的。


public class VisibilityBySynchronizedDemo {


    public static void main(String[] args) throws InterruptedException {
        System.out.println("\nVisibilityBySynchronizedDemo\n");
        Thread writeThread = new Thread(() -> {
            System.out.println("start write");
            ShareData.write(100);
            System.out.println("end write");
        });

        Thread readThread = new Thread(() -> {
            System.out.println("reading...");
            while (true) {
                if (ShareData.read() == 100) {
                    System.out.println("read it");
                    break;
                }
            }
        });

        readThread.start();
        Thread.sleep(1000);
        writeThread.start();

    }


    static class ShareData {
        public static volatile int flag = 0;

        public static synchronized int read() {
            return flag;
        }

        public static synchronized void write(int value) {
            flag = value;
        }

    }
}
/**
 * 輸出:
 * VisibilityBySynchronizedDemo
 *
 * reading...
 * start write
 * end write
 * read it
 **/


由於read()方法和write()方法都使用了synchronized關鍵字修飾,保證了原子性和可見性,程序正常結束並且輸出read it。

線程間的不可見性是怎樣產生的

TODO

Thread.sleep()導致的線程可見的情況

TODO

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