摘要:安全性小結我們上邊介紹了原子性操作內存可見性以及指令重排序三個在多線程執行過程中會影響到安全性的問題。
指令重排序
如果說內存可見性問題已經讓你抓狂了,那么下邊的這個指令重排序的事兒估計就要罵娘了~這事兒還得從一段代碼說起:
public class Reordering { private static boolean flag; private static int num; public static void main(String[] args) { Thread t1 = new Thread(new Runnable() { @Override public void run() { while (!flag) { Thread.yield(); } System.out.println(num); } }, "t1"); t1.start(); num = 5; flag = true; } }
需要注意到flag并不是一個volatile變量,也就是說它存在內存可見性問題,但是即便如此,num = 5也是寫在flag = true的前邊的,等到t1線程檢測到了flag值的變化,num值的變化應該是早于flag值刷新到主內存的,所以線程t1最后的輸出結果肯定是5!!!
no!no!no! 輸出的結果也可能是0,也就是說flag = true可能先于num = 5執行,有沒有亮瞎你的狗眼~ 這些代碼最后都會變成機器能識別的二進制指令,我們把這種指令不按書寫順序執行的情況稱為指令重排序。大多數現代處理器都會采用將指令亂序執行的方法,在條件允許的情況下,直接運行當前有能力立即執行的后續指令,避開獲取下一條指令所需數據時造成的等待。通過亂序執行的技術,處理器可以大大提高執行效率。
Within-Thread As-If-Serial Semantics既然存在指令重排序這種現象,為什么我們之前寫代碼從來沒感覺到呢?到了多線程這才發現問題?
指令重排序不是隨便排,一個一萬行的程序直接把最后一行當成第一行就給執行那不就逆天了了么,指令重排序是需要遵循代碼依賴情況的。比如下邊幾行代碼:
int i = 0, b = 0; i = i + 5; //指令1 i = i*2; //指令2 b = b + 3; //指令3
對于上邊標注的3個指令來說,指令2是對指令1有依賴的,所以指令2不能被排到指令1之前執行。但是指令3跟指令1和指令2都沒有關系,所以指令3可以被排在指令1之前,或者指令1和指令2中間或者指令2后邊執行都可以~ 這樣在單線程中執行這段代碼的時候,最終結果和沒有重排序的執行結果是一樣的,所以這種重排序有著Within-Thread As-If-Serial Semantics的含義,翻譯過來就是線程內表現為串行的語義。
但是這種指令重排序在單線程中沒有任何問題的,但是在多線程中,就引發了我們上邊在執行flag = true后,num的值仍然不能確定是0還是5~
抑制重排序在多線程并發編程的過程中,執行重排序有時候會造成錯誤的后果,比如一個線程在main線程中調用setFlag(true)的前邊修改了某些程序配置項,而在t1線程里需要用到這些配置項,所以會造成配置缺失的錯誤。但是java給我們提供了一些抑制指令重排序的方式。
同步代碼抑制指令重排序
將需要抑制指令重排序的代碼放入同步代碼塊中:
public class Reordering { private static boolean flag; private static int num; public static void main(String[] args) { Thread t1 = new Thread(new Runnable() { @Override public void run() { while (!getFlag()) { Thread.yield(); } System.out.println(num); } }, "t1"); t1.start(); num = 5; setFlag(true); } public synchronized static void setFlag(boolean flag) { Reordering.flag = flag; } public synchronized static boolean getFlag() { return flag; } }
在獲取鎖的時候,它前邊的操作必須已經執行完成,不能和同步代碼塊重排序;在釋放鎖的時候,同步代碼塊中的代碼必須全部執行完成,不能和同步代碼塊后邊的代碼重排序。
加了鎖之后,num=5就不能和flag=true的代碼進行重排序了,所以在線程2中看到的num值肯定是5,而不會是0嘍~
雖然抑制重排序可以保證多線程程序按照我們期望的執行順序進行執行,但是它抑制了處理器對指令執行的優化,原來能并行執行的指令現在只能串行執行,會導致一定程度的性能下降,所以加鎖只能保證在執行同步代碼塊時,它之前的代碼已經執行完成,在同步代碼塊執行完成之前,代碼塊后邊的代碼是不能執行的,也就是只保證加鎖前、加鎖中、加鎖后這三部分的執行時序,但是同步代碼塊之前的代碼可以重排序,同步代碼塊中的代碼可以重排序,同步代碼塊之后的代碼也可以進行重排序,在保證執行順序的基礎上,盡最大可能讓性能得到提升,比方說下邊這段代碼:
int i = 1; int j = 2; synchronized (Reordering.class) { int m = 3; int n = 4; } int x = 5; int y = 6;
它的一個執行時序可能是:
volatile變量抑制指令重排序
還是那句老話,加鎖會導致競爭同一個鎖的線程阻塞,造成線程切換,代價比較大,volatile變量也提供了一些抑制指令重排序的語義,上邊的程序可以改成這樣:
public class Reordering { private static volatile boolean flag; private static int num; public static void main(String[] args) { Thread t1 = new Thread(new Runnable() { @Override public void run() { while (!flag) { Thread.yield(); } System.out.println(num); } }); t1.start(); num = 5; flag = true; } } `` 也就是把``flag``聲明為``volatile變量``,這樣也能起到抑制重排序的效果,``volatile變量``具體抑制重排序的規則如下: 1. volatile寫之前的操作不會被重排序到volatile寫之后。 2. volatile讀之后的操作不會被重排序到volatile讀之前。 3. 前邊是volatile寫,后邊是volatile讀,這兩個操作不能重排序。 ![圖片描述][3] 除了這三條規定以外,其他的操作可以由處理器按照自己的特性進行重排序,換句話說,就是怎么執行著快,就怎么來。比如說:
flag = true;
num = 5;
``
在volatile變量之后進行普通變量的寫操作,那就可以重排序嘍,直到遇到一條volatile讀或者有執行依賴的代碼才會阻止重排序的過程。
final變量抑制指令重排序
在java語言中,用final修飾的字段被賦予了一些特殊的語義,它可以阻止某些重排序,具體的規則就這兩條:
在構造方法內對一個final字段的寫入,與隨后把這個被構造對象的引用賦值給一個引用變量,這兩個操作之間不能重排序。
初次讀一個包含final字段對象的引用,與隨后初次讀這個final字段,這兩個操作不能重排序。
可能大家看的有些懵逼,趕緊寫代碼理解一下:
public class FinalReordering { int i; final int j; static FinalReordering obj; public FinalReordering() { i = 1; j = 2; } public static void write() { obj = new FinalReordering(); } public static void read() { FinalReordering finalReordering = FinalReordering.obj; int a = finalReordering.i; int b = finalReordering.j; } }
我們假設有一個線程執行write方法,另一個線程執行read方法。
先看一下對final字段進行寫操作時,不同線程執行write方法和read方法的一種可能情況是:
從上圖中可以看出,普通的字段可能在構造方法完成之后才被真正的寫入值,所以另一個線程在訪問這個普通變量的時候可能讀到了0,這顯然是不符合我們的預期的。但是final字段的賦值不允許被重排序到構造方法完成之后,所以在把該字段所在對象的引用賦值出去之前,final字段肯定是被賦值過了,也就是說這兩個操作不能被重排序。
再來看一下初次讀取final字段的情況,下邊是不同線程執行write方法和read方法的一種可能情況:
從上圖可以看出,普通字段的讀取操作可能被重排序到讀取該字段所在對象引用前邊,自然會得到NullPointerException異常嘍,但是對于final字段,在讀final字段之前,必須保證它前邊的讀操作都執行完成,也就是說必須先進行該字段所在對象的引用的讀取,再讀取該字段,也就是說這兩個操作不能進行重排序。
值得注意的是,讀取對象引用與讀取該對象的字段是存在間接依賴的關系的,對象引用都沒有被賦值,還讀個錘子對象的字段嘍,一般的處理器默認是不會重排序這兩個操作的,可是有一些為了性能不顧一切的處理器,比如alpha處理器,這種處理器是可能把這兩個操作進行重排序的,所以這個規則就是給這種處理器貼身設計的~ 也就是說對于final字段,不管在什么處理器上,都得先進行對象引用的讀取,再進行final字段的讀取。但是并不保證在所有處理器上,對于對象引用讀取和普通字段讀取的順序是有序的。
安全性小結我們上邊介紹了原子性操作、內存可見性以及指令重排序三個在多線程執行過程中會影響到安全性的問題。
synchronized可以把三個問題都解決掉,但是伴隨著這種萬能特性,是多線程在競爭同一個鎖的時候會造成線程切換,導致線程阻塞,這個對性能的影響是非常大的。
volatile不能保證一系列操作的原子性,但是可以保證對于一個變量的讀取和寫入是原子性的,一個線程對某個volatile變量的寫入是可以立即對其他線程可見的,另外,它還可以禁止處理器對一些指令執行的重排序。
final變量依靠它的禁止重排序規則,保證在使用過程中的安全性。一旦被賦值成功,它的值在之后程序執行過程中都不會改變,也不存在所謂的內存可見性問題。
文章版權歸作者所有,未經允許請勿轉載,若此文章存在違規行為,您可以聯系管理員刪除。
轉載請注明本文地址:http://specialneedsforspecialkids.com/yun/74148.html
摘要:假設不發生編譯器重排和指令重排,線程修改了的值,但是修改以后,的值可能還沒有寫回到主存中,那么線程得到就是很自然的事了。同理,線程對于的賦值操作也可能沒有及時刷新到主存中。線程的最后操作與線程發現線程已經結束同步。 很久沒更新文章了,對隔三差五過來刷更新的讀者說聲抱歉。 關于 Java 并發也算是寫了好幾篇文章了,本文將介紹一些比較基礎的內容,注意,閱讀本文需要一定的并發基礎。 本文的...
摘要:并發編程的挑戰并發編程的目的是為了讓程序運行的更快,但是,并不是啟動更多的線程就能讓程序最大限度的并發執行。的實現原理與應用在多線程并發編程中一直是元老級角色,很多人都會稱呼它為重量級鎖。 并發編程的挑戰 并發編程的目的是為了讓程序運行的更快,但是,并不是啟動更多的線程就能讓程序最大限度的并發執行。如果希望通過多線程執行任務讓程序運行的更快,會面臨非常多的挑戰:(1)上下文切換(2)死...
摘要:本文會先闡述在并發編程中解決的問題多線程可見性,然后再詳細講解原則本身。所以與內存之間的高速緩存就是導致線程可見性問題的一個原因。原則上面討論了中多線程共享變量的可見性問題及產生這種問題的原因。 Happens-Before是一個非常抽象的概念,然而它又是學習Java并發編程不可跨域的部分。本文會先闡述Happens-Before在并發編程中解決的問題——多線程可見性,然后再詳細講解H...
摘要:這個規則比較好理解,無論是在單線程環境還是多線程環境,一個鎖處于被鎖定狀態,那么必須先執行操作后面才能進行操作。線程啟動規則獨享的方法先行于此線程的每一個動作。 1. 指令重排序 關于指令重排序的概念,比較復雜,不好理解。我們從一個例子分析: public class SimpleHappenBefore { /** 這是一個驗證結果的變量 */ private st...
摘要:并發編程關鍵字解析解析概覽內存模型的相關概念并發編程中的三個概念內存模型深入剖析關鍵字使用關鍵字的場景內存模型的相關概念緩存一致性問題。事實上,這個規則是用來保證程序在單線程中執行結果的正確性,但無法保證程序在多線程中執行的正確性。 Java并發編程:volatile關鍵字解析 1、解析概覽 內存模型的相關概念 并發編程中的三個概念 Java內存模型 深入剖析volatile關鍵字 ...
閱讀 2328·2021-11-22 14:56
閱讀 1460·2021-09-24 09:47
閱讀 904·2019-08-26 18:37
閱讀 2818·2019-08-26 12:10
閱讀 1522·2019-08-26 11:55
閱讀 3140·2019-08-23 18:07
閱讀 2294·2019-08-23 14:08
閱讀 605·2019-08-23 12:12