深入了解String和常量池

一. 认识String类

写在开头,string本质是一个char数组。string中的字符串是由一个个char组成,形成一个不可变的char数组,string对象负责管理这个char数组的生命周期,string类中大多数方法也是针对这个char数组的操作

public final class String
    implements java.io.Serializable, Comparable<String>, CharSequence {
    /** The value is used for character storage. */
    private final char value[];

    /** Cache the hash code for the string */
    private int hash; // Default to 0

    /** use serialVersionUID from JDK 1.0.2 for interoperability */
    private static final long serialVersionUID = -6849794470754667710L;
    
    public native String intern();
    
    public boolean equals(Object anObject) {
        if (this == anObject) {
            return true;
        }
        if (anObject instanceof String) {
            String anotherString = (String)anObject;
            int n = value.length;
            if (n == anotherString.value.length) {
                char v1[] = value;
                char v2[] = anotherString.value;
                int i = 0;
                while (n-- != 0) {
                    if (v1[i] != v2[i])
                        return false;
                    i++;
                }
                return true;
            }
        }
        return false;
    }
    
    public int hashCode() {
        int h = hash;
        if (h == 0 && value.length > 0) {
            char val[] = value;

            for (int i = 0; i < value.length; i++) {
                h = 31 * h + val[i];
            }
            hash = h;
        }
        return h;
    }
    
}    

1.1 声明string的俩种方式

  • 使用字面量
    String str = "test"

  • 使用new关键字
    String str = new String("test")

以下是一个demo

public class Demo2 {
    public static void main(String[] args) {
        String s = new String("test1");
        String s2 = "test1";
        String s3 = "test1";

        boolean result = s == s2; // false
        boolean result1 = s2 == s3; // true
        System.out.println(result);
        System.out.println(result1);
    }
}

如上有一段代码:
字符串s声明的是对象,字符串s2、s3声明的是字面量。

那么,使用这俩种方式声明的字符串有什么区别呢

  • new关键字
    使用 new() 的方式来创建一个 String 对象,执行后会在字符串常量池和堆中各创建一个对象,即创建两个对象,若字符串常量池中已存在了"test1"对象,则只会在Heap中创建,即只创建了一个对象。

  • 字面量声明
    JVM 首先将会对我们赋的值到 String Pool 中进行查找,如果找到的话,就返回已经存在这个值的引用,如果没找到,就创建一个字符串放入到常量池中。

1.2 俩种方式的不同点和共同点

上小节中的 == 比较的是内存地址,结果显示的result是false,result1是true。

基于JDK8的JVM内存模型图,网上有很多类似的图,但我觉得画的都不是很准确,下面的图我觉得能清晰说明string模型的结构

1.png

下面,我们用实际结果来证明上图的正确性

1.2.1 使用字面量声明的字符串指向的内存地址相同,并且字符串中的char数组地址也是一致

我们要证明的是以下俩个点:

  • 字面量声明的字符串内存地址都是相同的
  • 字面量声明的字符串中char数组地址也是一致的

1.2.1.1 证明内存地址相同

我们先看第一张图:

2.png

我们看到s2和s3的hashCode相同,是否能说明s2和s3指向的对象地址一样呢。注意,此处的hashcode使用的是JVM的底层方法处理,调用的是native方法也即是c++方法去处理的。

问题:那么,底层hashCode一致是否能代表内存地址一致呢?

答:不一定,我们可以从GC层面去思考,垃圾回收时(复制算法,整理算法)都要发生对象移动,都要改变对象的内存地址。但hashCode又不能变化,那么该值一定是不会和内存地址强挂钩,并且被保存在对象的某个地方了(对象头)。

那么,我们要如何证明这个内存地址是一样的呢,其实java天然提供这个功能,使用==默认比较的就是内存地址,我们可以看到s2和s3==比较的结果是true的。因此可以直接证明s2和s3的内存地址是一致的。

问题已经证明了,但是我们这边有几个其他点可以延伸:

问题1: 原生hashCode能不能表示内存地址?
答:其实是可以的,请移步这篇文章 https://blog.csdn.net/sinat_42457748/article/details/123961244,具体如下:

原生hashcode是有几种生成策略的,并且通过JVM参数可以指定。jdk1.8前,默认hashcode为0,jdk1.8后,默认为5

问题2: 为什么不使用string方法中的hashCode方法而是使用System.identityHashCode去呈现示例
答: 因为string中的hashcode方法是重写,天然带有二次封装性,无法区分说明例子,下面看张图

3.png

我们可以看到,如果使用String中的hashcode去,那么s和s2、s3都是相同的。这明显不符合常理,但是又可以解释的通,因为string的hashCode方法是计算String内部char数组内容是否一致。

1.2.1.2 证明同一字面量string中char数组也是一致的

如何证明string内部final属性的char数组也是一致的的呢,用代码不好直接去做验证,因为char数组是private的,string没有直接提供访问方法。

除非我们使用反射进行访问。但是这里我们没这么麻烦,使用idea可以直接进行查看,如下图。


4.png

我们可以看到,s2和s3的同一个字符串"test1"内部所对应的value数组内存地址是一样的,因此,我们可以判定我们所要证明的结论。
结论:同一字面量string中char数组也是一致的

1.2.2 new关键字声明string也会复用数组char

1.2图中,展示的new String("test1")的char数组指向常量池中的引用,这个我们如何证明呢?
一样的,我们通过代码还需要进行反射才能证明,不如直接通过idea来去看

5.png

我们可以清楚的看到,s和s2、s3的char数组内存地址是一样的,是复用了这个char数组。
因此我们基于以上总结画出了1.2的图示。

二、深入了解String

2.1 来看几个问题

问题1:这段代码创建了几个对象?
String str1 = new String("test1");

答案:
俩个,"test1"对象和String对象
解释:

public class demo.Demo2
 minor version: 0
 major version: 52
 flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
  #1 = Methodref          #6.#22         // java/lang/Object."<init>":()V
  #2 = Class              #23            // java/lang/String
  #3 = String             #24            // test1
  #4 = Methodref          #2.#25         // java/lang/String."<init>":(Ljava/lang/String;)V
  #5 = Class              #26            // demo/Demo2
  #6 = Class              #27            // java/lang/Object
  #7 = Utf8               <init>
  #8 = Utf8               ()V
  #9 = Utf8               Code
 #10 = Utf8               LineNumberTable
 #11 = Utf8               LocalVariableTable
 #12 = Utf8               this
 #13 = Utf8               Ldemo/Demo2;
 #14 = Utf8               main
 #15 = Utf8               ([Ljava/lang/String;)V
 #16 = Utf8               args
 #17 = Utf8               [Ljava/lang/String;
 #18 = Utf8               s
 #19 = Utf8               Ljava/lang/String;
 #20 = Utf8               SourceFile
 #21 = Utf8               Demo2.java
 #22 = NameAndType        #7:#8          // "<init>":()V
 #23 = Utf8               java/lang/String
 #24 = Utf8               test1
 #25 = NameAndType        #7:#28         // "<init>":(Ljava/lang/String;)V
 #26 = Utf8               demo/Demo2
 #27 = Utf8               java/lang/Object
 #28 = Utf8               (Ljava/lang/String;)V
{
 public demo.Demo2();
   descriptor: ()V
   flags: ACC_PUBLIC
   Code:
     stack=1, locals=1, args_size=1
        0: aload_0
        1: invokespecial #1                  // Method java/lang/Object."<init>":()V
        4: return
     LineNumberTable:
       line 9: 0
     LocalVariableTable:
       Start  Length  Slot  Name   Signature
           0       5     0  this   Ldemo/Demo2;
 public static void main(java.lang.String[]);
   descriptor: ([Ljava/lang/String;)V
   flags: ACC_PUBLIC, ACC_STATIC
   Code:
     stack=3, locals=2, args_size=1
        0: new           #2                  // class java/lang/String
        3: dup
        4: ldc           #3                  // String test1
        6: invokespecial #4                  // Method java/lang/String."<init>":(Ljava/lang/String;)V
        9: astore_1
       10: return
     LineNumberTable:
       line 11: 0
       line 22: 10
     LocalVariableTable:
       Start  Length  Slot  Name   Signature
           0      11     0  args   [Ljava/lang/String;
          10       1     1     s   Ljava/lang/String;
}

问题2: 输出结果是true还是false?

String str1 = new String("test1");
 String str2 = "test1";
 System.out.println(str1 == str2);

根据1.2解释答案很明显是false,因为两个变量指向的地址不同,一个指向字符串常量池,一个指向堆上的对象,而==比较的就是地址。

问题3: 输出结果是true?

String str1 = new String("test1");
 str1.intern();
 String str2 = "test1";
 System.out.println(str1 == str2);

intern官网注释:

/** * Returns a canonical representation for the string object. * <p> * A pool of strings, initially empty, is maintained privately by the * class {@code String}. * <p> * When the intern method is invoked, if the pool already contains a * string equal to this {@code String} object as determined by * the {@link #equals(Object)} method, then the string from the pool is * returned. Otherwise, this {@code String} object is added to the * pool and a reference to this {@code String} object is returned. * <p> * It follows that for any two strings {@code s} and {@code t}, * {@code s.intern() == t.intern()} is {@code true} * if and only if {@code s.equals(t)} is {@code true}. * <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™ 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. */
译文:
返回字符串对象的规范表示。
一个字符串池,最初是空的,由 String 类私下维护。
当调用 intern 方法时,如果池中已经包含一个等于该 String 对象的字符串,由 equals(Object) 方法确定,则返回池中的字符串。否则,将此 String 对象添加到池中并返回对该 String 对象的引用。
由此可见,对于任何两个字符串 s 和 t,当且仅当 s.equals(t) 为真时,s.intern() == t.intern() 才为真。
所有文字字符串和字符串值的常量表达式都是实习的。字符串文字在 Java™ 语言规范的第 3.10.5 节中定义。

答案:

false
下面画一个图来表示:
基于jdk1.8

6.png

s = new String("test1")后,在堆中生成了一个对象,同时也在字符串常量池中生成了一个字符串常量。

为什么这么能肯定,因为假设没有在常量池中生成"test1",那么在调用s.intern后,常量池中存在的就不是新对象,而是s的引用(通过官网文档),但事实我们上面的结果是false,说明常量池中存在的不是s的引用,而是新的字符串。

s2获取到的字符串和指向的地址是由s在new String初始化过程中在常量池中生成的字符串,s.intern()操作后并没有改变任何情况,这时候,声明s2字面量的时候,直接是从常量池中拿到的字符串。
因此s指向的是堆中地址,s2指向的是常量池中地址。俩者引用地址不一样。

问题4: 基于问题3的改造,如何让问题3变成true?

String str3 = new String("test") + new String("1");
str3.intern();
String str4 = "test1";
System.out.println(str3 == str4);

答案:

7.png

我们再画一张图


71.png

2.2 为什么string是不可变的

  1. 不可变的原因
  • String的主要成员变量char value[]是private final类型的;
  • String被声明为final class,是典型的Immutable类;
  1. 不可变的好处
  • 可以缓存 hash 值
    因为 String 的 hash 值经常被使用,例如 String 用作 HashMap 的 key。 不可变的特性可以使得 hash 值也不可变,因此只需要进行一次计算。

  • String Pool 的需要
    如果一个 String 对象已经被创建过了,那么就会从 String Pool 中取得引用。 只有 String 是不可变的,才可能使用 String Pool。

  1. 安全性
    String 经常作为参数,String 不可变性可以保证参数不可变。例如在作为网络连接参数的情况下如果 String 是可变的,那么在网络连接过程中,String 被改变,改变 String 对象的那一方以为现在连接的是其它主机,而实际情况却不一定是。

  2. 线程安全
    String 不可变性天生具备线程安全,可以在多个线程中安全地使用。

2.3 是否能够变更String

我们通过2.2知道string是不可变更的,那么事实上,string到底能不能变更,实际,我们使用相应技术是可以实现变更的,但也只能变更特定场景下的。

我们至少有俩种方式可以实现变更:

2.3.1 使用反射改变

我们可以使用反射动态修改string类中的char数组达到修改string的目的

2.3.2 使用代理

我们也可以代理String类,达到动态修改内部数据的目的,但是因为String类是final的,原则上来说不允许使用cglib代理,cglib要求类是public修饰符的。因此,我们还需要使用javaagent技术来修改String类的访问修饰符。

补充:虽然技术上可以实现修改string,但是final类型的string,在编译期会被优化,直接以常量的形式嵌入到代码中,因此,只要我们能够逃脱编译期优化,使用代理和反射能够使得字符串得到变更。

三、了解JVM常量池

推荐文章:【JVM】Java 8 中的常量池、字符串池、包装类对象池

3.1 Class文件常量池

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

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

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

    • 文本字符串,也就是我们经常申明的:public String s = "abc";中的"abc"

    • 用final修饰的成员变量,包括静态变量、实例变量和局部变量:public final static int f = 0x101;,final int temp = 3;

    • 而对于基本类型数据(甚至是方法中的局部变量),如int value = 1常量池中只保留了他的的字段描述符int和字段的名称value,他们的字面量不会存在于常量池。

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

    • 类和接口的全限定名,也就是java/lang/String;这样,将类名中原来的".“替换为”/"得到的,主要用于在运行时解析得到类的直接引用

    • 字段的名称和描述符,字段也就是类或者接口中声明的变量,包括类级别变量和实例级的变量

    • 方法中的名称和描述符,也即参数类型+返回值

3.2 运行时常量池

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

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

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

3.3 字符串常量池

在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的长度可以通过参数指定。

字符串常量池设计思想:

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

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

    • 为字符串开辟一个字符串常量池,类似于缓存区

    • 创建字符串常量时,首先查看字符串常量池是否存在该字符串

    • 存在该字符串,返回引用实例,不存在,实例化该字符串并放入池中

  3. 实现的基础

    • 实现该优化的基础是因为字符串是不可变的,可以不用担心数据冲突进行共享

    • 运行时实例创建的全局字符串常量池中有一个表,总是为池中每个唯一的字符串对象维护一个引用,这就意味着它们一直引用着字符串常量池中的对象,所以,在常量池中的这些字符串不会被垃圾收集器回收

四、字符串常量池和String的发展

4.1 方法区的演变

Hotspot中方法区的变化:

这是针对于HotSpot而言

版本 变化
JDK1.6及之前 有永久代,字符串常量池、静态变量存放在永久代上
8.png
JDK1.7 有永久代,但已经逐步”去永久代“,字符串常量池、静态变量以及保存在堆中了。
9.png
JDK1.8及之后 无永久代,类型信息、字段、方法、常量保存在本地内存的元空间,但字符串常量池、静态变量仍在堆中
10.png
  1. 在某些场景下,如果动态加载类过多,容易产生Perm区的OOM。比如某个实际Web工程中,因为功能点比较多,在运行过程中,要不断动态加载很多类(列如jsp),同理string常量池的迁移也是一样。会经常出现致命错误:java.lang.OutOfMemoryError:PermGen
    而元空间和永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制。

  2. 对永久代进行调优是很困难的。

4.2 java9的String

参考文章:JDK9为何要将String的底层实现由char[]改成了byte[]?

string演变


11.jpg

有关 Java 9 的 String

public final class String implements java.io.Serializable, Comparable<String>, CharSequence {      
  @Stable     
  private final byte[] value;      
  private final byte coder;          
  @Native static final byte LATIN1 = 0;     
  @Native static final byte UTF16  = 1;          
  static final boolean COMPACT_STRINGS;        
}

一直到 Java 8,Strings 在 Java 中使用字符数组进行存储的,同时使用的是 UTF-16 字符集,因此每一个字符将会使用 2 字节的内存。

从 Java 9 开始,Java 提供了一个叫做压缩字符(Compact Strings)的存储概念。

这个存储将会针对字符串使用 char[] 和 byte[] 中字符编码,这个将会与你需要存储的内容有关。

简单来说就是从 Java 9 开始,String 将会根据存储内容的不同来使用不同的存储格式,只会在必要的时候才会使用 UTF-16 编码,这种设计将会显著降低 String 对内存的使用,并且能够让来让垃圾清理程序(Garbage Collector)更有效率的工作。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,544评论 6 501
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,430评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,764评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,193评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,216评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,182评论 1 299
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,063评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,917评论 0 274
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,329评论 1 310
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,543评论 2 332
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,722评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,425评论 5 343
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,019评论 3 326
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,671评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,825评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,729评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,614评论 2 353

推荐阅读更多精彩内容