面試話癆系列是從技術廣度的角度去回答面試官提的問題,適合萌新觀看!html
常量在哪裏呀,常量在哪裏,常量在那小朋友的眼睛裏前端
1、從一道常常問的字符串題提及java
面試官:已知String s1 = "ab",String s2 = "a" + "b",String s3 = new String("ab"),求s一、s二、s3的相等狀況。程序員
進階版的還會將intern(),final String s1 = "ab" 這些狀況加進來。面試
相等的斷定分爲兩種,equals和==。equals咱們知道都是相等的,面試話癆(二)中已經詳細描述過了,這裏咱們重點來研究下「==」的狀況。算法
「==」考驗的是咱們對JVM結構和編譯運行過程知識的掌握。編程
2、簡單說下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.七、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文件是從哪裏被讀取到了哪裏,線程是經過哪兩種結構來記錄程序執行步驟的,爲啥是用着兩種結構實現?執行第二行時,是在哪裏建立的對象空間,又是在哪裏保存了指向該對象的指針?執行第3、4、五行時,是新建立空間仍是用老的?最終的判等結果是什麼?
爲何方法內建立的變量是局部變量?爲何普通的全局變量必須經過類的對象去訪問,而類中的靜態變量和常量能夠直接經過類名訪問?
相同內容的字符串常量會指向同一個地址,還有哪些數據會有這種狀況?
方法區的實現是如何改變的?爲何會這麼改變?
最後,JVM的運行時數據區和運行時常量池的區別什麼?運行時數據區由哪些部分組成,每一個部分的做用是什麼?
若是能回答出以上的問題,那麼繼續往下看吧,若是回答不出來,你可能有點暈了,建議休息一下再看一遍。
3、簡單說下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"記住編譯和加載的原理。
4、簡單說下剩下的JVM連接和初始化
連接分爲了三步
① 驗證 : 校驗類的格式,數據,符號的正確性。驗證時的異常也屬於編譯時異常,與編譯階段的主要區別是,編譯階段是在某個文件內部驗證語法語義的正確性,連接中的校驗是經過類之間的調用關係,鏈起來判斷代碼的正確性。
② 準備: 預加載類的靜態變量,並賦初始值0、null
③ 解析: 將類中的符號引用轉換成直接引用,如類A中引用了類B,那麼在編譯時咱們並不能確認類B的實際的地址,因此只能先用符號引用佔位,等到解析時再轉換成直接引用
初始化主要是將連接的準備階段中的靜態變量,替換成實際的值。以及執行靜態代碼塊,執行的順序是優先父類的靜態代碼執行。
使用就是利用JVM中的棧、程序計數器、堆,去執行實際的代碼邏輯,操做對應數據,獲取代碼結果。
5、總結及發散
JVM的相關知識,其實能夠經過三個階段來記,使用前,使用中,使用後。
使用前須要作好準備,包括校驗程序員寫的代碼,再轉換成JVM能讀懂的代碼,再根據須要加載當前須要的一部分代碼,並把一部分能夠提早肯定的數據初始化。
使用中則根據使用前準備好的代碼和數據,一行一行的執行代碼。經過棧記錄線程,經過棧記錄方法,經過程序計數器記錄執行到哪一行,經過堆記錄代碼執行過程當中所需的數據。
使用後則須要有專門的清潔工收拾殘餘垃圾,也就是GC。具體的看後續專門介紹(面試話癆N)。
但願你們可以經過String的幾道面試題,記牢JVM使用前,使用中的過程及原理。