Synchronized的那些事

来源:https://www.cnblogs.com/CodeBear/archive/2018/12/24/10166821.html
-Advertisement-
Play Games

在上一篇博客中,我“蜻蜓點水”般的介紹了下Java記憶體模型,在這一篇博客,我將帶著大家看下Synchronized關鍵字的那些事,其實把Synchronized關鍵字放到上一篇博客中去介紹,也是符合 “Java記憶體模型”這個標題的,因為Synchronized關鍵字和Java記憶體模型有著密不可分的關 ...


在上一篇博客中,我“蜻蜓點水”般的介紹了下Java記憶體模型,在這一篇博客,我將帶著大家看下Synchronized關鍵字的那些事,其實把Synchronized關鍵字放到上一篇博客中去介紹,也是符合 “Java記憶體模型”這個標題的,因為Synchronized關鍵字和Java記憶體模型有著密不可分的關係。但是這樣,上一節的內容就太多了。同樣的,這一節的內容也相當多。

好了,廢話不多說,讓我們開始吧,

Synchronized基本使用

首先從一個最簡單的例子開始看:

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了。

Synchronized與原子性

為什麼會出現這樣的問題呢,可能就有一小部分人不知道其中的原因了。

這和Java的記憶體模型有關係:在Java的記憶體模型中,保證併發安全的三大特性是 原子性,可見性,有序性。導致這問題出現的原因 便是 num++ 不是原子性操作,它至少有三個操作:
1.把i讀取出來
2.做自增計算
3.把值寫回i

讓我們設想有這樣的一個場景:

當num=5

  1. A線程執行到num++這一步,讀到了num的值為5(因為還沒進行自增操作)。

  2. B線程也執行到了num++這一步,讀到了num的值還是為5(因為A線程中的num還沒有來得及進行自增操作)。

  3. A線程中的num終於進行了自增操作,num為6。

  4. B線程的num也進行了自增操作,num也為6。

可能光用文字描述,還是有點懵,所以我畫了一張圖來幫助大家理解:

image.png

結合文字和圖片,應該就可以理解了。

可以看出來,雖然執行了兩次自增操作,但是實際的效果只是自增了一次。

所以在第一段代碼中,運行的結果並不是1000,而是比1000小的數字。

對於在多線程環境中,出現奇怪的結果或者情況,我們也稱為“線程不安全”。

而第二段代碼,就是通過Synchronized關鍵字,把test方法串列化執行了,也就是 A線程執行完test方法,B線程才可以執行test方法。兩個線程是互斥的。這樣就保證了線程的安全性,最後的結果就是1000。如果從Java記憶體模型的角度來說,就是保證了操作的“原子性”。

Synchronized幾種使用方法

上面的例子是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的對象。

JConsole探究Synchronized關鍵字

我們需要用到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裡面了:

image.png

點擊某個線程,可以看到關於線程的一些信息:

image.png

image.png

其中四個線程都處於BLOCKED,只有一個處於TIME_WAITING,說明只有一個線程獲得了鎖,併在TIME_WAITING,其餘的線程都沒有獲得鎖,沒有進入到方法,說明瞭Synchronized的互斥性。關於線程的狀態,這篇不會深入,以後可能會介紹這方面的知識。

因為我是一邊寫博客,一邊執行各種操作的,所以速度上有些跟不上,導致截圖和描述不同,大家可以自己去試試。

javap探究Synchronized關鍵字

為了把問題簡單化,讓大家看的清楚,我只保留synchronized相關的代碼:

public class Main {
    public static void main(String[] args) {
        synchronized (Main.class) {
        }
    }
}

編譯後,用javap命令查看位元組碼文件:

javap -v Main.class

image.png

用紅圈圈出來的就是添加synchronized後帶來的命令了。執行同步代碼塊,先是調用monitorenter命令,執行完畢後,再調用monitorexit命令,為什麼會有兩個monitorexit呢,一個是正常執行辦法後的monitorexit,一個是發生異常後的monitorexit。

synchronized標記方法會是什麼情況呢?

public class Main {
    public synchronized void Hello(){
        System.out.println("Hellol");
    }
    public static void main(String[] args) {
    }
}

image.png

鎖與Monitor

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");
            }
        }
    }

結果:
image.png

主線程獲取了類鎖,列印出 “第一個synchronized”,緊接著主線程又獲取了類鎖,列印出“第二個synchronized”。

問題來了,第一個類鎖明明還沒有釋放,下麵又獲取了這個類鎖。如果沒有“鎖的重入性”,這裡應該只會列印出 “第一個synchronized”,然後程式就死鎖了,因為它會一直等待釋放第一個類鎖,但是卻永遠等不到那一刻。

這也就是解釋了為什麼會是“當線程獲取monitor後,計數器便會+1,釋放monitor後,計數器便會-1“這樣的設計。只有當計數器=0,才代表monitor已經被釋放。第二個線程才能再次獲取monitor。

當然,鎖的重入性是針對於同一個線程來說。

Synchronized與有序性,可見性

在上一篇中,我們簡單的介紹了指令重排,知道了三大特性之一的有序性,但是介紹的太簡單。這一次,我們把上一次的內容補充下。

其實,指令重排分為兩種:

  1. 編譯器重排
  2. 運行時CPU指令排序

為什麼編譯器和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與單例模式

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進行的優化,有機會,會再抽出一節的內容來講講這個。


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • 函數式編程 filter的使用 reduce curry let dragon = (name,size,element) = console.log(dragon('fluffykins','tiny','lightling')) //curry let dragon = name = size ...
  • JavaScript 系列博客(五) 前言 本篇博客學習 js 選擇器來控制 css 和 html、使用事件(鉤子函數)來處理事件完成後完成指定功能以及js 事件控制頁面內容。 js 選擇器 在學習 js 選擇器前需要瞭解幾個概念。 節點(一):在文檔(document)中出現的所有內容都是 doc ...
  • 1.同源問題解決 首先,在同一個域下搭建網路功能變數名稱訪問,需要nginx軟體,下載之後修改部分配置 然後再終端下cmd nginx.exe命令,或者打開nginx.exe文件,會運行nginx一閃而過,在後臺運行而且一次是打開兩個的,可以在任務管理器控制結束進程, 接下來,你就可以打開8080介面給同域 ...
  • 本文是 "Rxjs 響應式編程 第一章:響應式" 這篇文章的學習筆記。 示例代碼地址: "【示例代碼】" 更多文章: "【《大史住在大前端》博文集目錄】" [TOC] 一. 劃重點 三句非常重要的話: 從理念上來理解,Rx模式引入了一種新的 “一切皆流” 的編程範式 從設計模式的角度來看, 是 發佈 ...
  • 國內的設計師大都喜歡用px,而國外的網站大都喜歡用em和rem,那麼三者有什麼區別,又各自有什麼優劣呢? PX特點 1. IE無法調整那些使用px作為單位的字體大小; 2. 國外的大部分網站能夠調整的原因在於其使用了em或rem作為字體單位; 3. Firefox能夠調整px和em,rem,但是96 ...
  • react本身能夠完成動態數據的監聽和更新,如果不是必要可以不適用redux。 安裝redux: cnpm install redux --save,或者yarn add redux。 一、react基本用法 redux是獨立的用於狀態管理的第三方包,它建立狀態機來對單項數據進行管理。 上圖是個人粗 ...
  • 裝飾器模式允許向現有對象中添加新功能,同時又不改變其結構。 介紹 裝飾器模式屬於結構型模式,主要功能是能夠動態地為一個對象添加額外功能。在保證現有功能的基礎上,再添加新功能,可聯想到 WPF 中的附件屬性。 類圖描述 由上圖可知,我們定義了一個基礎介面 IShape 用於約定對象的基礎行為。然後通過 ...
  • 在實際應用中,集群環境里共用一些數據是不可避免的。我的意思是有些數據可以在任何節點進行共用同步讀寫,困難的是如何解決更改衝突問題。本來可以通過分散式資料庫來實現這樣的功能,但使用和維護成本又過高,不值得。分散式數據類型distributed-data (ddata)正是為解決這樣的困局而設計的。ak ...
一周排行
    -Advertisement-
    Play Games
  • 移動開發(一):使用.NET MAUI開發第一個安卓APP 對於工作多年的C#程式員來說,近來想嘗試開發一款安卓APP,考慮了很久最終選擇使用.NET MAUI這個微軟官方的框架來嘗試體驗開發安卓APP,畢竟是使用Visual Studio開發工具,使用起來也比較的順手,結合微軟官方的教程進行了安卓 ...
  • 前言 QuestPDF 是一個開源 .NET 庫,用於生成 PDF 文檔。使用了C# Fluent API方式可簡化開發、減少錯誤並提高工作效率。利用它可以輕鬆生成 PDF 報告、發票、導出文件等。 項目介紹 QuestPDF 是一個革命性的開源 .NET 庫,它徹底改變了我們生成 PDF 文檔的方 ...
  • 項目地址 項目後端地址: https://github.com/ZyPLJ/ZYTteeHole 項目前端頁面地址: ZyPLJ/TreeHoleVue (github.com) https://github.com/ZyPLJ/TreeHoleVue 目前項目測試訪問地址: http://tree ...
  • 話不多說,直接開乾 一.下載 1.官方鏈接下載: https://www.microsoft.com/zh-cn/sql-server/sql-server-downloads 2.在下載目錄中找到下麵這個小的安裝包 SQL2022-SSEI-Dev.exe,運行開始下載SQL server; 二. ...
  • 前言 隨著物聯網(IoT)技術的迅猛發展,MQTT(消息隊列遙測傳輸)協議憑藉其輕量級和高效性,已成為眾多物聯網應用的首選通信標準。 MQTTnet 作為一個高性能的 .NET 開源庫,為 .NET 平臺上的 MQTT 客戶端與伺服器開發提供了強大的支持。 本文將全面介紹 MQTTnet 的核心功能 ...
  • Serilog支持多種接收器用於日誌存儲,增強器用於添加屬性,LogContext管理動態屬性,支持多種輸出格式包括純文本、JSON及ExpressionTemplate。還提供了自定義格式化選項,適用於不同需求。 ...
  • 目錄簡介獲取 HTML 文檔解析 HTML 文檔測試參考文章 簡介 動態內容網站使用 JavaScript 腳本動態檢索和渲染數據,爬取信息時需要模擬瀏覽器行為,否則獲取到的源碼基本是空的。 本文使用的爬取步驟如下: 使用 Selenium 獲取渲染後的 HTML 文檔 使用 HtmlAgility ...
  • 1.前言 什麼是熱更新 游戲或者軟體更新時,無需重新下載客戶端進行安裝,而是在應用程式啟動的情況下,在內部進行資源或者代碼更新 Unity目前常用熱更新解決方案 HybridCLR,Xlua,ILRuntime等 Unity目前常用資源管理解決方案 AssetBundles,Addressable, ...
  • 本文章主要是在C# ASP.NET Core Web API框架實現向手機發送驗證碼簡訊功能。這裡我選擇是一個互億無線簡訊驗證碼平臺,其實像阿裡雲,騰訊雲上面也可以。 首先我們先去 互億無線 https://www.ihuyi.com/api/sms.html 去註冊一個賬號 註冊完成賬號後,它會送 ...
  • 通過以下方式可以高效,並保證數據同步的可靠性 1.API設計 使用RESTful設計,確保API端點明確,並使用適當的HTTP方法(如POST用於創建,PUT用於更新)。 設計清晰的請求和響應模型,以確保客戶端能夠理解預期格式。 2.數據驗證 在伺服器端進行嚴格的數據驗證,確保接收到的數據符合預期格 ...