重溫JVM(二)JVM內存模型

一、前言

上文講過了虛擬機的內存劃分,即,我們將內存分爲線程共享和線程私有。
線程共享的即java堆,和方法區。java堆大家可能都不會陌生;而方法區中包含了常量池,他也被稱爲永久代。通常方法區也會被叫做非堆,但是在邏輯上,他卻是java堆的一部分,而且有些虛擬機會將方法區直接與java堆合併。
線程私有的就是虛擬機棧了,而虛擬機棧,本地方法棧,以及程序計數器。這裏我們就不展開討論了。
上面我就簡單的回顧了虛擬機的內存劃分部分,下面開始正文。

二、java內存模型簡述

1、主內存

java內存模型規定了,所有的變量都必須存儲在主內存當中。

2、工作內存

每天線程私有的內存,即工作內存。
工作內存中保存了該線程所使用的變量的主內存的副本的拷貝。線程對變量所做的操作,都必須在工作內存中進行。
不同個的線程,無法訪問對方的工作內存變量,只能通過主內存,來達到線程、工作內存、主內存三者之間的信息交互。
簡圖如下:
在這裏插入圖片描述
主內存、工作內存,與我上一篇博客中講述的java內存區域中的堆、棧、方法區等,並不是同一個層次的內存劃分。
不同,爲了方便記憶,我們可以這麼理解:
主內存對應的是java堆中的實例數據部分,工作內存對應的是java虛擬機棧中的部分區域。
從計算機的組織原理來說,我們也可以這麼來理解,主內存對應的是物理硬件的內存,所以如果主內存與進程進行數據交互,它將是非常耗時的。
工作內存優先存儲在寄存器和高速緩存中,因爲程序在運行一般訪問的是工作內存。
(所以我在上篇博客的開頭就講了,拋開操作系統和組織原理來講虛擬機,就是在耍流氓 =_=)

三、關於原子性的二三事

1、從一段代碼開始

No BB, show code

    private static volatile  int i = 0;
    public static  void add(){
        i++;
    }

    public static void main(String [] args){

        for(int c=0; c<20; c++){
            Thread thread = new Thread(new Runnable() {
                @Override
                public void run() {
                    for(int k = 0; k<10000; k++){
                        add();
                    }
                }
            });
            thread.start();
        }
        while (Thread.activeCount()>1){
            Thread.yield();
        }
        System.out.println(i);
    }

如果你看過上面的代碼,那可以繼續閱讀,如果沒有見過上面的代碼,這裏建議思考下,最後輸出的值是多少?
顯而易見,結果並不是200000。(如果最後的結果就是200000,那麼我舉這個例子幹嘛 =。=)

2、虛擬機內部的原子操作

無論是長輩,還是其他人的建議,都提過,帶着問題閱讀的效率會比漫無目的閱讀,效果好很多,所以上面我提出了問題,下文自然是爲了解決問題而展開的額討論和說明。這裏,先從java虛擬機內存的操作開始講起。

lock:作用於主內存,將主內存的某變量標誌爲一條線程獨佔。
unlock:作用於主內存,將主內存中的變量,從鎖定狀態解放出來,解放出來的變量,纔可以重新被其他線程佔用。
read:作用於主內存,將主內存中的變量,從主內存傳輸到工作內存中。
load:作用於工作內存,將read到的值,放到工作內存的副本當中。
use:作用於工作內存,將工作內存中的一個變量,傳遞給執行引擎。當虛擬機執行的字節碼指令,運用到此值時,使用此操作。
assign:作用於工作內存,將從執行引擎接受到的值,賦值給工作內存的變量。每當執行字節碼的賦值語句時,會使用此操作。
store:作用於工作內存的變量,將工作內存中的變量,傳輸到主內存中。
write:作用於主內存,將store中從工作內存獲取到的變量,放到主內存的變量當中。

3、原子操作的劃分

原子操作分爲兩部分,一般,通過read、load、use、write等讀寫操作,就可以保證數據的原子性。
但是有時候我們需要整塊的業務代碼,都具有原子性時,就需要使用lock與unlock。

4、volatile說明

細心的同學可能已經發現了,我上面的代碼中。遍歷時被volatile聲明。
那麼volatile的作用是什麼呢?
一般來說,volatile變量對所有的線程,都是理解可見的。對於volatile變量所有的寫操作,都能理解反應到其他線程中。
換言之,volatile在所有線程中都是一致的,所以,所有基於volatile變量的運算在併發下都是安全的。
其實不然,volatile變量,並不能保證併發安全。

(1)執行結果對比
變量類型 執行結果1 執行結果2 執行結果3 執行結果4 執行結果5 平均值(去掉極值)
volatile 186632 196403 193658 197305 186825 192295
一般變量 178387 179369 189835 174015 199458 182530

我記錄了五次代碼的執行結果。如上表格所示。都不是我們的目標值200000。那是不是說明volatile聲明的變量和不進行聲明,是完全一致的呢?
非也,我在去掉了volatile聲明後,執行得到的結果,如上表格展示。
最後得出的結論是,加了volatile聲明,結果更加趨近目標值。造成這一現象的原因是什麼呢?

(2) 從字節碼開始說明

查看字節碼的方式有一般有兩種。
一是找到生產的class文件,執行 javap指令,查看編譯的代碼。
二是,如果你用的是idea編輯器(idea天下第一),你可以在選中要查看的java類後,點擊view菜單 點擊 Show Bytecode。
這兩種方式,我一般選擇方式二,方式二方便,且查看的代碼格式符合我的閱讀習慣。

 public static void add();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=0, args_size=0
         0: getstatic     #2                  // Field i:I
         3: iconst_1
         4: iadd
         5: putstatic     #2                  // Field i:I
         8: return
      LineNumberTable:
        line 12: 0
        line 13: 8
volatile聲明對象的字節碼指令執行流程說明

volatile變量聲明的對象,的的確確是,當他在主內存中的值發生變化,會立即反應到工作內存中。
這裏就有一個節點,也就是我們字節碼中的

getstatic

getstatic指令,此指令,是獲取了當前最新的實時的變量值。後續的對此值進行+1操作,然後返回。但是可能存在一個情況,就是在執行+1操作或者返回操作時,其他線程對這個值進行了處理,導致此線程返回的值並不是正確值了。
可能還是不太理解,我們模擬一下場景。

  • 場景①
    時刻1:線程A獲取了此值1。(最快)
    時刻2:線程B獲取了此值1。(次快)
    時刻3:線程C獲取了此值1.(最慢)
  • 場景②
    時刻4:線程A處理完畢了值,且write了值到主內存,執行完畢後主內存的值爲2.
    時刻5:線程B,在線程A修改完主內存值後,纔開始執行getstatic指令,最後他執行的是2+1,執行完畢後主內存值爲3
    時刻6:線程C,在執行getstatic方法時,線程A已經寫完數據到了主內存,而線程B還在進行+1操作。所以此時主內存值爲2。他再對2進行+1操作,執行完畢後,將3寫入主內存。
    所以最後主內存的值爲3,並不是我們的目標值4。這也是我們的代碼執行結果了,小於200000的原因。
不加volatile聲明對象的字節碼流程說明

不加volatile聲明,可能在進入線程後,未進行getstatic指令前,變量值發生了改變,而線程不知道。
所以,這也就是加了

四、線程安全的正確姿勢

講到這裏,我想大家應該對上方的代碼執行結果沒有什麼疑慮了。
但是問題又來了,如何確保能正確的得到目標值呢。

1、萬能的synchronized

相比大家看到此關鍵字,就已經知道了我下面要講什麼了。

 public synchronized static  void add(){
        i++;
    }

對add方法,加了synchronized關鍵字進行修飾之後,最後得到的目標結果,就是我們的目標值20000了。
當然,越是萬能,往往代表越是無能。
此方法的性能會比使用自己手動的進行lock以及unlock,性能要差很多。
特別是在1.5的jdk版本,性能差異非常大。不過在後續的jdk版本中,逐漸對synchronized在進行優化。而且官方也推薦這種方式,畢竟,他較之ReentrantLock要優雅、coooooool很多。

2、高性能的ReentrantLock

private static  int i = 0;
    private static ReentrantLock lock = new ReentrantLock();
    
    public  static  void add(){
        lock.lock();
        try{
            i++;
        }finally {
            lock.unlock();
        }
    }

即使是i++,我們也要進行try,這是爲了養成良好的語義習慣 =_=
每一次加鎖,必然要進行一次解鎖。不然…嘿嘿嘿嘿
需要說明的是,ReentrantLock(重入鎖)比之synchronized,多了其他的高級功能,等待可中斷、實現公平鎖、所可以綁定多個條件。這裏就不進行展開討論。

3、狹隘的AtomicInteger

 private static AtomicInteger i =new AtomicInteger(0);
    public  static  void add(){
        i.addAndGet(1);
    }

Atomic對象有很多,如AtomicBoolean、AtomicLong等。
他保證了數據操作的原子性,實現原理是通過CAS原理。
何爲CAS?即比較和交換:
獲取主內存值(A),將獲取到的值(A)與新的值(B)放入參數。在此獲取其值,如果,獲取到的值與傳輸的值A一致,就修改主內存值爲新的值B。
這也就是CAS
當然在Atimic的實現中,還是用了Unsafe類,他可以直接操作物理內存!!!!
這裏我們不對他詳細的展開論述。

五、總結

內存模型中,分爲工作內存與主內存。
這麼講其實沒意義,我換個說法,爲什麼要區分工作內存和主內存??
線程是程序運行的基礎,而線程需要與計算機進行數據交換,而由於計算機的組成,進行數據交換會,有的內存區域傳輸快,有的傳輸慢。而且也爲了保證數據的安全性,我們區分出了主內存(可以狹義的理解爲物理內存)與工作內存(寄存器即高速緩存,當量大時,也會存儲到物理內存中)

在重溫JVM時,我多次的是思考了爲什麼?也就是爲什麼要這麼設計,這麼設計有什麼好處,收益頗多。

六、參考

《深入理解Java虛擬機》

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章