JVM
Java內存管理
1.運行時數據區域劃分
-
堆(Heap)溢出異常
Java Heap是Java虛擬機所管理的內存中最大的一塊。Java堆是被所有線程共享的一塊內存區域,在虛擬機啟動時創建。此區域的唯一目的就是存放對象實例,從內存回收的角度來看,由于現在收集器基本都采用分代收集算法,所以Java堆中還可以細分為:新生代和老年代
-
棧(Stack)溢出異常
-
JVM方法棧
每個方法在執行的同時都會創建一個棧幀用于存儲局部變量表,操作數棧,動態鏈接,方法出口等信息。 每一個方法從調用直至執行完成的過程,就對應著一個棧幀在虛擬機棧中入棧到出棧的過程,當方法被調用則入棧,一旦完成調用則出棧。所有的棧幀都出棧后,線程就結束了。
-
本地方法棧(非Java代碼接口)
Native Method Stack與虛擬機棧的作用非常相似,區別是:虛擬機棧為虛擬機執行Java方法(也就是字節碼)服務,而本地方法棧則為虛擬機使用到的Native方法。
-
-
方法區
Method Area是各個線程共享內存區域,用于存儲已被虛擬機加載的類信息,常量,靜態變量,即時編譯器編譯后的代碼等數據。 在HtoSpot虛擬機中該區域叫永久代 。
-
運行時常量池
Runtime Constant Pool是方法區的一部分。Class文件中除了有類的版本,字段,方法,接口等描述信息,還有一項信息是常量池,用于存放編譯期生成的各種字面量和符號引用,這部分內容將在類加載后進入方法區的運行時常量池中存放。 另外一個重要特征是具備動態性 ,運行期間也可能將新的常量放入池中,這種特性被開發人員利用比較多的就是String類的intern()方法。
-
-
程序計數器(CS:IP)
程序計數器(Program Counter Register)是一塊較小的內存空間,可以看做當前線程所執行的字節碼的行號指示器。
垃圾回收期和內存分配策略
垃圾收集(Garbage Collection,GC)需要思考以下三個問題:
- 哪些內存需要回收
- 什么時候回收
- 如何回收
死亡對象確認
引用計數算法
對象添加一個引用計數器,沒有一個地方引用它時,計數器就加一;當引用失效,計數器減一;計數器為零的對象即不可能再使被使用的。但是此算法無法解決循環引用問題,JVM并沒有采用。
可達性分析算法
此算法的核心思想:通過一系列稱為“GC Roots”的對象作為起始點,從這些節點開始向下搜索,搜索走過的路徑稱為“引用鏈”,當一個對象到 GC Roots 沒有任何的引用鏈相連時(從 GC Roots 到這個對象不可達)時,證明此對象不可用。
對象Object5 —Object7之間雖然彼此還有聯系,但是它們到 GC Roots 是不可達的,因此它們會被判定為可回收對象。
Java中的引用概念
如果reference類型中的存儲的數值代表的是另一塊內存的起始地址,就成為這塊內存代表著一個引用。此種定義太狹隘,JDK1.2進行了擴充。
- 強引用:
Object obj = new Object();
- 軟引用:
SoftReference
,如果內存空間足夠,垃圾回收器就不會回收它,如果內存空間不足了,就會回收這些對象的內存。 - 弱引用:在垃圾回收器線程掃描它所管轄的內存區域的過程中,一旦發現了只具有弱引用的對象,不管當前內存空間足夠與否,都會回收它的內存。
- 虛引用: 如果一個對象僅持有虛引用,那么它就和沒有任何引用一樣,在任何時候都可能被垃圾回收。虛引用主要用來跟蹤對象被垃圾回收的活動。
垃圾收集算法
標記-清除算法
標記-清除算法分為兩個階段:標記階段和清除階段。標記階段的任務是標記出所有需要被回收的對象,清除階段就是回收被標記的對象所占用的空間。
標記-清除算法實現起來比較容易,但是有一個比較嚴重的問題就是容易產生內存碎片,碎片太多可能會導致后續過程中需要為大對象分配空間時無法找到足夠的空間而提前觸發新的一次垃圾收集動作。
復制算法
它將可用內存按容量劃分為大小相等的兩塊,每次只使用其中的一塊。當這一塊的內存用完了,就將還存活著的對象復制到另外一塊上面,然后再把已使用的內存空間一次清理掉,這樣一來就不容易出現內存碎片的問題。具體過程如下圖所示:
雖然實現簡單,運行高效且不容易產生內存碎片,但是卻對內存空間的使用做出了高昂的代價,因為能夠使用的內存縮減到原來的一半。
標記-整理算法
為了解決Copying算法的缺陷,充分利用內存空間,提出了Mark-Compact算法。該算法標記階段和Mark-Sweep一樣,但是在完成標記之后,它不是直接清理可回收對象,而是將存活對象都向一端移動(美團面試題目,記住是完成標記之后,先不清理,先移動再清理回收對象),然后清理掉端邊界以外的內存(美團問過). 標記-整理算法是在標記-清除算法的基礎上,又進行了對象的移動,因此成本更高,但是卻解決了內存碎片的問題。
分代收集算法
分代收集算法是目前大部分JVM的垃圾收集器采用的算法。它的核心思想是根據對象存活的生命周期將內存劃分為若干個不同的區域。 一般情況下將堆區劃分為老年代(Tenured Generation)和新生代(Young Generation)。 老年代的特點是每次垃圾收集時只有少量對象需要被回收,而新生代的特點是每次垃圾回收時都有大量的對象需要被回收,那么就可以根據不同代的特點采取最適合的收集算法。
大部分垃圾收集器對于新生代都采取Copying算法,因為新生代中每次垃圾回收都要回收大部分對象,也就是說需要復制的操作次數較少,但是實際中并不是按照1:1的比例來劃分新生代的空間的,一般來說是將新生代劃分為一塊較大的Eden空間和兩塊較小的Survivor空間(一般為8:1:1),每次使用Eden空間和其中的一塊Survivor空間,當進行回收時,將Eden和Survivor中還存活的對象復制到另一塊Survivor空間中,然后清理掉Eden和剛才使用過的Survivor空間。
而由于老年代的特點是每次回收都只回收少量對象,一般使用的是Mark-Compact算法。
年輕代(Young Generation)的回收算法
- 所有新生成的對象首先都是放在年輕代的。年輕代的目標就是盡可能快速的收集掉那些生命周期短的對象。
- 新生代內存按照8:1:1的比例分為一個eden區和兩個survivor(survivor0,survivor1)區。一個Eden區,兩個 Survivor區(一般而言)。大部分對象在Eden區中生成。回收時先將eden區存活對象復制到一個survivor0區,然后清空eden區,當這個survivor0區也存放滿了時,則將eden區和survivor0區存活對象復制到另一個survivor1區,然后清空eden和這個survivor0區,此時survivor0區是空的,然后將survivor0區和survivor1區交換,即保持survivor1區為空(美團面試,問的太細,為啥保持survivor1為空,答案:為了讓eden和survivor0 交換存活對象), 如此往復。當Eden沒有足夠空間的時候就會 觸發jvm發起一次Minor GC
- 當survivor1區不足以存放 eden和survivor0的存活對象時,就將存活對象直接存放到老年代。若是老年代也滿了就會觸發一次Full GC(Major GC),也就是新生代、老年代都進行回收。
- 新生代發生的GC也叫做Minor GC,MinorGC發生頻率比較高(不一定等Eden區滿了才觸發)。
年老代(Old Generation)的回收算法
- 在年輕代中經歷了N次垃圾回收后仍然存活的對象,就會被放到年老代中。因此,可以認為年老代中存放的都是一些生命周期較長的對象。
- 當老年代內存滿時觸發Major GC即Full GC,Full GC發生頻率比較低,老年代對象存活時間比較長,存活率標記高。
新生代和老年代的區別
所謂的新生代和老年代是針對于分代收集算法來定義的,新生代又分為Eden和Survivor兩個區。 數據會首先分配到Eden區 當中(當然也有特殊情況,如果是大對象那么會直接放入到老年代(大對象是指需要大量連續內存空間的java對象)。),當Eden沒有足夠空間的時候就會 觸發jvm發起一次Minor GC。如果對象經過一次Minor GC還存活,并且又能被Survivor空間接受,那么將被移動到Survivor空 間當中。并將其年齡設為1,對象在Survivor每熬過一次Minor GC,年齡就加1,當年齡達到一定的程度(默認為15)時,就會被晉升到老年代 中了,當然晉升老年代的年齡是可以設置的。如果老年代滿了就執行:Full GC 因為不經常執行,因此采用了 Mark-Compact算法清理
GC是什么時候觸發的
由于對象進行了分代處理,因此垃圾回收區域、時間也不一樣。GC有兩種類型:Scavenge GC和Full GC
- Scavenge GC: 一般情況下,當新對象生成,并且在Eden申請空間失敗時,就會觸發Scavenge GC,對Eden區域進行GC,清除非存活對象,并且把尚且存活的對象移動到Survivor區。然后整理Survivor的兩個區。這種方式的GC是對年輕代的Eden區進行,不會影響到年老代。
- Full GC: 對整個堆進行整理,包括Young、Tenured和Perm。Full GC因為需要對整個堆進行回收,所以比Scavenge GC要慢,因此應該盡可能減少Full GC的次數。在對JVM調優的過程中,很大一部分工作就是對于Full GC的調節。 有如下原因可能導致Full GC :
- 年老代(Tenured)被寫滿;
- 持久代(Perm)被寫滿
- System.gc()被顯示調用
- 上一次GC之后Heap的各域分配策略動態變化
虛擬機類加載機制
虛擬機把描述類的數據從Class文件加載到內存,并對數據進行校驗、轉換解析以及初始化,最終形成可以被虛擬機直接使用的Java類型,這就是虛擬機的類加載機制。
什么時候需要加載類?
- 使用new關鍵字實例化對象、讀取或者設置一個靜態字段
- 使用java.lang.reflect包的方法對類進行反射調用時,如果類沒有進行過初始化,那就需先觸發其初始化
- 當初始化一個類時,如果發現其父類尚未初始化,則需先觸發其父類的初始化
- 當虛擬機啟動時,用戶需要指定一個要執行的主類(包含main()方法的那個類),虛擬機會先初始化這個主類
- 當使用動態語言支持時,如果一個java.lang.invoke.MethodHandle實例最后的解析結果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且這個方法句柄所對應的類沒有進行過初始化,則需要先進行初始化
加載類過程
-
加載:“加載”是類加載過程的一個階段
通過類的全限名獲取此類的二進制字節流;將字節流轉換為方法區的運行時數據結構;生成
java.lang.class
對象,作為方法區這個類的各種數據的訪問入口。 -
驗證
確保Class文件的字節流中包含的信息符合當前虛擬機的要求,并且不會危害虛擬機自身的安全
-
準備
類變量分配內存,并設置類變量初始值的階段,這些變量使用的內存,都在方法區中進行分配。
public static int value = 123 // 初始值是0,而不是123,這個時候沒有執行任何Java方法
要注意,一般是數據類型的零值,但是還有特殊情況,比如被final修飾,存在ConstantValue屬性,會在準備階段就會賦值。
-
解析
解析階段就是將常量池中的符號引用替換成直接引用了。這里需要延伸一下知識,Java為了實現其動態性,在編譯的時候都是通過符號引用來占位子,在這個階段就要對應具體的引用對象了
-
初始化
初始化是類加載的最后一步,會真正執行類中定義的Java代碼。準備階段,變量賦予了零值,初始化階段要賦予真正的值
類加載器
通過類的全限名獲取此類的二進制字節流的實現動作,成為“類加載器”
類與類加載器
類加載器雖然只用于實現類的加載動作,但它在Java中還有更重要的作用。比較兩個類是否相等,只有在兩個類是由同一個類加載器加載的前提下才有意義。同一個class文件被同一個虛擬機的不用類加載器加載,那這兩個類一定不相等。
從代碼分包的角度來看,類加載器分為以下三種:
- 啟動類加載器(Bootstrap ClassLoader):這個放在java_home\lib目錄中,或者-Xbootclasspath參數所指定的路徑,被虛擬機識別(固定名稱)。 這個加載器無法被程序直接引用,使用直接返回Null
- 擴展類加載器(Extension ClassLoader):這個加載器由sun.misc.Launcher$ExtClassLoader實現,負責加載java_home\lib\ext目錄中的,或者被java.ext.dirs系統變量指定的路徑所在類庫,開發者可以直接使用擴展類加載器。
- 應用程序類加載器(Application ClassLoader):這個類加載器由
sun.misc.Launcher$AppClassLoader
實現。這個類加載器是ClassLoader
中的getSystemClassLoader
方法的返回值,所以也稱為系統類加載器。 負責加載用戶ClassPath上所指定的類庫,可以直接使用,如果代碼里面沒有定義自己的加載器,就會默認使用這個。
Java中的類是存在繼承關系的,所有的類都繼承自java.lang.Object
。如果用戶自己再編寫一個稱為java.lang.Object
的類,并放在程序的Classpath中,那系統中會出現多個不同的Object類,Java中的類不再是 instance of Object, java類型體系中最基礎的行為也無法保證 。所以需要一個模型來限制類加載關系,如果類已經加載就不會再次加載。
雙親委派模型
如果一個類收到了類加載的請求,它首先不會自己去加載這個類,而是把請求委派給父類加載器去完成,每一個層次的類加載器都是如此。因此所有的加載請求最終都應該傳送到頂層的啟動類加載器中,只有當父加載器反饋自己無法完成此加載請求(它的搜索范圍中沒有找到所需的類)時,子加載器才會嘗試自己去加載。
雙親委派模型要求除了頂層的啟動類加載器之外,其他的類加載器都應該有自己的父類加載器。這里類加載器之間的父子關系一般不會以繼承(Inheritance)的關系來實現,而是都使用組合(Composition)關系來復用父加載器的代碼。
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// First, check if the class has already been loaded
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
}
if (c == null) {
// If still not found, then invoke findClass in order
// to find the class.
long t1 = System.nanoTime();
c = findClass(name);
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
破壞雙親委派模型
雙親委派模型不是一個強制性的約束模型,為了開發利用Java強大的動態性,一般通過破壞雙親委派模型實現。
第一次是因為兼容老版本的JDK,這個模型在1.2版本才出現,但是ClassLoader在1.0就存在了。添加了一個protected方法findClass()
,之前版本繼承ClassLoader唯一目的就是重寫loadClass
方法,因為虛擬機會去調用loadClassInternal
方法,這個方法就是執行loadClass()
。1.2之后就不提倡重寫loadClass
了,而是寫findClass
方法,因為loadClass
的基本邏輯已經寫好,父類加載失敗,就會調用自己的findClass進行加載,這樣保證新寫出來的類加載器是符合雙親委派模型的。
第二次破壞是因為雙親委派模型自身的缺陷,作為頂層的類通常是被底層類調用的,但是如果頂層的基本類調用了底層的用戶類就麻煩大了。頂層的類加載器并不認識用戶的類,這樣如何去加載這個類呢?典型的例子就是JNDI服務,其目的是對資源進行集中管理和查找,需要調用應用程序的classpath的代碼。 為了解決這個問題,設計了一個線程上下文加載器Thread Context ClassLoader。這個類加載器可以通過Thread類的setContextClassLoader方法進行設置,如果創建線程的時候沒有設置,將會從父線程中繼承一個,全局范圍都沒有設置,默認就是應用程序類加載器。設置了這個,就可以在父加載器中獲取子加載器,讓其進行加載類了。
第三次被破壞是由于追求動態性導致的。比如:代碼熱替換,模塊熱部署等。 深入探索 Java 熱部署
Java內存模型與線程
由于計算機的存儲設備與處理器的運算速度有著幾個數據量的差異,現代計算機系統不得不加入一層或者多層讀寫速度盡可能接近處理器運行速度的高速緩存(Cache)來作為內存與處理器之間的緩沖:將運算需要的數據復制到緩存中,讓處理器運算能夠快速開始,運算結束后將結果從緩存中同步刷新回主存,保證處理器運算不需要等待緩慢的內存讀寫。
高速緩存的存在解決了處理器運算快而內存讀寫慢的矛盾,但是也引入另一個復雜的問題:緩存一致性(Cache Coherence)
Java內存模型
Java虛擬機試圖定義一種Java內存模型來屏蔽掉各種硬件和操作系統的內存訪問差異,以實現讓Java程序在各種平臺下都能達到一致的內存訪問效果。 解決由于多線程通過共享內存進行通信時,存在的緩存數據不一致、編譯器會對代碼指令重排序、處理器會對代碼亂序執行等帶來的問題。目的是保證并發編程場景中的原子性、可見性和有序性。
JMM規定所有的變量存儲在主內存中(Main Memory),每條線程有自己的工作內存(Working Memory),保存該線程使用的變量的主內存副本(對于基本數據直接復制,對于對象可能復制對象的引用、對象中某個線程訪問到的字段而不是直接復制該對象),線程對變量的所有操作(讀寫)都是在工作內存中進行,而不能直接讀寫主內存中的數據。
內存間交互
物理機高速緩存和主內存之間的交互有協議,同樣的,java內存中線程的工作內存和主內存的交互是由java虛擬機定義了如下的8種操作來完成的,每種操作必須是原子性:
lock(鎖定):作用于主內存的變量,一個變量在同一時間只能一個線程鎖定,該操作表示這條線成獨占這個變量
unlock(解鎖):作用于主內存的變量,表示這個變量的狀態由處于鎖定狀態被釋放,這樣其他線程才能對該變量進行鎖定
read(讀取):作用于主內存變量,表示把一個主內存變量的值傳輸到線程的工作內存,以便隨后的load操作使用
load(載入):作用于線程的工作內存的變量,表示把read操作從主內存中讀取的變量的值放到工作內存的變量副本中(副本是相對于主內存的變量而言的)
use(使用):作用于線程的工作內存中的變量,表示把工作內存中的一個變量的值傳遞給執行引擎,每當虛擬機遇到一個需要使用變量的值的字節碼指令時就會執行該操作
assign(賦值):作用于線程的工作內存的變量,表示把執行引擎返回的結果賦值給工作內存中的變量,每當虛擬機遇到一個給變量賦值的字節碼指令時就會執行該操作
store(存儲):作用于線程的工作內存中的變量,把工作內存中的一個變量的值傳遞給主內存,以便隨后的write操作使用
write(寫入):作用于主內存的變量,把store操作從工作內存中得到的變量的值放入主內存的變量中.
如果要把一個變量從主內存傳輸到工作內存,那就要順序的執行read和load操作,如果要把一個變量從工作內存回寫到主內存,就要順序的執行store和write操作。對于普通變量,虛擬機只是要求順序的執行,并沒有要求連續的執行,所以如下也是正確的。對于兩個線程,分別從主內存中讀取變量a和b的值,并不一樣要read a; load a; read b; load b; 也會出現如下執行順序:read a; read b; load b; load a;
volatile
volatile是一種輕量且在有限的條件下線程安全技術,它保證修飾的變量的可見性和有序性,但非原子性。
保證變量的內存可見性: 當一個線程對
volatile
修飾的變量進行寫操作時,JMM會立即把該線程對應的本地內存中的共享變量的值刷新到主內存;當一個線程對volatile
修飾的變量進行讀操作時,JMM會把立即該線程對應的本地內存置為無效,從主內存中讀取共享變量的值。-
禁止volatile變量與普通變量重排序:(JSR133提出,Java 5 開始才有這個“增強的volatile內存語義”,提供了一種比鎖更輕量級的線程間通信機制。如單寫多讀模型。
對于有volatile修飾的變量,在其匯編代碼中可以發現多執行了一個
lock addl $0x0, (%esp)
操作,其作用相當于一個內存屏障(指令重排序時不能把后面的指令重排序到內存屏障之前的位置),它的作用是使得本CPU的Cache寫入了內存,且會引起別的CPU無效化其Cache,可讓前面volatile變量的修改對其他CPU立即可見。
happens-before
一方面,程序員需要JMM提供一個強的內存模型來編寫代碼;另一方面,編譯器和處理器希望JMM對它們的束縛越少越好,這樣它們就可以最可能多的做優化來提高性能,希望的是一個弱的內存模型。 JMM考慮了這兩種需求,并且找到了平衡點,對編譯器和處理器來說,只要不改變程序的執行結果(單線程程序和正確同步了的多線程程序),編譯器和處理器怎么優化都行。 而對于程序員,JMM提供了happens-before規則(JSR-133規范),滿足了程序員的需求——簡單易懂,并且提供了足夠強的內存可見性保證。換言之,程序員只要遵循happens-before規則,那他寫的程序就能保證在JMM中具有強的內存可見性。
happens-before關系的定義如下:
- 如果一個操作happens-before另一個操作,那么第一個操作的執行結果將對第二個操作可見,而且第一個操作的執行順序排在第二個操作之前。
- 兩個操作之間存在happens-before關系,并不意味著Java平臺的具體實現必須要按照happens-before關系指定的順序來執行。如果重排序之后的執行結果,與按happens-before關系來執行的結果一致,那么JMM也允許這樣的重排序。
在Java中,有以下天然的happens-before關系:
- 程序順序規則:一個線程中的每一個操作,happens-before于該線程中的任意后續操作。
- 監視器鎖規則:對一個鎖的解鎖,happens-before于隨后對這個鎖的加鎖。
- volatile變量規則:對一個volatile域的寫,happens-before于任意后續對這個volatile域的讀。
- 傳遞性:如果A happens-before B,且B happens-before C,那么A happens-before C。
- start規則:如果線程A執行操作ThreadB.start()啟動線程B,那么A線程的ThreadB.start()操作happens-before于線程B中的任意操作、
- join規則:如果線程A執行操作ThreadB.join()并成功返回,那么線程B中的任意操作happens-before于線程A從ThreadB.join()操作成功返回。
案例
private int value = 0;
public void setValue(int value){
this.value = value;
}
public int getValue(){
return this.value
}
此時存在線程A和B,線程A時間上先調用了setValue然后線程B調用了同一個對象的getValue,那么B線程收到的返回值是什么?
由于沒有滿足happens-before原則,即使線程A在時間上先執行,實際上執行的時候無法確定A\B間的先后順序,也無法確定線程B 能夠獲取的值是什么。
我們可以通過synchronized方法套用監視器鎖規則或者使用volatile修飾value套用volatile關鍵字場景以實現先行發生關系。
線程安全與鎖優化
線程安全
當多個線程訪問一個對象時,如果不用考慮這些線程在運行時環境下的調度和交替執行,也不需要額外的同步,或者在調用方進行任何其他的協調操作,調用這個對象的行為都可以獲得正確的結果,那這個對象是線程安全的。
實現方法
互斥同步
同步指的是在多個線程并發訪問共享數據時,保證共享數據同一時刻只能被一個線程使用。互斥是實現同步的手段,臨界區、互斥量和信號量都是主要的實現互斥的方式。
在Java中,最基本的互斥手段就是synchronized關鍵字,synchronized在編譯后會在同步代碼塊的前后分別形成monitorenter和monitorexit兩個字節碼指令,這兩個指令都需要一個reference類型的參數來指明要鎖定和解鎖的對象。在執行monitoenter過程中,首先嘗試獲取對象的鎖。如果這個對象沒有被鎖定,或者當前線程已經擁有了這個對象鎖,把鎖的計數器加一,相應地,在執行monitorexit指令時會將鎖計數器減一,當計數器為0時,鎖就被釋放。如果獲取鎖對象失敗,那么當前線程則會阻塞等待,直到鎖對象被另一個線程釋放為止。
ReentrantLock 和 Synchronized類似,一個表現為API 層面上的互斥(lock 和 unlock 方法),一個表現為原生語法層面上的互斥。ReentrantLock 比 Synchronized增加了一些高級功能。
- 等待中斷:持有鎖的線程長期不釋放鎖(執行時間長的同步塊)的時候,正在等待的線程可以選擇放棄等待,做其他事情。
- 實現公平鎖:ReentrantLock 默認是非公平的,通過構造參數可設置為公平鎖,Synchronized是非公平的
- 鎖可以綁定多個條件,ReentrantLock對象可以同時綁定多個Condition對象,而在synchronized中,鎖對象的notify()和wait()方法可以實現一個隱含的條件,如果和多于一個的條件關聯時,必須加鎖,而RenentrantLock多次調用newCondition()即可。
非阻塞同步
互斥同步是一種悲觀的同步策略,認為只要不去做正確的同步措施,那就肯定會出現問題,無論共享數據是否發生沖突它都要進行加鎖、用戶核心態轉換、維護鎖計數器等操作。非阻塞同步是一種樂觀同步策略,基于一種沖突檢測的策略,就是先進行操作,如果沒有沖突,沒有其他線程爭搶共享數據,那就操作成功,如果存在沖突,則進行其他補救措施(例如常用的不斷的重試,直到成功為止)。
依靠“硬件指令集的發展”,尤其是比較并交換(CAS)指令的發展,樂觀并發策略得以執行。CAS指令需要三個操作數,分別是內存位置(變量的內存地址)V,舊的預期值A和新值B。CAS指令執行時,當且僅當V符合預期值A 時,處理器會用新值B更新V,否則它就不執行,這個過程是一個原子操作。在JDK1.5之后,Java程序才可以使用CAS操作,該操作有sun.misc.Unsafe
類里面的campareAndSwapint()
和compareAndSwapLong
等方法包裝而成。
這里我們以AtomicInteger
類的getAndAdd(int delta)
方法為例,來看看Java是如何實現原子操作的。
// 真正依賴的實現
private static final jdk.internal.misc.Unsafe U = jdk.internal.misc.Unsafe.getUnsafe();
// 參數中offset,用于獲取某個字段相對Java對象的“起始地址”的偏移量
private static final long VALUE = U.objectFieldOffset(AtomicInteger.class, "value");
public final int getAndAdd(int delta) {
return U.getAndAddInt(this, VALUE, delta);
}
// Unsafe中被調用的實現
@HotSpotIntrinsicCandidate
public final int getAndAddInt(Object o, long offset, int delta) {
int v;
do {
// 獲取原值,然后在原值基礎上更新,更新失敗更新原值后再次嘗試更新
// 一直更新失敗一直嘗試更新
v = getIntVolatile(o, offset);
} while (!weakCompareAndSetInt(o, offset, v, v + delta));
return v;
}
// 循環條件
public final boolean weakCompareAndSetInt(Object o, long offset,
int expected,
int x) {
return compareAndSetInt(o, offset, expected, x);
}
// 最終還是native方法
public final native boolean compareAndSetInt(Object o, long offset,
int expected,
int x);
看到它是在不斷嘗試去用CAS更新。如果更新失敗,就繼續重試。那為什么要把獲取“舊值”v的操作放到循環體內呢?其實這也很好理解。前面我們說了,CAS如果舊值V不等于預期值E,它就會更新失敗。說明舊的值發生了變化。那我們當然需要返回的是被其他線程改變之后的舊值了,因此放在了do循環體內。
CAS帶來的三大問題
-
ABA問題
所謂ABA問題,就是一個值原來是A,變成了B,又變回了A。這個時候使用CAS是檢查不出變化的,但實際上卻被更新了兩次。
ABA問題的解決思路是在變量前面追加上版本號或者時間戳。從JDK 1.5開始,JDK的atomic包里提供了一個類
AtomicStampedReference
類來解決ABA問題。這個類的compareAndSet
方法的作用是首先檢查當前引用是否等于預期引用,并且檢查當前標志是否等于預期標志,如果二者都相等,才使用CAS設置為新的值和標志。 -
循環時間長開銷大
CAS多與自旋結合。如果自旋CAS長時間不成功,會占用大量的CPU資源。
解決思路是讓JVM支持處理器提供的pause指令。
pause指令能讓自旋失敗時cpu睡眠一小段時間再繼續自旋,從而使得讀操作的頻率低很多,為解決內存順序沖突而導致的CPU流水線重排的代價也會小很多。
-
只能保證一個共享變量的原子操作
- 使用JDK 1.5開始就提供的
AtomicReference
類保證對象之間的原子性,把多個變量放到一個對象里面進行CAS操作; - 使用鎖。鎖內的臨界區代碼可以保證只有當前線程能操作。
- 使用JDK 1.5開始就提供的
無同步方案-ThreadLocal
ThreadLocal叫做線程變量,意思是ThreadLocal中填充的變量屬于當前線程,該變量對其他線程而言是隔離的。ThreadLocal為變量在每個線程中都創建了一個副本,那么每個線程可以訪問自己內部的副本變量。
使用場景:變量在線程之間無需可見共享,為線程獨有;變量創建和使用在不同的方法里且不想過度重復書寫形參
原理:
public void set(T value) {
//(1)獲取當前線程(調用者線程)
Thread t = Thread.currentThread();
//(2)以當前線程作為key值,去查找對應的線程變量,找到對應的map
ThreadLocalMap map = getMap(t);
//(3)如果map不為null,就直接添加本地變量,key為當前線程,值為添加的本地變量值
if (map != null)
map.set(this, value);
//(4)如果map為null,說明首次添加,需要首先創建出對應的map
else
createMap(t, value);
}
public T get() {
//(1)獲取當前線程
Thread t = Thread.currentThread();
//(2)獲取當前線程的threadLocals變量
ThreadLocalMap map = getMap(t);
//(3)如果threadLocals變量不為null,就可以在map中查找到本地變量的值
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
//(4)執行到此處,threadLocals為null,調用該更改初始化當前線程的threadLocals變量
return setInitialValue();
}
private T setInitialValue() {
//protected T initialValue() {return null;}
T value = initialValue();
//獲取當前線程
Thread t = Thread.currentThread();
//以當前線程作為key值,去查找對應的線程變量,找到對應的map
ThreadLocalMap map = getMap(t);
//如果map不為null,就直接添加本地變量,key為當前線程,值為添加的本地變量值
if (map != null)
map.set(this, value);
//如果map為null,說明首次添加,需要首先創建出對應的map
else
createMap(t, value);
return value;
}
鎖優化
https://hitomeng.gitee.io/java-notes/2020/04/19/Java%E5%B9%B6%E5%8F%91%E7%BC%96%E7%A8%8B/#more