DEVLOG 1.3 Java多線程 -- 鎖、阻塞、喚醒
參考文章:
Java中的線程和操作系統(tǒng)中的線程的關(guān)系
https://zhuanlan.zhihu.com/p/133275094
Java多線程基礎(chǔ) :https://www.liaoxuefeng.com/wiki/1252599548343744/1304521607217185
Java中的瑣事 -- 美團技術(shù)團隊:
https://tech.meituan.com/2018/11/15/java-lock.html

本文是對Java多線程中涉及到鎖的一些基本知識的一些總結(jié),如果有出錯之處,歡迎指正~
文章目錄:
#1?Java中的多線程
????a. Java中的線程的生命周期
????b.Java中的線程和操作系統(tǒng)中的線程的關(guān)系
#2 鎖 阻塞 喚醒
????a.?鎖的使用舉例
? ? b. synchronized 的實現(xiàn)
#3 鎖的分類
?? ?a.?樂觀鎖 悲觀鎖
? ? b. 可重入鎖 不可重入鎖

PART1?Java中的多線程
進程是操作系統(tǒng)提出的偉大的概念。簡單而言的理解,我們使用的各項應(yīng)用都可以看成一個進程,我們可以一遍聽音樂一邊打游戲還可以進行其他的任務(wù)。這些進程看似互不沖突,但是實際上是通過CPU的時間分片執(zhí)行的。這些進程快速切換,給我們一種同時執(zhí)行的錯覺。
這種切換我們稱為進程的上下文切換,通常而言進程的上下文切換需要保存被切換掉的進程的寄存器程序計數(shù)器等等的信息,同時加載切換到的進程的寄存器和程序計數(shù)器信息。
而線程,根據(jù)維基百科的定義,是操作系統(tǒng)可以執(zhí)行運算的最小單元。線程通常被包含在進行內(nèi)部執(zhí)行。借用廖雪峰老師的一張圖, 可能的模式是這樣子:

? ? a. Java中的線程的生命周期
Java天然的在JDK中提供了線程的支持,同時線程存在下面的生命周期狀態(tài)。

yield表示當前running狀態(tài)的線程放棄CPU,會轉(zhuǎn)到就緒態(tài);如果當前線程創(chuàng)建了子線程的話,調(diào)用join會阻塞自身等待子線程的執(zhí)行。
? ? b. Java中的線程和操作系統(tǒng)中的線程的關(guān)系
這里主要參考了這篇文章:https://zhuanlan.zhihu.com/p/133275094
因為Java是一種跨平臺的語言,所以Java在上層(應(yīng)用層面)的開發(fā)我們不需要考慮操作系統(tǒng)的的細節(jié)。平臺之間的差異通過JVM實現(xiàn)屏蔽掉了。
在Linux中,Java的線程是通過Linux的pthread實現(xiàn)的。

PARTII 鎖、阻塞 喚醒
a.?鎖的使用舉例
上面Java線程生命周期的例子看起來比較抽象,下面結(jié)合一個經(jīng)典的生產(chǎn)者消費者的實例來解釋這些有Java提供的API接口的使用:
生產(chǎn)者向中間倉庫Storage中寫入消息,生產(chǎn)者生產(chǎn)的速度慢于消費者消費的速度。
消費者讀取Storage中的數(shù)據(jù),而且是一次性讀完。
為了解釋這個例子,首先得引入【鎖】的概念。
因為生產(chǎn)者和消費者都需要訪問Storage中的消息,好比兩個人通知爭搶一塊蛋糕,或者兩個人同時行走在一根獨木橋上,如果不控制兩個人的順序,會發(fā)生斗爭或者是沒有一個人能夠過橋的情況。 這種需要爭搶的資源我們通常稱作【互斥資源】。
鎖在計算機語言中表示對于這種互斥資源的限制,如果想要訪問互斥資源,必須需要獲取鎖。Java通過內(nèi)置的synchronized關(guān)鍵字提供了基本的鎖的支持。synchronized方法塊施加在storage對象上,并且生產(chǎn)者和消費者都持有storage,這樣可以保證兩方只有一方能夠訪問storage。
因為同時啟動了兩個線程Producer和Consumer,所以訪問我們可以理解為是同時執(zhí)行的,這樣當Producer獲取storage的鎖的時候,Consumer只能進入阻塞狀態(tài)。阻塞狀態(tài)的結(jié)束,要么等到Producer的synchronized方法塊的完結(jié),或者是Producer在方法塊中調(diào)用notify/nofityAll喚醒等待在storage的【鎖】上的Consumer。 ?
當Consumer先于Producer獲取storage的鎖時,storage中沒有任何message,此時調(diào)用storage.wait,Consumer自行進入阻塞狀態(tài)的同時會釋放storage的【鎖】。這里要明確地記住,調(diào)用storage.wait會釋放鎖。
在具有更多線程的情況下, 為了使得CPU利用率最大,假如當前CPU上運行的線程被阻塞,CPU是不會等到這個線程拿到鎖,重新變成就緒態(tài)的,如果有其他線程,其他線程也會在這個CPU上執(zhí)行。
所以通俗來講,【鎖】表示對于互斥資源的控制,阻塞主要是當沒有獲取到互斥資源時的排隊行為;除了等待持有鎖的線程結(jié)束資源的使用,還可以通過喚醒的方式,將互斥資源交給阻塞的其他線程。尤其是當兩個線程同時寫一個互斥資源的時候,使用鎖控制訪問就顯得尤其重要!
b.?synchronized 的實現(xiàn)
synchronized的實現(xiàn)有賴于底層一種名為monitor機制。對于任何一個Java對象而言,這個對象在內(nèi)存中的布局都包含這三個部分:

填充數(shù)據(jù)是為了保證在訪問內(nèi)存的時候更加快速;實例變量就是存放了實例的屬性信息。
和synchronized的實現(xiàn)有關(guān)的部分在于對象頭中:

對象頭分成兩個部分,一個是Mark Word另外一個是Class Metadata Address。后者用于確定這個實例屬于那個類,mark word則包含指向monitor的指針,后者表示鎖的類型(主要有四種)。
mark word雖然有32位,但是會根據(jù)鎖的類型不同,其中每一位表示的意義也有所區(qū)別。
monitor這種機制在HotSpot虛擬機中通過Cpp的ObjectMonitor類實現(xiàn):
每一個對象都會使用一個ObjectMonitor C++類和它關(guān)聯(lián)。 下面我們通過圖示來說明Monitor機制具體的工作細節(jié)(reference:https://blog.csdn.net/javazejian/article/details/72828483)

當有很多線程在訪問這個對象的鎖時,首先會進入對象實例(ObjectMonitor)的_EntryList中,當如果成功獲取對象鎖,會被移除,同時ObjectMonitor中的_owner會設(shè)置為獲取到鎖的線程。如果對象實例使用wait方法,獲取鎖的線程會進入阻塞狀態(tài),進入_WaitSet;否則當完成之后釋放鎖并且退出。
正是因為Java中的內(nèi)置鎖synchronized免不了和對象關(guān)聯(lián), 所以每個類在繼承Object的同時也繼承了wait notify等等方法。

PART III Java中鎖的分類
a. 樂觀鎖和悲觀鎖?
通過對于對象是否在某個線程持有階段被其他線程修改,鎖可以分成樂觀鎖和悲觀鎖。
樂觀鎖認為線程在持有數(shù)據(jù)的階段不會被其他的線程修改(如果發(fā)生修改就執(zhí)行對應(yīng)的策略);悲觀鎖認為持有數(shù)據(jù)時,其他的線程一定會修改這個數(shù)據(jù),因此需要對數(shù)據(jù)加鎖,控制訪問順序。
先介紹悲觀鎖,再來說樂觀鎖。
悲觀鎖
Java中的synchronized和Lock類都是悲觀鎖,使用Lock相對于synchronized關(guān)鍵字而言,鎖的粒度更細。
樂觀鎖
在介紹樂觀鎖之前,首先要說明兩個概念:原子操作和CAS
Conception#1: 原子操作
原子操作則是在操作系統(tǒng)層面上不會被中斷的操作。比如i++這個操作就不是原子操作,他可以分成三部分,取出i,計算+1的值,寫回i對應(yīng)的地址,如果在執(zhí)行i++的第二部分到第三部分之間,有其他的線程讀取i的值,讀取的結(jié)果還是i加1之前的值。所以原子操作就是為了避免出現(xiàn)這種情況。
Conception#2: CAS
CAS操作就是一種原子操作,在Java API層面上,JDK提供了Unsafe類的一些方法,但是具體的實現(xiàn)都是native的,有賴于底層的操作系統(tǒng)。這里說明一下,所謂Unsafe類就是提供了一些可以和操作系統(tǒng)底層交互的方法,但是由于這樣的方法可能有不安全的問題,所以命名為【Unsafe】。
CAS操作就是Compare and Set的縮寫,主要接受這樣的三個參數(shù):
V 目標變量的內(nèi)存地址; A 需要比較的值,B需要寫入的新的值。如果目標值和A相等,就將B寫到對應(yīng)的目標地址,并且返回true,否則返回false。
下面我們繼續(xù)說一下樂觀鎖。樂觀鎖的具體實現(xiàn)可以參考AtomicInteger類:
可以看到,樂觀鎖并不是使用了某種提供的具體的API,這里通過do-while循環(huán)使當前線程不斷訪問目標地址的值,如果這個值等于A,則修改為B。
這種使用while,do-while形式的“鎖”我們稱之為【自旋鎖】。在多線程條件下,線程請求另外一種互斥資源時,如果沒有拿到鎖,這個線程就會被阻塞,但是上下文切換時存在很高的代價的。使用自旋鎖是為了避免因為使用阻塞而出現(xiàn)上下文切換,從而造成更大的時間浪費。?
樂觀鎖Vs悲觀鎖
首先,自旋鎖也存在一些問題:
ABA問題: 如果在CAS+循環(huán)的迭代過程中,目標位置的變量從A->B->A,此時CAS還是會認為目標位置的變量沒有變化。但是這樣是存在隱患的,比如使用CAS修改堆棧棧頂?shù)闹?,但是站?nèi)元素已經(jīng)發(fā)生改變了。 對于ABA問題,可以讓每次修改都對應(yīng)版本號,如1A->2B->3A。
樂觀鎖能否進行訪問控制的幅度有限,通常只能管一個變量。使用Lock或者是synchronized可以鎖住一整個代碼塊。
當鎖的競爭不激烈時,可以使用樂觀鎖,因為悲觀鎖會讓其他的線程無法同時訪問;當競爭激烈時,CAS會導致重試次數(shù)較多,白白浪費CPU資源。
JVM也針對并發(fā)中的鎖問題進行了一些優(yōu)化,鎖可以從偏向鎖升級為重量級鎖。

在實際開發(fā)過程中,多次獲取鎖的極有可能是同一個線程,這就不存在多個線程相互競爭。

偏向鎖的mark word中存儲了線程的ID,如果是同一個線程,會檢測mark word中是否存放著此線程的ID。如果有其他的鎖嘗試競爭偏向鎖,偏向鎖會升級為輕量級鎖。
當其他線程訪問輕量級鎖時,這個線程會保持自旋等待,當?shù)谌齻€線程訪問時,輕量級鎖升級為重量級鎖。
當輕量級鎖升級為重量級鎖時,所有的線程都必須阻塞訪問。
b.?可重入鎖不可重入鎖
可重入鎖指的是,對于同一個線程訪問互斥資源,線程不需要等待自身釋放資源
?對于這個例子而言,Thread1先調(diào)用了do1,然后再調(diào)用do2,如果synchronized不是可重入鎖,則需要等待Thread1釋放鎖,這樣就產(chǎn)生了【死鎖】。

ReentrantLock就是可重入鎖,可以看出在源碼中,ReentrantLock通過檢查當前的線程來判斷是否可重入。

總結(jié):
本文首先簡單描述了Java中的多線程的概念,并且和操作系統(tǒng)層面的多線程作對比。在Linux中,JVM的線程基于Linux的pthread。
在第二小節(jié)中,通過消費者生產(chǎn)者的例子重點說明了線程生命周期中的阻塞和運行。Java天然就支持多線程,這種多線程的支持不光是體現(xiàn)在繼承自O(shè)bject類的wait和notify上,所有的對象都具有對象頭。在JVM層,monitor機制負責管理、記錄阻塞的線程隊列。同時簡單介紹了鎖優(yōu)化的一些知識,JDK6中引入的鎖優(yōu)化可以從偏向鎖開始逐步過渡到重量級鎖。
最后一小節(jié)主要介紹了兩種鎖的分類。根據(jù)數(shù)據(jù)的假定數(shù)據(jù)是否被修改可以分成樂觀鎖和悲觀鎖。悲觀鎖主要是使用synchronized和各種Lock類;樂觀鎖主要介紹了自旋鎖,自旋鎖就是在有限的時間片還沒結(jié)束時,也不會讓自身的線程阻塞。如果在若干次迭代中,持有鎖的線程釋放鎖,自旋鎖可以得到,并且修改值。 通過鎖的可重入性可以分成可重入和不可重入鎖。