目录
典型回答
常量池基本概念
字符串常量池的结构
再看字面量和运行时常量池
intern
还是创建了几个对象
intern的正确用法
-
典型回答
- 创建的对象数应该是1个或者2个
- 如果常量池中存在,则直接new一个对象
- 如果常量池不存在,则在常量池中创建一个对象,也在堆中创建一个对象
- 首先要清楚什么是对象?
- Java是一种面向对象的语言,而Java对象在JVM中的存储也是有一定的结构的,在HotSpot虚拟机中,存储的形式就是oop-klass model,即Java对象模型
- 在Java代码中,使用new创建一个对象的时候,JVM会创建一个instanceOopDesc对象,这个对象中包含了两部分信息,对象头以及元数据
- 对象头中有一些运行时数据,其中就包括和多线程相关的锁的信息
- 元数据其实维护的是指针,指向的是对象所属的类的instanceKlass
- 这才叫对象
- 其他的,一概都不叫对象
- 那么不管怎么样,一次new的过程,都会在堆上创建一个对象,那么就是起码有一个对象了
- 至于另外一个对象,到底有没有要看具体情况了
- 另外这一个对象就是常量池中的字符串常量,这个字符串其实是类编译阶段就进到Class常量池的,那么当这个类第一次被ClassLoader加载的时候,会从Class常量池进入到运行时常量池
- 在运行时常量池中,也并不是会立刻被解析成对象,而是会先以JVM_CONSTANT_UnresolveString_info的形式驻留在常量池
- 在后面,该引用第一次被LDC指令执行到的时候,就尝试在堆上创建字符串对象,并将对象的引用驻留在字符串常量池中
- 通过看上面的过程,也能发现,这个过程的触发条件是没办法决定的,问题的题干中也没提到
- 有可能执行这段代码的时候是第一次LDC指令执行,也许在前面就执行过了
- 所以,如果是第一次执行,那么就是会同时创建两个对象
- 一个字符串常量引用指向的对象,一个我们new出来的对象
- 如果不是第一次执行,那么就只会创建我们自己new出来的对象
- 至于有人说什么在字符串池内还有在栈上还有一个引用对象,引用就是引用;别往对象上面扯
-
常量池基本概念
- 下面是基于jdk8版本进行说明:
- class 文件常量池:
- 在 class 文件中保存了一份常量池(Constant Pool),主要存储编译时确定的数据,包括代码中的字面量(literal)和符号引用
- 运行时常量池:
- 位于方法区中,全局共享,class 文件常量池中的内容会在类加载后存放到方法区的运行时常量池中;除此之外,在运行期间可以将新的变量放入运行时常量池中,相对 class 文件常量池而言运行时常量池更具备动态性
- 字符串常量池:
- 位于堆中,全局共享,这里可以先粗略的认为它存储的是 String 对象的直接引用,而不是直接存放的对象,具体的实例对象是在堆中存放
- 可以用一张图来描述它们各自所处的位置:
-
字符串常量池的结构
- 在 Hotspot JVM 中,字符串常量池StringTable的本质是一张HashTable
- 那么当我们说将一个字符串放入字符串常量池的时候,实际上放进去的是什么呢?
- 以字面量的方式创建 String 对象为例,字符串常量池以及堆栈的结构如下图所示(忽略了 jvm 中的各种OopDesc实例):
- 实际上字符串常量池 HashTable 采用的是数组加链表的结构,链表中的节点是一个个的HashTableEntry,而 HashTableEntry 中的 value 则存储了堆上 String 对象的引用
- 那么,下一个问题来了,这个字符串对象的引用是什么时候被放到字符串常量池中的?
- 具体可为两种情况:
- 使用字面量声明 String 对象时,也就是被双引号包围的字符串,在堆上创建对象,并驻留到字符串常量池中(注意这个用词)
- 调用intern()方法,当字符串常量池没有相等的字符串时,会保存该字符串的引用
- 注意!在上面用到了一个词驻留,这里对它进行一下规范
- 当说驻留一个字符串到字符串常量池时,指的是创建 HashTableEntry ,再使它的 value 指向堆上的 String 实例,并把 HashTableEntry放入字符串常量池,而不是直接把 String 对象放入字符串常量池中
- 简单来说,可以理解为将 String 对象的引用保存在字符串常量池中
- 把 intern() 方法放在后面细说,先主要看第一种情况,这里直接整理结论:
- 在类加载阶段,JVM 会在堆中创建对应这些 class 文件常量池中的字符串对象实例,并在字符串常量池中驻留其引用
- 这一过程具体是在 resolve 阶段(个人理解就是 resolution 解析阶段)执行,但是并不是立即就创建对象并驻留了引用,因为在 JVM 规范里指明了 resolve 阶段可以是 lazy 的
- CONSTANT_String会在第一次引用该项的 ldc 指令被第一次执行到的时候才会 resolve
- 就 HotSpot VM 的实现来说,加载类时字符串字面量会进入到运行时常量池,不会进入全局的字符串常量池,即在 StringTable 中并没有相应的引用,在堆中也没有对应的对象产生
- 在弄清楚上面几个概念后,再回过头来,先看看用字面量声明 String 的方式,代码如下:
- 反编译生成的字节码文件:
- 解释一下上面的字节码指令:
- 0: ldc,查找后面索引为#2 对应的项,#2 表示常量在常量池中的位置
- 在这个过程中,会触发前面提到的lazy resolve,在 resolve 过程如果发现StringTable已经有了内容匹配的 String 引用,则直接返回这个引用,反之如果StringTable里没有内容匹配的 String 对象的引用,则会在堆里创建一个对应内容的 String 对象,然后在StringTable驻留这个对象引用,并返回这个引用,之后再压入操作数栈中
- 2: astore_1,弹出栈顶元素,并将栈顶引用类型值保存到局部变量 1 中,也就是保存到变量s中
- 3: return,执行void函数返回
- 可以看到,在这种模式下,只有堆中创建了一个 "Hydra" 对象,在字符串常量池中驻留了它的引用
- 并且,如果再给字符串 s2、s3 也用字面量的形式赋值为 "Hydra" ,它们用的都是堆中的唯一这一个对象
- 再看一下以构造方法的形式创建字符串的方式:
- 同样反编译这段代码的字节码文件:
- 看一下和之前不同的字节码指令部分:
- 0: new,在堆上创建一个 String 对象,并将它的引用压入操作数栈,注意这时的对象还只是一个空壳,并没有调用类的构造方法进行初始化
- 3: dup,复制栈顶元素,也就是复制了上面的对象引用,并将复制后的对象引用压入栈顶;这里之所以要进行复制,是因为之后要执行的构造方法会从操作数栈弹出需要的参数和这个对象引用本身(这个引用起到的作用就是构造方法中的this指针),如果不进行复制,在弹出后会无法得到初始化后的对象引用
- 4: ldc,在堆上创建字符串对象,驻留到字符串常量池,并将字符串的引用压入操作数栈
- 6: invokespecial,执行 String 的构造方法,这一步执行完成后得到一个完整对象
- 到这里可以看到一共创建了两个String 对象,并且两个都是在堆上创建的,且字面量方式创建的String 对象的引用被驻留到了字符串常量池中
- 而栈里的s只是一个变量,并不是实际意义上的对象,不把它包括在内
- 其实想要验证这个结论也很简单,可以使用 idea 中强大的 debug 功能来直观的对比一下对象数量的变化,先看字面量创建 String 方式:
- 这个对象数量的计数器是在 debug 时,点击下方右侧Memory的Load classes弹出的
- 对比语句执行前后可以看到,只创建了一个 String 对象,以及一个 char 数组对象,也就是 String 对象中的value
- 再看看构造方法创建 String 的方式:
- 可以看到,创建了两个 String 对象,一个 char 数组对象,也说明了两个 String 中的value指向了同一个char 数组对象,符合上面从字节码指令角度解释的结果
- 最后再看一下下面的这种情况,当字符串常量池已经驻留过某个字符串引用,再使用构造方法创建String 时,创建了几个对象?
- 答案是只创建一个对象,对于这种重复字面量的字符串,看一下反编译后的字节码指令:
- 可以看到两次执行 ldc 指令时后面索引相同,而 ldc 判断是否需要创建新的 String 实例的依据是根据在第一次执行这条指令时, StringTable 是否已经保存了一个对应内容的 String 实例的引用
- 所以在第一次执行 ldc 时会创建 String 实例,而在第二次 ldc 就会直接返回而不需要再创建实例了
-
再看字面量和运行时常量池
- JVM为了提高性能和减少内存开销,在实例化字符串常量的时候进行了一些优化
- 为了减少在JVM中创建的字符串的数量,字符串类维护了一个字符串常量池
- 在JVM运行时区域的方法区中,有一块区域是运行时常量池,主要用来存储编译期生成的各种字面量和符号引用
- 在java代码被 javac 编译之后,文件结构中是包含一部分 Constant pool 的
- 比如以下代码:
- 经过编译后,常量池内容如下:
- 上面的Class文件中的常量池中,比较重要的几个内容:
- 上面几个常量中,s 就是前面提到的符号引用,而 Hollis 就是前面提到的字面量
- 而Class文件中的常量池部分的内容,会在运行期被运行时常量池加载进去
-
intern
- 编译期生成的各种字面量和符号引用是运行时常量池中比较重要的一部分来源,但是并不是全部
- 那么还有一种情况,可以在运行期向运行时常量池中增加常量
- 那就是 String 的 intern 方法
- 当一个 String 实例调用 intern() 方法时,Java查找常量池中是否有相同Unicode的字符串常量,如果有,则返回其的引用,如果没有,则在常量池中增加一个Unicode等于str的字符串并返回它的引用
- intern()有两个作用,第一个是将字符串字面量放入常量池(如果池没有的话),第二个是返回这个常量的引用
- 作为动词时它有禁闭、关押的意思,通过前面的介绍,与其说是将字符串关押到字符串常量池StringTable中,可能将它理解为缓存它的引用会更加贴切
- String 的intern()是一个本地方法,可以强制将 String 驻留进入字符串常量池,可以分为两种情况:
- 如果字符串常量池中已经驻留了一个等于此 String 对象内容的字符串引用,则返回此字符串在常量池中的引用
- 否则,在常量池中创建一个引用指向这个 String 对象,然后返回常量池中的这个引用
- 那下面看一下这段代码,它的运行结果应该是什么?
- 输出打印:
- 用一张图来描述它们的关系,就很容易明白了:
- 在创建s1的时候,其实堆里已经创建了两个字符串对象StringObject1和StringObject2,并且在字符串常量池中驻留了StringObject2
- 当执行s1.intern()方法时,字符串常量池中已经存在内容等于"Hydra"的字符串StringObject2,直接返回这个引用并赋值给s2
- s1和s2指向的是两个不同的 String 对象,因此返回 false
- s2指向的就是驻留在字符串常量池的StringObject2,因此s2=="Hydra"为 true,而s1指向的不是常量池中的对象引用所以返回 false
- 上面是常量池中已存在内容相等的字符串驻留的情况,下面再看看常量池中不存在的情况,看下面的例子:
- 执行结果:
- 简单分析一下这个过程,第一步会在堆上创建 "Hy" 和 "dra" 的字符串对象,并驻留到字符串常量池中
- 接下来完成字符串的拼接操作,前面说过,实际上 jvm 会把拼接优化成 StringBuilder 的 append 方法,并最终调用 toString 方法返回一个 String 对象
- 在完成字符串的拼接后,字符串常量池中并没有驻留一个内容等于 "Hydra" 的字符串
- 所以执行 s1.intern() 时,会在字符串常量池创建一个引用,指向前面 StringBuilder 创建的那个字符串,也就是变量 s1 所指向的字符串对象
- 在《深入理解 Java 虚拟机》这本书中,作者对这进行了解释,因为从 jdk7 开始,字符串常量池就已经移到了堆中,那么这里就只需要在字符串常量池中记录一下首次出现的实例引用即可
- 最后当执行 String s2 = "Hydra" 时,发现字符串常量池中已经驻留这个字符串,直接返回对象的引用,因此 s1 和 s2 指向的是相同的对象
-
还是创建了几个对象
- 解决了前面数 String 对象个数的问题,那么接着加点难度,看看下面这段代码,创建了几个对象
- 先揭晓答案,只创建了一个对象!
- 可以直观的对比一下源代码和反编译后的字节码文件:
- 如果使用前面提到过的 debug 小技巧,也可以直观的看到语句执行完后,只增加了一个 String 对象,以及一个 char 数组对象
- 并且这个字符串就是驻留在字符串常量池中的那一个,如果后面再使用字面量"abc"的方式声明一个字符串,指向的仍是这一个,堆中 String 对象的数量不会发生变化
- 至于为什么源代码中字符串拼接的操作,在编译完成后会消失,直接呈现为一个拼接后的完整字符串,是因为在编译期间,应用了编译器优化中一种被称为常量折叠(Constant Folding)的技术
- 常量折叠会将编译期常量的加减乘除的运算过程在编译过程中折叠
- 编译器通过语法分析,会将常量表达式计算求值,并用求出的值来替换表达式,而不必等到运行期间再进行运算处理,从而在运行期间节省处理器资源
- 而上边提到的编译期常量的特点就是它的值在编译期就可以确定,并且需要完整满足下面的要求,才可能是一个编译期常量:
- 被声明为final
- 基本类型或者字符串类型
- 声明时就已经初始化
- 使用常量表达式进行初始化
- 下面通过几段代码加深对它的理解:
- 执行结果:
- 代码中字符串 h1 和 h2 都使用常量赋值,区别在于是否使用了 final 进行修饰,对比编译后的代码,s1 进行了折叠而 s2 没有
- 可以印证上面的理论,final 修饰的字符串变量才有可能是编译期常量
- 再看一段代码,执行下面的程序,结果会返回什么呢?
- 答案是 false ,因为虽然这里字符串h2被final修饰,但是初始化时没有使用常量表达式,因此它也不是编译期常量
- 那么到底什么才是常量表达式呢?
- 在Oracle官网的文档中,列举了很多种情况,下面对常见的情况进行列举(除了下面这些之外官方文档上还列举了不少情况,如果有兴趣的话,可以自己查看):
- 基本类型和 String 类型的字面量
- 基本类型和 String 类型的强制类型转换
- 使用+或-或!等一元运算符(不包括++和--)进行计算
- 使用加减运算符+、-,乘除运算符*、 / 、% 进行计算
- 使用移位运算符 >>、 <<、 >>>进行位移操作…
- …
- 至于从文章一开始就提到的字面量(literals),是用于表达源代码中一个固定值的表示法,在 Java中创建一个对象时需要使用new关键字,但是给一个基本类型变量赋值时不需要使用new关键字,这种方式就可以被称为字面量
- Java 中字面量主要包括了以下类型的字面量:
- 再说点题外话,和编译期常量相对的,另一种类型的常量是运行时常量,看一下下面这段代码:
- 编译器能够在编译期就得到 s1 的值是 hello Hydra ,不需要等到程序的运行期间,因此 s1 属于编译期常量
- 而对 s2 来说,虽然也被声明为 final 类型,并且在声明时就已经初始化,但使用的不是常量表达式,因此不属于编译期常量,这一类型的常量被称为运行时常量
- 再看一下编译后的字节码文件中的常量池区域:
- 可以看到常量池中只有一个 String 类型的常量 hello Hydra ,而 s2 对应的字符串常量则不在此区域
- 对编译器来说,运行时常量在编译期间无法进行折叠,编译器只会对尝试修改它的操作进行报错处理
-
intern的正确用法
- 在 String s3 = new String("tang").intern(); 中,其实 intern 是多余的?
- 因为就算不用 intern ,Hollis作为一个字面量也会被加载到Class文件的常量池,进而加入到运行时常量池中,为啥还要多此一举呢?
- 到底什么场景下才需要使用 intern 呢?
- 在解释这个之前,先来看下以下代码:
- 在经过反编译后,得到代码如下:
- 可以发现,同样是字符串拼接,s3和s4在经过编译器编译后的实现方式并不一样
- s3被转化成 StringBuilder 及 append ,而s4被直接拼接成新的字符串
- 还能发现, String s3 = s1 + s2; 经过编译之后,常量池中是有两个字符串常量的分别是 Hollis 、 Chuang(其实 Hollis 和 Chuang 是 String s1 = "Hollis"; 和 String s2 = "Chuang"; 定义出来的),拼接结果 HollisChuang 并不在常量池中
- 如果代码只有 String s4 = "Hollis" + "Chuang"; ,那么常量池中将只有 HollisChuang 而没有"Hollis" 和 "Chuang"
- 究其原因,是因为常量池要保存的是已确定的字面量值
- 也就是说,对于字符串的拼接,纯字面量和字面量的拼接,会把拼接结果作为常量保存到字符串池
- 如果在字符串拼接中,有一个参数是非字面量,而是一个变量的话,整个拼接操作会被编译成 StringBuilder.append ,这种情况编译器是无法知道其确定值的
- 只有在运行期才能确定
- 那么有了这个特性了, intern 就有用武之地了
- 那就是很多时候,在程序中得到的字符串是只有在运行期才能确定的,在编译期是无法确定的,那么也就没办法在编译期被加入到常量池中
- 这时候,对于那种可能经常使用的字符串,使用 intern 进行定义,每次JVM运行到这段代码的时候,就会直接把常量池中该字面值的引用返回,这样就可以减少大量字符串对象的创建了
- 举一个例子:
- 在以上代码中,明确的知道,会有很多重复的相同的字符串产生,但是这些字符串的值都是只有在运行期才能确定的
- 所以,只能通过 intern 显示的将其加入常量池,这样可以减少很多字符串的重复创建