深入浅出Java基础——字符串常量池

StringConstantPool(jdk8)

1.字符串常量池是什么

字符串常量池用于存储编译期间存在的所有字符串实例的引用,以及运行时动态添加的引用。字符串常量池是全局的,只有一个。当我们以 String str = "123"形式创建字符串实例时,首先会去判断字符串常量池中是否有引用指向相同内容的实例,如果有则返回该实例。否则在堆中创建 String 对象并将引用驻留在字符串常量池中。

2.为什么要有字符串常量池的存在

想象现在有这样一个场景:一个内容为 username 的字符串需要经常使用在登录时,没有字符串常量池的情况下,如果要实现数据的共享,可以通过将这个字符串声明为全局常量(static final)的方式,并通过一个哈希表对这些常量进行统一管理。如果直接不实现数据共享,每次有用户登录,再次使用该字符串时都去创建一个新的对象,那就浪费太多时间以及空间了。

众所周知,偷懒是科技发展的动力。

这个时候有个大佬想:能否直接在内存中将可能经常使用的字符串统一管理,实现数据的共享呢?这样就可以少打些代码了~ 于是一个享元模式的实例:全局字符串常量池诞生了。

这里还需要提出相关的两个常量池 1. Class 文件中的常量池 2.运行时常量池

1.Class 文件常量池:存储了字面量以及符号引用

  • 字面量:文本字符串,例如类中有这样一行代码 private String str = "123",那么常量池中会出现 str、123、 Ljava/lang/String(类的描述方式:L + 全限定名)等字面量。
  • 符号引用:包含 1.类和接口的全限定名 2.字段的简单名称及描述符 3.方法的简单名称及描述符(本文不作探讨)

2.运行时常量池

运行时常量池在 jdk8 ,位于元空间内。用于存储从 class 文件中读取的信息,包括常量池。当类被加载时,虚拟机会将 Class 文件中的静态数据转化为运行时常量池中的运行时数据。**至于由常量转化成了什么,以及对常量做了什么操作,下面第六节会进行讲解。**运行时常量池对于 Class 文件常量池的重要特征是具有动态性。Java 中的常量不单单能在编译期间产生,也可以在运行期间动态加入。例如 String##intern。

3.字符串常量池实现的基础

字符串常量池实现的基础是 String 类的不可变性。具体内容在我上篇文章的第三节(https://blog.csdn.net/qq_44707077/article/details/116478278)。

4.字符串常量池的实现原理

字符串常量池使用了一个固定长度的哈希表 Hashtable 来存储字符串 (数组 + 链表),在 JDK 8,默认哈希桶容量为 60031。可以通过启动参数 -XX:+PrintStringTableStatistics查看哈希桶的实际用量。如果数据过多,频繁出现哈希冲突,导致链表过长,降低查询效率。可以通过启动参数 -XX:StringTableSize= 进行调整哈希桶容量。

5.intern 方法作用

程序员可以通过调用 String 的 intern 方法在运行时向字符串常量池中添加新的字符串引用。

intern 方法的作用:

当字符串对象调用 intern 方法时,需要判断该字符串内容是否在字符串常量池中 “首次出现”。 如果之前没有相同内容的字符串实例,则在堆中创建一个此字符串内容的实例,并将该实例的引用存驻留在字符串常量池中,并返回这个引用。

如果已经有相同内容的字符串实例,则直接返回字符串常量池中这个实例的引用。

你以为到这就快结束了?错!我刚要开始。让我们通过一个练习进入本文重点,请判断下面输出什么

练习:《深入 Java 虚拟机》书中第 63 页的测试代码

public class String_intern {
    public static void main(String[] args) {
        //例子1
        String str = new StringBuilder("ja").append("va").toString();
        System.out.println(str.intern() == str);
		//例子2
        String str1 = new StringBuilder("计算机").append("软件").toString();
        System.out.println(str1.intern() == str1);

    }
}


//output
false
true

例子1:周志明大大说,在执行 new StringBuilder(“ja”).append(“va”).toString(); 前,字符串常量池中就已经存在内容为 “java” 实例的引用了。(R大填了该书第二版的坑:在加载 sun.misc.Version 类时 “java” 字符串进入常量池)。所以 str 调用 intern() 返回的是字符串常量池中的引用,而 str 本身是个 StringBuilder 的引用,自然两个不同类型对象引用不可能指向同一个地址,所以输出 false。

关于例子1的思考:既然相同内容的 String 与 StringBuilder 不可能指向同一个地址,那么如果我不通过 StringBuilder 拼接字符串呢,会输出什么结果?

    String test = new String("ja") + new String("va");
    System.out.println(test.intern() == test);//false

还是输出 false!!! 哈哈是不是怀疑人生了,想不想知道为什么,往下看。

当虚拟机编译 String test = new String("ja") + new String("va"); 时,会将代码优化为 String test = new StringBuilder().append("ja").append("va").toString();。具体过程请等待下一篇博客,我会写出我对字符串运算的理解。

    String test1 = "java";
    System.out.println(test1.intern() == test1);//true
    String test2 = new String("java");
    System.out.println(test2.intern() == test2);//false

如果以上第一种方式直接创建内容为 “java” 的 String 对象,那么输出一定是 true。因为字符串常量池中已存在 “java” 的引用,再次创建只是将该引用返回。
而第二种方式,通过有参构造创建 String 对象,是一定要创建一个新的 String 对象的,这个对象属于方法中的局部变量,所以 test2 对象的引用存在于虚拟机栈中。而 test2.intern() 存储的内存地址是堆中的,那么自然就输出 false。

这两行代码的区别可以通过反编译看出来:

**1.在此之前,内容为 java 的字符串对象已经被创建**
54: ldc           #13                 // String java,将常量压入操作数栈顶
56: astore_2
**2.创建了新的 String 对象**
76: new           #4                  // class java/lang/String
79: dup
80: ldc           #13                 // String java
82: invokespecial #6                  // Method java/lang/String."":(Ljava/lang/String;)V
85: astore_3

例子2:而字符串 “计算机软件” 在调用 intern 方法时,字符串常量池中不存在相同内容的实例,所以输出 true。

6.探秘字符串常量池(阅读本章节需要熟悉类文件结构)

在笔者对 intern 方法进行测试时,发现了有趣的事情。我在前两个例子的基础上又尝试写了个例子

String str2 = new StringBuilder("ma").append("in").toString();
System.out.println(str2.intern() == str2);//false

发现 main 跟上面例子中的 java 一样,输出的也是 false,那么说明在调用 intern 方法前,字符串常量池中已经存在指向 main 字符串实例的引用。我猜测有两个可能

  1. 同 “java” 一样,当虚拟机加载某个类时,创建了 “main” 字符串,并将字符串实例的引用存储在字符串常量池中。这我无从得知 我不会
  2. 了解 Class 文件结构并使用过 javap 反编译的同学知道,方法名称 存在与 Class 文件常量池中。是否可能在某个时刻,虚拟机创建了 “main” 字符串实例,并将实例的引用驻留中字符串常量池中。让我们来研究一下。

首先要查看常量池内容,需要使用 javap -verbose 命令反编译该类查看 Class 文件中常量池的内容(只保留了与本文相关的 CONSTANT_Utf8_info 及 CONSTANT_String_info 常量)

Constant pool:			//常量池
#3 = String             #29            // ma
#5 = String             #31            // in
#11 = String             #40            // app
#12 = String             #41            // end
#15 = Utf8               <init>
...
#19 = Utf8               main  //主函数方法名称
...
#29 = Utf8               ma
#31 = Utf8               in
#40 = Utf8               app
#41 = Utf8               end
...
#48 = Utf8               append //方法名称
...
#50 = Utf8               toString //方法名称
...
#53 = Utf8               out
...
#55 = Utf8               intern //方法名称
#56 = Utf8               println //方法名称
...
  • CONSTANT_Utf8_info 类型的数据持有 String 的内容(Utf8 编码)
  • CONSTANT_String_info 类型的数据,是 String 常量的类型,只是持有一个 CONSTANT_Utf8_info 的索引,并不持有 String 常量的内容。

发现!内容为 “main” 的字面量在第 #19 行,main 是主函数的方法名称,类似的方法名称还有 #48 append,#50 toString…

我们知道当一个类被加载(类的加载第一阶段)时,虚拟机会将 Class 文件中常量池的文本字符串(字面量)及符号引用加载进每个类自己的运行时常量池中(将静态存储结构转化为方法区的运行时数据结构)。是否在此时,虚拟机在堆中创建 “main” 等字面量对应的字符串实例并将字符串实例的引用驻留在堆中的字符串常量池中?

(以下参考知乎上木大的回答https://www.zhihu.com/question/55994121 )

在 HotSpot VM 的运行时常量池中,以上两种常量对应的运行时结构是:

  • CONSTANT_Utf8_info -> Symbol* ( 指针指向一个 Symbol 对象,Symbol 是 C++ 对象,内容与 UTF8 常量持有的 UTF-8 编码的 字符串内容相同)
  • CONSTANT_String_info -> java.lang.String( String 对象的引用 )

R大:在类加载阶段, JVM会在堆中创建对应这些 class 文件常量池中的字符串对象实例 ,并在字符串常量池中驻留其引用。具体在resolve阶段执行。这些常量全局共享。

CONSTANT_Utf8_info 在进入运行时常量池中时,会被全部创建出来。CONSTANT_String_info 进入运行时常量池后,JVM 管它叫 JVM_CONSTANT_UnresolveString_info,意为未解析的String,内容与常量池中一样,还是一个索引。需要经过解析阶段,咸鱼翻身成为真正的 String 对象的引用。

那么是什么时刻,运行时常量池中的 UnresolveString_info 被解析呢?按照木大的说法:在第一次引用该项的ldc指令被第一次执行到的时候才会resolve

JVM 没有规定解析阶段发生的具体时间,默认解析是 lazy 的,只要求了解析阶段需要在 ldc 等17种用于操作符号引用的指令之前执行。当虚拟机执行 ldc 指令时,会根据 ldc 指令的索引在运行时常量池中寻找对应的项,并判断这个项是否已经解析过,如果没有解析,则解析这个项,并返回解析后的内容,并将此项标记为已解析状态。

当 ldc 指令遇到 String 类型常量时,解析过程中如果发现字符串常量池中已经有了与该常量内容相同的 String 对象的引用,则直接返回这个引用。否则,会在堆中创建一个包含该字符串常量的 String 对象,并将对象的引用驻留在字符串常量池中,再将该引用返回。

上面balabala说了这么多,不如来个图加深一下印象。

深入浅出Java基础——字符串常量池_第1张图片

清楚了字面量是如何加载进入运行时常量池,又是如何被创建成 String 对象后,我们回到最开始的问题

main 是否通过加载过程进入运行时常量池,然后被驻留在字符串常量池中呢。我们回看上面该类的常量池,发现并没有内容为 main 的 CONSTANT_Utf8_info 的索引 的 CONSTANT_String_info 常量。那么也就意味着,没有任一 ldc 指令的索引与 main 相关,main 也就不会被创建以及被驻留在字符串常量池中。

String str3 = new StringBuilder("app").append("end").toString();
System.out.println(str3.intern() == str3);//true  

StringBuilder 拼接字符串时调用了 append 方法,那么 class 常量池中就会存在内容为 append 的 CONSTANT_Utf8_info 的常量。append 方法与 main 方法一样,没有对应的 CONSTANT_String_info 常量。所以输出 true 在我们意料之中。那么究竟 main 这个字符串的引用为何会存在于字符串常量池中呢,在没有对 Java 底层更加了解之前,我只能暂且猜测当虚拟机加载某个类时创建了 “main” 字符串。(创建 java 字符串还有情可原,谁没事创建 main 干嘛,希望有看到本文的大佬为我答疑解惑)

7.字符串常量池在内存中的位置

public class PoolPosTest {
    public static void main(String[] args) {
        //JVM 启动参数:-XX:+PrintGCDetails -Xms20M -Xmx20M
        //当设置堆最小内存和最大内存相同时,可以限制堆的扩展。
        Set cache = new HashSet(); //使用 set 保存对字符串对象的引用,防止字符串被gc回收
        int i = 0;
        while (true){
            cache.add(String.valueOf(i++).intern()); //向字符串常量池中添加引用
        }
    }
}

以上代码抛出 Exception in thread “main” java.lang.OutOfMemoryError: Java heap space 异常,表示字符串常量池位于堆中。

按照笔者之前的认知,方法区存储了被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。这让方法区看起来像一个真实的物理区域。

但通过了解,方法区只是一个 JVM 规范中的概念,方法区并不是某个真实的物理区域。jdk 1.6方法区就是永久代;jdk 1.7 将运行时常量池和字符串常量池移动到堆中后,方法区由 永久代 + 堆 实现;jdk 1.8 使用元空间替换永久代,方法区由 元空间 + 堆 实现。元空间存储类型信息等元数据,堆存储运行时常量池及字符串常量池。

“所以,字符串常量池在方法区”,这句话也可以理解为正确的。

8.总结

  1. 三个常量池:1. class 文件常量池 2.运行时常量池 3.字符串常量池

  2. 字符串常量池的好处:节省空间时间,少写代码

  3. 字符串常量池底层是个不可扩容的 Hashtable

  4. String##intern 方法可以在运行时添加常量

  5. Class文件常量池中的字符串常量如何进入字符串常量池

  6. 《深入理解 Java 虚拟机》是本好书

  7. 有关 JVM 的问题就上知乎找 R大 的回答,真香啊。

  8. Idea 插件 jclasslib bytecode viewer 可以看字节码文件。但对于初学者建议使用 winhex,一个个字节对照 Class 文件结构去看字节码文件,以及 javap 指令查看反编译结果。(初学者就是我)

你可能感兴趣的:(深入Java基础,java,jvm,字符串,设计模式)