0%

Java_String

String常量池#

JVM 相关知识#

下面这张图是 JVM 的体系结构图:

下面我们了解下 Java 栈、Java 堆、方法区和常量池:

Java 栈(线程私有数据区)#

每个 Java 虚拟机线程都有自己的 Java 虚拟机栈,Java 虚拟机栈用来存放栈帧,每个方法被执行的时候都会同时创建一个栈帧(Stack Frame)用于存储局部变量表、操作栈、动态链接、方法出口等信息。每一个方法被调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。

Java 堆(线程共享数据区)#

在虚拟机启动时创建,此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配。

方法区(线程共享数据区)#

方法区在虚拟机启动的时候被创建,它存储了每一个类的结构信息,例如运行时常量池、字段和方法数据、构造函数和普通方法的字节码内容、还包括在类、实例、接口初始化时用到的特殊方法。在 JDK8 之前永久代是方法区的一种实现,而 JDK8 元空间替代了永久代,永久代被移除,也可以理解为元空间是方法区的一种实现。

常量池(线程共享数据区)#

常量池常被分为两大类:静态常量池和运行时常量池。

静态常量池也就是 Class 文件中的常量池,存在于 Class 文件中。

运行时常量池(Runtime Constant Pool)是方法区的一部分,存放一些运行时常量数据。

字符串常量池(重点)#

字符串常量池存在运行时常量池之中(在 JDK7 之前存在运行时常量池之中,在 JDK7 已经将其转移到堆中)。

字符串常量池的存在使 JVM 提高了性能和减少了内存开销。

常量池中的常量#

使用字符串常量池,每当我们使用字面量(String s=”1”)创建字符串常量时,JVM 会首先检查字符串常量池,如果该字符串已经存在常量池中,那么就将此字符串对象的地址赋值给引用 s(引用 s 在 Java 栈中)。如果字符串不存在常量池中,就会实例化该字符串并且将其放到常量池中,并将此字符串对象的地址赋值给引用 s(引用 s 在 Java 栈中)。

使用字符串常量池,每当我们使用关键字 new(String s=new String”1”)创建字符串常量时,JVM 会首先检查字符串常量池,如果该字符串已经存在常量池中,那么不再在字符串常量池创建该字符串对象,而直接堆中复制该对象的副本,然后将堆中对象的地址赋值给引用 s,如果字符串不存在常量池中,就会实例化该字符串并且将其放到常量池中,然后在堆中复制该对象的副本,然后将堆中对象的地址赋值给引用 s。

API 说明#

翻译为:“初始化一个新创建的字符串对象,以便它表示与参数相同的字符序列;换句话说,新创建的字符串是参数字符串的副本。除非需要显式的原始副本,否则使用此构造函数是不必要的,因为字符串是不可变的。”

由于 String 字符串的不可变性我们可以十分肯定常量池中一定不存在两个相同的字符串。

鉴于 String.intern() 在 API 上的说明和 new String(“a”)创建字符串(创建了两个对象,如果字符串常量池存在则是一个对象)在官方 API 上的说明,我个人认为字符串常量池存的是字符串对象,当然在 JKD7 之后,常量池中存储的可能是堆对象的引用,后面会讲到。(可用 javap -c 反编译即可得到 JVM 执行的字节码内容,javap -verbose 反编译查看常量池内容)。

String 源码分析#

下面是 String 类的部分源码:

1
2
3
4
5
6
7
8
9
10
11
12
13

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;
}

首先我们来看看 String 类,String 类是用 final 修饰的,这意味着 String 不能被继承,而且所有的成员方法都默认为 final 方法。

接下来看看 String 类实现的接口:

java.io.Serializable:这个序列化接口仅用于标识序列化的语意。

Comparable:这个 compareTo(T 0) 接口用于对两个实例化对象比较大小。

CharSequence:这个接口是一个只读的字符序列。包括 length(), charAt(int index), subSequence(int start, int end) 这几个 API 接口,值得一提的是,StringBuffer 和 StringBuild 也是实现了改接口。

最后看看 String 的成员属性:

value[]: char 数组用于储存 String 的内容。
hash:String 实例化的 hashcode 的一个缓存,String 的哈希码被频繁使用,将其缓存起来,每次使用就没必要再次去计算,这也是一种性能优化的手段。这也是 String 被设计为不可变的原因之一,后面会讲到。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public String substring(int beginIndex, int endIndex) {
if (beginIndex < 0) {
throw new StringIndexOutOfBoundsException(beginIndex);
}
if (endIndex > value.length) {
throw new StringIndexOutOfBoundsException(endIndex);
}
int subLen = endIndex - beginIndex;
if (subLen < 0) {
throw new StringIndexOutOfBoundsException(subLen);
}
return ((beginIndex == 0) && (endIndex == value.length)) ? this
: new String(value, beginIndex, subLen);
}

可以发现,最初传入的 String 并没有改变,其返回的是一个 new String(),即新创建的 String 对象。其实 String 类的其他方法也是如此,并不会改变原字符串。这也是 String 的不可变性,后面会讲到。

Srtring 在 JVM 层解析#

创建字符串形式#

首先形如声明为 S ss 是一个类 S 的引用变量 ss(我们常常称之为句柄,后面 JVM 相关内容会讲到),而对象一般通过 new 创建。所以这里的 ss 仅仅是引用变量,并不是对象。

创建字符串的两种基本形式

1. String s1=”1”;
2. String s2=new String(“1”);

从图中可以看出,s1 使用”” 引号(也是平时所说的字面量)创建字符串,在编译期的时候就对常量池进行判断是否存在该字符串,如果存在则不创建直接返回对象的引用;如果不存在,则先在常量池中创建该字符串实例再返回实例的引用给 s1。注意:编译期的常量池是静态常量池。

再来看看 s2,s2 使用关键词 new 创建字符串,JVM 会首先检查字符串常量池,如果该字符串已经存在常量池中,那么不再在字符串常量池创建该字符串对象,而直接堆中复制该对象的副本,然后将堆中对象的地址赋值给引用 s2,如果字符串不存在常量池中,就会实例化该字符串并且将其放到常量池中,然后在堆中复制该对象的副本,然后将堆中对象的地址赋值给引用 s2。注意:此时是运行期,那么字符串常量池是在运行时常量池中的.

“+” 连接形式创建字符串(更多可以查看 API)#

1. String s1=”1”+”2”+”3”;

使用包含常量的字符串连接创建是也是常量,编译期就能确定了,直接入字符串常量池,当然同样需要判断是否已经存在该字符串。

2. String s2=”1”+”3”+new String(“1”)+”4”;

当使用 “+” 连接字符串中含有变量时,也是在运行期才能确定的。首先连接操作最开始时如果都是字符串常量,编译后将尽可能多的字符串常量连接在一起,形成新的字符串常量参与后续的连接(可通过反编译工具 jd-gui 进行查看)。

接下来的字符串连接是从左向右依次进行,对于不同的字符串,首先以最左边的字符串为参数创建 StringBuilder 对象(可变字符串对象),然后依次对右边进行 append 操作,最后将 StringBuilder 对象通过 toString() 方法转换成 String 对象(注意:中间的多个字符串常量不会自动拼接)。

实际上的实现过程为:

1
2
3
String s2=new StringBuilder(“13”)
.append(new String(“1”))
.append(“4”).toString();
    当使用 + 进行多个字符串连接时,实际上是产生了一个 StringBuilder 对象和一个 String 对象。

3. String s3=new String(“1”)+new String(“1”);

这个过程跟(2)类似

String.intern() 解析#

String.intern() 是一个 Native 方法,底层调用 C++ 的 StringTable::intern 方法,源码注释:当调用 intern 方法时,如果常量池中已经该字符串,则返回池中的字符串;否则将此字符串添加到常量池中,并返回字符串的引用。

下面我们来看个案例:

1
2
3
4
5
6
public class StringTest {
public static void main(String[] args) {
String s3 = new String("1") + new String("1");
System.out.println(s3 == s3.intern());
}
}

JDK6 的执行结果为:false
JDK7 和 JDK8 的执行结果为:true

JDK6 的内存模型如下:

我们都知道 JDK6 中的常量池是放在永久代的,永久代和 Java 堆是两个完全分开的区域。而存在变量使用 “+” 连接而来的的对象存在 Java 堆中,且并未将对象存于常量池中,当调用 intern 方法时,如果常量池中已经该字符串,则返回池中的字符串;否则将此字符串添加到常量池中,并返回字符串的引用。所以结果为 false。

JDK7、DK8 的内存模型如下:

JDK7 中,字符串常量池已经被转移至 Java 堆中,开发人员也对 intern 方法做了一些修改。因为字符串常量池和 new 的对象都存于 Java 堆中,为了优化性能和减少内存开销,当调用 intern 方法时,如果常量池中已经存在该字符串,则返回池中字符串;否则直接存储堆中的引用,也就是字符串常量池中存储的是指向堆里的对象。所以结果为 true。

String 典型案例#

equals 和 ==#

(1)对于 ==,如果作用于基本数据类型的变量(byte,short,char,int,long,float,double,boolean ),则直接比较其存储的 “值” 是否相等;如果作用于引用类型的变量(String),则比较的是所指向的对象的地址(即是否指向同一个对象)。

(2)equals 方法是基类 Object 中的方法,因此对于所有的继承于 Object 的类都会有该方法。在 Object 类中,equals 方法是用来比较两个对象的引用是否相等。

(3)对于 equals 方法,注意:equals 方法不能作用于基本数据类型的变量。如果没有对 equals 方法进行重写,则比较的是引用类型的变量所指向的对象的地址;而 String 类对 equals 方法进行了重写,用来比较指向的字符串对象所存储的字符串是否相等。其他的一些类诸如 Double,Date,Integer 等,都对 equals 方法进行了重写用来比较指向的对象所存储的内容是否相等。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
public class StringTest {
public static void main(String[] args) {
/* 情景一:字符串池
* JAVA虚拟机(JVM)中存在着一个字符串池,其中保存着很多String对象;
* 并且可以被共享使用,因此它提高了效率。
* 由于String类是final的,它的值一经创建就不可改变。
* 字符串池由String类维护,我们可以调用intern()方法来访问字符串池。
*/
String s1 = "abc"; //↑ 在字符串池创建了一个对象
String s2 = "abc"; //↑ 字符串pool已经存在对象“abc”(共享),所以创建0个对象,累计创建一个对象
System.out.println("s1 == s2 : "+(s1==s2)); //↑ true 指向同一个对象,
System.out.println("s1.equals(s2) : " + (s1.equals(s2))); //↑ true值相等

/* 情景二:关于new String("")*/
String s3 = new String("abc");
String s4 = new String("abc");
System.out.println("s3 == s4 : "+(s3==s4)); //false
System.out.println("s3.equals(s4) : "+(s3.equals(s4))); //true
System.out.println("s1 == s3 : "+(s1==s3)); // false
System.out.println("s1.equals(s3) : "+(s1.equals(s3))); // true


/** * 情景三: * 由于常量的值在编译的时候就被确定(优化)了。
// * 在这里,"ab"和"cd"都是常量,因此变量str3的值在编译时就可以确定。
// * 这行代码编译后的效果等同于: String str3 = "abcd"; */
String str1 = "ab" + "cd"; //1个对象
String str11 = "abcd";
System.out.println("str1 = str11 : "+ (str1 == str11));

/** 情景四:
* 局部变量str2,str3存储的是存储两个拘留字符串对象(intern字符串对象)的地址。
* 第三行代码原理(str2+str3):运行期JVM首先会在堆中创建一个StringBuilder类,
* 同时用str2指向的拘留字符串对象完成初始化,然后调用append方法完成对str3所指向的拘留字符串的合并,
* 接着调用StringBuilder的toString()方法在堆中创建一个String对象,
* 最后将刚生成的String对象的堆地址存放在局部变量str4中。
* 而str5存储的是字符串池中"abcd"所对应的拘留字符串对象的地址。
* str4与str5地址当然不一样了。 *
* 内存中实际上有五个字符串对象: *
三个拘留字符串对象、一个String对象和一个StringBuilder对象。
*/

String str2 = "ab"; //1个对象
String str3 = "cd"; //1个对象
String str4 = str2+str3;
String str5 = "abcd";
System.out.println("str4 = str5 : " + (str4==str5)); // false


/* 情景五:
JAVA编译器对string + 基本类型/常量是当成常量表达式直接求值来优化的。
运行期的两个string相加,会产生新的对象的,存储在堆(heap)中
*/
String str6 = "b";
String str7 = "a" + str6;
String str67 = "ab";
System.out.println("str7 = str67 : "+ (str7 == str67));

final String str8 = "b";
String str9 = "a" + str8;
String str89 = "ab";
System.out.println("str9 = str89 : "+ (str9 == str89));

}
}

运行结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
s1 == s2 : true

s1.equals(s2) : true

s3 == s4 : false

s3.equals(s4) : true

s1 == s3 : false

s1.equals(s3) : true

str1 = str11 : true

str4 = str5 : false

str7 = str67 : false

str9 = str89 : true

String 被设计成不可变和不能被继承的原因#

String 是不可变和不能被继承的(final 修饰),这样设计的原因主要是为了设计考虑、效率和安全性。

字符串常量池的需要#

只有当字符串是不可变的,字符串池才有可能实现。字符串池的实现可以在运行时节约很多 heap 空间,因为不同的字符串变量都指向池中的同一个字符串。假若字符串对象允许改变, 那么将会导致各种逻辑错误, 比如改变一个对象会影响到另一个独立对象. 严格来说,这种常量池的思想, 是一种优化手段。

String 对象缓存 HashCode#

上面解析 String 类的源码的时候已经提到了 HashCode。Java 中的 String 对象的哈希码被频繁地使用,字符串的不可变性保证了 hash 码的唯一性。

安全性#

首先 String 被许多 Java 类用来当参数,如果字符串可变,那么会引起各种严重错误和安全漏洞。

再者 String 作为核心类,很多的内部方法的实现都是本地调用的,即调用操作系统本地 API,其和操作系统交流频繁,假如这个类被继承重写的话,难免会是操作系统造成巨大的隐患。

最后字符串的不可变性使得同一字符串实例被多个线程共享,所以保障了多线程的安全性。而且类加载器要用到字符串,不可变性提供了安全性,以便正确的类被加载。

学习参考资料:#

https://www.cnblogs.com/xiaoxi/p/6036701.html

https://tech.meituan.com/in_depth_understanding_string_intern.html