《java併發》--volatile修飾符

volatile

參考:
Java併發:volatile內存可見性和指令重排
你真的瞭解volatile嗎,關於volatile的那些事
java中volatile關鍵字的含義

volatile 作用

  • 保證內存可見性
  • 防止指令重排
  • 不能解決原子性

volatile 理解

java中多線程共享的變量存儲在主內存中,每個線程都有自己的工作內存,工作內存保存了主內存的副本,線程要操作共享變量,實際操作的是線程工作內存的副本,操作完畢後再同步寫入主內存,各個線程線程只能訪問自己的工作內存,不可以訪問其它線程的工作內存。

java中線程工作內存跟主內存的交互

image

  1. lock:將主內存中的變量鎖定,爲一個線程所獨佔
  2. unclock:將lock加的鎖定解除,此時其它的線程可以有機會訪問此變量
  3. read:將主內存中的變量值讀到工作內存當中
  4. load:將read讀取的值保存到工作內存中的變量副本中。
  5. use:將值傳遞給線程的代碼執行引擎(多次)
  6. assign:將執行引擎處理返回的值重新賦值給變量副本(多次)
  7. store:將變量副本的值存儲到主內存中。
  8. write:將store存儲的值寫入到主內存的共享變量當中。

可見性:保證線程使用共享變量時每次都去主內存獲取最新的,保證了read-load的一致性

原子性:保證線程在read-load-use-assign-store-write共享變量過程中,其它線程不能對共享變量進行修改

共享變量使用volatile修飾後,保證線程每次訪問共享變量都去主內存獲取,保證每次獲取到的是主內存中最新的值,即保證了read-load是最新的,這樣就實現了可見性,但是在後續的use-assign-store-write過程中,其它線程可能會對共享變量進行操作更改,這樣無法保證原子性

代碼解讀可見性

如果不使用volatile修飾共享變量,線程只會在第一次使用共享變量的時候去主內存加載建立副本,這樣子線程永遠不會停止

使用volatile修飾修飾共享變量,在while循環的判斷running值的時候,每次都去主內存獲取最新的值,當主線程將running設置爲false的時候,停止子線程,在while循環中使用了count變量,如果只將count用volatile修飾,也能停止子線程,由此可見,線程去主內存讀取共享變量的時候,會把所有用到的共享變量都在工作內存建立副本

public class Task implements Runnable{
  //將count用volatile修飾,保證每次去主存讀取count值,
  //讀取的同時會將running也從主存讀取,不管running是否用volatile修飾
  private volatile int count = 0;
  private boolean running = true;

  @Override
  public void run() {
    while(running){
      //
      count++;
    }
    System.out.println("子線程"+Thread.currentThread().getName()+"停止");
  }

  public static void main(String[] args) throws InterruptedException {
    Task task = new Task();
    //啓動子線程
    new Thread(task).start();
    Thread.currentThread().sleep(3000);
    task.setRunning(false);
    System.out.println("主線程停止");
  }

  public void setRunning(boolean running) {
    this.running = running;
  }

  public int getCount() {
    return count;
  }
}

代碼解讀無法實現原子性

下面這段程序執行完畢後無法保證count的數量最終爲1000,這是因爲volatile只能保證使用count的時候去主內存讀取到最新的值,但是在對count進行+1操作的時候,其它線程可能會對count進行修改+1然後寫會主內存,造成最後的結果不是1000,如果要保證1000,還是要對整個read到write回主內存保證一致性,這就需要使用synchronized或者lock去實現了。

public class Counter {
  //使用volatile修飾共享變量
  public volatile static int count = 0;

  public static void inc() {
    // 這裏延遲1毫秒,使得結果明顯
    try {
      Thread.sleep(1);
    } catch (InterruptedException e) {
    }
    //無法保證是1000
    count++;
  }

  public static void main(String[] args) {

    // 同時啓動1000個線程,去進行i++計算
    for (int i = 0; i < 1000; i++) {
      new Thread(new Runnable() {
        @Override
        public void run() {
          Counter.inc();
        }
      }).start();
    }
    // 無法保證count值爲1000
    System.out.println("運行結果:Counter.count=" + Counter.count);
  }
}

volatile修飾避免代碼重排

指令重排序是JVM爲了優化指令,提高程序運行效率,在不影響單線程程序執行結果的前提下,儘可能地提高並行度。編譯器、處理器也遵循這樣一個目標。注意是單線程。多線程的情況下指令重排序就會給程序員帶來問題。

內存屏障:在使用volatile修飾的變量前後插入一個內存柵欄,告訴JVM該條指令不能跟前後語句進行重排。

指令重排在多線程操作的時候,如果變量沒有使用volatile修飾,可能會出現問題

//線程1初始化User
User user;
user = new User();
//線程2讀取user
if(user!=null){
  user.getName();
}

具體來看User user = new User的語義:
1:分配對象的內存空間
2:初始化對線
3:設置user指向剛分配的內存地址

操作2依賴於操作1,但是操作3並不依賴於操作2,所以JVM是可以針對它們進行指令的優化重排序的,優化後變爲 1->3->2
這些線程1在執行完第3步而還沒來得及執行完第2步的時候,如果內存刷新到了主存,那麼線程2將得到一個未初始化完成的對象。

//在線程A中:
context = loadContext();
inited = true;

//在線程B中:
while(!inited ){ //根據線程A中對inited變量的修改決定是否使用context變量
   sleep(100);
}
doSomethingwithconfig(context);

//假設線程A中發生了指令重排序:
inited = true;
context = loadContext();
//那麼B中很可能就會拿到一個尚未初始化或尚未初始化完成的context,從而引發程序錯誤。

volatile關鍵字通過提供“內存屏障”的方式來防止指令被重排序,爲了實現volatile的內存語義,編譯器在生成字節碼時,會在指令序列中插入內存屏障來禁止特定類型的處理器重排序。懶漢式單例模式就是使用volatile防止創建多個實例對象

總結

  • volatile無法實現原子性,只能實現可見性
  • 當要訪問的變量已在synchronized代碼塊中,或者爲常量時,沒必要使用volatile。
  • 由於使用volatile屏蔽掉了JVM中必要的代碼優化,所以在效率上比較低,因此一定在必要時才使用此關鍵字。
  • 在需要同步的時候,第一選擇應該是synchronized關鍵字,這是最安全的方式,嘗試其他任何方式都是有風險的。尤其在、jdK1.5之後,對synchronized同步機制做了很多優化,如:自適應的自旋鎖、鎖粗化、鎖消除、輕量級鎖等,使得它的性能明顯有了很大的提升。
  • 當且僅當滿足以下所有條件時,才應該使用volatile變量:
    1. 對變量的寫入操作不依賴變量的當前值,或者你能確保只有單個線程更新變量的值
    2. 該變量沒有包含在具有其他變量的不變式中,防止影響其他變量??
    3. 防止代碼重排
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章