国产探花免费观看_亚洲丰满少妇自慰呻吟_97日韩有码在线_资源在线日韩欧美_一区二区精品毛片,辰东完美世界有声小说,欢乐颂第一季,yy玄幻小说排行榜完本

首頁 > 學院 > 開發設計 > 正文

從JVM內存模型談線程安全

2019-11-06 06:40:40
字體:
來源:轉載
供稿:網友

存儲器層次結構

對于開發者來說,存儲器的層次結構應該是非常熟悉的,大體如下: 這里寫圖片描述

其中寄存器,L1,L2,L3都被封裝在CPU芯片中,作為應用開發者而言我們很少去注意和使用它.之所以引入L1,L2,L3高速寄存器,其根本是為了解決訪問運算器和內存速度不匹配.但緩存的引入也帶來兩個問題:

緩存命中率:緩存的數據都是主存中數據的備份,如果指令所需要的數據恰好在緩存中,我們就說緩存命中,反之,需要從主存中獲取.一個好的緩存策略應該盡可能的提高命中率,如何提高卻是一件非常困難的事情.緩存一致性問題:我們知道緩存是主存數據的備份,但每個核心都有自己的緩存,當緩存中的數據和內存中的數據不一致時,應該以誰的數據為準呢,這就是所謂緩存一致性問題.

上面只是展示存儲器的層次結構,現在我們來更形象的來看一下CPU芯片與內存之間聯系,以Intel i5雙核處理器為例: 這里寫圖片描述

通過上圖我們能明顯的看出各個緩存之間的聯系,在隨后的JVM內存模型剖析中,你同樣會發現類似的結構.關于存儲器層次結構到這里已經足夠,畢竟我們不是專門做操作系統的,下面我們來聊聊主存,更確切的說抽象的虛擬內存.


虛擬內存

談起內存的時候,每個人的腦海中都會呈現出內存條的形象,在很多時候,這種實物給我們對內存最直觀的理解,對于非開發者這么理解是可以接受的,但是對于從事開發開發工作的工程師而言,我們還要加深一點. 這里寫圖片描述

從硬件的角度來看,內存就是一塊有固定容量的存儲體,與該硬件直接打交道的是我們的操作系統.我們知道系統的進程都是共享CPU和內存資源的,現代操作系統為了更有效的管理內存,提出了內存的抽象概念,稱之為虛擬內存.換言之,我們在操作系統中所提到的內存管理談的都是虛擬內存.虛擬內存的提出帶來幾個好處:

虛擬內存將主存看成是一個存儲在磁盤上的地址空間的告訴緩存.應用在未運行之前,只是存儲在磁盤上二進制文件,運行后,該應用才被復制到主存中.它為每個進程提供了一致的地址空間,簡化了內存管理機制.簡單點來看就是每個進程都認為自己獨占該主存.最簡單的例子就是一棟樓被分成許多個房間,每個房間都是獨立,是戶主專有,每個戶主都可以從零開始自助裝修.另外,在未征得其他戶主的同意之前,你是無法進入其他房間的.

虛擬內存的提出也改變了內存訪問的方式.之前CPU訪問主存的方式如下: 這里寫圖片描述 上圖演示了CPU直接通過物理地址(假設是2)來訪問主存的過程,但如果有了虛擬內存之后,整個訪問過程如下: 這里寫圖片描述 CPU給定一個虛擬地址,然后經過MMU(內存管理單元,硬件)將虛擬地址翻譯成真正的物理地址,再訪問主存.比如現在虛擬地址是4200經過MMU的翻譯直接變成真正的物理地址2.

這里來解釋下什么是虛擬內存地址.我們知道虛擬內存為每個進程提供了一個假象:每個進程都在獨占地使用主存,每個進程看到的內存都是一樣的,這稱之為虛擬地址空間.舉個例子來說,比如我們內存條是1G的,即最大地址空間210,這時某個進程需要4G的內存,那么操作系統可以將其映射成更大的地址空間232,這個地址空間就是所謂的虛擬內存地址.關于如何映射,有興趣的可以自行學習.用一張圖來抽象的表示: 這里寫圖片描述

到現在我們明白原來原來我們所談操作系統中談的內存其實是虛擬內存,如果你是C語言開發者,那對此的感受可能更深.既然每個進程都擁有自己的虛擬地址空間,那么它的布局是如何的呢?以linux系統為例,來看一下它的進程空間地址的布局: 這里寫圖片描述

到現在為止,我們終于走到了進程這一步.我們知道,每個JVM都運行在一個單獨的進程當中,和普通應用不同,JVM相當于一個操作系統,它有著自己的內存模型.下面,就切入到JVM的內存模型中.


并發模型(線程)

如果java沒有多線程的支持,沒有JIT的存在,那么也不會有現在JVM內存模型.為什么這么說呢?首先我們從JIT說起,JIT會追蹤程序的運行過程,并對其中可能的地方進行優化,其中有一項優化和處理器的亂序執行類似,不過這里叫做指令重排.如果沒有多線程,也就不會存在所謂的臨界資源,如果這個前置條件不存在當然也就不會存在資源競爭這一說法了.這樣一來,可能Java早已經被拋棄在歷史的長河中.

盡管Java語言不像C語言能夠直接操作內存,但是掌握JVM內存模型仍然非常重要.對于為什么要掌握JVM內存模型得先從Java的并發編程模型說起.

在并發模型中需要處理兩個關鍵問題:線程之間如何通信以及線程之間如何同步.所謂的通信指的是線程之間如何交換消息,而同步則用于控制不同線程之間操作發生的相對順序.

從實現的角度來說,并發模型一般有兩種方式:基于共享內存和基于消息傳遞.兩者實現的不同決定了通信和同步的行為的差異.在基于共享內存的并發模型中,同步是顯示的,通信是隱式的;而在基于消息傳遞的并發模型中,通信是顯式的,同步是隱式的.我們來具體解釋一下.

在共享內存的并發模型中,任何線程都可以公共內存進行操作,如果不加以顯示同步,那么執行順序將是不可知的,也恰是因為哪個線程都可以對公共內存操作,所以通信是隱式的.而在基于消息傳遞的并發模型中,由于消息的發送一定是在接受之前,因此同步是隱式的,但是線程之間必須通過明確的發送消息來進行通信.

在最終并發模型選擇方案上,java選擇基于共享內存的并發模型,也就是顯式同步,隱式通信.如果在編寫程序時,不處理好這兩個問題,那在多線程會出現各種奇怪的問題.因此,對任何Java程序員來說,熟悉JVM的內存模型是非常重要的.


JVM內存結構

對于JVM內存,主要包含兩方面:JVM內存結構和JVM內存模型.兩者之間的區別在于模型是一種協議,規定對特定內存或緩存的讀寫過程,千萬不要弄混了.

很多人往往對JVM內存結構和進程的內存結構感到困惑,這里我將幫助你梳理一下.

JVM本質上也是一個程序,只不過它又有著類似操作系統的特性.當一個JVM實例開始運行時,此時在Linux進程中,其內存布局如下: 這里寫圖片描述

JVM在進程堆空間的基礎上再次進行劃分,來簡單看一下.此時的永生代本質上就是Java程序程序的代碼區和數據區,而年輕代和老年代才是Java程序真正使用的堆區,也就是我們經常掛在嘴邊的.但是此時的堆區和進程上的堆卻又很大的區別:在調用C程序的malloc函數時,會引起一次系統級的調用;在使用free函數釋放內存時,同樣也會引起一次系統級的調用,但是JVM中堆區并非如此:JVM一次性向系統申請一塊連續的內存區域,作為Java程序的堆,當Java程序使用new申請內存時,JVM會根據需要在這段內存區域中為其分配,而不需要除非一次系統級別的調用.可以看出JVM其實自行實現了一條堆內存的管理機制,這種管理方式有以下好處:

減少系統級別的調用.大部分內存申請和回首不需要觸發系統函數,僅僅只在Java堆大小發生變化時才會引起系統函數的調用.相比系統級別的調用,JVM實現內存管理成本更低.減少內存泄漏情況的發生.通過JVM接管內存管理過程,可以避免大多情況下的內存泄漏問題.

現在已經簡單介紹了JVM內存結構,希望這樣能幫助你打通上下.當然,為了好理解,我省略了其中一些相對不重要的點,如有興趣可以自行學習.講完了JVM內存結構,下一步該是什么呢?


JVM內存模型

Java采用的是基于共享內存的并發模型,使得JVM看起來非常類似現代多核處理器:在基于共享內存的多核處理器體系架構中,每個處理器都有自己的緩存,并且定期與主內存進行協調.這里的線程同樣有自己的緩存(也叫工作內存),此時,JVM內存模型呈現出如下結構: 這里寫圖片描述

上圖展示JVM的內存模型,也稱之為JMM.對于JMM有以下規定: 1. 所有的變量都存儲在主內存(Main Memory) 2. 每個線程也有用自己的工作內存(Work Memory) 3. 工作內存中的變量是主內存變量的拷貝,線程不能直接讀寫主內存的變量,而只能操作自己工作內存中的變量 4. 線程間不共享工作內存,如果線程間需要通信必須借助主內存來完成

共享變量所在的內存區域也就是共享內存,也稱之為堆內存,該區域中的變量都可能被共享,即被多線程訪問.說的再通俗點就是在java當中,堆內存是在線程間共享的,而局部變量,形參和異常程序參數不在堆內存,因此就不存在多線程共享的情況.

與JMM規定相對應,我們定義了以下四個原子性操作來實現變量從主內存拷貝到工作內存的過程:

read:讀取主內存的變量,并將其傳送到工作內存load:把read操作從主內存得到的變量值放入到工作內存的拷貝中store:把工作內存中的一個變量值傳送到主內存當中,以便用于后面的write操作write:把store操作從工作內存中得到的變量的值放入主內存的變量中.

可以看出,從主內存到工作內存的過程其實是要經過read和load兩個操作的,反之需要經過store和write兩個操作.

現在我們來看一段代碼,并用結合上文談談下多線程安全問題:

public class ThreadTest {    public static void main(String[] args) throws InterruptedException {        ShareVar ins = new ShareVar();        List<Thread> threadList = new ArrayList<>();        for (int i = 0; i < 10; i++) {            Thread thread;            if (i % 2 == 0) {                thread = new Thread(new AddThread(ins));            } else {                thread = new Thread(new SubThread(ins));            }            thread.start();            threadList.add(thread);        }        for (Thread thread : threadList) {            thread.join();        }        System.out.PRintln(Thread.currentThread().getId() + "   " + ins.getCount());    }}class ShareVar {    private int count;    public void add() {        try {            Thread.sleep(100);//此處為了更好的體現多線程安全問題        } catch (InterruptedException e) {            e.printStackTrace();        }        count++;    }    public void sub() {        count--;    }    public int getCount() {        return count;    }}class AddThread implements Runnable {    private ShareVar shareVar;    public AddThread(ShareVar shareVar) {        this.shareVar = shareVar;    }    @Override    public void run() {        shareVar.add();    }}class SubThread implements Runnable {    private ShareVar shareVar;    public SubThread(ShareVar shareVar) {        this.shareVar = shareVar;    }    @Override    public void run() {        shareVar.sub();    }}1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818212345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182

理想情況下,最后應該輸出0,但是多次運行你會先可能輸出-1或者-2等.為什么呢? 在創建的這10個線程中,每個線程都有自己工作內存,而這些線程又共享了ShareVar對象的count變量,當線程啟動時,會經過read-load操作從主內存中拷貝該變量至自己的工作內存中,隨后每個線程會在自己的工作內存中操作該變量副本,最后會將該副本重新寫會到主內存,替換原先變量的值.但在多個線程中,但由于線程間無法直接通信,這就導致變量的變化不能及時的反應在線程當中,這種細微的時間差最終導致每個線程當前操作的變量值未必是最新的,這就是所謂的內存不可見性.

現在我想你已經完全明白了多線程安全問題的由來.那該怎么解決呢?最簡單的方法就是讓多個線程對共享對象的讀寫操作編程串行,也就是同一時刻只允許一個線程對共享對象進行操作.我們將這種機制成為鎖機制,java中規定每個對象都有一把鎖,稱之為監視器(monitor),有人也叫作對象鎖,同一時刻,該對象鎖只能服務一個線程.

有了鎖對象之后,它是怎么生效的呢?為此JMM中又定義了兩個原子操作:

lock:將主內存的變量標識為一條線程獨占狀態unlock:解除主內存中變量的線程獨占狀態

在鎖對象和這兩個原子操作共同作用下而成的鎖機制就可以實現同步了,體現在語言層面就是synchronized關鍵字.上面我們也說道Java采用的是基于共享內存的并發模型,該模型典型的特征是要顯式同步,也就是說在要人為的使用synchronized關鍵字來做同步.現在我們來改進上面的代碼,只需要為add()和sub()方法添加syhcronized關鍵字即可,但在這之前,先來看看這兩個方法對應的字節碼文件:

  public void add();    descriptor: ()V    flags: ACC_PUBLIC    Code:      stack=3, locals=2, args_size=1         0: ldc2_w        #2                  // long 100l         3: invokestatic  #4                  // Method java/lang/Thread.sleep:(J)V         6: goto          14         9: astore_1        10: aload_1        11: invokevirtual #6                  // Method java/lang/InterruptedException.printStackTrace:()V        14: aload_0        15: dup        16: getfield      #7                  // Field count:I        19: iconst_1        20: iadd        21: putfield      #7                  // Field count:I        24: return  public void sub();    descriptor: ()V    flags: ACC_PUBLIC    Code:      stack=3, locals=1, args_size=1         0: aload_0         1: dup         2: getfield      #7                  // Field count:I         5: iconst_1         6: isub         7: putfield      #7                  // Field count:I        10: return      LineNumberTable:        line 18: 0        line 19: 1012345678910111213141516171819202122232425262728293031323334351234567891011121314151617181920212223242526272829303132333435

現在我們使用synchronized來讓著兩個方法變得安全起來:

class ShareVar {    private int count;    public synchronized void add() {        try {            Thread.sleep(100);        } catch (InterruptedException e) {            e.printStackTrace();        }        count++;    }    public synchronized void sub() {        count--;    }    public int getCount() {        return count;    }}12345678910111213141516171819202122231234567891011121314151617181920212223

此時這段代碼在多線程中就會表現良好.再來看看它的字節碼文件發生了什么變化:

  public synchronized void add();    descriptor: ()V    flags: ACC_PUBLIC, ACC_SYNCHRONIZED    Code:      stack=3, locals=2, args_size=1         0: ldc2_w        #2                  // long 100l         3: invokestatic  #4                  // Method java/lang/Thread.sleep:(J)V         6: goto          14         9: astore_1        10: aload_1        11: invokevirtual #6                  // Method java/lang/InterruptedException.printStackTrace:()V        14: aload_0        15: dup        16: getfield      #7                  // Field count:I        19: iconst_1        20: iadd        21: putfield      #7                  // Field count:I        24: return  public synchronized void sub();    descriptor: ()V    flags: ACC_PUBLIC, ACC_SYNCHRONIZED    Code:      stack=3, locals=1, args_size=1         0: aload_0         1: dup         2: getfield      #7                  // Field count:I         5: iconst_1         6: isub         7: putfield      #7                  // Field count:I        10: return      LineNumberTable:        line 18: 0        line 19: 10123456789101112131415161718192021222324252627282930313233343536123456789101112131415161718192021222324252627282930313233343536

通過字節碼不難看出最大的變化在于方法的flags中增加了ACC_SYNCHRONIZED標識,虛擬機在遇到該標識時,會隱式的為方法添加monitorenter和monitorexit指令,這兩個指令就是在JMM的lock和unlock操作上實現的.

其中monitorenter指令會獲取對象的占有權,此時有以下三種可能:

如果該對象的monitor的值0,則該線程進入該monitor,并將其值標為1,表明對象被該線程獨占.同一個線程,如果之前已經占有該對象了,當再次進入時,需將該對象的monitor的值加1.如果該對象的monitor值不為0,表明該對象被其他線程獨占了,此時該線程進入阻塞狀態,等到該對象的monitor的值為0時,在嘗試獲取該對象.

而monitorexit的指令則是已占有該對象的線程在離開時,將monitor的值減1,表明該線程已經不再獨占該對象.

用synchronized修飾的方法叫做同步方法,除了這種方式之外,還可以使用同步代碼塊的形式:

package com.cd.app;class ShareVar {    private int count;    public void add() {        synchronized (this) {            try {                Thread.sleep(100);            } catch (InterruptedException e) {                e.printStackTrace();            }            count++;        }    }    public void sub() {        synchronized (this) {            count--;        }    }    public int getCount() {        return count;    }}123456789101112131415161718192021222324252627282930123456789101112131415161718192021222324252627282930

接下來同樣是看一下他的字節碼,主要看add()和sub()方法:

 public void add();    descriptor: ()V    flags: ACC_PUBLIC    Code:      stack=3, locals=3, args_size=1         0: aload_0         1: dup         2: astore_1         3: monitorenter         4: aload_0         5: dup         6: getfield      #2                  // Field count:I         9: iconst_1        10: iadd        11: putfield      #2                  // Field count:I        14: aload_1        15: monitorexit        16: goto          24        19: astore_2        20: aload_1        21: monitorexit        22: aload_2        23: athrow        24: returnpublic void sub();    descriptor: ()V    flags: ACC_PUBLIC    Code:      stack=3, locals=3, args_size=1         0: aload_0         1: dup         2: astore_1         3: monitorenter         4: aload_0         5: dup         6: getfield      #2                  // Field count:I         9: iconst_1        10: isub        11: putfield      #2                  // Field count:I        14: aload_1        15: monitorexit        16: goto          24        19: astore_2        20: aload_1        21: monitorexit        22: aload_2        23: athrow        24: return1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515212345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152

同步代碼塊和同步方法的實現原理是一致的,都是通過monitorenter/monitorexit指令,唯一的區別在于同步代碼塊中monitorenter/monitorexit是顯式的加載字節碼文件當中的.

上面我們通過synchronized解決了內存可見性問題,另外也可以認為凡是被synchronized修飾的方法或代碼塊都是原子性的,即一個變量從主內存到工作內存,再從工作內存到主內存這個過程是不可分割的.

正如我們在 談亂序執行和內存屏障所提到的,javac編譯器和JVM為了提高性能會通過指令重排的方式來企圖提高性能,但是在某些情況下我們同樣需要阻止這過程,由于synchronized關鍵字保證了持有同一個鎖的的兩個同步方法/同步塊只能串行進入,因此無形之中也就相當阻止了指令重排.


總結

希望這么從下往上,再從上往下的解釋能讓各位同學對JVM內存模型以及多線程安全問題有個更通透的理解.好了,今天就到這,歡迎關注訂閱”江湖人稱小白哥”的博客.


發表評論 共有條評論
用戶名: 密碼:
驗證碼: 匿名發表
主站蜘蛛池模板: 威海市| 教育| 济宁市| 丁青县| 炎陵县| 鄄城县| 丰台区| 永康市| 葵青区| 铅山县| 丰顺县| 巴林左旗| 西林县| 夏津县| 基隆市| 锡林郭勒盟| 游戏| 措勤县| 牡丹江市| 都安| 桐庐县| 南溪县| 缙云县| 堆龙德庆县| 达州市| 景洪市| 东光县| 朝阳市| 浙江省| 洛扎县| 成都市| 钦州市| 武山县| 巴青县| 突泉县| 靖远县| 昭觉县| 且末县| 布拖县| 永平县| 和平县|