前言
多線程開發中,同步控制是必不可少的手段。而同步的實現需要用到鎖,Java中提供了兩種基本的鎖,分別是synchronized 和 Lock。兩種鎖都非常常用,但也各有利弊,下面開始學習。
synchronized用法
synchronized 是Java的關鍵字,是應用最為廣泛的同步工具之一。當它用來修飾一個方法或者一個代碼塊的時候,能夠保證在同一時刻最多只有一個線程執行該段代碼,同時,值得說明的是,它是在軟件層面依賴JVM
實現同步的。
synchronized 的用法很簡單,直接用其修飾代碼塊即可,一般可將其用於修飾方法和代碼塊,根據修飾地方的不同還有不同的作用域,下面一一介紹。
修飾方法
synchronized 修飾方法分為兩種情況:
- 修飾實例方法,作用於當前實例加鎖,進入同步代碼前要獲得當前實例的鎖。
- 修飾靜態方法,作用於當前類對象加鎖,進入同步代碼前要獲得當前類對象的鎖。
修飾實例方法
顧名思義就是修飾類中的實例方法,並且默認是當前對象作為鎖的對象,而一個對象只有一把鎖,所以同一時刻只能有一個線程執行被同步的方法,等到線程執行完方法后,其他線程才能繼續執行被同步的方法。實例代碼如下:
public class SyncTest implements Runnable{
//靜態變量
public static int TEST_INT = 0;
//被同步的實例方法
public synchronized void increase(){
TEST_INT++;
}
@Override
public void run() {
for(int i=1;i<=100000;i++){
increase();
}
}
public static void main(String[] args) throws InterruptedException {
//實例化對象
SyncTest instance = new SyncTest();
Thread t1=new Thread(instance);
Thread t2=new Thread(instance);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(TEST_INT);
}
}
運行上方的程序,結果會是200000,因為main函數中只實例化一個SyncTest
對象,所以,兩個線程運行的時候只能有一個線程獲取到對象的鎖,當一個線程獲取了該對象的鎖之后,其他線程無法獲取該對象的鎖,所以無法訪問該對象的其他synchronized實例方法,當然其他線程還是可以訪問該對象的非synchronized方法的。
不過,上面的情況只是針對一個對象實例進行操作,如果有多個對象實例的話,修飾實例方法是無法保證線程安全的,我們可以把main函數的程序修改下:
public static void main(String[] args) throws InterruptedException {
//每個線程實例化一個SyncTest對象
Thread t1=new Thread(new SyncTest());
Thread t2=new Thread(new SyncTest());
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(TEST_INT);
}
運行程序后,會發現結果永遠小於200000,說明synchronized沒有起到同步的作用了,說明修飾實例方法只能作用實例對象,不能作用到類對象。
修飾靜態方法
要想synchronized同步到類對象本身,可以用它修飾類中的靜態方法。修改下上述代碼中的increase
方法為靜態方法,並在main函數中新建兩條線程:
//被同步的靜態方法
public synchronized static void increase(){
TEST_INT++;
}
public static void main(String[] args) throws InterruptedException {
//每個線程實例化一個SyncTest對象
Thread t1=new Thread(new SyncTest());
Thread t2=new Thread(new SyncTest());
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(TEST_INT);
}
運行程序,結果是200000,說明synchronized是作用到類對象本身的,其鎖對象是當前類的class對象,所以,不管實例化多個對象實例時,被同步的方法同一時刻只能被一個線程執行。
同步代碼塊
除了同步實例方法和靜態方法外,還可以使用synchronized 同步代碼塊,某些情況下,我們可能只需要同步一小塊代碼,假設代碼所在的方法體量太大的話,直接同步整個方法會影響程序的運行效率,這種情況下同步代碼塊就非常的合適,實例代碼如下:
public class SyncTest implements Runnable{
public static SyncTest instance = new SyncTest();
//靜態變量
public static int TEST_INT = 0;
@Override
public void run() {
synchronized (instance) {
for (int i = 1; i <= 100000; i++) {
TEST_INT++;
}
}
}
public static void main(String[] args) throws InterruptedException {
//每個線程實例化一個SyncTest對象
Thread t1=new Thread(new SyncTest());
Thread t2=new Thread(new SyncTest());
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(TEST_INT);
}
}
上面的代碼中,在run()方法中對實例對象instance
做了同步處理,運行程序后輸出的結果為200000。之所以能達到同步的效果,是因為每次當線程進入synchronized包裹的代碼塊時就會要求當前線程持有instance這個實例對象鎖,其他的線程就必須等待,這樣也就保證了每次只有一個線程執行被同步的代碼塊。
引出Lock
synchronized的用法還是比較簡單的,同步的效果也比較明顯,盡管如此,synchronized本身還是存在着不少缺陷,比如對鎖的釋放。
當線程執行到synchronized同步的程序后會獲取對應的鎖,其他的線程要一直等待,等到該線程釋放對應的鎖,而該線程釋放鎖的情況無非是這兩種:
- 線程執行完了該代碼塊,然后釋放對鎖的占有;
- 線程執行過程發生異常,此時JVM會讓線程自動釋放鎖。
因為synchronized是由JDK實現的,不需要程序員編寫代碼去控制加鎖和釋放。這種釋放機制有很大的弊端,舉個例子,如果獲取到該鎖的線程有非常耗時的程序,例如等待IO或者被阻塞了,然后沒有及時釋放鎖,那么其他的線程就必須一直等待,白白浪費了不少時間,這樣的結果顯然不是我們想看到的,那么有什么辦法能解決呢?
針對這樣的情況,Lock就派上用場了。Lock是Java並發工具包下提供的一個接口,同樣可以實現同步訪問。
與synchronized不同的是,Lock要求程序員手動控制加鎖和釋放,它不會自動釋放鎖,如果沒有手動釋放鎖,線程會一直占用鎖,可能造成死鎖現象。
Lock用法
Lock是一個接口,點開源碼,可以發現其代碼中定義這幾個方法:
public interface Lock {
void lock();
void lockInterruptibly() throws InterruptedException;
boolean tryLock();
boolean tryLock(long var1, TimeUnit var3) throws InterruptedException;
void unlock();
Condition newCondition();
}
其中,lock()、lockInterruptibly()、tryLock()、unlock()都是對鎖的獲取操作,unLock()是釋放鎖的方法,newCondition()是返回一個Condition
接口,Condition
接口可以代替Object監視器方法的使用,相當於充當了Object.wait() 和Object.notify() 的作用,起到線程等待和通知的作用。
前面說到了Lock必須手動釋放鎖的操作,所以,當調用Lock的獲取鎖方法后,在執行完程序時還需要調用釋放鎖的方法,用法大致如下:
Lock lock = new ReentrantLock();
lock.lock();
try {
//.............執行程序..........
} finally {
lock.unlock();
}
通過捕獲異常的方式來調用Lock釋放鎖的方法,這樣就能保證即使程序發生異常也能成功釋放鎖。
值得說明的是,Lock只是一個接口,在作為同步工具使用時,必須先實例化它的子類,而代碼中的ReentrantLock
就是Lock的子類。
子類:ReentrantLock
ReentrantLock是Lock一個非常強大的子類,意思是 “可重入鎖”,那么可重入鎖是什么意思呢?后面會細說,先展示ReentrantLock的具體用法。
public class LockTest implements Runnable {
public Lock lock = new ReentrantLock();
public static int i = 0;
@Override
public void run() {
for (int j = 0;j<100000;j++){
lock.lock();
try {
i++;
} finally {
lock.unlock();
}
}
}
public static void main(String[] args) throws InterruptedException {
LockTest lt = new LockTest();
Thread t1 = new Thread(lt);
Thread t2 = new Thread(lt);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(i);
}
}
我們在 LockTest 的 run() 里加了ReentrantLock保護臨界區資源 i,確保多線程對臨界區資源操作的安全性,執行main方法,可以看到結果成功輸出 200000。說明ReentrantLock 確實起到了同步 的作用。
接着說回可重入鎖的話題,之所以這么叫,是因為這種鎖是可以重復進入的,例如,改造一下run()方法中的代碼:
@Override
public void run() {
for (int j = 0;j<100000;j++){
lock.lock();
lock.lock();
try {
i++;
} finally {
lock.unlock();
lock.unlock();
}
}
}
運行main方法,代碼正常輸出200000。說明鎖可以被連續使用,因為如果不能被連續使用的話,那么當第二次獲取鎖時,將會因為第一個鎖沒釋放而一直在等待,同時第二個鎖的釋放又必須等第二個鎖獲取並執行 i++ 的程序后才能實現,這樣就相當於線程與自己產生了死鎖。當然,還需要注意一點,那就是線程獲取鎖的次數和釋放次數必須是相同的,否則就會拋出異常。
讀寫分離鎖:ReadWriteLock
除了Lock接口外,Java的API還提供了另一種讀寫分離鎖,那就是ReadWriteLock。ReadWriteLock是JDK1.5后才引入的,作為讀寫分離鎖,可以有效的幫助減少鎖的競爭,提升系統性能。
用鎖分離的機制來提升性能比較好理解。舉個例子,有三個線程A1、A2、A3進行寫操作,三個線程B1、B2、B3進行讀的操作。如果使用重入鎖或者synchronized(內部鎖),理論上所有的讀之間、讀與寫之間、寫與寫之間都是串行操作。當B1進行讀取時,B2、B3則必須進行等待。由於讀操作並不對數據的完整性進行破壞,所以這種等待是不合理的。因此,讀寫分離鎖就派上了用場,它能支持多個讀的操作並行執行。
需要注意的是,讀寫分離鎖只是針對讀讀之間能夠並行,在讀寫和寫寫之間依然會互斥,總結起來就是這三種情況:
- 讀-讀不互斥:讀讀之間不阻塞;
- 讀-寫互斥:讀阻塞寫,寫也會阻塞讀;
- 寫-寫互斥:寫寫阻塞;
概念上就大概是這樣了,下面就是如何使用了。先看一下ReadWriteLock的源碼:
public interface ReadWriteLock {
Lock readLock();
Lock writeLock();
}
可以看出,ReadWriteLock是一個接口,並且只提供了兩個方法,從字面上很容易就可以理解,分別是寫入鎖的方法 readLock 和 讀取鎖的方法 writeLock ,返回的都是Lock接口。
值得說明的是,ReadWriteLock是一個接口,其使用的方式和Lock類似,都是需要先實例化接口的實現類,而其子類只有一個,那就是 ReentrantReadWriteLock,下面用一段代碼來測驗一下讀寫鎖的性能:
public class ReadWriteLockDemo {
private static ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock();
private static Lock writeLock = readWriteLock.readLock();
private static Lock readLock = readWriteLock.readLock();
private int i;
//讀的方法
public int ReadValue(Lock lock) throws Exception {
try {
lock.lock();
Thread.sleep(1000);
return i;
} finally {
lock.unlock();
}
}
//寫的方法
public void setValue(Lock lock, int value) throws Exception {
try {
lock.lock();
Thread.sleep(1000);
i = value;
} finally {
lock.unlock();
}
}
public static void main(String[] args) {
final ReadWriteLockDemo demo = new ReadWriteLockDemo();
Runnable readRunnable = new Runnable() {
@Override
public void run() {
try {
demo.ReadValue(readLock);
} catch (Exception e) {
e.printStackTrace();
}
}
};
Runnable writeRunnable = new Runnable() {
@Override
public void run() {
try {
demo.setValue(writeLock, new Random().nextInt());
} catch (Exception e) {
e.printStackTrace();
}
}
};
for (int i = 0; i < 20; i++) {
new Thread(readRunnable).start();
}
for (int j = 0; j < 2; j++) {
new Thread(writeRunnable).start();
}
}
}
先說明一下這段代碼,在ReadWriteLockDemo類中定義了一個ReentrantReadWriteLock實例,並創建它的讀寫對象,分別是 writeLock 和 readLock,同時,在類中還定義了一個讀的方法和寫的方法,用Thread.sleep模擬了耗時操作,分別對應讀耗時和寫耗時。main函數里定義讀的線程和寫的線程,同時用for循環開啟了20個讀線程和2個寫的線程。
以上的代碼采用的就是簡單的讀寫分離操作,正常運行后,程序兩秒多鍾就結束了 ,這說明,讀的線程之間是並行的,而寫的線程之間會相互阻塞,這也印證了之前的結論。
讀寫分離鎖就講到這吧,關於ReentrantReadWriteLock本身還有很多妙用,這里就不展開了。
Lock和synchronized比較
最后,說一下老生常談的話題吧,就是對Lock和synchronized做個對比總結。
1、Lock是一個接口,而synchronized是Java中的關鍵字,synchronized是內置的語言實現;
2、synchronized由程序自動釋放鎖,而Lock需要程序員手動釋放,避免死鎖;
3、Lock可以讓等待鎖的線程響應中斷,而synchronized卻不行,使用synchronized時,等待的線程會一直等待下去,不能夠響應中斷;
4、Lock可以知道是否成功獲得鎖,但synchronized不行;
5、Lock支持可重入鎖,但synchronized不行;
6、synchronized鎖的范圍是整個方法或代碼塊;而Lock是方法調用的方式,靈活性更大;
7、ReadWriteLock可以提升多個線程進行讀操作的效率,而synchronized做不到;
再說明一點,從JDK1.6開始,synchronized的性能已經做到了很大的優化,如果是競爭資源不激烈也就是線程不多的情況下,synchronized和Lock的性能是差不多的,而如果資源競爭比較激烈,使用Lock的性能要遠遠優於synchronized的。
所以,還是那句話,根據不同的場景選擇適合的技術才是最好的。