Java併發原語——線程、互斥與同步

本文將介紹:

  • Java線程基本操作(創建、等待等)
  • Java線程同步原語(同步、互斥)
如果你對以上話題已瞭如指掌,請止步。

Java線程基本操作

Java的線程API以java.lang.Thread類提供,線程的基本操作被封裝爲爲Thread類的方法,其中常用的方法是:

  方法 說明
void start() 啓動線程
void join() 等待線程結束

創建(啓動)線程

Java中,創建線程的過程分爲兩步:

  1. 創建可執行(Runnable)的線程對象;
  2. 調用它的start()方法;
可執行的線程對象,即可以調用start()啓動的線程對象;而創建可執行的線程對象有兩種方法:
  1. 繼承(extends)Thread類,重寫(override)run()方法;
  2. 實現(implements)Runnable接口(實現run()方法);

兩種創建線程的對象的代碼實例如下:

繼承Thread類

繼承Thread類創建線程,如下:

class ExtendsThread extends Thread {
	@Override
	public void run() {
		for (int i = 0; i < 100; ++i) {
			System.out.print("*");
			try {
				Thread.sleep(100);
			} catch (InterruptedException e) {
				e.printStackTrace();
			}
		}
	}
}

public class TestExtendsThread {
	public static void main(String[] args) {
		// 1.創建線程對象
		Thread backThread = new ExtendsThread(); 
		
		// 2.啓動線程
		backThread.start(); 
		
		for(int i=0; i < 100; ++i) {
			System.out.print("#");
			try {
				Thread.sleep(100);
			} catch (InterruptedException e) {
				e.printStackTrace();
			}
		}
	}
}		
	
該程序打印出的*和#是交替的;這說明backThread的run()和主線程同時在執行!當然,如果一個線程的代碼不是多次重複使用,可以將該線程寫成“匿名內部類”的形式:
public class TestExtendsThread {
	public static void main(String[] args) {
		new Thread() {
			public void run() {
				for (int i = 0; i < 100; ++i) {
					System.out.print("*");
					try {
						Thread.sleep(100);
					} catch (InterruptedException e) {
						e.printStackTrace();
					}
				}
			}
		}.start();

		for (int i = 0; i < 100; ++i) {
			System.out.print("#");
			try {
				Thread.sleep(100);
			} catch (InterruptedException e) {
				e.printStackTrace();
			}
		}
	}
}
	

實現Runnable接口

Java中創建線程對象的另一種方法是:實現Runnable接口,再用具體類的實例作爲Thread的參數構造線程,代碼如下:

class RunnableImpl implements Runnable {
	@Override
	public void run() {
		for(int i=0; i < 100; ++i) {
			System.out.print("*");
			try {
				Thread.sleep(100);
			} catch (InterruptedException e) {
				e.printStackTrace();
			}
		}
	}
}

public class TestImlementsRunnable {
	public static void main(String[] args) {
		Runnable callback = new RunnableImpl();
		Thread backThread = new Thread(callback); 
		backThread.start(); // 啓動線程
		
		for(int i=0; i < 100; ++i) {
			System.out.print("#");
			try {
				Thread.sleep(100);
			} catch (InterruptedException e) {
				e.printStackTrace();
			}
		}		
	}
}
	
類似地,RunnableImpl若是不被複用,也可寫成“匿名內部類”的形式:
public class TestImlementsRunnable {

	public static void main(String[] args) {
		new Thread(new Runnable() {
					@Override
					public void run() {
						for(int i=0; i < 100; ++i) {
							System.out.print("*");
							try {
								Thread.sleep(100);
							} catch (InterruptedException e) {
								e.printStackTrace();
							}
						}		
					}
				}).start(); 
		
		for(int i=0; i < 100; ++i) {
			System.out.print("#");
			try {
				Thread.sleep(100);
			} catch (InterruptedException e) {
				e.printStackTrace();
			}
		}		
	}
	
}		
	

這兩種方法都實現了run()方法,而Thread的start()方法會調用傳入的Runnable對象的run()方法(或是調用自己的run方法)。 run()在這裏的作用就是爲新線程提供一個入口,或者說run描述了新線程將來要“幹什麼”;相當於一些C庫的回調函數。

等待線程結束

Thread的join()方法提供了“等待線程結束”的功能,Java的主線程默認會等待其他線程的結束。Thread.join()提供的是:一個線程等待另一個線程的功能;例如,在main方法(主線程)中調用 backThread.join();則主線程將會在調用處等待,直到backThread執行完畢。如下代碼是典型的start和join的使用順序:

// in main()
Runnable r = new Runnable() {
    public void run() {
        // ...
    }
};
Thread back = new Thread(r);

back.start();
back.join();		
	
這段代碼對應的序列圖如下:

start()的作用是啓動一個線程(程序執行流),使得調用處的執行流程一分爲二;而join()的作用則與start相反,使得兩個執行流程“合二爲一”,如下圖所示:

兩個線程和幾個方法執行時間的先後關係,執行流程先“一分爲二”和“合二爲一”。

互斥

Java的互斥語義由synchronized關鍵字提供,具體有兩種:

  1. synchronized代碼塊
  2. synchronized方法

下面分別介紹。


爲什麼需要互斥?

由於本文的定位爲多線程編程入門,所以順便介紹一下爲什麼會有互斥問題。

猜測下面的程序的輸出:

public class NonAtomic {

	static int count = 0;
	
	public static void main(String[] args) {
		Thread back = new Thread() {
			@Override
			public void run() {
				for(int i=0; i<10000; ++i) {
					++count;
				}
			}
		};
		
		back.start();
		
		for(int i=0; i<10000; ++i) {
			++count;
		}
		
		try {
			back.join(); // wait for back thread finish.
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
		
		System.out.println(count);
	}

}
	
這個程序並不能像想象中的那樣輸出20000,而總是小了一些。爲什麼會這樣?因爲++count;操作並不是“原子性”的,即不是一條指令就能完成的功能。在多數體系結構上,實現內存中的整數“自增”操作至少需要三步:
  1. 從內存中讀數據到寄存器
  2. 在寄存器內加一
  3. 寫回內存
一種可能的兩個線程同時執行“自增”的情形如下:

在這幅圖中,A、B兩個線程同時對value執行“自增”,預期的value值應該是11,而實際的value值卻是10。

由此可見,要保證多線程環境下“自增”操作的正確性,就必須保證以上三個操作“一次性執行”而不被其他線程干擾,這就是所謂的“原子性”。


synchronized代碼塊

synchronized代碼塊的形式如下:

	synchronized(obj) 
	{
		// do something.
	}
	
這段代碼保證了花括號內代碼的“原子性”,就是說兩個線程同時執行這一代碼塊的時候會表現出“要麼都不執行,要麼全部執行”的特性,即“互斥執行”。兩個使用同一obj的synchronized代碼塊也同樣具有“互斥執行”的特性。

只需將上面的NonAtomic稍作修改:
// static int count = 0; 後加一行:
static Object lock = new Object(); 

// ++count改爲:
synchronized(lock) {
	++count;
}
	

就能保證程序的輸出爲20000。


synchronized方法

synchronized代碼塊通常是方法內的一部分,如果整個方法體都需要用synchronized(this)鎖定,那麼也可以用synchronized關鍵字修飾這個方法。
就是說,這個方法:

	public synchronized void someMethod() {
		// do something...
	}
	
等價於:
	public void someMethod() {
		synchronized(this) {
			// do something...
		}
	}
	

同步

通俗地說,“同步”就是保證兩個線程事件的時序(先後)關係,這在多線程環境下非常有用。例如,兩個線程A, B正在執行一系列工作Ai, Bi,現在想要使得A3發生在B2之後,就需要使用“同步原語”:

支持“同步”操作的調用叫做“同步原語”,在多數《操作系統》教材中,這種原語通常被定義爲條件變量(condition variable)。

Java的同步原語爲java.lang.Object類的幾個方法:

  1. wait() 等待通知,該調用會阻塞當前線程。
  2. notify() 發出通知,如果有多個線程阻塞在該obj上,該調用會喚醒一個(阻塞)等待該obj的線程。
  3. notifyAll()發出通知,如果有多個線程阻塞在該obj上,該調用會喚醒所有(阻塞)等待該obj的線程。
notify()通常用於通知“有資源可用”;例如,生產者——消費者模型中,緩衝區爲空時,消費者線程等待新產品的到來,此時生產者線程生產一個產品後可用notify()通知消費者線程。
notifyAll()通常用於通知“狀態改變”,例如,一個多線程測試程序中,多個後臺線程被創建後,全都等待主線程發出“開始測試”的命令,此時主線程可用notifyAll()通知各個測試線程。

例如如下代碼,模擬運動員起跑過程:首先,發令員等待個運動員就緒;然後發令員一聲槍響,所有運動員起跑;

public class TestStartRunning {

	static final int NUM_ATHLETES = 10; 
	
	static int readyCount = 0;
	static Object ready = new Object();
	static Object start = new Object();

	public static void main(String[] args) {
		Thread[] athletes = new Thread[NUM_ATHLETES];

		// 創建運動員
		for (int i = 0; i < athletes.length; ++i) {
			final int num = i;
			athletes[i] = new Thread() {
				@Override
				public void run() {
					System.out.println(Thread.currentThread().getName() + " ready!");
					
					synchronized (ready) {
						++readyCount;
						ready.notify(); // 通知發令員,“I'm ready!”
					}
					
					// 等待發令槍響
					try {
						synchronized (start) {
							start.wait();
						}
					} catch (InterruptedException e) {
						e.printStackTrace();
					}
					System.out.println(Thread.currentThread().getName() + " go!");
				}
			};
		}

		// 運動員上場
		for (int i = 0; i < athletes.length; ++i)
			athletes[i].start();

		// 主線程充當裁判員角色
		try {
			synchronized (ready) {
				// 等待所有運動員就位
				while (readyCount < athletes.length) {
					ready.wait();
				}
			}
		} catch (Exception e) {
			e.printStackTrace();
		}

		System.out.println(Thread.currentThread().getName() + " START!");
		synchronized (start) {
			start.notifyAll(); // 打響發令槍
		}
	}
}


信號丟失

wait/notify/notifyAll提供了一種線程間事件通知的方式,但這種通知並不能被有效的“記住”;所以,就存在通知丟失(notify missing)的可能——發出通知的線程先notify,接收通知的線程後wait,此時這個事先發出的通知就會丟失。在POSIX規範上,叫做信號丟失;由於現在的多數操作系統(LINUX,Mac,Unix)都遵循POSIX;所以“信號丟失”這個詞使用的更廣泛。

如下是一個演示通知丟失的代碼:

public class TestNotifyMissing {
	static Object cond = new Object();
	public static void main(String[] args) {
		new Thread() {
			public void run() {
				try {
					Thread.sleep(1000); 
					
					System.out.println("[back] wait for notify...");
					synchronized (cond) {
						cond.wait();
					}
				} catch (InterruptedException e) {
					e.printStackTrace();
				}
				System.out.println("[back] wakeup");
			}
		}.start();
		
		System.out.println("[main] notify");
		synchronized (cond) {
			cond.notify();
		}
	}
}
這個程序不能正常退出,後臺線程因爲錯過了主線程發出的通知而一直在後臺等待,程序也不會輸出“[back] wake up”。

通俗地說,wait/notify只是一種口頭交流,如果你沒有聽到,就會錯過(而不像郵件、公告板,你收到通知的時間可以比別人發出的時間晚)。

如何避免通知丟失呢?由於notify本身不具備“記憶”,所以可以使用額外的變量作爲“公告板”;在notify之前修改這個“公告板”;這樣,即便其他線程調用wait的時間晚於notify的時間,也能看到寫在“公共板”上的通知。

這同時也解釋了另外一個語言設計上的問題:爲什麼Java的wait和notify端都必須要用synchronized鎖定?首先,這不是語法級別的規定,不這麼寫也能編譯通過,只是運行時會拋異常;這是JVM的一種運行時安全檢查機制,這種機制是在提醒我們——應該使用額外的變量來防止產生通知丟失。例如剛纔的NotifyMissing只需稍作修改就能夠正常結束

public class TestNotifyMissingSolution {
	static boolean notified = false; // +++++
	static Object cond = new Object();
	
	public static void main(String[] args) {
		new Thread() {
			public void run() {
				try {
					Thread.sleep(1000);
					
					System.out.println("[back] wait for notify...");
					synchronized (cond) {
						while(!notified) // +++++
							cond.wait();
					}
				} catch (InterruptedException e) {
					e.printStackTrace();
				}
				System.out.println("[back] wakeup");
			}
		}.start();
		
		System.out.println("[main] notify");
		synchronized (cond) {
			notified = true; // +++++
			cond.notify();
		}
		System.out.println("[main] notified");
	}
}


虛假喚醒

在例子TestNotifyMissingSolution中,cond.wait()前添加if(!notified),也能夠正常運行;但這種做法與文檔中給出的while(...)不同,文檔中同時指出了虛假喚醒(Spurious Wakeup)的概念。虛假喚醒在《Programming with POSIX Threads》中的解釋是::當一個線程wait在某個條件變量上,這個條件變量上沒發生broadcast(相當於notifyAll)或signal(相當於notify)調用,wait也又可能返回。虛假喚醒聽起來很奇怪,但是在多核系統上,使條件喚醒完全可預測可能導致多數條件變量操作變慢。"

爲了防止虛假喚醒,需要在wait返回後繼續檢查某個條件是否達成,所有通常wait端的條件寫爲while而不是if,在Java中通常是:

// 等待線程:
synchronized(cond) {
	while(!done) {
		cond.wait();
	}
}

// 喚醒線程:
doSth();
synchronized(cond) {
	done = true;
	cond.notify();
}


總結

在<操作系統>的概念中,提供“互斥語義”的叫互斥器(Mutex),提供同步語義的叫條件變量(Condition Variable)。而在Java中,synchronized關鍵字和java.lang.Object提供了互斥量(mutex)語義,java.lang.Object的wait/notify/notifyAll則提供了條件變量語義。

另外,多線程環境下對象的回收是十分困難的,Java運行環境的垃圾回收(Garbage Collection,GC)功能減輕了程序員的負擔。


參考

Java 1.6 apidocs Thread,http://tool.oschina.net/uploads/apidocs/jdk-zh/java/lang/Thread.html

《Java Concurrency in Practice》(中譯本名爲《Java併發實踐》)

Spurious Wakeup -- Wikipedia,http://en.wikipedia.org/wiki/Spurious_wakeup

多線程編程中條件變量和虛假喚醒(spurious wakeup)的討論,http://siwind.iteye.com/blog/1469216

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章