漫談并發編程(三):共享受限資源
來源:程序員人生 發布時間:2014-11-07 09:02:59 閱讀次數:1977次
解決同享資源競爭
1個不正確的訪問資源示例
斟酌下面的例子,其中1個任務產生偶數,而其他任務消費這些數字。這里,消費者任務的唯1工作就是檢查偶數的有效性。
我們先定義1個偶數生成器的抽象父類。
public abstract class IntGenerator {
private volatile boolean canceled = false;
public abstract int next( );
public void cancle( ) { canceled = true; }
public boolean isCanceled( ) { return canceled; }
}
下面定義消費者任務。
public class EvenChecker implements Runnable {
private IntGenerator generator;
private final int id;
public EvenChecker(IntGenerator g , int ident) {
generator = g;
id = ident;
}
public void run() {
while( !generator.isCanceled() ) {
int val = generator.next();
if(val % 2 != 0) {
System.out.println(val + "not even!");
generator.cancle();
}
}
}
public static void test(IntGenerator gp, int count) {
System.out.println("Press Control -C to exit");
ExecutorService exec = Executors.newCachedThreadPool();
for(int i = 0; i < 10;i++)
exec.execute(new EvenChecker(gp, i));
exec.shutdown();
}
public static void test(IntGenerator gp) {
test(gp, 10);
}
}
public class EvenGenerator extends IntGenerator {
private int currentEvenValue = 0;
public int next() {
++currentEvenValue;
++currentEvenValue;
return currentEvenValue;
}
public static void main(String []args) {
EvenChecker.test(new EvenGenerator());
}
}
/* output:
Press Control -C to exit
15243not even!
15245not even!
這個程序終究失敗,由于各個EvenChecker任務在EvenGenerator處于"不恰當的"狀態時,仍能夠訪問其中的信息。比以下面場景:1. A線程履行1條自加操作后放棄時間片,B線程接著履行兩次自加及輸出。 2. A線程在自加后return語句前放棄時間片,B線程完成1次自加,然后A又履行,在這類情況下依然會返回奇數。
解決競爭的方法
基本上所有的并發模式在解決線程沖突問題的時候,都是采取序列化訪問同享資源的方案。這意味著在給定時刻只允許1個任務訪問同享資源,通常這是通過在代碼前面加上1條鎖語句來實現的,這就使得在1段時間內只有1個任務可以運行這段代碼。由于鎖語句產生了1種相互排擠的效果,所以這類機制常常被稱為互斥量(mutex)。
Java以提供關鍵字synchronized的情勢,為避免資源沖突提供了內置支持。當任務要履行被synchronized關鍵字保護的代碼片斷的時候,它將檢查鎖是不是可用,然后獲得鎖,履行代碼,釋放鎖。
要控制對同享資源的訪問,得先把它包裝進1個對象,然后把所有要訪問這個資源的方法標記為synchronized,如果某個任務處于1個對標記為synchronized的方法的調用中,那末在這個線程從該方法返回之前,其他所有要調用類中任何標記為synchronized方法的線程都將被阻塞。
下面是聲明synchronized方法的方式:
synchronized void f() {/*... */}
synchronized void g(){/*....*/}
所有對象都自動含有單1的鎖(也稱為監視鎖)。當在對象上調用其任意synchronied方法的時候,此對象都被加鎖,這時候該對象上的其他synchronized方法只有等到前1個方法調用終了并釋放了鎖以后才能被調用。在使用并發時,將域設置為private是非常重要的,這是1種保證,保證沒有其他任務可以直接訪問到該域。
1個任務可以屢次取得對象的鎖。如果1個方法在同1個對象上調用了第2個方法,后者又調用了同1個對象上的另外一個方法,就會產生這類情況。JVM負責跟蹤對象被加鎖的次數。如果1個對象被解鎖(即鎖被完全釋放),其計數變成0。
針對每一個類,也有1個鎖(作為類的class對象的1部份),所以synchronized static方法可以在類的范圍內避免對static數據的并發訪問。
總結來講,在多個線程訪問同1對象時,如果會出現線程競速問題(所有線程只讀則不會出現此狀態),解決辦法是把這個同享對象轉變成線程安全對象(或使被調用的方法是線程安全的),或將所有線程對該資源的訪問序列化(用鎖在線程本身任務內同步)。如果對該資源的訪問是復合操作,即便同享對象本身是線程安全的,也沒法保證數據的1致性,例如:if(
put(**))這類操作,就必須要把復合操作全部包括在鎖內,對存在多個對象的同享,如果相互之間有狀態的關聯,這類處理方式仍然有效。
同步控制EvenGenerator
通過在EvenGenerator.java中加入synchronized關鍵字,可以避免不希望的線程訪問:
public class SynchronizedEvenGenerator extends IntGenerator {
private int currentEvenValue = 0;
@Override
public synchronized int next() {
++currentEvenValue;
Thread.yield();
++currentEvenValue;
return currentEvenValue;
}
public static void main(String[] args) {
EvenChecker.test(new SynchronizedEvenGenerator());
}
}
使用顯式的Lock對象
java.util.concurrent類庫中還包括有顯式的互斥機制。Lock對象必須被顯式的創建、鎖定和釋放。因此,它與內建的鎖情勢相比,代碼缺少優雅性,但更加靈活。下面是采取Lock重寫的EvenGenerator。
public class MutexEvenGenerator extends IntGenerator {
public int currentEvenValue = 0;
private Lock lock = new ReentrantLock();
@Override
public int next() {
lock.lock();
try {
++currentEvenValue;
++currentEvenValue;
return currentEvenValue;
} finally {
lock.unlock();
}
}
public static void main(String []args) {
EvenChecker.test( new MutexEvenGenerator( ));
}
}
MutexEvenGenerator添加了1個互斥調用的鎖,并使用lock()和unlock()方法在next()內部創建了臨界資源。當你在使用Lock對象時,有1些原則需要被記住:你必須放置在finally子句中帶有unlock()的try-finally語句,以免該鎖被無窮期鎖住。注意,return語句必須在try子句中出現,以確保unlock()不會過早產生,從而將數據暴露給第2個任務。
Lock比synchronized靈活體現在:可以在同享對象中使用多個Lock來分隔操作,以提高并發度。除此以外,Lock可以支持你嘗試獲得鎖且終究獲得鎖失敗,或嘗試著獲得鎖1段時間,然后放棄它。
public class MyMutexTest {
private static Lock lock = new ReentrantLock();
public static void main(String args[]) {
new Thread( new Runnable() {
public void run() {
lock.lock();
while(true);
}
}).start();
new Thread(new Runnable() {
public void run() {
if( lock.tryLock() == false ) {
System.out.println("acquire lock failed 1");
}
}
}).start();;
new Thread( new Runnable() {
public void run() {
try {
if(lock.tryLock(2, TimeUnit.SECONDS) == false) {
System.out.println("acquire lock failed 2");
}} catch (InterruptedException e) {
}
}
}).start();
}
}
/*output:
acquire lock failed 1
acquire lock failed 2
原子性與可見性
原子性可以利用于除long和double以外的所有基本類型之上的"簡單操作"。對讀取和寫入除long和double以外的基本類型變量這樣的操作,可以保證它們會被當作不可分(原子)的操作來操作內存。但是JVM可以將64位(long和double變量)的讀取和寫入當作兩個分離的32位操作來履行,這就產生了在1個讀取和寫入操作中間產生上下文切換,從而致使不同的任務可以看到不正確結果的可能性(這有時被稱為字撕裂,由于你可能會看到部份被修改過的數值)。但是,當你定義long或double變量時,如果使用valatile關鍵字,就會取得(簡單的賦值與返回操作的)原子性。
對可見性的討論,從下面的例子開始:
public class MyVisibilityTest implements Runnable{
private static boolean mv = false;
private static int integer = 0;
@Override
public void run() {
while(true) {
if(mv == true) {
System.out.println(integer);
return;
}
}
}
public static void main(String []args) {
new Thread(new MyVisibilityTest()).start();
integer = 34;
mv = true;
}
}
上面的程序運行效果,有時很久才打印出34,有時乃至匪夷所思的打印出0,這是由于對象的可見性的原因。
在多處理器系統上,相對單處理器而言,可見性問題要突兀的多。1個任務做出的修改,即便在不中斷的意義上講是原子性的,對其他任務也多是不可見的(例如,修改只是暫時性地存儲在本地處理器的緩存中)。
volatile的作用
把變量聲明為volatile類型后,編譯器與運行時都會注意到這個變量是同享的,因此不會將該變量上的操作與其他內存操作1起重排序。volatile變量不會被緩存在寄存器或對其他處理器不可見的地方,因此在讀取volatile類型的變量時總會返回最新寫入的值。
如果我們將上面例子中的成員變量聲明為volatile類型,則程序將"正常"輸出。
下面借用《Java并發編程實戰》中對volatile類型使用處景的描寫。
- 對變量的寫入操作不依賴變量確當前值(如count++),或你能確保只有單個線程更新變量的值
- 該變量不會與其他狀態變量1起納入不變性條件中
- 在訪問變量時不需要加鎖(由于volatile只確保可見性)
個人使用經驗:對1個對象,不管是復雜類型還是基本類型如果在該對象上存在多個線程間的復合操作(如count++、if( ){do()}),則不應在此對象上使用volatile(而是直接使用同步機制保證線程安全性)。在滿足上述條件的基礎上,如果該變量是簡單類型,則可使用volatile保證其可見性,由于簡單類型具有原子性(double、long使用volatile后也具有),則對該變量的訪問是線程安全的。如果該變量是復合類型,如果對該變量的寫操作只是將援用直接修改,那末也能夠可以volatile保證寫操作的可見性,在此基礎上,對該復合類型的操作也就是線程安全的了。
對基本類型來講,原子性+可見性 = 該變量的線程安全性,就算變量本身是線程安全的,對該變量的復合操作也會致使線程不安全。
原子類
Java SE5引入了諸如AtomicInteger、AtomicLong、AtomicReference等特殊的原子性變量類。它們提供了下面情勢的原子性條件更新操作:
boolean compareAndSet(expectedValue, updateValue)
這些類被調劑為可以操作在機器級別上的原子性。如果在只同享1個對象的條件下,它為你提供了1種將線程間的復合操作轉為線程安全操作的機會。
下面用利用AtomicInteger重寫MutexEvenGenerator.java。
public class AtomicEvenGenerator extends IntGenerator {
private AtomicInteger currentEvenValue = new AtomicInteger(0);
public int next() {
return currentEvenValue.addAndGet(2);
}
public static void main(String args[]) {
EvenChecker.test(new AtomicEvenGenerator());
}
}
臨界區
有時,你只是希望避免多個線程同時訪問方法內部的部份代碼而不是避免訪問全部方法,通過這類方式分離出來的代碼段被稱為臨界區(critical section),它也使用synchronized關鍵字建立。這里,synchronized被用來指定某個對象,此對象的鎖被用來對花括號內的代碼進行同步控制:
synchronized(syncObject) {
....
}
這也被稱為同步控制塊;在進入此段代碼前,必須得到syncObject對象的鎖。如果其他線程已得到這個鎖,那末就得等到鎖被釋放以后,才能進入臨界區。
使用臨界區的用法其實和Lock用法極為類似,但Lock更加靈活。二者都得顯式的利用1個對象,synchronized是使用其他對象,Lock是使用本身,相比之下,synchronized更加晦澀。Lock可以在1個函數中加鎖,另外一個函數中解鎖,臨界區做不到,但這也給Lock帶來使用風險。sychronized怎樣才能不使用額外的1個對象進行加鎖?辦法就是對this加鎖,如果多個線程履行的是同1任務,使用sychronized是不錯的選擇,由于它可以免你顯式的定義和使用1個Lock。
線程本地貯存
避免任務在同享變量上產生沖突的第2種方式是根除對變量的同享。線程本地貯存(TLS)是1種自動化機制,可以為使用相同變量的每一個不同的線程都創建不同的貯存。因此,如果你有5個線程都要使用變量x所表示的對象,那線程本地貯存就會生成5個用于x的不同的貯存塊。主要是,它們使得你可以將狀態與線程關聯起來。
線程本地貯存不是1種線程間同享資源的機制,它主要作用是作為對每一個線程本身狀態的貯存,比如放在上下文環境中,因此1般使用為靜態域貯存。創建和管理線程本地貯存可以由java.lang.ThreadLocal類來實現,以下:
class ContextThread {
private static ThreadLocal<Integer> value = new ThreadLocal<Integer>();
public static void setInteger(Integer value){
ContextThread.value.set(value);
}
public static Integer getInteger() {
return value.get();
}
public static void increment() {
value.set(value.get() + 1);
}
}
public class ThreadLocalTest {
public static void main(String []args) {
for(int i = 0; i < 5; i++) {
new Thread(new Runnable() {
@Override
public void run() {
ContextThread.setInteger(0);
ContextThread.increment();
System.out.println( ContextThread.getInteger() );
}
}).start();
}
}
}
/*output
1
1
1
1
1
在創建ThreadLocal時,你只能通過get()和set()方法來訪問該對象的內容,其中,get()方法將返回與其線程相干的對象的副本,而set()會將參數插入到為其線程貯存的對象中。
生活不易,碼農辛苦
如果您覺得本網站對您的學習有所幫助,可以手機掃描二維碼進行捐贈