字符串常量池

前文介绍了 运行时常量池,它和本篇文章要介绍的「字符串常量池」是包含关系

在 JDK7 及以后,字符串常量池被挪到了 Java 堆中,但这并不影响它的作用,只是位置发生了变化

String 类

通过名字,第一印象很容易看出来,「字符串常量池」就是专门用来存放字符串的常量池,所以先来介绍一下String

注意:下面出现的代码大多都是从源码中摘出来的!有兴趣的可以结合String源码一起观看!!

关键点一

首先需要明确的是String是一个final类:

关键点二

其次需要知道的是String中存储数据的结构是一个char[]数组:

扩展:在 JDK9 之后,String的底层数据结构发生了变化,如下所示:

👉 JEP 254: Compact Strings

Motivation: The current implementation of the String class stores characters in a char array, using two bytes (sixteen bits) for each character. Data gathered from many different applications indicates that strings are a major component of heap usage and, moreover, that most String objects contain only Latin-1 characters. Such characters require only one byte of storage, hence half of the space in the internal char arrays of such String objects is going unused.

解释:使用char[]数组存储数据的话,每个字符占用 2 个字节 (16 bits);然而绝大多数字符串对象中仅仅只包含 Latin-1 字符,存储这些字符花 1 个字节即可。所以如果都用 2 个字节去存储,将会浪费大量的空间

问题:如何存储必须需要 2 个字节的字符呢??

Description: We propose to change the internal representation of the String class from a UTF-16 char array to a byte array plus an encoding-flag field.

解释:String中有一个编码标志域,来标识当前存储数据的编码,如下所示:

注意:String-related classes such as AbstractStringBuilder, StringBuilder, and StringBuffer will be updated to use the same representation, as will the HotSpot VM's intrinsic string operations.

解释:与字符串相关的类,如AbstractStringBuilderStringBuilderStringBuffer将被更新以使用相同的表示方法,HotSpot 虚拟机的内在字符串操作也是如此

关键点三

最后介绍四种创建字符串对象的方法:

字符串常量池的数据结构

字符串常量池底层是一个固定大小的HashTable,如果长度比较小,当放进去的字符串对象特别多,就会造成严重的 Hash 冲突从而导致链表很长,更近一步导致调用String::intern方法时效率低

可以使用参数-XX:StringTableSize=size设置 StringTable 的大小

在 JDK7 及之后,「字符串常量池」被移到了堆中,而对象也是在堆中,此时「字符串常量池」中存储的只是实例引用!!下面的讨论均基于 JDK7 及之后,如果有例外,会另外说明

初级比较

关于对象引用直接用= =比较的详细分析可见 「equals」「hashCode」

接着上一部分,如果s1直接和s2, s3, s4= =做比较,结果会如何?具体代码如下:

首先通过 JProfiler 软件,查看一下这些对象在堆中的结构,如下图所示:

8

上图对应的可视化解释如下图所示:

7

对于String s1 = "abc"来说,它就是一个字符串对象,有一个字符数组类型的字段value,存储实际的数据。所有双引号"xxx"中的内容都在字符串常量池中!!所以"abc"在字符串常量池中,更准确一点,是地址为0xb5d的字符串对象在字符串常量池中

对于String s2 = String.valueOf("abc")来说,先看一下valueOf()方法,如下所示:

可以看到,如果传入的参数是一个字符串对象,该方法就是把传入的对象返回了,所以方法 2 和方法 1 是等价的!

对于String s3 = new String("abc")来说,先看一下对应的构造函数,如下所示:

可以看到,将传入参数的valuehash字段赋值给自身的valuehash字段。举个很形象的例子,如果传入的对象是羊,那么new出来的新对象就是披着狼皮的羊

对于String s4 = new String(chars)来说,同样的,先看一下对应的构造函数,如下所示:

上面说过,String对象存储数据的结构是一个char[]数组,所以这个构造函数相当于把传入的数组 copy 了一份,然后赋值给自身的value字段

综上所述:如果只是给valuehash字段赋了相同的值,但对象还是不同的;用= =直接比较,比较的是对象的地址是否相同!!

扩展:继续看一下用equals()方法比较的原理,源码如下所示:

高级比较

上面的初级比较中,都是简单直接给出一个完整字符串,这一部分介绍几种高级比较!!

字符串拼接赋值

两个字符串直接拼接在一起的情况!

通过 JProfiler 软件发现 Java 堆中并没有"abc""def",只有"abcdef",为什么会这样呢??

通过javap反编译 Class 文件后,发现常量池中只有abcdef;为s2赋值时,也是直接把abcdef赋给了s2,并没有拼接的过程,具体如下所示:

这是因为在编译阶段,进行了一波优化!!(编译期优化) 不仅是字符串,整型常量也是如此,如下所示:

字符串和变量的拼接赋值

例子一

首先可以知道编译阶段没有进行和上面一样的优化,而且从底层来看,此处的拼接是借助StringBuilder完成的,上面代码和下面的代码等价 (编译后的字节指令完全一样):

下面给出编译后的字节码指令:

上面从字节码的角度分析了等价性,下面从内存的角度分析为什么不相等!

首先搞清楚哪些字符串在字符串常量池中,在字符串常量池的有:"abc", "def", "abcdef",通过 JProfiler 验证如下:

8

然后在看看看StringBuilder::toString的源码:

StringBuilder中也是用char[] value来存储字符,而StringBuilder::toString方法会调用String的构造函数public String(char value[], int offset, int count)

这个构造函数是 copy 了一份value字符数组,而不是直接把value赋值给自身,和public String(char value[])差不多,注意和public String(String original)区分!!

可视化解释如下图所示:

9

例子二

下面再给一个稍微特殊的例子:

唯一的不同在于s1前面被final修饰,s1是常量,不能再指向其他字符串对象

首先搞清楚编译后常量池中有什么,常量池的有:"abc", "abcdef",并没有"def"。顺便看一下编译后的字节码指令:

我们发现这个时候的拼接并没有像上面那样借助StringBuilder,甚至连拼接的过程都没有发现,都没有看到"def",这是为什么呢?

肯定和final修饰有关 (废话),被final修饰后,s1是常量,而"def"也是常量

所以直接在编译阶段通过优化把s1 + "def"的结果计算出来了,这也是为什么常量池中没有"def",但是有"abcdef"的原因

那为什么上一个例子不能这样处理呢?!上面一个例子中s1不是常量,编译阶段并不能知道s1到底指向哪一个字符串对象,只有在运行期间才能知道!!!

字符串和引用拼接赋值

这个和上面 字符串和变量的拼接赋值 的第一个例子差不多,字节码层面都是通过StringBuilder拼接,如下所示:

引用拼接赋值

原因同上,可以看反编译后的字节码指令,通过StringBuilder拼接,为了节约篇幅,这里就不贴出来了!

性能比较

通过上面的分析,知道了字符串直接用+拼接,底层是通过StringBuilder实现,下面给出两种不同方式的性能比较:

可以看到这差距还是很明显滴!!

对于f1()方法,循环中的每一次加操作都会经历三个步骤:创建StringBuilder对象;调用一次append()方法;调用一次toString()方法生成一个String对象

可想而知内存中会创建很多StringBuilderString对象,内存占用大;如果进行 GC,还需要花费额外的时间开销

对于f2()方法,从循环开始到结束仅仅只创建了一个StringBuilder对象,每一轮循环仅仅是调用了一次append()方法而已,所以效率上的提升也是肉眼可见的

这个点也是阿里巴巴 Java 开发手册中所推荐的:循环体内,字符串的连接方式,使用 StringBuilder 的 append 方法进行扩展

思考:对于f2()方法,有没有可以继续优化的地方呢?-> 既然这样说了,答案肯定是有!!!

先来看看StringBuilder的无参构造器,如下所示:

再来看看append()方法,如下所示:

扯了这么多,就是想说明如果初始化大小过小,会频繁的扩容,扩容又涉及到数组的复制,带来的开销较大

所以如果在实际开发中,可以基本确定需要添加的字符串长度不高于某个限定值,建议使用带参构造函数,避免频繁的扩容,如下所示:

到底创建了几个对象?!

问题一

这是一道经典面试的问题,如下所示:

首先字符串常量池中有一个对象"abc";其次new String("abc")创建了一个新的对象,指向字符串常量池中的"abc"

所以这个问题中创建了两个对象!!

问题二

下面是进阶版:

首先字符串常量池中有两个对象"abc", "def";

其次new了两个String对象,分别指向字符串常量池中的两个对象;

然后+拼接的底层是通过StringBuilder实现,所以有一个StringBuilder对象

调用append()方法后,最终会调用toString()方法生成一个String对象,并返回其引用给s

所以这个问题中创建了六个对象!!

字符串字面量何时进入字符串常量池?!

字符串字面量在上面提到过,所有双引号"xxx"中的内容都属于字符串字面量,现在讨论的问题是这种字面量什么时候进入字符串常量池呢??

首先可以确定的是,当一个源程序被编译后,字符串字面量就会被加入静态常量池中;当对应的类被加载到内存后,内存中存放静态常量池的地方就被称为运行时常量池,所以运行时常量池也有该字面量

在类加载过程的解析阶段,Java 虚拟机会在堆中创建对应 class 文件常量池中的字符串对象实例,并在字符串常量池中驻留其引用,字符串常量池中的内容全局共享!!

但但但《Java 虚拟机规范》中明确规定解析阶段可以是 lazy resolve 的!!!

《Java 虚拟机规范》中 Class 文件的常量池项目的类型,有两种东西:

后者是 String 常量的类型,但它并不直接持有 String 常量的内容,而是只持有一个符号引用,这个符号引用所指定的另一个常量池项必须是一个 CONSTANT_Utf8_info 类型的常量,这里才真正持有字符串的内容,具体如下所示:

在 HotSpot 虚拟机的运行时常量池中:

CONSTANT_Utf8_info 会在类加载的时候就被全部创建出来,而 CONSTANT_String_info 则是 lazy resolve 的。例如:在引用该项的 ldc 指令被第一次执行的时候才会 resolve

那么在尚未 resolve 的时候,HotSpot 虚拟机把它的类型叫做 JVM_CONSTANT_UnresolvedString,内容跟 Class 文件里一样只是一个符号引用

等到 resolve 过后这个项的常量类型就会变成最终的 JVM_CONSTANT_String,而内容则变成实际的引用 (直接引用)

《深入理解 Java 虚拟机》也有一段话不谋而合:虚拟机实现可以根据需要来自行判断,到底是在类被加载器加载的时候就对常量池中的符号引用进行解析,还是等到一个符号引用将要被使用前才去解析它

最后来一波小总结:就 HotSpot 虚拟机的实现来说,在类加载的时候字符串字面量会进入到当前类的运行时常量池,不会进入全局的字符串常量池 (即在 StringTable 中并没有相应的引用,在堆中也没有对应的对象产生)

ldc 指令

下面来强调一下ldc指令,它的作用:将intfloatString型常量值从常量池中推送至栈顶

上面强调过,类加载过程中的解析阶段是 lazy resolve (延迟),那到底会延迟到什么时候才会去解析呢?!

执行ldc指令就是触发 lazy resolution 动作的条件

ldc字节码在这里的执行语义是:到当前类的运行时常量池去查找该符号引用对应的项,如果该项尚未 resolve 则 resolve,并返回 resolve 后的内容

在遇到String类型常量时,resolve 的过程如果发现 StringTable 已经有了内容匹配的java.lang.String的引用,则直接返回这个引用

反之,如果 StringTable 里尚未有内容匹配的String实例的引用,则会在 Java 堆里创建一个对应内容的String对象,然后在 StringTable 记录下这个引用,并返回这个引用

可见,ldc指令是否需要创建新的String实例,全看在第一次执行这一条ldc指令时,StringTable 是否已经记录了一个对应内容的String的引用

关于这个内容的详细解释可见 Java 中new String("字面量") 中 "字面量" 是何时进入字符串常量池的? - 木女孩的回答 - 知乎

intern() 方法

将一个字符串对象加入字符串常量池,只有两种方式:

根据「字符串常量池」所在位置的不同,里面存的内容有些许的不同!!

JDK6 及之前,「字符串常量池」在永久代中 (非堆),而对象却是在堆中,所以会把首次遇到的字符串实例复制到「字符串常量池」中存储,返回的也是永久代里面这个字符串实例的引用

JDK7 及之后,「字符串常量池」被移到了堆中,而对象也是在堆中,所以就不需要再拷贝字符串的实例到「字符串常量池」,只需要在「字符串常量池」中记录一下首次出现的实例引用即可

这里分三种情况讨论,分别在 JDK6、JDK7、JDK8 的版本下 (是不是很麻烦!)

JDK6

JDK7

JDK8

这里详细解释一下"java"字符串为什么一开始会被加载到字符串常量池中

sun.misc.Version类中有一个静态常量,如下所示:

HotSpot VM 会在初始化过程中主动触发java.lang.System类的加载和初始化,过程中会调用到java.lang.System.initializeSystemClass()静态方法:

所以sun.misc.Version类会被加载到内存中,静态常量会被加载到字符串常量池中!!但是在 JDK8 的时候,这个地方有点变化,如下所示:

关于这个内容的详细解释可见 如何理解《深入理解java虚拟机》第二版中对String.intern()方法的讲解中所举的例子? - RednaxelaFX的回答 - 知乎

再来一个例子:

上文强调过「所有双引号"xxx"中的内容都在字符串常量池中!!」所以"计算机软件"在字符串常量池中,调用str2.intern()方法时字符串常量池中已经有了,返回的实例引用和str1是不同的

上个例子中String str1 = new StringBuilder("计算机").append("软件").toString()"计算机""软件"都在字符串常量池中,但"计算机软件"不在!!

强调

先给出一个例子:

相信有了 字符串字面量何时进入字符串常量池?!的铺垫,不难理解输出的结果,这里再强调一下!!

当执行到「第二步」时,此时字符串常量池中并没有"abcdef",所以会把s1加入到字符串常量池中

当执行到「第三步」时,由于字符串常量池中已经有了"abcdef",所以在调用ldc指令给s2赋值时,直接返回字符串常量池中的引用

所以s1s2的引用值是相同的!!!

对 String 不可变的理解

理解一

首先,字符串常量池中有三个字符串:"abc", "def", "ghi"。当执行到每一步时,s对应的实例引用如下图所示:

11

这里的+=其实是一种字符串拼接,字节码指令层面的原理和 字符串和变量的拼接赋值 相同!!

从上图可以看出,拼接的过程,变化的只有s的值 (引用),对于字符串对象本身来说并没有改变,每次都是生成了一个新的字符串对象

值的注意的是,上图中的紫色箭头是不可以变的,因为String类中的value字段被final修饰,所以值 (引用) 不可变,源码如下:

理解二

若在「理解一」的基础上稍加修改,如下所示:

原理和「理解一」中一样,由于sfinal修饰,所以值 (引用) 不可变,对应上图中就是红色箭头是不可以变的

理解三

给出一段代码,想想会输出什么:

在学习 C 语言的时候,肯定听过「传值」或「传引用」;Java 中也有类似的东西,但 Java 中的本质都是「传值」

如果将变量按照数据类型划分的话,可以分为两类:

之所以说 Java 中的本质都是「传值」,因为如果传的是一个基本数据类型,那就是值;如果传的是一个引用数据类型,那就是引用,其实也就是该变量的值

回到上面的代码,先解释s为什么没变?!

再解释cs为什么变了?!

理解四

假设String类中value字段不是private,即可以任意访问修改,会怎么样呢?下面我们来试试!!

可以通过反射机制访问或修改无权限的字段,关于操作运行时类的内部属性及方法的详细内容可见 反射机制

👇👇👇👇👇👇👇👇👇👇👇👇

本篇文章内容偏多,特意做了一个思维导图,便于把握文章的脉络!!😝