volatile與synchronized

      很早之前就面試就被人問到,除了synchroized同步鎖意外,還有沒有其他的方式來完成相關的信息同步了;這個問題記憶猶新呢,當時問的啞口無言,現在雖然也比較渣渣,所以得多總結,合理使用volatile與synchronized,也是對代碼的一種優化;

    造成線程安全問題的主要誘因有兩點,一是存在共享數據,二是存在多條線程共同操作共享數據。因此爲了解決這個問題,我們可能需要這樣一個方案,當存在多個線程操作共享數據時,需要保證同一時刻有且只有一個線程在操作共享數據,而且還要把當前的線程改變後的結構讓其他線程看得見,因此總結出了鎖的兩個特點:互斥,可見

一、synchronized。 重量級鎖

      在Java中,synchronized關鍵字是用來控制線程同步的,就是在多線程的環境下,控制synchronized代碼段不被多個線程同時執行。synchronized方法正常返回或者拋異常而終止,jvm會自動釋放對象鎖。synchronized既可以加在一段代碼上,也可以加在方法上,例如:

A 寫法
int number = 1; 
public synchronized void testOne(){
        try {
            System.out.println("=1==>開始---"+(++number));
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("=2==>結束---"+number);
}

public static void main(String[] args){
    final MainActivity mainActivity = new MainActivity();
    for (int i = 0; i < 3; i++) {
        new Thread(new Runnable() {
            @Override
            public void run() {
                mainActivity.testOne();
            }
        }).start();
    }
}
或者還可以這樣寫
B寫法
public void testOne(){
    synchronized(MainActivity.this){
        try {
            System.out.println("=1==>開始---"+(++number));
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("=2==>結束---"+number);
    }
}

打印結果:

=1==>開始---2
=2==>結束---2
=1==>開始---3
=2==>結束---3
=1==>開始---4
=2==>結束---4

這兩個方法是一樣的,能防止多個線程同時執行同一個對象的同步代碼段,不同的是  :A寫法 是修飾實例方法,作用於當前實例加鎖,進入同步代碼前要獲得當前實例的鎖 ;  B寫法 是修飾代碼塊,指定加鎖對象,對給定對象加鎖,進入同步代碼庫前要獲得給定對象的鎖(需要同步的代碼又只有一小部分,如果直接對整個方法進行同步操作,可能會得不償失,此時我們可以使用同步代碼塊的方式對需要同步的代碼進行包裹,這樣就無需對整個方法進行同步操作了); 

 

二、volatile(輕量級鎖)

      volatile是Java提供的一種輕量級的同步機制,同synchronized相比,volatile更輕量級,相比使用synchronized所帶來的開銷要小得多

     volatile是一個類型修飾符,作用是作爲指令關鍵字出現的,確保本條指令不會因編譯器的優化而省略,要求每次直接讀,volatile的變量是說這變量可能會被意想不到地改變,這樣,編譯器就不會去假設這個變量的值了

JMM是個抽象的內存模型,so 所謂的本地內存,主內存都是抽象概念,並不一定就真實的對應cpu緩存和物理內存)

-----------------volatile 是什麼,特點有什麼--------------------------------

精確地說就是,優化器在用到這個volatile變量時必須每次都小心地重新讀取這個變量的值,而不是使用保存在寄存器裏的備份

        如上面代碼段中的int類型變量number,線程A中 ++number 這個動作發生在線程A的本地內存中,此時還未同步到主內存中去;而線程B緩存了number的初始值1,此時可能沒有觀測到number的值被修改了,所以就導致了信息不對等的問題。通常都是加鎖synchronized或者Lock,但這些方式太重量級了,它們都是要獲取當前對象鎖的,開銷大。比較合理的方式就是volatile,當線程A的number發生了變化,也會同時刷新主內存中的數據,當線程B再使用number變量時,它不使用當前線程中的變量副本,而是去獲取主內存中的變量值,由此達到了各個線程中的數據同步

--------------------------volatile的原子性--------------------------------

但是注意了,實際運行出來的效果並不正確,主要是volatile不具備原子性,下面分析一下:

volatile int number2 = 1; 
    public void testTwo(){
        try { 
            System.out.println(Thread.currentThread().getName()+"==1==>開始---"+(++number2));
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(Thread.currentThread().getName()+"==2==>結束---"+(++number2));
    }

同樣在main方法中開啓三個線程執行該方法,多次運行效果如下:

Thread-0==1==>開始---2
Thread-1==1==>開始---3
Thread-2==1==>開始---4
Thread-1==2==>結束---5
Thread-0==2==>結束---7
Thread-2==2==>結束---6

Thread-0==1==>開始---2
Thread-1==1==>開始---3
Thread-2==1==>開始---4
Thread-2==2==>結束---5
Thread-0==2==>結束---6
Thread-1==2==>結束---5

很顯然,volatile在執行復合操作時,就表現的力不從心了;其實也好理解,++number2是先讀取-->疊加-->負值,分爲三步走,這樣就照成了較大的時間空隙,從而導致運行基數錯誤;那麼在線程A正在做加法運算的時候,線程B去讀取了主內存中還未被線程A所更新的數據去運算,這就照成了如上的兩個相同的結果;也就是說volatile沒有了snychronized的原子性;那麼做如下改動即可:

volatile AtomicInteger number2 = new AtomicInteger(0);
public void testTwo(){
        try {
            System.out.println(Thread.currentThread().getName()+"==1==>開始---"+(number2.incrementAndGet())); 
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(Thread.currentThread().getName()+"==2==>結束---"+(number2.incrementAndGet()));
    }

Thread-0==1==>開始---2
Thread-2==1==>開始---3
Thread-1==1==>開始---1
Thread-1==2==>結束---6
Thread-2==2==>結束---4
Thread-0==2==>結束---5

      結論,多次運行後,證明該方案可行,AtomicInteger原子類(AtomicBoolean/AtomicLong/AtomicInteger三個原子更新基本類型)保證了++number 的原子性;到此就分析完了,爲什麼volatile修飾的變量不能做複合操作了,所以大家在使用時一定要注意它的這個特性

 

--------------------------volatile的禁止指令重排序優化--------------------------

那先來簡單瞭解一下 重排序的背景

        我們知道現代CPU的主頻越來越高,與cache的交互次數也越來越多。當CPU的計算速度遠遠超過訪問緩存時,會產生cache wait,過多的 緩存等待⌛️ 就會造成性能瓶頸。
針對這種情況,多數架構(包括X86)採用了一種將cache分片的解決方案,即將一塊cache劃分成互不關聯地多個 slots (邏輯存儲單元,又名 Memory Bank 或 Cache Bank),CPU可以自行選擇在多個 idle bank 中進行存取。這種 SMP 的設計,顯著提高了CPU的並行處理能力,也迴避了cache訪問瓶頸。

Memory Bank的劃分

        一般 Memory bank 是按cache address來劃分的。比如 偶數adress 0×12345000?分到 bank 0, 奇數address 0×12345100?分到 bank1。

重排序的種類

        編譯期重排。編譯源代碼時,編譯器依據對上下文的分析,對指令進行重排序,以之更適合於CPU的並行執行。

        運行期重排,CPU在執行過程中,動態分析依賴部件的效能,對指令做重排序優化。

       關於執行集優化請查看https://www.jianshu.com/p/c6f190018db1

 

java代碼來說明一下吧 

int tag = 1,b;   boolean bl = false;

public void three(){ tag = 2; bl = true; }

public void doit(){ if (bl){ System.out.println(b = tag + 1); } }

 public static void main(String[] args){
        final MainActivity mainActivity = new MainActivity();
        for (int i = 0; i < 3; i++) {
            new Thread(new Runnable() {
                @Override
                public void run() { 
                    mainActivity.three();
                    mainActivity.doit();
                }
            }).start();
        }
    }

       我在論證的時候,並沒出現因爲指令重排序導致的運行異常,不過原理性還是要了解的,畢竟這也是潛在的危險

      上面運行的結果如果是正常的話,結果肯定b=3; 但也有可能b仍然爲2。上面我們提到過,爲了提供程序並行度,編譯器和處理器可能會對指令進行重排序,而上例中three方法中的tag 和bl 兩個變量不存在數據依賴關係,則有可能會被重排序,先執行bl=true再執行tag=2。而此時線程B會執行doit方法,而線程A中tag=2這個操作還未被執行,所以b=tag+1的結果也有可能依然等於2。

  使用volatile關鍵字修飾共享變量便可以禁止這種重排序。若用volatile修飾共享變量,在編譯時,會在指令序列中插入內存屏障來禁止特定類型的處理器重排序

 

三、總結

(1) volatile 一種輕量級的同步機制,具有可見性,但不具備synchronized擁有的原子性
(2) volatile 具有禁止指令重排序優化
(3) 性能方面,synchronized關鍵字是防止多個線程同時執行一段代碼,就會影響程序執行效率,而volatile關鍵字在讀取臨界值方面性能要優於synchronized。

 

 

 

        

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