「 JavaSE 」String、StringTable、String.intern()详解

「 JavaSE 」String、StringTable、String.intern()详解

参考&鸣谢

深入解析String#intern 美团技术团队

再议String-字符串常量池与String.intern() gcoder_

JVM系列之:String.intern和stringTable 程序那些事

看了这篇文章,我搞懂了StringTable robod

字符串常量池StringTable Mr_cdd


文章目录

  • 「 JavaSE 」String、StringTable、String.intern()详解
    • 一、前言
    • 二、String类的一些特性
      • String的不可变性
      • 字符串的拼接
    • 三、StringTable讲解
      • 字符串什么时候被放入StringTable的
      • new String()的时候都干了什么
    • 四、常量池
      • 常量池是什么?
      • 方法区
      • Class文件常量池
      • 运行时常量池
      • 字符串常量池
    • 五、String.intern()与字符串常量池
      • JDK1.6
      • JDK1.7
      • String.intern()的应用
    • 六、小结


一、前言

Java中的字符串(String)是一种不可变对象,它在许多应用程序中扮演着重要角色。为了更有效地处理和操作字符串,Java提供了一些有用的工具和方法,如StringTable和String.intern()。StringTable是String类内部使用的一种高效机制,它允许JVM共享相同的字符串以节省内存。String.intern()允许将字符串添加到StringTable中,并返回指向该字符串的引用。这两个工具都非常有用,但也需要小心使用,因为它们可能会导致内存问题或性能问题。在本文中,我们将通过进一步探究String、StringTable和String.intern(),来深入理解Java字符串及其相关工具的实现和优点。


二、String类的一些特性

String的不可变性

在讲介绍StringTable之前,就不得不提一下String的不可变性,因为只有当String是不可变的才使得StringTable的实现成为可能。当我们定义一个字符串时:

String s = "hello";

这时候,“hello”就被存放在StringTable中,而变量s是一个引用,s指向了StringTable中的“hello”。

「 JavaSE 」String、StringTable、String.intern()详解_第1张图片

当我们把s的值改一下,改成”hello world“

String s = "hello";
s = "hello world";

这时候,并不是原先s指向的”hello“的值改变为了”hello world“,而是指向了一个新的字符串。

「 JavaSE 」String、StringTable、String.intern()详解_第2张图片

如何去验证是指向了一个新的字符串而不是修改其内容呢,我们可以打印一下hash值看看。

        String s = "hello";
        System.out.println(System.identityHashCode(s));
        s = "hello world";
        System.out.println(s.hashCode());
        s = "hello";
        System.out.println(System.identityHashCode(s));

「 JavaSE 」String、StringTable、String.intern()详解_第3张图片

可以看到,第一次和第三次的hash值一样,第二次hash值和其它两次不同,说明确实是指向了一个新的对象而不是修改了String的值。

那么**String是怎么实现不可变的呢?**我们来看一下String类的源码:

「 JavaSE 」String、StringTable、String.intern()详解_第4张图片

从源码中我们可以看出,首先String类是final的,说明其不可被继承,就不会被子类改变其不可变的特性;其次,String的底层其实是一个被final修饰的数组,说明这个value在确定值后就不能指向一个新的数组。这里我们要明确一点,被final修饰的数组虽然不能指向一个新的数组,但却是可以修改数组的值的

「 JavaSE 」String、StringTable、String.intern()详解_第5张图片

既然可以被修改,那String怎么是不可变的呢?因为String类并没有提供任何一个方法去修改数组的值,所以String的不可变性是由于其底层的实现,而不是一个final。

那么**String为什么要设计成不可变的呢?**我觉得是因为出于安全性的考量,试想一下,在一个程序中,有多个地方同时引用了一个相同的String对象,但是你可能只是想在一个地方修改String的内容,要是String是可变的,导致了所有的String的内容都改变了,万一这是在一个重要场景下,比如传输密码什么的,不就出大问题了吗。所以String就被设计成了不可变的。


字符串的拼接

说完了String的不可变性,再来聊一聊字符串的拼接问题,看下面一段程序

public static void main(String[] args) {
    String a = "hello";
    String b = " world!";
    String c = a+b;
}

就是这么简单了一段程序,你知道它是怎么实现的吗?我们来看一下这段代码对应的字节码指令:

「 JavaSE 」String、StringTable、String.intern()详解_第6张图片

我就不一行行解释这些字节码指令是什么意思了,我们重点看一下用红色标注的几行代码,看不懂前面的字节码指令没关系,可以看后面的注释。可以看到,字符串拼接其实就是调用StringBuilder的append()方法,然后调用了toString()方法返回一个新的字符串。


三、StringTable讲解

字符串什么时候被放入StringTable的

先来简单介绍一下StringTable。它的底层数据结构是HashTable,每个元素都是key-value结构,采用了数组+单向链表的实现方式。

再来看下面一段代码:

public static void main(String[] args) {
-->	String a = "hello";
    String b = " world!";
    String c = "hello world!";
}

在类加载后,“hello”这些字符串仅仅是当作符号被加载进了运行时常量池中,还没有成为字符串对象,这是因为Java中的字符串采用了延迟加载的机制,就是程序运行到具体某一行的时候再去加载。比如当程序运行到箭头所指向的那一行时,“hello”会从一个符号变成一个字符串对象,然后去StringTable中找有没有相同的字符串对象,如果有的话就返回对应的地址给变量a,如果没有的话就把“hello”放入StringTable中,然后再把地址给变量a。我们来看一下是不是这样:

        String s1 = "hello world";
        String s2 = "hello world";
        String s3 = "hello world";
        String s4 = "hello world";
        System.out.println(System.identityHashCode(s1));
        System.out.println(System.identityHashCode(s2));
        System.out.println(System.identityHashCode(s3));
        System.out.println(System.identityHashCode(s4));

「 JavaSE 」String、StringTable、String.intern()详解_第7张图片

可以看到,四个字符串对象的hash值都一样,说明如果StringTable中已经有了相同的对象就会指向同一个对象而不是指向新的对象。

new String()的时候都干了什么

当我们使用new String()去创建一个字符串对象时和直接写String a = "hello"是不一样的。前者保存在堆内存中,后者保存在StringTable中。

「 JavaSE 」String、StringTable、String.intern()详解_第8张图片

其实StringTable也是在堆中,我后面会详细说明。我们先来验证一下上面的说法:

        String a = "hello";
        String b = new String("hello");
        System.out.println(a == b);

看一下运行结果:

image-20230402004215253

结果很显然肯定是false,说明两者确实不是一个对象。而且上面提到指向字符串常量时会先从StringTable中查找,找到就直接返回找到的字符串,但是new String()的时候却不是这样,每new 一个String就会在堆里面创建一个新的String对象,即使是相同的内容,比如我创建4个String对象。

String s1 = new String("hello world");
String s2 = new String("hello world");
String s3 = new String("hello world");
String s4 = new String("hello world");

这时候在堆里面就会存在4个String对象:

「 JavaSE 」String、StringTable、String.intern()详解_第9张图片

我们再来打印一下hash看看是不是4个对象:

System.out.println(System.identityHashCode(s1));
System.out.println(System.identityHashCode(s2));
System.out.println(System.identityHashCode(s3));
System.out.println(System.identityHashCode(s4));

「 JavaSE 」String、StringTable、String.intern()详解_第10张图片

从结果中看出,确实是4个不同的对象。


四、常量池

常量池是什么?

「 JavaSE 」String、StringTable、String.intern()详解_第11张图片

JVM常量池主要分为Class文件常量池、运行时常量池,全局字符串常量池,以及基本类型包装类对象常量池


方法区

方法区的作用是存储Java类的结构信息,当创建对象后,对象的类型信息存储在方法区中,实例数据存放在堆中。类型信息是定义在Java代码中的常量、静态变量、以及类中声明的各种方法,方法字段等;实例数据则是在Java中创建的对象实例以及他们的值。

该区域进行内存回收的主要目的是对常量池的回收和对内存数据的卸载;一般说这个区域的内存回收率比起Java堆低得多。


Class文件常量池

class文件是一组以字节为单位的二进制数据流,在Java代码的编译期间,我们编写的Java文件就被编译为.class文件格式的二进制数据存放在磁盘中,其中就包括class文件常量池。

class文件常量池主要存放两大常量:字面量和符号引用

字面量:字面量接近java语言层面的常量概念

  • 文本字符串,也就是我们经常申明的:public String s = “abc”;中的"abc"
  • 用final修饰的成员变量,包括静态变量、实例变量和局部变量:public final static int f = 0x101;,final int temp = 3;
  • 而对于基本类型数据(甚至是方法中的局部变量),如int value = 1常量池中只保留了他的的字段描述符int和字段的名称value,他们的字面量不会存在于常量池。

符号引用:符号引用主要设涉及编译原理方面的概念

  • 类和接口的全限定名,也就是java/lang/String;这样,将类名中原来的".“替换为”/"得到的,主要用于在运行时解析得到类的直接引用
  • 字段的名称和描述符,字段也就是类或者接口中声明的变量,包括类级别变量和实例级的变量
  • 方法中的名称和描述符,也即参数类型+返回值

运行时常量池

当Java文件被编译成class文件之后,会生成上面的class文件常量池,JVM在执行某个类的时候,必须经过加载、链接(验证、准备、解析)、初始化的步骤,运行时常量池则是在JVM将类加载到内存后,就会将class常量池中的内容存放到运行时常量池中,也就是class常量池被加载到内存之后的版本,是方法区的一部分。

在解析阶段,会把符号引用替换为直接引用,解析的过程会去查询字符串常量池,也就StringTable,以保证运行时常量池所引用的字符串与字符串常量池中是一致的。

运行时常量池相对于class常量池一大特征就是具有动态性,Java规范并不要求常量只能在运行时才产生,也就是说运行时常量池的内容并不全部来自class常量池,在运行时可以通过代码生成常量并将其放入运行时常量池中,这种特性被用的最多的就是String.intern()。


字符串常量池

在JDK6.0及之前版本,字符串常量池存放在方法区中,在JDK7.0版本以后,字符串常量池被移到了中了。至于为什么移到堆内,大概是由于方法区的内存空间太小了。在HotSpot VM里实现的string pool功能的是一个StringTable类,它是一个Hash表,默认值大小长度是1009;这个StringTable在每个HotSpot VM的实例只有一份,被所有的类共享。字符串常量由一个一个字符组成,放在了StringTable上。

在JDK6.0中,StringTable的长度是固定的,长度就是1009,因此如果放入String Pool中的String非常多,就会造成hash冲突,导致链表过长,当调用String#intern()时会需要到链表上一个一个找,从而导致性能大幅度下降;在JDK7.0中,StringTable的长度可以通过参数指定。

设计思想:

  • 字符串的分配,和其他的对象分配一样,耗费高昂的时间与空间代价,作为最基础的数据类型,大量频繁的创建字符串,极大程度地影响程序的性能

  • JVM为了提高性能和减少内存开销,在实例化字符串常量的时候进行了一些优化

    • 为字符串开辟一个字符串常量池,类似于缓存区
    • 创建字符串常量时,首先查看字符串常量池是否存在该字符串
    • 存在该字符串,返回引用实例,不存在,实例化该字符串并放入池中

实现基础:

  • 实现该优化的基础是因为字符串是不可变的,可以不用担心数据冲突进行共享
  • 运行时实例创建的全局字符串常量池中有一个表,总是为池中每个唯一的字符串对象维护一个引用,这就意味着它们一直引用着字符串常量池中的对象,所以,在常量池中的这些字符串不会被垃圾收集器回收

五、String.intern()与字符串常量池

视频学习:

关于intern()面试难题

/** 
 * Returns a canonical representation for the string object. 
 * 

* A pool of strings, initially empty, is maintained privately by the * class String. *

* When the intern method is invoked, if the pool already contains a * string equal to this String object as determined by * the {@link #equals(Object)} method, then the string from the pool is * returned. Otherwise, this String object is added to the * pool and a reference to this String object is returned. *

* It follows that for any two strings s and t, * s.intern() == t.intern() is true * if and only if s.equals(t) is true. *

* All literal strings and string-valued constant expressions are * interned. String literals are defined in section 3.10.5 of the * The Java™ Language Specification. * * @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();

字符串常量池的位置也是随着jdk版本的不同而位置不同。在jdk6中,常量池的位置在永久代(方法区)中,此时常量池中存储的是对象。在jdk7中,常量池的位置在堆中,此时,常量池存储的就是引用了。

在jdk8中,永久代(方法区)被元空间取代了。这里就引出了一个很常见很经典的问题,看下面这段代码。

@Test
public void test(){
    String s = new String("2");
    s.intern();
    String s2 = "2";
    System.out.println(s == s2);


    String s3 = new String("3") + new String("3");
    s3.intern();
    String s4 = "33";
    System.out.println(s3 == s4);
}

//jdk6
//false
//false

//jdk7
//false
//true

这段代码在jdk6中输出是false false,但是在jdk7中输出的是false true。我们通过图来一行行解释。

JDK1.6

「 JavaSE 」String、StringTable、String.intern()详解_第12张图片

  • String s = new String(“2”);创建了两个对象,一个在堆中的StringObject对象,一个是在常量池中的“2”对象。
  • s.intern();在常量池中寻找与s变量内容相同的对象,发现已经存在内容相同对象“2”,返回对象2的地址。
  • String s2 = “2”;使用字面量创建,在常量池寻找是否有相同内容的对象,发现有,返回对象"2"的地址。
  • System.out.println(s == s2);从上面可以分析出,s变量和s2变量地址指向的是不同的对象,所以返回false

  • String s3 = new String(“3”) + new String(“3”);创建了两个对象,一个在堆中的StringObject对象,一个是在常量池中的“3”对象。中间还有2个匿名的new String(“3”)我们不去讨论它们。
  • s3.intern();在常量池中寻找与s3变量内容相同的对象,没有发现“33”对象,在常量池中创建“33”对象,返回“33”对象的地址。
  • String s4 = “33”;使用字面量创建,在常量池寻找是否有相同内容的对象,发现有,返回对象"33"的地址。
  • System.out.println(s3 == s4);从上面可以分析出,s3变量和s4变量地址指向的是不同的对象,所以返回false

JDK1.7

「 JavaSE 」String、StringTable、String.intern()详解_第13张图片

  • String s = new String(“2”);创建了两个对象,一个在堆中的StringObject对象,一个是在堆中的“2”对象,并在常量池中保存“2”对象的引用地址。
  • s.intern();在常量池中寻找与s变量内容相同的对象,发现已经存在内容相同对象“2”,返回对象“2”的引用地址。
  • String s2 = “2”;使用字面量创建,在常量池寻找是否有相同内容的对象,发现有,返回对象“2”的引用地址。
  • System.out.println(s == s2);从上面可以分析出,s变量和s2变量地址指向的是不同的对象,所以返回false

  • String s3 = new String(“3”) + new String(“3”);创建了两个对象,一个在堆中的StringObject对象,一个是在堆中的“3”对象,并在常量池中保存“3”对象的引用地址。中间还有2个匿名的new String(“3”)我们不去讨论它们。
  • s3.intern();在常量池中寻找与s3变量内容相同的对象,没有发现“33”对象,将s3对应的StringObject对象的地址保存到常量池中,返回StringObject对象的地址。
  • String s4 = “33”;使用字面量创建,在常量池寻找是否有相同内容的对象,发现有,返回其地址,也就是StringObject对象的引用地址。
  • System.out.println(s3 == s4);从上面可以分析出,s3变量和s4变量地址指向的是相同的对象,所以返回true。

String.intern()的应用

在大量字符串读取赋值的情况下,使用String.intern()会大大的节省内存空间。

static final int MAX = 1000 * 10000;
static final String[] arr = new String[MAX];

public static void main(String[] args) throws Exception {
    Integer[] DB_DATA = new Integer[10];
    Random random = new Random(10 * 10000);
    for (int i = 0; i < DB_DATA.length; i++) {
        DB_DATA[i] = random.nextInt();
    }
 long t = System.currentTimeMillis();
    for (int i = 0; i < MAX; i++) {
        //arr[i] = new String(String.valueOf(DB_DATA[i % DB_DATA.length]));
         arr[i] = new String(String.valueOf(DB_DATA[i % DB_DATA.length])).intern();
    }

 System.out.println((System.currentTimeMillis() - t) + "ms");
    System.gc();
}

运行的参数是:-Xmx2g -Xms2g -Xmn1500M 上述代码是一个演示代码,其中有两条语句不一样,一条是使用 intern,一条是未使用 intern。发现不使用 intern 的代码生成了1000w 个字符串,占用了大约640m 空间。

使用了 intern 的代码生成了1345个字符串,占用总空间 133k 左右。其实通过观察程序中只是用到了10个字符串,所以准确计算后应该是正好相差100w 倍。虽然例子有些极端,但确实能准确反应出 intern 使用后产生的巨大空间节省。

利用String的不变性,String.intern()方法本质就是维持了一个String的常量池,而且池里的String应该都是唯一的。这样,我们便可以利用这种唯一性,来做一些文章了。我们可以利用池里String的对象来做锁,实现对资源的控制。比如一个城市的某种资源同一时间只能一个线程访问,那就可以把城市名的String对象作为锁,放到常量池中去,同一时间只能一个线程获得。

不当的使用:fastjson 中对所有的 json 的 key 使用了 intern 方法,缓存到了字符串常量池中,这样每次读取的时候就会非常快,大大减少时间和空间,而且 json 的 key 通常都是不变的。但是这个地方没有考虑到大量的 json key 如果是变化的,那就会给字符串常量池带来很大的负担。另外,关注Java知音公众号,回复“后端面试”,送你一份面试题宝典!


六、小结

String、StringTable和String.intern()是Java中字符串处理的重要概念,它们的关系如下:

  • String是Java中的一个类,用于表示字符串。每个String对象都有一个底层的字符数组和一个表示字符串长度的变量。
  • StringTable是一个哈希表,用于存储所有在程序中使用的字符串字面量,以及通过String.intern()方法手动添加到StringTable中的字符串。StringTable的键是字符串的值,而值则是对应的String对象的引用。
  • String.intern()是一个方法,用于将一个字符串添加到StringTable中。如果StringTable中已经存在该字符串,则返回StringTable中该字符串的引用。否则,会创建一个新的String对象,并将其添加到StringTable中。

下面是对这些概念的详细解释:

  • String:String是Java中最基本的字符串类型。它是一个不可变的类,一旦被创建,就不能被修改。因此,每次对字符串进行修改时,都会创建一个新的String对象。这种特性使得String在处理字符串时非常高效,同时也保证了字符串的安全性。
  • StringTable:StringTable是一个哈希表,用于存储所有在程序中使用的字符串字面量,以及通过String.intern()方法手动添加到StringTable中的字符串。StringTable的目的是为了节省内存,并提高字符串比较的效率。当程序中使用字符串字面量时,JVM会先检查StringTable中是否已经存在该字符串,如果存在,则直接返回该字符串的引用。否则,会创建一个新的String对象,并将其添加到StringTable中。
  • String.intern():String.intern()是一个方法,用于将一个字符串添加到StringTable中。如果StringTable中已经存在该字符串,则返回StringTable中该字符串的引用。否则,会创建一个新的String对象,并将其添加到StringTable中。String.intern()方法可以用于手动控制StringTable中的字符串,从而节省内存并提高字符串比较的效率。

综上所述,String、StringTable和String.intern()是Java中字符串处理的重要概念,它们的关系密切,对于理解Java中字符串的处理机制非常重要。

你可能感兴趣的:(Java,jvm,java,开发语言)