在上一篇博客中,我「走馬觀花」般的介紹了下Java內存模型,在這一篇博客,我將帶着你們看下Synchronized關鍵字的那些事,其實把Synchronized關鍵字放到上一篇博客中去介紹,也是符合 「Java內存模型」這個標題的,由於Synchronized關鍵字和Java內存模型有着密不可分的關係。可是這樣,上一節的內容就太多了。一樣的,這一節的內容也至關多。java
好了,廢話很少說,讓咱們開始吧,程序員
首先從一個最簡單的例子開始看:面試
public class Main {
private int num = 0;
private void test() {
for (int i = 0; i < 50; i++) {
try {
TimeUnit.MILLISECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
num++;
}
}
public static void main(String[] args) {
Main main = new Main();
for (int i = 0; i < 20; i++) {
new Thread(() -> {
main.test();
}).start();
}
try {
TimeUnit.SECONDS.sleep(5);
System.out.println(main.num);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
複製代碼
Main方法中開啓了20個線程,每一個線程執行50次的累加操做,最後打印出來的應該是50*20,也就是1000,可是每次打印出來的都不是1000,而是比1000小的數字。相信這個例子,你們早就爛熟於心了,對解決方案也是手到擒來:編程
public class Main {
private int num = 0;
private synchronized void test() {
for (int i = 0; i < 50; i++) {
try {
TimeUnit.MILLISECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
num++;
}
}
public static void main(String[] args) {
Main main = new Main();
for (int i = 0; i < 20; i++) {
new Thread(() -> {
main.test();
}).start();
}
try {
TimeUnit.SECONDS.sleep(5);
System.out.println(main.num);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
複製代碼
只要在test方法上加一個synchronized關鍵字,就OK了。安全
爲何會出現這樣的問題呢,可能就有一小部分人不知道其中的緣由了。bash
這和Java的內存模型有關係:在Java的內存模型中,保證併發安全的三大特性是 原子性,可見性,有序性。致使這問題出現的緣由 即是 num++ 不是原子性操做,它至少有三個操做:多線程
讓咱們設想有這樣的一個場景:併發
當num=5app
A線程執行到num++這一步,讀到了num的值爲5(由於還沒進行自增操做)。工具
B線程也執行到了num++這一步,讀到了num的值仍是爲5(由於A線程中的num尚未來得及進行自增操做)。
A線程中的num終於進行了自增操做,num爲6。
B線程的num也進行了自增操做,num也爲6。
可能光用文字描述,仍是有點懵,因此我畫了一張圖來幫助你們理解:
結合文字和圖片,應該就能夠理解了。
能夠看出來,雖然執行了兩次自增操做,可是實際的效果只是自增了一次。
因此在第一段代碼中,運行的結果並非1000,而是比1000小的數字。
對於在多線程環境中,出現奇怪的結果或者狀況,咱們也稱爲「線程不安全」。
而第二段代碼,就是經過Synchronized關鍵字,把test方法串行化執行了,也就是 A線程執行完test方法,B線程才能夠執行test方法。兩個線程是互斥的。這樣就保證了線程的安全性,最後的結果就是1000。若是從Java內存模型的角度來講,就是保證了操做的「原子性」。
上面的例子是Synchronized關鍵字的使用方式之一,此時,synchronized標記的是類的實例方法,鎖對象是類的實例對象。固然還有其餘使用方式:
private static synchronized void test() {
for (int i = 0; i < 10; i++) {
try {
TimeUnit.MILLISECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(num++);
}
}
複製代碼
此時,synchronized標記的是類的靜態方法,鎖對象是類。
以上兩種,是直接標記在方法上。
還能夠包裹代碼塊:
private void test() {
synchronized (Main.class) {
for (int i = 0; i < 10; i++) {
try {
TimeUnit.MILLISECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(num++);
}
}
}
複製代碼
此時鎖的對象是 類。
private void test() {
synchronized (this) {
for (int i = 0; i < 10; i++) {
try {
TimeUnit.MILLISECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(num++);
}
}
}
複製代碼
此時鎖的對象是類的實例對象。
private Object object = new Object();
private void test() {
synchronized (object) {
for (int i = 0; i < 10; i++) {
try {
TimeUnit.MILLISECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(num++);
}
}
}
複製代碼
此時,鎖對象是Object的對象。
咱們須要用到JDK自帶的一個工具:JConsole,它位於JDK的bin目錄下。
爲了讓觀察更加方便,咱們須要給線程起一個名字,每一個線程內sleep的時間稍微長一點:
public class Main {
private synchronized void test() {
try {
TimeUnit.SECONDS.sleep(20);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public static void main(String[] args) {
Main main = new Main();
for (int i = 0; i < 5; i++) {
new Thread(() -> {
main.test();
}, "Hello,Thread " + i).start();
}
}
}
複製代碼
咱們先啓動項目,而後打開JConsole,找到你項目的進程,就能夠鏈接上去了。
能夠看到,5個線程已經顯示在JConsole裏面了:
點擊某個線程,能夠看到關於線程的一些信息:
其中四個線程都處於BLOCKED,只有一個處於TIME_WAITING,說明只有一個線程得到了鎖,並在TIME_WAITING,其他的線程都沒有得到鎖,沒有進入到方法,說明了Synchronized的互斥性。關於線程的狀態,這篇不會深刻,之後可能會介紹這方面的知識。
由於我是一邊寫博客,一邊執行各類操做的,因此速度上有些跟不上,致使截圖和描述不一樣,你們能夠本身去試試。
爲了把問題簡單化,讓你們看的清楚,我只保留synchronized相關的代碼:
public class Main {
public static void main(String[] args) {
synchronized (Main.class) {
}
}
}
複製代碼
編譯後,用javap命令查看字節碼文件:
javap -v Main.class
複製代碼
用紅圈圈出來的就是添加synchronized後帶來的命令了。執行同步代碼塊,先是調用monitorenter命令,執行完畢後,再調用monitorexit命令,爲何會有兩個monitorexit呢,一個是正常執行辦法後的monitorexit,一個是發生異常後的monitorexit。
synchronized標記方法會是什麼狀況呢?
public class Main {
public synchronized void Hello(){
System.out.println("Hellol");
}
public static void main(String[] args) {
}
}
複製代碼
JVM爲每一個對象都分配了一個monitor,syncrhoized就是利用monitor來實現加鎖,解鎖。同一時刻,只有一個線程能夠得到monitor,而且執行被包裹的代碼塊或者方法,其餘線程只能等待monitor釋放,整個過程是互斥的。monitor擁有一個計數器,當線程獲取monitor後,計數器便會+1,釋放monitor後,計數器便會-1。那麼爲何會是+1,-1 的操做,而不是「得到monitor,計數器=1,釋放monitor後,計數器=0」呢?這就涉及到 鎖的重入性了。咱們仍是經過一段簡單的代碼來看:
public static void main(String[] args) {
synchronized (Main.class){
System.out.println("第一個synchronized");
synchronized (Main.class){
System.out.println("第二個synchronized");
}
}
}
複製代碼
結果:
主線程獲取了類鎖,打印出 「第一個synchronized」,緊接着主線程又獲取了類鎖,打印出「第二個synchronized」。
問題來了,第一個類鎖明明尚未釋放,下面又獲取了這個類鎖。若是沒有「鎖的重入性」,這裏應該只會打印出 「第一個synchronized」,而後程序就死鎖了,由於它會一直等待釋放第一個類鎖,可是卻永遠等不到那一刻。
這也就是解釋了爲何會是「當線程獲取monitor後,計數器便會+1,釋放monitor後,計數器便會-1「這樣的設計。只有當計數器=0,才表明monitor已經被釋放。第二個線程才能再次獲取monitor。
固然,鎖的重入性是針對於同一個線程來講。
在上一篇中,咱們簡單的介紹了指令重排,知道了三大特性之一的有序性,可是介紹的太簡單。這一次,咱們把上一次的內容補充下。
其實,指令重排分爲兩種:
爲何編譯器和CPU會作「指令重排」這個「吃力不討好」的事情呢?固然是爲了效率。
指令重排會遵照兩個規則:即 self-if-serial 和 happens-before。
咱們來舉一個例子:
int a=1;//1
int b=5;//2
int c=a+b;//3
複製代碼
這結果顯而易見:c=6。
可是這段代碼真正交給CPU去執行是按照什麼順序呢,大部分人會認爲 」從上到下"。是的,從你們開始學編程第一天就被灌輸了這個思想,可是這僅僅是一個幻覺,真正交給CPU執行,多是 先執行第二行,而後再執行第一行,最後是第三行。由於第一行和第二行,哪一行先運行,並不影響最終的結果,可是第三行的執行順序就不能改變了,由於數據存在依懶性。若是改變了第三行的執行順序,那不亂套了。
編譯器,CPU會在不影響單線程程序最終執行的結果的狀況下進行「指令重排」。
這就是「 self-if-serial」規則。
這個規則就給程序員造給一種假象,在單線程中,代碼都是從上到下執行的,卻不知,編譯器和CPU其實在背後偷偷的作了不少事情,而作這些事情的目的只有一個「提升執行的速度」。
在單線程中,咱們可能並不須要關心指令重排,由於不管背後進行了多麼翻天覆地的「指令重排」都不會影響到最終的執行結果,可是self-if-serial是針對於單線程的,對於多線程,會有第二個規則:happens-before。
happens-before用來表述兩個操做之間的關係。若是A happens-before B,也就表明A發生在B以前。
因爲兩個操做可能處於不一樣的線程,happens-before規定,若是一個線程A happens-before另一個線程B,那麼A對B可見,正是因爲這個規定,咱們說Synchronized保證了線程的「可見性」。Synchronized具體是怎麼作的呢?當咱們得到鎖的時候,執行同步代碼,線程會被強制從主內存中讀取數據,先把主內存的數據複製到本地內存,而後在本地內存進行修改,在釋放鎖的時候,會把數據寫回主內存。
而Synchronized的同步特性,顯而易見的保證了「有序性」。
總結一下,Synchronized既能夠保證「原子性」,又能夠保證「可見性」,還能夠保證「有序性」。
Synchronized最經典的應用之一就是 懶漢式單例模式 了,以下:
public class Main {
private static Main main;
private Main() {
}
public static Main getInstance() {
if (main == null) {
synchronized (Main.class) {
if (main == null) {
main = new Main();
}
}
}
return main;
}
}
複製代碼
相信這代碼,你們已經熟悉的不能再熟悉了,可是在極端狀況下,可能會產生意想不到的狀況,這個時候,Synchronized的好基友Volatile就出現了,這是咱們下一節中要講的內容。
Synchronized能夠說是每次面試一定會出現的問題,平時在多線程開發的時候也會用到,可是真正要理解透徹,仍是有不小難度。雖然說Synchronized的互斥性,很影響性能,Java也提供了很多更好用的的併發工具,可是Synchronized是併發開發的基礎,因此值得花點時間去好好研究。
好了,本節的內容到這裏結束了,文章已經至關長了,可是還有一大塊東西沒有講:JDK1.6對Synchronized進行的優化,有機會,會再抽出一節的內容來說講這個。