Java的鎖
Java根據不同的特性來對鎖進行分類,大概有以下分類方式。
這裏主要討論樂觀鎖和悲觀鎖以及在Java中對應的實現。
對於同一個數據的併發操作,悲觀鎖認爲自己在使用數據時,一定會有其它線程來修改數據,所以在每次操作數據前都會加上一個鎖,以確保沒有其它線程來修改數據。Java中的synchronized鎖和lock鎖都是悲觀鎖。
而樂觀鎖每次都認爲不會有其它線程來修改數據,所以在操作數據時不會上鎖,而是在修改數據時去判斷有沒有其它線程修改了這個數據,如果沒有被修改,則更新成功,如果已經被其它線程修改,則重新嘗試或失敗。Java中最常用的就是通過CAS算法來實現無鎖併發編程。
根據悲觀鎖和樂觀鎖的概念可以發現:
- 悲觀鎖適合寫多讀少的場景,因爲先加鎖能保證寫操作的正確性。
- 樂觀鎖適合讀多寫少的場景,因爲讀操作一般並不需要加鎖(沒有修改數據),所以樂觀鎖的無鎖特性能使讀性能有很大的提升(減少了加鎖等待的時間)。
悲觀鎖
Synchroniezd鎖
synchronized是一種互斥鎖,也就是悲觀鎖,每次只允許一個線程進入synchronized修飾的方法或代碼塊中。synchronized鎖是可重入的,即一個線程可以多次獲取同一個對象或類的鎖。
synchronized通過使用內置鎖,對變量進行同步,來保證線程操作的原子性、有序性、可見性,可以確保多線程下的操作安全。
synchronized鎖有三種使用方式,分別是對對象加鎖(修飾普通方法,鎖的是當前類的對象)、對代碼塊加鎖(鎖的是非當前類對象)、對類加鎖(修飾靜態方法,鎖的是當前類)。更多:synchronized鎖
下面是用synchronized鎖,用N個線程循環打印0~M個數字。
public class SynchronizedTest implements Runnable {
// 定義一個對象用來保持鎖
private static final Object LOCK = new Object();
// 當前線程
private int threadNum;
// 線程總數
private int threadSum;
// 當前數字,從0開始打印
private static int current = 0;
// 要打印的最大值
private int max;
public SynchronizedTest(int threadNum, int threadSum, int max) {
this.threadNum = threadNum;
this.threadSum = threadSum;
this.max = max;
}
@Override
public void run() {
// 實現N個線程循環打印數字
while (true) {
// 對代碼塊加鎖,保證每次只有一個線程進入代碼塊
synchronized (LOCK) {
// 當前值 / 線程總數 = 當前線程
// 這裏一定要用while,而不能用if。因爲當線程被喚醒時,監視條件可能還沒有滿足(線程喚醒後是從wait後面開始執行)。
while (current % threadSum != threadNum) {
// 打印完了,跳出循環
if (current >= max) {
break;
}
// 不滿足打印條件,則讓出鎖,進入等待隊列
try {
LOCK.wait();
} catch (Exception e) {
e.printStackTrace();
}
}
// 這裏還要做一次判斷
if (current >= max) {
break;
}
System.out.println(Thread.currentThread().getName() + " 打印 " + current);
++current;
// 當前線程打印完了,通知所有等待的線程進入阻塞隊列,然後一起去爭搶鎖
LOCK.notifyAll();
}
}
}
public static void main(String[] args) {
// 開啓N個線程
int N = 3;
// 打印M個數字
int M = 15;
for (int i = 0; i < N; ++i) {
new Thread(new SynchronizedTest(i, N, M)).start();
}
}
}
Lock鎖
lock鎖也是一種互斥鎖,同時悲觀鎖。它是一種顯示鎖,加鎖與釋放鎖的操作都需要手動實現,而synchronized的釋放鎖是自動實現的。
ReentrantLock鎖內部定義了公平鎖和非公平鎖。對於公平鎖,內部維護了一個FIFO隊列用來保存進入的線程,保證先進入的線程能先執行。而對於非公平鎖,如果一個線程釋放了鎖,其它所有線程都可以去搶這個鎖,這樣就會導致有些人可能會餓死,可能永遠也得不到執行。但是公平鎖爲了實現時間上的絕對順序,需要頻繁的切換上下文,而非公平鎖會減少一定的上下文切換,降低了開銷。所以ReentrantLock默認採用的是非公平鎖,以提高性能。
reentrantLock實現可見性是通過AQS中用volatile修飾的state來實現的,下面來分析一下原理(以非公平鎖爲例)。
reentrantLock先顯示上鎖,調用lock方法。
final void lock() {
// 先嚐試獲取鎖,也就是將state更新爲1(這裏用了CAS),如果獲取成功,則將當前線程設置爲獨佔模式同步的當前所有者
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
// 如果獲取失敗,則進入acquire()方法
else
acquire(1);
}
下面進入acquire()方法:
public final void acquire(int arg) {
// 調用tryAcquire嘗試獲取鎖
// 如果獲取鎖失敗,則用當前線程創建一個獨佔結點加到等待隊列的尾部,並繼續嘗試獲取鎖
if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
這裏只進入tryAcquire看看:
protected final boolean tryAcquire(int acquires) {
// 內部又調用了一個非公平的嘗試獲取鎖方法
return nonfairTryAcquire(acquires);
}
進入往下看:
final boolean nonfairTryAcquire(int acquires) {
// 獲取當前線程
final Thread current = Thread.currentThread();
// 重點!首先從主存中獲取state(state是個volatile修飾的變量)
int c = getState();
// 如果state爲0,說明沒有獲取過鎖
if (c == 0) {
// 嘗試獲取鎖
if (compareAndSetState(0, acquires)) {
// 將當前線程設置爲獨佔模式當前所有者
setExclusiveOwnerThread(current);
return true;
}
}
// 如果state不爲0,說明之前獲取過鎖
else if (current == getExclusiveOwnerThread()) {
// 將鎖的數量疊加
int nextc = c + acquires;
if (nextc < 0) // 溢出(超過最大鎖的數量)則拋出異常
throw new Error("Maximum lock count exceeded");
// 因爲當前線程已經獲取了鎖,在這一步不會有其它線程來干擾,所以不需要用CAS來設置state
setState(nextc);
return true;
}
return false;
}
上面就是獲取鎖的主要代碼,如果獲取失敗了,將會被加入到等到隊列中繼續嘗試獲取鎖。(這一步不再分析)
下面再來看看釋放鎖的過程:
public void unlock() {
// 通過內部類調用父類AbstractQueuedSynchronizer的release方法
sync.release(1);
}
下面進入release方法:
public final boolean release(int arg) {
// 調用tryRelease方法來嘗試釋放鎖
if (tryRelease(arg)) {
Node h = head;
// 如果頭節點不爲空且等待狀態非0
if (h != null && h.waitStatus != 0)
// 如果頭節點的後繼節點存在,則喚醒它
unparkSuccessor(h);
return true;
}
return false;
}
reentrantLock的內部類sync重寫了tryRelease方法:
protected final boolean tryRelease(int releases) {
// 重點!也是首先獲取state,並減去要釋放的鎖的數量
int c = getState() - releases;
// 如果當前線程不等於當前獨佔模式擁有者線程
if (Thread.currentThread() != getExclusiveOwnerThread())
// 拋出一個非法監視器狀態異常
throw new IllegalMonitorStateException();
boolean free = false;
// 如果持有鎖的數量爲0
if (c == 0) {
// 設置鎖爲可釋放
free = true;
// 把當前獨佔線程清空
setExclusiveOwnerThread(null);
}
// 設置state
setState(c);
return free;
}
以上就是釋放鎖的關鍵代碼
通過以上分析可知,每次在加鎖和釋放鎖的時候,都會進入方法時先獲取state,最後以設置state結束。
由於state變量是通過volatile修飾的,所以state對於所有線程是可見的,又因爲volatile變量在每次強制刷新到主內存的時候,會將非volatile變量也刷新回主存。
在加鎖的代碼中,肯定是先調用lock(由於操作了volatile的state(先讀後寫),會強制刷新主存),最後調用unlock(也要操作state,會再次強制刷新主存),根據happens-before規則,volatile變量的寫對於下一次的讀是可見的。所以這保證了同步代碼中的共享變量是可見的。
下面是一個利用reentrantLock實現的循環交替打印ABC,其中還使用了locks的條件變量condition。
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
public class ReentrantLockTest {
// 定義一個顯示鎖
private static ReentrantLock lock = new ReentrantLock();
// 監控a的條件變量
private static Condition a = lock.newCondition();
// 監控b的條件變量
private static Condition b = lock.newCondition();
// 監控c的條件變量
private static Condition c = lock.newCondition();
// 控制要打印的值
private static int flag = 0;
public static void printA() {
for (int i = 0; i < 5; i++) {
// 顯示加鎖
lock.lock();
try {
try {
while (flag != 0) {
// 不滿足監視條件則等待
a.await();
}
System.out.println(Thread.currentThread().getName() + "我是A");
flag = 1;
// 通知b線程去打印
b.signal();
} catch (Exception e) {
e.printStackTrace();
}
} finally {
// 釋放鎖
lock.unlock();
}
}
}
public static void printB() {
for (int i = 0; i < 5; i++) {
lock.lock();
try {
try {
while (flag != 1) {
b.await();
}
System.out.println(Thread.currentThread().getName() + "我是B");
flag = 2;
c.signal();
} catch (Exception e) {
e.printStackTrace();
}
} finally {
lock.unlock();
}
}
}
public static void printC() {
for (int i = 0; i < 5; i++) {
lock.lock();
try {
try {
while (flag != 2) {
c.await();
}
System.out.println(Thread.currentThread().getName() + "我是C");
flag = 0;
a.signal();
} catch (Exception e) {
e.printStackTrace();
}
} finally {
lock.unlock();
}
}
}
public static void main(String[] args) {
new Thread(() -> {
printA();
}).start();
new Thread(() -> {
printB();
}).start();
new Thread(() -> {
printC();
}).start();
}
}
synchronized鎖和reentrantLock鎖
相同點
- 都能對資源加鎖,保證線程間的同步訪問。
- 都是可重入鎖,即一個線程能多資源反覆加鎖。
- 都保證了多線程操作的原子性、有序性、可見性(這個只能保證共享變量在加鎖操作內的可見性,而在加鎖操作外的可見性不能得到絕對的保證,因爲鎖外不能保證一直從主存中獲取數據,工作內存可能會不同步)
不同點
- 同步實現機制不同
- synchronized通過java對象關聯的monitor監視器實現
- reentrantLock通過AQS、CAS等實現
- 可見性實現機制不同
- synchronized通過java內存模型來保證可見性
- reentrantLock通過AQS的state(volatile修飾的)來保證可見性
- 監控條件不同
- synchronized通過java對象作爲監控條件
- reentrantLock通過Condition(提供 await、signal 等方法)作爲監控條件
- 使用方式不同
- synchronized可用來修飾實例方法(鎖住實例對象)、靜態方法(鎖住類對象)、同步代碼塊(鎖住指定的對象)
- reentrantLock需要顯示的調用lock加鎖,並需要在finally中釋放鎖
- 功能豐富程序不同
- synchronized只是簡單的加鎖
- ReentrantLock 提供定時獲取鎖、可中斷獲取鎖、Condition(提供 await、signal 等方法)等特性。
- 鎖類型不同
- synchronized只支持費公平鎖。
- reentrantLock支持公平鎖和非公平鎖,但是非公平鎖效率更高
在 synchronized 優化以前,它比較重量級,其性能比 ReentrantLock 要差很多,但是自從 synchronized 引入了偏向鎖、輕量級鎖(自旋鎖)、鎖消除、鎖粗化等技術後,兩者的性能就相差不多了。
樂觀鎖
CAS算法
即compare and swap(比較與交換),是一種有名的無鎖算法。無鎖編程,即不使用鎖的情況下實現多線程之間的變量同步,也就是在沒有線程被阻塞的情況下實現變量的同步,所以也叫非阻塞同步(Non-blocking Synchronization)。
CAS算法涉及到三個操作數:
- 需要讀寫的內存值V(在內存中的值)
- 進行比較的值A(輸入的值)
- 要寫入的新值B(要更新的值)
當且僅當值V等於值A時,CAS通過原子方式將值V更新爲B(比較並替換是一個原子操作,unsafe底層通過操作系統來保證原子性),如果V不等於A,則失敗或重試。這裏沒有涉及到鎖操作,所以是很高效的。
但是它存在三個問題:
- 循環開銷大。CAS如果長時間操作不成功(寫的併發量比較大),會導致長時間自旋,從而造成CPU資源的浪費。
- 只能保證一個變量的原子操作。但是開始JDK1.5提供了AtomicReference類來保證引用對象之間的原子性,可以把多個變量放在一個對象裏來進行CAS操作。
- ABA問題。如果CAS先把值改爲B,又改回A。在CAS看來這個值是沒有變化的,但實際上是變化了的。最典型的就是ATM取錢問題:餘額100,我取出50,此時ATM開了兩個線程,但是一個線程暫時掛了,一個線程成功把餘額更新爲50,然後我朋友又給我轉了50,此時餘額爲100,但是剛剛那個取錢的線程又活了,繼續剛剛的操作,嘗試將100更新爲50,emmm,在CAS看來它是可以成功的,但這是不符合邏輯的(我朋友轉給我的50塊去哪啦???)。ABA問題的一般解決思路就是在變量前加個版本號,這樣更新操作就變成了1A->2B->3A,這樣CAS就會認爲他們不一樣了。JDK1.5開始提供AtomicStampedReference中引入了標誌,這個類的compareAndSet()方法中需要當前標誌和預期標誌相同才能更新成功(每次更新時都會更新這個標誌)。
這裏結合AtomicStampedReference和CountDownLatch實現一個ABA的例子(通過版本號可以解決問題)
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicStampedReference;
public class CasTest {
// 定義一個原子類型變量
private static AtomicStampedReference<Integer> asr = new AtomicStampedReference<>(1, 0);
// 定義一個線程計數器
private static CountDownLatch countDownLatch = new CountDownLatch(1);
public static void main(String[] args) {
new Thread(() -> {
// 打印當前線程的值
System.out.println("線程 " + Thread.currentThread().getName()
+ " value" + asr.getReference());
// 最開始的版本
int stamp = asr.getStamp();
try {
// 等待其它線程全部執行完畢(這裏只需等待線程2運行結束)
countDownLatch.await();
} catch (Exception e) {
e.printStackTrace();
}
// 將1改爲2,又改爲1後,再次嘗試將最開始的版本的1修改爲2
// 操作結果應該是失敗的,因爲當前版本(0)與預期版本(2)不同
// 如果將取版本號的操作放在當前,操作結果肯定是成功的(因爲這裏修改的1不是最開始版本的1)
System.out.println(Thread.currentThread().getName()
+ " CAS操作結果 "
+ asr.compareAndSet(1, 2, stamp, stamp + 1));
}, "線程1").start();
new Thread(() -> {
// 把值修改爲2
asr.compareAndSet(1, 2, asr.getStamp(), asr.getStamp() + 1);
System.out.println("線程 " + Thread.currentThread().getName()
+ " value" + asr.getReference());
// 把值修改爲1
asr.compareAndSet(2, 1, asr.getStamp(), asr.getStamp() + 1);
System.out.println("線程 " + Thread.currentThread().getName()
+ " value" + asr.getReference());
// 當前任務執行完畢,等待線程數減1
countDownLatch.countDown();
}, "線程2").start();
}
}