對volatile的理解–從JMM以及單例模式剖析
請談談你對volatile的理解
1.volitale是Java虛擬機提供的一種輕量級的同步機制
三大特性1.1保證可見性 1.2不保證原子性 1.3禁止指令重排
首先保證可見性
1.1 可見性
概念:當多個線程訪問同一個變量時,一個線程修改了這個變量的值,其他線程能夠立即看到修改的值
package com.yuxue.juc.volatileTest;
/**
* 1驗證volatile的可見性
* 1.1 如果int num = 0,number變量沒有添加volatile關鍵字修飾
* 1.2 添加了volatile,可以解決可見性
*/
class VolatileDemo1 {
//自定義的類
public static class MyTest{
//類的內部成員變量num
public int num = 0;
//numTo60 方法,讓num值為60
public void numTo60(){
num = 60;
}
}
public static void main(String[] args) {
MyTest myTest = new MyTest();
//第一個線程
new Thread(() -> {
try {
System.out.println(Thread.currentThread().getName() + "\t come in");
Thread.sleep(3000);
myTest.numTo60();
System.out.println(Thread.currentThread().getName() + "\t update value:" + myTest.num);
} catch (InterruptedException e) {
e.printStackTrace();
}
} ,"thread1").start();;
//主線程判斷num值
while (myTest.num == 0){
//如果myData的num一直為零,main線程一直在這裡循環
}
System.out.println(Thread.currentThread().getName() + "\t mission is over, num value is " + myTest.num);
}
}
如上代碼是沒有保證可見性的,可見性存在於JMM當中即java內存模型當中的,可見性主要是指當一個線程改變其內部的工作內存當中的變量後,其他線程是否可以觀察到,因為不同的線程件無法訪問對方的工作內存,線程間的通信(傳值)必須通過主內存來完成,因為此處沒有添加volatile指令,導致其中thread1對num值變量進行更改時,main線程無法感知到num值發生更改,導致在while處無限循環,讀不到新的num值,會發生死循環
此時修改類中代碼為
/**
* volatile可以保證可見性,及時通知其他線程,主物理內存的值已經被修改
*/
public static class MyTest{
//類的內部成員變量num
public volatile int num = 0;
//numTo60 方法,讓num值為60
public void numTo60(){
num = 60;
}
}
此時volatile就可以保證內存的可見性,此時運行代碼就可以發現
1.2 不保證原子性
原子性概念:不可分割、完整性,即某個線程正在做某個具體業務時,中間不可以被加塞或者被分割,需要整體完整,要麼同時成功,要麼同時失敗
類代碼為:
//自定義的類
public static class MyTest {
//類的內部成員變量num
public volatile int num = 0;
public void numPlusPlus() {
num++;
}
}
主方法為
public static void main(String[] args) {
MyTest myTest = new MyTest();
/**
* 10個線程創建出來,每個線程執行2000次num++操作
* 我們知道,在位元組碼及底層,i++被抽象為三個操作
* 即先取值,再自加,再賦值操作
*/
for (int i = 1; i <= 10; i++) {
new Thread(() -> {
for (int j = 0; j < 2000; j++) {
myTest.numPlusPlus();
}
}, "Thread" + i).start();
}
//這裡規定線程數大於2,一般有GC線程以及main主線程
while (Thread.activeCount() > 2) {
Thread.yield();
}
System.out.println(Thread.currentThread().getName() + "\t finally num value is " + myTest.num);
}
代碼如上所示,如果volatile保證原子性,那麼10個線程分別執行自加2000次操作,那麼最終結果一定是20000,但是執行三次結果如下
//第一次
main finally num value is 19003
//第二次
main finally num value is 18694
//第三次
main finally num value is 19552
可以發現,我們num的值每次都不相同,且最後的值都沒有達到20000,這是為什麼呢?
為什麼會出現這種情況?
首先,我們要考慮到這種情況,假如線程A執行到第11行即myTest.numPlusPlus();
方法時
線程進入方法執行numPlusPlus
方法後,num的值不管是多少,線程A將num的值首先初始化為0(假如主存中num的值為0),之後num的值自增為1,之後線程A掛起,線程B此時也將主存中的num值讀到自己的工作內存中值為0,之後num的值自增1,之後線程B掛起,線程A繼續運行將num的值寫回主存,但是因為volatile關鍵字保證可見性,但是在很短的時間內,線程B也將num的值寫回主存,此時num的值就少加了一次,所以最後總數基本上少於20000
如何解決?
但是JUC有線程的原子類為AtomicInteger
類,此時,將類代碼更改為
public static class MyTest {
//類的內部成員變量num
public volatile int num = 0;
AtomicInteger atomicInteger = new AtomicInteger();
//numTo60 方法,讓num值為60
public void numTo60() {
num = 60;
}
public void numPlusPlus() {
num++;
}
public void myAtomPlus(){
atomicInteger.getAndIncrement();
}
}
共同測試num和atomicInteger,此時執行主函數,三次結果為
//第一次
main finally num value is 19217
main finally atomicInteger value is 20000
//第二次
main finally num value is 19605
main finally atomicInteger value is 20000
//第三次
main finally num value is 18614
main finally atomicInteger value is 20000
我們發現volatile關鍵字並沒有保證我們的變量的原子性,但是JUC內部的AtomicInteger類保證了我們變量相關的原子性,AtomicInteger底層用到了CAS,CAS不了解的話可以參考這篇文章
1.3 禁止指令重排
有序性的概念:在計算機執行程序時,為了提高性能,編譯器和處理器常常會對指令做重排,一般分以下三種
單線程環境裏面確保程序最終執行結果和代碼順序執行的結果一致。
處理器在進行重排順序是必須要考慮指令之間的數據依賴性
多線程環境中線程交替執行,由於編譯器優化重排的存在,兩個線程中使用的變量能否保證一致性時無法確定的,結果無法預測
重排代碼實例: 聲明變量: int a,b,x,y=0
線程A | 線程B |
---|---|
x=a; | y=b; |
b=1; | a=2; |
執行結果 | x=0,y=0 |
如果編譯器對這段程序代碼執行重排優化後,可能出現如下情況:
線程A | 線程B |
---|---|
b=1; | a=2; |
x=a; | y=b; |
執行結果 | x=2,y=1 |
這個結果說明在多線程環境下,由於編譯器優化重排的存在,兩個線程中使用的變量能否保證一致性是無法確定的
volatile實現禁止指令重排,從而避免了多線程環境下程序出現亂序執行的現象
內存屏障(Memory Barrier)又稱內存柵欄,是一個CPU指令,他的作用有兩個:
- 保證特定操作的執行順序
- 保證某些變量的內存可見性(利用該特性實現volatile的內存可見性)
由於編譯器和處理器都能執行指令重排優化。如果在之間插入一條Memory Barrier則會告訴編譯器和CPU, 不管什麼指令都不能和這條Memory Barrier指令重排順序,也就是說通過插入內存屏障禁止在內存屏障前後的指令執行重排序優化。內存屏障另外一個作用是強制刷出各種CPU的緩存數據,因此任何CPU上的線程都能讀 取到這些數據的最新版本
2.JMM(java內存模型)
為什麼提到JMM?JMM當中規定了可見性、原子性、以及有序性的問題,在多線程中只要保證了以上問題的正確性,那麼基本上不會發生多線程當中存在數據安全問題
JMM(Java Memory Model)本身是一種抽象的概念,並不真實存在,他描述的時一組規則或規範,通過這組規範定義了程序中各個變量(包括實例字段,靜態字段和構成數組對象的元素)的訪問方式。
JMM關於同步的規定:
- 線程解鎖前,必須把共享變量的值刷新回主內存
- 線程加鎖前,必須讀取主內存的最新值到自己的工作內存
- 加鎖解鎖時同一把鎖
由於JVM運行程序的實體是線程,而每個線程創建時JVM都會為其創建一個工作內存(有的成為棧空間),工作內存是每個線程的私有數據區域,而java內存模型中規定所有變量都存儲在主內存,主內存是貢獻內存區域,所有線程都可以訪問,但線程對變量的操作(讀取賦值等)必須在工作內存中進行,首先概要將變量從主內存拷貝到自己的工作內存空間,然後對變量進行操作,操作完成後再將變量寫回主內存,不能直接操作主內存中的變量,各個線程中的工作內存中存儲着主內存的變量副本拷貝,因此不同的線程件無法訪問對方的工作內存,線程間的通信(傳值)必須通過主內存來完成,期間要訪問過程如下圖:
JMM的三大特性
2.1可見性
2.2原子性
2.3有序性
所以JMM當中的2.1和2.3在volatile當中都有很好的體現,volatile關鍵字並不能保證多線程當中的原子性,但是volatile是輕量級的同步機制,不想synchronized鎖一樣粒度太大
3.你在那些地方用過volatile?結合實際談論一下?
當普通單例模式在多線程情況下:
/**
* 普通單例模式
* */
public class SingletonDemo {
private static SingletonDemo instance = null;
private SingletonDemo() {
System.out.println(Thread.currentThread().getName() + "\t 構造方法 SingletonDemo()");
}
public static SingletonDemo getInstance() {
if (instance == null) {
instance = new SingletonDemo();
}
return instance;
}
public static void main(String[] args) {
//構造方法只會被執行一次
// System.out.println(getInstance() == getInstance());
// System.out.println(getInstance() == getInstance());
// System.out.println(getInstance() == getInstance());
//並發多線程後,構造方法會在一些情況下執行多次
for (int i = 0; i < 10; i++) {
new Thread(() -> {
SingletonDemo.getInstance();
}, "Thread " + i).start();
}
}
}
此時會出現兩個線程運行了SingletonDemo的構造方法
此時就違反了單例模式的規定,其構造方法在一些情況下會被執行多次
解決方式:
- 單例模式DCL代碼
DCL (Double Check Lock雙端檢鎖機制)在加鎖前和加鎖後都進行一次判斷
public static SingletonDemo getInstance() {
if (instance == null) {
synchronized (SingletonDemo.class) {
if (instance == null) {
instance = new SingletonDemo();
}
}
}
return instance;
}
不僅兩次判空讓程序執行更有效率,同時對代碼塊加鎖,保證了線程的安全性
但是!還存在問題!
什麼問題?
大部分運行結果構造方法只會被執行一次,但指令重排機制會讓程序很小的幾率出現構造方法被執行多次
DCL(雙端檢鎖)機制不一定線程安全,原因時有指令重排的存在,加入volatile可以禁止指令重排
原因是在某一個線程執行到第一次檢測,讀取到instance不為null時,instance的引用對象可能沒有完成初始化。instance=new SingleDemo();可以被分為一下三步(偽代碼):
memory = allocate();//1.分配對象內存空間
instance(memory); //2.初始化對象
instance = memory; //3.設置instance執行剛分配的內存地址,此時instance!=null
步驟2和步驟3不存在數據依賴關係,而且無論重排前還是重排後程序的執行結果在單線程中並沒有改變,因此這種重排優化時允許的
所以如果3步驟提前於步驟2,但是instance還沒有初始化完成指令重排只會保證串行語義的執行的一致性(單線程),但並不關心多線程間的語義一致性。
所以當一條線程訪問instance不為null時,由於instance示例未必已初始化完成,也就造成了線程安全問題。
此時加上volatile後就不會出現線程安全問題
private static volatile SingletonDemo instance = null;
因為volatile禁止了指令重排序的問題