轉自https://mp.weixin.qq.com/s/s9h13tepy9d2wRrn5EPpFQ
筆者近期看到一篇對volatile理解特別好的文章,特記錄下來以便自己以後查看
一.可見性
如何理解可見性,還是來看個會出現死循環的例子:
(注意:運行時請加上jvm參數:-server,while循環內不要有標準輸出):
public class Task implements Runnable{
boolean running =true;
int i=0;
@Override
public void run(){
while(running){
i++;
}
}
public static void main(String[] args) throws InterruptedException{
Task task=new Task();
Thread th=new Thread(task);
th.start();
Thread.sleep(10);
task.running=fasle;
Thread.sleep(100);
System.out.println(task.i);
System.out.println("程序停止");
}
}
這是爲什麼呢?先來看看java的內存模型,如下圖:
java內存分爲工作內存和主存
工作內存:即java線程的本地內存,是單獨給某個線程分配的,存儲局部變量等,同時也會複製主存的共享變量作爲本地的副本,目的是爲了減少和主存通信的頻率,提高效率。主存:存儲類成員變量等。
可見性是指的是線程訪問變量是否是最新值。
局部變量不存在可見性問題,而共享內存就會有可見性問題,因爲本地線程在創建的時候,會從主存中讀取一個共享變量的副本,且修改也是修改副本,且並不是立即刷新到主存中去,那麼其他線程並不會馬上共享變量的修改。
因此,線程B修改共享變量後,線程A並不會馬上知曉,就會出現上述死循環的問題。
解決共享變量可見性問題,需要用volatile關鍵字修飾。
如下圖代碼就不會出現死循環:
public class Task2 implements Runnable{
volatile boolean running=true;
int i=0;
@Override
public void run(){
while(running){
i++;
}
}
public static void main(String[] args) throws InterruptedException{
Task2 task=new Task2();
Thread th=new Thread(task);
th.start();
Thread.sleep(1);
task.running=false;
Thread.sleep(1000);
System.out.println(task.i);
System.out.println("程序停止");
}
}
那麼爲什麼能解決死循環的問題呢?
可見性的特性總結爲以下2點:
對volatile變量的寫會立即刷新到主存
對volatile變量的讀會讀主存中的新值
可以用如下圖更清晰的描述:
如此一來,就不會出現死循環了。
爲了能更深刻的理解volatile的語義,我們來看下面的時序圖,回答這2個問題:
問題1:t2時刻,如果線程A讀取running變量,會讀取到false,還是等待線程B執行完呢?
答案是否定的,volatile並沒有鎖的特性。
問題2:t4時刻,線程A是否一定能讀取到線程B修改後的最新值
答案是肯定的,線程A會從重新從主存中讀取running的最新值。
還有一種辦法也可以解決死循環的問題:
public class Task implements Runnable{
volatile boolean running=true;
int i=0;
@Override
public void run(){
while(this.isRunning()){
i++;
}
}
public static void main(String[] args) throws InterruptedException{
Task task=new Task();
Thread th=new Thread(task);
th.start();
Thread.sleep(10);
task.setRunning(false);
System.out.println(task.i);
System.out.println("程序停止");
}
public synchronized booble isRunning(){
return running;
}
public synchronized void setRunning(boolean running){
this.running=running;
}
}
雖然running變量上沒有volatile關鍵字修飾,但是讀和寫running都是同步方法.
同步塊存在如下語義
進入同步塊,訪問共享變量會去讀取主存
退出同步塊,本地內存對共享變量的修改會立即刷新到主存
因此上述代碼不會出現死循環。
二.volatile變量的原子性
我看了很多文章,有些文章甚至是出版的書籍都說volatile不是原子的,他們舉的例子是i++操作,i++本身不是原子操作,是讀並寫,我這裏要講的原子性指的是寫操作,原子性的特別總結爲2點:
對一個volatile變量的寫操作,只有所有步驟完成,才能被其它線程讀取到。
多個線程對volatile變量的寫操作本質上是有先後順序的。也就是說併發寫沒有問題。
這樣說也許讀者感覺不到和非volatile變量有什麼區別,我來舉個例子:
//線程1初始化User
User user;
user = new User();
//線程2讀取user
if(user!=null){
user.getName();
}
在多線程併發環境下,線程2讀取到的user可能未初始化完成,具體來看User user = new User的語義:
分配對象的內存空間
初始化對線
設置user指向剛分配的內存地址
步驟2和步驟3可能會被重排序,流程變爲
1->3->2
這些線程1在執行完第3步而還沒來得及執行完第2步的時候,如果內存刷新到了主存,那麼線程2將得到一個未初始化完成的對象。因此如果將user聲明爲volatile的,那麼步驟2,3將不會被重排序。
下面我們來看一個具體案例,一個基於雙重檢查的懶加載的單例模式實現:
public class Singleton{
private static Singleton instance;
private Singleton(){};
public static Singleton getInstance(){
if(instance==null){//步驟1
synchronized(Singleton.class){//步驟2
if(instance==null){//步驟3
instance=new Singleton();//步驟4
}
}
}
return instance;
}
}
這個單例模式看起來很完美,如果instance爲空,則加鎖,只有一個線程進入同步塊完成對象的初始化,然後instance不爲空,那麼後續的所有線程獲取instance都不用加鎖,從而提升了性能。
但是我們剛纔講了對象賦值操作步驟可能會存在重排序,即當前線程的步驟4執行到一半,其它線程如果進來執行到步驟1,instance已經不爲null,因此將會讀取到一個沒有初始化完成的對象。
但如果將instance用volatile來修飾,就完全不一樣了,對instance的寫入操作將會變成一個原子操作,沒有初始化完,就不會被刷新到主存中。
修改後的單例模式代碼如下:
public class Singleton{
private static volatile Singleton instance;
private Singleton(){};
public static Singleton getInstance(){
if(instance==null){//步驟1
synchronized(Singleton.class){//步驟2
if(instance==null){//步驟3
instance=new Singleton();//步驟4
}
}
}
return instance;
}
}
三.對volatile理解的誤區
很多人會認爲對volatile變量的所有操作都是原子性的,比如自增i++
這其實是不對的。
看如下代碼:
public class VolatileDemo implements Runnable{
volatile inti=0;
@Override
public void run(){
for(int j=0;j<10000;j++){
i++;
}
}
public static void main(String[] args) throws InterruptedException{
VolatileDemo task=new VolatileDemo();
Thread t1=new Thread(task);
Thread t2=new Thread(task);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(task.i);
}
}
如果i++的操作是線程安全的,那麼預期結果應該是i=20000
然而運行的結果是:11349
說明i++存在併發問題,i++語義是i=i+1
分爲2個步驟
讀取i=0
計算i+1=1,並重新賦值給i
那麼可能存在2個線程同時讀取到i=0,並計算出結果i=1然後賦值給i,那麼就得不到預期結果i=2。
這個問題說明了2個問題:
i++這種操作不是原子操作
volatile 並不會有鎖的特性