Java volatile與鎖機(jī)制對比案例分析
前言:最近有強(qiáng)烈的感覺就是在補(bǔ)充上學(xué)的時候沒學(xué)好的知識,沒打好的基礎(chǔ),果然只要是想做得好,欠過的債總是要還的。我也沒有想到工作幾年,竟然會有這樣悔不當(dāng)初的總結(jié)。
曾經(jīng)的鮮衣怒馬,終究是過眼云煙,潮水退去,發(fā)現(xiàn)裸泳的竟然是我自己。
多線程案例
一個經(jīng)典場景:
當(dāng)前類中有一個volatile Strategy strategy作為成員變量,這個成員變量有不加鎖的getter和setter,類還有一個成員方法execute是對strategy進(jìn)行某種操作,這個execute方法是受鎖保護(hù)的。
假設(shè)這個execute要執(zhí)行10s鐘,在這個執(zhí)行的過程中如果getter和setter被其他線程調(diào)用了,getter/setter線程是否會阻塞?如果getter/setter加鎖,它們的調(diào)用線程會不會阻塞?
問題的答案:
如果getter/setter不加鎖,非阻塞,它們的調(diào)用線程可以立即執(zhí)行。
如果getter/setter加鎖,阻塞,且此時volatile關(guān)鍵詞可以去掉。
機(jī)制對比
鎖 和 volatile 是兩種完全不同的機(jī)制,它們在這個場景下是互不干擾的。
volatile機(jī)制
volatile 保證的是變量的可見性和防止指令重排序。
volatile修飾的變量,可見性是指變量發(fā)生改變時會對其他線程立即可見。也就是說volatile的變量發(fā)生寫操作時,是立即刷新到主存的,讀操作也是立即從主存讀取最新的值,不會從緩存中讀。
防止指令重排序是指如果當(dāng)前操作在機(jī)器指令上會被翻譯成多個指令,那么這些個指令不會被亂序執(zhí)行。
volatile它不涉及互斥性。讀寫 volatile 變量就像讀寫普通變量一樣,不會導(dǎo)致線程掛起,JVM是直接對內(nèi)存進(jìn)行操作的。
鎖機(jī)制
在這個場景下,這里就不展開synchronized 和ReentrantLock的區(qū)別了。
鎖保證的是對內(nèi)存的獨(dú)占訪問,鎖的概念緊密關(guān)聯(lián)的是對象。被鎖修飾的代碼邏輯,可以保證原子性,本質(zhì)上是因為只有唯一的線程會訪問當(dāng)前的代碼邏輯,那么這段邏輯在其他線程看來就是“不可再分的”。那么為什么鎖保護(hù)的是對象,在原子性上卻生效在代碼邏輯上了?鎖通過鎖住對象(獨(dú)占訪問),實現(xiàn)了邏輯上排他性地執(zhí)行代碼。因為別人進(jìn)不來,所以無法打斷你的邏輯,也就無法看到中間狀態(tài)。
有鎖保護(hù)時,線程對對象操作會獲取當(dāng)前對象的Monitor(監(jiān)視器鎖)。如果當(dāng)前對象上鎖,則線程阻塞,這就是鎖的互斥性。
但同時,鎖在釋放時,也會立即刷新主存,這也就說明了鎖也保證了“可見性”,此時對象已經(jīng)刷盤了。根據(jù) Java 內(nèi)存模型,線程釋放鎖時會將本地內(nèi)存的修改強(qiáng)制刷新回主存,線程獲取鎖時會強(qiáng)制從主存重新加載變量。這就是為什么此時volatile關(guān)鍵字需要去掉。
多線程訪問變量案例解釋
基于以上的理解,再考慮最開始的案例。
getter/setter不加鎖
public class StrategyContext {
// 1. volatile 保證可見性,但不提供鎖
private volatile Strategy strategy = new Strategy("Default");
// 2. 無鎖 Setter:任何時候都能調(diào),不會被阻塞
public void setStrategy(Strategy strategy) {
this.strategy = strategy;
}
// 3. 無鎖 Getter:任何時候都能調(diào)
public Strategy getStrategy() {
return strategy;
}
// 4. 加鎖的方法:執(zhí)行耗時 10 秒
public synchronized void execute() {
System.out.println(Thread.currentThread().getName() + " 拿到鎖,開始執(zhí)行 execute (10s)...");
try {
// 模擬長時間執(zhí)行
for (int i = 0; i < 5; i++) {
// 危險:如果這里直接使用成員變量 strategy,可能會在執(zhí)行中途發(fā)現(xiàn)它變了
System.out.println("Execute 中途讀取策略: " + strategy.getName());
Thread.sleep(2000);
}
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + " 執(zhí)行結(jié)束,釋放鎖。");
}
}風(fēng)險點(diǎn):如果線程 A 的 execute 方法邏輯中,在第 1 秒讀取了一次 strategy,在第 9 秒又讀取了一次 strategy。那么線程 A 會發(fā)現(xiàn)在同一個方法執(zhí)行過程中,strategy 變了(前 1 秒是老策略,后 1 秒是新策略)。這可能導(dǎo)致邏輯不一致。
解決方案:內(nèi)存快照。將volatile對象賦給一個局部變量,后續(xù)的邏輯使用局部變量操作,那么后續(xù)的邏輯都基于局部變量。注意這里有暫時的一致性問題,即如果當(dāng)前方法需要執(zhí)行10s,而其他線程在這個過程中修改了strategy,那么get方法會拿到最新的值,但是execute會一直執(zhí)行上一個快照變量下的邏輯。
public synchronized void execute() {
// 【關(guān)鍵】進(jìn)入方法時,將 volatile 變量賦值給局部變量
// 局部變量存在于線程棧中,其他線程無法修改它
Strategy localStrategy = this.strategy;
// 后續(xù)所有操作都使用 localStrategy
// 即使成員變量 this.strategy 被其他線程改了,localStrategy 依然指向舊的對象
localStrategy.algorithm();
}getter/setter加鎖
public class StrategyContext {
private volatile Strategy strategy = new Strategy("Default");
//1. 加鎖,完全互斥,阻塞
public synchronized void setStrategy(Strategy strategy) {
this.strategy = strategy;
}
//2. 加鎖,完全互斥,阻塞
public synchronized Strategy getStrategy() {
return strategy;
}
// 加鎖的方法:執(zhí)行耗時 10 秒
public synchronized void execute() {
System.out.println(Thread.currentThread().getName() + " 拿到鎖,開始執(zhí)行 execute (10s)...");
try {
// 此時其他線程執(zhí)行g(shù)etter, setter方法會掛起等待,直到execute方法退出,鎖釋放,JVM喚醒等待線程
Thread.sleep(10000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + " 執(zhí)行結(jié)束,釋放鎖。");
}
}問題
- synchronized 本身就保證了可見性。此時volatile關(guān)鍵詞是多余的。
- 性能低,讀寫性能不對稱,極端情況讀操作需要掛起等待方法執(zhí)行,但返回引用本身是納秒級的操作。
- 死鎖風(fēng)險,如果 execute 內(nèi)部還需要獲取其他鎖,持有大鎖的時間越長,發(fā)生死鎖的概率越高。
解決
當(dāng)然可以用上面那種getter/setter不加鎖的方式解決,弱一致性問題用局部快照。本質(zhì)上也是一種copy-on-write的思路。
如果要求強(qiáng)一致性問題,可以使用讀寫鎖。
ReentrantReadWriteLock 的核心在于把鎖分成了兩把:讀鎖 (Read Lock):共享鎖。大家都可以讀,只要沒人寫。寫鎖 (Write Lock):獨(dú)占鎖。只要我在寫,誰都別想讀,也別想寫。
對于加多把鎖的情況,很重要的就是要分清哪里加讀鎖,哪里加寫鎖。getter/setter很清楚,而execute呢?這里要結(jié)合Java內(nèi)存模型理解,方法調(diào)用本質(zhì)是讀,上讀鎖。
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class RWLockStrategyContext {
private Strategy strategy = new Strategy("Default");
//注意讀寫有鎖保護(hù)時,不需要volatile的可見性了
private final ReadWriteLock rwLock = new ReentrantReadWriteLock();
// 1. 寫鎖保護(hù) Setter
// 只有當(dāng)沒有任何人在讀(包括 execute 和 get),也沒有人在寫時,才能拿到這把鎖
public void setStrategy(Strategy strategy) {
rwLock.writeLock().lock();
try {
this.strategy = strategy;
} finally {
rwLock.writeLock().unlock();
}
}
// 2. 讀鎖保護(hù) Getter
// 只要沒有人持有寫鎖,可以多線程訪問
public Strategy getStrategy() {
rwLock.readLock().lock();
try {
return strategy;
} finally {
rwLock.readLock().unlock();
}
}
// 3. 讀鎖保護(hù) Execute
public void execute() {
rwLock.readLock().lock(); // 獲取讀鎖
try {
System.out.println(Thread.currentThread().getName() + " 拿到讀鎖,開始 execute (10s)...");
// 只要還在這個 try 塊里,寫鎖(setStrategy)就進(jìn)不來
// 但是其他讀鎖(getStrategy)可以隨便進(jìn)!
for (int i = 0; i < 5; i++) {
System.out.println("Execute 運(yùn)行中,當(dāng)前策略: " + strategy.getName());
try { Thread.sleep(2000); } catch (InterruptedException e) {}
}
} finally {
System.out.println(Thread.currentThread().getName() + " 執(zhí)行結(jié)束,釋放讀鎖。");
rwLock.readLock().unlock();
}
}
}并發(fā)模型理解
鎖的本質(zhì)
“鎖”的本質(zhì)是對象頭的一部分,它與對象緊密連接,跟著對象走的。面對并發(fā)編程的場景,需要牢記的是雖然你總是在代碼邏輯里加鎖,但鎖 鎖住的是共享內(nèi)存,不是線程,不是代碼邏輯。
而編程世界里,“實體”、“功能” 都是靠代碼邏輯實現(xiàn)的,叫鎖,并不是物理世界里的真實屏障,而是一種邏輯機(jī)制。這也解釋了只有上鎖才有互斥性,而鎖和volatile是互不影響的。因為只有代碼邏輯執(zhí)行到syncronized/ReentrantLock鎖時,會觸發(fā)鎖邏輯,去訪問對象頭。查看鎖狀態(tài)。
如果一個線程受鎖保護(hù),另一個線程不受鎖保護(hù),那么后者在執(zhí)行的時候,就不會走到鎖的機(jī)制里,它會直接操作內(nèi)存邏輯。
如果一個線程受鎖保護(hù),另一個線程受相同的鎖保護(hù),那么后者在執(zhí)行的時候,就會走到鎖機(jī)制里,先檢查鎖狀態(tài),再執(zhí)行內(nèi)存邏輯。
Java 內(nèi)存模型
在這里解釋一下為什么execute,方法調(diào)用本質(zhì)是個讀操作。
相對于并發(fā)模型而言:代碼的“執(zhí)行”是邏輯行為,“執(zhí)行”本身是不需要鎖的,只有“執(zhí)行過程中訪問共享數(shù)據(jù)”才需要鎖。
strategy.execute() 在 JVM 的指令層面,它實際上分成了兩步完全獨(dú)立的操作:讀strategy引用+執(zhí)行execute邏輯。
讀/加載:
- 線程找到這個 strategy 這個變量的在堆內(nèi)存的引用,從內(nèi)存中讀?。截愐环莸刂罚┑疆?dāng)前的棧幀中。volatile保證是從主存讀到的最新值。
執(zhí)行 - 線程拿著剛才讀到的地址,去堆內(nèi)存中找到那個對象,在對象中找到execute方法在方法區(qū)中的地址,然后JVM為這個方法創(chuàng)建一個新的棧幀,一行一行把方法區(qū)內(nèi)的指令壓棧執(zhí)行。因此代碼內(nèi)部的調(diào)用在本地棧幀執(zhí)行。棧幀是私有的,安全。
- 這里一旦讀完成了,這一步的執(zhí)行實際上就跟 變量沒有任何關(guān)系了。此時及時strategy引用變了,也不影響執(zhí)行,因為代碼執(zhí)行的是strategy被讀取進(jìn)棧幀那一刻的副本。
后記
如果對volatile和鎖的機(jī)制理解的很透徹,會發(fā)現(xiàn)最開始的的問題答案變得非常清晰。
很多東西都是當(dāng)你懂了會發(fā)現(xiàn)一點(diǎn)都不難,但是如果沒有理解到位就會拿不準(zhǔn)。這些是Java并發(fā)編程的純基礎(chǔ)內(nèi)容,但是總會在不同場景下有新的理解,??闯P?。我其實知道當(dāng)前的理解還是不夠底層的,比如沒有涉及到底層的讀寫屏障和緩存一致性原則等等,這些東西在我校招入職的時候,我的導(dǎo)師就發(fā)給我一篇英文經(jīng)典文獻(xiàn)讓我看,我看過,但全忘了,沒理解透。而現(xiàn)在,我還是沒有達(dá)到他當(dāng)時對我的要求。
到此這篇關(guān)于Java volatile與鎖機(jī)制對比的文章就介紹到這了,更多相關(guān)Java volatile鎖機(jī)制內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
Spring原生Rpc六種的正確打開方式實現(xiàn)示例
這篇文章主要為大家展示了Spring原生Rpc六種的正確打開方式實現(xiàn)示例,有需要的朋友可以借鑒參考下,希望能夠有所幫助祝大家多多進(jìn)步早日升職加薪2022-02-02
IDEA JetBrains Mono字體介紹和安裝教程(詳解)
這篇文章主要介紹了IDEA JetBrains Mono字體介紹和安裝教程,本給大家介紹的非常詳細(xì),對大家的學(xué)習(xí)或工作具有一定的參考借鑒價值,需要的朋友可以參考下2020-03-03
Maven 搭建SpringMVC+Hibernate項目詳解
本文主要介紹Maven 搭建SpringMVC+Hibernate的知識,這里整理了詳細(xì)的資料,并附示例代碼,有興趣的小伙伴可以參考下2016-09-09

