并发编程:原子性问题,可见性问题,有序性问题。
以下是本文的目錄大綱:
一.內存模型的相關概念
二.并發編程中的三個概念
三.Java內存模型
一.內存模型的相關概念
大家都知道,計算機在執行程序時,每條指令都是在CPU中執行的,而執行指令過程中,勢必涉及到數據的讀取和寫入。由于程序運行過程中的臨時數據是存放在主存(物理內存)當中的,這時就存在一個問題,由于CPU執行速度很快,而從內存讀取數據和向內存寫入數據的過程跟CPU執行指令的速度比起來要慢的多,因此如果任何時候對數據的操作都要通過和內存的交互來進行,會大大降低指令執行的速度。因此在CPU里面就有了高速緩存。
也就是,當程序在運行過程中,會將運算需要的數據從主存復制一份到CPU的高速緩存當中,那么CPU進行計算時就可以直接從它的高速緩存讀取數據和向其中寫入數據,當運算結束之后,再將高速緩存中的數據刷新到主存當中。舉個簡單的例子,比如下面的這段代碼:
| 1 | i = i +?1; |
? 當線程執行這個語句時,會先從主存當中讀取i的值,然后復制一份到高速緩存當中,然后CPU執行指令對i進行加1操作,然后將數據寫入高速緩存,最后將高速緩存中i最新的值刷新到主存當中。
這個代碼在單線程中運行是沒有任何問題的,但是在多線程中運行就會有問題了。在多核CPU中,每條線程可能運行于不同的CPU中,因此每個線程運行時有自己的高速緩存(對單核CPU來說,其實也會出現這種問題,只不過是以線程調度的形式來分別執行的)。本文我們以多核CPU為例。
比如同時有2個線程執行這段代碼,假如初始時i的值為0,那么我們希望兩個線程執行完之后i的值變為2。但是事實會是這樣嗎?
可能存在下面一種情況:初始時,兩個線程分別讀取i的值存入各自所在的CPU的高速緩存當中,然后線程1進行加1操作,然后把i的最新值1寫入到內存。此時線程2的高速緩存當中i的值還是0,進行加1操作之后,i的值為1,然后線程2把i的值寫入內存。
最終結果i的值是1,而不是2。這就是著名的緩存一致性問題。通常稱這種被多個線程訪問的變量為共享變量。
也就是說,如果一個變量在多個CPU中都存在緩存(一般在多線程編程時才會出現),那么就可能存在緩存不一致的問題。
為了解決緩存不一致性問題,通常來說有以下2種解決方法:
1)通過在總線加LOCK#鎖的方式
2)通過緩存一致性協議
這2種方式都是硬件層面上提供的方式。
在早期的CPU當中,是通過在總線上加LOCK#鎖的形式來解決緩存不一致的問題。因為CPU和其他部件進行通信都是通過總線來進行的,如果對總線加LOCK#鎖的話,也就是說阻塞了其他CPU對其他部件訪問(如內存),從而使得只能有一個CPU能使用這個變量的內存。比如上面例子中如果一個線程在執行 i = i +1,如果在執行這段代碼的過程中,在總線上發出了LCOK#鎖的信號,那么只有等待這段代碼完全執行完畢之后,其他CPU才能從變量i所在的內存讀取變量,然后進行相應的操作。這樣就解決了緩存不一致的問題。
但是上面的方式會有一個問題,由于在鎖住總線期間,其他CPU無法訪問內存,導致效率低下。
所以就出現了緩存一致性協議。最出名的就是Intel 的MESI協議,MESI協議保證了每個緩存中使用的共享變量的副本是一致的。它核心的思想是:當CPU寫數據時,如果發現操作的變量是共享變量,即在其他CPU中也存在該變量的副本,會發出信號通知其他CPU將該變量的緩存行置為無效狀態,因此當其他CPU需要讀取這個變量時,發現自己緩存中緩存該變量的緩存行是無效的,那么它就會從內存重新讀取。
?
二.并發編程中的三個概念
在并發編程中,我們通常會遇到以下三個問題:原子性問題,可見性問題,有序性問題。我們先看具體看一下這三個概念:
1.原子性
原子性:即一個操作或者多個操作 要么全部執行并且執行的過程不會被任何因素打斷,要么就都不執行。
一個很經典的例子就是銀行賬戶轉賬問題:
比如從賬戶A向賬戶B轉1000元,那么必然包括2個操作:從賬戶A減去1000元,往賬戶B加上1000元。
試想一下,如果這2個操作不具備原子性,會造成什么樣的后果。假如從賬戶A減去1000元之后,操作突然中止。然后又從B取出了500元,取出500元之后,再執行 往賬戶B加上1000元 的操作。這樣就會導致賬戶A雖然減去了1000元,但是賬戶B沒有收到這個轉過來的1000元。
所以這2個操作必須要具備原子性才能保證不出現一些意外的問題。
同樣地反映到并發編程中會出現什么結果呢?
舉個最簡單的例子,大家想一下假如為一個32位的變量賦值過程不具備原子性的話,會發生什么后果?
| 1 | i = 9; |
假若一個線程執行到這個語句時,我暫且假設為一個32位的變量賦值包括兩個過程:為低16位賦值,為高16位賦值。
那么就可能發生一種情況:當將低16位數值寫入之后,突然被中斷,而此時又有一個線程去讀取i的值,那么讀取到的就是錯誤的數據。
2.可見性
可見性是指當多個線程訪問同一個變量時,一個線程修改了這個變量的值,其他線程能夠立即看得到修改的值。
舉個簡單的例子,看下面這段代碼:
| 1 2 3 4 5 6 | //線程1執行的代碼 int i = 0; i = 10; ? //線程2執行的代碼 j = i; |
假若執行線程1的是CPU1,執行線程2的是CPU2。由上面的分析可知,當線程1執行 i =10這句時,會先把i的初始值加載到CPU1的高速緩存中,然后賦值為10,那么在CPU1的高速緩存當中i的值變為10了,卻沒有立即寫入到主存當中。
此時線程2執行 j = i,它會先去主存讀取i的值并加載到CPU2的緩存當中,注意此時內存當中i的值還是0,那么就會使得j的值為0,而不是10.
這就是可見性的問題,線程1對變量i修改了之后,線程2沒有立即看到線程1修改的值。
3.有序性
有序性:即程序執行的順序按照代碼的先后順序執行。舉個簡單的例子,看下面這段代碼:
| 1 2 3 4 | int i = 0;????????????? boolean flag = false; i = 1;??????????????? //語句1? flag = true;????????? //語句2 |
上面代碼定義了一個int型變量,定義了一個boolean類型變量,然后分別對兩個變量進行賦值操作。從代碼順序上看,語句1是在語句2前面的,那么JVM在真正執行這段代碼的時候會保證語句1一定會在語句2前面執行嗎?不一定,為什么呢?這里可能會發生指令重排序(InstructionReorder)。
下面解釋一下什么是指令重排序,一般來說,處理器為了提高程序運行效率,可能會對輸入代碼進行優化,它不保證程序中各個語句的執行先后順序同代碼中的順序一致,但是它會保證程序最終執行結果和代碼順序執行的結果是一致的。
比如上面的代碼中,語句1和語句2誰先執行對最終的程序結果并沒有影響,那么就有可能在執行過程中,語句2先執行而語句1后執行。
注意:前后指令只有數據依賴關系時才會禁止指令重排序!當具有邏輯控制重排序的時候不足以禁止指令重排序,如下面的if語句,也是可以進行指令重排序的!
if(flag){ //語句1result = 5; //語句2}但是要注意,雖然處理器會對指令進行重排序,但是它會保證程序最終結果會和代碼順序執行結果相同,那么它靠什么保證的呢?再看下面一個例子:
| 1 2 3 4 | int a = 10;??? //語句1 int r = 2;??? //語句2 a = a + 3;??? //語句3 r = a*a;???? //語句4 |
這段代碼有4個語句,那么可能的一個執行順序是:
?
那么可不可能是這個執行順序呢: 語句2?? 語句1??? 語句4?? 語句3
不可能,因為處理器在進行重排序時是會考慮指令之間的數據依賴性,如果一個指令Instruction 2必須用到Instruction 1的結果,那么處理器會保證Instruction 1會在Instruction2之前執行。
雖然重排序不會影響單個線程內程序執行的結果,但是多線程呢?下面看一個例子:
| 1 2 3 4 5 6 7 8 9 | //線程1: context = loadContext();?? //語句1 inited = true;???????????? //語句2 ? //線程2: while(!inited ){ ??sleep() } doSomethingwithconfig(context); |
上面代碼中,由于語句1和語句2沒有數據依賴性,因此可能會被重排序。假如發生了重排序,在線程1執行過程中先執行語句2,而此是線程2會以為初始化工作已經完成,那么就會跳出while循環,去執行doSomethingwithconfig(context)方法,而此時context并沒有被初始化,就會導致程序出錯。
從上面可以看出,指令重排序不會影響單個線程的執行,但是會影響到線程并發執行的正確性。
也就是說,要想并發程序正確地執行,必須要保證原子性、可見性以及有序性。只要有一個沒有被保證,就有可能會導致程序運行不正確。
三.Java內存模型
在前面談到了一些關于內存模型以及并發編程中可能會出現的一些問題。下面我們來看一下Java內存模型,研究一下Java內存模型為我們提供了哪些保證以及在java中提供了哪些方法和機制來讓我們在進行多線程編程時能夠保證程序執行的正確性。
在Java虛擬機規范中試圖定義一種Java內存模型(Java Memory Model,JMM)來屏蔽各個硬件平臺和操作系統的內存訪問差異,以實現讓Java程序在各種平臺下都能達到一致的內存訪問效果。那么Java內存模型規定了哪些東西呢,它定義了程序中變量的訪問規則,往大一點說是定義了程序執行的次序。注意,為了獲得較好的執行性能,Java內存模型并沒有限制執行引擎使用處理器的寄存器或者高速緩存來提升指令執行速度,也沒有限制編譯器對指令進行重排序。也就是說,在java內存模型中,也會存在緩存一致性問題和指令重排序的問題。
Java內存模型規定所有的變量都是存在主存當中(類似于前面說的物理內存),每個線程都有自己的工作內存(類似于前面的高速緩存)。線程對變量的所有操作都必須在工作內存(各自的緩存中)中進行,而不能直接對主存進行操作。并且每個線程不能訪問其他線程的工作內存。
舉個簡單的例子:在java中,執行下面這個語句:
| 1 | i? =?10; |
? 執行線程必須先在自己的工作線程中對變量i所在的緩存行進行賦值操作,然后再寫入主存當中。而不是直接將數值10寫入主存當中。
那么Java語言本身對原子性、可見性以及有序性提供了哪些保證呢?
1.原子性
在Java中,對基本數據類型的變量的讀取和賦值操作是原子性操作,即這些操作是不可被中斷的,要么執行,要么不執行。
上面一句話雖然看起來簡單,但是理解起來并不是那么容易。看下面一個例子i:
請分析以下哪些操作是原子性操作:
| 1 2 3 4 | x =?10;?????????//語句1 y = x;?????????//語句2 x++;???????????//語句3 x = x +?1;?????//語句4 |
? 咋一看,有些朋友可能會說上面的4個語句中的操作都是原子性操作。其實只有語句1是原子性操作,其他三個語句都不是原子性操作。
語句1是直接將數值10賦值給x,也就是說線程執行這個語句的會直接將數值10寫入到工作內存中。
語句2實際上包含2個操作,它先要去讀取x的值,再將x的值寫入工作內存,雖然讀取x的值以及將x的值寫入工作內存這2個操作都是原子性操作,但是合起來就不是原子性操作了(可能讀取完就被中斷了)。
同樣的,x++和 x = x+1包括3個操作:讀取x的值,進行加1操作,寫入新的值。
? 所以上面4個語句只有語句1的操作具備原子性。
也就是說,只有簡單的讀取、賦值(而且必須是將數字賦值給某個變量,變量之間的相互賦值不是原子操作)才是原子操作。
不過這里有一點需要注意:在32位平臺下,對64位數據的讀取和賦值是需要通過兩個操作來完成的,不能保證其原子性。但是好像在最新的JDK中,JVM已經保證對64位數據的讀取和賦值也是原子性操作了。
從上面可以看出,Java內存模型只保證了基本讀取和賦值是原子性操作,如果要實現更大范圍操作的原子性,可以通過synchronized和Lock來實現。由于synchronized和Lock能夠保證任一時刻只有一個線程執行該代碼塊,那么自然就不存在原子性問題了,從而保證了原子性。
?
2.可見性
對于可見性,Java提供了volatile關鍵字來保證可見性。
當一個共享變量被volatile修飾時,它會保證修改的值會立即被更新到主存,當有其他線程需要讀取時,它會去內存中讀取新值。(volatile會立刻更新)
而普通的共享變量不能保證可見性,因為普通共享變量被修改之后,什么時候被寫入主存是不確定的,當其他線程去讀取時,此時內存中可能還是原來的舊值,因此無法保證可見性。
另外,通過synchronized和Lock也能夠保證可見性,synchronized和Lock能保證同一時刻只有一個線程獲取鎖然后執行同步代碼,并且在釋放鎖之前會將對變量的修改刷新到主存當中。因此可以保證可見性。(個人的理解是他們的效率太慢了)
synchronized?
3.有序性
在Java內存模型中,允許編譯器和處理器對指令進行重排序,但是重排序過程不會影響到單線程程序的執行,卻會影響到多線程并發執行的正確性。
在Java里面,可以通過volatile關鍵字來保證一定的“有序性”(具體原理在下一篇博文講述)。另外可以通過synchronized和Lock來保證有序性,很顯然,synchronized和Lock保證每個時刻是有一個線程執行同步代碼,相當于是讓線程順序執行同步代碼,自然就保證了有序性。
另外,Java內存模型具備一些先天的“有序性”,即不需要通過任何手段就能夠得到保證的有序性,這個通常也稱為 happens-before 原則。如果兩個操作的執行次序無法從happens-before原則推導出來,那么它們就不能保證它們的有序性,虛擬機可以隨意地對它們進行重排序。
總結
以上是生活随笔為你收集整理的并发编程:原子性问题,可见性问题,有序性问题。的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: Dubbo:Dubbo入门---搭建一个
- 下一篇: 多线程:happens-before原则