java面試題:談談你對volatile的理解

  最近打算整理下Java面試中頻率比較高,相對比較難的一些面試題,感興趣的小夥伴可以關注下。

Volatile關鍵字

  volatile是Java虛擬機提供的輕量級的同步機制.何爲輕量級呢,這要相對於synchronized來說。Volatile有如下三個特點。

volatile
保證可見性
不支持原子性
禁止指令重排序

  要搞清楚上面列舉的名詞可見性 原子性 指令重排的含義我們需要首先弄清楚JMM(Java內存模型是怎麼回事)

JMM

  JMM規定了內存主要劃分爲主內存工作內存兩種。此處的主內存和工作內存跟JVM內存劃分(堆、棧、方法區)是在不同的層次上進行的,如果非要對應起來,主內存對應的是Java堆中的對象實例部分,工作內存對應的是棧中的部分區域,從更底層的來說,主內存對應的是硬件的物理內存,工作內存對應的是寄存器和高速緩存.

在這裏插入圖片描述
  JVM在設計時候考慮到,如果JAVA線程每次讀取和寫入變量都直接操作主內存,對性能影響比較大,所以每條線程擁有各自的工作內存,工作內存中的變量是主內存中的一份拷貝,線程對變量的讀取和寫入,直接在工作內存中操作,而不能直接去操作主內存中的變量。但是這樣就會出現一個問題,當一個線程修改了自己工作內存中變量,對其他線程是不可見的,會導致線程不安全的問題。因爲JMM制定了一套標準來保證開發者在編寫多線程程序的時候,能夠控制什麼時候內存會被同步給其他線程。

可見性

  各個線程對主內存中共享變量的操作都是各個線程各自拷貝到自己的工作內存進行操作後再寫回主內存中的。
  這就可能存在一個線程A修改了共享變量X的值但還未寫回主內存時,另一個線程B又對準內存中同一個共享變量X進行操作,但此時A線程工作內存中共享變量X對線程B來說並不是可見,這種工作內存與主內存同步存在延遲現象就造成了可見性問題。
  通過代碼來看下可見性的問題

package com.dpb.spring.aop.demo;

import java.util.concurrent.TimeUnit;

/**
 * 可見性問題分析
 */
public class VolatileDemo1 {
    public static void main(String[] args){
        final MyData myData = new MyData();
        // 開啓一個新的線程
        new Thread(()->{
            System.out.println(Thread.currentThread().getName() + "開始了...");
            try{TimeUnit.SECONDS.sleep(2);} catch (InterruptedException e) {e.printStackTrace();}
            // 在子線程中修改了變量的信息  修改的本線程在工作內存中的數據
            myData.addTo60();
            System.out.println(Thread.currentThread().getName() + "更新後的數據是:"+myData.number);
        },"BBB").start();
        // 因爲在其他線程中修改的信息主線程的工作內存中的數據並沒有改變所以此時number還是爲0
        while(myData.number == 0){
            // 會一直卡在此處
            //System.out.println("1111");
        }
        System.out.println(Thread.currentThread().getName()+"\t number =  " + myData.number);
    }
}

class MyData{
	// 沒有用volatile來修飾
    int number = 0;

    public void addTo60(){
        this.number = 60;
    }

}

效果如下:

在這裏插入圖片描述

  通過volatile來解決此問題

在這裏插入圖片描述

在這裏插入圖片描述

  我們可以發現當變量被volatile修飾的時候,在子線程的工作內存中的變量被修改後其他線程中對應的變量是可以立馬知道的。這就是我們講的可見性

原子性

  原子性是不可分割完整性,也即某個線程正在做某個具體業務時,中間不可以被加塞或者分割,需要整體完成,要麼同時成功,要麼同時失敗.
  volatile是不支持原子性的,接下來我們可以驗證下。

package com.dpb.spring.aop.demo;

import java.util.concurrent.TimeUnit;

/**
 * 可見性問題分析
 */
public class VolatileDemo2 {
    public static void main(String[] args){
        final MyData2 myData = new MyData2();
        for (int i = 1; i <= 20 ; i++) {
            new Thread(()->{
                for (int j = 1; j <= 1000 ; j++) {
                    myData.addPlusPlus();
                }
            },String.valueOf(i)).start();
        }
        // 等待子線程執行完成
        while(Thread.activeCount() > 2){
            Thread.yield();
        }
        // 在主線程中獲取統計的信息值
        System.out.println(Thread.currentThread().getName()+"\t finnally number value: "+myData.number);
    }
}

class MyData2{
   // 操作的變量被volatile修飾了
    volatile int number = 0;

    public void addPlusPlus(){
        number++;
    }

}

執行的效果
在這裏插入圖片描述

  根據正常的邏輯在開啓的20個子線程,每個執行1000遍累加,得到的結果應該是20000,但是我們發現運行的結果大概率會比我們期望的要小,而且變量也已經被volatile修飾了。說明並沒有滿足我們要求的原子性。這種情況下我們要保證操作的原子性,我們有兩個選擇

  1. 通過synchronized來實現
  2. 通過JUC下的AtomicInteger來實現

  synchronized的實現是重量級的,影響併發的效率,所以我們通過AtomicInteger來實現。

package com.dpb.spring.aop.demo;

import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;

/**
 * 可見性問題分析
 */
public class VolatileDemo2 {
    public static void main(String[] args){
        final MyData2 myData = new MyData2();
        for (int i = 1; i <= 20 ; i++) {
            new Thread(()->{
                for (int j = 1; j <= 1000 ; j++) {
                    myData.addPlusPlus();
                    myData.addAtomicPlus();
                }
            },String.valueOf(i)).start();
        }
        // 等待子線程執行完成
        while(Thread.activeCount() > 2){
            Thread.yield();
        }
        // 在主線程中獲取統計的信息值
        System.out.println(Thread.currentThread().getName()+"\t finnally number value: "+myData.number);
        System.out.println(Thread.currentThread().getName()+"\t finnally number value: "+myData.atomicInteger.get());
    }
}

class MyData2{
   // 操作的變量被volatile修飾了
    volatile int number = 0;
    // AtomicInteger 來保證操作的原子性
    AtomicInteger atomicInteger = new AtomicInteger();

    public  void addPlusPlus(){
        number++;
    }

    public void addAtomicPlus(){
        atomicInteger.getAndIncrement();
    }

}

效果:

在這裏插入圖片描述
注意:通過效果發現AtomicInteger在多線程環境下處理的數據和我們期望的結果是一致的都是20000.說明實現的操作的原子性。

有序性

  計算機在執行程序時,爲了提高性能,編譯器和處理器常常會對指令做重排,一般分一下3種:

在這裏插入圖片描述

  • 單線程環境裏面確保程序最終執行結果和代碼順序執行的結果一致。
  • 處理器在進行重排序時必須考慮指令之間的數據依賴性
  • 多線程環境中線程交替執行,由於編譯器優化重排的存在,兩個線程中使用的變量能否保證一致性是無法確定的,結果無法預測。

案例代碼

package com.dpb.spring.aop.demo;

public class SortDemo {
    int a = 0;
    boolean flag = false;

    public void fun1(){
        a = 1;  // 語句1
        flag = true; // 語句2
    }

    public void fun2(){
        if(flag){
            a = a + 5; // 語句3
            System.out.println("a = " + a );
        }
    }
}

注意:在多線程環境中線程交替執行,由於編譯器優化重排的存在,兩個線程中使用的變量能否保證一致性是無法確定的,結果無法預測。

指令重排小結:
  volatile實現禁止指令重排優化,從而避免多線程環境下程序出現亂序執行的現象。
先了解一個概念,內存屏障又稱內存柵欄,是一個CPU指令,它的作用有兩個:

  1. 是保證特定操作的執行順序
  2. 是保證某些變量的內存可見性(利用該特性實現volatile的內存可見性)

  由於編譯器和處理器都能執行指令重排優化。如果在指令間插入一條Memory Barrier則告訴編譯器和CPU,不管什麼指令都不能和這條Memory Barrier指令重新排序,也就是說通過插入內存屏障禁止在內存屏障前後的指令執行重排序優化。內存屏障另外一個作用是強制刷出各種CPU的緩存數據,因此任何CPU上的線程都能讀取到這些數據的最新版本。

線程安全的總結:

  1. 工作內存和主內存同步延遲現象導致的可見性問題,可以使用synchronized或volatile關鍵字解決,他們都可以使一個線程修改後的變量立即對其他線程可見。

  2. 對於指令重排導致的可見性問題有序性問題,可以利用volatile關鍵字解決,因爲volatile的另外一個作用就是禁止重排序優化。

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