通俗易懂:深入String 字符串常量池的存储机制
通俗易懂:深入String 字符串常量池的存储机制
字符串常量池是Java中一种重要的内存优化机制,用于存储字符串字面量。本文将深入探讨字符串常量池的存储机制,通过代码示例和详细解释,帮助读者理解其工作原理和使用场景。
先看一下以下代码会输出什么:
public void f1(){
String a = "hello";
String b = "hello";
String c = new String("hello");
System.out.println(a == b); // true
System.out.println(a == c); // false
System.out.println(b == c); // false
System.out.println(a.equals(c)); // true
System.out.println(b.equals(c));// true
}
我们知道String类型是引用类型,如果是基本数据类型那么==就是判断两边的数值是否相等。如果是引用类型就是判断两边的引用是否指向同一个对象。而equals则是比较字符串的内容是否一样。
按照常理来讲,a,b,c应该是三个不同的String对象才对,所以三个对象两两用==号比较的时候应该都为false才对,但是a == b却意外的输出了true。这个原因就跟本文要讲述的字符串常量池储存机制有关。接下来我会详细讲述,什么时候字符串会放入常量池,以及常量池是如何管理和使用这些存入的字符串的。
什么是字符串常量池?
字符串常量池(String Constant Pool)是Java中的一种内存优化机制,用于提高字符串的复用性和性能。具体来说,它是在JVM内部维护的一个特殊的内存区域,专门存储字符串字面量(即直接写在代码中的字符串常量,如"hello", "java"等带引号的具体的字符串)。当一个字符串常量被创建时,JVM首先会检查常量池中是否已有相同内容的字符串,如果有,直接返回该字符串的引用;如果没有,则将该字符串加入池中。
这种机制的好处在于,字符串在Java中是不可变的,因此相同的字符串常量可以共享同一份内存,避免了重复创建相同内容的字符串,节省了内存空间并提高了性能。
String结构
查看String类型的结构,有三个成员属性:char[] value,字符数据就是用这个char数组保存的。int hash 保存该字符串的hash值。这两个属性都是跟字符串常量池有关的属性。
至于serialVersionUID 是用于标识唯一的序列号,在实现序列化和反序列化的时候会用到,本文不会涉及讨论,故只关注前两个属性就好。
String比较常用的三种构造方法:
- 无参构造:让字符串默认值为空字符串。
- 有参构造(参数为字符串):新构建的字符串对象的value数组和hash值都沿用参数字符串的值。
- 有参构造(参数为char数组):通过copy方法,把传入的char数组的值全部赋给新对象的value数组。
之所以这里要着重讲这三个构造方法,是因为了解了这三个构造方法初始化字符串数据的方法后,再结合常量池的知识,就能明白各种情况下字符串内容一样但地址却不同的原因是什么。
什么时候字符串会被放入常量池?
以之前的代码为例子,流程图如下:
在执行String a = "hello"时,JVM发现hello是一个字符串常量,于是在字符串常量池中查找是否有hello,发现没有,于是创建了一个String对象,并将属性value的值初始化为hello。最后将创建的对象放入常量池中。然后返回常量池中hello对象的地址0X23给a。
继续执行String b = "hello"时,JVM依然去常量池中查找有没有hello,发现有,于是直接返回查找到对象的地址0X23给b。所以a == b当然是true了,因为它们都指向同一个对象地址。
那String c = new String("hello");c又为什么和a,b不指向同一个对象呢? 因为用new String相当于就是程序员显式的要求在堆中分配一块空间创建一个String对象。所以他并不会把常量池的对象地址交给c,因为c已经要求了要自己开辟一个空间存放String对象。那么此时常量池的复用性是不是就失效了呢?因为c都要求自己新创建对象了。其实不然,因为观察String的构造方法就可以知道,它只是进行了一个简单的把参数String对象的value和hash复用了。
执行Sting c = new String("hello")时,走带String参数的有参构造方法,此时就会在堆内存中开辟一个空间0x33存储这个新对象,然后再根据构造方法初始化属性值,this.value = original.value。original是“hello”,是字符串常量,只要是这种字符串常量,JVM就会查找常量池有没有该字符串,如果没有就创建value为“hello”的字符串对象,然后再进行this.value = original.value这样的赋值操作。
因为“hello”已经在常量池中了,所以会把在常量池中的hello对象的value数组引用给新对象的value。所以c和a、b的value其实都是指向同一个数组,只是c的String对象地址和a、b的不同。我们可以打断掉调试,看是否为同一个数组。发现确实数组的地址都为1632。
** 总结: 无论在什么地方,只要遇到具体的字符串如"hello","java"等,就表示该字符串会被放入常量池(如果常量池没有该字符串的话)。举个例子,假设String c = new String("java");在前面并没有出现java这个字符串,所以jvm会自动创建一个字符串对象,它的值就为java,然后会把它放入常量池中。然后再用这个jvm创建的对象给我们创建的对象c进行属性初始化(有参构造方法中的方式)。**
如果你已经明白和掌握了前文的内容,那么可以再来看接下来的例子,思考为什么会输出false?d和e的value数组是同一个吗?
char[] arr = {'j','a','v','a'};
String d = new String(arr);
String e = "java";
System.out.println(d == e); // false
首先解释为什么是false,因为d使用的是构造方法创建对象,而带数组的有参构造方法是通过把传入数组的值copy给新数组,再把新数组的地址返回给String对象的value属性。如下图:
此时可以思考,那此时会存在把“java”的String对象放入常量池的情况吗?答案是不会,因为自始至终都没有出现过字符串常量,此时走的构造方法仅仅是通过copy数组的方式,存入java。那怎么证明我们的说法呢?因为如果String d = new String(arr)已经把“java”放入常量池的话,就表示把d指向的String 对象放入了常量池,而String e = "java"会直接指向常量池中的对象,既然如此那d和e不就指向一个对象了吗?就不会输出false了。但结果是输出false,所以String d = new String(arr)是不会放入常量池的。
既然没有放入常量池,那执行String e = "java"时,JVM在常量池找不到对应对象,于是就会创建String对象存放“java”,并放入常量池中。
故第二个问题也能解答了,d和e不仅指向的String对象不同,并且数组也不同。
如果我把顺序反过来,他们指向的对象和数组地址可以相同吗?
String e = "java";
char[] arr = {'j','a','v','a'};
String d = new String(arr);
System.out.println(d == e); // false
相信大家能很快判断出来指向的对象不同,因为一个是常量池中的,一个是自己在堆中创建的。关键是数组地址会不会相同,此时就看new String(arr)构造方法究竟是如何初始化属性的了,它是通过把原数组的值复制到新数组中,再把新数组的地址给String对象的value属性。这里面根本就没有字符串常量“java”的事,除非初始化属性的方式是this.value = "java".vaule,d和e的value才会指向同一个数组。
字符串和字符串是如何相加的?
先看如下代码,思考为什么最后输出的会是false。
public void f2(){
String a = "hello";
String b = "world";
String c = "helloworld";
String d = a + b;
System.out.println(c == d); // false
}
按照前文讲的,在执行完String c = "helloworld"时,不是会把“helloworld”放进常量池中吗?d也是等于这个,而常量池中又存在,所以把常量池中的对象的引用给d, 那c和d不是指向同一个对象吗,怎么会输出false呢?
其实d = a + b;并不等同于 d = "helloworld"; 因为java在处理字符串变量相加的时候,底层是使用的StringBuilder的append方法进行追加的。
当字符串追加完后调用toString()方法返回字符串:
最后是通过new String的有参构造方法返回字符串,所以不会返回字符串常量池中的对象。那么此时又来思考一个问题,那这个有参构造方法中的value会和常量池对象中的value指向相同吗?就看这个构造方法是怎么初始化value属性的了。
从构造方法可以看出,这和String带数组参数的构造方法一样,都是通过copy原数组得到一个新数组的引用返回。而非用常量池对象的数组引用返回。所以当然c和d的value不是指向同一个数组的。
那么对于所有字符串相加都是采用这种方式吗?并不是,如果所有相加的字符串都是常量不是变量的话那么就等同于一个字符串常量,如下所示:
String s1 = "hello" + "java"; // 这种写法,编译器会直接优化成 hellojava
String s2 = "hellojava";
System.out.println(s1 == s2); // true
只要相加的字符串至少含有一个变量的时候,才会用StringBuilder的方式,如下,p1和p3都是用StringBuilder的方式追加字符串并返回。
String p = "aa";
String p1 = "www" + p + "hhh";
String p2 = "bb;
String p3 = p + p2;
Intern方法的使用
Intern方法的作用是返回该字符串对象在常量池中的地址,如果常量池不存在就放入该对象地址并返回。拿之前的代码举例子,我把java换成了小王,因为java是JVM经过特殊优化的字符串,JVM会预先加载一些常用的字符串,相当于java就算我没有使用它也被预先加载到常量池里面了,所以我用这个举例会体现不出来intern的作用和效果:
public void f3(){
char[] arr = {'小','王'};
String d = new String(arr);
d.intern();
String e = "小王";
System.out.println(d == e); // true
}
之前说这段代码的结果会是false,但是加入intern之后就会变为true。因为之前说过String d = new String(arr);并不会在常量池中放入该对象,但是一旦调用intern方法,他就会查找常量池中是否有java对象,如果有就返回,没有就放入。所以d.intern()就会把d指向的那个对象放入常量池中,此时执行e = "小王"时,常量池就能在池中找到相应的对象返回。这时d和e就指向同一个对象了。
再举一个常量池中本来就存在该字符串的例子:
public void f1(){
String a = "hello";
String b = "hello";
String c = new String("hello");
System.out.println(a == b); // true
System.out.println(a == c); // false
System.out.println(b == c); // false
System.out.println(a == c.intern()); // true
}
因为a == c.intern()等于true,就是因为c.intern()返回的是在常量池的对象,自然和a指向的是同一个了,所以为true。
常量池中的存储结构
之前为了方便理解,所以简化了常量池的结构。其实,常量池的结构比简化的版本要复杂一些。它的底层实现是一个哈希表,这个哈希表本质上是一个数组,每个数组元素充当头节点(Node)。
常量池的结构
- 数组:常量池的哈希表以一个数组为基础,数组的每个元素对应一个索引位置。
- Node 节点:数组中的每个元素是一个头节点(Node),Node 中保存了以下内容:
- String 对象的引用:指向实际存储在池中的字符串对象。
- 字符串的哈希值:通过字符串内容计算得到,用于快速定位。
- 下一个节点的引用(next):如果出现哈希冲突,形成链表结构,next 指向链表的下一个节点。
常量池中索引位置的三种情况
- 为空:对应的索引位置没有任何节点。
- 只有一个头节点:该位置只有一个字符串节点,没有哈希冲突。
- 链表结构:出现哈希冲突时,多个节点通过 next 指针形成链表。
字符串保存到常量池的过程
- 计算哈希值:根据字符串内容计算哈希值,确定映射到哈希表中的索引位置。
- 查找节点:检查对应索引位置是否存在相同内容的节点:
- 如果已存在,则直接返回该节点的字符串引用。
- 如果不存在,则创建一个新的节点,并将其添加到该索引位置。
- 处理冲突:如果该索引位置存在链表,则沿着链表逐一比较节点内容,确保唯一性。
总结
字符串常量池通过哈希表的结构高效管理字符串,避免重复存储相同内容。它的设计结合了数组和链表的优势,实现了高效的字符串查找与插入。同时,利用哈希值定位索引和链表处理冲突的方式,保证了性能和内存使用的平衡。