1. 程式人生 > >Java並發編程-synchronized

Java並發編程-synchronized

代碼執行 方法 存儲 範圍 對象的引用 locked AI ear 合並

  這是Java並發編程學習的第一篇,最早在2013年時便勵誌要把JAVA的並發編程好好學習一下,那個時候才工作一年。後來由於各種各樣的原因,未能學習起來,5年時間過去,技術止步不前,學到的都是業務領域知識,站在我個人發展角度,我希望在技術,主要是JAVA後端技術領域再往前走一步,所以在這裏記錄下我學習的點點滴滴,同時將代碼記錄在Github上。並發編程的文章主要是記錄我的學習過程,應該會有很多錯誤的地方,也會有很多沒有深度的內容,歡迎大家糾正。

1、為什麽會用到synchronized

  Java語言的一個高級特性就是支持多線程,線程在操作系統的實現上,可以看成是輕量級的進程,同一進程中的線程都將共享進程的內存空間,所以Java的多線程在共享JVM的內存空間。JVM的內存空間主要分為:程序計數器、虛擬機棧、本地方法棧、堆、方法區和運行時常量池。

  在這些內存空間中,我們重點關註棧和堆,這裏的棧包括了虛擬機棧和本地方法棧(實際上很多JVM的實現就是兩者合二為一)。在JVM中,每個線程有自己的棧內存,其他線程無法訪問該棧的內存數據,棧中的數據也僅僅限於基本類型和對象引用。在JVM中,所有的線程共享堆內存,而堆上則不保存基本類型和對象引用,只包含對象。除了重點關註的棧和堆,還有一部分數據存放在方法區,比如類的靜態變量,方法區和棧類似,只能存放基本類型和對象引用,不同的是方法區是所有線程共享的。

  如上所述,JVM的堆(對象信息)和方法區(靜態變量)是所有線程共享的,那麽多線程如果訪問同一個對象或者靜態變量時,就需要進行管控,否則可能出現無法預測的結果。為了協調多線程對數據的共享訪問,JVM給每個對象和類都分配了一個鎖,同一時刻,只有一個線程可以擁有這個對象或者類的鎖。JVM中鎖是通過監視器(Monitors)來實現的,監視器的主要功能就是監視一段代碼,確保在同一時間只有一個線程在執行。每個監視器都和一個對象關聯,當線程執行到監視器的監視代碼第一條指令時,線程獲取到該對象的鎖定,直到代碼執行完成,執行完成後,線程釋放該對象的鎖。

  synchronized就是Java語言中一種內置的Monitor實現,我們在多線程的實現上就會用到synchronized來對類和對象進行行為的管控。

2、synchronized用法及背後原理

  主要提供了2種方式來協調多線程的同步訪問數據:同步方法和同步代碼塊。代碼如下:

public class SynchronizedPrincipleTest {
        public synchronized void f1() {
            System.out.println("synchronized void f1()");
        }

        
public void f2() { synchronized(this) { System.out.println("synchronized(this)"); } } public static void main(String[] args) { SynchronizedPrincipleTest test = new SynchronizedPrincipleTest(); test.f1(); test.f2(); } }

f1就是同步方法,f2就是同步代碼塊。這兩種實現在背後有什麽差異呢?我們可以先javac編譯,然後再通過javap反編譯來看下。

技術分享圖片

                 圖一

從圖一可以看出同步方法JVM是通過ACC_SYNCHRONIZED來實現的,同步代碼塊JVM是通過monitorenter、monitorexit來實現的。在JVM規範中,同步方法通過ACC_SYNCHRONIZED標記對方法隱式加鎖,同步代碼塊則顯示的通過monitorenter和monitorexit進行加鎖,當線程執行到monitorenter時,先獲得鎖,然後執行方法,執行到monitorexit再釋放鎖。

3、JVM Monitor背後實現

  查閱網上各種資料及翻閱openJDK代碼。

synchronized uses a locking mechanism that is built into the JVM and MONITORENTER / MONITOREXIT bytecode instructions. So the underlying implementation is JVM-specific (that is why it is called intrinsic lock) and AFAIK usually (subject to change) uses a pretty conservative strategy: once lock is "inflated" after threads collision on lock acquiring, synchronized begin to use OS-based locking ("fat locking") instead of fast CAS ("thin locking") and do not "like" to use CAS again soon (even if contention is gone).
…………
PS: youre pretty curious and I highly recommend you to look at HotSpot sources to go deeper (and to find out exact implementations for specific platform version). It may really help. Starting point is somewhere here: http://hg.openjdk.java.net/jdk8/jdk8/hotspot/file/87ee5ee27509/src/share/vm/runtime/synchronizer.cpp

上述表達的大致意思是同步在字節碼層面就是通過monitorenter和monitorexit來實現的,可以理解為這種實現是JVM規範,一旦線程在鎖獲取時出現沖突,鎖就會膨脹,這種膨脹是基於系統的實現(胖鎖)來替代CAS實現(瘦鎖)。最後給出JVM底層C++代碼的鏈接。

查看該類代碼,有如下註釋:

// This is full version of monitor enter and exit. I choose not
// to use enter() and exit() in order to make sure user be ware
// of the performance and semantics difference. They are normally
// used by ObjectLocker etc. The interpreter and compiler use
// assembly copies of these routines. Please keep them synchornized.

知道這個類是所有monitor enter and exit的實現,其中方法jni_enter和jni_exit就是heavy weight monitor的實現。再看這個jni_enter方法的實現,調用了inflate方法,返回ObjectMonitor指針。

再看類ObjectMonitor,給出了具體enter和exit方法的實現。

// The ObjectMonitor class is used to implement JavaMonitors which have
// transformed from the lightweight structure of the thread stack to a
// heavy weight lock due to contention

與wiki.openjdk描述一致:

Synchronization affects multiple parts of the JVM: The structure of the object header is defined in the classes oopDesc and markOopDesc, the code for thin locks is integrated in the interpreter and compilers, and the class ObjectMonitor represents inflated locks.

4、鎖優化

  從上述中的註釋我們可以看出synchronized是一種heavy weight lock,Brian Goetz在IBM developerworks的論文《Java theory and practice:More flexible, scalable locking in JDK 5.0》也比較了synchronized和ReentrantLock兩者的性能,所以在JDK1.6之後對鎖做了很多優化,主要有:自旋鎖和自適應自旋、鎖消除、鎖粗化、輕量級鎖和偏向鎖

4.1、自旋鎖和自適應自旋

  線程執行到synchronized同步方法或者代碼塊時,如果另外的線程已經獲取到該對象的鎖,該線程就只能等待,被操作系統掛起,直到另外的線程處理完成,該線程才能恢復執行。線程的掛起和恢復都需要從應用的用戶態切換到操作系統的內核態才能完成,這種操作給系統也帶來了性能上很大的影響。同時虛擬機的研發團隊註意到在很多應用上,共享數據上的鎖只會持續很短的時間,為了這點時間去掛起和恢復線程是不值得的。如果物理機上有多個CPU,那麽就可以同時讓多個線程並行執行,就可以在JVM的層面上讓請求鎖的線程“稍等一下”,但不放棄CPU的執行時間,看下持有鎖的線程是否會很快就釋放鎖。為了讓線程等待,就讓線程去執行一個忙循環(自旋),這種技術就是所謂的自旋鎖。舉個例子,我覺得有點像,工作中我正在回復郵件,這個動作其實很快就能做完,這個時候另外一個人給我打電話,我接通了,但是我告訴他等我一下,我回復完這封郵件,咱們再交流。這個過程,回復郵件占用了我這個資源,另外一個人要和我通話,如果完全阻塞,我就不接電話直接完成回復郵件再接通電話,但是其實回復郵件只要一會兒時間,所以我接通了電話,然後對方一直在線上等占用著我的時間(他自己也一直在等我,暫時不做別的事情,忙循環),等我回復郵件完成,立馬切換過來電話交流。在這個例子裏面,其實我們也可以看出如果對方一直等待,如果我郵件遲遲未回復完成,對方也是一直在耗著等待且不能做其他的工作,這也是性能的浪費,這個就是自旋鎖的缺點,所以自旋不能沒有限制,要能做到“智能”的判斷,這個就是自適應自旋的概念,自適應自旋時間不固定,是由前一次鎖的自旋時間和鎖的擁有者的狀態決定的,如果之前自旋成功獲取過鎖,則此次自旋等待的時間可以長一點,否則省略自旋,避免資源浪費。同樣,拿這個例子來說,如果此次對方在電話那頭就等了我一小段時間,我就和對方溝通了,那麽下次碰到同樣的情況時,對方會繼續在電話耐心的等待,否則對方就直接掛電話了(因為喪失了“信任”)。

4.2、鎖消除

  鎖消除指的是JVM在JIT運行時,對一些代碼要求同步而實際該段代碼在數據共享上不可能出現競爭的鎖而進行消除操作。比如代碼(EascapeTest類)如下:

private static String concatString(String s1,String s2) {
     return s1 + s2;
}

public static void main(String[] args) {
     EascapeTest eascapeTest = new EascapeTest();
     eascapeTest.concatString("a","b");
}

  方法concatString,是我們在實際開發中經常會用到的一個字符串拼接的實現,從源代碼層面上看是沒有任何同步操作的。但實際JVM在運行這個方法時會優化為StringBuilder的append()操作,這個我們可以通過javap反編譯來驗證,見圖二。

技術分享圖片

                                圖二

JVM采用StringBuilder來實現,是通過同步方法來實現的,但是concatString方法中StringBuilder的對象的作用域是被限制在這個方法內部,只要做到EascapeTest被安全的發布,那麽concatString方法中StringBuilder的所有對象都不會發生逸出,也就是線程安全的,對應的鎖可以被消除,從而提升性能。

4.3、鎖粗化

  鎖粗化是合並使用相同鎖定對象的相鄰同步塊的過程。看如下代碼:

public void addStooges(Vector v) {
     v.add("Moe");
     v.add("Larry");
     v.add("Curly");
}

addStooges方法中的一系列操作都是在不斷的對同一個對象進行反復的加鎖和解鎖,即使沒有線程競爭,如此頻繁的同步操作也是很損耗性能的,JVM如果探測到這樣的操作,就會對同步範圍進行粗化,把鎖放在第一個操作加上,然後在最後一個操作中釋放,這樣就只加了一次鎖但是達到了同樣的效果。

4.4、輕量級鎖和偏向鎖

  學習輕量級鎖和偏向鎖之前,咱們得先來學習下Java對象模型和對象頭,有了這個基礎才好來理解這兩個鎖。

4.4.1、Java對象模型及對象頭

  Java虛擬機有很多對應的實現版本,本小節的內容基於HotSpot虛擬機來學習下Java對象模型和對象頭。HotSpot的底層是用C++實現的,這個可以通過下載OpenJDK源代碼來看即可確認。眾所周知,C++和Java都是面向對象的語言,那麽Java的對象在虛擬機的表示,最簡單的一種實現就是在C++層面上實現一個與之對應的類,然而HotSpot並沒有這麽實現,而是專門設計一套OOP-Klass二分模型。

OOP:ordinary object pointer,普通對象指針,用來描述對象實例信息。

Klass:Java類的C++對等體,用來描述Java類。

之所以會這麽設計,其中一個理由就是設計者不想讓每一個對象都有一個C++虛函數指針(取自klass.hpp註釋)。

// One reason for the oop/klass dichotomy in the implementation is
// that we don‘t want a C++ vtbl pointer in every object.  ……….

對於OOP對象來說,主要職能是表示對象的實例信息,沒必要持有任何虛函數;而在描述Java類的Klass對象中含有VTBL(繼承自klass_vtbl),那麽Klass就可以根據Java對象的實際類型進行C++的分發,這樣OOP對象只需要通過相應的Klass便可以找到所有的虛函數,就避免了給每一個對象都分配一個C++的虛函數指針。

Klass向JVM提供了2個功能:

實現語言層面的Java類;

實現Java對象的分發功能;

這2個功能在一個C++類中就能實現,前者在基類Klass中已經實現,而後者就由Klass的子類提供虛函數實現(取自klass.hpp註釋)。

// A Klass provides:
//  1: language level class object (method dictionary etc.)
//  2: provide vm dispatch behavior for the object
// Both functions are combined into one C++ class.

OOP框架和Klass框架的關系可以在oopsHierarchy.hpp文件中體現,JDK1.7和JDK1.8由於內存空間的變化,所以oopsHierarchy.hpp的實現也不一樣,這裏以OpenJDK1.7來描述OOP-Klass。

typedef class oopDesc*                            oop;//oops基類
typedef class   instanceOopDesc*            instanceOop; //Java類實例
typedef class   methodOopDesc*                    methodOop; //Java方法
typedef class   constMethodOopDesc*            constMethodOop; //Java方法不變信息
typedef class   methodDataOopDesc*            methodDataOop; //性能信息數據結構
typedef class   arrayOopDesc*                    arrayOop; //數組oops基類
typedef class     objArrayOopDesc*            objArrayOop; //數組oops對象
typedef class     typeArrayOopDesc*            typeArrayOop;
typedef class   constantPoolOopDesc*            constantPoolOop;
typedef class   constantPoolCacheOopDesc*   constantPoolCacheOop;
typedef class   klassOopDesc*                    klassOop; //與Java類對等的C++類
typedef class   markOopDesc*                    markOop; //Java對象頭
typedef class   compiledICHolderOopDesc*    compiledICHolderOop;

在Java程序運行的過程中,每創建一個Java對象,在JVM內部就會相應的創建一個OOP對象來表示該Java對象。OOP對象的基類就是oopDesc,它的代碼實現如下:

 volatile markOop  _mark;
  union _metadata {
    wideKlassOop    _klass;
    narrowOop       _compressed_klass;
  } _metadata;

在虛擬機內部,通過instanceOopDesc來表示一個Java對象。對象在內部中的布局可以分為兩個連續的部分:instanceOopDesc和實例數據。instanceOopDesc又被稱為對象頭,繼承自oopDesc,看instanceOop.hpp的實現,未新增新的數據結構,和oopDesc一樣,包含如下2部分信息:

_mark:markOop類型,存儲對象運行時記錄信息,主要有HashCode、分代年齡、鎖狀態標記、線程持有的鎖、偏向線程ID等,占用內存和虛擬機位長一致,如果是32位虛擬機則為32位,在64位虛擬機則為64位;

_metadata:聯合體,指向描述類型的Klass對象的指針,因為Klass對象包含了實例對象所屬類型的元數據,故被稱為元數據指針。虛擬機運行時將頻繁使用這個指針定位到方法區的類信息。

到此基本描述了Java的對象頭,但是這只是一部分,還有一部分是Klass,合起來才是完整的對象模型。那麽Klass在對象模型中是如何體現的呢?實際上,HotSpot是這樣處理的,通過為每一個已加載的Java類創建一個instanceKlass對象,用來在JVM層表示Java類。來看看instanceKlass的數據結構。

// Method array.方法列表
objArrayOop     _methods;
// Int array containing the original order of method in the class file (for
// JVMTI).方法順序
typeArrayOop    _method_ordering;
// Interface (klassOops) this class declares locally to implement.實現接口
objArrayOop     _local_interfaces;
// Interface (klassOops) this class implements transitively.繼承接口
objArrayOop     _transitive_interfaces;
…………
typeArrayOop    _fields;
// Constant pool for this class.
constantPoolOop _constants;
// Class loader used to load this class, NULL if VM loader used.
oop             _class_loader;
// Protection domain.
oop             _protection_domain;

可以看到一個類該有的內容,instanceKlass基本都有了。

綜上,Java對象在JVM中的表示是這樣的,對象的實例(instanceOopDesc)存儲在堆上,對象的元數據(instanceKlass)存儲在方法區,對象的引用存儲在棧上。如下圖:

技術分享圖片

            圖三 取自參考資料

4.4.2、輕量級鎖

  輕量級鎖並不是用來替代重量級鎖的,它的本意是在沒有多線程競爭的前提下,減少重量級鎖使用操作系統互斥量產生的性能消耗。上面我們已經介紹了Java對象頭(instanceOopDesc),數據結構如下:

enum { age_bits                 = 4,
         lock_bits                = 2,
         biased_lock_bits         = 1,
         max_hash_bits            = BitsPerWord - age_bits - lock_bits - biased_lock_bits,
         hash_bits                = max_hash_bits > 31 ? 31 : max_hash_bits,
         cms_bits                 = LP64_ONLY(1) NOT_LP64(0),
         epoch_bits               = 2
  };

此結構和網絡數據包的報文頭結構非常像。

技術分享圖片

                    圖四 取自參考資料

簡單介紹完對象頭的構造後,回到輕量級鎖的的執行過程上。在代碼進入同步塊的時候,如果此時同步對象未被鎖定(Unlocked,01),JVM會在當前線程的棧幀中建立一個名為鎖記錄(Lock Record)的空間,用於存儲鎖對象目前的Mark Word的拷貝(Displace Mark Word)。然後,JVM將使用CAS操作嘗試將該對象的Mark Word指向Lock Record的指針,如果這個操作成功,則線程就擁有了該對象的鎖,並且對象的Mark Word狀態變成(Light-weight locked,00),表示輕量級鎖定。如果這個操作失敗的話,JVM會先檢查對象的Mark Word是不是已經指向當前棧幀,如果是則直接進入同步代碼塊執行,否則說明對象已經被其他線程搶占了。如果同時有兩個線程以上爭用同一個鎖,那輕量級鎖不再有效,要膨脹為重量級鎖,鎖標記狀態更新為“10”(Heavy-weight locked),後面等待鎖的線程直接進入阻塞狀態。

輕量級鎖的解鎖過程,也是一樣的,通過CAS來實現,如果對象的Mark Word仍然指向線程的鎖記錄,那麽就用CAS操作把對象當前的Mark Word和線程復制的Displace Mark Word替換回來。

4.4.3、偏向鎖

  如果說輕量級鎖是在無競爭的情況下使用CAS操作消除同步使用的互斥量,那偏向鎖就是在無競爭的情況下將整個同步都消除掉,連CAS都不操作了。偏向的意思就是這個對象的鎖會偏向於第一個獲取到它的線程,如果再接下來的過程中,該鎖沒有被其他線程獲取,則持有偏向鎖的線程將永遠不需要同步。

是否開啟偏向鎖模式,看的是參數UseBiasedLocking,這個在synchronizer.cpp文件中也可以看到。

if (UseBiasedLocking) {
    BiasedLocking::revoke_and_rebias(obj, false, THREAD);
    assert(!obj->mark()->has_bias_pattern(), "biases should be revoked by now");
 }

如果啟用了偏向鎖,那麽當鎖對象第一次被線程獲取的時候,JVM會把對象頭的標誌位置為“01”,Biased/Biasable,同時使用CAS操作把獲得鎖的線程ID記錄在對象的Mark Word之中,如果CAS操作成功,持有偏向鎖的線程以後每次進入到這個鎖的相關同步塊時JVM均不用再次進行同步操作。當另外有線程去嘗試獲取這個鎖時,偏向模式宣布結束,恢復到Unlocked或者是Light-weight locked,後續的同步操作就和上述輕量級鎖那樣執行。

提綱:先描述了為什麽會用到synchronized,再學習了同步的用法及背後的實現,最後到JVM中ObjectMonitor,這裏沒有分析ObjectMonitor具體是怎麽做的,最後學了一下鎖的優化技術。

參考資料:

https://github.com/lingjiango/ConcurrentProgramPractice

https://www.javaworld.com/article/2076971/java-concurrency/how-the-java-virtual-machine-performs-thread-synchronization.html

https://wiki.openjdk.java.net/display/HotSpot/Synchronization

https://stackoverflow.com/questions/26357186/what-is-in-java-object-header

https://stackoverflow.com/questions/36371149/reentrantlock-vs-synchronized-on-cpu-level

https://www.ibm.com/developerworks/java/library/j-jtp10264/

https://stackoverflow.com/questions/47605/string-concatenation-concat-vs-operator

<<HotSpot實戰>>

Java並發編程-synchronized