从字符串到常量池,一文看懂String类(2)

本文涉及的产品
云解析 DNS,旗舰版 1个月
公共DNS(含HTTPDNS解析),每月1000万次HTTP解析
全局流量管理 GTM,标准版 1个月
简介: 从字符串到常量池,一文看懂String类(2)

字符串常量池


位置在哪?


字符串常量池比较特殊,在JDK1.7之前,其存在于永久代中,到JDK1.7及之后,已经中永久代移到了堆中。当然,如果你非要说永久代也是堆的一部分那我也没办法。


另外还要说明一点,经常有同学会将方法区,元空间,永久代(permgen space)的概念混淆。请注意


  1. 方法区是JVM在内存分配时需要遵守的规范,是一个理论,具体的实现可以因人而异
  2. 永久代是hotspot的jdk1.8以前对方法区的实现,使用jdk1.7的老司机肯定以前经常遇到过java.lang.OutOfMemoryError: PremGen space异常。这里的PermGen space其实指的就是方法区。不过方法区和PermGen space又有着本质的区别。前者是JVM的规范,而后者则是JVM规范的一种实现,并且只有HotSpot才有PermGen space。
  3. 元空间是jdk1.8对方法区的实现,jdk1.8彻底移除了永久代,其实,移除永久代的工作从JDK 1.7就开始了。JDK 1.7中,存储在永久代的部分数据就已经转移到Java Heap或者Native Heap。但永久代仍存在于JDK 1.7中,并没有完全移除,譬如符号引用(Symbols)转移到了native heap;字面量(interned strings)转移到了Java heap;类的静态变量(class statics)转移到了Java heap。到jdk1.8彻底移除了永久代,将JDK7中还剩余的永久代信息全部移到元空间,元空间相比对永久代最大的差别是,元空间使用的是本地内存(Native Memory)。


用来干什么的?


字符串常量池,顾名思义,肯定就是用来存储字符串的嘛,准确来说存储的是字符串实例对象的引用。我查阅了很多博客、资料,它们都会说,字符串常量池中存储的就是字符串对象。其实我们可以类比下面这段代码:

HashSet<Person> persons = new HashSet<Person>;

在persons这个集合中,存储的是Person对象还是Person对象对应的引用呢?


所以,请大声跟我念三遍


字符串常量池存储的是字符串实例对象的引用!

字符串常量池存储的是字符串实例对象的引用!

字符串常量池存储的是字符串实例对象的引用!


下面我们来看R大博文下评论的一段话:


简单来说,HotSpot VM里StringTable是个哈希表,里面存的是驻留字符串的引用(而不是驻留字符串实例自身)。也就是说某些普通的字符串实例被这个StringTable引用之后就等同被赋予了“驻留字符串”的身份。这个StringTable在每个HotSpot VM的实例里只有一份,被所有的类共享。类的运行时常量池里的CONSTANT_String类型的常量,经过解析(resolve)之后,同样存的是字符串的引用;解析的过程会去查询StringTable,以保证运行时常量池所引用的字符串与StringTable所引用的是一致的。


------R大博客


从上面我们可以知道

  1. 字符串常量池本质就是一个哈希表
  2. 字符串常量池中存储的是字符串实例的引用
  3. 字符串常量池在被整个JVM共享
  4. 在解析运行时常量池中的符号引用时,会去查询字符串常量池,确保运行时常量池中解析后的直接引用跟字符串常量池中的引用是一致的

为了更好理解上面的内容,我们需要去分析String中的一个方法-----intern()


intern方法分析

/** 
 * Returns a canonical representation for the string object. 
 * <p> 
 * A pool of strings, initially empty, is maintained privately by the 
 * class <code>String</code>. 
 * <p> 
 * When the intern method is invoked, if the pool already contains a 
 * string equal to this <code>String</code> object as determined by 
 * the {@link #equals(Object)} method, then the string from the pool is 
 * returned. Otherwise, this <code>String</code> object is added to the 
 * pool and a reference to this <code>String</code> object is returned. 
 * <p> 
 * It follows that for any two strings <code>s</code> and <code>t</code>, 
 * <code>s.intern()&nbsp;==&nbsp;t.intern()</code> is <code>true</code> 
 * if and only if <code>s.equals(t)</code> is <code>true</code>. 
 * <p> 
 * All literal strings and string-valued constant expressions are 
 * interned. String literals are defined in section 3.10.5 of the 
 * <cite>The Java&trade; Language Specification</cite>. 
 * 
 * @return  a string that has the same contents as this string, but is 
 *          guaranteed to be from a pool of unique strings. 
 */  
public native String intern();  

String#intern方法中看到,这个方法是一个 native 的方法,但注释写的非常明了。“如果常量池中存在当前字符串, 就会直接返回当前字符串. 如果常量池中没有此字符串, 会将此字符串放入常量池中后, 再返回”。


关于其详细的分析可以参考:美团:深入解析String#intern


珠玉在前,所以本文着重就分析下intern方法在JDK不同版本下的差异,首先我们要知道引起差异的原因是因为**JDK1.7及之后将字符串常量池从永久代挪到了堆中。**


我这里就以美团文章中的示例代码来进行分析,代码如下:

public static void main(String[] args) {
    String s = new String("1");
    s.intern();
    String s2 = "1";
    System.out.println(s == s2);
    String s3 = new String("1") + new String("1");
    s3.intern();
    String s4 = "11";
    System.out.println(s3 == s4);
}

打印结果是


  • jdk6 下false false
  • jdk7 下false true

在美团的文章中已经对这个结果做了详细的解释,接下来我就用我的图解方式再分析一波这个过程


jdk6 执行流程


**第一步:**执行String s = new String("1"),要清楚这行代码的执行过程,我们还是得从字节码入手,这行代码对应的字节码如下:

  public static void main(java.lang.String[]);
    Code:
       0: new           #2                  // class java/lang/String
       3: dup
       4: ldc           #3                  // String 1
       6: invokespecial #4                  // Method java/lang/String."<init>":(Ljava/lang/String;)V
       9: astore_1
      10: return

new :创建了一个类的实例(还没有调用构造器函数),并将其引用压入操作数栈顶


dup:复制栈顶数值并将复制值压入栈顶,这是因为invokespecial跟astore_1各需要消耗一个引用


ldc:解析常量池符号引用,将实际的直接引用压入操作数栈顶


invokespecial:弹出此时栈顶的常量引用及对象引用,执行invokespecial指令,调用构造函数


astore_1:将此时操作数栈顶的元素弹出,赋值给局部变量表中1号元素(0号元素存的是main函数的参数)


我们可以将上面整个过程分为两个阶段

1.解析常量

2.调用构造函数创建对象并返回引用


在解析常量的过程中,因为该字符串常量是第一次解析,所以会先在永久代中创建一个字符串实例对象,并将其引用添加到字符串常量池中。此时内存状态如下:

image.png

当真正通过new方式创建对象完成后,对应的内存状态如下,因为在分析class文件中的常量池的时候已经对栈区做了详细的分析,所以这里就省略一些细节了,在执行完这行代码后,栈区存在一个引用,指向 了堆区的一个字符串实例内存状态对应如下:

微信图片_20221113184410.png

**第二步:**紧接着,我们调用了s的intern方法,对应代码就是s.intern()


当intern方法执行时,因为此时字符串常量池中已经存在了一个字面量信息跟s相同的字符串的引用,所以此时内存状态不会发生任何改变。


**第三步:**执行String s2 = "1",此时因为常量池中已经存在了字面量1的对应字符串实例的引用,所以,这里就直接返回了这个引用并且赋值给了局部变量s2。对应的内存状态如下:

微信图片_20221113184451.png

到这里就很清晰了,s跟s2指向两个不同的对象,所以s==s2肯定是false嘛~


如果看过美团那篇文章的同学可能会有些疑惑,我在图中对常量池的描述跟美团文章图中略有差异,在美团那篇文章中,直接将具体的字符串实例放到了字符串常量池中,而在我上面的图中,字符串常量池存的永远时引用,它的图是这样画的

微信图片_20221113184516.png

就我查阅的资料而言,我个人不赞同这种说法,常量池中应该保存的仅仅是引用。关于这个问题,我已经向美团的团队进行了留言,也请大佬出来纠错!

接着我们分析s3跟s4,对应的就是这几行代码:

String s3 = new String("1") + new String("1");
s3.intern();
String s4 = "11";
System.out.println(s3 == s4);

我们一行行分析,看看执行完后,内存的状态是什么样的


第一步:String s3 = new String("1") + new String("1"),执行完成后,堆区多了两个匿名对象,这个我们不用多关注,另外堆区还多了一个字面量为11的字符串实例,并且栈中存在一个引用指向这个实例

微信图片_20221113184622.png

实际上上图中还少了一个匿名的StringBuilder的对象,这是因为当我们在进行字符串拼接时,编译器默认会创建一个StringBuilder对象并调用其append方法来进行拼接,最后再调用其toString方法来转换成一个字符串,StringBuilder的toString方法其实就是new一个字符串

public String toString() {
    // Create a copy, don't share the array
    return new String(value, 0, count);
}

这也是为什么在图中会说在堆上多了一个字面量为11的字符串实例的原因,因为实际上就是new出来的嘛!


第二步:s3.intern()


调用intern方法后,因为字符串常量池中目前没有11这个字面量对应的字符串实例的应用,所以JVM会先从堆区复制一个字符串实例到永久代中,再将其引用添加到字符串常量池中,最终的内存状态就如下所示

微信图片_20221113184756.png

第三步:String s4 = "11"

这应该没啥好说的了吧,常量池中有了,直接指向对应的字符串实例

微信图片_20221113184824.png

到这里可以发现,s3跟s4指向的根本就是两个不同的对象,所以也返回false


jdk7 执行流程


在jdk1.7中,s跟s2的执行结果还是一样的,这是因为 String s = new String("1")这行代码本身就创建了两个字符串对象,一个属于被常量池引用的驻留字符串,而另外一个只是堆上的一个普通字符串对象。跟1.6的区别在于,1.7中的驻留字符串位于堆上,而1.6中的位于方法区中,但是本质上它们还是两个不同的对象,在下面代码执行完后

    String s = new String("1");
    s.intern();
    String s2 = "1";
    System.out.println(s == s2);

内存状态为:

微信图片_20221113184950.png但是对于s3跟s4确不同了,因为在jdk1.7中不会再去复制字符串实例了,在intern方法执行时在发现堆上有对应的对象之后,直接将这个对应的引用添加到字符串常量池中,所以代码执行完,内存状态对应如下:

微信图片_20221113185025.png

看到了吧,s3跟s4指向的同一个对象,这是因为intern方法执行时,直接s3这个引用复制到了常量池,之后执行String s4= "11"的时候,直接再将常量池中的引用复制给了s4,所以s3==s4肯定为true啦。


在理解了它们之间的差异之后,我们再来思考一个问题,假设我现在将代码改成这个样子,那么运行结果是什么样的呢?

public static void main(String[] args) {
    String s = new String("1");
    String sintern = s.intern();
    String s2 = "1";
    System.out.println(sintern == s2);
    String s3 = new String("1") + new String("1");
    String s3intern = s3.intern();
    String s4 = "11";
    System.out.println(s3intern == s4);
}

上面这段代码运行起来结果会有差异吗?大家可以自行思考~

在我们对字符串常量池有了一定理解之后会发现,其实通过String name = "dmz"这行代码申明一个字符串,实际的执行逻辑就像下面这段伪代码所示

/**
  * 这段代码逻辑类比于
  * <code>String s = "字面量"</code>;这种方式申明一个字符串
  * 其中字面量就是在""中的值
  *
  */
public String declareString(字面量) {
    String s;
    // 这是一个伪方法,标明会根据字面量的值到字符串值中查找是否存在对应String实例的引用
    s = findInStringTable(字面量);
    // 说明字符串池中已经存在了这个引用,那么直接返回
    if (s != null) {
        return s;
    }
    // 不存在这个引用,需要新建一个字符串实例,然后调用其intern方法将其拘留到字符串池中,
    // 最后返回这个新建字符串的引用
    s = new String(字面量);
    // 调用intern方法,将创建好的字符串放入到StringTable中,
    // 类似就是调用StringTable.add(s)这也的一个伪方法
    s.intern();
    return s;
}

按照这个逻辑,我们将我们将上面思考题中的所有字面量进行替换,会发现不管在哪个版本中结果都应该返回true。


运行时常量池


位置在哪?


位于方法区中,1.6在永久代,1.7在元空间中,永久代跟元空间都是对方法区的实现


用来干什么?


jvm在执行某个类的时候,必须经过加载、连接、初始化,而连接又包括验证、准备、解析三个阶段。而当类加载到内存中后,jvm就会将class常量池中的内容存放到运行时常量池中,由此可知,运行时常量池也是每个类都有一个。在上面我也说了,class常量池中存的是字面量和符号引用,也就是说他们存的并不是对象的实例,而是对象的符号引用值。而经过解析(resolve)之后,也就是把符号引用替换为直接引用,解析的过程会去查询全局字符串池,也就是我们上面所说的StringTable,以保证运行时常量池所引用的字符串与全局字符串池中所引用的是一致的。


所以简单来说,运行时常量池就是用来存放class常量池中的内容的。


总结


我们将三者进行一个比较

微信图片_20221113185538.png

以一道测试题结束

// 环境1.7及以上
public class Clazz {
    public static void main(String[] args) {
        String s1 = new StringBuilder().append("ja").append("va1").toString();
        String s2 = s1.intern();
        System.out.println(s1==s2);
        String s5 = "dmz";
        String s3 = new StringBuilder().append("d").append("mz").toString();
        String s4 = s3.intern();
        System.out.println(s3 == s4);
        String s7 = new StringBuilder().append("s").append("pring").toString();
        String s8 = s7.intern();
        String s6 = "spring";
        System.out.println(s7 == s8);
    }
}

答案是true,false,true。
/

相关文章
|
16天前
|
索引 Python
String(字符串)
String(字符串)。
25 3
|
2月前
|
Java
【编程基础知识】(讲解+示例实战)方法参数的传递机制(值传递及地址传递)以及String类的对象的不可变性
本文深入探讨了Java中方法参数的传递机制,包括值传递和引用传递的区别,以及String类对象的不可变性。通过详细讲解和示例代码,帮助读者理解参数传递的内部原理,并掌握在实际编程中正确处理参数传递的方法。关键词:Java, 方法参数传递, 值传递, 引用传递, String不可变性。
58 1
【编程基础知识】(讲解+示例实战)方法参数的传递机制(值传递及地址传递)以及String类的对象的不可变性
|
2月前
|
NoSQL Redis
Redis 字符串(String)
10月更文挑战第16天
43 4
|
2月前
|
安全 Java 测试技术
Java零基础-StringBuffer 类详解
【10月更文挑战第9天】Java零基础教学篇,手把手实践教学!
34 2
|
2月前
|
存储 安全 C++
【C++打怪之路Lv8】-- string类
【C++打怪之路Lv8】-- string类
22 1
|
2月前
|
数据可视化 Java
让星星月亮告诉你,通过反射创建类的实例对象,并通过Unsafe theUnsafe来修改实例对象的私有的String类型的成员属性的值
本文介绍了如何使用 Unsafe 类通过反射机制修改对象的私有属性值。主要包括: 1. 获取 Unsafe 的 theUnsafe 属性:通过反射获取 Unsafe类的私有静态属性theUnsafe,并放开其访问权限,以便后续操作 2. 利用反射创建 User 类的实例对象:通过反射创建User类的实例对象,并定义预期值 3. 利用反射获取实例对象的name属性并修改:通过反射获取 User类实例对象的私有属性name,使用 Unsafe`的compareAndSwapObject方法直接在内存地址上修改属性值 核心代码展示了详细的步骤和逻辑,确保了对私有属性的修改不受 JVM 访问权限的限制
56 4
|
2月前
|
canal 安全 索引
(StringBuffer和StringBuilder)以及回文串,字符串经典习题
(StringBuffer和StringBuilder)以及回文串,字符串经典习题
37 5
|
2月前
|
存储 安全 Java
【一步一步了解Java系列】:认识String类
【一步一步了解Java系列】:认识String类
25 2
|
2月前
|
C语言 C++
C++番外篇——string类的实现
C++番外篇——string类的实现
20 0
|
2月前
|
C++ 容器
C++入门7——string类的使用-2
C++入门7——string类的使用-2
21 0