通过踩坑带你读透虚拟机的“锁粗化”
之前在學習volatile時,踩過一些坑。通過這些坑,學習了一些jvm的鎖優化機制。后來在面試的過程中,被問到的概率還挺高。于是,我整理了這篇踩坑記錄。
1. java多線程內存模型
在聊踩坑記錄前,先要了解下java多線程內存模型。大家可通過“并發編程網”的一篇文章去學習這塊知識,網址是http://ifeve.com/java-memory-model-1/。下面截取部分段落,先讓大家熟悉下。
在java中,所有實例域、靜態域和數組元素存儲在堆內存中,堆內存在線程之間共享(本文使用“共享變量”這個術語代指實例域,靜態域和數組元素)。
局部變量(Local variables),方法定義參數(java語言規范稱之為formal method parameters)和異常處理器參數(exception handler parameters)不會在線程之間共享,它們不會有內存可見性問題,也不受內存模型的影響。
Java線程之間的通信由Java內存模型(本文簡稱為JMM)控制,JMM決定一個線程對共享變量的寫入何時對另一個線程可見。
從抽象的角度來看,JMM定義了線程和主內存之間的抽象關系:線程之間的共享變量存儲在主內存(main memory)中,每個線程都有一個私有的本地內存(local memory),本地內存中存儲了該線程以讀/寫共享變量的副本。
本地內存是JMM的一個抽象概念,并不真實存在。它涵蓋了緩存,寫緩沖區,寄存器以及其他的硬件和編譯器優化。
Java內存模型的抽象示意圖如下:
多線程內存模型
從上圖來看,線程A與線程B之間如要通信的話,必須要經歷下面2個步驟:
1、首先,線程A把本地內存A中更新過的共享變量副本刷新到主內存中去。
2、然后,線程B到主內存中去讀取線程A之前已更新過的共享變量。
上面內容可以總結如下:
1、多線程在運行時,會有主內存和工作內存的區分。 2、每個線程都有各自的工作內存,工作內存會復制一份主內存的變量副本。 3、線程其后的運行,都是修改工作內存中的變量副本。然后在某個時間,再同步到主存中。 4、這種工作機制,可能使得多個線程在同一個時刻獲取到的變量值不同。2. volatile關鍵字的作用
2.1. volatile關鍵字語義
共享變量被volatile修飾之后,那么就具備了兩層語義:
1)保證了不同線程對這個變量進行操作時的可見性,即一個線程修改了某個變量的值,這新值對其他線程來說是立即可見的。
2)禁止進行指令重排序。
2.2. volatile關鍵字如何保證線程間的可見性?
1、使用volatile關鍵字,線程會將修改的值立即同步至主內存中
2、使用volatile關鍵字,線程會強制從主存中讀取值。
3、所以,這就保證了某個線程修改的值,會立即被其余線程獲得。
2.3. volatile關鍵字不保證原子性
volatile并不能代替synchronized關鍵字,因為它不能保證原子性。
下面給大家舉個例子:
1、多個線程對變量i進行自增操作。 2、A線程從主存中獲得變量i的值,為6. 3、在A獲取主存的值后,B線程將運算結果7同步至主存。 4、A線程對變量i進行i++操作,然后同步至主存。主存結果依然為7。這時i++明顯小于預期結果。造成上述原因,就是因為volatile關鍵字不能保證自增操作的原子性。
3. 踩坑之synchronized的可見性
看完java多線程模型和volatile關鍵字的作用,我們正式來聊踩坑記錄。
public class VolatileTest implements Runnable {public static String name = "dog";@Overridepublic void run() {while (true) {System.out.println(name);}}public static void main(String[] args) throws InterruptedException {VolatileTest volatileTest = new VolatileTest();Thread thread = new Thread(volatileTest);thread.start();// 讓主線程睡一段時間,保證子線程的開啟。Thread.sleep(5000);VolatileTest.name = "wangcai";} }上述的name字段,我并沒有加volatile關鍵字。我還調用了Thread.sleep(5000);,以便讓子線程先開啟。
按照多線程模型的描述,子線程里的name字段應該是拷貝的變量副本“dog”。所以我在主線程修改name值為“wangcai”,并不對子線程可見。所以,按理來說,應無限循環打印“dog”。但事實上,打印結果如下:
dog dog dog wangcai wangcai wangcai這和上面的原理不符啊,一度讓我十分困惑。后來我翻了下System.out.println的源碼,發現其源碼如下:
public void println(String x) {synchronized (this) {print(x);newLine();} }看到源碼,答案也就呼之欲出了。因為println方法添加了synchronized關鍵字。synchronized不僅能保證原子性,還能保證代碼塊里變量的可見性。所以,每次打印的值都是從主存中獲取的,自然也就變為了“wangcai”。
4. 踩坑之我以為我懂了
發現上述原因后,我決定不再用System.out.println打印變量,這樣就不會觸發從主存中讀取數據。然而我還是太天真,事情的發展就是這么曲折。
我修改的代碼如下:
public class VolatileTest implements Runnable {public static String name = "dog";@Overridepublic void run() {for (; ; ) {if ("wangcai".equals(name)) {break;}System.out.println("我不是旺財");}}public static void main(String[] args) throws InterruptedException {VolatileTest volatileTest = new VolatileTest();Thread thread = new Thread(volatileTest);thread.start();Thread.sleep(5000);VolatileTest.name = "wangcai";} }這次我仍然沒有添加volatile關鍵字,更沒有打印name變量。按理說,這次應該無限循環打印“我不是旺財”了吧。但是線程跳出循環,并停止了。這時,我已經開始對多線程模型產生動搖了。經過探索,我又知道了“鎖粗化”的概念。
5. 鎖粗化
下面,我們看看《深入理解java虛擬機》對鎖粗化的描述:
原則上,我們在編寫代碼的時候,總是推薦將同步塊的作用范圍限制得盡量小-只在共享數據的實際作用域中才進行同步,這樣是為了使得需要同步的操作數量盡可能變小,如果存在鎖競爭,那等待鎖的線程也能盡快拿到鎖。
大部分情況下,上面的原則都是正確的,但是如果一系列的連續操作都對同一個對象反復加鎖和解鎖,甚至加鎖操作是出現在循環體中的,那即使沒有線程競爭,頻繁地進行互斥同步操作也會導致不必要的性能損耗。
如果虛擬機探測到有這樣零碎的操作都對統一對象加鎖,將會把加鎖同步的范圍擴展(粗化)到整個操作序列的外部。
將原代碼生成的class文件進行反編譯,得到如下代碼:
public void run() {while(!"wangcai".equals(name)) {System.out.println("我不是旺財");} }于是,while循環里的System.out.println("我不是旺財");具有同步代碼塊,每次都對PrintStream加鎖。于是,經過虛擬機的鎖粗化,鎖擴展到了外部,可見性也擴展到了外部。所以子線程能看見主線程對name的改變,所以會讓線程跳出,并停止。
6. 守得云開見月明
public class Test implements Runnable {private static String name = "dog";@Overridepublic void run() {while (true) {if ("wangcai".equals(name)) {System.out.println(name);break;}}}public static void main(String[] args) throws InterruptedException {Test test = new Test();Thread thread = new Thread(test);thread.start();Thread.sleep(5000);Test.name = "wangcai";} }最終,將代碼改成如上的樣式。不加volatile,主線程對name的改變,子線程不可見。所以線程會一直循環,不退出。
加了volatile,主線程的對name的改變,子線程是可見的。所以會打出“wangcai”,并退出。
看到這里,如果你有某些疑問,我會覺得你好好研讀上面的內容了。在while循環快中,我也加入了System.out.println函數,為什么沒有進行鎖粗化?這個依然是由反編譯后的代碼來決定的:
public void run() {while(!"wangcai".equals(name)) {;}System.out.println("我是旺財"); }通過反編譯得到的源碼,我們發現虛擬機對第二個代碼進行了優化,是將System.out.println("我是旺財");放在循環外的。而第一個優化后的代碼,是將System.out.println("我不是旺財");放在循環里的。
所以,第二個不會進行鎖粗化,而第一個會進行鎖粗化。
7. 總結
上面就是我在學習volatile關鍵字時,遇到的各種坑。但是通過踩坑,我不僅更加深入了解了volatile關鍵字,我也學會了虛擬機的鎖粗化機制。雖然我一開始是茫然的,但是我沒有放棄思考。每一次的難題,都會讓我彌補知識上的短板。走出自己的知識舒適區,你才能收獲成長。
通過實戰,你會更為扎實地掌握所學知識點。面試的時候,通過代碼向面試官闡述自己的思考過程,更能凸顯出你將理論融入實踐的能力,而不只是“紙上談兵”。
后面有機會,我還會和大家分享volatile關于“防止指令重排序”的特性以及其他鎖優化機制。
還是那句話,愿我們共同進步!
作者:永不言Qi QQ: 591232672 e-mail:stephenqi@qq.com 版權聲明:轉載請保留此鏈接,不得用于商業用途。 雖然我不是最優秀的程序員,但我還是想盡自己最大的努力,去分享一些學習心得。 如有錯誤,歡迎指正。若有幸能博得您的喜愛,歡迎關注及點贊哦。 愿我們共同進步!
作者:永不言Qi
鏈接:https://www.jianshu.com/p/f05423a21e78
來源:簡書
簡書著作權歸作者所有,任何形式的轉載都請聯系作者獲得授權并注明出處。
總結
以上是生活随笔為你收集整理的通过踩坑带你读透虚拟机的“锁粗化”的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 锁优化:逃逸分析、自旋锁、锁消除、锁粗化
- 下一篇: 1.6的锁优化(适应性自旋/锁粗化/锁削