Java併發編程之基礎篇(二) – 鎖
上一篇基礎篇介紹了併發編程最基本的東西。這篇說一下多線程如何正確的訪問共享可變資源。
所謂的共享可變資源就是每個線程都可以讀也都可以寫的資源。如何讓多個線程正確的修改以及讀取共享變量是一門學問。
問題引入
如下段代碼實現了一個線程計數器功能,也就是統計一下有多少個線程執行了任務。
首先定義一個任務
public class Task implements Runnable {
public static int count = 0;
public void increase(){
count++;
}
@Override
public void run() {
increase();
}
}
使用線程驅動任務
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 10000; i++){
Thread thread = new Thread(new Task());
thread.start();
}
//等待子線程執行結束
Thread.sleep(5000);
System.out.println(Task.count);
}
上面的示例代碼,如果你需要多執行幾次,就會發現得到的結果是不一樣的,可見併發程序的錯誤是多麼隱蔽。我在本地環境拿到的結果爲1W或者9999,如下所示:
問題分析
理論上,我們啓動了1W個線程,但結果卻有可能是9999。這個問題的原因有兩個,第一個原因是兩個線程可以同時修改和讀取變量count。第二個原因是在java裏面的自增++操作不是原子操作。
當執行count++操作的時候,實際上是三個動作,先讀取count的當前值,然後將count加1,最後將結果寫入count。
假設第一個線程讀取count之後,得到count的值爲0,然後執行自增操作的過程中,第二個線程也來讀取count的值,那麼第二個線程得到的值還是0,然後第二個線程也基於0做自增操作,這樣兩個線程執行完得到的結果都是1,並不是2。
解決方案
其實上面的問題可以概括爲“多線程如何正確的使用共享可變資源"的問題,這也是併發編程最爲核心的問題。對於這個問題,通常有兩種解決方案。
第一種方案就是對共享資源進行加鎖同步處理,鎖可以保證同一時刻只有一個線程在使用共享資源;
第二種方案就是不共享變量,比如每個線程都持有一份共享變量的Copy,或者只有一個線程可以修改共享變量,其他線程只讀。
鎖的介紹
當我們對一個資源或者一段代碼進行加鎖處理的時候,表示同一時刻只有一個線程可以執行該段代碼,當一個線程執行完並釋放鎖資源之後,其他線程纔有機會獲取該資源繼續執行。
這個過程好比多個人在爭搶一個衛生間的坑位,當衛生間被你搶到之後,立刻把衛生間鎖住,這樣其他人就沒辦法影響你使用了,如果你不加鎖,就會很多人不斷的把門拉開,對你產生影響。
鎖分類
從使用方式來看,Java 提供了兩種鎖,第一種鎖稱爲內製鎖也就是大家熟悉的synchronized。第二種鎖稱爲顯示鎖也就是ReentrantLock
內置鎖-synchronized
我們可以通過使用synchronized給increase()方法進行加鎖同步處理,這樣可以保證同一時刻只有一個線程使用共享資源count。
public synchronized void increase(){
count++;
}
在java中每個對象或者類都含有一個單一的內置鎖,也叫做監視器鎖。線程進入同步代碼塊時會自動獲取鎖,離開時會自動釋放鎖。
如果一個對象中有多個方法都是加鎖的,那麼他們共享同一把鎖,假設一個對象包含 public synchronized void f() 方法,以及public synchronized void g(); 方法,如果某個線程調用了f(),那麼其他線程必須等f()結束並釋放鎖之後,才能繼續調用f()或者g()。
內置鎖的重入
一個線程想獲取一個由其他線程持有的鎖時會發生阻塞,但是一個線程可以重新獲得由他自己持有的鎖。比如一個子類改寫父類的synchronized修飾的方法,然後再次調用父類中的方法,如果沒有鎖重入機制,那麼將發生死鎖。
public class Parent{
public synchronized void doSomething(){
//do something..
}
}
public class Children extends Parent{
public synchronized void doSomething(){
// children do something
super.doSomething();
}
}
臨界區
除了上面的鎖住整個方法以外,還可以鎖住部分代碼塊。這被稱爲同步控制塊,也叫臨界區。這樣做的目的可以顯著提高程序性能,因爲縮小了鎖粒度。
//和鎖住方法的效果是一樣的,但是縮小了鎖粒度
synchronized(synObject){
//do something
}
顯示鎖-ReentrantLock
對於上面的任務計數器代碼,除了內置鎖以外,還可以使用顯示鎖ReentrantLock來實現。示例代碼如下
public class ReentranLockTask implements Runnable {
public static ReentrantLock lock = new ReentrantLock();
public static int count;
public void increase(){
//鎖住
lock.lock();
try{
count++;
}finally {
//釋放
lock.unlock();
}
}
@Override
public void run() {
increase();
}
}
對於顯示的鎖,在上面的代碼量明顯比內製鎖要多,因爲顯示鎖除了要自己聲明鎖以外,還要自己手動釋放鎖,如果忘記釋放鎖,那將會是災難的。
但是顯示鎖也有自己的特點,比如更加靈活,你可以在發生異常時,清理線程資源。但是如果是內製鎖,你能做的恐怕就不多了。
除此之外,使用顯示鎖對資源進行獲取時,可以指定時間範圍,比如通過tryLock(long timeout, TimeUnit unit) 方法,如果在指定時間內沒有獲取,線程可以去執行一些其他事情,不用長時間處於阻塞狀態。
顯示鎖-讀寫分離鎖
從名字可以看出這是兩把鎖,一個是讀鎖,一個是寫鎖。讀寫鎖允許多個讀線程同時執行,但是當有寫線程操作的時候還是隻有一個線程可以操作。
讀寫鎖在讀多寫少的情況下,可以顯著提高性能,因爲多個讀操作時並行執行。
一個典型的讀多寫少的應用場景就是緩存。下面的代碼示例分別使用顯示鎖和讀寫分離鎖來實現兩個不同的緩存。可以明顯感受到兩個緩存的性能區別。
抽象類 DemoCache,定義了緩存的基本操作,顯示鎖實現的緩存和讀寫分離鎖實現的緩存都繼承自該類。
public abstract class DemoCache {
abstract String read(String key) throws Exception;
abstract void write(String key, String value) throws Exception;
}
DemoLockCache,使用顯示鎖實現的緩存,性能比較差。
public class DemoLockCache extends DemoCache {
//顯示鎖,也可以使用synchronized
private ReentrantLock lock = new ReentrantLock();
//緩存Map
private Map<String,String> cacheMap = new HashMap<String,String>();
@Override
String read(String key) throws Exception {
lock.lock();
try{
String value = cacheMap.get(key);
Thread.sleep(500);
return value;
}finally {
lock.unlock();
}
}
@Override
void write(String key, String value) throws Exception {
lock.lock();
try{
cacheMap.put(key,value);
Thread.sleep(300);
}finally {
lock.unlock();
}
}
}
DemoReadWriteLockCache, 使用讀寫鎖實現的緩存,性能較好。
public class DemoReadWriteLockCache extends DemoCache {
//讀寫分離鎖
private ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock();
//緩存Map
private Map<String, String> cacheMap = new HashMap<String, String>();
@Override
String read(String key) throws InterruptedException {
//讀鎖
Lock readLock = readWriteLock.readLock();
readLock.lock();
try {
String value = cacheMap.get(key);
Thread.sleep(500);
return value;
} finally {
readLock.unlock();
}
}
@Override
void write(String key, String value) throws InterruptedException {
//寫鎖
Lock writeLock = readWriteLock.writeLock();
writeLock.lock();
try {
cacheMap.put(key, value);
Thread.sleep(300);
} finally {
writeLock.unlock();
}
}
}
創建兩個任務,一個用於讀操作,一個用於寫操作。
用於讀操作的DemoCacheReadTask
public class DemoCacheReadTask implements Runnable {
private DemoCache demoCache;
public DemoCacheReadTask(DemoCache demoCache){
this.demoCache = demoCache;
}
@Override
public void run() {
String key = Thread.currentThread().getName();
try {
demoCache.read(key);
} catch (Exception e) {
e.printStackTrace();
}
}
}
用於寫操作的DemoCacheWriteTask
public class DemoCacheWriteTask implements Runnable {
private DemoCache demoCache;
public DemoCacheWriteTask(DemoCache demoCache){
this.demoCache = demoCache;
}
@Override
public void run() {
String key = Thread.currentThread().getName();
String value = key + "value";
try {
demoCache.write(key,value);
} catch (Exception e) {
e.printStackTrace();
}
}
}
測試類DemoCacheTest
public class DemoCacheTest {
public static void main(String[] args){
//非讀寫分離實現的緩存
//DemoCache demoCache = new DemoLockCache();
//讀寫分離實現的緩存
DemoCache demoCache = new DemoReadWriteLockCache();
//讀線程
for (int i = 0; i < 10; i++){
Thread thread = new Thread(new DemoCacheReadTask(demoCache));
thread.start();
}
//寫線程
for (int i = 0; i < 3; i++){
Thread thread = new Thread(new DemoCacheWriteTask(demoCache));
thread.start();
}
}
}
結束
這篇文章主要介紹了,如何通過加鎖的方式,實現共享可變資源的正確訪問。其中包括內置鎖,顯示鎖,讀寫鎖。在一般情況下建議大家使用內置鎖,如果內置鎖不能滿足要求可以考慮使用顯示鎖,但一定不要忘記手動釋放鎖。在讀多寫少的場景,可以考慮使用讀寫分離鎖提高性能。