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