面試話癆(四)常量在哪裡呀,常量在哪裡

有營養的yyl發表於2020-11-23

  面試話癆系列是從技術廣度的角度去回答面試官提的問題,適合萌新觀看!

  常量在哪裡呀,常量在哪裡,常量在那小朋友的眼睛裡


   

 

一、從一道經常問的字串題說起

  面試官:已知String s1 = "ab",String s2 = "a" + "b",String s3 = new String("ab"),求s1、s2、s3的相等情況。

  進階版的還會將intern(),final String s1 = "ab" 這些情況加進來。

  相等的判定分為兩種,equals和==。equals我們知道都是相等的,面試話癆(二)中已經詳細描述過了,這裡我們重點來研究下“==”的情況。

 

  “==”考驗的是我們對JVM結構和編譯執行過程知識的掌握

二、簡單說下JVM記憶體模型

  JVM記憶體模型這裡主要說下它存資料的地方,這個地方被稱作執行時資料區,主要分為三個部分:堆,棧,程式計數器。這裡沒有把方法區算作第四個部分,因為方法區只是一個概念。打個比方,JVM是一個房間,堆,棧,程式計數器就是鞋櫃,沙發和床,那麼方法區就是 吃飯的地方。吃飯的地方可以是餐桌,陽臺甚至廁所。

  不同版本的JDK,方法區實際指代的區域都不一樣。1.6方法區是用永久代實現的,1.7是用永久代和堆,1.8是元空間加堆。方法區比較複雜,我們先把堆,棧,程式計數器熟悉了。

  我們先來通過一段程式碼,熟悉堆,棧,程式計數器。

 

public void test() {
HashMap map = new HashMap(); String s1
= new String("123"); }

  這段程式碼是如何被執行的?

  首先得有個執行緒來執行是吧,不論是main的主執行緒,還是通過執行緒池開啟的其他執行緒,執行緒被建立時,都會建立一個執行緒私有的棧和程式計數器。執行緒總會按照順序執行一個或者多個方法,每個方法在被執行時,都會線上程私有的棧中新建一個格子,這個格子被稱作

 

  我們都知道棧是一種資料結構,那為什麼這裡要用棧,而不是用佇列呢?因為棧的特點是先進後出,這個跟我們方法呼叫規則一致,當方法一呼叫了方法二,需要方法二執行完成才能返回來執行方法一,即先進後出。

  棧還分為本地方法棧和虛擬機器棧。本地方法棧執行一些計算機底層C提供的方法,他們都是用native關鍵字修飾的,比如Object內的getClass方法。虛擬機器棧執行java方法。

 

  迴歸正題,當某個執行緒呼叫了test()方法時,便會在自己的私有棧中新增一幀。然後逐行執行編譯後的程式碼,並且會用程式計數器記錄程式碼已經執行到哪一行了。

  為什麼需要程式計數器呢?在面試話癆(三)中,我們講過,CPU因為運算能力太強,所以都是通過時間片輪轉制度同時做很多件事情。如果一個執行緒的時間片用完了,那麼它就會被強行停止,為了保證下一次喚醒它時我們能繼續執行,就需要準確的記住執行緒狀態。棧只能記住執行緒被執行到哪一個方法(幀)了,不能記住執行到方法的哪一行了。

  所以需要一個程式計數器,方便執行緒被再次喚醒時,準確的恢復執行緒的執行狀態。

 

  再說點題外話,發散一下。線上程被強行停止時,會儲存執行緒的最新狀態,爾後線上程被喚醒時,重新載入執行緒的最新狀態,這個過程,被稱為上下文切換。程式計數器就是為了上下文切換而存在的。它的存在增加了空間複雜度,但是換來了CPU的多執行緒執行。上下文切換主要有三種,執行緒間上下文切換,程式間上下文切換,使用者態核心態上下文切換。

  1. 執行緒間上下文切換。若兩個執行緒屬於不同的程式,那麼此次執行緒間切換就是程式間切換;若是程式內部的兩個執行緒切換,那麼它的速度會快很多。因為執行緒間共享的區域是不用快取再恢復的,只用快取執行緒私有的棧、程式計數器資訊。

  2. 程式間上下文切換需要儲存大量的資訊,包括使用者態下的虛擬記憶體、棧、堆,還包括核心態下的堆、棧、暫存器。一次切換往往需要浪費掉幾十納秒到幾微妙的時間。

  3. 核心態使用者態上下文切換。核心態擁有更高的管理許可權,相當於我們平常用cmd時,右鍵選擇了以管理員身份執行。最簡單的,讀取檔案就需要核心態的許可權去讀取。所以當你在程式碼中寫下 new FileInputStream(new File("C:/aa.txt")); 時,就存在兩次上下文切換,一次使用者態切換成核心態,讀取到檔案資訊,一次核心態切換回使用者態,將檔案資訊換成使用者態可以直接操作的物件。後續如果需要對外傳輸檔案,也需要用到核心態的許可權去開啟Socket通道。所以就有了一個有關檔案傳輸的優化:零拷貝技術。直接一次下發檔案的拷貝,傳輸命令,CPU會將資料從硬碟中放到記憶體,將記憶體地址傳送到Socket快取區,再呼叫Socket傳送資料,將6次上下文切換優化成2次。

  在前端中,也有上下文切換的概念,前端中的上下文切換考察的是從一個方法進入另外一個方法後,全域性變數、區域性變數的預載入,以及this指標重定向到何處,和這裡的不一樣。

 

  迴歸正題。通過上面的介紹,我們已經知道了執行緒在執行test()方法時,棧、幀、程式計數器是怎麼配合的。並且通過了解先進先出、上下文切換做到了知其然且知其所以然。如果沒有記清楚,建議再看一遍。因為後面還有更復雜的東西需要掌握。

  我們已經知道了test()方法被載入時的準備工作,那在每一行的執行過程中,JVM是如何工作的?

  比如 HashMap map = new HashMap(); ,這句到底幹了啥?

  很簡單,第一步,在堆中中開闢一個空間,用於存放new HashMap()。第二步,在test()對應的幀中新建一個區域性map指標,指向堆中的new HashMap()地址。

 

  第一步,new HashMap()在堆中開闢了一個空間。堆其實還分為很多個部分。最老派的分法是,新生代,老年代,永久代,新生代又分為又分為一個伊甸園區和兩個倖存區。伊甸園就是亞當和夏娃偷吃蘋果的那個伊甸園,寓意著萬物之始,所以一般來說,新建的物件都是在這個伊甸園區的。當然如果物件過大,大到伊甸園區的剩餘可用空間裝不下,它會直接建到老年代區,如果老年代也不夠,那就會觸發垃圾回收。

  第二步,我們都知道,這個map是個區域性變數,區域性變數只在方法內有效,為什麼區域性變數只在方法內有效?就是因為它是被建在幀中的,與幀同生共死。一個幀就是一個方法,當方法被執行完後,幀就需要從執行緒棧中出棧,相應地,幀中的map指標也被丟棄,new HashMap()在堆中建立的空間也會被標記為不可達(沒有存活的指標指向該物件),不可達的物件會在下次GC時被JVM回收(回收前會呼叫finalize方法,具體邏輯面試話癆二中有介紹)。

  總的來說,棧,堆,程式計數器管的是方法執行過程中的事,垃圾回收管的是方法執行完成之後的事,我們後面細說,剩下的方法執行之前的準備工作,就歸方法區管了

  方法區存放著類編譯後的位元組碼,常量,靜態變數等資訊(注意普通的全域性變數,會在類物件被建立時,一起建立在堆中,這也是為什麼靜態變數、常量可以用類直接訪問,而普通的全域性變數需要物件建立出來以後才能訪問的原因)。

  對於常量,我們這裡需要特別說明。方法區中有個專門的執行時常量池來存放常量,因為常量有不可修改的特性,所以如果常量值相等的引用,可以優化成一個記憶體地址。JVM中不同地方的"ab"和"ab"會被指向同一個地址。

  另外Byte,Short,Integer,Long,Character這五個基礎類的包裝類的-128至127的值也會直接建立常量池,如 Integer i1 = 12; Integer i2 = 12 中,i1和i2就同時指向了常量池中的地址,所以i1 == i2 的結果是true,而-128至127以外的數,指向的就不是一個地址了。

  方法區jdk1.6中是通過永久代實現的。用永久代的原因是因為懶,想跟堆用一套GC演算法。但是後續發現,方法區中的靜態變數、常量這種資料物件,和普通物件一樣適用於堆的GC演算法,但是對於類編譯後的方法啊,關鍵字啊這些東西,不適應於GC演算法。所以也就有了JDK1.7、JDK1.8中的逐漸將執行時常量池,靜態變數移入堆中,將其他的資訊放入獨立的元空間的操作。元空間就是外部的直接記憶體,堆是JVM的虛擬記憶體。

  網上一般說的移到元空間的原因有兩個,一是元空間使用實體記憶體,理論上不會再有記憶體溢位的問題(記憶體佔用過高時,cpu會通過強制失效機制將一部分資料放入磁碟,要用該部分資料時再從磁碟載入回記憶體。所以理論上不會再有記憶體溢位,只有可能CPU100%),二是使用直接記憶體,讀取和寫入的速度都會更快。但是我個人覺得,還是因為GC演算法鬧不合,導致了他們的分家。

  關於常量池還有一些容易記混的知識,這裡一併說下。常量池分為class類常量池和執行時常量池。class類常量池是在編譯後產生的,是放在class檔案中的,是在硬碟中的資料。而執行時常量池是class類常量池被載入到JVM後的資料,是放在記憶體(虛擬記憶體)中的。另外還有個字串常量池,在我看來,字串常量池只是class類常量池或者執行時常量池中的一個小類,它能被單獨提出來說,是因為在JDK優化方法區的過程時,在JDK1.7中優先將字串常量池從執行時常量池中剝離了出來,先轉移到了堆中,爾後,1.8中將剩餘的整個執行時常量池都轉入了堆,那麼也就沒有了單獨的字串常量池。所以我認為,字串常量池應該只是一個JDK1.7中的歷史產物,它之所以還會被提起,就是因為JVM對於字串常量獨特的優化,這個優化也是這道面試題存在的根本原因。


  以上就是關於JVM記憶體模型的各個部分的介紹。下面我們先試著用這部分知識,解決面試題中的一部分問題吧。

 

1     public static void main(String[] args) {
2         String s1 = "ab";
3         String s2 = "ab";
4         String s3 = new String("ab");
5         String s4 = new String("ab");
6         System.out.println(s1 == s2);
7         System.out.println(s1 == s3);
8         System.out.println(s3 == s4);
9     }

 

  好好想一想,編譯後的class檔案是從哪裡被讀取到了哪裡,執行緒是通過哪兩種結構來記錄程式執行步驟的,為啥是用著兩種結構實現?執行第二行時,是在哪裡建立的物件空間,又是在哪裡儲存了指向該物件的指標?執行第三、四、五行時,是新建立空間還是用老的?最終的判等結果是什麼?

  為什麼方法內建立的變數是區域性變數?為什麼普通的全域性變數必須通過類的物件去訪問,而類中的靜態變數和常量可以直接通過類名訪問?

  相同內容的字串常量會指向同一個地址,還有哪些資料會有這種情況?

  方法區的實現是如何改變的?為什麼會這麼改變?

  最後,JVM的執行時資料區和執行時常量池的區別什麼?執行時資料區由哪些部分組成,每個部分的作用是什麼?

  如果能回答出以上的問題,那麼繼續往下看吧,如果回答不出來,你可能有點暈了,建議休息一下再看一遍。

 

三、簡單說下JVM編譯和裝載

 

  下面程式碼的結果是什麼?

 

    public static void main(String[] args) {
        String s1 = "a" + "b";
        String s2 = "ab";
        System.out.println(s1 == s2);
    }

  這兩個語句是否相等,主要是要明白JVM的編譯裝載執行過程,主要涉及到編譯和裝載兩步

  將程式設計師能讀懂的高階程式語言,轉換成計算機能讀懂的二進位制語言,這個過程就是編譯

  廣義的編譯的步驟是:詞法分析,語法分析,語義分析,中間程式碼生成及程式碼優化,二進位制程式碼生成。當然因為Java是轉給JVM看的,所以Java中的編譯,最終生成的不是二進位制檔案,而是class檔案編譯不是一個簡單的事,不信你試著去寫一段程式碼:輸入一段字串,該字串是一段數學運算,包含加、減、乘、除、正號、負號、小括號,求出該運算的最終結果)。

 

  編譯的前三步很好記,就跟我們讀英語一樣,先判斷每個單詞拼寫對不對(詞法分析),再判斷單詞的時態對不對(語法分析),再判斷整句的意思是否矛盾(語義分析)。

  至於中間程式碼生成及程式碼優化,就是編譯器對程式碼的一些補充和調整。通過補充和調整讓程式碼更規範、效能更好。比如 int daySecond = 24 * 60 * 60; ,這個編譯後就是 int daySecond = 86400; 。因為無論執行時的前後程式碼變數是什麼,daySecond的值都是86400,所以編譯時會將程式碼直接計算成86400,提升執行時的效率。

  第二步是裝載,裝載是通過雙親委派機制,將類的編譯後資訊放入方法區,然後在堆中建立指向。方法區中放的不止有類的編譯資訊,只是在裝載這一步,只裝載了類的編譯資訊。

  比如這個“a” + "b",“a”和“b”都是已知的不會更改的常量,不論“a” + "b"的前後有怎樣的程式碼,它的結果都是“ab”,對於這種程式碼,編譯時肯定就會被優化成“ab”。如圖:

   左邊為編譯之後的class,“a” + "b"已被合併。

  通過第一步編譯,我們知道“a” + "b"已經被優化成了"ab",但這還並不能說明String s1 = "ab"與String s2 = "a" + "b"是"=="的,我們還得看第二步:裝載。

  裝載就是通過包名+類名獲取到指定類的位元組流,將其放入方法區。方法區中包括類的基本資訊,類編譯後的程式碼,常量,變數。但是在裝載這一步中,只會先將類的基本資訊,類編譯後的程式碼,常量放入方法區。並在堆中新建一個該類的物件,指向了方法區中的類資訊。

  裝載這一步時,就會將常量放到方法區中的執行時常量池。這裡就用到了上面說過的字串常量池,若字串常量池中已存在相同的字串,則不會生成新的字串。因為常量是不可更改的,所以不用擔心多個指標引用同一個地址時,造成的資料水波。

  因為在編譯這一步,"a" + "b"被優化成了"ab",又因為在裝載這一步,又會將內容一致的字串指向同一個地址,所以s1等於s2。

  同理,大家應該能還快看出以下程式碼的結果

    public static void main(String[] args) {
        String s1 = "ab";
        
        String s2 = "a";
        String s3 = s2 + "b";
        System.out.println(s1 == s3);

        final String s4 = "a";
        String s5 = s4 + "b";
        System.out.println(s1 == s5);
    }

  希望大家能通過編譯和載入的原理明白為什麼"a" + "b"等於"ab",也能通過"a" + "b"等於"ab"記住編譯和載入的原理。

四、簡單說下剩下的JVM連結和初始化

  連結分為了三步

  ① 驗證 : 校驗類的格式,資料,符號的正確性。驗證時的異常也屬於編譯時異常,與編譯階段的主要區別是,編譯階段是在某個檔案內部驗證語法語義的正確性,連結中的校驗是通過類之間的呼叫關係,鏈起來判斷程式碼的正確性。

  ② 準備:  預載入類的靜態變數,並賦初始值0、null

  ③ 解析: 將類中的符號引用轉換成直接引用,如類A中引用了類B,那麼在編譯時我們並不能確認類B的實際的地址,所以只能先用符號引用佔位,等到解析時再轉換成直接引用

  初始化主要是將連結的準備階段中的靜態變數,替換成實際的值。以及執行靜態程式碼塊,執行的順序是優先父類的靜態程式碼執行。

  使用就是利用JVM中的棧、程式計數器、堆,去執行實際的程式碼邏輯,操作對應資料,獲取程式碼結果。

五、總結及發散

  JVM的相關知識,其實可以通過三個階段來記,使用前,使用中,使用後。

  使用前需要做好準備,包括校驗程式設計師寫的程式碼,再轉換成JVM能讀懂的程式碼,再根據需要載入當前需要的一部分程式碼,並把一部分可以提前確定的資料初始化。

  使用中則根據使用前準備好的程式碼和資料,一行一行的執行程式碼。通過棧記錄執行緒,通過棧記錄方法,通過程式計數器記錄執行到哪一行,通過堆記錄程式碼執行過程中所需的資料。

  使用後則需要有專門的清潔工收拾殘餘垃圾,也就是GC。具體的看後續專門介紹(面試話癆N)。

  希望大家能夠通過String的幾道面試題,記牢JVM使用前,使用中的過程及原理。

 

相關文章