java編程思想之併發(共享資源)

關注個人公號:Android開發吹牛皮。互相學習java

有了併發咱們能夠同時作不少事情,可是,兩個或者多個線程互相干擾的問題也存在。若是不防範這種衝突,就可能出現兩個線程同時訪問一個銀行帳戶,向同一個打印機打印,改變同一個值等問題。npm

共享資源

單個線程每次只能作一件事情。由於只有一個實體因此永遠不用擔憂兩我的在同一個地方停車的問題。可是多線程會在同時訪問一個資源。緩存

不正確的訪問資源

咱們先作一個實驗,多個任務。一個任務產生一個偶數,其餘的任務檢驗偶數的有效性。安全

public abstract class IntGenerator {
	//爲了表示可見性,使用 volatile 修飾
	private volatile boolean canceled = false;
	public abstract int next();
	public void cancel(){
		canceled = true;
	}
	//查看該對象是否已經被撤銷
	public boolean isCanceled() {
		return canceled;
	}
}

複製代碼

任何 IntGenerator 均可以用下面的 EvenChecker 類來測試:bash

public class EvenChecker implements Runnable{
	private IntGenerator generator;
	private final int id;

	protected EvenChecker(IntGenerator generator, int id) {
		super();
		this.generator = generator;
		this.id = id;
	}

	@Override
	public void run() {
		while (!generator.isCanceled()) {
			int val = generator.next();
			if (val %2 !=0) {
				System.out.println("不是偶數");
				generator.cancel();
			}

		}

	}

	public static void test(IntGenerator gp,int count) {
		ExecutorService service = Executors.newCachedThreadPool();
		for (int i = 0; i < count; i++) {
			service.execute(new EvenChecker(gp, i));
		}
		service.shutdown();
	}

	public static void test(IntGenerator gp) {
		test(gp,10);
	}

}
複製代碼

上面的示例中 generator.cancel() 撤銷的不是任務自己,而是 IntGenerator 對象是否能夠被撤銷的條件。必須仔細考慮併發系統失敗的全部可能途徑,例如,一個任務不能依賴於另外一個任務。由於任務關閉的順序沒法獲得保證。這裏,經過使任務依賴於非任務對象,咱們能夠消除潛在的競爭條件。多線程

EvenChecker 老是讀取和測試 IntGenerator 的返回值。若是 isCanceled() 返回值爲 true,則 run() 返回,這將告知 test() 中的 Executor 該任務完成了。任何 EvenChecker 任務均可以在與其關聯的 IntGenerator 上調用 cancel(),這將致使全部其餘使用該 IntGenerator 的 EvenChecker 獲得關閉。併發

第一個 IntGenerator 有一個能夠產生一些列偶數值的 next() 方法:dom

public class EvenGenerator extends IntGenerator{
	private int currentEvenValue = 0;

	@Override
	public int next() {
		++currentEvenValue;
		++currentEvenValue;

		return currentEvenValue;
	}

	public static void main(String[] args) {
		EvenChecker.test(new EvenGenerator());
	}
}

複製代碼

執行結果:jvm

1537不是偶數
1541不是偶數
1539不是偶數

複製代碼

一個任務可能在另外一個任務執行第一個遞增操做以後,可是沒有執行第二個遞增操做以前,調用 next() 方法。這將使這個值處於不恰當狀態。爲了證實這是可能發生的,text() 方法建立了一組 EvenChecker 對象,以用來連續的讀取並輸出同一個 EvenGenerator,並檢測每一個數值是否都是偶數。若是不是就報錯終止。ide

這個程序最終會失敗終止,由於每一個 EvenChecker 任務在 EvenGenerator 處於不恰當的狀態時,仍可以訪問其中的信息。可是根據不一樣的操做系統和實現細節這個問題在循環屢次以後也可能不會被探測到。有一點很重要,那就是遞增程序自身也須要多個步驟,而且在遞增過程當中任務可能被掛起。也就是說遞增在 Java 中不是原子性操做。所以,若是不保護任務,即便單一的遞增也不是安全的。

解決共享資源競爭

前面的示例展現使用線程的一個基本問題:你永遠不知道一個線程什麼時候在運行。對於併發操做,你須要某種方式來防止兩個任務訪問相同的資源,至少在關鍵階段不能出現這種狀況。防止這種衝突的方法是當資源被一個任務使用時,在其上加鎖。第一個訪問某項資源的任務必須鎖定這個資源,使其餘任務在其被解鎖前沒法訪問他,而在其解鎖之時,另外一個任務就能夠鎖定並使用它,以此類推。

基本上全部的併發模式在解決線程衝突問題的時候,都是採用序列化訪問共享資源的方案。這意味着在給定時刻只容許一個任務訪問共享資源。一般這種是經過在代碼前面加上一句鎖語句來實現的,這就使得在一段時間內只有一個任務能夠運行這段代碼。由於鎖語句產生一種相互排斥的效果,這種機制稱爲互斥量。

另外當一個鎖被解鎖的時候,咱們並不能肯定下一個使用鎖的任務,由於線程調度機制並非肯定性的。能夠經過 yield() 和 setPriorit() 來給線程調度器提供建議。

Java 以提供關鍵字 synchronized 的形式,爲防止資源衝突提供了內在支持。當任務要執行被 synchronized 關鍵字保護的代碼片斷的時候,它將檢查鎖是否可用,而後獲取鎖,執行代碼,釋放鎖。共享資源通常是以對象像是存在於內存片斷,能夠是文件、輸入輸出端口。要控制對共享資源的訪問,得先把它包裝進一個對象。而後把全部要訪問這個資源的方法標記爲 synchronized。

下面是聲明 synchronized 方法的方式:

synchronized void f(){};
synchronized void g(){};
複製代碼

全部對象都自動含有單一的鎖(監視器)。當在對象上調用其任意 synchronized 方法的時候,此對象被加鎖,這時這個對象上的其餘 synchronized 方法只有等到前一個方法調用完畢並釋放了鎖以後才能被調用。對於某個特定對象來講,其全部 synchronized 方法共享同一個鎖,這能夠被用來防止多個任務同時訪問被編碼爲對象內存。

注意:使用併發時將對象設置爲 private 是很是重要的,不然,synchronized 關鍵字就不能防止其餘的任務直接訪問域,這樣就會產生衝突。

針對每一個類也有一個鎖,因此 synchronized static 方法能夠在類的範圍內防止對 static 數據的併發訪問。

該何時同步呢?

若是你正在寫一個變量,它可能接下來被另外一個線程讀取,或者正在讀取一個上一次被另外一個線程寫過的變量,那麼你必須使用同步,而且,讀寫線程都必須用相同的監視器鎖同步。
複製代碼

同步控制 EvenGenerator

經過在 EvenGenerator 中加入 synchronized 關鍵字,能夠防止不但願的線程訪問:

public class EvenGenerator 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 EvenGenerator());
	}
}
複製代碼

對 Thread.yield() 的調用被插入到兩個線程之間,以提升奇數的可能性。由於互斥能夠防止多個任務同時進入臨界區,因此上面不會產生任何的失敗。第一個進入 next() 的任務得到鎖,任何其餘試圖獲取鎖的任務都將被阻塞,直到第一個任務釋放鎖。

使用顯示的 Lock 對象

Java SE5 的類庫中還包含定義在 java.util.concurrent.locks 中的顯示的互斥機制。Lock 對象必須被顯示地建立、鎖定和釋放。所以,它與內建的鎖形式相比,代碼缺少有雅性。可是對於解決某些類型的問題時更加的靈活。

下面用顯示的 Lock 重寫上面的代碼:

public class EvenGenerator extends IntGenerator{
	private int currentEvenValue = 0;
	//建立鎖
	private Lock lock = new ReentrantLock();
	@Override
	public int next() {
		//鎖定
		lock.lock();
		try {
			++currentEvenValue;
			Thread.yield();
			++currentEvenValue;

			return currentEvenValue;
		}finally {
			//計算完畢後釋放鎖
			lock.unlock();
		}
	}

	public static void main(String[] args) {
		EvenChecker.test(new EvenGenerator());
	}
}

複製代碼

當你在使用 lock 對象時,示例的慣用法很重要:對 unlock() 方法的調用必須放在 try-finlly 語句中。注意,return 語句必須在 try 子句中出現,以確保 unlock() 不會過早的發生,從而將數據暴露在第二個任務。儘管 try-finlly 子句比 synchronized 關鍵字要多,但顯示的 lock 的優勢也是顯而易見的。若是在使用 synchronized 關鍵字時某些事物失敗了,那麼就會拋出一個異常。可是咱們並無機會去處理,以維護系統良好的狀態。顯示的 lock 對象,你就可使用 finlly 子句維護系統的正確狀態。大致上咱們使用 synchronized 的狀況更多,只有遇到解決特殊問題時纔是用顯示的 lock 對象。

示例:使用 synchronized 關鍵字不能嘗試着獲取鎖且獲取鎖會失敗,或者嘗試着獲取一段時間而後放棄它。

public class AttemptLocking {
	private ReentrantLock lock = new ReentrantLock();
	  public void untimed() {
		  //嘗試獲取鎖
	    boolean captured = lock.tryLock();
	    try {
	      System.out.println("tryLock(): " + captured);
	    } finally {
	      if(captured)
	        lock.unlock();
	    }
	  }
	  public void timed() {
	    boolean captured = false;
	    try {
	    	//嘗試2秒後失敗
	      captured = lock.tryLock(2, TimeUnit.SECONDS);
	    } catch(InterruptedException e) {
	      throw new RuntimeException(e);
	    }
	    try {
	      System.out.println("tryLock(2, TimeUnit.SECONDS): " +
	        captured);
	    } finally {
	      if(captured)
	        lock.unlock();
	    }
	  }
	  public static void main(String[] args) {
	    final AttemptLocking al = new AttemptLocking();
	    al.untimed(); // True -- lock is available
	    al.timed();   // True -- lock is available
	    // Now create a separate task to grab the lock:
	    new Thread() {
	      { setDaemon(true); }
	      public void run() {
	        al.lock.lock();
	        System.out.println("acquired");
	      }
	    }.start();
	    Thread.yield(); // Give the 2nd task a chance
	    al.untimed(); // False -- lock grabbed by task
	    al.timed();   // False -- lock grabbed by task
	  }
}

複製代碼

執行結果:

tryLock(): true
tryLock(2, TimeUnit.SECONDS): true
tryLock(): true
tryLock(2, TimeUnit.SECONDS): true
acquired
複製代碼

ReentrantLock 容許咱們嘗試着獲取鎖可是最終未獲取鎖,這樣若是其餘人已經獲取了鎖,那麼你就能夠決定離開作一些其餘的事情,而不是一直等待這個鎖被釋放。顯示的 Lock 對象在加鎖和釋放鎖方面,相對於內建的 synchronized 鎖來講,還賦予了你更細粒度的控制力。

原子性和易變性

在 Java 線程中,經常咱們會認爲原子操做不須要進行同步控制。原子操做是不能被線程調度機制中斷的。這樣的想法是錯誤的,依賴於原子性是危險的。原子性在 Java 的類庫中已經實現了一些更加巧妙的構建。原子性能夠應用於除了 long 和 double 以外的全部基本類型之上的 「簡單操做」。可是 jvm 會把 64 位的 long 和 double 操做當作兩個分離的 32 位的操做來執行,這就產生了一個讀取和寫入操做之間產生上下文切換,從而致使了不一樣的任務產生不正確結果的可能性。可是若是咱們使用 volatile 關鍵字就會得到原子性 (在 Java SE5 以前一直未能正確工做)。

所以,原子操做可由線程機制來保證其不可中斷,可是即使這樣,這也是一種簡化的機制。有時看起來很安全的原子性操做實際上也可能不安全。

在多核處理器上,可視性問題遠比原子性問題多得多。一個任務作出的修改可能對其餘任務是不可見的。由於每一個任務都會暫時把信息存儲在緩存中。同步機制強制在處理器中一個任務作出的修改必須是可見的。volatile 關鍵字確保了這種可視性。一個任務修改了對這個修飾對象的操做,那麼其餘的任務讀寫操做都能看到這個修改。即便是用了緩存也能被看到,由於 volatile 會被當即寫入主存。而讀寫操做就發生在主存中。同步也會致使向主存中刷新,因此若是一個對象是 synchronized 保護的那麼久沒必要使用 volatile 修飾。使用 volatile 而不是 synchronized 的惟一安全的狀況是類中只有一個可變的域。咱們的第一選擇應該是 synchronized 關鍵字,這是最安全的方式。

什麼是原子性操做?

對域中的值作賦值和返回操做一般都是原子性的。可是遞增和遞減並非:

public class Atomicity {
	int i;
	void f(){
		i++;
	}
	void g(){
		i +=3;
	}
}
複製代碼

咱們看編譯後的文件:

void f();
		0  aload_0 [this]
		1  dup
		2  getfield concurrency.Atomicity.i : int [17]
		5  iconst_1
		6  iadd
		7  putfield concurrency.Atomicity.i : int [17]
 // Method descriptor #8 ()V
 // Stack: 3, Locals: 1
 void g();
		0  aload_0 [this]
		1  dup
		2  getfield concurrency.Atomicity.i : int [17]
		5  iconst_3
		6  iadd
		7  putfield concurrency.Atomicity.i : int [17]
}
複製代碼

每一個指令都產生了一個 get 和 put ,他麼之間還有一些其餘的指令。所以在獲取和修改之間,另外一個任務可能會修改這個域。因此,這些操做不是原子性的:

咱們再看下面這個例子是否符合上面的描述:

public class AtomicityTest implements Runnable {
	  private int i = 0;
	  public int getValue() {
		  return i;
	  }

	  private synchronized void evenIncrement() {
		  i++;
		  i++;
	  }

	  public void run() {
	    while(true)
	      evenIncrement();
	  }

	  public static void main(String[] args) {
	    ExecutorService exec = Executors.newCachedThreadPool();
	    AtomicityTest at = new AtomicityTest();
	    exec.execute(at);
	    while(true) {
	      int val = at.getValue();
	      if(val % 2 != 0) {
	        System.out.println(val);
	        System.exit(0);
	      }
	    }
	  }
}

複製代碼

測試結果:

1

複製代碼

改程序找到奇數並終止。儘管 return i 是原子性操做,可是缺乏同步使得其數值能夠在不穩定的中間狀態時被讀取。還有因爲 i 不是 volatile 的也存在可視性的問題。getValue() 和 evenIncrement() 必須都是 synchronized 的。對於基本類型的讀取和賦值操做被認爲是安全的原子性操做。可是當對象處於不穩定狀態時,仍舊頗有可能使用原子性操做獲得訪問。最明智的作法是遵循同步的規則。

原子類

Java SE5 中引入了諸如 AtomicInteger、AtomicLong、AtomicReference 等等特殊的原子性變量類,他們提供下面形式的原子性條件更新操做:

boolean compareAndSet(expectedValue,updateValue);
複製代碼

這些類被調整爲可使用在現代處理器上,而且是機器級別的原子性,所以在使用他們時不須要擔憂。常規來講不多使用他們,可是對於性能調優來講,他們就大有用武之地了。

示例,重寫上面的實例:

public class AtomicIntegerTest implements Runnable{
	private AtomicInteger ger = new AtomicInteger(0);
	public int getValue() {
		return ger.get();
	}
	private void eventIncrement() {
		ger.addAndGet(2);
	}

	@Override
	public void run() {
		while (true) {
			eventIncrement();
		}

	}

	public static void main(String[] args) {
		ExecutorService exec = Executors.newCachedThreadPool();
		AtomicIntegerTest aIntegerTest = new AtomicIntegerTest();
		exec.execute(aIntegerTest);
		while (true) {
			int val = aIntegerTest.getValue();
			if (val % 2 !=0) {
				System.out.println(val);
				System.exit(0);
			}
		}
	}

}

複製代碼

Atomic 類被設計爲構建 Java.util.concurrent 中的類,所以只有在特殊狀況下才在代碼中使用他們。上面的例子沒有使用任何加鎖機制也能獲得很好的同步。可是一般依賴於鎖對咱們來講更安全一點。

臨界區

有時咱們須要防止多個線程同時訪問方法內部的部分代碼而不是防止訪問整個方法。經過這種方式分離出來的代碼被稱爲臨界區,也是使用 synchronized 關鍵字修飾。語法是:synchronized 被用來指定某個對象,此對象的鎖被用來對括號內的代碼進行同步控制:

synchronized (syncObject){
	//被同步控制的代碼塊
}
複製代碼

這被稱之爲同步代碼塊;在進入此段代碼以前,必須獲得 syncObject 對象的鎖。若是其餘線程已經獲得鎖,那麼就得等到鎖被釋放以後,才能進入臨界區。經過使用同步控制塊,而不是整個方法進行同步控制,可使多個任務訪問對象的時間性獲得顯著提升。

下面的例子比較了兩種同步控制方法:

public class Pair {
	  private int x, y;
	  public Pair(int x, int y) {
	    this.x = x;
	    this.y = y;
	  }
	  public Pair() { this(0, 0); }
	  public int getX() { return x; }
	  public int getY() { return y; }
	  //遞增操做是非線程安全的

	  public void incrementX() {
		  x++;
	  }
	  public void incrementY() {
		  y++;
	  }

	  public String toString() {
	    return "x: " + x + ", y: " + y;
	  }

	  public class PairValuesNotEqualException extends RuntimeException {
	    public PairValuesNotEqualException() {
	      super("Pair values not equal: " + Pair.this);
	    }
	  }
	  // Arbitrary invariant -- both variables must be equal:
	  public void checkState() {
	    if(x != y)
	      throw new PairValuesNotEqualException();
	  }
}

複製代碼

模板類:

public abstract class PairManager {
      //線程安全的
	  AtomicInteger checkCounter = new AtomicInteger(0);
	  protected Pair p = new Pair();
	  //集合也是線程安全的
	  private List<Pair> storage =Collections.synchronizedList(new ArrayList<Pair>());

	  //方法是線程安全的
	  public synchronized Pair getPair() {
	    // Make a copy to keep the original safe:
	    return new Pair(p.getX(), p.getY());
	  }

	  // 每次添加一次間隔 50毫秒
	  protected void store(Pair p) {
	    storage.add(p);
	    try {
	      TimeUnit.MILLISECONDS.sleep(50);
	    } catch(InterruptedException ignore) {

	    }
	  }

	  public abstract void increment();
}

複製代碼

實現模板:

public class PairManager1 extends PairManager{

	//在方法體上修飾代表方法是同步控制的
	@Override
	public synchronized void increment() {
		// 遞增和遞減是非線程安全的
		 p.incrementX();
		 p.incrementY();
		 store(getPair());
	}

}

public class PairManager2 extends PairManager{

	@Override
	public void increment() {
		Pair temp;
		//同步代碼塊,計算完畢以後賦值
	    synchronized(this) {
	      p.incrementX();
	      p.incrementY();
	      temp = getPair();
	    }
	    store(temp);

	}

}

複製代碼

建立兩個線程:

public class PairManipulator implements Runnable {

	  private PairManager pm;
	  public PairManipulator(PairManager pm) {
	    this.pm = pm;
	  }

	  public void run() {
	    while(true)
	      pm.increment();
	  }

	  public String toString() {
	    return "Pair: " + pm.getPair() +
	      " checkCounter = " + pm.checkCounter.get();
	  }

}

public class PairChecker implements Runnable{

	  private PairManager pm;
	  public PairChecker(PairManager pm) {
	    this.pm = pm;
	  }
	  public void run() {
	    while(true) {
	      pm.checkCounter.incrementAndGet();
	      pm.getPair().checkState();
	    }
	  }

}
複製代碼

測試類:

public class CriticalSection {
	 static void testApproaches(PairManager pman1, PairManager pman2) {
	    ExecutorService exec = Executors.newCachedThreadPool();

	    PairManipulator
	      pm1 = new PairManipulator(pman1),
	      pm2 = new PairManipulator(pman2);
	    PairChecker
	      pcheck1 = new PairChecker(pman1),
	      pcheck2 = new PairChecker(pman2);

	    exec.execute(pm1);
	    exec.execute(pm2);
	    exec.execute(pcheck1);
	    exec.execute(pcheck2);
	    try {
	      TimeUnit.MILLISECONDS.sleep(500);
	    } catch(InterruptedException e) {
	      System.out.println("Sleep interrupted");
	    }
	    System.out.println("pm1: " + pm1 + "\npm2: " + pm2);
	    System.exit(0);
	  }
	  public static void main(String[] args) {
	    PairManager
	      pman1 = new PairManager1(),
	      pman2 = new PairManager2();
	    testApproaches(pman1, pman2);
	  }
}

複製代碼

最後的測試結果:

pm1: Pair: x: 11, y: 11 checkCounter = 2183
pm2: Pair: x: 12, y: 12 checkCounter = 24600386
複製代碼

儘管每次運行的結果可能會不一樣,但通常狀況下 PairChecker 的檢查頻率 PairManager1 比 PairManager2 少。後者採用同步代碼塊進行控制,因此對象不加鎖的時間更長。使得其餘線程可以更多的訪問。

在其餘對象上同步

synchronized 塊必須給定一個在其上同步的對象,而且合理的方式是,使用其方法正在被調用的當前對象:synchronized(this),在這種方式中若是得到了 synchronized 塊上的鎖,那麼該對象其餘的 synchronized 方法和臨界區就不能被調用了。

有時必須在另一個對象上同步,可是若是你這樣作,就必須確保全部相關的任務都是在同一個對象上同步的。

下面的例子演示了兩個任務能夠同時進入同一個對象,只要這個對象上的方法是在不一樣的鎖上同步的便可:

class DualSynch {
  private Object syncObject = new Object();
  public synchronized void f() {
    for(int i = 0; i < 5; i++) {
      print("f()");
      Thread.yield();
    }
  }
  public void g() {
    synchronized(syncObject) {
      for(int i = 0; i < 5; i++) {
        print("g()");
        Thread.yield();
      }
    }
  }
}

public class SyncObject {
  public static void main(String[] args) {
    final DualSynch ds = new DualSynch();
    new Thread() {
      public void run() {
        ds.f();
      }
    }.start();
    ds.g();
  }
}
複製代碼

執行結果:

g()
f()
g()
f()...
複製代碼

其中 f() 是在 this 上同步的,而 g() 是在一個 syncObject 上同步的 synchronized 塊。所以,這兩個同步是相互獨立的。經過在 main() 中的方法調用能夠看到,這兩個方法並無阻塞。

線程本地存儲

防止任務在共享資源上產生衝突的第二種方式是根除對變量內存的共享。線程本地存儲是一種自動化機制,可使用相同變量的每一個不一樣的線程建立不一樣的存儲。所以,若是你有5個線程,那麼線程會在本地生成 5 個不一樣的存儲塊。它們使得你能夠將狀態和線程關聯起來。

建立和管理線程本地存儲能夠由 java.lang.ThreadLocal 類來實現:

public class Accessor implements Runnable{

	private final int id;
	protected Accessor(int id) {
		super();
		this.id = id;
	}
	@Override
	public void run() {
		while (!Thread.currentThread().isInterrupted()) {
			ThreadLocalVariableHolder.increment();
			System.out.println(this);
			Thread.yield();
		}

	}
	@Override
	public String toString() {
		// TODO Auto-generated method stub
		return "#"+id+":"+ThreadLocalVariableHolder.get();
	}

}
複製代碼

線程本地存儲:

public class ThreadLocalVariableHolder {
	private static ThreadLocal<Integer> value = new ThreadLocal<Integer>(){
		private Random dRandom = new Random(47);
		protected synchronized Integer initialValue(){
			return dRandom.nextInt(10000);
		}
	};

	public static void increment() {
		value.set(value.get()+1);
	}

	public static int get() {
		return value.get();
	}

	public static void main(String[] args) throws Exception{
		ExecutorService executorService = Executors.newCachedThreadPool();
		for (int i = 0; i < 5; i++) {
			executorService.execute(new Accessor(i));
		}
		TimeUnit.SECONDS.sleep(3);
		executorService.shutdown();
	}
}

複製代碼

測試結果:

#0:712564
#0:712565
#0:712566
#0:712567
#0:712568/...
複製代碼

ThreadLocal 對象一般當作靜態存儲域。建立 ThreadLocal 方法時只能經過 get() 和 set() 方法來訪問內容,其中,get() 方法返回與對象相關聯的副本,而 set() 將會將參數插入到爲其線程存儲的對象中,並返回存儲中原有對象。運行這個程序的時候會發現每一個單獨的線程都分配了本身的存儲,由於他們每一個都要跟蹤本身的計數值。

相關文章
相關標籤/搜索