讀書筆記——Java虛擬機自動內存管理機制

本文章講解的內容是Java虛擬機自動內存管理機制

概述

對於從事CC++程序開發的開發人員來說,在內存管理領域,他們既擁有每一個對象的“所有權”又擔負着每一個對象生命開始到終結的維護責任

對於Java程序員來說,在Java虛擬機自動內存管理機制的幫助下,不再需要爲每一個new操作去寫配對的delete/free代碼不容易出現內存泄漏和內存溢出問題,這看起來一切美好,不過正是因爲Java程序員把內存控制的權力交給Java虛擬機,一旦出現內存泄漏內存溢出的問題的時候,如果不瞭解Java虛擬機是怎樣使用內存的話,那麼排查錯誤將會一項異常艱難的工作。

運行時數據區域

Java虛擬機在執行Java程序的過程中會把它所管理的內存劃分爲若干個不同的數據區域。這些區域有各自的用途,以及創建銷燬的時間,有的區域隨着Java虛擬機進程啓動存在,有的區域則依賴用戶線程啓動結束建立銷燬。根據**《Java虛擬機規範(Java SE 7版)》的規定,Java虛擬機所管理的內存將會包括以下幾個運行時數據區域**,如下圖所示:
JavaVirtualMachineRuntimeDataArea.png
由所有線程共享的數據區

  • 方法區
  • 直接內存

線程隔離的數據區

  • 程序計數器
  • 虛擬機棧
  • 本地方法棧

程序計數器

程序計數器(Program Counter Register)是一塊較小內存空間,它可以看作是當前線程所執行的字節碼行號指示器。在虛擬機概念模型(僅是概念模型,各種虛擬機可能會通過一些更高效的方式去實現)裏,字節碼解釋器工作時就是通過改變這個計數器來選取下一條需要執行的字節碼指令分支循環跳轉異常處理線程恢復基礎功能都需要依賴這個計數器來完成。

由於Java虛擬機多線程是通過線程輪流切換分配處理器執行時間的方式來實現的,在任何一個確定的時刻,一個處理器(對於多核處理器來說是一個內核)都只會執行一條線程中的指令。因此,爲了線程切換後能恢復正確的執行位置每條線程都需要有一個獨立的程序計數器各條線程之間計數器互不影響獨立存儲,我們稱這類內存區域爲**“線程私有”內存**。

  • 如果線程正在執行的是一個Java方法,這個計數器記錄的是正在執行虛擬機字節碼指令的地址
  • 如果線程正在執行的是一個Native方法,這個計數器值則爲空(Undefined)

此內存區域是唯一一個在Java虛擬機規範中沒有規定任何OutOfMemoryError情況的區域

Java虛擬機棧

程序計數器一樣,Java虛擬機棧(Java Virtual Machine Stacks)也是線程私有的,它的生命週期與線程相同虛擬機棧描述的是Java方法執行的內存模型,每個方法在執行的同時都會創建一個棧幀(Stack Frame)用於存儲局部變量表操作數棧動態鏈接方法出口等消息。每一個方法從調用直至執行完成的過程,就對應着一個棧幀虛擬機棧入棧出棧的過程。

經常有人把Java內存區分爲堆內存(Heap)棧內存(Stack),這種分法比較粗糙Java內存區域劃分實際上遠比這複雜。這種劃分方式的流行只能說明大多數程序員最關注的與對象內存分配關係最密切的內存區域這兩塊。其中所指的**“堆”後面會講到,而所指的就是現在講的Java虛擬機棧中的局部變量表部分**。

局部變量表存放了編譯器可知的各種基本數據類型(boolean、byte、char、short、int、float、long、double)對象引用(reference類型,它不等同於對象本身,可能是一個指向對象起始地址的引用指針,也可能是指向一個代表對象的句柄或其他與此對象相關的位置)returnAddress類型(指向了一條字節碼指令的地址)

其中64位長度longdouble類型的數據會佔用兩個局部變量空間(Slot)其餘的數據類型只佔用一個局部變量表所需的內存空間編譯期間完成分配,當進入一個方法時,這個方法需要在中分配多大的局部變量空間完全確定的,在方法運行期間不會改變局部變量表大小

Java虛擬機規範中,對這個區域規定了兩種異常狀態

  • 如果線程請求的棧深度大於虛擬機所允許的的深度,將拋出StackOverflowError異常。
  • 如果虛擬機棧可以動態擴展(當前大部分的Java虛擬機都可動態擴展,只不過Java虛擬機規範中也允許固定長度的虛擬機棧),如果擴展時無法申請到足夠的內存,就會拋出OutOfMemoryError異常。

本地方法棧

本地方法棧(Native Method Stack)虛擬機棧所發揮的作用是非常相似的,它們之間的區別不過是虛擬機棧虛擬機執行Java方法(也就是字節碼)服務,而本地方法棧則爲虛擬機使用到的Native方法服務。在虛擬機規範中對本地方法棧方法使用的語言使用方式數據結構沒有強制規定,因此具體的虛擬機可以自由實現它。甚至有的虛擬機(例如:Sun HotSpot虛擬機)直接就把虛擬機棧本地方法棧合二爲一。與虛擬機棧一樣,本地方法棧區域也會拋出StackOverflowErrorOutOfMemoryError異常。

Java堆

對於大多數應用來說,Java堆(Java Heap)Java虛擬機所管理的的內存中最大的一塊。Java堆是被所有線程共享的一塊內存區域,在虛擬機啓動時創建。此內存區域唯一目的就是存放對象實例幾乎所有的對象實例都在這裏分配內存。這一點在Java虛擬機規範中描述是:所有的對象實例以及數組都要在堆上分配,但是隨着JIT編譯器的發展與逃逸分析技術逐漸成熟,棧上分配標量替換優化技術將會導致一些微妙的變化發生,所有的對象都分配在上也漸漸變得不是那麼**”絕對“**了。

Java堆垃圾收集器管理的主要區域,因此很多時候也被稱做**”GC堆(Grabage Collected Heap)。從內存回收的角度來看,由於現在收集器基本採用分代收集算法**,所以Java堆中還可以細分爲:新生代老年代;再細緻一點的有Eden空間From Survivor空間To Survivor空間等。從內存分配的角度來看,線程共享Java堆中可能劃分出多個線程私有的分配緩衝區(Thread Local Allocation Buffer,TLAB)。不過無論如何劃分,都與存放內容無關,無論哪個區域,存儲的仍然是對象實例,進一步劃分的目的是爲了更好地回收內存,或者更快地分配內存

根據Java虛擬機規範的規定,Java堆可以處於物理上不連續的內存空間中,只要邏輯上是連續即可,就像我們的磁盤一樣。在實現時,既可以實現成固定大小的,也可以是可擴展的,不過當前主流的虛擬機都是按照可擴展來實現的**(通過-Xmx和-Xms控制)。如果在沒有內存完成實例分配**,並且無法擴展時,將會拋出OutOfMemoryError異常。

方法區

方法區(Method Area)Java堆一樣,是各個線程共享的內存區域,它用於存儲已被虛擬機加載的類信息常量靜態變量即時編譯器編譯後的代碼數據。雖然Java虛擬機規範方法區描述爲一個邏輯部分,但是它卻有一個別名叫做非堆(Non-Heap),目的應該是與Java堆區分開來。

對於習慣在HotSpot虛擬機開發部署程序的開發者來說,很多人都更願意把方法區稱爲**“永久代”(Permanent Generation),本質上兩者並不等價**,僅僅是因爲HotSpot虛擬機的設計團隊選擇把GC分代收集擴展到方法區,或者說使用永久代來實現方法區而已,這樣HotSpot垃圾收集器可以像管理Java堆一樣管理這部分內存,能夠省去專門爲方法區編寫內存管理代碼的工作。對於其他虛擬機(例如:BEA JRockit、IBM J9等等)來說是不存在永久代的概念的。原則上,如何實現方法區屬於虛擬機實現細節,不受虛擬機規範約束,但是使用永久代來實現方法區,現在看來並不是一個好主意,因爲這樣更容易遇到內存溢出問題(永久代有-XX:MaxPermSize的上限,J9和JRockit只要沒有觸碰到進程可用內存的上限,例如:32系統中的4GB,就不會出現問題),而且有極少數方法(例如:String.intern())會因爲這個原因導致不同虛擬機下有不同的表現。因此,對HotSpot虛擬機,根據官方發佈的路線圖信息,現在也有放棄永久代並逐步改爲採用Native Memory來實現方法區的規劃了,在目前已經發布的JDK 1.7HotSpot中,已經把原本放在永久代字符串常量池移出。

Java虛擬機規範方法區的限制非常寬鬆,除了和Java堆一樣不需要連續的內存可以選擇固定大小或者可擴展外,還可以選擇不實現垃圾收集。相對而言,垃圾收集行爲在這個區域是比較少出現的,但是並非數據進入了方法區就如永久代的名字一樣**“永久”存在了。這個區域的內存回收目標主要是針對常量池的回收對類型的卸載**,一般來說,這個區域的回收“成績”比較難令人滿意,尤其是類型的加載,條件相當苛刻,但是這部分區域的回收確實是必要的。在Sun公司BUG列表中,曾出現過的若干個嚴重BUG就是由於低版本HotSpot虛擬機對此區域未完全回收而導致內存泄漏

根據Java虛擬機規範的規定,當方法區無法滿足內存分配需要時,將拋出OutOfMemoryError異常。

運行時常量池

運行時常量池(Runtime Constant Pool)方法區的一部分。Class文件中除了有版本字段方法接口等描述信息外,還有一項信息是常量池(Constant Pool Table),用於存放編譯器生成的各種字面量符號引用,這部分內容將在類加載後進入方法區運行時常量池中存放。

Java虛擬機Class文件每一部分(自然也包括常量池)的格式都有嚴格規定,每一個字節用於存儲哪種數據都必須符合規範上的要求才會被虛擬機認可裝載執行,但是對於運行時常量池Java虛擬機規範沒有做任何細節的要求,不同的提供商實現的虛擬機可以按照自己的需要來實現這個內存區域。不過,一般來說,除了保存Class文件中描述的符號引用外,還會把翻譯出來的直接引用也存儲在運行時常量池中。

運行時常量池相對於Class文件常量池的另外一個重要特徵是具備動態性Java語言並不要求常量一定只有編譯期才能產生,也就是並非預置入Class文件常量池的內容才能進入方法區運行時常量池運行期間也可能將新的常量放入池中,這種特性被開發人員利用得比較多的便是String類的**intern()**方法。

既然運行時常量池方法區的一部分,自然受到方法區內存的限制,當常量池無法再申請到內存時就會拋出OutOfMemoryError異常。

直接內存

直接內存(Direct Memory)並不是虛擬機運行時數據區的一部分,也不是Java虛擬機規範中定義的內存區域。但是這部分內存被頻繁地使用,而且也可能導致OutOfMemoryError異常出現,所以我們放在這裏一起講解。

JDK1.4中新加入了NIO(New Input/Output)類,引入了一種基於通道(Channel)緩衝區(Buffer)I/O方式,它可以使用Native函數庫直接分配堆外內存,然後通過一個存儲在Java堆中的DirectByteBuffer對象作爲這塊內存的引用進行操作。這樣就能在一些場景中顯著提高性能,因爲避免了在Java堆Native堆來回複製數據

顯然,本機直接內存的分配不會受到Java堆大小的限制,但是,既然是內存,肯定還是會受到本機總內存(包括RAM以及SWAP區或者分頁文件)大小以及處理器尋址空間的限制。服務器管理員在配置虛擬機參數時,會根據實際內存設置**-Xmx等參數信息,但是經常忽略直接內存**,使得各個內存區域總和大於物理內存限制(包括物理和操作系統級的限制),從而導致動態擴展時出現OutOfMemoryError異常。

HotSpot虛擬機對象探祕

介紹完Java虛擬機運行時數據區之後,我們大致知道了虛擬機內存的概況,在瞭解內存放了些什麼後,也許就會想更進一步瞭解這些虛擬機內存中的數據的其他細節,譬如它們是如何創建、如何佈局以及如何訪問的。對於這樣涉及細節的問題,必須把討論範圍限定在具體的虛擬機和集中在某一個內存區域上纔有意義。基於實用優先的原則,我以常用的虛擬機HotSpot和常用的內存區域Java堆爲例,深入探討HotSpot虛擬機Java堆對象分配佈局訪問的全過程。

對象的創建

Java是一門面向對象編程語言,在Java程序運行過程中無時無刻都有對象被創建出來。在語言層面上,創建對象(例如:克隆、反序列化)通常僅僅是一個new關鍵字而已,而在虛擬機中,對象(文章中討論的對象僅限於普通的Java對象,不包括數組和Class對象等)創建又是怎樣一個過程呢?

虛擬機遇到一條new指令時,首先將去檢查這個指令的參數是否能在常量池中定位到一個符號引用,並且檢查這個符號引用代表的是否已被加載解析初始化過。如果沒有,那必須先執行相應的類加載過程

類加載檢查通過後,接下來虛擬機將爲新生對象分配內存。對象所需內存的大小在類加載完成後便可完全確定,爲對象分配空間的任務等同於把一塊確定大小的內存從Java堆中劃分出來。假設Java堆中內存時絕對規整的所有用過的內存都放在一邊空閒的內存放在另一邊中間放着一個指針作爲分界點的指示器,那所分配內存就僅僅是把那個指針空閒空間那邊挪動一段與對象大小相等的距離,這種分配方式成爲**“指針碰撞”(Bump the Pointer)。如果Java堆中的內存並不是規整的**,已使用的內存和空閒的內存相互交錯,那就沒有辦法簡單地進行指針碰撞了,虛擬機就必須維護一個列表,記錄上哪些內存塊是可用的,在分配的時候從列表中找到一塊足夠大的空間劃分給對象實例,並更新列表上的記錄,這種分配方式成爲**“空閒列表”(Free List)選擇哪種分配方式由Java堆是否規整決定而Java堆是否規整又由所採用的垃圾收集器是否帶有壓縮整理功能決定。因此,在使用Serial**、ParNew等帶Compact過程的收集器時,系統採用的分配算法指針碰撞,而使用CMS這種基於Mark-Sweep算法的收集器時,通常採用空閒列表

除如何劃分可用空間之外,還有另外一個需要考慮的問題是對象創建虛擬機中是非常頻繁的行爲,即使是僅僅修改一個指針所指向的位置,在併發情況下也並不是線程安全的,可能出現正在給對象A分配內存,指針還沒來得及修改,對象B又同時使用了原來的指針分配內存的情況。解決這個問題有兩種方案

  • 分配內存空間的動作進行同步處理——實際上虛擬機採用CAS配上失敗重試的方式保證更新操作的原子性
  • 內存分配的動作按照線程劃分到不同的空間之中進行,即每個線程Java堆預先分配一小塊內存,稱爲本地線程分配緩衝(Thread Local Allocation Buffer,TLAB)。哪個線程分配內存,就在哪個線程TLAB上分配,只有TLAB用完並分配新的TLAB時,才需要同步鎖定虛擬機是否使用TLAB,可以通過**-XX:+/-UseTLAB**參數來設定。

內存分配完成後,虛擬機需要將分配到的內存空間初始化零值(不包括對象頭),如果使用TLAB,這一工作過程也可以提前至TLAB分配時進行。這一步操作保證了對象實例字段Java代碼中可以不賦初始值直接使用,程序能訪問到這些字段數據類型所對應的零值

接下來,虛擬機要對對象進行必要的設置,譬如這個對象是哪個類的實例、如何才能找到元數據信息對象的哈希碼對象的GC分代年齡等信息。這些信息存放在對象對象頭(Object Header)之中。根據虛擬機當前的運行狀態的不同,例如:是否啓用偏向鎖等,對象頭會有不同的設置方式

在上面工作都完成後,從虛擬機的視角來看,一個新的對象已經產生了,但是從Java程序的視角來看,對象創建纔剛剛開始——方法還沒有執行,所有的字段都還爲零。所以,一般來說(由字節碼中是否跟隨invokespecial指令所決定),執行new指令之後會接着執行**方法**,把對象按照程序員的意願進行初始化,這樣一個真正可用對象纔算完全生產出來

總結一下對象的創建過程

  1. 類加載檢查
  2. 分配內存
  3. 初始化零值
  4. 設置對象頭
  5. 執行init方法

對象的內存佈局

HotSpot虛擬機中,對象內存存儲的佈局可以分爲三塊區域對象頭(Header)實例數據(Instance Data)對齊填充(Padding)

對象頭

HotSpot虛擬機對象頭包括兩部分信息

  • 第一部分用於存儲對象自身的運行時數據,例如:哈希碼(HashCode)GC分代年齡鎖狀態標誌線程持有的鎖偏向線程ID偏向時間戳等,這部分數據的長度在32位64位虛擬機(未開啓壓縮指針)中分別爲32bit64bit,官方稱它爲**“Mark Word”對象需要存儲的運行時數據很多**,其實已經超出了32位64位Bitmap結構所能記錄的限度,但是對象頭信息是與對象自身定義的數據無關額外存儲成本,考慮到虛擬機空間效率Mark Word被設計成一個非固定數據結構以便在極小的空間內存儲儘量多的信息,它會根據對象的狀態複用自己的存儲空間,例如:在32位HotSpot虛擬機中,如果對象處於未被鎖定的狀態下,那麼Mark Word32bit空間中的25bit用於存儲對象哈希碼4bit用於存儲對象分代年齡2bit用於存儲鎖標誌位1bit固定爲0,而在其他狀態(輕量級鎖定、重量級鎖定、GC標記、可偏向)對象存儲內容如下圖所示:
    HotSpotVirtualMachineObjectHeadMarkWord.png
  • 第二部分類型指針,即對象指向它的類元數據的指針虛擬機通過這個指針來確定這個對象哪個類實例。並不是所有的虛擬機實現都必須在對象數據保留類型指針,換句話說,查找對象元數據信息並不一定要經過對象本身,這點將在下面要講的對象的訪問定位講解。另外,如果對象是一個Java數組,那在對象頭中必須有一塊用於記錄數組長度的數據,因爲虛擬機可以通過普通Java對象元數據信息確定Java對象大小,但是從數組元數據中卻無法確定數組的大小

實例數據

實例數據對象真正存儲的有效信息也是在程序代碼中所定義的各種類型的字段內容。無論是從父類繼承下來的,還是在子類中定義的,都需要記錄起來。這部分的存儲順序會受到虛擬機分配策略參數(FieldsAllocationStyle)字段在Java源碼中定義順序的影響。HotSpot虛擬機默認的分配策略longs/doublesintsshorts/charsbytes/booleansoops(Ordinary Object Pointers),從分配策略中可以看出,相同寬度的字段總是被分配到一起。在滿足這個前提條件的情況下,在父類中定義變量會出現在子類之前。如果CompactFields參數值爲true(默認爲true),那麼子類之中較窄變量也可能會插入到父類變量的空隙之中。

對齊填充

對齊填充不是必然存在的,也沒有特別的定義,它僅僅起着佔位符的作用。由於HotSpot VM自動內存管理系統要求對象起始地址必須是8字節整數倍,換句話說,就是對象大小必須是8字節整數倍。而對象頭部分正好是8字節倍數(1倍或者2倍),因此,當對象實例數據部分沒有對齊時,就需要通過對齊填充補全

對象的訪問定位

建立對象是爲了使用對象,我們的Java程序需要通過上的reference數據來操作上的具體對象。由於reference類型在Java虛擬機規範只規定一個指向對象的引用並沒有定義這個引用應該通過何種方式去定位、訪問堆中對象的具體位置,所以對象訪問方式也是取決於虛擬機實現而定的。目前主流訪問方式有使用句柄直接指針兩種:

  • 如果使用句柄訪問的話,那麼Java堆中將會劃分出一塊內存來作爲句柄池reference中存儲的就是對象句柄地址,而句柄中包含了對象實例數據類型數據各自的具體地址信息,如下圖所示:
    AccessTheObjectThroughTheHandle.png
  • 如果使用直接指針訪問的話,那麼Java堆對象的佈局中就必須考慮如何放置訪問類型數據相關信息,而reference中存儲的直接就是對象地址,如下圖所示: AccessingAnObjectThroughADirectPointer.png

兩種對象訪問方式各有優勢,使用句柄來訪問的最大好處就是reference中存儲的是穩定的句柄地址,在對象被移動(垃圾收集時移動對象是非常普遍的行爲)只會改變句柄中實例數據指針,而reference本身不需要修改

使用直接指針訪問方式的最大好處就是速度更快它節省了一次指針定位的時間開銷,由於對象的訪問Java非常頻繁,因此這裏開銷積少成多後也是一項非常可觀執行成本。本文章討論的虛擬機Sun HotSpot使用的是第二種方式,也就是使用直接指針進行對象訪問的,但是從整個軟件開發的範圍來看,各種語言框架使用句柄來進行對象訪問也是十分常見的

題外話

我想聊一下Java基本數據類型包裝類常量池String類型常量池

Java基本數據類型包裝類常量池

Java基本數據類型中的byteshortintlongbooleanchar包裝類使用了常量池,它們只在**[-128, 127]範圍內使用相應類型緩存數據**,超出這個範圍的就會創建新的對象,而floatdouble包裝類沒有使用常量池

舉個例子,代碼如下所示:

/**
 * Created by TanJiaJun on 2020/6/26.
 */
public class ConstantPoolTest {

    public static void main(String[] args) {
         Integer i1 = 3;
         Integer i2 = 4;
         Integer i3 = 7;
         Integer i4 = 7;
         Integer i5 = 777;
         Integer i6 = 777;
         Integer i7 = new Integer(3);
         Integer i8 = new Integer(4);
         Integer i9 = new Integer(7);
         Double d1 = 7.7;
         Double d2 = 7.7;

         System.out.println(i3 == i4);      // true
         System.out.println(i1 + i2 == i3); // true
         System.out.println(i5 == i6);      // false
         System.out.println(i3 == i9);      // false
         System.out.println(i7 + i8 == i9); // true
         System.out.println(i7 + i8 == 7);  // true
         System.out.println(d1 == d2);      // false
    }

}

Java中,==兩個作用:

  • 比較的是Java基本數據類型的話,就是比較它們的值是否相等。
  • 比較的是引用類型的話,就是比較它們的引用地址是否相同。

解析:

  • i3 == i4,返回truei3i4都是Integer類型,它們的相等,而且都在**[-128, 127]範圍內,所以它們同時使用着常量池中的對象,也就是它們是同一個對象**,因此返回true
  • i1 + i2 == i3,返回truei1i2i3都是Integer類型,加號不適用於Integer對象編譯器會對i1i2進行自動拆箱,進行數值相加,然後變成7 == i3,因爲i3也是Integer類型,它無法和數值進行直接比較,所以編譯器也會對i3進行自動拆箱,最後就變成數值的比較7 == 7,所以返回true
  • i5 == i6,返回falsei5i6都是Integer類型,它們的相等,但是不在**[-128, 127]範圍內,所以它們都各自創建新的對象,也就是它們不是同一個對象**,因此返回false
  • i3 == i9,返回falsei3i9都是Integer類型,i3會使用Integer常量池的對象,而i9會創建新的Integer對象,所以它們不是同一個對象,因此返回false
  • i7 + i8 == i9,返回truei7i8i9都是Integer類型,加號不適用於Integer對象編譯器會對i7i8進行自動拆箱,進行數值相加,然後變成7 == i9,因爲i9也是Integer類型,它無法和數值進行直接比較,所以編譯器也會對i9進行自動拆箱,最後就變成數值的比較7 == 7,所以返回true
  • i7 + i8 == 7,返回truei7i8都是Integer類型,加號不適用於Integer對象編譯器會對i7i8進行自動拆箱,進行數值相加,最後變成數值的比較7 == 7,所以返回true
  • d1 == d2,返回falsed1d2都是Double類型,它們的相等,但是沒有使用常量池,所以它們都各自創建新的對象,也就是它們不是同一個對象,因此返回false

當聲明爲如上述示例代碼中的i1i2i3i4時,編譯器會幫我們自動裝箱,調用Integer類的valueOf方法,看下相關的源碼,源碼如下所示:

// Integer.java
package java.lang;

import java.lang.annotation.Native;

public final class Integer extends Number implements Comparable<Integer> {

    private static class IntegerCache {
        // 緩存的最小值是-128
        static final int low = -128;
        static final int high;
        static final Integer cache[];

        static {
            // 緩存的最大值是127
            int h = 127;
            String integerCacheHighPropValue =
                sun.misc.VM.getSavedProperty("java.lang.Integer.IntegerCache.high");
            if (integerCacheHighPropValue != null) {
                try {
                    int i = parseInt(integerCacheHighPropValue);
                    i = Math.max(i, 127);
                    // 數組的最大大小爲Integer.MAX_VALUE
                    h = Math.min(i, Integer.MAX_VALUE - (-low) -1);
                } catch( NumberFormatException nfe) {
                    // 如果不能將該屬性解析爲int,就忽略它
                }
            }
            high = h;

            cache = new Integer[(high - low) + 1];
            int j = low;
            for(int k = 0; k < cache.length; k++)
                cache[k] = new Integer(j++);

            assert IntegerCache.high >= 127;
        }

        private IntegerCache() {}
    }

    public static Integer valueOf(int i) {
        if (i >= IntegerCache.low && i <= IntegerCache.high)
            return IntegerCache.cache[i + (-IntegerCache.low)];
        return new Integer(i);
    }

}

可以看到Integer類的valueOf方法,如果是大於等於IntegerCache.low的值(-128),同時小於等於IntegerCache.high的值(127),就會使用IntegerCache,也就是使用緩存,否則就創建新的Integer對象

這裏順便提一下equals方法,它和**==有什麼區別呢?先看下Object類的equals**方法,源碼如下所示:

// Object.java
public class Object {

    // 省略部分代碼

    public boolean equals(Object obj) {
        return (this == obj);
    }

    // 省略部分代碼

}

可以看到equals方法的邏輯就是**==,然後看下Integer類的equals**方法,源碼如下所示:

// Integer.java
public final class Integer extends Number implements Comparable<Integer> {

    // 省略部分代碼

    // Integer的值
    private final int value;

    // 以int的形式返回該Integer的值
    public int intValue() {
        return value;
    }

    public boolean equals(Object obj) {
        // 判斷參數obj是否爲Integer類的實例
        if (obj instanceof Integer) {
            // 如果參數obj是Integer類的實例,就調用它的intValue方法得到值,並且判斷value是否與該值相等
            return value == ((Integer)obj).intValue();
        }
        // 如果參數obj不是Integer類的實例,就返回false
        return false;
    }

    // 省略部分代碼

}

再看下String類的equals方法,源碼如下所示:

// String.java
public final class String
    implements java.io.Serializable, Comparable<String>, CharSequence {

    // 省略部分代碼

    public boolean equals(Object anObject) {
        // 判斷參數anObject的引用地址是否與該對象相同
        if (this == anObject) {
            // 如果參數anObject的引用地址與該對象相同,就返回true
            return true;
        }
        // 如果參數anObject的引用地址與該對象不相同,就判斷anObject是否爲String類的實例
        if (anObject instanceof String) {
            // 強制轉成String對象
            String anotherString = (String)anObject;
            int n = length();
            if (n == anotherString.length()) {
                int i = 0;
                // 判斷String類型的參數anObject中的每個字符是否與該對象的每個字符相等
                while (n-- != 0) {
                    if (charAt(i) != anotherString.charAt(i))
                            // 如果String類型的參數anObject中的有其中一個字符與該對象的其中一個字符不相等,就返回false
                            return false;
                    i++;
                }
                // 如果String類型的參數anObject中的每個字符都與該對象的每個字符相等,就返回true
                return true;
            }
        }
        // 如果參數anObject不是String類的實例,就返回false
        return false;
    }

    // 省略部分代碼

}

可以看到Integer類和String重寫Object類的equals方法,邏輯也改成判斷對應類型的值是否相等

字符串常量池

JDK 1.7之後(包括JDK 1.7),字符串常量池方法區移動到

字面量聲明

示例代碼如下:

String str = "譚嘉俊";

這種聲明方式叫做字面量聲明,它是把字符串雙引號包起來,然後賦值給一個變量,這種情況下,它會把字符串放到字符串常量池,然後返回給變量

new String()

示例代碼如下:

String str = new String("譚嘉俊");

使用new String()方法不管在字符串常量池中有沒有,它都會在創建一個新的對象

intern()

源碼代碼如下:

// String.java
public final class String
    implements java.io.Serializable, Comparable<String>, CharSequence {

    // 省略部分代碼

    public native String intern();

}

可以看到intern方法是個native方法。

字符串常量池最初是空的,由String類私有地維護,當intern方法被調用的時候,如果當前字符串存在於字符串常量池,判斷條件是使用equals方法是返回true的話,就會直接返回這個字符串在字符串常量池的引用,如果不存在,它就會在字符串常量池中創建一個引用,並且指向堆中已存在的字符串,然後返回對應的字符串常量池的引用。

舉個例子,代碼如下:

/**
 * Created by TanJiaJun on 2020/6/27.
 */
public class StringConstantPoolTest {

    public static void main(String[] args) {
         String str1 = "譚嘉俊";
         String str2 = "譚嘉俊";
         String str3 = "我叫";
         String str4 = new String(str1 + "譚嘉俊");
         String str5 = new String(str1 + "譚嘉俊");

         System.out.println(str1 == str2); // 1.true
         System.out.println(str1 == str4); // 2.false
         System.out.println(str4 == str5); // 3.false
         str4.intern();
         System.out.println(str1 == str4); // 4.false
         str4 = str4.intern();
         System.out.println(str1 == str4); // 5.true
         str5 = str5.intern();
         System.out.println(str4 == str5); // 6.true
    }

}
  1. str1 == str2,返回truestr1str2都是字面量聲明,而且相等,所以它們都指向字符串常量池中同一個對象,因此返回true
  2. str1 == str4,返回falsestr1字面量聲明,它是從字符串常量池取出來的,str4是在創建對象,所以它們不是同一個對象,因此返回false
  3. str4 == str5,返回falsestr4str5各自都在創建對象,所以它們不是同一個對象,因此返回false
  4. str1 == str4,返回false:雖然str4調用了intern方法,但是沒有返回,所以它們還是不是同一個對象,因此返回false
  5. str1 == str4,返回truestr4調用了intern方法,並且返回給str4,所以它們都指向字符串常量池中同一個對象,因此返回true
  6. str4 == str5,返回true:前面的邏輯,str4調用了intern方法,str5也調用了intern方法,所以它們都指向字符串常量池中同一個對象,因此返回true

參考文獻

[1] 周志明,深入理解Java虛擬機(第2版)[M],機械工業出版社,2013年9月1日,37頁~49頁

我的GitHub:TanJiaJunBeyond

Android通用框架:Android通用框架

我的掘金:譚嘉俊

我的簡書:譚嘉俊

我的CSDN:譚嘉俊

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章