JVM和併發編程總結

文章目錄

1 JVM相關

1.1 JVM內存結構

JVM有五大內存區域:

① 程序計數器:線程私有,記錄字節碼指令地址,記錄當前程序執行到字節碼文件的第幾行。

② Java棧(虛擬機棧):線程私有,生命週期與線程相同,虛擬機棧當中存儲的是幀,幀與幀之間不一定是連續內存;

  • :每調用一個方法就創建一個幀,每一個幀都有自己的本地變量數組和操作數據棧,這個內存大小在編譯時就確定了。在線程執行的任何一個時刻,都只有一個幀是激活狀態,稱爲當前幀;
    • 本地變量數組:每一幀都包含一個變量數組,就是都熟知的本地變量存儲的地方
    • 操作數棧:每個幀包含一個後進先出的棧,用於存儲正在執行的jvm指令的操作數
    • 動態鏈接:將符號引用裝換成具體方法的引用

③ 本地方法棧:線程私有,作用於虛擬機棧作用類似,只是本地方法棧是爲native方法服務。

④ 堆:線程共享。是java虛擬機管理內存最大的一塊內存區域,所有對象實例及數組都要在堆上分配內存。

  • 從內存分配的角度,Java堆中有多個線程私有的分配緩衝區,用於線程高速的分配內存空間,讀取還是線程共享的。解決多個線程分配內存空間的同步效率問題
  • 從GC角度,Java堆可以分爲老年代和新生代,新生代又分爲Eden、S0、S1區域。

⑤ 方法區(1.8之前):線程共享,用於存儲已被虛擬機加載的類信息、常量、靜態變量。1.8之後的方法區在元空間(本地內存),不受JVM控制,不進行GC,通過其他方式進行垃圾回收。

1.2 Java內存模型(JMM)

Java內存模型定義了線程與主內存之間的抽象關係,所有的共享變量都存儲在主內存中,每個線程還有自己的本地內存,本地內存中存儲的是主內存中共享變量的副本,線程對變量的所有操作都只能在本地內存中操作,不能直接讀寫主內存。

volatile的作用就是當線程修改被volatile修飾的變量時,要立即寫入到主內存,當線程讀取被volatile修飾的變量時,要立即到主內存中去讀取,保證了可見性。JVM向CPU發送一個LOCK指令,表示該變量的緩存行要刷新到內存,而緩存行刷新到內存會導致其他處理器的該數據的緩存行失效,也就意味着其他線程會到內存中讀取數據。

1.3 JVM垃圾回收算法

1.3.1 哪些內存需要垃圾回收

程序計數器、虛擬機棧、本地方法棧是隨着線程自生自滅,而方法區和堆內存則需要垃圾回收。

1.3.2 判斷對象存活算法

  • 引用計數法:給對象添加引用計數器,優點:效率高 缺點:難以解決循環引用問題(node1.next = node2; node2.prev=node1;當將node1=null;node2=null後也不能回收)
  • **可達性分析算法:**以一系列叫做 GC Root 的對象爲起點出發,引出它們指向的下一個節點,再以下個節點爲起點,引出此節點指向的下一個結點。。。(這樣通過 GC Root 串成的一條線就叫引用鏈),直到所有的結點都遍歷完畢,如果相關對象不在任意一個以 GC Root 爲起點的引用鏈中,則這些對象會被判斷爲「垃圾」,會被 GC 回收。
    • 若不是以GCRoot爲起點的引用,也會被判定爲垃圾

1.3.3 GC root對象

  • 虛擬機棧中的引用對象;
  • 方法區中的類靜態熟悉引用的對象,即static修飾的引用對象
  • 方法區中常量引用對象,即final修飾的引用對象
  • 本地方法棧中的對象,即native修飾的引用對象
  • 虛擬機內部的引用,如基本數據類型的class對象等
  • 所有被同步鎖()持有的對象

1.3.4 finalize方法

finalize方法是object中的方法,如果該類重寫了該方法,那麼第一次對該對象回收時就會先執行finalize方法,若執行完finalize方法後對象爲可達狀態則不會進行回收。

注意該方法只能被執行一次,下一次再回收該對象時若finalize已經被執行一次,則直接回收。

1.3.5 垃圾回收算法

  • 標記清除算法
    • 有內存碎片
  • 標記複製算法
  • 標記整理算法
  • 分代收集算法
    • 新生代:將新生代內存分爲Eden、S0、S1區域,比例8:1:1。GC開始時,對象只會存在於Eden區域和From Survivor區域,GC進行時,eden區域存活的對象會複製到To Survivor區域,而From Survivor區域存活的對象會根據存活年齡分配去處,年齡大的分配到老年代,否則去To Survivor。
    • **老年代:**採用標記清除或標記整理算法

1.3.6 何時晉升到老年代

  • 對象的年齡到達JVM限定的年齡
  • 創建一個大對象的時候
  • S0或S1區域的相同年齡的對象的大小之和大於S區域的一半時,會將年齡大於該值的對象移入老年代。

1.3.7 Minor GC和Full GC觸發條件

  • Minor GC觸發條件
    • 當Eden區域滿的時候
  • Full GC觸發條件
    • 老年代空間不足
    • 老年代最大可用連續空間小於新生代所有對象的總空間且虛擬機不允許擔保策略時會發生full GC;
    • 若允許擔保策略,則老年代最大可用連續空間小於歷次晉升到老年代對象的平均大小時fullGC

1.3.8 safe point(安全點)

安全點:決定了用戶程序執行時並非在代碼任意位置都可以停下來進行垃圾回收,而是強制要求必須到達安全點後才能暫停用戶線程進行垃圾回收。

安全點的選取原則:是否具有讓程序長時間執行的特徵。

  • 方法調用
  • 循環末尾
  • 拋出異常

如何保證所有線程都在安全點上?

  • 搶先式中斷:垃圾回收時發現有線程不在安全點,則讓該線程繼續執行到安全點。
  • 主動式中斷:

1.4 垃圾回收器

垃圾回收主要有四大思想:串行、並行、併發、G1。

查看默認的垃圾回收器

java -XX:+PrintCommandLineFlags -version

image-20200603111605087

所有的垃圾回收器

image-20200603112712423

新生代默認都是標記複製算法。

部分參數

image-20200603113208659

1.4.1 Serial垃圾收集器

單線程的收集器,用於新生代,在垃圾收集的時候必須暫停所有的工作線程直到收集結束。

該收集器現在一般用於單核cpu。開啓後老年代也會默認使用串行的收集器Serial Old

-XX:+UseSerialGC   # 開啓新生代使用串行收集器,開啓後老年代也會默認使用串行的收集器

1.4.2 ParNew收集器

多線程進行垃圾回收,用於新生代,在垃圾收集的時候必須暫停所有的工作線程直到收集結束。一般新生代使用ParNew配合老年代CMS使用。

若新生代開啓UseParNew,默認此時老年代是serialOld,會警告不被推薦使用。需再配置老年代收集器。

-XX:+UseParNewGC  # 啓用ParNew收集器,隻影響新生代,不影響老年代。

-XX:ParNewGCThreads   #限制線程數量,默認是CPU數量相同的線程數

1.4.3 Parallel Scavenge收集器

Java8默認的收集器。

多線程進行垃圾回收,用於新生代。在垃圾收集的時候必須暫停所有的工作線程直到收集結束。開啓後會默認老年代也是用並行的垃圾收集器

這個並行收集器關注的是吞吐量(運行用戶代碼時間/(用戶時間+GC時間)

-XX:+UseParallelGC   #開啓新生代Parallel Scavenge收集器,開啓後老年代默認使用Parallel Old收集器

1.4.4 Serial Old收集器

Serial收集器的老年代版本,單線程收集器,標記整理算法暫停所有用戶線程並單線程收集垃圾。

1.4.5 Parallel Old收集器

Parallel Scavenge的老年代版本,多線程併發收集。

-XX:UseParallelOldGC   #開啓老年代代Parallel Old收集器,開啓後新生代默認使用Parallel Scavenge收集器

1.4.6 CMS垃圾收集器

Concurrent Mark Sweep:併發標記清除

一種以獲得最短回收停頓時間爲目標的收集器,適用於互聯網或B/S系統服務器。CMS一般只作用於老年代的收集,基於標記清除算法

-XX:+UseConcMarkSweepGC  #開啓CMS,默認新生代會開啓ParaNew

image-20200603150403533

四個步驟

  • 初始標記:只標記GC root能直接關聯的對象,所以速度很快。需要暫停用戶線程

  • 併發標記:可達性分析過程

  • 重新標記:修正併發標記期間因用戶線程繼續運行而導致標記產生變動的那一部分對象的標記記錄,這個時間稍長於初始標記。需要暫停用戶線程。

  • 併發清除:併發的清除垃圾

  • 優點:GC線程可以與用戶線程併發執行,從而降低收集停頓時間

  • 缺點

    • CPU資源敏感。因爲需要分出幾個線程((處理器核心數+3)/4)行進回收,造成吞吐量小;(若cpu核心數少,性能影響大)
    • 有浮動垃圾。最後併發清除期間會產生垃圾不能回收,只能下次回收。
    • 內存碎片。標記清除算法固有缺點
-XX:CMSInitiatingOccu-pancyFraction  #代表CMS收集器的啓動閾值百分比

在JDK6之後,默認CMS啓動閾值時92%,即老年代空間達到92%時纔會啓動CMS收集器回收垃圾,

但是CMS垃圾收集器必須在老年代堆內存用盡之前完成垃圾收集,否則會造成失敗,失敗後後使用serial Old收集器備用收集,此時停頓時間將會很長;

所以不建議將該參數設置得非常大。

1.4.7 垃圾收集器的選擇

  • 單cpu或者小內存單機程序
    • 使用serialGC即可 -XX:UseSerialGC
  • 多CPU,需求是最大吞吐量,如後臺計算型應用
    • 使用ParallelGC即可。配置-XX:+UseParallelGC 或者 -XX:UseParallelOldGC
  • 多CPU,追求停頓時間短,交互性強的互聯網應用
    • 使用ParNew+CMS組合

1.4.8 G1收集器

在實現高吞吐量的同時,儘量減少停頓時間。

  • 收集器特點:

    • 雖然也有新生代和老年代的概念,但不像CMS那樣將新生代和老年代產生物理上的隔離,而是將堆劃分成一個個區域,E區、S區、O區和H區域;H區域代表存儲的是巨大對象;gc不必在全堆上進行。
    • G1收集器有內存整理過程,不會產生很多內存碎片。
    • 用戶可以指定期望的停頓時間
    • image-20200603153637548
  • 回收細節:

    • G1 跟蹤各個塊裏垃圾堆積的價值大小(回收所獲得的空間大小及回收所需經驗值),這樣根據價值大小維護一個優先列表,根據允許的收集時間,優先收集回收價值最大的Region,也就避免了整個老年代的回收,也就減少了STW 造成的停頓時間。
  • 收集步驟

    • 初始標記:僅標記gcroots能直接到達的對象
    • 併發標記:可達性分析
    • 最終標記:暫停用戶線程,做最終標記
    • 篩選回收:負責更新每個塊的統計數據,然後對每個塊的回收價值進行排序,根據用戶期望停留時間選擇哪些塊進行回收。每個塊進行回收時,採用複製算法

image-20200603154318205

  • 對於CMS的優勢
    • 沒有內存碎片
    • 能指定期望停頓時間

1.4.9 垃圾收集器總結

image-20200603152515975

JVM參數在啓動微服務時配置,如啓動一個訂單微服務模塊

java -server -Xms1024m -Xmx1024m -XX:+UseG1GC -war server-order.war
# 設置初始和最大堆內存1G,並採用G1垃圾回收器

1.5 類加載機制

1.5.1 類加載過程

  • ​ 什麼是類加載機制?
    • 虛擬機把描述類的數據從Class文件加載到內存,並對數據進行校驗、轉換解析和初始化,最終形成可以被虛擬機直接使用的Java類型, 這就是虛擬機的類加載機制。
  • 類加載過程?
    • 加載:將class文件讀入內存並創建java.lang.Class對象
    • 驗證:驗證class格式、語法定義等
    • 準備:爲static靜態域默認初始化,並分配內存空間。
    • 解析:將符號引用替換成直接引用
    • 初始化:根據程序初始化類變量和其他資源

1.5.2 類加載機制

  • 全盤負責機制:當一個加載器加載某個Class時,該Class依賴的Class也由該加載器加載。

  • **雙親委派機制:**首先採用該類的父類加載器去加載,失敗時才自己去加載。

  • ​ 類加載器有哪些?

    • 啓動類加載器:加載java核心類,C++編寫。加載lJAVA_HOME/lib目錄
    • 擴展類加載器:加載擴展類,JAVA_HOME/lib/ext目錄
    • 應用程序類加載器:加載用戶類路徑上所有類庫。
  • 雙親委派模型加載過程?

    • 所有類的加載都會先去委託父級類加載器加載,所以無論哪一個類加載器要加載一個類,最終都會委託最頂端的啓動類加載器去加載,只有加載失敗時纔會輪到下一級加載器加載。
    • 雙親委派模型也保證了在自己的classPath上寫一個java.lang.Object類不會被加載,因爲最終會委託到啓動類加載器加載,此時加載的是lib目錄下的類。

1.5.3 雙親委派模型的破壞及Tomcat的類加載機制

網易面試時的問題:在Tomcat裏面部署兩個應用程序,但是兩個程序使用不同的spring版本,怎麼實現的?

其實這裏是破壞了雙親委派模型規則的,這個規則本身也不是強制的。到目前爲止,雙親委派模型出現過幾次大規模破壞:

  • SPI破壞雙親委派:Java 在覈心類庫中定義了許多接口,並且還給出了針對這些接口的調用邏輯,然而並未給出實現。開發者要做的就是定製一個實現類,以供核心類庫使用。

    java.sql.Driver 是最爲典型的 SPI 接口。java.sql.DriverManager 通過掃包的方式拿到指定的實現類,完成 DriverManager的初始化。

    而根據雙親委派模型,啓動類加載器 加載的 DriverManager 是不可能拿到 系統應用類加載器 加載的實現類 ,這似乎通過某種機制打破了雙親委派模型。通過 Class.forName 配合 classloader拿到。

  • 熱部署破壞雙親委派:

回到面試題,考點就是Tomcat的類加載機制:

img

  • commonLoader:Tomcat最基本的類加載器,加載路徑中的class可以被Tomcat容器本身以及各個Webapp訪問;

  • catalinaLoader:Tomcat容器私有的類加載器,加載路徑中的class對於Webapp不可見;

  • sharedLoader:各個Webapp共享的類加載器,加載路徑中的class對於所有Webapp可見,但是對於Tomcat容器不可見;

  • WebappClassLoader:各個Webapp私有的類加載器,加載路徑中的class只對當前Webapp可見;

tomcat 爲了實現隔離性,沒有遵守雙親委派,每個webappClassLoader加載自己的目錄下的class文件,不會傳遞給父類加載器。

JSP的類加載機制: tomcat 是支持JSP 熱部署的 ,jsp 文件其實也就是class文件,那麼如果修改了,但類名還是一樣,類加載器會直接取方法區中已經存在的,修改後的jsp是不會重新加載的。那麼怎麼辦呢?我們可以直接卸載掉這jsp文件的類加載器,所以你應該想到了,每個jsp文件對應一個唯一的類加載器,當一個jsp文件修改了,就直接卸載這個jsp類加載器。重新創建類加載器,重新加載jsp文件, 所以會需要一個JSP classLoader。

1.5.4 類初始化與實例初始化

經典面試題如下:

image-20200605100650006

運行結果如下:

image-20200605095448738

  • 三個考點:
    • 類的初始化(Son執行main方法):父類的初始化先於子類的初始化,在Son類中要執行main方法就要初始化Son類,初始化Son類就要先初始化Father類。類初始化順序:按照代碼書寫順序執行,在上面代碼中爲:靜態變量的初始化 > 靜態代碼塊的初始化。如果交換位置,則順序相反。
      • 初始化類的打印結果:(5)(1)(10)(6)
    • 實例的初始化(new Son()):父類的構造先於子類的構造,且注意父類方法的重寫問題。非靜態變量與非靜態代碼塊的執行順序與代碼書寫順序有關。
      • 子類的實例化:super() > (子類的非靜態變量初始化 與 子類的非靜態構造塊) > 子類的構造器。super指的是父類的構造的全部。
      • 父類的構造:super() > (父類的非靜態變量初始化 與 父類的非靜態構造塊) > 父類的構造器
      • 執行順序如下:
        • 父類Father的非靜態變量初始化,即test方法,由於重寫了方法,所以打印(9)
        • 父類的非靜態構造塊:(3)
        • 父類的構造器:(2)
        • 子類的非靜態變量初始化,test方法:(9)
        • 子類的非靜態構造器:(8)
        • 子類的構造器:(7)
    • 重寫方法
      • final方法,靜態方法,private方法不會被重寫

1.6 四種引用方式

image-20200603085747032

  • 強引用:在有引用的情況下,JVM寧願OOM也不願回收;
  • 軟引用(SoftReference):是否回收取決於內存是否足夠,不足時就回收;如mybatis高速緩存就用了一些軟引用。
  • 弱引用(WeakReference):不論內存是否充足都會被回收;
  • 虛引用:任何時候都可能被回收
    public static void main(String[] args) {
        Object obj1 = new Object();
//        Object obj2 = obj1;   //強引用
//        SoftReference<Object> obj3 = new SoftReference<>(obj1);  //軟引用
        WeakReference<Object> obj4 = new WeakReference<>(obj1);    //弱引用
        obj1 = null;  
        System.gc();  //此時只有obj4弱引用指向Object對象,會被回收。
//        System.out.println(obj2);
//        System.out.println(obj3.get());
        System.out.println(obj4.get());
    }

場景:應用需要讀取大量的本地圖片,若每次都從磁盤讀取性能不佳,若全部放內存又可能造成OOM,如何解決?

將讀取的圖片對象讀取爲軟引用或者弱引用,即將對象緩存到內存,當內存不足時進行垃圾回收;

使用一個Map<String,SoftReference> 存儲。

1.6.1 WeakHashMap

key不是強引用,當沒有引用指向key對象時,GC會進行回收。

public static void main(String[] args) {
    WeakHashMap<Integer, String> map = new WeakHashMap<>();
    Integer key = new Integer(1);
    map.put(key, "map");

    key = null;
    System.gc();
    System.out.println(map);
}

但是Value是強引用,不會進行垃圾回收;

public static void main(String[] args) {
    WeakHashMap<Integer, Object> map = new WeakHashMap<>();
    Object value = new Object();
    Integer key = new Integer(1);
    map.put(key, value);

    value = null;
    System.gc();
    System.out.println(map);
}

1.6.2 引用隊列ReferenceQueue

引用隊列的作用是,當軟(弱)引用進行垃圾回收時,將軟(弱)引用對象放到引用隊列中。

public static void main(String[] args) {
    Object o = new Object();
    ReferenceQueue<Object> referenceQueue = new ReferenceQueue<>();  //引用隊列
    WeakReference<Object> weakReference = new WeakReference<>(o, referenceQueue);  //弱引用對象

    System.out.println("############垃圾回收前##########");
    System.out.println(o);
    System.out.println(weakReference);
    System.out.println(weakReference.get());
    System.out.println(referenceQueue.poll());

    o = null;
    System.gc();

    System.out.println("############垃圾回收後##########");
    System.out.println(o);
    System.out.println(weakReference.get());
    System.out.println(referenceQueue.poll());
}

image-20200603095930029

引用隊列一般與虛引用配合使用,用於在finalize方法之中。

1.6.3 垃圾回收總結

從GCRoot開始可達性分析算法,若某對象被強引用,則即使OOM也不會去垃圾回收;

若某對象不被強引用,而被軟引用,則內存情況而定,內存不足則回收

若對象只被弱引用,則一定會被回收。

若某對象不可達,則回收。

(以上情況不考慮finalize方法的影響)

image-20200603100600459

1.7 虛擬機性能監控、故障處理

1.7.1 虛擬機故障處理工具

在JDK的bin目錄中,有java.exe、javac.exe,用於執行和編譯,除此之外還有許多其他命令用於打包、部署、調試、監控、運維等場景。

  • jps:虛擬機進程狀態工具。可以查看虛擬機中的所有進程。

    C:\Users\me>jps
    40448 RemoteMavenServer36
    38292 JseckillBackendApp
    38756 Launcher
    52292
    34076 RemoteJdbcServer
    54012 Jps
    
  • jstat:虛擬機統計信息監視工具

    jstat -gc 2764 250 20 :查詢進程2764的gc信息,每個250毫秒查詢一次,共查詢20次。

    jstat -class 3872 :查詢3872的類加載信息。

    -gcutil :監視內容與gc相同,輸出的是百分比信息。

    C:\Users\me>jstat -gcutil 38292
      S0     S1     E      O      M     CCS    YGC     YGCT    FGC    FGCT     GCT
    0.00  93.05  56.62  12.05  94.96  93.38      7    0.052     2    0.061    0.113
    
    s0爲空
    s1已用93%
    eden已用56%
    old老年代已用12%
    youngGC 7次  用時0.052秒
    fullGC 2次  用時0.06秒
    總共GC用時0.113秒
    
  • jinfo:Java配置信息工具

    • 作用:實時查看和調整JVM虛擬機各項參數。查看某個進程的某個JVM參數

    • E:\JavaProject\Kaoshi>jinfo -flag PrintGCDetails 18164
      -XX:-PrintGCDetails   # -xx:  表示該參數爲boolean類型;  -PrintGCDetails表示未開啓
      
    • image-20200602151614085

1.7.2 查看JVM的參數

查看當前進程的JVM參數

jinfo -falgs 進程pid

image-20200602152342048

  • Non-default VM flags: 表示系統默認參數
  • Command line: 表示自己設置的參數
-XX:InitialHeapSize=268435456  等價於-Xms  表示初始堆內存
-XX:MaxHeapSize=4280287232    等價於-Xmx  表示最大堆內存

查看JVM所有初始參數

java -XX:+PrintFlagsInitial

=表示JVM默認值 :=表示修改過的值

查看使用的垃圾回收器

java -XX:+PrintCommandLineFlags -version

image-20200602171725905

可以看到默認使用的是UseParallelGC,並行垃圾回收器。

1.7.3 基本常用參數

image-20200602172140776

# 堆棧大小
Xms:初始堆內存大小,默認物理內存1/64。等價於  -XX:InitialHeapSize
Xmx:最大堆內存大小,默認物理內存1/4。等價於    -XX:MaxHeapSize
Xss:運行時棧空間,默認初始值1024Kb(Linux系統下)。等價於XX:ThreadStackSize
# 年輕代和元空間大小
Xmn:#年輕代大小

#元空間大小(元空間不放在虛擬機中,而是使用本地內存),這個可以設置大一些
-XX:MetaspaceSize   
-XX:+PrintGCDetails   #打印出垃圾回收信息

測試參數:將最大堆內存和初始堆內存改爲10m,並加上-XX:+PrintGCDetails ,new一個10m的byte數組,將看到打印信息如下

image-20200602174641752

從打印信息中可以看到GC和FullGC前後內存大小的變化

-XX:+SurvivorRatio    #Eden區和S區的比例。默認值爲8,代表比例8:1:1

-XX:NewRatio    #年輕代和老年代在堆結構上的佔比。默認爲2,即1:2;

-XX:MaxTenuringThreshold    # 進入老年代的年齡閾值(Java8最大設置爲15,也是默認15)

1.8 常見JVM層面的報錯OOM

image-20200603103537864

1.8.1 StackOverflowError

在做遍歷有向圖的所有環時,出現過棧溢出;出現原因就是遞歸太深!!

在適當調大-Xss後還是不能解決,由於題目限制只找出環數量小於7的環,所以限制遞歸。

1.8.2 OOM java heap space

堆內存溢出,出現原因堆滿了

1.8.3 OOM GCOverHead

堆內存快滿了發生GC,但是GC清除了2%不到,多次這種情況就會報錯。因爲絕大多數時間都在GC

1.8.4 OOM:Direct buffer member

Java的元空間內存時分配在虛擬機之外的,不受java虛擬機內存限制。

寫INO程序時可以直接使用Native函數庫直接分配堆外內存,然後通過Java堆中的DirectByteBuffer對象去引用堆外內存進行操作。

可能Java堆內存空間空餘,而堆外內存已經滿了,則會造成OOM:Direct buffer member。

1.8.5 OOM:Metaspace

元空間內存溢出。

  • 元空間主要存放以下信息:
    • 虛擬機加載類信息
    • 常量池
    • 靜態變量
    • 編譯後的代碼

元空間雖然不受虛擬機內存限制,只受限於本地內存大小,但是初始參數設置爲21M,有可能會造成內存溢出。

2 併發編程

2.1 什麼是線程安全?

2.2 創建多線程的幾種方式

  • 直接繼承Thread類,重寫run方法。
  • 實現Runnable接口,重寫run方法,實現類作爲參數傳入Thread的構造方法。
  • 實現Callable接口,重寫call方法,將實現類包裝成一個FutureTask對象作爲參數傳入Thread的構造方法。優點是可以帶有返回值,缺點是相對複雜。
  • 使用線程池創建線程

2.3 併發機制底層實現

2.3.1 synchronized關鍵字

synchronized關鍵字的作用

用於爲Java對象、方法、代碼塊提供線程安全的操作,屬於排它的悲觀鎖,也屬於可重入鎖。

synchronized關鍵字可作用於代碼塊、方法、靜態方法。

  • 修飾實例方法:作用於當前對象實例加鎖。鎖的是this
  • 修飾靜態方法:給當前類加鎖。會作用於當前類的所有實例,因爲靜態成員不屬於任何一個實例,是類成員。鎖的是class
  • 修飾代碼塊:收到傳入一個鎖對象,鎖傳入的對象

**注意:**當線程A調用synchronized修飾的非靜態方法,線程b調用synchronized修飾的靜態方法是被允許的。因爲非靜態方法是使用實例對象的鎖,靜態方法是使用類的鎖。

synchronized的實現原理

在JVM中,對象是分成三部分存在的:對象頭、實例數據、對齊填充。對象頭主要結構是由Mark WordClass Metadata Address組成,其中Mark Word存儲對象的hashCode、鎖信息或分代年齡或GC標誌等信息,Mark Word結構如下:

img

當鎖標記爲10時爲重量級鎖,有一個指針指向一個Monitor對象,monitor裏面有一些數據結構如鎖競爭隊列ContentionList、競爭候選列表(EntryList)、等待集合WaitSet分別保存想要獲得鎖的線程、在鎖競爭隊列中有資格獲得鎖的線程、調用wait方法後阻塞的線程(三個集合中的線程都爲阻塞狀態)。monitor中還有個Owner標識位表示當前哪個線程獲得鎖,用於互斥。

  1. **修飾代碼塊時:**通過monitor(監視器)機制實現。首先通過monitorEnter和monitorExit進入和退出臨界區(monitorExit有兩個位置,一個是正常退出,一個是異常時退出)。然後讀取對象頭中Mark work的標誌位,判斷鎖的狀態,是偏向鎖還是輕量級鎖還是重量級鎖。若是偏向鎖,則判斷當前線程id是否等於記錄的線程id,相等則直接運行。若輕量級鎖,則通過自旋獲得鎖,自旋一定次數失敗後轉重量級鎖。若重量級鎖,則將線程加入monitor對象的的鎖競爭隊列裏。
  2. 修飾方法時:對方是否加鎖是通過一個標記位來判斷的。

synchronized如何保證可見性

  • 線程解鎖前,必須把共享變量的最新值刷新到主內存中
  • 線程獲得鎖時,將清空工作內存中共享變量的值,從而使用共享變量時需要從主內存中重新獲取最新的值

synchronized鎖的執行流程

  1. 首先是偏向鎖;如果一個線程獲得了鎖,那麼鎖就進入偏向模式,當該線程再次請求鎖時,無需再做任何同步操作,即獲取鎖的過程只需要檢查鎖標記位爲偏向鎖以及當前線程ID等於對象頭中的ThreadID即可,這樣就省去了大量有關鎖申請的操作。
  2. 輕量級鎖;當第二個線程申請鎖且沒有鎖競爭時,就轉爲輕量級鎖,使用CAS方式修改共享變量。
  3. **自旋鎖;**若輕量級鎖失敗,線程不會立即釋放cpu資源,而是進行自旋持續的獲取鎖。(注:這種方式明顯造成了不公平現象,最後申請的線程可能獲取鎖)
  4. **重量級鎖;**輕量級鎖失敗的線程放入鎖競爭隊列(阻塞態);

雖然synchronized有鎖升級的過程,但是這個過程基本不可逆,所以還是推薦使用Lock鎖

2.3.2 synchronized與Lock的區別聯繫

  1. synchronized是java的關鍵字,JVM實現,通過monitor實現;Lock鎖是JUC併發包中的實現,基於AQS模板重寫tryAcquire、tryRelease實現;
  2. synchronized可以修飾方法,而Lock只能用於代碼塊。
  3. synchronized會自動釋放鎖,而Lock需手動釋放。
  4. Lock可以是公平鎖也可以是非公平鎖,而synchronized只能是非公平鎖。
  5. synchronized不可中斷,除非拋出異常或者正常執行完畢;ReentrantLock可中斷,tryLock可以設置超時時間,lockInterruptibly()放入代碼塊中,調用interrupt()方法可以中斷。
  6. ReentrantLock可以綁定多個Condition條件用於實現分組喚醒需要喚醒的線程,實現精確喚醒。而synchronized要麼隨機喚醒一個要麼全部喚醒。
  7. 兩者都是可重入鎖。

2.3.3 volatile關鍵字

輕量級的同步機制,保證可見性和禁止指令重排保證有序性。不保證原子性。

  • volatile作用:保證可見性和禁止指令重排。Java把處理器的多級緩存抽象爲JMM,即線程私有的工作內存和線程公有的主內存,每個線程從主內存拷貝所需數據到自己的工作內存。volatile的作用就是當線程修改被volatile修飾的變量時,要立即寫入到主內存,並通知其他線程該變量已經修改,當線程讀取被volatile修飾的變量時,要立即到主內存中去讀取,保證了可見性。禁止指令重排來保證順序性。(單例模式的雙重校驗最好是加上volatile關鍵字,防止指令重排)
  • 可見性的實現:每個線程從主內存拷貝所需數據到自己的工作內存。volatile的作用就是當線程修改被volatile修飾的變量時,要立即寫入到主內存,並通知其他線程該變量已經修改,當線程讀取被volatile修飾的變量時,要立即到主內存中去讀取,保證了可見性。
  • **Volatile實現原理:**①JVM向處理器發送一條LOCK指令,表示將這個變量的緩存行的數據寫回到內存。②一個處理器的緩存行寫回到內存會導致其他處理器的緩存無效(緩存一致性協議:處理器會嗅探總線上的傳播數據來判斷自己緩存的數據是否過期)。

總線風暴?

因爲緩存一致性原理和CAS循環導致總線無效的交互太多,總線帶寬達到峯值。

爲什麼volatile不保證原子性

javap查看字節碼文件,如對一個volatile關鍵字修飾的變量n執行n++操作的指令是

getfield
iadd
putfield

指令被拆成了三個,那麼多線程對一個數據進行修改時,會出現寫覆蓋的情況。當某個線程執行到getfield指令之後被掛起,那麼該線程將獲取不到其他線程修改後的最新數據。

如何保證volatile的原子性

如有n++等操作可以使用atomic類如atomicInteger。(atomic原理CAS)

指令重排的原理

指令重排:在保證數據依賴性的情況下,編譯器優化可能對指令進行重排,指令重排在單線程情況下不會有任何問題。

在單線程情況下沒有依賴性的數據在多線程情況下就可能有依賴性,就會出現問題。所以volatile關鍵字會禁止指令重排。

在哪裏用到volatile

單例模式會用到。雙重校驗單例模式+volatile禁止指令重排

public class Main {
	private volatile Main instance = null;  //volatile關鍵字禁止指令重排,防止多線程情況下instance不爲null但是還未初始化完成的情況出現。
    private Main(){
        System.out.println("執行構造函數");
    }
    
    public Main getInstance(){
    //雙重檢驗
        if(instance == null){
            synchronized (Main.class){
                if(instance == null){
                    instance = new Main();
                }
            }
        }
        return instance;
    }
}

2.3.4 atomic包和CAS原理及問題

AtomicInteger中的自增操作詳解

AtomicInteger類的定義

public class AtomicInteger extends Number implements java.io.Serializable {
    private static final long serialVersionUID = 6214790243416807050L;
    private static final Unsafe unsafe = Unsafe.getUnsafe();
    private static final long valueOffset;

    static {
        try {
            valueOffset = unsafe.objectFieldOffset
                (AtomicInteger.class.getDeclaredField("value"));
        } catch (Exception ex) { throw new Error(ex); }
    }

    private volatile int value;

可見整個value使用volatile關鍵字修飾,保證可見性和禁止指令重排。

再看自增函數的實現

public final int getAndIncrement() {
        return unsafe.getAndAddInt(this, valueOffset, 1);
    }
    //this代表當前對象,valueoffset代表value這個值的內存偏移量,1代表要加1操作。

找到unsafe類的實現。do while循環自旋鎖實現

public final int getAndAddInt(Object var1, long var2, int var4) {
    int var5;  //聲明修改前的值
    do {
    	var5 = this.getIntVolatile(var1, var2);  //本地方法,根據對象和內存偏移量獲取值。
    } 
    //自旋鎖本地方法實現比較並交換。val1:當前對象  val2:位移偏移量  val5:修改前的值  val5+val4:修改後的值
    //根據val1和val2獲取當前值,與val5比較,若相等則將該值賦值爲val5+val4
    //compareAndSwapInt方法是利用cpu原語實現,不可中斷保證原子性。
    while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

    return var5;
}
  • 處理器解決原子操作:

    • 總線鎖:處理器提供一個LOCK #信號,當一個處理器在總線上輸出此信號時,其他處理器請求將被阻塞。(缺點:其他處理器也不能操作其他內存,開銷大)
    • 緩存鎖:通過緩存鎖定實現。
    • 處理器提供一系列指令實現總線鎖和緩存鎖兩個機制,CMPXCHG指令用於實現Java的CAS操作。
  • CAS的三大問題:

    • ABA問題。Atomic包中有一個類可以解決這個問題
    • 循環時間長時CPU開銷大(併發太高的情況下不適用)
    • 只能保證一個共享變量的原子操作;
  • ABA問題的解決:時間戳的原子引用

    AtomicReference類

    在atomic包中有基本的原子類實現,如果需要實現自己寫的User類的原子操作就需要使用AtomicReference加泛型實現。

    class User{
        String username;
        int age;
        User(String name, int age){
            this.username = name;
            this.age = age;
        }
    
        @Override
        public String toString() {
            return username+"  "+age;
        }
    }
    public class Main {
        public static void main(String[] args) {
            User u1 = new User("aa", 18);
            User u2 = new User("bb", 20);
            AtomicReference<User> userAtomicReference = new AtomicReference<User>(u1);
            System.out.println(userAtomicReference.compareAndSet(u1, u2));
            System.out.println(userAtomicReference.get());
        }
    }
    

加上修改版本號解決ABA問題

在JUC包中有一個AtomicStampedReference類已經可以實現帶版本號的原子引用。

new AtomicStampedReference<User>(u1,1);  //u1爲初始值,1爲初始版本號。以後每次修改版本號加一

原理:類中是一個Pair類作爲數據結構解決ABA問題,修改後版本後不一樣。

    private static class Pair<T> {
        final T reference;  //我們的數據
        final int stamp;   //版本號
        private Pair(T reference, int stamp) {
            this.reference = reference;
            this.stamp = stamp;
        }
        static <T> Pair<T> of(T reference, int stamp) {
            return new Pair<T>(reference, stamp);
        }
    }

    private volatile Pair<V> pair;   //最主要的數據,每次比較這個值

2.4 Java併發容器

2.4.1 List集合的線程安全

    public static void main(String[] args) {
        List<String> list = new ArrayList<String>();
        for(int i=0;i<10;++i){
            new Thread(()->{
                list.add(UUID.randomUUID().toString().substring(0,8));
                System.out.println(list);
            }).start();
        }
    }

當多線程對共享變量list添加數據時,會發生線程安全問題,上述代碼可能報錯如下:

Exception in thread "Thread-0" java.util.ConcurrentModificationException
//多線程修改異常

解決方案:

  • vector

  • List<String> list = Collections.synchronizedList(new ArrayList<>());
    
  • CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>();
    

CopyOnWriteArrayList詳解

將Object[]數組採用volatile關鍵字保證可見性和有序性,然後提供get和set方法。

private transient volatile Object[] array;

final Object[] getArray() {
    return array;
}

final void setArray(Object[] a) {
    array = a;
}

在add方法時copyonwrite,寫完後替換數組,這樣不影響讀操作。

public boolean add(E e) {
    final ReentrantLock lock = this.lock;
    lock.lock();
    try {
        Object[] elements = getArray();
        int len = elements.length;
        Object[] newElements = Arrays.copyOf(elements, len + 1);
        newElements[len] = e;
        setArray(newElements);
        return true;
    } finally {
        lock.unlock();
    }
}

讀操作時沒有任何的鎖限制,這樣讀寫分離的操作提高了併發。

2.4.2 Set集合類的線程安全

有一個CopyOnWriteArraySet可以實現,底層是利用CopyOnWriteArrayList實現,但是list是基於數組實現,在插入數據時會遍歷數組判斷數據是否存在,複雜度高,所以這個基本不會使用。

利用new ConcurrentHashMap<>()實現線程安全的set。map中的key存值,value統一存同一個final object。

2.4.3 Map的線程安全

HashMap結構及存在的問題

HashMap結構及1.7和1.8變化

HashMap在1.7的時候使用數組加鏈表實現,它的數據結點是一個內部類Entry結點,且採用頭插法作爲哈希衝突的鏈表插入。頭插法在多線程擴容中可能造成死循環的問題。Java在1.8之後做了一定的優化。

HashMap在1.8之後採用數組加鏈表加紅黑樹的結構實現,數據結點是一個Node結點。當有哈希衝突後會採用尾插入的方式加入鏈表,當鏈表長度大於8且數組長度大於64時會轉爲紅黑樹結構,當紅黑樹的結點數量小於等於6時重新轉成鏈表。

死循環如何產生?

多線程時擴容的死循環問題:若某個inde處的鏈表爲:A—B—null,若兩個線程同時對這裏擴容,線程一在獲取A這個Node後被掛起,線程二執行擴容並完成,此時新數組的鏈表爲B—A—null;線程一繼續執行,線程一種的ANode的next還是指向B的,而獲取BNode後B又是指向A的,從此ABABAB死循環。主要造成原因還是因爲JDK1.7中頭插法造成。JDK1.8之後採用尾插,不會有此問題。

多線程同時put數據造成數據丟失覆蓋:兩個線程同時對一個index添加數據,可能會有覆蓋丟失問題。

擴容機制

HashMap的初始數組大小是16,負載因子爲0.75,當數組的大小大於閾值(數組長度*負載因子)的時候會進行擴容,擴容時新建一個2倍大小的數組,然後根據哈希算法重新計算索引然後進行插入。

哈希衝突還有哪些解決方案?

  • 開放地址法:即在數組中通過規則找到其他位置存放
  • 鏈表法
  • 再哈希法:多個哈希算法,第一個衝突使用第二個,第二個衝突使用第三個…

ConcurrentHashMap整體結構

  • Node數組:存放鍵值對的數組,最重要的存放數據的數組。

    • transient volatile Node<K,V>[] table;
      
  • put方法:添加元素的主要方法。putVal(key, value)

    • 第一步:判斷key和value是否有null,若有則報錯。
    • 第二步:一個死循環包含整個添加元素的代碼塊。for (Node<K,V>[] tab = table;😉 {添加元素的代碼塊}
    • 第三步:判斷數組是否初始化,若沒有則執行初始化後回到第二步。
    • 第四步:根據hash值計算出索引,若索引處爲null,則CAS方式寫入,若失敗則回到步驟二,成功則跳出死循環;
    • 第五步:判斷是否正在擴容,若是就幫助擴容。
    • 第六步:2345步驟都不成立時,都對index處的Node加synchronized鎖。分別針對鏈表和紅黑樹兩種情況進行寫入。
    • 第七步:調用addCount函數,即對size+1;
    • 注:23456幾個步驟之間是if else關係,一次循環只會執行一個。
  • 如何對map大小進行計數(addCount)?

    • concurrentHashMap裏面有一個long baseCount和 CounterCell數組counterCells。CounterCell其實就是一個計數器功能的類(添加了一個註解解決僞共享帶來的性能消耗)。
    • 當put完元素後,首先判斷CounterCells這個數組是否爲null,若爲null,則嘗試採用CAS方式寫入,若數組不爲null或寫入失敗,則操作CounterCells數組。
    • 操作CounterCells數組方式:首先確認CounterCells不爲空後,通過該線程獲取一個隨機數然後與CounterCells數組的length-1進行位與操作,得到一個數組下標。若數組下標不爲null,則對下標的對象採用CAS進行計數器加一操作。若CAS失敗,調用fullAddCount方法;fullAddCount中再CAS一次,若失敗則將CounterCell數組擴容再CAS。
  • 如何獲取map的size

    就是將baseCount和CounterCell數組中的計數值。

    final long sumCount() {
            CounterCell[] as = counterCells; CounterCell a;
            long sum = baseCount;
            if (as != null) {
                for (int i = 0; i < as.length; ++i) {
                    if ((a = as[i]) != null)
                        sum += a.value;
                }
            }
            return sum;
        }
    
  • map擴容原理

    • 什麼時候擴容:①當put成功後鏈表長度大於8需要將其轉換成紅黑樹之前,判斷map的size是否小於64,若小於64則擴容(也就意味着數組size小於64的話不會存在紅黑樹); ②put成功後size大小大於數組大小的0.75倍。
    • 多線程擴容基本實現原理:每個線程都從後往前進行遍歷,一個index只能有一個線程進行擴容,若已經擴容完成或正在擴容,將該index頭結點設置爲Node的一個子類(ForwardingNode)。新數組每個index只能有一個線程進行操作,若不爲null,synchronized關鍵字鎖住鏈表頭Node。

2.4.4 CountDownLatch(倒計數)

用於一個或多個線程等待其他線程完成。

需求:在主線程中開啓多個線程去執行特定任務,在所有任務執行完成後主線程中打印所有任務執行完畢。

方案一:使用join()方法,讓開啓的線程加入join方法,意味着執行線程即main線程等待join線程執行完畢。這種方法會使多線程順序執行

    public static void main(String[] args) throws Exception {
        for(int i=0; i<10; ++i){
            final int temp_i = i;
            Thread thread = new Thread(() -> {
                try {
                    System.out.println("線程" + temp_i + "執行");
                    Thread.sleep(200);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            });
            thread.start();
            thread.join();
        }
        System.out.println("所有線程執行完畢");
    }

方案二:使用CountDownLatch實現。

    public static void main(String[] args) throws Exception {
        CountDownLatch countDownLatch = new CountDownLatch(10);
        for(int i=0; i<10; ++i){
            final int temp_i = i;
             new Thread(() -> {
                System.out.print(Thread.currentThread().getName());
                System.out.println("線程" + temp_i + "執行");
                countDownLatch.countDown();  //這裏countDownLatch中的值減一
            }).start();
        }
        countDownLatch.await();  //在這裏等待所有線程執行完畢,即值變爲0
        System.out.println("所有線程執行完畢");
    }

2.4.5 CyclicBarrier(計數器)

與CountDownLatch相反,CyclicBarrier需達到多少數據纔會執行某線程。一個做加法,一個做減法。

public static void main(String[] args) throws Exception {
    CyclicBarrier cyclicBarrier = new CyclicBarrier(5, ()->{
        System.out.println("所有線程執行完畢");
    });
    for(int i=0; i<10; ++i){
        final int temp_i = i;
         new Thread(() -> {
             try {
                 System.out.print(Thread.currentThread().getName());
                 System.out.println("線程" + temp_i + "執行");
                 cyclicBarrier.await();
             } catch (Exception e) {
                 e.printStackTrace();
             }
         }).start();
    }

}

2.4.6 Semaphore信號量

信號量主要用於兩個目的:多個共享資源的互斥使用;併發線程數控制;

使用semaphore控制併發數,只能允許5個線程去執行,當有線程執行完畢後其他線程才能進去執行。

public static void main(String[] args) throws Exception {
    Semaphore semaphore = new Semaphore(5);  //5代表只能有5個線程執行
    for(int i=0; i<10; ++i){
         new Thread(() -> {
             try {
                 semaphore.acquire();  //信號量加一
                 System.out.println(Thread.currentThread().getName()+"執行");
                 Thread.sleep(3000);
                 System.out.println(Thread.currentThread().getName()+"執行完畢");
             } catch (Exception e) {
                 e.printStackTrace();
             }finally {
                 semaphore.release();  //信號量減一
             }
         }).start();
    }
}

2.4.7 阻塞隊列

阻塞隊列的作用是:當隊列滿了,生產者線程將阻塞,當隊列爲空,消費者線程阻塞,避免去使用wait、notify的複雜操作。

阻塞隊列的接口:BlockingQueue(阻塞隊列)、BlockingDeque(雙端阻塞隊列)。

阻塞隊列的實現類:image-20200601154346454

基本原理:使用Lock鎖和condition實現鎖和阻塞喚醒線程,count爲隊列數量,當隊列滿了則阻塞offer等加入隊列的方法,當隊列爲空則阻塞poll等方法。

public class ArrayBlockingQueue<E> extends AbstractQueue<E>
        implements BlockingQueue<E>, java.io.Serializable {
    final Object[] items;

    int takeIndex;

    int putIndex;

    int count;

    final ReentrantLock lock;

    //兩個condition精準喚醒線程,而不是喚醒所有線程
    private final Condition notEmpty;

    private final Condition notFull;
}

1. ArrayBlockingQueue

三種往阻塞隊列中添加和刪除元素的方法:

  • 添加元素
    • add(): 往隊列中添加元素,若隊列滿了則拋出異常
    • offer(): 往隊列中添加元素,若隊列滿了則返回false,可以傳參指定等待時間
    • put():往隊列中添加元素,若隊列滿了則阻塞線程直到隊列不是滿的狀態時,加入隊列
  • 刪除元素
    • remove:隊列爲空時報錯
    • poll:隊列爲空時返回null,可以傳參指定等待時間
    • take:隊列爲空時阻塞線程
public static void main(String[] args) throws Exception {
    BlockingQueue<String> blockingQueue = new ArrayBlockingQueue<>(3);
    blockingQueue.add("a");
    blockingQueue.offer("b");
    blockingQueue.put("c");

    String remove = blockingQueue.remove();
    String poll = blockingQueue.poll();
    String take = blockingQueue.take();
}

2. SynchronousQueue

不存儲元素,可以理解成單個元素的阻塞隊列

public static void main(String[] args) throws Exception {
    BlockingQueue<Integer> queue = new SynchronousQueue<>();

//生產者線程
    new Thread(()->{
        for(int i=0;i<10;++i){
            try {
                System.out.println("生產者生產了"+i);
                queue.put(i);
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }).start();

//消費者線程
    new Thread(()->{
        for(int i=0;i<10;++i){
            try {
                System.out.println("生產者消費了"+i);
                queue.take();
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }).start();

}

2.4.2 AQS詳解

Java併發包(JUC)中提供了很多併發工具,這其中,很多我們耳熟能詳的併發工具,譬如ReentrangLock、Semaphore,它們的實現都用到了一個共同的基類–AbstractQueuedSynchronizer,簡稱AQS。

AQS是一個用來構建鎖和同步器的框架,使用AQS能簡單且高效地構造出應用廣泛的大量的同步器,比如我們提到的ReentrantLockSemaphore,其他的諸如ReentrantReadWriteLockSynchronousQueueFutureTask等等皆是基於AQS的。當然,我們自己也能利用AQS非常輕鬆容易地構造出符合我們自己需求的同步器。

AQS維護了一個state變量,來表示同步器的狀態,state可以稱爲AQS的靈魂,基於AQS實現的好多JUC工具,都是通過操作state來實現的,state爲0表示沒有任何線程持有鎖;state爲1表示某一個線程拿到了一次鎖,state爲n(n > 1),表示這個線程獲取了n次這把鎖,用來表達所謂的“可重入鎖”的概念。

  • AQS主要成員變量及數據結構

    • private transient volatile Node head; //隊列頭(隊列用雙向鏈表實現)
    • private transient volatile Node tail; //隊列尾
    • private volatile int state; //共享變量表示同步狀態
    • Node結構:雙向鏈表的節點。AQS將每一條請求共享資源的線程封裝成隊列的一個結點(Node),來實現鎖的分配。
  • 獨佔鎖和共享鎖

    • 獨佔,只有一個線程能獲得鎖,如ReentrantLock
    • 共享,多個線程可以同時獲得鎖,如Semaphore、CountDownLatch、ReadWriteLock,CyclicBarrier
  • 實現AQS需要重寫哪些方法?

    • isHeldExclusively():該線程是否正在獨佔資源。只有用到condition才需要去實現它,如ReenTrantLock。
    • tryAcquire(int):獨佔方式。嘗試獲取資源,成功則返回true,失敗則返回false。
    • tryRelease(int):獨佔方式。嘗試釋放資源,成功則返回true,失敗則返回false。
    • tryAcquireShared(int):共享方式。嘗試獲取資源。負數表示失敗;0表示成功,但沒有剩餘可用資源;正數表示成功,且有剩餘資源。
    • tryReleaseShared(int):共享方式。嘗試釋放資源,如果釋放後允許喚醒後續等待結點返回true,否則返回false。
    • 注意,以上方法不是抽象方法,而是空實現。這種方法在模板設計模式中又叫鉤子方法。
  • AQS執行流程圖

ReentrantLock

公平鎖和非公平鎖如何實現?

以ReentrantLock的實現爲例!

final boolean nonfairTryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    int c = getState();
    //判斷當前鎖是否被線程佔有
    if (c == 0) {  
        //若沒有線程擁有鎖,則直接CAS獲取鎖(非公平的體現)
        if (compareAndSetState(0, acquires)) {
            setExclusiveOwnerThread(current);
            return true;
        }
    }
    //判斷佔有鎖的線程是否是當前線程,若是當前線程也會獲取鎖(重入鎖的體現)
    else if (current == getExclusiveOwnerThread()) {
        int nextc = c + acquires;
        if (nextc < 0) // overflow
            throw new Error("Maximum lock count exceeded");
        setState(nextc);
        return true;
    }
    return false;
}
protected final boolean tryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    int c = getState();
    if (c == 0) {
        //若沒有線程擁有鎖,先判斷隊列中沒有線程等待獲取鎖時纔會直接CAS獲取鎖
        if (!hasQueuedPredecessors() &&
            compareAndSetState(0, acquires)) {
            setExclusiveOwnerThread(current);
            return true;
        }
    }
    else if (current == getExclusiveOwnerThread()) {
        int nextc = c + acquires;
        if (nextc < 0)
            throw new Error("Maximum lock count exceeded");
        setState(nextc);
        return true;
    }
    return false;
}

獨佔鎖和共享鎖如何實現

獨佔鎖的體現:ReentrantLock爲例,只有當stata==0 或者獲取當前鎖的線程爲當前線程時才能獲取鎖,即鎖只能有一個線程擁有。

共享鎖的體現:stata變量可以爲

2.4.3 ReenTrantLock詳解

ReenTrantLock類基於AQS實現,主要的成員變量private final Sync sync; Sync類爲繼承AQS抽象類並實現tryAcquire、tryRelease、isHeldExclusively方法的抽象子類,提供了lock等抽象方法。

sync又有非公平鎖和公平鎖兩個實現。兩者主要是在tryAcquire()方法的重寫上有不一樣的實現。

Condition獲取:final ConditionObject newCondition() { return new ConditionObject();}

ConditionObject類爲ReenTrantLock的內部類,主要作用就是實現await()/signal()功能,相當於synchronized的wait和notify,用於掛起線程和喚醒線程。

2.5 鎖

2.5.1 公平鎖和非公平鎖

2.5.2 可重入鎖

可重入鎖是指任意線程在獲取到鎖之後能夠再次獲取該鎖而不會被阻塞。

2.5.3 自旋鎖

2.5.4 讀寫鎖

讀寫鎖是一對鎖,一個讀鎖和一個寫鎖,通過分離讀寫鎖使得併發性比一般的排它鎖有了很大提升。

讀寫鎖在同一時刻可以允許多個線程訪問,但是在寫線程訪問時,所有讀寫線程均被阻塞。

class MyCache{
    private volatile Map<String, Object> map = new HashMap<>();
    private ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();  
    private ReentrantReadWriteLock.WriteLock writeLock = rwLock.writeLock(); //寫鎖
    private ReentrantReadWriteLock.ReadLock readLock = rwLock.readLock();  //讀鎖


    public void put(String key,Object value){
        writeLock.lock();  //加寫鎖
        try {
            map.put(key,value);
        }catch (Exception e){
            e.printStackTrace();
        }finally {
            writeLock.unlock();
        }
    }

    public Object get(String key){
        readLock.lock();
        try {
            return map.get(key);
        }finally {
            readLock.unlock();
        }
    }
}

2.6 生產者消費者的幾種實現

2.6.1 lock鎖實現

class SharaData{
    private int num = 0;
    private Lock lock = new ReentrantLock();
    private Condition condition = lock.newCondition();
    //生產
    public void increment(){
        lock.lock();
        try {
            //必須是while判斷,否則可能出現虛假喚醒。在多個生產者消費者情況下會出現問題
            while (num!=0){  
                condition.await();
            }
            num++;
            System.out.println(Thread.currentThread().getName()+"生產");
            condition.signalAll();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }finally {
            lock.unlock();
        }
    }

    public void decrement(){
        lock.lock();
        try {
            while(num==0){
                condition.await();
            }
            num--;
            System.out.println(Thread.currentThread().getName()+"消費");
            condition.signalAll();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }finally {
            lock.unlock();
        }
    }
}

public class Main {
    public static void main(String[] args) throws Exception {
        SharaData sharaData = new SharaData();

        new Thread(()->{
            for (int i = 0; i < 10; i++) {
                sharaData.increment();
            }
        }).start();

        new Thread(()->{
            for (int i = 0; i < 10; i++) {
                sharaData.decrement();
            }
        }).start();
    }
}

2.6.2 阻塞隊列實現

class ShareData{
    private volatile Boolean flag = true;
    private BlockingQueue<String> blockingQueue = null;
    private AtomicInteger num = new AtomicInteger(0);

    public ShareData(BlockingQueue blockingQueue){
        this.blockingQueue = blockingQueue;
    }

    //生產者
    public void product() throws InterruptedException {
        String data = null;
        boolean offer;
        while(flag){
            data = "產品"+num;
            num.incrementAndGet();
            offer = blockingQueue.offer(data, 1, TimeUnit.SECONDS);
            if(offer){
                System.out.println("生產"+data);
            }else {
                System.out.println("生產"+data+"失敗");
            }
            Thread.sleep(500);
        }
    }

    //消費者
    public void consume() throws InterruptedException {
        String poll = null;
        while(flag){
            poll = blockingQueue.poll(2, TimeUnit.SECONDS);
            if(null == poll || "".equals(poll)){
                System.out.println("消費失敗");
            }else {
                System.out.println("消費"+poll);
            }
            Thread.sleep(700);
        }
    }

    //停止生產和消費
    public void stop(){
        flag = false;
    }
}
public static void main(String[] args) {
    ArrayBlockingQueue<String> queue = new ArrayBlockingQueue<>(3);
    ShareData shareData = new ShareData(queue);

    new Thread(()->{
        try {
            shareData.product();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }).start();

    new Thread(()->{
        try {
            shareData.consume();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }).start();

    try {
        Thread.sleep(5000);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    shareData.stop();
    System.out.println("停止生產和消費");
}

2.7 線程池詳解

2.7.1 ThreadPoolExecutor創建線程池

  • 構造函數及其參數意義
public ThreadPoolExecutor(int corePoolSize,   //核心線程數
        int maximumPoolSize,   //最大線程數
        long keepAliveTime,    //非核心線程空閒存活時間
        TimeUnit unit,         //時間單位
        BlockingQueue<Runnable> workQueue,   //阻塞隊列
        ThreadFactory threadFactory,   //線程工廠,用於創建新線程
        RejectedExecutionHandler handler)   //當達到最大線程數時的執行策略
  • 執行流程

img

  • 最大線程數的執行策略有哪些?
    • 拋出異常(默認)、什麼都不做、拋棄最老的任務來執行、使用當前線程(如main)來執行。

自定義一個線程池:

ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(
        5,  //核心線程數
        10,  //最大線程數
        1,  //非核心線程存活時間
        TimeUnit.SECONDS,
        new LinkedBlockingDeque<Runnable>(20),  //阻塞隊列,注意一定要指定大小。
        Executors.defaultThreadFactory(),     //採用默認的線程創建工廠
        new ThreadPoolExecutor.AbortPolicy()  //拒絕策略
);

如何合理設置線程池參數:

  1. 看一下自己的cpu核心數Runtime類查看。
  2. 如果是CPU密集型:cpu核心數+1
  3. IO密集型:2*cpu核心數

2.7.2 使用Executors創建線程池

  • Executors.newCachedThreadPool();創建一個可緩存的線程池,適用於負載較輕的場景。

    • public static ExecutorService newCachedThreadPool() {
      	return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
      								60L, TimeUnit.SECONDS,
                                      new SynchronousQueue<Runnable>());
      }
      
      核心線程數:0   最大線程數:Integer.MAX_VALUE    非核心線程空閒存活時間:60秒
      阻塞隊列爲:SynchronousQueue
      
    • SynchronousQueue是一個不存儲元素的阻塞隊列,負責把生產者線程處理的數據直接傳遞給消費者線程。

    • 線程池可以無限創建線程處理任務,處理完後60後銷燬線程。適用於負載較輕的場景。

  • Executors.newFixedThreadPool(10);創建固定數目線程的線程池,用於負載過重。

    • public static ExecutorService newFixedThreadPool(int nThreads) {
              return new ThreadPoolExecutor(nThreads, nThreads,
                                            0L, TimeUnit.MILLISECONDS,
                                            new LinkedBlockingQueue<Runnable>());
      }
      
      核心線程數=最大線程數,非核心線程空閒存活時間=0,阻塞隊列爲LinkedBlockingQueue
      
    • LinkedBlockingQueue 是一個用鏈表實現的有界阻塞隊列。此隊列的默認和最大長度爲 Integer.MAX_VALUE。顯然這個線程池沒有對LinkedBlockQueue傳參,也就是隊列的界爲MAXVALUE。

    • 線程池中只能有nThreads個線程的線程池。若無限的創建線程,則會導致OOM。

  • newSingleThreadExecutor();創建只有一個線程的線程池,適用於線程之間順序執行。

    • public static ExecutorService newSingleThreadExecutor() {
          return new FinalizableDelegatedExecutorService
             (new ThreadPoolExecutor(1, 1,
                                     0L, TimeUnit.MILLISECONDS,
                                     new LinkedBlockingQueue<Runnable>()));
      }
      
    • 核心線程數=最大線程數=1,則請求線程的任務全部放在隊列裏,可以順序執行;

    • 無限創建線程會導致OOM

  • newScheduledThreadPool(10):適用於執行延時或者週期性任務。

    • public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) {
      	return new ScheduledThreadPoolExecutor(corePoolSize);
      }
      
    • public class ScheduledThreadPoolExecutor extends ThreadPoolExecutor
              implements ScheduledExecutorService {
              
          public ScheduledThreadPoolExecutor(int corePoolSize) {
              super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS,
                    new DelayedWorkQueue());
          }       
      }
      
  • 核心線程通過傳參,最大線程數爲MAXVALUE;

    • DelayedWorkQueue爲一個內部類,也是繼承AbstractQueue並實現BlockingQueue接口,屬於ArrayBlockQueue和LinkedBlockQueue的兄弟類,基於DelayedQueue實現。

阿里巴巴Java開發手冊明確規定,不允許使用excutors創建線程池,因爲底層的LinkedBlockingQueue的最大堆積爲Integer.MAX_VALUE,如果任務堆積過多會造成OOM。

2.7.3 阻塞隊列串講

  • 爲什麼要使用阻塞隊列?

    阻塞隊列滿時會阻塞線程往裏面加入元素,爲空時會阻塞線程取元素。

    阻塞隊列可以保證任務隊列中沒有任務時阻塞獲取任務的線程,使得線程進入wait狀態,釋放cpu資源。
    當隊列中有任務時才喚醒對應線程從隊列中取出消息進行執行。

  • 阻塞隊列的實現類?

    • ArrayBlockingQueue:ArrayBlockingQueue 是一個有界的阻塞隊列,其內部實現是將對象放到一個數組裏。有界也就意味着,它不能夠存儲無限多數量的元素。它有一個同一時間能夠存儲元素數量的上限。你可以在對其初始化的時候設定這個上限,但之後就無法對這個上限進行修改了(譯者注:因爲它是基於數組實現的,也就具有數組的特性:一旦初始化,大小就無法修改)
    • DelayQueue:DelayQueue 對元素進行持有直到一個特定的延遲到期。注入其中的元素必須實現 java.util.concurrent.Delayed 接口。
    • LinkedBlockingQueue:LinkedBlockingQueue 內部以一個鏈式結構(鏈接節點)對其元素進行存儲。如果需要的話,這一鏈式結構可以選擇一個上限。如果沒有定義上限,將使用 Integer.MAX_VALUE 作爲上限。
    • PriorityBlockingQueue:PriorityBlockingQueue 是一個無界的併發隊列。它使用了和類 java.util.PriorityQueue 一樣的排序規則。你無法向這個隊列中插入 null 值。所有插入到 PriorityBlockingQueue 的元素必須實現 java.lang.Comparable 接口。因此該隊列中元素的排序就取決於你自己的 Comparable 實現。
    • SynchronousQueue:SynchronousQueue 是一個特殊的隊列,它的內部同時只能夠容納單個元素。如果該隊列已有一元素的話,試圖向隊列中插入一個新元素的線程將會阻塞,直到另一個線程將該元素從隊列中抽走。同樣,如果該隊列爲空,試圖向隊列中抽取一個元素的線程將會阻塞,直到另一個線程向隊列中插入了一條新的元素。據此,把這個類稱作一個隊列顯然是誇大其詞了。它更多像是一個匯合點。
  • 阻塞隊列實現原理?

    ReentranLock + Condition 實現隊列的阻塞,ReentranLock 是鎖,Condition是條件狀態,通過等待/通知機制,來實現線程之間的通信。

2.8 多線程中死鎖

模擬兩個線程相互獲取鎖造成死鎖狀態

class Sisuo implements Runnable{
    private String lockA;
    private String lockB;

    Sisuo(String lockA, String lockB){
        this.lockA = lockA;
        this.lockB = lockB;
    }

    @Override
    public void run() {
        synchronized (lockA){
            System.out.println(Thread.currentThread().getName()+"擁有鎖:"+lockA);
            System.out.println(Thread.currentThread().getName()+"嘗試獲取鎖:"+lockB);
            synchronized (lockB){
                System.out.println(",,,,");
            }
        }
    }
}

public class Main {

    public static void main(String[] args) {

        ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(
                2,
                5,
                1,
                TimeUnit.SECONDS,
                new LinkedBlockingDeque<Runnable>(3),
                Executors.defaultThreadFactory(),
                new ThreadPoolExecutor.AbortPolicy()
        );
        String lock1 = "LockA";
        String lock2 = "LockB";

        threadPoolExecutor.execute(new Sisuo(lock1, lock2));
        threadPoolExecutor.execute(new Sisuo(lock2, lock1));
    }
}

image-20200602135043219

出現程序執行不下去的情況。

死鎖如何排查?

通過jps指令找對對應進程的PID :

jps

image-20200602135258654

查看對應進程的堆棧信息

jstack 2888

image-20200602135540453

2.9 ThreadLocal類

每個Thread內部都有一個Map(私有靜態內部類ThreadLocalMap結構),我們每當定義一個ThreadLocal變量,就相當於往這個Map裏放了一個key,key爲一個ThreadLocal<?>類型,並定義一個對應的value。每當使用ThreadLocal,就相當於map.get(key),尋找其對應的value。

ThreadLocal的內存泄漏問題:ThreadLocalMap中key採用弱引用,可在刪除key時垃圾回收,而value採用的是強引用,不會進行垃圾回收;value會隨着線程銷燬而消亡,內存泄漏一般出現在線程池時。避免方法就是:調用remove方法;

ThreadLocal在使用線程池時,在使用完ThreadLocal後記得清除ThreadLocalMap。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章