Java線程安全機制及原理

Java線程安全

線程的合理使用能夠提升程序的處理性能,真正意義上實現併發執行任務,提升程序吞吐量,但是也會引發一些列線程安全問題,比如共享變量等待。對於線程安全性,本質上是管理對於數據狀態的訪問,而且這個狀態通常是共享的、可變的。共享是指這個變量可以同時被多個線程訪問了;可變,指這個變量的值在它的生命週期內是可以改變的。

一個對象線是否是線程安全的,取決於它是否會被多個線程同時訪問,以及程序中是如何去使用這個對象的。如果多個線程訪問同一個共享對象,在不需要額外的同步以及調用端代碼不用做其他協調的情況下,這個共享對象的狀態依然是正確的(正確性意味着這個對象的結果與預期規定的結果保持一致),那說明這個對象是線程安全的。

java中如何解決由於線程並行導致的數據安全性問題?

數據安全性問題的本質在於解決共享數據併發訪問的問題,如果能使線程並行變成串行,那麼就能解決這個問題。在java中,聽到和接觸到最多的就是鎖的概念,比如悲觀鎖、樂觀鎖等等,它是處理併發的一種同步手段,如果要解決數據安全性問題,那麼這個鎖一定需要實現互斥的特性,java提供枷鎖的方法就是synchronized關鍵字。

synchronized的基本認識

多線程併發編程中 synchronized 一直是元老級角色,很多人都會稱呼它爲重量級鎖。但是,隨着 Java SE 1.6 對 synchronized 進行了各種優化之後,有些情況下它就並不那麼重,Java SE 1.6 中爲了減少獲得鎖和釋放鎖帶來的性能消耗而引入的偏向鎖和輕量級鎖。

synchronized的基本使用

synchronized的使用分爲兩種形式:一種是修飾代碼塊(比較靈活),一種是修飾方法;

兩種作用範圍:對象鎖、類鎖,區別是是否跨對象、跨線程被保護

  • 修飾實例方法(作用域爲當前實例,對當前實例枷鎖,進入代碼前要先獲得當前實例的鎖)

    synchronized修飾實例方法和在代碼塊中修飾當前實例對象synchronized(this)是等價的,作用範圍是當前實例對象

  • 修飾代碼塊(指定枷鎖對象,對指定對象枷鎖,進入同步代碼前要先獲得給定對象的鎖)

  • 修飾靜態方法(作用域爲類對象,對當前類對象枷鎖,進入同步代碼前要獲得當前類對象的鎖)

    synchronized修飾靜態方法和在代碼塊中修飾當前類對象synchronized(Class)是等價的,作用範圍是全局的

代碼示例

/**
 * 兩種表現形式:修飾方法、修飾代碼塊
 * synchronized兩種作用域:對象鎖(只作用域當前實例)、類鎖(全局,跨對象、跨線程)
 * 方法test1和test2是等價的(對象鎖),方法test3和test4是等價的(類鎖)
 */
public class SynchronizedTest {
    public synchronized void test1() {}

    public void test2() { synchronized (this) {} }

    public synchronized static void test3() {}

    public void test4() { synchronized (SynchronizedTest.class) {} }

    public static void main(String[] args) throws IOException {
        SynchronizedTest st1 = new SynchronizedTest();
        SynchronizedTest st2 = new SynchronizedTest();
      	// 不同實例調用同一個方法
      	// new Thread(()->st1.test1()).start();
        // new Thread(()->st2.test1()).start();
      	// 同一實例調用不同的方法
        // new Thread(()->st1.test1()).start();
        // new Thread(()->st1.test2()).start();
        // 類鎖示例
        new Thread(()->st1.test4()).start();
        new Thread(()->st2.test4()).start();
        System.in.read();
    }
}

synchronized鎖在內存中是如何存儲的?

從語法上看,synchronized(lock)是基於lock這個對象的生命週期來控制鎖的粒度,也就說作用域由對象的生命週期決定的(實例、類對象),那麼鎖與對象是什麼樣的關係?接下來去看下jvm源碼,看下對象在內存中是如何佈局的。

對象在內存中的佈局

在Hotspot虛擬機中,對象在內存中的佈局可以分爲三個部分:對象頭(Header)、實例數據(Instance Data)、對其填充(Padding),如下圖所示:
在這裏插入圖片描述

jvm源碼實現

我們在java代碼中,使用new創建一個對象實例,JVM實際上會創建一個instanceOopDesc對象。

Hotspot虛擬機採用OOP-Klass模型來描述Java對象實例(OOP(Ordinary Object Point)指的是普通對象指針,Klass用來描述對象實例的具體類型),Hotspot虛擬機採用instanceOopDesc和arrayOopDesc來描述對象頭(arrayOopDesc用來描述數組類型)

instanceOopDesc對應hotstpot虛擬機文件位置/src/share/vm/oops/instanceOop.hpp,
arrayOopDesc對應hotstpot虛擬機文件位置/src/share/vm/oops/arryOop.hpp,源碼如下:

class instanceOopDesc : public oopDesc {
 public:
  // aligned header size.
  static int header_size() { return sizeof(instanceOopDesc)/HeapWordSize; }

  // If compressed, the offset of the fields of the instance may not be aligned.
  static int base_offset_in_bytes() {
    // offset computation code breaks if UseCompressedClassPointers
    // only is true
    return (UseCompressedOops && UseCompressedClassPointers) ?
             klass_gap_offset_in_bytes() :
             sizeof(instanceOopDesc);
  }

  static bool contains_field_offset(int offset, int nonstatic_field_size) {
    int base_in_bytes = base_offset_in_bytes();
    return (offset >= base_in_bytes &&
            (offset-base_in_bytes) < nonstatic_field_size * heapOopSize);
  }
};

從instanceOopDesc代碼中可以看出,instanceOopDesc繼承自oopDesc,oopDesc的定義在/src/share/vm/oops/oop.hpp中

class oopDesc {
  friend class VMStructs;
 private:
  volatile markOop  _mark;
  union _metadata {
    Klass*      _klass;
    narrowKlass _compressed_klass;
  } _metadata;

  // Fast access to barrier set.  Must be initialized.
  static BarrierSet* _bs;
  /** 省略下面的部分源碼 */
}

從源碼中可以看到,普通對象的實例中,oopDesc的定義包含兩個成員,分別是_mark_metadata

  • _mark表示對象標記,屬於markOop類型,也就是MarkWord,記錄了對象和鎖有關的信息
  • _metadata表示類元信息,存儲的是對象指向他的類元數據(Klass)的首地址,其中_klass表示普通指針,_compressed_klass表示壓縮類指針

markOop定義在/src/share/vm/oops/markOop.hpp文件中,源碼如下:

// The markOop describes the header of an object.
//
// Note that the mark is not a real oop but just a word.
// It is placed in the oop hierarchy for historical reasons.
//
// Bit-format of an object header (most significant first, big endian layout below):
//
//  32 bits:
//  --------
//             hash:25 ------------>| age:4    biased_lock:1 lock:2 (normal object)
//             JavaThread*:23 epoch:2 age:4    biased_lock:1 lock:2 (biased object)
//             size:32 ------------------------------------------>| (CMS free block)
//             PromotedObject*:29 ---------->| promo_bits:3 ----->| (CMS promoted object)
//
//  64 bits:
//  --------
//  unused:25 hash:31 -->| unused:1   age:4    biased_lock:1 lock:2 (normal object)
//  JavaThread*:54 epoch:2 unused:1   age:4    biased_lock:1 lock:2 (biased object)
//  PromotedObject*:61 --------------------->| promo_bits:3 ----->| (CMS promoted object)
//  size:64 ----------------------------------------------------->| (CMS free block)
//
//  unused:25 hash:31 -->| cms_free:1 age:4    biased_lock:1 lock:2 (COOPs && normal object)
//  JavaThread*:54 epoch:2 cms_free:1 age:4    biased_lock:1 lock:2 (COOPs && biased object)
//  narrowOop:32 unused:24 cms_free:1 unused:4 promo_bits:3 ----->| (COOPs && CMS promoted object)
//  unused:21 size:35 -->| cms_free:1 unused:7 ------------------>| (COOPs && CMS free block)
//
//  - hash contains the identity hash value: largest value is
//    31 bits, see os::random().  Also, 64-bit vm's require
//    a hash value no bigger than 32 bits because they will not
//    properly generate a mask larger than that: see library_call.cpp
//    and c1_CodePatterns_sparc.cpp.
//
//  - the biased lock pattern is used to bias a lock toward a given
//    thread. When this pattern is set in the low three bits, the lock
//    is either biased toward a given thread or "anonymously" biased,
//    indicating that it is possible for it to be biased. When the
//    lock is biased toward a given thread, locking and unlocking can
//    be performed by that thread without using atomic operations.
//    When a lock's bias is revoked, it reverts back to the normal
//    locking scheme described below.
//
//    Note that we are overloading the meaning of the "unlocked" state
//    of the header. Because we steal a bit from the age we can
//    guarantee that the bias pattern will never be seen for a truly
//    unlocked object.
//
//    Note also that the biased state contains the age bits normally
//    contained in the object header. Large increases in scavenge
//    times were seen when these bits were absent and an arbitrary age
//    assigned to all biased objects, because they tended to consume a
//    significant fraction of the eden semispaces and were not
//    promoted promptly, causing an increase in the amount of copying
//    performed. The runtime system aligns all JavaThread* pointers to
//    a very large value (currently 128 bytes (32bVM) or 256 bytes (64bVM))
//    to make room for the age bits & the epoch bits (used in support of
//    biased locking), and for the CMS "freeness" bit in the 64bVM (+COOPs).
//
//    [JavaThread* | epoch | age | 1 | 01]       lock is biased toward given thread
//    [0           | epoch | age | 1 | 01]       lock is anonymously biased
//
//  - the two lock bits are used to describe three states: locked/unlocked and monitor.
//
//    [ptr             | 00]  locked             ptr points to real header on stack
//    [header      | 0 | 01]  unlocked           regular object header
//    [ptr             | 10]  monitor            inflated lock (header is wapped out)
//    [ptr             | 11]  marked             used by markSweep to mark an object
//                                               not valid at any other time
//
//    We assume that stack/thread pointers have the lowest two bits cleared.
class markOopDesc: public oopDesc {
 private:
  // Conversion
  uintptr_t value() const { return (uintptr_t) this; }

 public:
  // Constants
  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  // 偏向鎖的時間戳
  };
  /** 其他代碼省略 */
}

MarkWord記錄了對象和鎖的有關信息,當一個對象被synchronized關鍵字修飾作爲同步鎖時,接下來圍繞這個鎖的一些列操作都和MarkWord有關係。MarkWord在32位虛擬機長度爲32bit,在64位虛擬機長度爲64bit。MarkWord裏面存儲的數據會隨着鎖標誌位的變化而變化,變化情況分爲以下5種:
在這裏插入圖片描述

JAVA中任何對象都可以實現鎖

  1. java中的每個對象都派生自Object類,而Java Object在JVM內部都有一個native C++ 對象oop/oopDesc與之對應

  2. 線程在獲取鎖的時候,實際上就是獲得一個監視器對象(monitor),monitor可以認爲是一個同步對象,所有的Java對象天生攜帶monitor,在Hotspot源碼的markOop.hpp文件中,可以看到下面的這段代碼。

ObjectMonitor* monitor() const {
  assert(has_monitor(), "check");
  // Use xor instead of &~ to provide one extra tag-bit check.
  return (ObjectMonitor*) (value() ^ monitor_value);
}

多個線程訪問代碼快時,相當於去競爭對象監視器,修改對象中的鎖標識,上面代碼中ObjectMonitor這個對象和線程競爭鎖的邏輯有密切的關係。

synchronized鎖的升級

java1.6之後對Synchronized鎖做了優化,優化的背景或產生偏向鎖、輕量級鎖的需求場景是什麼?

前面分析了MarkWord的源碼,提到了偏向鎖、輕量級鎖、重量級鎖,既然使用鎖能夠實現線程的安全性,但同時也會帶來性能的下降。如果不使用鎖,雖然能提高併發性能,但是不能保證線程安全,兩者之間不能同時滿足要求。

Hotspot虛擬機編寫的作者經過調查發現,大部分情況下,枷鎖的代碼不僅僅不存在多線程競爭,而且總是有同一個線程多次獲得鎖。基於這樣一個概率,在jdk1.6之後,對鎖做了一些優化,爲了減少獲得鎖和釋放鎖所帶來的性能開銷,引入了偏向鎖、輕量級鎖的概念。所以synchronized鎖在運行時存在四種狀態:無鎖、偏向鎖、輕量級鎖和重量級鎖,鎖的狀態會根據線程競爭鎖的激烈程度從低到高不斷升級。(鎖只能升級(或被撤銷),不能降級)

偏向鎖的基本原理

經過調查,大部分情況下,鎖不緊不存在多線程競爭,而是總是有同一個線程多次獲得,爲了讓線程獲得鎖的代價更低,就引入了偏向鎖的概念。

點一個線程訪問了加入同步鎖的代碼塊,會在鎖的對象頭中存儲當前線程的ID,後續這個線程進入和退出同步代碼塊時,不需要再次枷鎖和釋放鎖,直接比較對象頭中是否存儲了指向當前線程的偏向鎖,如果偏向鎖存儲的線程ID與當前線程ID相等,表示偏向鎖是偏向於當前線程的,就不需要再次嘗試獲得鎖。

偏向鎖的獲取和撤銷邏輯
  1. 獲取鎖對象的MarkWord,判斷是否處於偏向狀態(biased_lock=1,並且ThreadId爲空)

  2. 如果是可偏向狀態,則通過CAS操作,把當前線程ID寫入到MarkWord

    • 如果cas成功,標示獲得了鎖對象的偏向鎖,接下來執行同步代碼塊
    • 如果cas失敗,說明有其他線程已經獲得了偏向鎖,當前鎖存在競爭,需要撤銷已經獲得鎖的線程,並且把鎖升級成爲輕量級鎖(這個操作需要等到全局安全點,也就是沒有線程執行同步代碼塊,才能執行)
  3. 如果是已偏向的狀態,需要檢查MarkWord中存儲的ThreaId是否等於當前線程的ID

    • 如果相等,不需要再次獲得鎖,可以直接執行同步代碼塊

    • 如果不相等,說明偏向鎖偏向其他線程,需要撤銷偏向鎖並升級爲輕量級鎖

偏向鎖的撤銷

偏向鎖的撤銷並不是把鎖對象恢復到無鎖可偏向狀態(偏向鎖不存在鎖釋放的概念),而是在獲取鎖的過程中,發現cas失敗,存在線程競爭時,直接把偏向鎖升級到了輕量級鎖。

對原持有偏向鎖的線程進行撤銷時,原獲得鎖的線程有兩種情況:

  1. 原獲得偏向鎖的線程如果已經退出了臨界區,也就是同步代碼塊執行完畢,這個時候會把對象頭設置成無鎖狀態,爭奪鎖的線程可以基於CAS重新偏向於自己。

  2. 如果原獲得偏向鎖的線程還沒有執行完同步代碼塊,處於臨界區之內,這個時候會把偏向鎖升級成爲輕量級鎖,原獲得偏向鎖的線程繼續執行代碼塊

在實際應用開發中,絕大部分情況一定會存在2個以上的線程競爭鎖,那麼開啓偏向鎖反而會增加獲得鎖的資源消耗,可以通過jvm參數UserBiasedLocking來設置開啓和關閉偏向鎖
在這裏插入圖片描述

輕量級鎖的基本原理

輕量級鎖的加鎖和解鎖邏輯

鎖升級爲輕量級鎖之後,鎖對象的MarkWord也會進行相應的變化,升級爲輕量級鎖的過程如下:

  1. 線程在自己的棧針中創建鎖記錄LockRecord
  2. 將鎖對象的對象頭的MarkWord複製到線程創建的鎖記錄中
  3. 將鎖記錄中的owner指針指向鎖對象
  4. 將鎖對象的對象頭的MarkWord替換爲指向線程鎖記錄的指針
    在這裏插入圖片描述
    在這裏插入圖片描述
自旋鎖

輕量級鎖在加鎖過程中使用到了自旋鎖,所謂自旋鎖就是指,當另外一個線程來競爭所是,這個線程會原地循環等待,而不是把線程阻塞。獲得鎖的線程釋放鎖之後,這個線程就可以立馬獲得鎖(獲得鎖的過程在原地循環的時候,會消耗CPU資源,相當於在執行一個啥也沒有的for循環)。所以,輕量級鎖只用於同步代碼塊執行時間很短的場景,這樣原地等待的線程能夠很短的時間就能獲得鎖。

自旋鎖的使用,也是有一定概率的背景,在大部分同步代碼塊執行時間很短的情況下,通過循環可以提高鎖的性能。但是自旋必須要有一定的條件控制,否則會因爲一個線程同步代碼塊執行很長時間,不斷的循環導致消耗過多CPU資源。默認情況下自旋次數是10,可以通過JVM參數PreBlockSpin來修改。

在JDK1.6之後,引入了自適應自旋鎖,自適應自旋鎖意味着自旋的次數不是固定不變的,而是根據前一次在同一個鎖上自旋的時間以及鎖的擁有者的狀態來決定。如果在同一個鎖對象上,一個線程自旋等待成功獲得鎖,並且持有鎖的線程正在運行中,那麼虛擬機會認爲這次自旋很有可能再次成功,進而它將允許自旋等待持續相對更長的時間。如果某個鎖通過自旋很少成功獲得過,那麼以後嘗試獲取這個鎖可能省略掉自旋過程,直接阻塞,避免浪費處理器資源。

輕量級鎖的解鎖

輕量級鎖的釋放過程就是獲得鎖的逆向邏輯,通過CAS操作吧棧針中的LockRecord替換回到所對的MarkWord中,如果成功,表示沒有競爭;如果失敗,表示當前鎖存在競爭,那麼輕量級鎖會升級爲重量級鎖。
在這裏插入圖片描述

重量級鎖的基本原理

當輕量級鎖膨脹到重量級鎖之後,意味着線程只能被掛起阻塞,等待被喚醒。

重量級鎖的monitor
public class App {
    public static void main(String[] args) {
        synchronized (App.class) {}
        test();
    }
    public static synchronized void test () {}
}

運行以後通過 java p工具查看生成的 class 文件信息分析 synchronized關鍵字的實細節 javap -v app. class,

加了同步代碼塊以後,在字節碼文件中會看到一個monitorenter和monitorexit

public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=3, args_size=1
         0: ldc           #2                  // class com/seiya/concurrent/App
         2: dup
         3: astore_1
         4: monitorenter
         5: aload_1
         6: monitorexit
         7: goto          15
        10: astore_2
        11: aload_1
        12: monitorexit
        13: aload_2
        14: athrow
        15: invokestatic  #3                  // Method test:()V
        18: return

每一個Java對象都會與一個監視器monitor關聯,我們可以把它理解成爲一把鎖,當一個線程想要執行一段被synchronized關鍵字修飾的代碼塊是,該線程先獲得synchronized修飾的對象對應的monitor。monitorenter表示去獲得一個對象監視器,monitorexit表示釋放監視器的所有權,使得其他被阻塞的線程可以嘗試去獲得這個監視器。

monitor監視器依賴操作系統的MutexLock(互斥鎖)來實現,線程被阻塞後,進入內核(Linux)調度狀態,這個導致系統在用戶態和內核態之間來回切換,嚴重影響鎖的性能。

重量級鎖加鎖的基本流程

在這裏插入圖片描述
任意線程對Object(Object有synchronized保護)的訪問,首先要獲得Object的監視器。如果失敗,線程進入同步隊列,狀態變成BLOCKED。當訪問Object的的前驅(獲得了鎖的線程)釋放了鎖,則該釋放操作喚醒阻塞在同步隊列的線程,是其重新嘗試對監視器的獲取。

synchronized結合Java Object對象中的wait、notify、notifyall

阻塞線程什麼時候被喚醒,取決於獲得鎖的線程什麼時候執行完同步代碼塊並且釋放鎖。如果要想顯示控制,需要藉助一個信號機制:在Object對象中,提供了wait/notify/notifyall,可以用於控制線程的狀態。

wai/notify/notifyall的基本概念

  • wait:表示持有對象鎖的線程A準備釋放鎖權限,釋放CPU資源並進入等待狀態

  • notify:表示持有對象鎖的線程A準備釋放鎖權限,通知jvm喚醒某個競爭該對象鎖的線程X。線程A執行完同步代碼並且釋放了鎖之後,線程X直接獲得對象鎖權限,其他競爭線程繼續等待(即使線程X同步完畢,釋放對象鎖,其他競爭線程仍然繼續等待,直至有新的notify或notifyall來喚醒)

  • notifyall:和notify不同的是,notifyall會喚醒所有競爭同一個對象鎖的所有線程,當獲得鎖的線程A釋放鎖之後,所有被喚醒的線程都有可能獲得對象鎖權限

三個方法必須在synchronized同步關鍵字所限定的作用域內調用,否則會報錯java.lang.IllegalMonitorStateException,意思就是沒有同步,所以線程對對象鎖的狀態是不確定的,不能調用這些方法。另外,通過同步機制來確保線程從wait方法返回是能夠感知到notify線程對變量做出的修改。

wait/notify 的基本原理

在這裏插入圖片描述

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