并發(fā)與高并發(fā)系列第二集-Java內(nèi)存區(qū)域劃分
本文轉(zhuǎn)載自微信公眾號「安琪拉的博客」,作者安琪拉。轉(zhuǎn)載本文請聯(lián)系安琪拉的博客公眾號。
面試官:上次我們公司搞了個專場面試,來了一百多候選人,現(xiàn)場很熱鬧,你怎么沒來?
安琪拉: 天氣太熱,你們公司離地鐵站又比較遠,以我的能力,面完肯定是搶不到共享單車的,所以就不湊這個熱鬧了。
面試官:你這是什么意思?
安琪拉: 沒什么意思。。。哎,我等不及了,快開始吧。
面試官:你簡歷上寫熟悉多線程,你能給我講為什么要用多線程嗎?多線程有什么好處?最好能給我舉個你工作中的實際例子。
安琪拉: 比如: 用戶查看在支付寶買的電影票的時候,順便在頁面下半部分推薦給用戶一些最近的熱門電影。
比如安琪拉看自己買的“寂靜之地2”的時候,頁面底部同時推薦給我“速度與激情9”,放個預告片、影片介紹啥的。
面試官: 這就完了? 然后呢,詳細講講怎么用到多線程的?
安琪拉: 如果不使用多線程,支付寶服務(wù)端先查詢用戶的購票信息,查詢完之后再查詢熱門電影推薦信息,這樣串行效率很慢。
改成多線程,同時進行二個請求的查詢,查詢完成把結(jié)果組裝,展示給用戶。
面試官:那如果查詢熱門電影推薦失敗了,或者查詢推薦信息很慢,豈不是也很影響用戶查看自己買的票,如果這個時候用戶著急看電影,一直出不來,豈不是3.25啦。
安琪拉: 我們會把查票信息和查詢電影推薦信息請求放在二個不同的線程池,查詢熱門電影推薦這個請求做成弱依賴,也就是查詢熱門電影推薦失敗或者超時也不會影響到查詢電影票信息。
面試官:能寫個偽代碼說明下嗎?
安琪拉: 可以,幫我拿張A4紙,順便把你筆借我一下。(下面涉及Future、線程池的代碼看不懂沒關(guān)系,后面并發(fā)系列介紹完回過頭來看也可以)
- //查詢票信息
- Future getTicketFuture = ticketHandlePool.submit(()->{
- //查詢票信息
- doQuery();
- });
- //查詢推薦電影
- Future recMovieFuture = recMovieHandlePool.submit(()->{
- //查詢推薦電影
- try {
- doQuery();
- } catch (Exception ex) {
- //異常捕獲記錄
- logger.warn("信息", ex);
- }
- });
- //獲取票查詢結(jié)果
- try {
- recMovieFuture.get(2, TimeUnit.SECONDS);
- } catch (Exception e) {
- //弱依賴: 超時、中斷等異常只是warn級別記錄,任務(wù)取消,不拋出異常
- logger.warn("信息", e);
- recMovieFuture.cancel(true);
- }
- //獲取推薦信息查詢結(jié)果強依賴
- try {
- getTicketFuture.get(3, TimeUnit.SECONDS);
- } catch (Exception e) {
- logger.error("信息", e);
- recMovieFuture.cancel(true);
- throw new ***Exception(e, "獲取票信息異常");
- }
面試官:那你給我總結(jié)一下并發(fā)編程的優(yōu)勢吧。
安琪拉: 【看來實踐環(huán)節(jié)過了,開始上八股文了。】
嗯,剛才我們也看到了,并發(fā)能提升程序執(zhí)行的效率,充分利用CPU,特別是對于多核,IO密集型(經(jīng)常需要等待I/O,多線程可以充分利用CPU資源),并發(fā)也是一種設(shè)計,在某些多任務(wù)處理,或者一個大任務(wù)需要拆分成很多個子任務(wù)的場景,并發(fā)一方面能提升執(zhí)行效率,另一方面能清晰的表達程序設(shè)計者的意圖。
【這一波方法論應(yīng)該能讓面試官抖一抖】
面試官:那并發(fā)編程有什么風險呢?
安琪拉: 總的來說有這么幾個:
- 線程頻繁上下文切換,會有性能損耗;
- 共享數(shù)據(jù)多線程訪問,如果不加控制,可能會出現(xiàn)線程安全問題;
面試官:關(guān)于線程安全相關(guān)的,你能我有幾個問題想問你。
安琪拉: 請出題。
面試官:你給我講講JVM運行時數(shù)據(jù)區(qū)域的劃分嗎?
安琪拉: 【它來了,它終于還是來了】
這個給個小提示,有時候我們會把JVM的運行時數(shù)據(jù)區(qū)域和Java內(nèi)存模型搞混,面試題二個一般都會問到。
- JVM的運行時數(shù)據(jù)區(qū)就是堆、棧這些,規(guī)定運行時內(nèi)存(含寄存器) 分成哪幾塊,起什么作用;
- Java內(nèi)存模型是為了Java語言的跨平臺表現(xiàn)一致性,屏蔽硬件和操作系統(tǒng)實現(xiàn)提出的規(guī)范,例如規(guī)定了線程和主內(nèi)存之間的抽象關(guān)系,既然是規(guī)范,只會規(guī)定概念,具體實現(xiàn)依賴不同平臺的JVM虛擬機的實現(xiàn)。
其實日常寫代碼心里有總體概念就好了,不需要像做研究一樣的深入實現(xiàn)細節(jié),除非面試的是JVM虛擬機開發(fā)崗這種。
如下如所示,就是JVM運行時數(shù)據(jù)區(qū)
面試官:那我們來一塊一塊區(qū)域的講。你先給我講下什么是虛擬機棧(JVM Stacks)?
安琪拉: 要講虛擬機棧,我們先要知道棧是什么時候創(chuàng)建的?因為棧是線程私有的,棧的生命周期跟線程一致,所以記住棧是跟線程綁定在一起的就好了,棧中存的內(nèi)容也是線程運行需要用到的,看我們上圖畫的,棧是由一個個棧幀組成的,棧幀里面存放局部變量表、操作棧、動態(tài)鏈接、方法返回地址。
面試官:詳細講講這四個玩意唄。
安琪拉: 我得寫段代碼演示一下。
- public static void main(String[] args) {
- String str = dance("angela", 3);
- }
- private static String dance(String name, int count) {
- String result = name + ":" + count;
- return result;
- }
每個方法在執(zhí)行的同時都會創(chuàng)建一個棧幀(Stack Frame),棧幀是方法運行時的基本數(shù)據(jù)單元,用于存儲局部變量表、操作數(shù)棧、動態(tài)鏈接、方法出口等信息。
每一個方法從調(diào)用直至執(zhí)行完成的過程,就對應(yīng)著一個棧幀在虛擬機棧中入棧到出棧的過程。
局部變量表
main方法執(zhí)行,會啟動一個線程,這時候主線程的虛擬棧中會壓入一個棧幀,調(diào)用dance 方法時再會壓入一個棧幀,存放name、count 等數(shù)據(jù),name、count、result就是存在在局部變量表中,局部變量表是存放方法參數(shù)和局部變量的區(qū)域。
如果是非靜態(tài)方法,則在局部變量表 index[0] 位置上存儲的是方法所屬對象實例的引用,一個引用變量占 4 個字節(jié),隨后存儲的是方法參數(shù)和局部變量。
操作數(shù)棧
操作數(shù)非常有意思,這里要講到程序執(zhí)行原理,String result = name + ":" + count; 這行代碼分成好幾個步驟,簡化就是:取數(shù)、執(zhí)行、存數(shù)。取name壓入操作數(shù)棧、取count壓入操作數(shù)棧,然后彈棧2次,執(zhí)行拼接動作,把結(jié)果壓棧,存入局部變量表。
所以為什么說JVM 的執(zhí)行引擎是基于棧的執(zhí)行引擎,就是這個原因,這里的棧就是操作數(shù)棧。
后面的系列講到volatile的時候會介紹load、store等相關(guān)指令。
字節(jié)碼指令中的 STORE 指令就是將操作棧中計算完成的結(jié)果寫回局部變量表的存儲空間內(nèi)。
再說動態(tài)鏈接和方法返回地址。
動態(tài)鏈接
每個棧幀中包含一個在常量池中對當前方法的引用, 目的是支持方法調(diào)用過程的動態(tài)連接。
可能有點繞,這部分深入說要講類的編譯、加載、鏈接的過程,Class 文件中存放了大量的符號引用,字節(jié)碼中的方法調(diào)用指令就是以常量池中指向方法的符號引用作為參數(shù)。這些符號引用一部分會在類加載階段或第一次使用時轉(zhuǎn)化為直接引用,這種轉(zhuǎn)化稱為靜態(tài)解析。另一部分將在每一次運行期間轉(zhuǎn)化為直接引用,這部分稱為動態(tài)連接,比如反射時invokedynamic 調(diào)用的,在運行時常量池存放的當前方法的引用是動態(tài)生成的,運行時可以動態(tài)鏈接。
方法返回地址
方法執(zhí)行完,執(zhí)行彈棧操作,彈出當前棧幀,方法返回地址就是方法執(zhí)行之后(彈棧之后)下一步要執(zhí)行的地址。
面試官:那本地方法棧和你說的虛擬機棧什么區(qū)別?
安琪拉: 本地方法棧(Native Method Stack)與虛擬機棧很相似,它們之間的區(qū)別不過是虛擬機棧為虛擬機執(zhí)行 Java 方法(也就是字節(jié)碼)服務(wù),而本地方法棧則為虛擬機使用到的 Native 方法服務(wù)。
比如Thread類的 start0 方法,就是Native方法。
- private native void start0();
Sun HotSpot 虛擬機直接把本地方法棧和虛擬機棧合二為一。
面試官:那 Java 堆呢?
安琪拉: Java 堆是被所有線程共享的一塊內(nèi)存區(qū)域,在虛擬機啟動時創(chuàng)建,幾乎所有的對象實例都在這里分配內(nèi)存。
面試官:關(guān)于Java堆的內(nèi)存回收你能講一下嗎?
安琪拉: 堆是垃圾收集器(GC)管理的主要區(qū)域,因此很多時候也被稱做“GC堆”(Garbage Collected Heap)。從內(nèi)存回收的角度來看,由于現(xiàn)在收集器基本都采用分代收集算法,所以 Java 堆中還可以細分為:新生代和老年代;再細致一點的有 Eden 空間、From Survivor 空間、To Survivor 空間等。
面試官:方法區(qū)呢?你知道JVM規(guī)范中JDK8之前和之后方法區(qū)的變化嗎?
安琪拉: 方法區(qū)是JVM規(guī)范中的說話,具體到不同JVM,有不同的實現(xiàn),以最流行的Sun Hotspot為例,JDK8 之前,Hotspot 中方法區(qū)的實現(xiàn)是永久代(Perm),JDK8 開始使用元空間(Metaspace),以前永久代的字符串常量移至堆內(nèi)存,其他內(nèi)容移至元空間,元空間直接在本地內(nèi)存分配。
面試官:方法區(qū),或者說它的實現(xiàn)元空間是做什么的?
安琪拉: 方法區(qū)(Method Area)與 Java 堆一樣,是各個線程共享的內(nèi)存區(qū)域,它用于存儲已被虛擬機加載的類信息、常量、靜態(tài)變量、即時編譯器編譯后的代碼等數(shù)據(jù)。
其實從底層物理存儲來講,跟堆是都是在內(nèi)存中,Java 虛擬機規(guī)范把方法區(qū)描述為堆的一個邏輯部分,但是它卻有一個別名叫做 Non-Heap(非堆),目的應(yīng)該是與 Java 堆區(qū)分開來。
面試官:為什么要使用元空間取代永久代的實現(xiàn)?
安琪拉: 主要有幾點原因:
- 字符串存在永久代中,容易出現(xiàn)性能問題和內(nèi)存溢出。由于 PermGen(永久代) 經(jīng)常會溢出,引發(fā) java.lang.OutOfMemoryError: PermGen 問題,所以 JVM 的開發(fā)者希望這一塊內(nèi)存可以更靈活地被管理,不要再經(jīng)常出現(xiàn)這樣的 OOM;
- 移除 PermGen 可以促進 HotSpot JVM 與 JRockit VM 的融合,因為 JRockit 沒有永久代。
面試官:我看你圖中畫了元數(shù)據(jù)區(qū)的常量池,JVM中常量池能詳細講講嗎?
安琪拉: 首先明確一點,JVM中有三種常量池:
- JVM常量池
- 運行時常量池
字符串常量池
然后我們分別說下三種的區(qū)別和聯(lián)系,JVM常量池也叫class文件常量池,是class文件的一部分,用于保存編譯時確定的數(shù)據(jù)。
最關(guān)鍵的是編譯期三個字。
這個我們寫個Java程序,反編譯一下,看字節(jié)碼就知道了,如下圖,常量池的符號引用都列出來了。
#1 引用 #5.#25 什么意思呢,我們看#5 是 java/lang/Object, #25 是 #8:#9 // "":()V
其實就是調(diào)用初始化方法,引用方法名稱、返回值和繼承的類(任何類都繼承Object類,所以引用了 java/lang/Object)
常量池存了一堆符號引用。
在Class編譯加載后Class常量池加載到運行時常量池,運行時常量池存儲在元空間。JVM在執(zhí)行某個類的時候,會經(jīng)過加載、連接、初始化,而連接又包括驗證、準備、解析三個階段。而當類加載到內(nèi)存中后,jvm就會將class常量池中的內(nèi)容存放到運行時常量池中。
最后一個就是字符串常量池(String Constant Pool),很多人以為上圖反編譯的Class常量池中的字符串就存儲在字符串常量池,網(wǎng)上很多博客二者也搞混了,Class常量池只在編譯期間起作用,編譯期間確定了一堆引用關(guān)系,比如: 類和方法的全限定名、字段的名稱和描述符 、方法的名稱和描述符、文本字符串。
存儲在哪?
字符串常量池存儲在堆上,在JDK6.0及之前版本,字符串常量池是放在Perm Gen區(qū)(也就是方法區(qū))中。
怎么存儲?
在HotSpot VM里實現(xiàn)常量池的是一個StringTable類,它是一個Hash表,默認值大小長度是1009;這個StringTable只有一份,被所有的類共享。字符串常量由一個一個字符組成,放在了StringTable上。
存儲什么?
字符串常量池中的字符串只存在一份!
- 在JDK6.0及之前版本中,字符串常量池(String Pool)里放的都是字符串常量;
- 在JDK7.0中,字符串常量池(String Pool)中也可以存放放于堆內(nèi)的字符串對象的引用。
面試官:你說JDK7.0后字符串常量池(String Pool)中也可以存放放于堆內(nèi)的字符串對象的引用,能舉個例子嗎?
安琪拉: 在JDK 7下,當執(zhí)行String.intern();時,因為常量池中沒有“like”這個字符串,所以會在常量池中生成一個對堆中的“like”的引用(注意這里是引用 ,就是這個區(qū)別于JDK 1.6的地方。在JDK1.6下是生成原字符串的拷貝)
- public void stringTest() {
- String str1 = "follow";
- String str2 = "angela";
- String str3 = new String("like");
- str3.intern();
- }
如下圖,JDK1.6 String.intern()的操作,生成原字符串“like”的拷貝。
JDK1.7 如下圖:生成一個對堆中的“like”的引用
面試官:那你給我講講前面說的Java內(nèi)存模型吧,最好能寫點實際工程代碼,說明Java內(nèi)存模型在實際項目的用處。
安琪拉: 要不還是下次吧,今天有點晚了,你們公司離地鐵遠,我要早點去搶共享單車,這題就留給二面面試官吧。
面試官:也行,那你先回去吧,有消息我通知你。
安琪拉: 好嘞,回見。