Java線程安全(上)

前言
我們?cè)贏ndroid項(xiàng)目開(kāi)發(fā)中,經(jīng)常會(huì)使用到多線程異步處理很多事務(wù),但是在實(shí)際使用線程開(kāi)發(fā)時(shí)有些線程概念理解很模糊,再加上一些線程操作誤區(qū),導(dǎo)致應(yīng)用運(yùn)行線程混亂,“不科學(xué)的bug”越來(lái)越多,分享本篇文章的目的就是讓大家理解線程安全原理,合理使用線程,并從中受到一些設(shè)計(jì)啟發(fā)。
01
—
線程的基本介紹
線程調(diào)度
在講線程安全之前我們需要將線程調(diào)度相關(guān)知識(shí)作為前置條件,計(jì)算機(jī)通常只有一個(gè)CPU,在任意時(shí)刻只能執(zhí)行一條機(jī)器指令,每個(gè)線程只有獲得CPU的使用權(quán)才能執(zhí)行指令。所謂多線程的并發(fā)運(yùn)行,其實(shí)是指從宏觀上看,各個(gè)線程輪流獲得CPU的使用權(quán),分別執(zhí)行各自的任務(wù)。在運(yùn)行池中,會(huì)有多個(gè)處于就緒狀態(tài)的線程在等待CPU,JVM的一項(xiàng)任務(wù)就是負(fù)責(zé)線程的調(diào)度,線程調(diào)度是指按照特定機(jī)制為多個(gè)線程分配CPU的使用權(quán)。
有兩種調(diào)度模型:分時(shí)調(diào)度模型和搶占式調(diào)度模型。
分時(shí)調(diào)度模型是指讓所有的線程輪流獲得CPU的使用權(quán),并且平均分配每個(gè)線程占用的CPU的時(shí)間片這個(gè)也比較好理解。
Java虛擬機(jī)采用搶占式調(diào)度模型,是指優(yōu)先讓可運(yùn)行池中優(yōu)先級(jí)高的線程占用CPU,如果可運(yùn)行池中的線程優(yōu)先級(jí)相同,那么就隨機(jī)選擇一個(gè)線程,使其占用CPU。處于運(yùn)行狀態(tài)的線程會(huì)一直運(yùn)行,直至它不得不放棄CPU。
JMM
JMM(Java Memory Model),是一種基于計(jì)算機(jī)內(nèi)存模型(定義了共享內(nèi)存系統(tǒng)中多線程程序讀寫(xiě)操作行為的規(guī)范),屏蔽了各種硬件和操作系統(tǒng)的訪問(wèn)差異的,保證了Java程序在各種平臺(tái)下對(duì)內(nèi)存的訪問(wèn)都能保證效果一致的機(jī)制及規(guī)范。保證共享內(nèi)存的原子性、可見(jiàn)性、有序性。

02
—
什么是線程安全?
線程安全問(wèn)題指的是多個(gè)線程之間對(duì)一個(gè)或多個(gè)共享可變對(duì)象交錯(cuò)操作時(shí),有可能導(dǎo)致數(shù)據(jù)異常。
競(jìng)態(tài)條件(Race Condition)
計(jì)算的正確性取決于多個(gè)線程的交替執(zhí)行時(shí)序時(shí),就會(huì)發(fā)生競(jìng)態(tài)條件。舉個(gè)例子,線程A和線程B同時(shí)執(zhí)行單例里的getInstance(),當(dāng)線程A執(zhí)行時(shí)發(fā)現(xiàn)getInstance()返回的是null,會(huì)立即創(chuàng)建單例對(duì)象,同時(shí)線程B執(zhí)行結(jié)果也一樣,也會(huì)創(chuàng)建一個(gè)單例對(duì)象。
競(jìng)態(tài)不一定導(dǎo)致計(jì)算結(jié)果的不正確,而是不排除計(jì)算結(jié)果有時(shí)正確有時(shí)錯(cuò)誤的可能。和大多數(shù)并發(fā)錯(cuò)誤一樣,競(jìng)態(tài)條件不總是會(huì)產(chǎn)生問(wèn)題,還需要不恰當(dāng)?shù)膱?zhí)行時(shí)序。
線程原子性
線程原子性表示的是在共享變量的操作必須是功能聚合不可分的,必須要連續(xù)完成,舉個(gè)例子,線程A里有一個(gè)對(duì)共享變量x++的操作,這個(gè)操作的流程應(yīng)該是將共享內(nèi)存中讀取數(shù)據(jù)后,在線程內(nèi)創(chuàng)建一個(gè)臨時(shí)x變量,然后對(duì)臨時(shí)x變量進(jìn)行x++,最終將輸出結(jié)果同步到共享區(qū)域的x變量?jī)?nèi),這一系列的操作如果在中途有其他線程對(duì)變量a進(jìn)行重新賦值,那么就沒(méi)辦法保證線程A對(duì)x變量操作是正確的,所以我們必須要保證線程操作共享變量必須是具有原子性的,如何保證線程原子性,在后面會(huì)講到。

線程可見(jiàn)性
線程可見(jiàn)性指的是在多線程環(huán)境下如果某一個(gè)線程對(duì)共享變量的數(shù)據(jù)進(jìn)行更新后,對(duì)于其他的線程訪問(wèn)這個(gè)共享變量是否為更新后的結(jié)果。
線程有序性
有序性指的是在程序執(zhí)行的時(shí)候,代碼執(zhí)行的順序和語(yǔ)句的順序是一致的。出現(xiàn)線程無(wú)序是因?yàn)樵贘ava內(nèi)存環(huán)境下,允許編譯器和處理器對(duì)指令進(jìn)行重排序,雖然不會(huì)影響單線程的執(zhí)行順序,但是會(huì)影響到多線程并發(fā)的執(zhí)行正確性。如何避免重排序,會(huì)在下文里提到。
03
—
如何實(shí)現(xiàn)線程安全?
要實(shí)現(xiàn)線程安全就必須要保證原子性、可見(jiàn)性和有序性。其中包括鎖和原子類型。
線程鎖
線程鎖指的是在多線程環(huán)境下,某個(gè)線程要對(duì)共享內(nèi)存里的數(shù)據(jù)進(jìn)行操作時(shí),先將其上鎖,處理完成后,再進(jìn)行解鎖操作。舉個(gè)例子,我們?cè)诂F(xiàn)實(shí)生活中逛超市的時(shí)候需要把隨身物品寄存在寄存箱里,然后把箱子上鎖后開(kāi)始逛超市,買(mǎi)完了東西后,解鎖寄存箱來(lái)取隨身物品,然后這個(gè)寄存箱就可以提供給別人使用了。寄存箱相當(dāng)于共享區(qū)域的對(duì)象,而隨身物品則是對(duì)象的值,寄存的操作由人來(lái)完成,人相當(dāng)于線程。

鎖的特點(diǎn)
臨界區(qū)
我們?cè)诮o超市的寄存箱上鎖后,將隨身物品放入寄存箱然后去逛超市,直到逛完超市后解鎖寄存箱取出隨身物品,這個(gè)上鎖和解鎖的區(qū)間就是臨界區(qū)。代碼里的表現(xiàn)就是,持有鎖的線程獲取鎖后和釋放鎖的執(zhí)行操作,這個(gè)區(qū)間執(zhí)行的代碼叫做臨界區(qū)。
串行
多線程開(kāi)發(fā)環(huán)境下,有多個(gè)線程并行執(zhí)行事務(wù),當(dāng)有鎖的介入時(shí),就相當(dāng)于把多個(gè)線程串行執(zhí)行,舉個(gè)例子,你在使用寄存箱的時(shí)候,如果別人要使用,必須得排隊(duì)等你解鎖后才可以使用。
調(diào)度策略
鎖的調(diào)度策略分為公平策略和非公平策略,對(duì)應(yīng)的鎖就叫公平鎖和非公平鎖。
公平鎖
就是多線程按照申請(qǐng)鎖的順序,未申請(qǐng)到鎖的線程會(huì)在線程等待隊(duì)列里進(jìn)行排隊(duì),只有隊(duì)列首位才能拿到鎖。
優(yōu)點(diǎn):所有線程都能得到資源,不會(huì)造成線程饑餓
缺點(diǎn):增加了上下文切換的代價(jià),增加了線程暫停和喚醒的操作,會(huì)造成吞吐量低,等待隊(duì)列里會(huì)長(zhǎng)時(shí)間阻塞大量未獲取到鎖的線程,CPU喚醒線程的開(kāi)銷也會(huì)變大。
非公平鎖
在線程去獲取對(duì)象鎖的時(shí)候,會(huì)直接嘗試獲取,如果獲取不到,再進(jìn)入等待隊(duì)列,如果能獲取到,就直接獲取鎖。
優(yōu)點(diǎn):減少CPU喚醒線程的開(kāi)銷,吞吐量高
缺點(diǎn):會(huì)導(dǎo)致等待隊(duì)列中的某些檢測(cè)長(zhǎng)時(shí)間獲取不到鎖,造成線程饑餓
鎖的問(wèn)題
鎖泄漏
鎖泄漏指的是代碼的錯(cuò)誤可能導(dǎo)致一個(gè)線程在其執(zhí)行完臨界區(qū)代碼之后未能釋放引導(dǎo)這個(gè)臨界區(qū)的鎖,最終導(dǎo)致其他線程無(wú)法獲取鎖
04
—
內(nèi)部鎖
synchronized是Java提供的內(nèi)部鎖,里邊有類鎖和對(duì)象鎖;在靜態(tài)方法中,我們一般使用類鎖,在實(shí)例方法中,我們一般使用對(duì)象鎖。使用 synchronized 實(shí)現(xiàn)的線程同步是通過(guò)監(jiān)視器(monitor)來(lái)實(shí)現(xiàn)的,所以內(nèi)部鎖也叫監(jiān)視器鎖。
內(nèi)部鎖的臨界區(qū)
同步代碼塊就是內(nèi)部鎖的臨界區(qū),如果某一條線程需要執(zhí)行同步代碼塊的事務(wù),必須先持有此代碼塊也就是臨界區(qū)的鎖。
不會(huì)造成鎖泄漏
鎖泄漏上文有提過(guò),內(nèi)部鎖不會(huì)導(dǎo)致鎖泄漏,javac編譯器把同步代碼塊編譯成字節(jié)碼時(shí),對(duì)內(nèi)部鎖的同步代碼塊中的事務(wù)做了特殊處理,即使在代碼執(zhí)行異常,也不會(huì)導(dǎo)致鎖的釋放,所以不會(huì)造成鎖泄漏。
非公平鎖
內(nèi)部鎖的策略走的是非公平鎖,也就是有可能會(huì)造成線程饑餓,但是不會(huì)增加線程上下文切換的開(kāi)銷
內(nèi)部鎖的基本用法:
Thread threadA = new Thread(new Runnable() {public void run() {lock1();}},"my-ThreadB");threadA.start();Thread threadB = new Thread(new Runnable() {public void run() {lock2();}},"my-ThreadA");threadB.start();
private final String lockTest = "test";private void lock1(){Log.e("線程測(cè)試","threadA開(kāi)始獲取鎖");synchronized (lockTest){Log.e("線程測(cè)試","threadA拿到內(nèi)部鎖,開(kāi)始執(zhí)行臨界區(qū)事務(wù)");try {Thread.sleep(2000);} catch (InterruptedException e) {e.printStackTrace();}Log.e("線程測(cè)試","threadA臨界區(qū)事務(wù)執(zhí)行完成,釋放鎖");}}private void lock2(){Log.e("線程測(cè)試","threadB開(kāi)始獲取鎖");synchronized (lockTest){Log.e("線程測(cè)試","threadB拿到內(nèi)部鎖,開(kāi)始執(zhí)行臨界區(qū)事務(wù)");}Log.e("線程測(cè)試","threadB臨界區(qū)事務(wù)執(zhí)行完成,釋放鎖");}
程序執(zhí)行結(jié)果:

05
—
顯式鎖
想做到線程同步,方案不止內(nèi)部鎖一種,而當(dāng)內(nèi)部鎖不滿足某些特定場(chǎng)景需求的時(shí)候,則可以選擇使用顯式鎖使用更靈活的功能。
我們正常使用顯式鎖的操作是用Lock接口來(lái)實(shí)現(xiàn),Lock接口對(duì)顯式鎖進(jìn)行了抽象,ReentrantLock則是Lock接口的實(shí)現(xiàn)類。
ReentrantLock

我們可以根據(jù)ReentrantLock的源碼重載方法分析到,其實(shí)顯式鎖是支持公平/非公平鎖,因?yàn)楣芥i的上下文開(kāi)銷比較大,所以默認(rèn)不做配置則是非公平策略

顯式鎖的臨界區(qū)
Lock接口提供了lock()與unlock()方法,代表的是鎖定和解鎖的操作,這兩個(gè)方法間的代碼塊就是顯式鎖的臨界區(qū)
鎖泄漏
顯式鎖和內(nèi)部鎖不一樣,如果操作不當(dāng)會(huì)造成鎖泄漏,所以必須要手動(dòng)釋放鎖
顯式鎖的基本用法
private final String lockTest = "test";private void lock1(){Log.e("線程測(cè)試","threadA開(kāi)始獲取鎖");synchronized (lockTest){Log.e("線程測(cè)試","threadA拿到內(nèi)部鎖,開(kāi)始執(zhí)行臨界區(qū)事務(wù)");try {Thread.sleep(2000);} catch (InterruptedException e) {e.printStackTrace();}Log.e("線程測(cè)試","threadA臨界區(qū)事務(wù)執(zhí)行完成,釋放鎖");????}}private void lock2(){Log.e("線程測(cè)試","threadB開(kāi)始獲取鎖");synchronized (lockTest){Log.e("線程測(cè)試","threadB拿到內(nèi)部鎖,開(kāi)始執(zhí)行臨界區(qū)事務(wù)");}Log.e("線程測(cè)試","threadB臨界區(qū)事務(wù)執(zhí)行完成,釋放鎖");}
顯式鎖執(zhí)行結(jié)果:

顯式鎖獲取鎖的方法
lock()
獲取顯式鎖,如果獲取成功則執(zhí)行臨界區(qū)的代碼,獲取失敗則線程進(jìn)入阻塞狀態(tài)。
tryLock()
獲取顯式鎖,獲取成功后返回true,失敗返回false,失敗之后不會(huì)讓線程進(jìn)入阻塞狀態(tài)。
基本使用方式如下:
//實(shí)例化Lock接口對(duì)象private final Lock lock = new ReentrantLock();//根據(jù)嘗試獲取鎖的值來(lái)判斷具體執(zhí)行的代碼if(lock.tryLock()) {try{//處理任務(wù)}catch(Exception ex){}finally{//當(dāng)獲取鎖成功時(shí)最后一定要記住finally去關(guān)閉鎖lock.unlock(); //釋放鎖}}else {//else時(shí)為未獲取鎖,則無(wú)需去關(guān)閉鎖//如果不能獲取鎖,則直接做其他事情}
tryLock(long time, TimeUnit unit)
是tryLock()的重載方法,功能一致,只不過(guò)參數(shù)可控在指定時(shí)間內(nèi)沒(méi)有獲取到鎖,才返回false。
lockInterruptibly()
與lock()方法區(qū)別在與lock方法是不可以通過(guò)Thread.interrupt來(lái)中斷線程的,而lockInterruptibly()方法其他線程可以通過(guò)Thread.interrupt中斷線程并且立即返回,簡(jiǎn)單來(lái)說(shuō)該方法被調(diào)用后一直阻塞到獲得鎖 但是接受中斷信號(hào),而lock()不接受中斷信號(hào)。
06
—
內(nèi)部鎖和顯式鎖的區(qū)別
靈活性
內(nèi)部鎖是基于代碼的鎖,鎖的獲取和釋放都是在方法塊里被動(dòng)執(zhí)行,缺乏靈活性。
顯式鎖是基于對(duì)象的鎖,鎖的獲取和釋放可以由開(kāi)發(fā)者自定義控制,會(huì)更加靈活。
鎖的調(diào)度策略
內(nèi)部鎖僅支持非公平鎖
顯式鎖支持公平鎖和非公平鎖,開(kāi)發(fā)者可以根據(jù)使用場(chǎng)景來(lái)控制
便利性
內(nèi)部鎖簡(jiǎn)單易用,鎖泄漏的處理系統(tǒng)已經(jīng)幫你處理好了,不需要額外投入精力去做釋放操作。
顯式鎖需要手動(dòng)獲取和釋放鎖,在某種未考慮到的特定場(chǎng)景下,就有可能會(huì)造成鎖泄漏。
阻塞
內(nèi)部鎖在獲取鎖的時(shí)候,如果獲取不到,則讓線程進(jìn)入等待隊(duì)列
顯式鎖接口提供了tryLock()的方法,如果獲取不到鎖,則直接返回false,不會(huì)導(dǎo)致線程阻塞。
適用場(chǎng)景
在多線程環(huán)境下如果臨界區(qū)的事務(wù)耗時(shí)短則考慮使用內(nèi)部鎖
在多線程環(huán)境下如果臨界區(qū)的事務(wù)耗時(shí)長(zhǎng)則考慮使用顯式鎖
總結(jié)
因篇幅原因本文主要是介紹了一部分線程的運(yùn)行環(huán)境、線程安全性的基礎(chǔ)理論以及鎖的相關(guān)知識(shí),請(qǐng)大家及時(shí)關(guān)注《Java線程安全(下)》,下文會(huì)將到讀寫(xiě)鎖、volatile、原子類型、線程活躍性、死鎖、鎖死、線程間的安全協(xié)作等。
感謝您的閱讀,祝您工作順利
