new String("abc")創建了幾個對象
面試官考察點猜想
這種問題,考察你對JVM的理解程度。涉及到常量池、對象內存分配等問題。
涉及背景知識詳解
在分析這個問題之前,我們先來了解一下JVM的組成,如圖所示。

在JVM1.8中,內存划分為堆、程序計數器、本地方發棧、方法區(元空間)、虛擬機棧。
JVM知識點普及
下面分別解釋一下JVM運行時內存的功能。
堆內存空間
堆是 JVM 內存中最大的一塊內存空間,該內存被所有線程共享,幾乎所有對象和數組都被分配到了堆內存中。堆被划分為新生代和老年代,新生代又被進一步划分為 Eden 和 Survivor 區,最后 Survivor 由 From Survivor 和 To Survivor 組成。
但需要注意的是,這些區域的划分因不同的垃圾收集器而不同。大部分垃圾收集器都是基於分代收集理論設計的,就會采用這種分代模型。而一些新的垃圾收集器不采用分代設計,比如 G1 收集器就是把堆內存拆分為多個大小相等的 Region。

方法區
在 jdk8 之前,HotSopt 虛擬機的方法區又被稱為永久代,由於永久代的設計容易導致內存溢出等問題,jdk8 之后就沒有永久代了,取而代之的是元空間(MetaSpace)。元空間並沒有處於堆內存上,而是直接占用的本地內存,因此元空間的最大大小受本地內存限制。
方法區與堆空間類似,是所有線程共享的。方法區主要是用來存放已被虛擬機加載的類型信息、常量、靜態變量等數據。方法區是一個邏輯分區,包含元空間、運行時常量池、字符串常量池,元空間物理上使用的本地內存,運行時常量池和字符串常量池是在堆中開辟的一塊特殊內存區域。這樣做的好處之一是可以避免運行時動態生成的常量的復制遷移,可以直接使用堆中的引用。
要注意的是,字符串常量池在JVM中只有一個,而運行時常量池是和類型數據綁定的,每個Class一個。

- 每個class的字節碼文件中都有一個常量池,里面是編譯后即知的該class會用到的
字面量與符號引用,這就是class文件常量池。JVM加載class,會將其類信息,包括class文件常量池置於方法區中。 - class類信息及其class文件常量池是字節碼的二進制流,它代表的是一個類的靜態存儲結構,JVM加載類時,需要將其轉換為方法區中的
java.lang.Class類的對象實例;同時,會將class文件常量池中的內容導入運行時常量池。 - 運行時常量池中的常量對應的內容只是字面量,比如一個"字符串",它還不是String對象;當Java程序在運行時執行到這個"字符串"字面量時,會去
字符串常量池里找該字面量的對象引用是否存在,存在則直接返回該引用,不存在則在Java堆里創建該字面量對應的String對象,並將其引用置於字符串常量池中,然后返回該引用。 - Java的基本數據類型中,除了兩個浮點數類型,其他的基本數據類型都在各自內部實現了常量池,但都在[-128~127]這個范圍內。
虛擬機棧
每當啟動一個新的線程,虛擬機都會在虛擬機棧里為它分配一個線程棧,線程棧與線程同生共死。線程棧以棧幀為單位保存線程的運行狀態,虛擬機只會對線程棧執行兩種操作:以棧幀為單位的壓棧或出棧。每個方法在執行的同時都會創建一個棧幀,每個方法從調用開始到結束,就對應着一個棧幀在線程棧中壓棧和出棧的過程。方法可以通過兩種方式結束,一種通過 return 正常返回,一種通過拋出異常而終止。方法返回后,虛擬機都會彈出當前棧幀然后釋放掉。
當虛擬機調用一個Java方法時.它從對應類的類型信息中得到此方法的局部變量區和操作數棧的大小,並據此分配棧幀內存,然后壓入Java棧中。
棧幀由三部分組成:局部變量區、操作數棧、幀數據區。

1)局部變量區:
- 局部變量區是一個數組結構,主要存放對應方法的參數和局部變量。
- 如果是實例方法,局部變量表第一個參數是一個 reference 引用類型,存放的是當前對象本身 this。
2)操作數棧:
- 操作數棧也是一個數組結構,但並不是通過索引來訪問的,而是棧的壓棧和出棧操作。
- 操作數棧是虛擬機的工作區,大多數指令都要從這里彈出數據、執行運算、然后把結果壓回操作數棧。
3)動態鏈接:
-
每個棧幀內部都包含一個指向當前方法所在類型的運行時常量池的引用,以便對當前方法的代碼實現動態鏈接。
-
在class文件里面,一個方法若要調用其他方法,或者訪問成員變量,則需要通過符號引用來表示,動態鏈接的作用就是將這些以符號引用所表示的方法轉換為對實際方法的直接引用。
4)方法返回:
- 方法執行后,有兩種方式退出該方法:正常調用完成,執行返回指令。異常調用完成,遇到未捕獲異常,不會有方法返回值給調用者。
本地方法棧
本地方法棧與虛擬機棧所發揮的作用是相似的,當線程調用Java方法時,會創建一個棧幀並壓入虛擬機棧;而調用本地方法時,虛擬機會保持棧不變,不會壓入新的棧幀,虛擬機只是簡單的動態鏈接並直接調用指定的本地方法,使用的是某種本地方法棧。比如某個虛擬機實現的本地方法接口是使用C連接模型,那么它的本地方法棧就是C棧。
本地方法可以通過本地方法接口來訪問虛擬機的運行時數據區,它可以做任何他想做的事情,本地方法不受虛擬機控制。
程序計數器
每一個運行的線程都會有它的程序計數器(PC寄存器),與線程的生命周期一樣。執行某個方法時,PC寄存器的內容總是下一條將被執行的地址,這個地址可以是一個本地指針,也可以是在方法字節碼中相對於該方法起始指令的偏移量。如果該線程正在執行一個本地方法,那么此時PC寄存器的值是 undefined。
程序計數器是程序控制流的指示器,分支、循環、跳轉、異常處理、線程恢復等基礎功能都需要依賴這個計數器來完成。多線程環境下,為了線程切換后能恢復到正確的執行位置,每條線程都需要有一個獨立的程序計數器,各條線程之間計數器互不影響,獨立存儲。
代碼在JVM內存中的體現
當我們通過Object o=new Object()創建一個對象時,在JVM中會分配一塊內存用來存儲該對象的信息,實現原理如下圖所示。

在main方法中,創建了一個局部變量o,當main方法運行時,首先會把main方法壓入到棧幀中,接着執行該方法的Object o =new Object()創建對象。
- 在局部變量表中創建一個局部變量
o。 - 在堆內存中分配一塊內存地址,用來存儲
object對象。 - 變量
o指向堆內存中的內存地址。
我們再來看一個例子,聲明一個Person對象,在該對象中存在一個常量name、以及一個成員變量age,當運行該類中的main方法時,此時JVM內存中的運行情況如下。

在這個例子中,看到了常量池的出現,看來,還有必要了解一下常量池的知識
JVM中的常量池
在JVM中,常量池主要分為:Class文件常量池、運行時常量池,當然還有全局字符串常量池,以及基本類型包裝類對象常量池。
常量池主要存放兩大類常量:字面量和符號引用。
- 字面量:字面量主要是文本字符串、final 常量值、類名和方法名的常量等。
- 符號引用:符號引用對java動態連接起着非常重要的作用。主要的符號引用有:類和接口的全限定名、字段的名稱和描述符、方法的名稱和描述符等。
Class文件常量池
class文件是一組以8位字節為單位的二進制數據流,在java代碼的編譯期間,我們編寫的.java文件就被編譯為.class文件格式的二進制數據存放在磁盤中,其中就包括class文件常量池。
為了更好的說明,我們通過下面這段代碼為例進行講解。
class ConstantExample{
private int value = 1;
public String s = "abc";
public final static int f = 0x101;
public void setValue(int v){
final int temp = 3;
this.value = temp + v;
}
public int getValue(){
return value;
}
}
這段代碼被編譯后,通過javap -v命令查看編譯后的字節碼。
從下面這個字節碼信息中可以看到,執行這個命令之后我們得到了該class文件的版本號、常量池、已經編譯后的字節碼指令(處於篇幅原因這里省略),下面我們會對照這個class文件來講解:
example/target/classes/HelloExample.class
Last modified 2021-10-25; size 734 bytes
MD5 checksum fd06c1426f4fdef12aa109ee7f010a45
Compiled from "HelloExample.java"
public class HelloExample
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #6.#32 // java/lang/Object."<init>":()V
#2 = Fieldref #5.#33 // HelloExample.value:I
#3 = String #34 // abc
#4 = Fieldref #5.#35 // HelloExample.s:Ljava/lang/String;
#5 = Class #36 // HelloExample
#6 = Class #37 // java/lang/Object
#7 = Utf8 value
#8 = Utf8 I
#9 = Utf8 s
#10 = Utf8 Ljava/lang/String;
#11 = Utf8 f
#12 = Utf8 ConstantValue
#13 = Integer 257
#14 = Utf8 <init>
#15 = Utf8 ()V
#16 = Utf8 Code
#17 = Utf8 LineNumberTable
#18 = Utf8 LocalVariableTable
#19 = Utf8 this
#20 = Utf8 LHelloExample;
#21 = Utf8 getValue
#22 = Utf8 ()I
#23 = Utf8 setValue
#24 = Utf8 (I)V
#25 = Utf8 MethodParameters
#26 = Utf8 main
#27 = Utf8 ([Ljava/lang/String;)V
#28 = Utf8 args
#29 = Utf8 [Ljava/lang/String;
#30 = Utf8 SourceFile
#31 = Utf8 HelloExample.java
#32 = NameAndType #14:#15 // "<init>":()V
#33 = NameAndType #7:#8 // value:I
#34 = Utf8 abc
#35 = NameAndType #9:#10 // s:Ljava/lang/String;
#36 = Utf8 HelloExample
#37 = Utf8 java/lang/Object
字面量
字面量接近於java語言層面的常量概念,主要包括:
-
文本字符串,也就是我們經常聲明的:
public String s = "abc";中的"abc"#3 = String #34 // abc -
用final修飾的成員變量,包括靜態變量、實例變量和局部變量
#11 = Utf8 f #12 = Utf8 ConstantValue #13 = Integer 257
這里需要說明的一點,上面說的存在於常量池的字面量,指的是數據的值,也就是abc和0x101(257),通過上面對常量池的觀察可知這兩個字面量是確實存在於常量池的。
而對於基本類型數據(甚至是方法中的局部變量),也就是上面的private int value = 1;常量池中只保留了他的的字段描述符I和字段的名稱value,他們的字面量不會存在於常量池:
符號引用
符號引用主要設涉及編譯原理方面的概念,包括下面三類常量:
-
類和接口的全限定名,也就是
Ljava/lang/String;這樣,將類名中原來的"."替換為"/"得到的,主要用於在運行時解析得到類的直接引用.#5 = Class #36 // HelloExample #6 = Class #37 // java/lang/Object -
字段的名稱和描述符,字段也就是類或者接口中聲明的變量,包括類級別變量(static)和實例級的變量
#2 = Fieldref #5.#33 // HelloExample.value:I #7 = Utf8 value #8 = Utf8 I
運行時常量
運行時常量池是方法區的一部分,所以也是全局共享的。我們知道,jvm在執行某個類的時候,必須經過加載、連接(驗證,准備,解析)、初始化,在第一步的加載階段,虛擬機需要完成下面3件事情:
- 通過一個類的“全限定名”來獲取此類的二進制字節流
- 將這個字節流所代表的靜態儲存結構轉化為方法區的運行時數據結構
- 在內存中生成一個類代表這類的java.lang.Class對象,作為方法區這個類的各種數據訪問的入口
這里需要說明的一點是,類對象和普通的實例對象是不同的,類對象是在類加載的時候生成的,普通的實例對象一般是在調用new之后創建。
上面第二條,將class字節流代表的靜態儲存結構轉化為方法區的運行時數據結構,其中就包含了class文件常量池進入運行時常量池的過程。這里需要強調一下,不同的類共用一個運行時常量池,同時在進入運行時常量池的過程中,多個class文件中常量池中相同的字符串只會存在一份在運行時常量池中,這也是一種優化。
運行時常量池的作用是存儲 Java class文件常量池中的符號信息。運行時常量池 中保存着一些 class 文件中描述的符號引用,同時在類加載的“解析階段”還會將這些符號引用所翻譯出來的直接引用(直接指向實例對象的指針)存儲在 運行時常量池 中。
運行時常量池相對於 class 常量池一大特征就是其具有動態性,Java 規范並不要求常量只能在運行時才產生,也就是說運行時常量池中的內容並不全部來自 class 常量池,class 常量池並非運行時常量池的唯一數據輸入口;在運行時可以通過代碼生成常量並將其放入運行時常量池中,這種特性被用的較多的是String.intern()(這個方法下面將會詳細講)。
問題解答
理解了上述JVM的背景知識之后,再回到最開始的問題.下面這段代碼會創建幾個對象?
String str=new String("abc");
- 首先,我們看到這個代碼中有一個
new關鍵字,我們知道new指令是創建一個類的實例對象並完成加載初始化的,因此這個字符串對象是在運行期才能確定的,創建的字符串對象是在堆內存上。 - 其次,在String的構造方法中傳遞了一個字符串
abc,由於這里的abc是被final修飾的屬性,所以它是一個字符串常量。在首次構建這個對象時,JVM拿字面量"abc"去字符串常量池試圖獲取其對應String對象的引用。於是在堆中創建了一個"abc"的String對象,並將其引用保存到字符串常量池中,然后返回;
所以,這里正確的回答應該是: 如果abc這個字符串常量不存在,則創建兩個對象,分別是abc這個字符串常量,以及new String這個實例對象。
如果abc這字符串常量存在,則只會創建一個對象。
問題總結
關於這道題,其實涉及到的知識點非常多,我並沒有非常完整的把JVM的內容整體說完,因為JVM整個體系還是較為龐大的。
所以,建議大家平時如果有時間的情況下,可以系統化的學習一下JVM有關的內容,這塊的面試問題還是比較多的。
關注[跟着Mic學架構]公眾號,獲取更多精品原創

