剖析面試最常見問題之Java 併發知識進階(上)
點關注,不迷路;持續更新Java相關技術及資訊!!!
1. synchronized 關鍵字
1.1. 說一說自己對於 synchronized 關鍵字的瞭解
synchronized關鍵字解決的是多個線程之間訪問資源的同步性,synchronized關鍵字可以保證被它修飾的方法或者代碼塊在任意時刻只能有一個線程執行。
另外,在 Java 早期版本中,synchronized屬於重量級鎖,效率低下,因爲監視器鎖(monitor)是依賴於底層的操作系統的 Mutex Lock 來實現的,Java 的線程是映射到操作系統的原生線程之上的。如果要掛起或者喚醒一個線程,都需要操作系統幫忙完成,而操作系統實現線程之間的切換時需要從用戶態轉換到內核態,這個狀態之間的轉換需要相對比較長的時間,時間成本相對較高,這也是爲什麼早期的 synchronized 效率低的原因。慶幸的是在 Java 6 之後 Java 官方對從 JVM 層面對synchronized 較大優化,所以現在的 synchronized 鎖效率也優化得很不錯了。JDK1.6對鎖的實現引入了大量的優化,如自旋鎖、適應性自旋鎖、鎖消除、鎖粗化、偏向鎖、輕量級鎖等技術來減少鎖操作的開銷。
1.2. 說說自己是怎麼使用 synchronized 關鍵字,在項目中用到了嗎
推薦:博主的一個【java技術交流小站】:點擊進入 暗號csdn
羣號:530720915 進羣驗證“csdn”
分享Java中高級開發進階資料、源碼、筆記、視頻、Dubbo、Redis、Netty、zookeeper、Springcloud、分佈式、高併發等架構技術架構教程
synchronized關鍵字最主要的三種使用方式:
修飾實例方法: 作用於當前對象實例加鎖,進入同步代碼前要獲得當前對象實例的鎖
修飾靜態方法: 也就是給當前類加鎖,會作用於類的所有對象實例,因爲靜態成員不屬於任何一個實例對象,是類成員( static 表明這是該類的一個靜態資源,不管new了多少個對象,只有一份)。所以如果一個線程A調用一個實例對象的非靜態 synchronized 方法,而線程B需要調用這個實例對象所屬類的靜態 synchronized 方法,是允許的,不會發生互斥現象,因爲訪問靜態 synchronized 方法佔用的鎖是當前類的鎖,而訪問非靜態 synchronized 方法佔用的鎖是當前實例對象鎖。
修飾代碼塊: 指定加鎖對象,對給定對象加鎖,進入同步代碼庫前要獲得給定對象的鎖。
總結: synchronized 關鍵字加到 static 靜態方法和 synchronized(class)代碼塊上都是是給 Class 類上鎖。synchronized 關鍵字加到實例方法上是給對象實例上鎖。儘量不要使用 synchronized(String a) 因爲JVM中,字符串常量池具有緩存功能!
下面我以一個常見的面試題爲例講解一下 synchronized 關鍵字的具體使用。
面試中面試官經常會說:“單例模式瞭解嗎?來給我手寫一下!給我解釋一下雙重檢驗鎖方式實現單例模式的原理唄!”
public class Singleton {
private volatile static Singleton uniqueInstance;
private Singleton() {
}
public static Singleton getUniqueInstance() {
//先判斷對象是否已經實例過,沒有實例化過才進入加鎖代碼
if (uniqueInstance == null) {
//類對象加鎖
synchronized (Singleton.class) {
if (uniqueInstance == null) {
uniqueInstance = new Singleton();
}
}
}
return uniqueInstance;
}
}
另外,需要注意 uniqueInstance 採用 volatile 關鍵字修飾也是很有必要。
uniqueInstance 採用 volatile 關鍵字修飾也是很有必要的, uniqueInstance = new Singleton(); 這段代碼其實是分爲三步執行:
- 爲 uniqueInstance 分配內存空間
- 初始化 uniqueInstance 將 uniqueInstance
- 指向分配的內存地址
但是由於 JVM 具有指令重排的特性,執行順序有可能變成 1->3->2。指令重排在單線程環境下不會出先問題,但是在多線程環境下會導致一個線程獲得還沒有初始化的實例。例如,線程 T1 執行了 1 和 3,此時 T2 調用 getUniqueInstance() 後發現 uniqueInstance 不爲空,因此返回 uniqueInstance,但此時 uniqueInstance 還未被初始化。
使用 volatile 可以禁止 JVM 的指令重排,保證在多線程環境下也能正常運行。
1.3. 講一下 synchronized 關鍵字的底層原理
synchronized 關鍵字底層原理屬於 JVM 層面。
① synchronized 同步語句塊的情況
public class SynchronizedDemo {
public void method() {
synchronized (this) {
System.out.println("synchronized 代碼塊");
}
}
}
通過 JDK 自帶的 javap 命令查看 SynchronizedDemo 類的相關字節碼信息:首先切換到類的對應目錄執行 javac SynchronizedDemo.java
命令生成編譯後的 .class 文件,然後執行javap -c -s -v -l SynchronizedDemo.class
。
從上面我們可以看出:
synchronized 同步語句塊的實現使用的是 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步代碼塊的開始位置,monitorexit 指令則指明同步代碼塊的結束位置。 當執行 monitorenter 指令時,線程試圖獲取鎖也就是獲取 monitor(monitor對象存在於每個Java對象的對象頭中,synchronized 鎖便是通過這種方式獲取鎖的,也是爲什麼Java中任意對象可以作爲鎖的原因) 的持有權。當計數器爲0則可以成功獲取,獲取後將鎖計數器設爲1也就是加1。相應的在執行 monitorexit 指令後,將鎖計數器設爲0,表明鎖被釋放。如果獲取對象鎖失敗,那當前線程就要阻塞等待,直到鎖被另外一個線程釋放爲止。
② synchronized 修飾方法的的情況
public class SynchronizedDemo2 {
public synchronized void method() {
System.out.println("synchronized 方法");
}
}
synchronized 修飾的方法並沒有 monitorenter 指令和 monitorexit 指令,取得代之的確實是 ACC_SYNCHRONIZED 標識,該標識指明瞭該方法是一個同步方法,JVM 通過該 ACC_SYNCHRONIZED 訪問標誌來辨別一個方法是否聲明爲同步方法,從而執行相應的同步調用。
1.4. 說說 JDK1.6 之後的synchronized 關鍵字底層做了哪些優化,可以詳細介紹一下這些優化嗎
JDK1.6 對鎖的實現引入了大量的優化,如偏向鎖、輕量級鎖、自旋鎖、適應性自旋鎖、鎖消除、鎖粗化等技術來減少鎖操作的開銷 。
鎖主要存在四種狀態,依次是:無鎖狀態、偏向鎖狀態、輕量級鎖狀態、重量級鎖狀態,他們會隨着競爭的激烈而逐漸升級。注意鎖可以升級不可降級,這種策略是爲了提高獲得鎖和釋放鎖的效率。
1.5. 談談 synchronized和ReentrantLock 的區別
① 兩者都是可重入鎖
兩者都是可重入鎖。“可重入鎖”概念是:自己可以再次獲取自己的內部鎖。比如一個線程獲得了某個對象的鎖,此時這個對象鎖還沒有釋放,當其再次想要獲取這個對象的鎖的時候還是可以獲取的,如果不可鎖重入的話,就會造成死鎖。同一個線程每次獲取鎖,鎖的計數器都自增1,所以要等到鎖的計數器下降爲0時才能釋放鎖。
② synchronized 依賴於 JVM 而 ReentrantLock 依賴於 API
synchronized 是依賴於 JVM 實現的,前面我們也講到了 虛擬機團隊在 JDK1.6 爲 synchronized 關鍵字進行了很多優化,但是這些優化都是在虛擬機層面實現的,並沒有直接暴露給我們。ReentrantLock 是 JDK 層面實現的(也就是 API 層面,需要 lock() 和 unlock() 方法配合 try/finally 語句塊來完成),所以我們可以通過查看它的源代碼,來看它是如何實現的。
③ ReentrantLock 比 synchronized 增加了一些高級功能
相比synchronized,ReentrantLock增加了一些高級功能。主要來說主要有三點:①等待可中斷;②可實現公平鎖;③可實現選擇性通知(鎖可以綁定多個條件)
- ReentrantLock提供了一種能夠中斷等待鎖的線程的機制,通過lock.lockInterruptibly()來實現這個機制。也就是說正在等待的線程可以選擇放棄等待,改爲處理其他事情。
- ReentrantLock可以指定是公平鎖還是非公平鎖。而synchronized只能是非公平鎖。所謂的公平鎖就是先等待的線程先獲得鎖。 ReentrantLock默認情況是非公平的,可以通過 ReentrantLock類的
ReentrantLock(boolean fair)
構造方法來制定是否是公平的。 - synchronized關鍵字與wait()和notify()/notifyAll()方法相結合可以實現等待/通知機制,ReentrantLock類當然也可以實現,但是需要藉助於Condition接口與newCondition() 方法。Condition是JDK1.5之後纔有的,它具有很好的靈活性,比如可以實現多路通知功能也就是在一個Lock對象中可以創建多個Condition實例(即對象監視器),線程對象可以註冊在指定的Condition中,從而可以有選擇性的進行線程通知,在調度線程上更加靈活。 在使用notify()/notifyAll()方法進行通知時,被通知的線程是由 JVM 選擇的,用ReentrantLock類結合Condition實例可以實現“選擇性通知” ,這個功能非常重要,而且是Condition接口默認提供的。而synchronized關鍵字就相當於整個Lock對象中只有一個Condition實例,所有的線程都註冊在它一個身上。如果執行notifyAll()方法的話就會通知所有處於等待狀態的線程這樣會造成很大的效率問題,而Condition實例的signalAll()方法 只會喚醒註冊在該Condition實例中的所有等待線程。
如果你想使用上述功能,那麼選擇ReentrantLock是一個不錯的選擇。
④ 性能已不是選擇標準
2. volatile關鍵字
2.1. 講一下Java內存模型
在 JDK1.2 之前,Java的內存模型實現總是從主存(即共享內存)讀取變量,是不需要進行特別的注意的。而在當前的 Java 內存模型下,線程可以把變量保存本地內存比如機器的寄存器)中,而不是直接在主存中進行讀寫。這就可能造成一個線程在主存中修改了一個變量的值,而另外一個線程還繼續使用它在寄存器中的變量值的拷貝,造成數據的不一致。
要解決這個問題,就需要把變量聲明爲volatile,這就指示 JVM,這個變量是不穩定的,每次使用它都到主存中進行讀取。
說白了, volatile 關鍵字的主要作用就是保證變量的可見性然後還有一個作用是防止指令重排序。
2.2. 說說 synchronized 關鍵字和 volatile 關鍵字的區別
synchronized關鍵字和volatile關鍵字比較
- volatile關鍵字是線程同步的輕量級實現,所以volatile性能肯定比synchronized關鍵字要好。但是volatile關鍵字只能用於變量而synchronized關鍵字可以修飾方法以及代碼塊。synchronized關鍵字在JavaSE1.6之後進行了主要包括爲了減少獲得鎖和釋放鎖帶來的性能消耗而引入的偏向鎖和輕量級鎖以及其它各種優化之後執行效率有了顯著提升,實際開發中使用 synchronized 關鍵字的場景還是更多一些。
- 多線程訪問volatile關鍵字不會發生阻塞,而synchronized關鍵字可能會發生阻塞
- volatile關鍵字能保證數據的可見性,但不能保證數據的原子性。synchronized關鍵字兩者都能保證。
- volatile關鍵字主要用於解決變量在多個線程之間的可見性,而 synchronized關鍵字解決的是多個線程之間訪問資源的同步性。
3. ThreadLocal
3.1. ThreadLocal簡介
通常情況下,我們創建的變量是可以被任何一個線程訪問並修改的。如果想實現每一個線程都有自己的專屬本地變量該如何解決呢? JDK中提供的ThreadLocal
類正是爲了解決這樣的問題。 ThreadLocal
類主要解決的就是讓每個線程綁定自己的值,可以將ThreadLocal
類形象的比喻成存放數據的盒子,盒子中可以存儲每個線程的私有數據。
如果你創建了一個ThreadLocal
變量,那麼訪問這個變量的每個線程都會有這個變量的本地副本,這也是ThreadLocal
變量名的由來。他們可以使用 get()
和 set()
方法來獲取默認值或將其值更改爲當前線程所存的副本的值,從而避免了線程安全問題。
再舉個簡單的例子:
比如有兩個人去寶屋收集寶物,這兩個共用一個袋子的話肯定會產生爭執,但是給他們兩個人每個人分配一個袋子的話就不會出現這樣的問題。如果把這兩個人比作線程的話,那麼ThreadLocal就是用來這兩個線程競爭的。
3.2. ThreadLocal示例
相信看了上面的解釋,大家已經搞懂 ThreadLocal 類是個什麼東西了。
import java.text.SimpleDateFormat;
import java.util.Random;
public class ThreadLocalExample implements Runnable{
// SimpleDateFormat 不是線程安全的,所以每個線程都要有自己獨立的副本
private static final ThreadLocal<SimpleDateFormat> formatter = ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyyMMdd HHmm"));
public static void main(String[] args) throws InterruptedException {
ThreadLocalExample obj = new ThreadLocalExample();
for(int i=0 ; i<10; i++){
Thread t = new Thread(obj, ""+i);
Thread.sleep(new Random().nextInt(1000));
t.start();
}
}
@Override
public void run() {
System.out.println("Thread Name= "+Thread.currentThread().getName()+" default Formatter = "+formatter.get().toPattern());
try {
Thread.sleep(new Random().nextInt(1000));
} catch (InterruptedException e) {
e.printStackTrace();
}
//formatter pattern is changed here by thread, but it won't reflect to other threads
formatter.set(new SimpleDateFormat());
System.out.println("Thread Name= "+Thread.currentThread().getName()+" formatter = "+formatter.get().toPattern());
}
}
Output:
Thread Name= 0 default Formatter = yyyyMMdd HHmm
Thread Name= 0 formatter = yy-M-d ah:mm
Thread Name= 1 default Formatter = yyyyMMdd HHmm
Thread Name= 2 default Formatter = yyyyMMdd HHmm
Thread Name= 1 formatter = yy-M-d ah:mm
Thread Name= 3 default Formatter = yyyyMMdd HHmm
Thread Name= 2 formatter = yy-M-d ah:mm
Thread Name= 4 default Formatter = yyyyMMdd HHmm
Thread Name= 3 formatter = yy-M-d ah:mm
Thread Name= 4 formatter = yy-M-d ah:mm
Thread Name= 5 default Formatter = yyyyMMdd HHmm
Thread Name= 5 formatter = yy-M-d ah:mm
Thread Name= 6 default Formatter = yyyyMMdd HHmm
Thread Name= 6 formatter = yy-M-d ah:mm
Thread Name= 7 default Formatter = yyyyMMdd HHmm
Thread Name= 7 formatter = yy-M-d ah:mm
Thread Name= 8 default Formatter = yyyyMMdd HHmm
Thread Name= 9 default Formatter = yyyyMMdd HHmm
Thread Name= 8 formatter = yy-M-d ah:mm
Thread Name= 9 formatter = yy-M-d ah:mm
從輸出中可以看出,Thread-0已經改變了formatter的值,但仍然是thread-2默認格式化程序與初始化值相同,其他線程也一樣。
上面有一段代碼用到了創建 ThreadLocal
變量的那段代碼用到了 Java8 的知識,它等於下面這段代碼,如果你寫了下面這段代碼的話,IDEA會提示你轉換爲Java8的格式(IDEA真的不錯!)。因爲ThreadLocal類在Java 8中擴展,使用一個新的方法withInitial()
,將Supplier功能接口作爲參數。
private static final ThreadLocal<SimpleDateFormat> formatter = new ThreadLocal<SimpleDateFormat>(){
@Override
protected SimpleDateFormat initialValue()
{
return new SimpleDateFormat("yyyyMMdd HHmm");
}
};
3.3. ThreadLocal原理
從 Thread類源代碼入手。
public class Thread implements Runnable {
......
//與此線程有關的ThreadLocal值。由ThreadLocal類維護
ThreadLocal.ThreadLocalMap threadLocals = null;
//與此線程有關的InheritableThreadLocal值。由InheritableThreadLocal類維護
ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;
......
}
從上面Thread
類 源代碼可以看出Thread
類中有一個 threadLocals
和 一個 inheritableThreadLocals
變量,它們都是 ThreadLocalMap
類型的變量,我們可以把 ThreadLocalMap
理解爲ThreadLocal
類實現的定製化的 HashMap
。默認情況下這兩個變量都是null,只有當前線程調用 ThreadLocal
類的 set
或get
方法時才創建它們,實際上調用這兩個方法的時候,我們調用的是ThreadLocalMap
類對應的 get()
、set()
方法。
ThreadLocal
類的set()
方法
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
}
ThreadLocalMap getMap(Thread t) {
return t.threadLocals;
}
通過上面這些內容,我們足以通過猜測得出結論:最終的變量是放在了當前線程的 ThreadLocalMap
中,並不是存在 ThreadLocal
上,ThreadLocal
可以理解爲只是ThreadLocalMap
的封裝,傳遞了變量值。
每個Thread
中都具備一個ThreadLocalMap
,而ThreadLocalMap
可以存儲以ThreadLocal
爲key的鍵值對。這裏解釋了爲什麼每個線程訪問同一個ThreadLocal
,得到的確是不同的數值。另外,ThreadLocal
是 map結構是爲了讓每個線程可以關聯多個 ThreadLocal
變量。
ThreadLocalMap
是ThreadLocal
的靜態內部類。
3.4. ThreadLocal 內存泄露問題
ThreadLocalMap
中使用的 key 爲 ThreadLocal
的弱引用,而 value 是強引用。所以,如果 ThreadLocal
沒有被外部強引用的情況下,在垃圾回收的時候會 key 會被清理掉,而 value 不會被清理掉。這樣一來,ThreadLocalMap
中就會出現key爲null的Entry。假如我們不做任何措施的話,value 永遠無法被GC 回收,這個時候就可能會產生內存泄露。ThreadLocalMap
實現中已經考慮了這種情況,在調用 set()
、get()
、remove()
方法的時候,會清理掉 key 爲 null 的記錄。使用完 ThreadLocal
方法後 最好手動調用remove
()方法
static class Entry extends WeakReference<ThreadLocal<?>> {
/** The value associated with this ThreadLocal. */
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
弱引用介紹:
如果一個對象只具有弱引用,那就類似於可有可無的生活用品。弱引用與軟引用的區別在於:只具有弱引用的對象擁有更短暫的生命週期。在垃圾回收器線程掃描它
所管轄的內存區域的過程中,一旦發現了只具有弱引用的對象,不管當前內存空間足夠與否,都會回收它的內存。不過,由於垃圾回收器是一個優先級很低的線程,
因此不一定會很快發現那些只具有弱引用的對象。弱引用可以和一個引用隊列(ReferenceQueue)聯合使用,如果弱引用所引用的對象被垃圾回收,Java虛擬機就會把這個弱引用加入到與之關聯的引用隊列中。
既然讀到了這裏 別忘了在下評論’學習了’ 哦 還有前文的福利感謝 希望本文對大家有所幫助,一起進步,喜歡點個贊 評論 點下關注唄
以後將不斷持續更新~
【java技術交流小站】:點擊進入 暗號csdn
-
下編主題正在趕稿 :
《剖析面試最常見問題之Java 併發知識進階(下)》記得點關注哦