標籤:沒有 記憶體 並發 操作 rup one tor interrupt 層級
假設一個線程為變數賦值:variable = 3;
記憶體模型需要解決一個問題:“在什麼條件下,讀取variable的線程將看到這個值為3?”
這看上去理所當然,但是如果缺少同步,那麼將會有許多因素使得線程無法立即甚至永遠,看到另一個線程的操作結果。
如:
1.在編譯器中產生的指令順序,可以與原始碼中的順序不同,此外編譯器還會將變數儲存在寄存器而不是記憶體中;
2.處理器可以採用亂序或並行等方式來執行指令;
3.緩衝可能會改變將寫入變數提交到主記憶體的次序;
4.而且儲存在處理器本機快取中的值,對於其他處理器是不可見的。
這些因素都會使得一個線程無法看到變數的最新值,並且會導致其他線程中的記憶體操作似乎在亂序執行。
Java語言規範要求JVM線上程中維護一種類似串列的語義:只要程式的最終結果與嚴格串列環境中執行的結果相同,那麼上述所有的操作都是允許的。
這確實是一件好事,因為電腦近年來在效能上的提升很大程度要歸功於這些重新排序措施。
在單線程環境中,我們無法看到所有這些底層技術,它們除了提高程式的執行速度外,不會產生其他影響。
在多線程環境中,要維護程式的串列性將導致很大的效能開銷。對於並發應用程式中的線程來說,它們在大部分時間裡都執行各自的任務,因此線上程之間的協調操作只會降低應用程式的運行速度,而不會帶來任何好處。只有當多個線程要共用資料時,才必須協調它們之間的操作,並且JVM依賴程式通過同步操作來找出這些協調操作將在何時發生。
JVM規定了一組最小保證,這組保證規定了對變數的寫入操作將在何時對於其他線程可見。JVM在設計時就在可預測性和程式的易於開發性之間進行了權衡,從而在各種主流的處理器體系架構上都能實現高效能的JVM。
平台的記憶體模型
在共用記憶體的多處理器體系架構中,每個處理器都擁有自己的緩衝,並且定期地與主記憶體進行協調。
在不同的處理器架構中提供了不同層級的緩衝一致性,其中一部分只提供最小的保證,即允許不同的處理器在任意時刻從同一個儲存位置上看到不同的值。
要想確保每個處理器都能在任意時刻知道其他處理器進行中的工作,將需要非常大的開銷。在大多數時間裡,這種資訊是不必要的,因此處理器會適當放寬儲存一致性保證,以換取效能的提升。
在架構定義的記憶體模型中將告訴應用程式可以從記憶體系統中獲得怎樣的保證,此外還定義了一些特殊的指令(稱為記憶體柵欄),當需要共用資料時,這些指令就能實現額外的儲存協調保證。為了使Java開發人員無須關心不同架構上記憶體模型之間的差異,Java還提供了自己的記憶體模型,並且JVM通過在適當的位置插上記憶體柵欄來屏蔽在JVM與底層平台記憶體模型之間的差異。
假設:想象在程式中只存在唯一的操作執行順序,而不考慮這些操作在何種處理器上執行,並且在每次讀取變數時,都能獲得在執行序列中(任何處理器)最近一次寫入該變數的值。
這種樂觀的模型被稱為串列一致性,開發人員經常會錯誤的假設存在串列一致性,但在任何一款現代多處理器架構中都不會提供這種串列一致性,JVM也如此。
在支援共用記憶體的多處理器和編譯器中,當跨線程共用資料時,會出現一些奇怪的情況,除非通過使用記憶體柵欄來防止這些情況的發生。不過在Java程式中不需要指定記憶體柵欄的位置,而只需要通過正確地使用同步來找出何時將訪問共用狀態。
重排序/**
* @author 83921
* 在沒有正確同步的情況下,即使要推斷最簡單的並發程式的行為也很困難。在樣本中:
* 很容易想象如何輸出(1,0),(0,1)或(1,1),T1可以在T2開始之前完成,T2也可以在T1開始之前完成,或者二者交替執行。
* 但還可以輸出(0,0),由於每個線程中的各個操作之間不存在資料流依賴性,因此這些操作可以亂序執行(即使這些操作按照順序執行,但在將
* 緩衝重新整理到主記憶體的不同時序中也可能出現這種情況,在T2的角度看,T1的賦值操作可能以相反的次序執行)。
* 可以想象在T2看來的執行順序[ x=b, b=1, y=a, a=1 ]
* 要列舉出這個簡單樣本的所有可能執行結果非常困難,記憶體級的重排序會使程式的行為變得不可預測。
* 而要確保在程式中正確地使用同步卻非常容易,同步將限制 編譯器、運行時和硬體對記憶體操作重排序的方式,從而在重排序時不會破壞JVM提供的可見度保證。
*/
public class Demo{
static int x = 0, y = 0;
static int a = 0, b = 0;
public static void main(String[] args) throws InterruptedException {
Thread T1 = new Thread(new Runnable(){
public void run() {
a = 1;
x = b;
}
});
Thread T2 = new Thread(new Runnable(){
public void run() {
b = 1;
y = a;
}
});
T1.start();
T2.start();
T1.join();
T2.join();
System.out.println(x +"--"+y);
}
} Java記憶體模型
java記憶體模型是通過各種操作來定義的,包括對變數的讀/寫操作,監視器的加鎖和釋放操作,以及線程的啟動和合併作業。
JVM為程式中所有的操作定義了一個偏序關係,稱為Happens-Before。要想保證執行操作B的線程看到操作A的結果(無論A和B是否在同一個線程中執行),那麼在A和B之間必須滿足Happens-Before關係。如果缺乏這個關係,那麼JVM可以對它們任意的重排序。
當一個變數被多個線程讀取並且至少被一個線程寫入時,如果在讀操作和寫操作之間沒有依照Happens-Before來排序,那麼就會產生資料競爭問題。在正確同步的程式中不存在資料競爭,並會表現出串列一致性,就是說程式中的所有操作都會按照一種固定的和全域的順序執行。
Happens-Before規則:
1.程式順序規則:如果程式中操作A在操作B之前,那麼線程中操作A將在操作B之前執行。
2.監視器鎖規則:在監視器鎖上的解鎖操作必須在同一個監視器鎖上的加鎖操作之前執行。
3.volatile變數規則:對volatile變數的寫入操作必須在對該變數的讀操作之前執行。
4.線程啟動規則:線上程上對Thread.Start的調用必須在該線程中執行任何操作之前執行。
5.線程結束規則:線程中的任何操作都必須在其他線程檢測到該線程已經結束之前執行,或者Thread.join中成功返回,或者在調用Thread.isAlive時返回false。
6.終端規則:當一個線程在另一個線程上調用interrupt時,必須在被終端線程檢測到interrupt調用之前執行通過拋出(InterruptException,或者調用isInterrupted和interrupted。)
7.終結器規則:對象的建構函式必須在啟動該對象的終結器之前執行完成。
8.傳遞性:如果操作A在操作B之前執行,並且操作B在操作C之前執行,那麼操作A必須在操作C之前執行。
雖然這些操作只滿足偏序關係,但同步操作,如鎖的擷取與釋放,以及volatile變數的讀取與寫入操作,都滿足全序關係。
因此,在描述Happens-Before關係時,就可以使用“後續的鎖擷取操作”和“後續的volatile變數讀取操作”等表達術語。
當兩個線程使用同一個鎖進行同步時,在它們之間存在Happens-Before關係。
線上程A內部的所有操作都按照它們在來源程式中的先後順序來排序,線上程B內部的操作也是如此。在A釋放了鎖M,並且B隨後擷取了鎖M,因此A中所有在釋放鎖之前的操作,就位於B中請求鎖之後的所有操作之前。而如果兩個線程是在不同的鎖上進行同步的,那麼就不能推斷它們之間的動作順序,因為兩個線程之間並不存在Happens-Before關係。
藉助同步
將Happens-Before的程式順序規則與其他某個順序規則(通常是監視器鎖規則或volatile變數規則)結合起來,從而對某個未被鎖保護的變數的訪問操作進行排序。
在FutureTask的保護方法AbstractQueuedSynchronizer中說明了如何使用這種“藉助”技巧。
AQS維護了一個表示同步器狀態的整數,FutureTask用這個整數來儲存任務的狀態。但FutureTask還維護了其他一些變數,如計算結果。
當一個線程調用set來儲存結果並且另一個線程調用get來擷取結果時,這兩個線程最好按照Happens-Before進行排序。這可以將執行結果的引用聲明為volatile類型來實現,但利用現有的同步機制可以更容易地實現相同的功能。
import java.util.concurrent.CancellationException;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.locks.AbstractQueuedSynchronizer;
/**
* @author 83921
* FutureTask在設計時能夠確保,在調用tryAcquireShared之前總能成功地調用tryReleaseShared。
* tryReleaseShared會寫入一個volatile類型的變數,而tryAcquireShared將讀取這個變數。
* 在儲存和擷取result時將調用innerSet和innerGet方法。
* 由於innerSet將在調用releaseShared(這又將調用tryReleaseShared)之前寫入result,
* 並且innerGet將在調用acquireShared(這又將調用tryAcquireShared)之後讀取result,
* 因此就可以確保innerSet的寫入操作在innerGet中的讀取操作之前執行。
*/
public class FutureTask<V>{
private final class Sync extends AbstractQueuedSynchronizer{
private static final int RUNNING = 1;
private static final int RAN = 2;
private static final int CANCELLED = 4;
private V result;
private Exception exception;
void innerset(V v){
while(true){
int s = getState();
if(ranOrCancelled(s)){
return;
}
if(compareAndSetState(s,RAN)){
break;
}
}
result = v;
releaseShared(0);
done();
}
V innerGet() throws InterruptedException, ExecutionException{
acquireSharedInterruptibly(0);
if(getState() == CANCELLED){
throw new CancellationException();
}
if(exception != null){
throw new ExecutionException(exception);
}
return result;
}
}
}
類庫中提供的其他Happens-Before排序如:
1.將一個元素放入一個安全執行緒容器的操作將在另一個線程從該容器中獲得這個元素的操作之前執行。
2.在CountDownLatch上的倒數操作將線上程從閉鎖上的await方法中返回之前執行。
3.在釋放Semaphore許可操作將在從該Semaphore上獲得一個許可之前執行。
4.Future表示的任務的所有操作將在從Future.get中返回之前執行。
5.向Exceutor提交一個Runnable或Callable的操作將在任務開始之前執行。
6.一個線程到達CyclicBarrier或Exchanger的操作將在其他到達該柵欄或交換點的線程被釋放之前執行。如果CyclicBarrier使用一個柵欄操作,那麼到達柵欄的操作將在柵欄操作之前執行,而柵欄操作又會線上程從柵欄中釋放之前執行。
發布 不安全的發布:
當缺少Happens-Before關係時,就可能出現重排序問題,這可以解釋為什麼在沒有充分同步的情況下發布一個對象會導致另一個線程看到一個只被部分構造的對象。
在初始化一個新的對象時需要寫入多個變數,即新對象中的各個域。同樣,在發布一個引用時也需要寫入一個變數,即新對象的引用。
如果無法確保發布共用引用的操作在另一個線程載入該共用引用之前執行,那麼對新對象引用的寫入操作與對象中各個域的寫入操作重排序(從使用該對象的線程的角度來看)。
在這種情況下,另一個線程可能看到對象引用的最新值。但同時也可能看到對象的某些或全部狀態中包含的是無效值,即一個被部分構造的對象。
/**
* @author 83921
* 程式中存在的問題似乎只有競態條件問題(當所有Resource樣本都相同時可以忽略)
* 即使不考慮這個問題,這樣發布仍然是不安全的,因為在另一個線程可能看到部分構造的Resource執行個體的引用。
*
* 假設T1是第一個調用getInstance的線程,它將看到resource為null,並且初始化一個新的Resource,然後將resource設定為這個新執行個體。
* 當T2隨後調用getInstance,它可能看到resource值為非空,因此使用這個已經構造好的Resource。
* 但T1寫入resource的操作與T2讀取resource的操作之間並不存在Happens-Before方法。
*
* 當新分配一個Resource時,Resource的建構函式將把新執行個體中的各個域由預設值修改為初始值。
* 由於兩線程未使用同步,因此T2看到的T1的操作順序,可能與T1執行這些操作時的順序不同。
* 即T2可能看到對resource的寫入操作將在Resource各個域的寫入操作之前發生。 從而T2就看到一個被部分構造處於無效狀態的Resource執行個體。
*/
public class Resource{
private static Resource resource;
public static Resource getInstance(){
if(resource == null){
resource = new Resource();
}
return resource;
}
}
除了不可變對象以外,使用被另一個線程初始化的對象通常都是不安全的,除非對象的發佈動作是在使用該對象的線程開始使用之前執行。
安全發布:在上面樣本中需要將getInstance改為synchronized,使用同步即可解決問題。
JVM在初始器中採用了特殊的方式來處理靜態域(或者在靜態初始化代碼塊中初始化的值),並提供了額外的執行緒安全性保證。
靜態初始化器是由JVM在類的初始化階段執行,即在類被載入後並且線程使用之前。
由於JVM將在初始化期間獲得一個鎖,並且每個線程只是擷取一次這個鎖以確保這個類已經載入,因此在靜態初始化期間,記憶體的寫入操作將自動對所有線程可見。
因此無論是在被構造期間還是被引用時,靜態初始化的對象都不需要顯示的同步。
然而這個規則僅適用於在構造時的狀態,如果對象時可變的,那麼在讀線程和寫線程之間仍然需要通過同步來確保隨後的修改操作是可見的,以及避免資料破壞。
將靜態初始化器這種特性和JVM的消極式載入機制結合起來,可以形成一種延遲初始化技術。
/**
* @author 83921
* 延遲初始化佔位模式:使用一個專門的類來初始化Resource。
* JVM將延遲ResourceHolder的初始化操作,直到開始使用這個類時才初始化,並且由於通過一個靜態初始化來初始化Resource,因此不需要額外的同步。
* 當任何一個線程第一次調用getResource時,都會使ResourceHolder被載入和初始化,此時靜態初始化器將執行Resource的初始化操作。
*/
public class ResourceFactory{
private static class ResourceHolder{
public static Resource resource = new Resource();
}
public static Resource getResource(){
return ResourceHolder.resource;
}
}
#筆記內容來自 《java並發編程實戰》
java並發編程12.java記憶體模型