[Java]多線程:共享資源同步——不認真看你會後悔的

共享資源同步
在進行多線程開發時最令人頭痛的問題估計就是對共享資源的控制了吧,今天就讓我們談一談這個問題吧。
共享資源顧名思義就是需要被多個線程使用的資源,但是很多情況下我們是不能允許多個線程同時使用這個資源的。這往往會產生令人意想不到的問題。就比如下面這個例子:

package com.mfs.thread;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/*
 * 這是一個生成器的抽象類
 */
abstract class Generator {
	
	private boolean canceled = false;
	public abstract int next();  //next()只給出接口,沒有實現,這要在其生成類中具體實現
	public void cancel() {   
		//取消該生成器,可以看到這個方法只有一條賦值語句,這是一原子操作,也就是說線程在調用此方法時絕對不會被打斷
		canceled = true;
	}
 	public boolean isCanceled() {
 		//返回該生成器是否已被取消,這也是個原子操作
		return canceled;
	}
}
/*
 * 本意是想寫一個偶數生成器,但事實證明這個生成器並不能做到僅生成偶數
 * 所以取名爲整數生成器,但你要記住我們的本意是寫一個偶數生成器
 */
class IntGenerator extends Generator {   
	private volatile int currentData = 0;    // 當前值。volatile關鍵字在後面介紹,你此時大可不必在意它。雖然他確實有他存在的道理
	@Override
	/*
	 * next方法會返回下一個偶數
	 * 但是可以看到,這其中有多條語句,並且使用了自增運算符,所以該方法一定不是原子操作
	 * 也就是說當一個線程執行此方法時極有可能在某個地方被終止
	 * 假設執行完第一個自增後被終止(此時currentData的值必定爲奇數,假設爲3)
	 * 則下一個進程進入此方法後自增兩次後,返回的值爲奇數5,這就不符合偶數生成器的初衷了
	 * 也就證明了多個線程同時共享同一個資源會產生不可思議的錯誤的問題了
	 */
	public int next() {	
		// TODO Auto-generated method stub
		currentData ++;
		Thread.yield();  
		//在此處加一個yied方法是爲了增加在此處發生線程中斷的機率,線程在何處中斷,什麼操作是原子操作跟具體的操作系統及JVM有很大關係
		//也就是說如果不加這個在有些情境下有可能產生一個完美序列,不會發生衝突(我的電腦就是如此),但我們絕對不可以靠這個來解決線程使用共享資源的衝突問題
		currentData ++;
		return currentData;
	}
	
}
class ConsumeInt implements Runnable {
	private static int taskCount = 0;
	private final int id = taskCount ++;  //線程id
	private Generator generator;	//持有的生成器對象
	public ConsumeInt(Generator gen) {
		// TODO Auto-generated constructor stub
		generator = gen;
	} 
	@Override
	public void run() {
		// TODO Auto-generated method stub
		while (!generator.isCanceled()) {	//只要生成器沒被取消就一直取出下一個值
			int n = generator.next();
			if (n % 2 != 0) {	//如果取出的值爲奇數,就說明此生成器已經發生了線程衝突,這時就取消這個生成器
				generator.cancel();
				System.out.println("#" + id + "已取消生成器" + n);
			}
			System.out.print("#" + id + "(" + n + ") ");	//輸出取出的值,格式爲:#id(值)
		}
	}
}

public class Test {
	public static void main(String[] args) {
		ExecutorService pool = Executors.newCachedThreadPool();
		Generator generator = new IntGenerator();
		for (int i = 0;i < 5; i ++) {  //開啓五個線程,這五個線程都持有同一個生成器對象
			pool.execute(new ConsumeInt(generator));
		}
		pool.shutdown();
	}
}

結果:

可以看到衝突確實發生了,線程一先進入了next方法,但是僅執行了一次自增運算就發生了中斷,此時的currentData的值爲1。然後線程0又進入了next方法,並且一次性執行完了這個方法,自增兩次後返回的值爲3,線程0就取消了生成器。但是線程1已經進入了next方法,所以即便是取消了生成器仍能接着執行第二次自增,最後返回4。
我們有什麼方法防止這種事情發生呢?其實方法還是有的,大致思路有兩種,第一種就是保證多個線程線性的獲取資源的控制權,在前一個線程沒有放棄掌控權之前其他線程必須等待;第二中就是把共享資源建立多個備份,每一個線程都要與一個備份建立關聯,但是這樣無疑會阻斷線程之間的通信。
按照上述思路實現衝突結局的方法也不少,接下來我們對第一種思路的方法一一介紹:

  1. 資源同步(synchronized)
    使用synchronized修飾的方法一次僅能有一個線程使用,如果一個對象的一個synchronized方法被線程0使用了,那麼同一個對象中的所有被synchronized修飾的方法都將不能被其他線程使用。
    一個線程獲取同步資源控制權後,該資源的加鎖次數就會曾加1,如果該線程又使用了該對象的其他同步資源枷鎖次數又會加1。使用完畢後加鎖次數會減一,直到減到0爲止。枷鎖次數爲零的資源才能被其他線程獲取控制權。
    針對上面例子我們只需要使用synchronized關鍵字修飾一下next()方法就可以解決線程衝突了,修改後的IntGenerator類的next方法如下:
public synchronized int next() {	//只有當前線程使用完此方法後纔會此方法才能被其他線程使用	
		// TODO Auto-generated method stub
		currentData ++;
		Thread.yield();  
		currentData ++;
		return currentData;
	}
  1. 顯式Lock對象
    顯示Lock對象也是對共享資源進行加鎖,與synchronized相比Lock對象能夠允許我們對方法的部分代碼片段加鎖(synchronized也可以,我們在後面介紹);Lock對象還允許我們嘗試獲取鎖,如果不能獲取還可以去執行其他任務減少等待所浪費的時間;還有就是Lock對象還能夠使我們在控制對象發生異常後進行一些處理。
    我們以使用Lock對象的方式來修改一下開頭例子中的代碼,修改後的IntGenerator類如下:
class IntGenerator extends Generator {   
	private volatile int currentData = 0;    
	private Lock lock = new ReentrantLock();
	@Override
	public int next() {	
		// TODO Auto-generated method stub
		lock.lock();
		try {
			currentData ++;
			Thread.yield();  
			currentData ++;
			return currentData;
		} finally {
			//如果發生異常可以保證此處吧也會被執行
			lock.unlock();
			//絕對不能再return之前解鎖,這樣可能導致還沒返回線程就被中斷了
		}
	}
	
}
  1. 原子操作
    如果能將每個方法的語句只有一條且爲原子操作,那麼也能夠保證一個線程進入該方法後立馬執行完畢而不給其他線程進入的機會。
    但是原子操作極少,我們能夠知道的原子操作也就return 和賦值操作(操作系統和JVM不同原子操作也會不同),而且原子性的數據類型也比較少。
    使用volatile關鍵字能夠將非原子性數據類型轉換成原子性數據類型。除保證原子性外volatile還有一個更重要的作用是保證應用可視性,就是保證我們對數據的每一次更改,都是對主存中數據的更改,而不是僅僅改掉緩衝器中的值。再文章開頭的例子中我們對volatile的使用就是使用的可視性,即保證再每次currentData自增後都是對主存中數據的更改,使得下一個插入的線程能夠讀到上一個線程更改後的值而不是原來的值。
    原子性是一個非常深奧的問題,所以除非你是專家,我們絕對絕對絕對不建議你使用原子性來解決資源同步問題。
    如果你不得不使用原子性來解決問題,那我們建議你使用Java提供的諸如AtomicInteger、AtomicLong、AtomicReference等的原子類。下面我們使用原子類來對IntGenerator進行修改:
class IntGenerator extends Generator {   
	private AtomicInteger currentData = new AtomicInteger(0);
	@Override
	public int next() {	  //線程先進入此方法使currentData加2然後再調用next獲取值,這中間又可能被中斷,但是絕對不會產生奇數
		// TODO Auto-generated method stub
		return currentData.get();
	}
	public void increament() {  
		currentData.addAndGet(2);	//AtomicIntger提供的方法基本都是原子性的
	}
	
}
  1. 臨界區(使用synchronized)
    上面提到過,synchronized也能實現部分代碼的同步處理接下來我們就實現一下:
class IntGenerator extends Generator {   
	private int currentData = 0;
	@Override
	public int next() {	  
		// TODO Auto-generated method stub
		synchronized (this) {	//要以一個對象作爲參數,因爲這個代碼塊中的內容要依賴這個對象參數的鎖來實現。這個對象通常是使用當前對象,但也不拒絕使用別的對象。如果這個類有兩個這樣的同步方法,你希望可以有兩個線程同時是進入這兩個方法時就可以使用兩個不同的對象參數實現。
			currentData ++;
			currentData ++;
			return currentData;
		}
	}	
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章