这种问题,考察你对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有关的内容,这块的面试问题还是比较多的。
下一节:这道题仍然是考察JVM层面的基本知识,面试官认为,基本功扎实,才能写出健壮性和稳定性很高的代码。