多線程并發(fā)問題彰亥,基本是面試必問的。
大部分同學應該都知道Synchronized衰齐,Lock任斋,部分同學能說到volatile、并發(fā)包耻涛,優(yōu)秀的同學則能在前面的基礎上废酷,說出Synchronized、volatile的原理抹缕,以及并發(fā)包中常用的數(shù)據(jù)結(jié)構(gòu)澈蟆,例如ConcurrentHashMap的原理。
一卓研、多線程為什么會有并發(fā)問題
為什么多線程同時訪問(讀寫)同個變量趴俘,會有并發(fā)問題?
Java 內(nèi)存模型規(guī)定了所有的變量都存儲在主內(nèi)存中奏赘,每條線程有自己的工作內(nèi)存寥闪。
線程的工作內(nèi)存中保存了該線程中用到的變量的主內(nèi)存副本拷貝,線程對變量的所有操作都必須在工作內(nèi)存中進行磨淌,而不能直接讀寫主內(nèi)存疲憋。
線程訪問一個變量,首先將變量從主內(nèi)存拷貝到工作內(nèi)存梁只,對變量的寫操作缚柳,不會馬上同步到主內(nèi)存。
不同的線程之間也無法直接訪問對方工作內(nèi)存中的變量敛纲,線程間變量的傳遞均需要自己的工作內(nèi)存和主存之間進行數(shù)據(jù)同步進行喂击。
二、Java 內(nèi)存模型(JMM)
Java 內(nèi)存模型(JMM) 作用于工作內(nèi)存(本地內(nèi)存)和主存之間數(shù)據(jù)同步過程淤翔,它規(guī)定了如何做數(shù)據(jù)同步以及什么時候做數(shù)據(jù)同步翰绊,如下圖。
三、并發(fā)三要素
- 原子性:在一個操作中监嗜,CPU 不可以在中途暫停然后再調(diào)度谐檀,即不被中斷操作,要么執(zhí)行完成裁奇,要么就不執(zhí)行桐猬。
- 可見性:多個線程訪問同一個變量時,一個線程修改了這個變量的值刽肠,其他線程能夠立即看得到修改的值溃肪。
- 有序性:程序執(zhí)行的順序按照代碼的先后順序執(zhí)行。
四音五、怎么做惫撰,才能解決并發(fā)問題?(重點)
下面結(jié)合不同場景分析解決并發(fā)問題的處理方式躺涝。
4.1 volatile 關(guān)鍵字
4.1.1 volatile 特性
保證可見性厨钻,不保證原子性
當寫一個volatile變量時,JVM會把本地內(nèi)存的變量強制刷新到主內(nèi)存中
這個寫操作導致其他線程中的緩存無效坚嗜,其他線程讀夯膀,會從主內(nèi)存讀。volatile的寫操作對其它線程實時可見苍蔬。
禁止指令重排序 指令重排序是指編譯器和處理器為了優(yōu)化程序性能對指令進行排序的一種手段诱建,需要遵守一定規(guī)則:
不會對存在依賴關(guān)系的指令重排序,例如 a = 1;b = a; a 和b存在依賴關(guān)系银室,不會被重排序
不能影響單線程下的執(zhí)行結(jié)果涂佃。比如:a=1;b=2;c=a+b這三個操作,前兩個操作可以重排序,但是c=a+b不會被重排序蜈敢,因為要保證結(jié)果是3
4.1.2 使用場景
對于一個變量辜荠,只有一個線程執(zhí)行寫操作,其它線程都是讀操作抓狭,這時候可以用 volatile 修飾這個變量伯病。
4.1.3 單例雙重鎖為什么要用到volatile?
public class TestInstance {
private static volatile TestInstance mInstance;
public static TestInstance getInstance(){ //1
if (mInstance == null){ //2
synchronized (TestInstance.class){ //3
if (mInstance == null){ //4
mInstance = new TestInstance(); //5
}
}
}
return mInstance;
}
假如沒有用volatile否过,并發(fā)情況下會出現(xiàn)問題午笛,線程A執(zhí)行到注釋5 new TestInstance() 的時候,分為如下幾個幾步操作:
- 分配內(nèi)存
- 初始化對象
- mInstance 指向內(nèi)存
這時候如果發(fā)生指令重排苗桂,執(zhí)行順序是132药磺,執(zhí)行到第3的時候,線程B剛好進來了煤伟,并且執(zhí)行到注釋2癌佩,這時候判斷mInstance 不為空木缝,直接使用一個未初始化的對象。所以使用volatile關(guān)鍵字來禁止指令重排序围辙。
4.1.4 volatile 原理
在JVM底層volatile是采用內(nèi)存屏障來實現(xiàn)的我碟,內(nèi)存屏障會提供3個功能:
它確保指令重排序時不會把其后面的指令排到內(nèi)存屏障之前的位置,也不會把前面的指令排到內(nèi)存屏障的后面姚建;即在執(zhí)行到內(nèi)存屏障這句指令時矫俺,在它前面的操作已經(jīng)全部完成;
它會強制將緩存的修改操作立即寫到主內(nèi)存
寫操作會導致其它CPU中的緩存行失效掸冤,寫之后厘托,其它線程的讀操作會從主內(nèi)存讀。
4.1.5 volatile 的局限性
volatile 只能保證可見性稿湿,不能保證原子性寫操作對其它線程可見催烘,但是不能解決多個線程同時寫的問題。
二缎罢、Synchronized
4.2.1 Synchronized 使用場景
多個線程同時寫一個變量。
例如售票考杉,余票是100張策精,窗口A和窗口B同時各賣出一張票, 假如余票變量用 volatile 修飾崇棠,是有問題的咽袜。
A窗口獲取余票是100,B窗口獲取余票也是100枕稀,A賣出一張變成99询刹,刷新回主內(nèi)存,同時B賣出一張變成99萎坷,也刷新回主內(nèi)存凹联,會導致最終主內(nèi)存余票是99而不是98。
前面說到 volatile 的局限性哆档,就是多個線程同時寫的情況蔽挠,這種情況一般可以使用Synchronized。
Synchronized 可以保證同一時刻瓜浸,只有一個線程可執(zhí)行某個方法或某個代碼塊澳淑。
4.2.2 Synchronized 原理
public class SynchronizedTest {
public static void main(String[] args) {
synchronized (SynchronizedTest.class) {
System.out.println("123");
}
method();
}
private static void method() {
}
}
將這段代碼先用javac命令編譯,再java p -v SynchronizedTest.class命令查看字節(jié)碼插佛,部分字節(jié)碼如下
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/lanshifu/opengldemo/test/SynchronizedTest
2: dup
3: astore_1
4: monitorenter
5: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
8: ldc #4 // String 123
10: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
13: aload_1
14: monitorexit
15: goto 23
18: astore_2
19: aload_1
20: monitorexit
21: aload_2
22: athrow
23: invokestatic #6 // Method method:()V
26: return
可以看到 4: monitorenter 和 14: monitorexit杠巡,中間是打印的語句。
執(zhí)行同步代碼塊雇寇,首先會執(zhí)行monitorenter指令氢拥,然后執(zhí)行同步代碼塊中的代碼蚌铜,退出同步代碼塊的時候會執(zhí)行monitorexit指令 。
使用Synchronized進行同步兄一,其關(guān)鍵就是必須要對對象的監(jiān)視器monitor進行獲取厘线,當線程獲取monitor后才能繼續(xù)往下執(zhí)行,否則就進入同步隊列出革,線程狀態(tài)變成BLOCK造壮,同一時刻只有一個線程能夠獲取到monitor,當監(jiān)聽到monitorexit被調(diào)用骂束,隊列里就有一個線程出隊耳璧,獲取monitor。詳情參考:www.reibang.com/p/d53bf830f…
每個對象擁有一個計數(shù)器展箱,當線程獲取該對象鎖后旨枯,計數(shù)器就會加一,釋放鎖后就會將計數(shù)器減一混驰,所以只要這個鎖的計數(shù)器大于0攀隔,其它線程訪問就只能等待。
4.2.3 Synchronized 鎖的升級
大家對Synchronized的理解可能就是重量級鎖栖榨,但是Java1.6對 Synchronized 進行了各種優(yōu)化之后昆汹,有些情況下它就并不那么重,Java1.6 中為了減少獲得鎖和釋放鎖帶來的性能消耗而引入的偏向鎖和輕量級鎖婴栽。
偏向鎖: 大多數(shù)情況下满粗,鎖不僅不存在多線程競爭,而且總是由同一線程多次獲得愚争,為了讓線程獲得鎖的代價更低而引入了偏向鎖映皆。
當一個線程A訪問加了同步鎖的代碼塊時,會在對象頭中存 儲當前線程的id轰枝,后續(xù)這個線程進入和退出這段加了同步鎖的代碼塊時捅彻,不需要再次加鎖和釋放鎖。
輕量級鎖: 在偏向鎖情況下鞍陨,如果線程B也訪問了同步代碼塊沟饥,比較對象頭的線程id不一樣,會升級為輕量級鎖湾戳,并且通過自旋的方式來獲取輕量級鎖贤旷。
重量級鎖: 如果線程A和線程B同時訪問同步代碼塊,則輕量級鎖會升級為重量級鎖砾脑,線程A獲取到重量級鎖的情況下幼驶,線程B只能入隊等待,進入BLOCK狀態(tài)韧衣。
4.2.4 Synchronized 缺點
- 不能設置鎖超時時間
- 不能通過代碼釋放鎖
- 容易造成死鎖
三盅藻、ReentrantLock
上面說到Synchronized的缺點购桑,不能設置鎖超時時間和不能通過代碼釋放鎖,ReentranLock就可以解決這個問題氏淑。
在多個條件變量和高度競爭鎖的地方勃蜘,用ReentrantLock更合適,ReentrantLock還提供了Condition假残,對線程的等待和喚醒等操作更加靈活缭贡,一個ReentrantLock可以有多個Condition實例,所以更有擴展性辉懒。
4.3.1 ReentrantLock 的使用
lock 和 unlock
ReentrantLock reentrantLock = new ReentrantLock();
System.out.println("reentrantLock->lock");
reentrantLock.lock();
try {
System.out.println("睡眠2秒...");
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}finally {
reentrantLock.unlock();
System.out.println("reentrantLock->unlock");
}
實現(xiàn)可定時的鎖請求:tryLock
public static void main(String[] args) {
ReentrantLock reentrantLock = new ReentrantLock();
Thread thread1 = new Thread_tryLock(reentrantLock);
thread1.setName("thread1");
thread1.start();
Thread thread2 = new Thread_tryLock(reentrantLock);
thread2.setName("thread2");
thread2.start();
}
static class Thread_tryLock extends Thread {
ReentrantLock reentrantLock;
public Thread_tryLock(ReentrantLock reentrantLock) {
this.reentrantLock = reentrantLock;
}
@Override
public void run() {
try {
System.out.println("try lock:" + Thread.currentThread().getName());
boolean tryLock = reentrantLock.tryLock(3, TimeUnit.SECONDS);
if (tryLock) {
System.out.println("try lock success :" + Thread.currentThread().getName());
System.out.println("睡眠一下:" + Thread.currentThread().getName());
Thread.sleep(5000);
System.out.println("醒了:" + Thread.currentThread().getName());
} else {
System.out.println("try lock 超時 :" + Thread.currentThread().getName());
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
System.out.println("unlock:" + Thread.currentThread().getName());
reentrantLock.unlock();
}
}
}
打印的日志:
try lock:thread1
try lock:thread2
try lock success :thread2
睡眠一下:thread2
try lock 超時 :thread1
unlock:thread1
Exception in thread "thread1" java.lang.IllegalMonitorStateException
at java.util.concurrent.locks.ReentrantLock$Sync.tryRelease(ReentrantLock.java:151)
at java.util.concurrent.locks.AbstractQueuedSynchronizer.release(AbstractQueuedSynchronizer.java:1261)
at java.util.concurrent.locks.ReentrantLock.unlock(ReentrantLock.java:457)
at com.lanshifu.demo_module.test.lock.ReentranLockTest$Thread_tryLock.run(ReentranLockTest.java:60)
醒了:thread2
unlock:thread2
上面演示了trtLock的使用阳惹,trtLock設置獲取鎖的等待時間,超過3秒直接返回失敗眶俩,可以從日志中看到結(jié)果莹汤。 有異常是因為thread1獲取鎖失敗,不應該調(diào)用unlock颠印。
4.3.2 Condition 條件
public static void main(String[] args) {
Thread_Condition thread_condition = new Thread_Condition();
thread_condition.setName("測試Condition的線程");
thread_condition.start();
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
thread_condition.singal();
}
static class Thread_Condition extends Thread {
@Override
public void run() {
await();
}
private ReentrantLock lock = new ReentrantLock();
public Condition condition = lock.newCondition();
public void await() {
try {
System.out.println("lock");
lock.lock();
System.out.println(Thread.currentThread().getName() + ":我在等待通知的到來...");
condition.await();//await 和 signal 對應
//condition.await(2, TimeUnit.SECONDS); //設置等待超時時間
System.out.println(Thread.currentThread().getName() + ":等到通知了纲岭,我繼續(xù)執(zhí)行>>>");
} catch (Exception e) {
e.printStackTrace();
} finally {
System.out.println("unlock");
lock.unlock();
}
}
public void singal() {
try {
System.out.println("lock");
lock.lock();
System.out.println("我要通知在等待的線程,condition.signal()");
condition.signal();//await 和 signal 對應
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
System.out.println("unlock");
lock.unlock();
}
}
}
運行打印日志
lock
測試Condition的線程:我在等待通知的到來...
lock
我要通知在等待的線程线罕,condition.signal()
unlock
測試Condition的線程:等到通知了荒勇,我繼續(xù)執(zhí)行>>>
unlock
上面演示了Condition的 await 和 signal 使用,前提要先lock闻坚。
4.3.3 公平鎖與非公平鎖
ReentrantLock 構(gòu)造函數(shù)傳true表示公平鎖。
公平鎖表示線程獲取鎖的順序是按照線程加鎖的順序來分配的兢孝,即先來先得的順序窿凤。而非公平鎖就是一種鎖的搶占機制,是隨機獲得鎖的跨蟹,可能會導致某些線程一致拿不到鎖雳殊,所以是不公平的。
4.3.4 ReentrantLock 注意點
- ReentrantLock使用lock和unlock來獲得鎖和釋放鎖
- unlock要放在finally中窗轩,這樣正常運行或者異常都會釋放鎖
- 使用condition的await和signal方法之前夯秃,必須調(diào)用lock方法獲得對象監(jiān)視器
四、并發(fā)包
通過上面分析痢艺,并發(fā)嚴重的情況下仓洼,使用鎖顯然效率低下,因為同一時刻只能有一個線程可以獲得鎖堤舒,其它線程只能乖乖等待色建。
Java提供了并發(fā)包解決這個問題,接下來介紹并發(fā)包里一些常用的數(shù)據(jù)結(jié)構(gòu)舌缤。
我們都知道HashMap是線程不安全的數(shù)據(jù)結(jié)構(gòu)箕戳,HashTable則在HashMap基礎上某残,get方法和put方法加上Synchronized修飾變成線程安全,不過在高并發(fā)情況下效率底下陵吸,最終被ConcurrentHashMap替代刨晴。
ConcurrentHashMap 采用分段鎖,內(nèi)部默認有16個桶圈膏,get和put操作思瘟,首先將key計算hashcode,然后跟16取余旨指,落到16個桶中的一個赏酥,然后每個桶中都加了鎖(ReentrantLock),桶中是HashMap結(jié)構(gòu)(數(shù)組加鏈表谆构,鏈表過長轉(zhuǎn)紅黑樹)裸扶。
所以理論上最多支持16個線程同時訪問。
4.4.2 LinkBlockingQueue
鏈表結(jié)構(gòu)的阻塞隊列搬素,內(nèi)部使用多個ReentrantLock
/** Lock held by take, poll, etc */
private final ReentrantLock takeLock = new ReentrantLock();
/** Wait queue for waiting takes */
private final Condition notEmpty = takeLock.newCondition();
/** Lock held by put, offer, etc */
private final ReentrantLock putLock = new ReentrantLock();
/** Wait queue for waiting puts */
private final Condition notFull = putLock.newCondition();
private void signalNotEmpty() {
final ReentrantLock takeLock = this.takeLock;
takeLock.lock();
try {
notEmpty.signal();
} finally {
takeLock.unlock();
}
}
/**
* Signals a waiting put. Called only from take/poll.
*/
private void signalNotFull() {
final ReentrantLock putLock = this.putLock;
putLock.lock();
try {
notFull.signal();
} finally {
putLock.unlock();
}
}
源碼不貼太多呵晨,簡單說一下LinkBlockingQueue 的邏輯:
從隊列獲取數(shù)據(jù),如果隊列中沒有數(shù)據(jù)熬尺,會調(diào)用notEmpty.await();進入等待摸屠。
在放數(shù)據(jù)進去隊列的時候會調(diào)用notEmpty.signal();,通知消費者粱哼,1中的等待結(jié)束季二,喚醒繼續(xù)執(zhí)行。
從隊列里取到數(shù)據(jù)的時候會調(diào)用notFull.signal();揭措,通知生產(chǎn)者繼續(xù)生產(chǎn)胯舷。
在put數(shù)據(jù)進入隊列的時候,如果判斷隊列中的數(shù)據(jù)達到最大值绊含,那么會調(diào)用notFull.await();桑嘶,等待消費者消費掉,也就是等待3去取數(shù)據(jù)并且發(fā)出notFull.signal();躬充,這時候生產(chǎn)者才能繼續(xù)生產(chǎn)逃顶。
LinkBlockingQueue 是典型的生產(chǎn)者消費者模式,源碼細節(jié)就不多說充甚。
4.4.3 原子操作類:AtomicInteger
內(nèi)部采用CAS(compare and swap)保證原子性
舉一個int自增的例子
AtomicInteger atomicInteger = new AtomicInteger(0);
atomicInteger.incrementAndGet();//自增
五以政、總結(jié)
- 當只有一個線程寫,其它線程都是讀的時候伴找,可以用volatile修飾變量
- 當多個線程寫妙蔗,那么一般情況下并發(fā)不嚴重的話可以用Synchronized,Synchronized并不是一開始就是重量級鎖疆瑰,在并發(fā)不嚴重的時候眉反,比如只有一個線程訪問的時候昙啄,是偏向鎖;當多個線程訪問寸五,但不是同時訪問梳凛,這時候鎖升級為輕量級鎖;當多個線程同時訪問梳杏,這時候升級為重量級鎖韧拒。所以在并發(fā)不是很嚴重的情況下,使用Synchronized是可以的十性。不過Synchronized有局限性叛溢,比如不能設置鎖超時,不能通過代碼釋放鎖劲适。
- ReentranLock 可以通過代碼釋放鎖楷掉,可以設置鎖超時。
4.高并發(fā)下霞势,Synchronized烹植、ReentranLock 效率低,因為同一時刻只有一個線程能進入同步代碼塊愕贡,如果同時有很多線程訪問草雕,那么其它線程就都在等待鎖。這個時候可以使用并發(fā)包下的數(shù)據(jù)結(jié)構(gòu)固以,例如ConcurrentHashMap墩虹,LinkBlockingQueue,以及原子性的數(shù)據(jù)結(jié)構(gòu)如:AtomicInteger憨琳。
面試的時候按照上面總結(jié)的這個思路回答基本就ok了诫钓。既然說到并發(fā)包,那么除了ConcurrentHashMap栽渴,其它一些常用的數(shù)據(jù)結(jié)構(gòu)的原理也需要去了解下,例如HashMap稳懒、HashTable闲擦、TreeMap原理,ArrayList场梆、LinkedList對比墅冷,這些都是老生常談的,自己去看源碼或者一些博客或油。