在多線程併發編程中synchronized一直是元老級角色,很多人都會稱呼它爲重量級鎖。但是,隨着Java SE 1.6對synchronized進行了各種優化之後,有些情況下它就並不那麼重了。本文詳細介紹java1.6中爲了減少 synchronized 獲取鎖和釋放鎖鎖帶來的嚴重的性能消耗而引入的偏向鎖和輕量級鎖,以及鎖膨脹的過程!
一、synchronized實現鎖的表現形式
- 修飾實例方法,對於普通同步方法,鎖是當前的實例對象
- 修飾靜態方法,對於靜態同步方法,鎖是當前的Class對象
- 修飾方法代碼塊,對於同步方法塊,鎖是synchronized括號裏面配置的對象!
當一個線程試圖訪問同步代碼塊的時候,就必須得到所,完成後(或者出現異常),就必須釋放鎖。那麼鎖究竟存在什麼地方呢?我們一塊來探究!
不過,相信,既然大家能夠找到這篇文章,相信大家對他的使用早已了熟於心,我們對於使用,以及爲什麼多線程情況下,數據會出現錯亂情況,不做詳細的解釋!只把他的幾種使用方式列出,供參考!
①修飾實例方法
修飾實例方法,對於普通同步方法,鎖是當前的實例對象
這個沒得說,使用的同一個實例,添加上synchronized後,線程需要排隊,完成一個原子操作,但是注意前提是使用的同一個實例
,他纔會生效!
正例:
/**
* @author huangfu
*/
public class ExploringSynchronized implements Runnable {
/**
* 共享資源(臨界資源)
*/
static int i=0;
public synchronized void add(){
i++;
}
@Override
public void run() {
for (int j = 0; j < 100000; j++) {
add();
}
}
public static void main(String[] args) throws InterruptedException {
ExploringSynchronized exploringSynchronized = new ExploringSynchronized();
Thread t1 = new Thread(exploringSynchronized);
Thread t2 = new Thread(exploringSynchronized);
t1.start();
t2.start();
//join 主線程需要等待子線程完成後在結束
t1.join();
t2.join();
System.out.println(i);
}
}
反例:
/**
* @author huangfu
*/
public class ExploringSynchronized implements Runnable {
/**
* 共享資源(臨界資源)
*/
static int i=0;
public synchronized void add(){
i++;
}
@Override
public void run() {
for (int j = 0; j < 100000; j++) {
add();
}
}
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(new ExploringSynchronized());
Thread t2 = new Thread(new ExploringSynchronized());
t1.start();
t2.start();
//join 主線程需要等待子線程完成後在結束
t1.join();
t2.join();
System.out.println(i);
}
}
這種,即使你再方法上加上了synchronized也無濟於事,因爲,對於普通同步方法,鎖是當前的實例對象!實例對象都不一樣了,那麼他們之間的鎖自然也就不是同一個!
②修飾靜態方法
修飾靜態方法,對於靜態同步方法,鎖是當前的Class對象
從定義上可以看出來,他的鎖是類對象,那麼也就是說,一上面那個類爲例:普通方法的鎖對象是 new ExploringSynchronized()
而靜態方法對應的鎖對象是ExploringSynchronized.class
所以對於靜態方法添加同步鎖,即使你重新創建一個實例,它拿到的鎖還是同一個!
package com.byit.test;
/**
* @author huangfu
*/
public class ExploringSynchronized implements Runnable {
/**
* 共享資源(臨界資源)
*/
static int i=0;
public synchronized static void add(){
i++;
}
@Override
public void run() {
for (int j = 0; j < 100000; j++) {
add();
}
}
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(new ExploringSynchronized());
Thread t2 = new Thread(new ExploringSynchronized());
t1.start();
t2.start();
//join 主線程需要等待子線程完成後在結束
t1.join();
t2.join();
System.out.println(i);
}
}
當然,結果是我們期待的 200000
③修飾方法代碼塊
修飾方法代碼塊,對於同步方法塊,鎖是synchronized括號裏面配置的對象!
package com.byit.test;
/**
* @author huangfu
*/
public class ExploringSynchronized implements Runnable {
/**
* 鎖標記
*/
private static final String LOCK_MARK = "LOCK_MARK";
/**
* 共享資源(臨界資源)
*/
static int i=0;
public void add(){
synchronized (LOCK_MARK){
i++;
}
}
@Override
public void run() {
for (int j = 0; j < 100000; j++) {
add();
}
}
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(new ExploringSynchronized());
Thread t2 = new Thread(new ExploringSynchronized());
t1.start();
t2.start();
//join 主線程需要等待子線程完成後在結束
t1.join();
t2.join();
System.out.println(i);
}
}
對於同步代碼塊,括號裏面是什麼,鎖對象就是什麼,裏面可以使this 字符串 對象等等!
二、synchronized的底層實現
java中synchronized
的實現是基於進入和退出的 Monitor
對象實現的,無論是顯式同步(修飾代碼塊,有明確的monitorenter
和 monitorexit
指令)還是隱式同步(修飾方法體)!
需要注意的是,只有修飾代碼塊的時候,纔是基於monitorenter
和 monitorexit
指令來實現的;修飾方法的時候,是通過另一種方式實現的!我會放到後面去說!
在瞭解整個實現底層之前,我還是希望你能夠大致瞭解一下對象在內存中的結構詳情!
- 實例變量:存放類的屬性數據信息,包括父類的屬性信息,如果是數組的實例部分還包括數組的長度,這部分內存按4字節對齊。
- 填充數據:由於虛擬機要求對象起始地址必須是8字節的整數倍。填充數據不是必須存在的,僅僅是爲了字節對齊,這點了解即可。
這兩個概念,我們簡單理解就好!我們今天並不去探究對象的構成原理!我們着重探究一下對象頭,他對我們理解鎖尤爲重要!
一般而言,synchronized
使用的鎖存在於對象頭裏面!如果是數組對象,則虛擬機使用3個字寬存儲對象,如果是非數組對象,則使用兩個字寬存儲對象頭!字虛擬機裏面1字寬等於4字節!主要結構是 Mark Word
和 Class Metadata Address
組成,結構如下:
虛擬機位數 | 頭對象結構 | 說明 |
---|---|---|
32/64bit | Mark Word | 存儲對象的hashCode、鎖信息或分代年齡或GC標誌等信息 |
32/64bit | Class Metadata Address | 存儲到隊形類型數據的指針 |
32/64bit(數組) | Aarray length | 數組的長度 |
通過上述表格能夠看出 鎖信息
存在於 Mark Word
內,那麼 Mark Word 內又是如何組成的呢?
鎖狀態 | 25bit | 4bit | 1bit是否是偏向鎖 | 2bit鎖標誌位 |
---|---|---|---|---|
無鎖狀態 | 對象的hashcode | 對象的分代年齡 | 0 | 01 |
在運行起見,mark Word 裏存儲的數據會隨着鎖的標誌位的變化而變化。mark Word可能變化爲存儲一下四種數據
Java SE 1.6爲了減少獲得鎖和釋放鎖帶來的消耗,引入了偏向鎖
和輕量級鎖
,從之前上來就是重量級鎖到1.6之後,鎖膨脹升級的優化,極大地提高了synchronized
的效率;
鎖一共有4中狀態,級別從低到高:
這幾個狀態會隨着鎖的競爭,逐漸升級。鎖可以升級,但是不能降級,其根本的原因就是爲了提高獲取鎖和釋放鎖的效率!
那麼,synchronized是又如何保證的線程安全的呢?或許我們需要從字節碼尋找答案!
package com.byit.test;
/**
* @author Administrator
*/
public class SynText {
private static String A = "a";
public int i ;
public void add(){
synchronized (A){
i++;
}
}
}
反編譯的字節碼
Compiled from "SynText.java"
public class com.byit.test.SynText {
public int i;
public com.byit.test.SynText();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public void add();
Code:
0: getstatic #2 // Field A:Ljava/lang/String;
3: dup
4: astore_1
5: monitorenter
6: aload_0
7: dup
8: getfield #3 // Field i:I
11: iconst_1
12: iadd
13: putfield #3 // Field i:I
16: aload_1
17: monitorexit
18: goto 26
21: astore_2
22: aload_1
23: monitorexit
24: aload_2
25: athrow
26: return
Exception table:
from to target type
6 18 21 any
21 24 21 any
static {};
Code:
0: ldc #4 // String a
2: putstatic #2 // Field A:Ljava/lang/String;
5: return
}
省去不必要的,簡化在簡化
5: monitorenter
...
17: monitorexit
...
23: monitorexit
從字節碼中可知同步語句塊的實現使用的是monitorenter
和 monitorexit
指令,其中monitorenter
指令指向同步代碼塊的開始位置,monitorexit
指令則指明同步代碼塊的結束位置,當執行monitorenter
指令的時候,線程將試圖獲取對象所所對應的monitor
特權,當monitor的的計數器爲0的時候,線程就可以獲取monitor
,並將計數器設置爲1.去鎖成功!如果當前線程已經擁有monitor特權,則可以直接進入方法(可重入鎖),計數器+1;如果其他線程已經擁有了monitor特權,那麼本縣城將會阻塞!
擁有monitor特權的線程執行完成後釋放monitor,並將計數器設置爲0;同時執行monitorexit
指令;不要擔心出現異常無法執行monitorexit
指令;爲了保證在方法異常完成時 monitorenter 和 monitorexit 指令依然可以正確配對執行,編譯器會自動產生一個異常處理器,這個異常處理器聲明可處理所有的異常,它的目的就是用來執行 monitorexit 指令。從字節碼中也可以看出多了一個monitorexit指令,它就是異常結束時被執行的釋放monitor 的指令。
*同步代碼塊的原理了解了,那麼同步方法如何解釋?*不急,我們不妨來反編譯一下同步方法的狀態!
javap -verbose -p SynText > 3.txt
代碼
package com.byit.test;
/**
* @author huangfu
*/
public class SynText {
public int i ;
public synchronized void add(){
i++;
}
}
字節碼
Classfile /D:/2020project/byit-myth-job/demo-client/byit-demo-client/target/classes/com/byit/test/SynText.class
Last modified 2020-1-6; size 382 bytes
MD5 checksum e06926a20f28772b8377a940b0a4984f
Compiled from "SynText.java"
public class com.byit.test.SynText
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #4.#17 // java/lang/Object."<init>":()V
#2 = Fieldref #3.#18 // com/byit/test/SynText.i:I
#3 = Class #19 // com/byit/test/SynText
#4 = Class #20 // java/lang/Object
#5 = Utf8 i
#6 = Utf8 I
#7 = Utf8 <init>
#8 = Utf8 ()V
#9 = Utf8 Code
#10 = Utf8 LineNumberTable
#11 = Utf8 LocalVariableTable
#12 = Utf8 this
#13 = Utf8 Lcom/byit/test/SynText;
#14 = Utf8 syncTask
#15 = Utf8 SourceFile
#16 = Utf8 SynText.java
#17 = NameAndType #7:#8 // "<init>":()V
#18 = NameAndType #5:#6 // i:I
#19 = Utf8 com/byit/test/SynText
#20 = Utf8 java/lang/Object
{
public int i;
descriptor: I
flags: ACC_PUBLIC
public com.byit.test.SynText();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 6: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Lcom/byit/test/SynText;
public synchronized void syncTask();
descriptor: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
stack=3, locals=1, args_size=1
0: aload_0
1: dup
2: getfield #2 // Field i:I
5: iconst_1
6: iadd
7: putfield #2 // Field i:I
10: return
LineNumberTable:
line 10: 0
line 11: 10
LocalVariableTable:
Start Length Slot Name Signature
0 11 0 this Lcom/byit/test/SynText;
}
SourceFile: "SynText.java"
簡化,在簡化
public synchronized void syncTask();
descriptor: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
stack=3, locals=1, args_size=1
0: aload_0
1: dup
我們能夠看到 flags: ACC_PUBLIC, ACC_SYNCHRONIZED
這樣的一句話
從字節碼中可以看出,synchronized修飾的方法並沒有monitorenter指令和monitorexit指令,取得代之的確實是ACC_SYNCHRONIZED標識,該標識指明瞭該方法是一個同步方法,JVM通過該ACC_SYNCHRONIZED訪問標誌來辨別一個方法是否聲明爲同步方法,從而執行相應的同步調用。這便是synchronized鎖在同步代碼塊和同步方法上實現的基本原理。
那麼在JAVA6之前,爲什麼synchronized
會如此的慢?
那是因爲,操作系統實現線程之間的切換需要系統內核從用戶態切換到核心態!這個狀態之間的轉換,需要較長的時間,時間成本高!所以這也就是synchronized慢的原因!
三、鎖膨脹的過程
在這之前,你需要知道什麼是鎖膨脹!他是JAVA6之後新增的一個概念!是一種針對之前重量級鎖的一種性能的優化!他的優化,大部分是基於經驗上的一些感官,對鎖來進行優化!
①偏向鎖
研究發現,大多數情況下,鎖不僅不存在多線程競爭,而且還總是由一條線程獲得!因爲爲了減少鎖申請的次數!引進了偏向鎖!在沒有鎖競爭的情況下,如果一個線程獲取到了鎖,那麼鎖就進入偏向鎖的模式!當線程再一次請求鎖時,無需申請,直接獲取鎖,進入方法!但是前提是沒有鎖競爭的情況,存在鎖競爭,鎖會立即膨脹,膨脹爲輕量級鎖!
②輕量級鎖
偏向鎖失敗,那麼鎖膨脹爲輕量級鎖!此時鎖機構變爲輕量級鎖結構!他的經驗依據是:“絕大多數情況下,在整個同步週期內,不會存在鎖的競爭”,故而,輕量級鎖適合,線程交替進行的場景!如果在同一時間出現兩條線程對同一把鎖的競爭,那麼此時輕量級鎖就不會生效了!但是,jdk官方爲了是鎖的優化性能更好,輕量級鎖失效後,並不會立即膨脹爲重量級鎖!而是將鎖轉換爲自旋鎖狀態!
③自旋鎖
輕量級鎖失敗後,爲了是避免線程掛起,引起內核態的切換!爲了優化,此時線程會進入自選狀態!他可能會進行幾十次,上百次的空輪訓!爲什麼呢?又是經驗之談!他們認爲,大多數情況下,線程持有鎖的時間都不會太長!做幾次空輪訓,就能大概率的等待到鎖!事實證明,這種優化方式確實有效!最後如果實在等不到鎖!沒辦法,纔會徹底升級爲重量級鎖!
④鎖消除
jvm在進行代碼編譯時,會基於上下文掃描;將一些不可能存在資源競爭的的鎖給消除掉!這也是JVM對於鎖的一種優化方式!不得不感嘆,jdk官方的腦子!舉個例子!在方法體類的局部變量對象,他永遠也不可能會發生鎖競爭,例如:
/**
* @author huangfu
*/
public class SynText {
public static void add(String name1 ,String name2){
StringBuffer sb = new StringBuffer();
sb.append(name1).append(name2);
}
public static void main(String[] args) {
for (int i = 0; i < 10000000; i++) {
add("w"+i,"q"+i);
}
}
}
不能否認,StringBuffer
是線程安全的!但是他永遠也不會被其他線程引用!故而,鎖失效!故而,被消除掉!
歡迎關注公衆號 關注公衆號,回覆架構師,提供各類技術的學習資料提供參閱!