Java併發編程之原子性操做

上頭一直在說以線程爲基礎的併發編程的好處了,什麼提升處理器利用率啦,簡化編程模型啦。可是磚家們仍是認爲併發編程是程序開發中最不可捉摸、最詭異、最扯犢子、最麻煩、最噁心、最心煩、最容易出錯、最不符合社會主義核心價值觀的一個部分~ 形成這麼多最的緣由其實很簡單:進程中的各類資源,好比內存和I/O,在代碼裏以變量的形式展示,而某些變量在多線程間是共享、可變的,共享意味着這個變量能夠被多個線程同時訪問,可變意味着變量的值可能被訪問它的線程修改。圍繞這些共享、可變的變量造成了併發編程的三大殺手:安全性、活躍性、性能,下邊咱們來詳細嘮叨這些風險~java

共享變量的含義

並非全部內存變量均可以被多個線程共享,在一個線程調用一個方法的時候,會在棧內存上爲局部變量以及方法參數申請一些內存,在方法調用結束的時候,這些內存便被釋放。不一樣線程調用同一個方法都會爲局部變量和方法參數拷貝一個副本(若是你忘了,須要從新學習一下方法的調用過程),因此這個棧內存是線程私有的,也就是說局部變量和方法參數是不能夠共享的。可是對象或者數組是在堆內存上建立的,堆內存是全部線程均可以訪問的,因此包括成員變量、靜態變量和數組元素是可共享的,咱們以後討論的就是這些能夠被共享的變量對併發編程形成的風險~ 若是不強調的話,咱們下邊所說的變量都表明成員變量、靜態變量或者數組元素。編程

安全性

原子性操做、內存可見性和指令重排序是構成線程安全性的三個主題,下邊咱們詳細看哈~數組

原子性操做

咱們先拿一個例子開場:安全

public class Increment {

    private int i;

    public void increase() {
        i++;
    }

    public int getI() {
        return i;
    }

    public static void test(int threadNum, int loopTimes) {
        Increment increment = new Increment();

        Thread[] threads = new Thread[threadNum];

        for (int i = 0; i < threads.length; i++) {
            Thread t = new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int i = 0; i < loopTimes; i++) {
                        increment.increase();
                    }
                }
            });
            threads[i] = t;
            t.start();
        }

        for (Thread t : threads) {  //main線程等待其餘線程都執行完成
            try {
                t.join();
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }

        System.out.println(threadNum + "個線程,循環" + loopTimes + "次結果:" + increment.getI());
    }

    public static void main(String[] args) {
        test(20, 1);
        test(20, 10);
        test(20, 100);
        test(20, 1000);
        test(20, 10000);
        test(20, 100000);
    }
}

其中,increase方法的做用是給成員變量i增1,test方法接受兩個參數,一個是線程的數量,一個是循環的次數,每一個線程中都有一個將成員變量i增1給定循環次數的任務,在全部線程的任務都完成以後,輸出成員變量i的值,若是沒有什麼問題的話,程序執行完成後成員變量i的值都是threadNum*loopTimes。你們看一下執行結果:網絡

20個線程,循環1次結果:20
20個線程,循環10次結果:200
20個線程,循環100次結果:2000
20個線程,循環1000次結果:19926
20個線程,循環10000次結果:119903
20個線程,循環100000次結果:1864988

咦,貌似有點兒不對勁唉~再次執行一遍的結果:多線程

20個線程,循環1次結果:20
20個線程,循環10次結果:200
20個線程,循環100次結果:2000
20個線程,循環1000次結果:19502
20個線程,循環10000次結果:100157
20個線程,循環100000次結果:1833170

這就更使人奇怪了~~ 當循環次數增長時,執行結果與咱們預期不一致,並且每次執行貌似都是不同的結果,這個是個什麼鬼?併發

答:這個就是多線程的非原子性操做致使的一個不肯定結果。ide

啥叫個原子性操做呢?就是一個或某幾個操做只能在一個線程執行完以後,另外一個線程才能開始執行該操做,也就是說這些操做是不可分割的,線程不能在這些操做上交替執行。java中自帶了一些原子性操做,好比給一個非long、double基本數據類型變量或者引用的賦值或者讀取操做。oop

爲何強調非long、double類型的變量?咱們稍後看哈~

那i++這個操做不是一個原子性操做麼?性能

答:還真不是,這個操做其實至關於執行了i = i + 1,也就是三個原子性操做:

  1. 讀取變量i的值
  2. 將變量i的值加1
  3. 將結果寫入i變量中

因爲線程是基於處理器分配的時間片執行的,在這個過程當中,這三個步驟可能讓多個線程交叉執行,爲簡化過程,咱們以兩個線程交叉執行爲例,看下圖:

這個圖的意思就是:
圖片描述

  1. 線程1執行increase方法先讀取變量i的值,發現是5,此時切換到線程2執行increase方法讀取變量i的值,發現也是5。
  2. 線程1執行將變量i的值加1的操做,獲得結果是6,線程二也執行這個操做。
  3. 線程1將結果賦值給變量i,線程2也將結果賦值給變量i。

在這兩個線程都執行了一次increase方法以後,最後的結果居然是變量i從5變到了6,而不是咱們想象中的7。。。

另外,因爲CPU的速度很是快,這種交叉執行在執行次數較低的時候體現的並不明顯,可是在執行次數多的時候就十分明顯了,從咱們上邊測試的結果上就能看出。

在真實編程環境中,咱們每每須要某些涉及共享、可變變量的一系列操做具備原子性,咱們能夠從下邊三個角度來保證這些操做具備原子性。

從共享性解決

若是一個變量變得不能夠被多線程共享,不就能夠隨便訪問了唄哈哈,大體有下面這麼兩種改進方案。

儘可能使用局部變量解決問題

由於方法中的局部變量(包括方法參數和方法體中建立的變量)是線程私有的,因此不管多少線程調用某個不涉及共享變量的方法都是安全的。因此若是能將問題轉換爲使用局部變量解決問題而不是共享變量解決,那將是極好的哈~。不過我貌似想不出什麼案例來講明一下,等想到了再說哈,各位想到了也能夠告訴我哈。

使用ThreadLocal類

爲了維護一些線程內能夠共享的數據,java提出了一個ThreadLocal類,它提供了下邊這些方法:

public class ThreadLocal<T> {

    protected T initialValue() {
        return null;
    }

    public void set(T value) {
        ... 
    }

    public T get() {
        ... 
    }

    public void remove() {
         ...
     }
}

其中,類型參數T就表明了在同一個線程中共享數據的類型,它的各個方法的含義是:

  • T initialValue():當某個線程初次調用get方法時,就會調用initialValue方法來獲取初始值。
  • void set(T value):調用當前線程將指定的value參數與該線程創建一對一關係(會覆蓋initialValue的值),以便後續get方法獲取該值。
  • T get():獲取與當前線程創建一對一關係的值。
  • void remove():將與當前線程創建一對一關係的值移除。

咱們能夠在同一個線程裏的任何代碼處存取該類型的值:

public class ThreadLocalDemo {

    public static ThreadLocal<String> THREAD_LOCAL = new ThreadLocal<String>(){
        @Override
        protected String initialValue() {
            return "調用initialValue方法初始化的值";
        }
    };

    public static void main(String[] args) {
        ThreadLocalDemo.THREAD_LOCAL.set("與main線程關聯的字符串");
        new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("t1線程從ThreadLocal中獲取的值:" + ThreadLocalDemo.THREAD_LOCAL.get());
                ThreadLocalDemo.THREAD_LOCAL.set("與t1線程關聯的字符串");
                System.out.println("t1線程再次從ThreadLocal中獲取的值:" + ThreadLocalDemo.THREAD_LOCAL.get());
            }
        }, "t1").start();

        System.out.println("main線程從ThreadLocal中獲取的值:" + ThreadLocalDemo.THREAD_LOCAL.get());
    }
}

執行結果是:

main線程從ThreadLocal中獲取的值:與main線程關聯的字符串
t1線程從ThreadLocal中獲取的值:調用initialValue方法初始化的值
t1線程再次從ThreadLocal中獲取的值:與t1線程關聯的字符串

從這個執行結果咱們也能夠看出來,不一樣線程操做同一個 ThreadLocal 對象執行各類操做而不會影響其餘線程裏的值。這一點很是有用,好比對於一個網絡程序,一般每個請求都分配一個線程去處理,能夠在ThreadLocal裏記錄一下這個請求對應的用戶信息,好比用戶名,登陸失效時間什麼的,這樣就頗有用了。

雖然ThreadLocal頗有用,可是它做爲一種線程級別的全局變量,若是某些代碼依賴它的話,會形成耦合,從而影響了代碼的可重用性,因此設計的時候仍是要權衡一會兒滴。

從可變性解決

若是一個變量能夠被共享,可是它自打被建立以後就不能被修改,那麼隨意哪一個線程去訪問均可以哈,反正又不能改變它的值,隨便讀啦~

再強調一遍,咱們寫的程序可能不只咱們本身會用,因此咱們不能靠猜、靠直覺、靠信任其餘使用咱們寫的代碼的客戶端程序猿,因此若是咱們想經過讓對象不可變的方式來保證線程安全,那就把該變量聲明爲 final 的吧 :

public class FinalDemo {
    private final int finalField;

    public FinalDemo(int finalField) {
        this.finalField = finalField;
    }
}

而後就能夠隨便在多線程間共享finalField這個變量嘍~

加鎖解決

鎖的概念

若是咱們的需求確實是須要共享而且可變的變量,又想讓某些關於這個變量的操做是原子性的,仍是以上邊的increase方法爲例,咱們如今面臨的困境是increase方法實際上是由下邊3個原子性操做累積起來的一個操做:

  1. 讀變量i;
  2. 運算;
  3. 寫變量i;

針對同一個變量i,不一樣線程可能交叉執行上邊的三個步驟,致使兩個線程讀到一樣的變量i的值,從而致使結果比預期的小。爲了讓increase方法裏的操做具備原子性,也就是在一個線程執行這一系列操做的同時禁止其餘線程執行這些操做,java提出了鎖的概念。

咱們拿上廁所作一個例子,好比咱們上廁所須要這幾步:

  1. 脫褲子
  2. 幹正事兒
  3. 擦屁股
  4. 提褲子

上廁所的時候必須把這些步驟都執行完了,才能圓滿的完成上廁所這個事兒,要否則執行到擦屁股環節被別人趕出來豈不是賊尷尬😅,因此爲了能安全的完成上廁所這個事兒,咱們不得不在進入廁所以後,就拿一把鎖把廁所門給鎖了,等提完褲子走出廁所的時候再把鎖給打開,讓其餘人來上廁所。

同步代碼塊

java語言裏把鎖給作了個抽象,任何一個對象均可以做爲一個鎖,也稱爲內置鎖,某個線程在進入某個代碼塊的時候去獲取一個鎖,在退出該代碼塊的時候把鎖給釋放掉,咱們來修改一下Increment的代碼:

public class Increment {

    private int i;

    private Object lock = new Object();

    public void increase() {
        synchronized (lock) {
            i++;
        }
    }

    public int getI() {
        synchronized (lock) {
            return i;
        }
    }

    public static void test(int threadNum, int loopTimes) {
    // ... 爲節省篇幅,省略此處代碼,與上邊的同樣
    }

    public static void main(String[] args) {
        test(20, 1);
        test(20, 10);
        test(20, 100);
        test(20, 1000);
        test(20, 10000);
        test(20, 100000);
    }
}

對i++加鎖以後的代碼執行結果是:

20個線程,循環1次結果:20
20個線程,循環10次結果:200
20個線程,循環100次結果:2000
20個線程,循環1000次結果:20000
20個線程,循環10000次結果:200000
20個線程,循環100000次結果:2000000

哈哈,這回就符合預期了,若是你不信能夠多執行幾遍試試。

咱們再回過頭來看這個加鎖的語法:

synchronized (鎖對象) {
    須要保持原子性的一系列代碼
}

若是一個線程獲取某個鎖以後,就至關於把廁所門兒給鎖上了,其餘的線程就不能獲取該鎖了,進不去廁所只能乾等着,也就是這些線程處於一種阻塞狀態,直到已經獲取鎖的線程把該鎖給釋放掉,也就是把廁所門再打開,某個線程就能夠再次得到鎖了。這樣線程們按照獲取鎖的順序執行的方式也叫作同步執行(英文名就是synchronized),這個被鎖保護的代碼塊也叫作同步代碼塊,咱們也會說這段代碼被這個鎖保護。因爲若是線程沒有得到鎖就會阻塞在同步代碼塊這,因此咱們須要格外注意的是,在同步代碼塊中的代碼要儘可能的短,不要把不須要同步的代碼也加入到同步代碼塊,在同步代碼塊中千萬不要執行特別耗時或者可能發生阻塞的一些操做,好比I/O操做啥的。

爲何一個對象就能夠看成一個鎖呢?咱們知道一個對象會佔據一些內存,這些內存地址但是惟一的,也就是說兩個對象不能佔用相同的內存。真實的對象在內存中的表示其實有對象頭和數據區組成的,數據區就是咱們聲明的各類字段佔用的內存部分,而對象頭裏存儲了一系列的有用信息,其中就有幾個位表明鎖信息,也就是這個對象有沒有做爲某個線程的鎖的信息。詳細狀況咱們會在JVM裏詳細說明,如今你們看個樂呵,瞭解用一個對象做爲鎖是有底層依據的哈~

鎖的重入

咱們前邊說過,當一個線程請求得到已經被其餘線程得到的鎖的時候,它就會被阻塞,可是若是一個線程請求一個它已經得到的鎖,那麼這個請求就會成功。

public class SynchronizedDemo {

    private Object lock = new Object();

    public void m1() {
        synchronized (lock) {
            System.out.println("這是第一個方法");
            m2();
        }
    }

    public void m2() {
        synchronized (lock) {
            System.out.println("這是第二個方法");
        }
    }

    public static void main(String[] args) {
        SynchronizedDemo synchronizedDemo = new SynchronizedDemo();
        synchronizedDemo.m1();
    }
}

執行結果是:

這是第一個方法
這是第二個方法

也就是說只要一個線程持有了某個鎖,那麼它就能夠進入任何被這個鎖保護的代碼塊。

小貼士:

這樣的重入鎖實現起來也簡單,能夠給每一個鎖關聯一個持有的線程和獲取鎖的次數,初始狀態下鎖的計數值是0,也就是沒有被任何線程持有鎖,當某個線程獲取這個鎖的時候,計數值爲1,若是繼續獲取該鎖,那麼計數值繼續遞增,每次退出一個同步代碼塊時,計數值遞減,直到遞減到0爲止。

同步方法

咱們前邊說爲了建立一個同步代碼塊,必須顯式的指定一個對象做爲鎖,有時候咱們想把整個方法的操做都寫入同步代碼塊,就像咱們上邊說過的increase方法,這種狀況下其實有個偷懶的辦法,由於咱們的程序中默默的藏着某些對象~

  • 對於成員方法來講,咱們能夠直接用this做爲鎖。
  • 對於靜態方法來講,咱們能夠直接用Class對象做爲鎖(Class對象能夠直接在任何地方訪問,若是不知道的話須要從新學一下反射了親)。

就像這樣:

public class Increment {

    private int i;

    public void increase() {
        synchronized (this) {   //使用this做爲鎖
            i++;
        }
    }

    public static void anotherStaticMethod() {
        synchronized (Increment.class) {   //使用Class對象做爲鎖
            // 此處填寫須要同步的代碼塊
        }
    }
}

爲了簡便起見,設計java的大叔們規定整個方法的操做都須要被同步,並且使用this做爲鎖的成員方法,使用Class對象做爲鎖的靜態方法,就能夠被簡寫成這樣:

public class Increment {

    private int i;

    public synchronized increase() {   //使用this做爲鎖
        i++;
    }

    public synchronized static void anotherStaticMethod() {   //使用Class對象做爲鎖
        // 此處填寫須要同步的代碼塊
    }
}

再寫一遍通用格式,你們長長記性:

public synchronized 返回類型 方法名(參數列表) {
    須要被同步執行的代碼
}

public synchronized static 返回類型 方法名(參數列表) {
    須要被同步執行的代碼
}

上述的兩種方法也被稱爲同步方法,也就是說整個方法都須要被同步執行,並且使用的鎖是this對象或者Class對象。

注意,同步方法只不過是同步代碼塊的另外一種寫法,沒什麼稀奇的~。

總結

  1. 共享、可變的變量造成了併發編程的三大殺手:安全性、活躍性、性能,本章詳細討論安全性問題。
  2. 本文中的共享變量指的是在堆內存上建立的對象或者數組,包括成員變量、靜態變量和數組元素。
  3. 安全性問題包括三個方面,原子性操做、內存可見性和指令重排序,本篇文章主要對原子性操做進行詳細討論。
  4. 原子性操做就是一個或某幾個操做只能在一個線程執行完以後,另外一個線程才能開始執行該操做,也就是說這些操做是不可分割的,線程不能在這些操做上交替執行。
  5. 爲了保證某些操做的原子性,提出了下邊幾種解決方案:
  • 儘可能使用局部變量解決問題
  • 使用ThreadLocal類解決問題
  • 從共享性解決,在編程時,最好使用下邊這兩種方案解決問題:
  • 從可變性解決,最好讓某個變量在程序運行過程當中不可變,把它使用final修飾。
  • 加鎖解決
  1. 任何一個對象均可以做爲一個鎖,也稱爲內置鎖。某個線程在進入某個同步代碼塊的時候去獲取一個鎖,在退出該代碼塊的時候把鎖給釋放掉。
  2. 鎖的重入是指只要一個線程持有了某個鎖,那麼它就能夠進入任何被這個鎖保護的代碼塊。
  3. 同步方法是一種比較特殊的同步代碼塊,對於成員方法來說,使用this做爲鎖對象,對於靜態方法來講,使用Class對象做爲鎖對象。
相關文章
相關標籤/搜索