一、java鎖存在的必要性 要認識java鎖,就必須對2個前置概念有一個深刻的理解:多線程和共用資源。 對於程式來說,數據就是資源。 在單個線程操作數據時,或快或慢不存在什麼問題,一個人你愛乾什麼乾什麼。 多個線程操作各自操作不同的數據,各乾各的,也不存在什麼問題。 多個線程對共用數據進行讀取操作, ...
一、java鎖存在的必要性
要認識java鎖,就必須對2個前置概念有一個深刻的理解:多線程和共用資源。
對於程式來說,數據就是資源。
在單個線程操作數據時,或快或慢不存在什麼問題,一個人你愛乾什麼乾什麼。
多個線程操作各自操作不同的數據,各乾各的,也不存在什麼問題。
多個線程對共用數據進行讀取操作,我就四處看看,什麼也不動,也不存在什麼問題。
但如果多個線程對共用數據進行寫操作,問題就來了。
經典庫存問題:
mysql 記錄剩餘:1,redis 緩存記錄剩餘:1。
小明上網下單,後臺程式檢查 redis 記錄存貨剩 1 台,資料庫執行 -1,但小明網太卡了,資料庫剛執行完 -1,redis 沒來得及更新成0,小紅的華為5G直接下單,redis 剩1台,資料庫-1,redis -1,下單成功一氣呵成。結果就是2個人買了同一臺手機。
這種業務場景可以說比比皆是,所以要解決這種數據同步問題就要有對應的辦法,所以發明瞭java鎖這個工具來保證數據的一致性,舉個例子:
在一個不分男女的公共廁所中上一把鎖,有人進去,把門鎖住,上完出來,把鎖打開,以此類推。
二、2個重要的java鎖
synchronized關鍵字
synchronized關鍵字是java開發人員最常用的給共用資源上鎖的方式,也基本可以滿足一般的進程同步要求,使用 synchronized 無需手動執行加鎖和釋放鎖的操作,只需在需要同步的代碼塊、普通方法、靜態方法上加入該關鍵字即可,JVM 層面會幫我們自動的進行加鎖和釋放鎖的操作。
修飾普通方法
/**
* synchronized 修飾普通方法
*/
public synchronized void method() {
// ....
}
當 synchronized 修飾普通方法時,被修飾的方法被稱為同步方法,其作用範圍是整個方法,作用的對象是調用這個方法的對象。
修飾靜態方法
/**
* synchronized 修飾靜態方法
*/
public static synchronized void staticMethod() {
// .......
}
當 synchronized 修飾靜態方法時,其作用範圍是整個程式,這個鎖對於所有調用這個鎖的對象都是互斥的。
修飾普通方法 VS 修飾靜態方法
創建一個類,其中有synchronized修飾的普通方法和synchronized修飾的靜態方法。
public class SynchronizedUsage {
/**
* synchronized 修飾普通方法
*/
public synchronized void method() {
System.out.println("普通方法執行時間:" + LocalDateTime.now());
try {
// 休眠 3s
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
/**
* synchronized 修飾靜態方法
*/
public static synchronized void staticMethod() {
System.out.println("靜態方法執行時間:" + LocalDateTime.now());
try {
// 休眠 3s
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
測試
public class Test01 {
/**
* 創建線程池同時執行任務
*/
static ExecutorService threadPool = Executors.newFixedThreadPool(10);
public static void main(String[] args) {
// 執行兩次靜態方法
threadPool.execute(() -> {
SynchronizedUsage.staticMethod();
});
threadPool.execute(() -> {
SynchronizedUsage.staticMethod();
});
// 執行兩次普通方法
threadPool.execute(() -> {
SynchronizedUsage usage = new SynchronizedUsage();
usage.method();
});
threadPool.execute(() -> {
SynchronizedUsage usage2 = new SynchronizedUsage();
usage2.method();
});
}
}
結果
說明:
普通方法的2次調用歸屬於不同的對象,也就是不同的鎖,所以執行的時候互不影響。
靜態方法的2次調用歸屬於同一個類,也就是相同的鎖,所以分先後執行,間隔3s。
修飾代碼塊
我們在日常開發中,最常用的是給代碼塊加鎖,而不是給方法加鎖,因為給方法加鎖,相當於給整個方法全部加鎖,這樣的話鎖的粒度就太大了,程式的執行性能就會受到影響,所以通常情況下,我們會使用 synchronized 給代碼塊加鎖,它的實現語法如下:
public void classMethod() throws InterruptedException {
// 前置代碼...
// 加鎖代碼
synchronized (SynchronizedUsage.class) {
// ......
}
// 後置代碼...
}
從上述代碼我們可以看出,相比於修飾方法,修飾代碼塊需要自己手動指定加鎖對象,加鎖的對象通常使用 this 或 xxx.class 這樣的形式來表示,比如以下代碼:
// 加鎖某個類
synchronized (SynchronizedUsage.class) {
// ......
}
// 加鎖當前類對象
synchronized (this) {
// ......
}
以上2中加鎖方式類似於上文中普通方法與靜態方法的區別,加鎖當前類對象this只作用於當前對象,對象不同則鎖不同,加鎖某個類則作用於該類,同屬於一個類的對象使用同一把鎖。
創建一個類
public class SynchronizedUsageBlock {
/**
* synchronized(this) 加鎖
*/
public void thisMethod() {
synchronized (this) {
System.out.println("synchronized(this) 加鎖:" + LocalDateTime.now());
try {
// 休眠 3s
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
/**
* synchronized(xxx.class) 加鎖
*/
public void classMethod() {
synchronized (SynchronizedUsageBlock.class) {
System.out.println("synchronized(xxx.class) 加鎖:" + LocalDateTime.now());
try {
// 休眠 3s
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
測試
public class Test02 {
public static void main(String[] args) {
// 創建線程池同時執行任務
ExecutorService threadPool = Executors.newFixedThreadPool(10);
// 執行兩次 synchronized(this)
threadPool.execute(() -> {
SynchronizedUsageBlock usage = new SynchronizedUsageBlock();
usage.thisMethod();
});
threadPool.execute(() -> {
SynchronizedUsageBlock usage2 = new SynchronizedUsageBlock();
usage2.thisMethod();
});
// 執行兩次 synchronized(xxx.class)
threadPool.execute(() -> {
SynchronizedUsageBlock usage3 = new SynchronizedUsageBlock();
usage3.classMethod();
});
threadPool.execute(() -> {
SynchronizedUsageBlock usage4 = new SynchronizedUsageBlock();
usage4.classMethod();
});
}
}
結果
Lock介面
Lock介面及其相關的實現類是在JDK 1.8之後在併發包中新增的,最常用且常見的就是ReentrantLock。與synchronized不同的是,ReentrantLock在使用時需要顯式的獲取和釋放鎖。
雖然它缺少了隱式獲取釋放鎖的便捷性,但是卻擁有了鎖獲取與釋放的可操作性、可中斷的獲取鎖以及超時獲取鎖等多種synchronized關鍵字所不具備的同步特性。
Lock介面提供的synchronized不具備的特性
Lock介面中定義的方法
儘管java實現的鎖機制有很多種,並且有些鎖機制性能也比synchronized高,但還是強烈推薦在多線程應用程式中使用該關鍵字,因為實現方便,後續工作由jvm來完成,可靠性高。只有在確定鎖機制是當前多線程程式的性能瓶頸時,才考慮使用其他機制,如ReentrantLock等。
三、java鎖的核心分類
悲觀鎖
悲觀鎖總是假設最壞的情況,每次取數據時都認為其他線程會對數據進行修改,所以都會加鎖,當其他線程想要訪問數據時,都需要阻塞掛起。所以悲觀鎖總結為悲觀加鎖阻塞線程。
- • 悲觀鎖適合寫操作多的場景,先加鎖可以保證寫操作時數據正確。
MySQL資料庫中的表鎖、行鎖、讀鎖、寫鎖等,Java中,synchronized關鍵字和Lock的實現類都是悲觀鎖。
樂觀鎖
而樂觀鎖認為自己在使用數據時不會有別的線程修改數據,所以不會添加鎖,只是在更新數據的時候去判斷之前有沒有別的線程更新了這個數據。如果這個數據沒有被更新,當前線程將自己修改的數據成功寫入。如果數據已經被其他線程更新,則根據不同的實現方式執行不同的操作(例如報錯或者自動重試)。總結為樂觀無鎖回滾重試。
-
• 樂觀鎖適合讀操作多的場景,不加鎖的特點能夠使其讀操作的性能大幅提升。
-
• 樂觀鎖天生免疫死鎖。
樂觀鎖一般有2種實現方式:
CAS演算法
即compare and swap 或者 compare and set,涉及到三個操作數,數據所在的記憶體值,預期值,新值。當需要更新時,判斷當前記憶體值與之前取到的值是否相等,若相等,則用新值更新,若失敗則重試,一般情況下是一個自旋操作,即不斷的重試。
優點:
效率比較高,無阻塞,無等待,重試。
缺點:
ABA問題: 因為CAS需要在操作值的時候,檢查值有沒有發生變化,如果沒有變化則更新,但是如果一個值原來是A,變成了B,又變成了A,那麼CAS檢查時發現它的值沒有發生變化,但實際上發生了變化:A->B->A的過程。
迴圈時間長,開銷大: 自旋CAS如果長時間不成功,會給CPU帶來很大的執行開銷。
只能保證一個共用變數的原子操作: 當對一個共用變數操作時,我們可以採用CAS的方式來保證原子操作,但是對多個共用變數操作時,迴圈CAS就無法保證操作的原子性。
版本號機制
一般是在數據表中加上一個數據版本號version欄位,表示數據被修改的次數,當數據被修改時,version值會加1。當線程A要更新數據值時,在讀取數據的同時也會讀取version值,在提交更新時,若剛纔讀取到的version值為當前資料庫中的version值相等時才更新,否則重試更新操作,直到更新成功。
update table
set x = x + 1, version = version + 1
where id = #{id} and version = #{version};
MybaisPlus對樂觀鎖的實現
1)在資料庫中添加 version 欄位,作為樂觀鎖的版本號
--在資料庫中的user表中添加一個version欄位,用於實現樂觀鎖
ALTER TABLE `user` ADD COLUMN `version` INT
2)在對應的實體類中添加 version 屬性,並且在這個屬性上面添加 @Version 註解
@Data
public class User {
@TableId(type = IdType.AUTO)//主鍵自動增長
private Long id;
private String name;
private Integer age;
private String email;
@TableField(fill = FieldFill.INSERT)//INSERT的含義就是添加,也就是說在做添加操作時,下麵一行中的createTime會有值
private Date createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)//INSERT_UPDATE的含義就是在做添加和修改時下麵一行中的updateTime都會有值,因為是第一次添加,還沒有做修改(一般都使用這個)
private Date updateTime;
@Version//版本號,用於實現樂觀鎖(這個一定要加)
@TableField(fill = FieldFill.INSERT)//添加這個註解是為了在後面設置初始值,不加也可以
private Integer version;
}
3)寫一個配置類,配置樂觀鎖插件
@Configuration
@MapperScan("cn.hb.mapper")
public class MpConfig {
//樂觀鎖插件
@Bean
public OptimisticLockerInterceptor optimisticLockerInterceptor() {
return new OptimisticLockerInterceptor();
}
}
4)設置版本號 version 的初始值為1
5)向表中添加一條數據,看 version 的值是否為1
6)測試樂觀鎖,看 version 的值是否加1
四、java鎖的其他分類
synchronized性能優化
鎖膨脹/鎖升級
JDK 6之前synchronized是一個獨占式的悲觀鎖、重量級鎖,效率偏低。JDK 6中為了減少獲得鎖和釋放鎖帶來的性能消耗,引入了“偏向鎖”和“輕量級鎖”。
所以目前鎖一共有4種狀態,級別從低到高依次是:無鎖、偏向鎖、輕量級鎖和重量級鎖。鎖狀態只能升級不能降級。(註意無鎖和偏向鎖是同一級別,鎖標誌位都是01,二者之間不存在膨脹關係,可以理解為無鎖狀態是輕量鎖的空閑狀態)
偏向鎖
在程式第一次執行到 synchronized 代碼塊的時候,鎖對象變成 偏向鎖 ,即偏向於第一個獲得它的線程的鎖。在程式第二次執行到改代碼塊時,線程會判斷此時持有鎖的線程是否就是它自己,如果是就繼續往下麵執行。值得註意的是,在第一次執行完同步代碼塊時,並不會釋放這個偏向鎖。從效率角度來看,如果第二次執行同步代碼塊的線程一直是一個,並不需要重新做加鎖操作,沒有額外開銷,效率極高。
輕量級鎖
當鎖是偏向鎖的時候,被另外的線程所訪問,偏向鎖就會升級為輕量級鎖,其他線程會通過自旋的形式嘗試獲取鎖,不會阻塞,從而提高性能。
這裡不同情況需值得註意:當第二個線程想要獲取鎖時,且這個鎖是偏向鎖時,會判斷當前持有鎖的線程是否仍然存活,如果該持有鎖的線程沒有存活,那麼偏向鎖並不會升級為輕量級鎖 。
重量級鎖
若當前只有一個等待線程,則該線程通過自旋進行等待。但是當自旋超過一定的次數,或者一個線程在持有鎖,一個在自旋,又有第三個來訪時,輕量級鎖升級為重量級鎖。
當其他線程再次嘗試獲取鎖的時候,發現現在的鎖是重量級鎖,此時線程都會進入阻塞狀態。
鎖消除
鎖消除即刪除不必要的加鎖操作。JVM在運行時,對一些“在代碼上要求同步,但是**被檢測到不可能存在共用數據競爭情況”的鎖進行消除。**根據代碼逃逸技術,如果判斷到一段代碼中,堆上的數據不會逃逸出當前線程,那麼就可以認為這段代碼是線程安全的,無需加鎖。
鎖粗化
假設一系列的連續操作都會對同一個對象反覆加鎖及解鎖,甚至加鎖操作是出現在迴圈體中的,即使沒有出現線程競爭,頻繁地進行互斥同步操作也會導致不必要的性能損耗。
如果JVM檢測到有一連串零碎的操作都是對同一對象的加鎖,將會擴大加鎖同步的範圍(即鎖粗化)到整個操作序列的外部。
自適應自旋鎖
自旋鎖:
現在絕大多數的個人電腦和伺服器都是多路(核)處理器系統,如果物理機器有一個以上的處理器或者處理器核心,能讓兩個或以上的線程同時並行執行,就可以讓後面請求鎖的那個線程“稍等一會”,但不放棄處理器的執行時間,看看持有鎖的線程是否很快就會釋放鎖。自旋鎖優點在於它避免一些線程的掛起和恢復操作,因為掛起線程和恢複線程都需要從用戶態轉入內核態,這個過程是比較慢的,所以通過自旋的方式可以一定程度上避免線程掛起和恢復所造成的性能開銷。
自適應自旋鎖:
但是,如果長時間自旋還獲取不到鎖,那麼也會造成一定的資源浪費,所以我們通常會給自旋設置一個固定的值來避免一直自旋的性能開銷。然而對於 synchronized 關鍵字來說,它的自旋鎖更加的“智能”,synchronized 中的自旋鎖是自適應自旋鎖。
自適應自旋鎖是指,**線程自旋的次數不再是固定的值,而是一個動態改變的值,這個值會根據前一次自旋獲取鎖的狀態來決定此次自旋的次數。**比如上一次通過自旋成功獲取到了鎖,那麼這次通過自旋也有可能會獲取到鎖,所以這次自旋的次數就會增多一些,而如果上一次通過自旋沒有成功獲取到鎖,那麼這次自旋可能也獲取不到鎖,所以為了避免資源的浪費,就會少迴圈或者不迴圈,以提高程式的執行效率。簡單來說,如果線程自旋成功了,則下次自旋的次數會增多,如果失敗,下次自旋的次數會減少。
防止死鎖
• 不要寫嵌套鎖,容易死鎖
• 儘量少用同步代碼塊(Synchronized)
• 儘量使用ReentrantLock的tryLock方法設置超時時間,超時可以退出,防止死鎖
• 儘量降低鎖粒度,儘量不要幾個功能一把鎖
公平鎖與非公平鎖
當一個線程持有的鎖釋放時,其他線程按照先後順序,先申請的先得到鎖,那麼這個鎖就是公平鎖。反之,如果後申請的線程有可能先獲取到鎖,就是非公平鎖 。
Java 中的 ReentrantLock 可以通過其構造函數來指定是否是公平鎖,預設是非公平鎖。一般來說,使用非公平鎖可以獲得較大的吞吐量,所以推薦優先使用非公平鎖。
synchronized 是一種非公平鎖。
可重入鎖和非可重入鎖
可重入鎖又名遞歸鎖,是指在同一個線程在外層方法獲取鎖的時候,再進入該線程的內層方法會自動獲取鎖(前提鎖對象得是同一個對象或者class),不會因為之前已經獲取過還沒釋放而阻塞。Java中ReentrantLock和synchronized都是可重入鎖,可重入鎖的一個優點是可一定程度避免死鎖。
public class Widget {
public synchronized void doSomething() {
System.out.println("方法1執行...");
doOthers();
}
public synchronized void doOthers() {
System.out.println("方法2執行...");
}
}
在上面的代碼中,類中的兩個方法都是被內置鎖synchronized修飾的,doSomething()方法中調用doOthers()方法。因為內置鎖是可重入的,所以同一個線程在調用doOthers()時可以直接獲得當前對象的鎖,進入doOthers()進行操作。
如果是一個不可重入鎖,那麼當前線程在調用doOthers()之前需要將執行doSomething()時獲取當前對象的鎖釋放掉,實際上該對象鎖已被當前線程所持有,且無法釋放。所以此時會出現死鎖。
獨享鎖與共用鎖
獨占鎖是一種思想:只能有一個線程獲取鎖,以獨占的方式持有鎖。
Java中用到的獨占鎖:synchronized,ReentrantLock
共用鎖是一種思想:可以有多個線程獲取讀鎖,以共用的方式持有鎖。
Java中用到的共用鎖:ReentrantReadWriteLock。
往期推薦:
● 學會@ConfigurationProperties月薪過三千
● 0.o?讓我看看怎麼個事兒之SpringBoot自動配置