基础不牢,地动山摇
String 是Java开发中最常见的类型之一,大家对它的第一反应可能是 “这不就是一个字符串嘛,好像还是个不可变的类型”,没错这个人就是我。因此在此对 String 相关知识进行一个总结,搞清楚 String 中的底层实现,以及不同的实现方式的存储方式。下面是在总结过程中参考的优秀资料(感谢分享):
截取 JDK8
中的部分源码能对其全貌了解一二了:
public final class String
implements java.io.Serializable, Comparable<String>, CharSequence {
/** The value is used for character storage. */
private final char value[];
}
从上面的源码中我们可以看出,相对于普通类多出了下面特别的部分:
length/charAt/subSequence/toString
,在 JDK8
之后,CharSequence 接口默认实现了 chars()/codePoints()
方法:返回 String 对象的输入流。另外,JDK9
与 JDK8
的类声明比较也有差异,下面是 JDK9
的类描述源码部分:
public final class String
implements java.io.Serializable, Comparable<String>, CharSequence {
/** The value is used for character storage. */
@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;
static {
COMPACT_STRINGS = true;
}
}
对比两个版本我们可以得出以下结论:
JDK8
中:String 底层最终使用字符数组 char[]
来存储字符值;但在 JDK9
之后,JDK
维护者将其改为了 byte[]
数组作为底层存储。究其原因是 JDK
开发人员调研了成千上万的应用程序的 heap dump 信息,然后得出结论:大部分的 String 都是以 Latin-1 字符编码来表示的,只需要一个字节存储就够了,两个字节完全是浪费;JDK9
之后,String 类多了一个成员变量 coder,它代表编码的格式,目前 String 支持两种编码格式 LATIN1 和 UTF16。LATIN1 需要用一个字节来存储。而 UTF16 需要使用 2 个字节或者 4 个字节来存储。在Java中,final关键字用于表示一个最终的、不可变的值或引用。当我们将一个变量声明为final时,它就成为了一个常量,一旦被初始化后就不能再改变它的值。
在String中,使用final关键字有以下几个好处:
在Java中,String类拥有许多不同的构造器,通过IDEA查看我们也可看到:
这里贴出几个常用的构造方法,介绍为JDK注解中的翻译:
空参:初始化新创建的 String 对象,使其表示空字符序列。 请注意,使用此构造函数是不必要的,因为字符串是不可变的。
/**
* Initializes a newly created {@code String} object so that it represents
* an empty character sequence. Note that use of this constructor is
* unnecessary since Strings are immutable.
*/
public String() {
this.value = "".value;
}
字符串参数:初始化一个新创建的 String 对象,使其表示与参数相同的字符序列;换句话说,新创建的字符串是参数字符串的副本。除非需要原始的显式副本,否则不需要使用此构造函数,因为字符串是不可变的。
/**
* Initializes a newly created {@code String} object so that it represents
* the same sequence of characters as the argument; in other words, the
* newly created string is a copy of the argument string. Unless an
* explicit copy of {@code original} is needed, use of this constructor is
* unnecessary since Strings are immutable.
*
* @param original
* A {@code String}
*/
public String(String original) {
this.value = original.value;
this.hash = original.hash;
}
字符数组参数:分配一个新的 String,使其表示字符数组参数中当前包含的字符序列。复制字符数组的内容;字符数组的后续修改不会影响新创建的字符串。
/**
* Allocates a new {@code String} so that it represents the sequence of
* characters currently contained in the character array argument. The
* contents of the character array are copied; subsequent modification of
* the character array does not affect the newly created string.
*
* @param value
* The initial value of the string
*/
public String(char value[]) {
this.value = Arrays.copyOf(value, value.length);
}
/**
* "+" 和 "+=" 是Java重载过的操作符,编译器会自动优化引用StringBuilder,更高效
*/
public class Concatenation {
public static void main(String[] args) {
String mango = "mango";
String s = "abc" + mango + "def" + 47;
System.out.print(s);
}
}
通过反编译查看字节码文件:
得出结论:在 Java 文件中,进行字符串拼接时,编译器会帮我们进行一次优化:new 一个 StringBuilder
,再调用 append 方法对之后拼接的字符串进行连接。低版本的 Java 编译器,是通过不断创建 StringBuilder
来实现新的字符串拼接。
实际上:
JDK5
开始就已经完成了优化,并且没有进行新的优化;StringBuilder
,再调用 append 方法;这样的弊端是多次循环之后,产生大量的失效对象(即使 GC
会回收);StringBuilder
对象,在循环内进行手动 append。这样不论外面循环多少层,编译器优化之后都只有一个 StringBuilder
对象。我们知道字符串的分配和其他对象分配一样,是需要消耗高昂的时间和空间的,而且字符串我们使用的非常多。
JVM为了提高性能和减少内存的开销,在实例化字符串的时候进行了一些优化:使用字符串常量池。每当我们创建字符串常量时,JVM会首先检查字符串常量池:
由于String字符串的不可变性我们可以十分肯定常量池中一定不存在两个相同的字符串。
在后续介绍常量池以及String底层时,这两个概念都会频繁出现,因此在这里统一对其进行一个介绍。
在Java中,字符串字面量指的是直接在代码中以双引号括起来的文本,在编译期间就被确定下来的值。例如:
String str1 = "Hello World";
String str2 = "Hello World";
在上面的例子中,“Hello World” 就是字符串字面量。
而对于符号引用,它是在运行时才会被解析成实际的对象或方法的引用。在Java中使用字符串作为类名、方法名或字段名时,都属于符号引用。例如:
String className = "com.example.MyClass"; // 符号引用:类名
Class<?> clazz = Class.forName(className); // 解析符号引用成为实际的类对象
String methodName = "myMethod"; // 符号引用:方法名
Method method = clazz.getMethod(methodName, String.class); // 解析符号引用成为实际的方法对象
String fieldName = "myField"; // 符号引用:字段名
Field field = clazz.getField(fieldName); // 解析符号引用成为实际的字段对象
在上面的例子中,变量 className
、methodName
和 fieldName
都是字符串,但它们并不表示实际的对象或方法,而是属于符号引用。在运行时,通过反射等机制,将这些符号引用解析成为实际的对象、方法或字段。
简单理解可以认为:
- 字面量就是我们在代码中常用于定义字符串的文本,如上面用双引号引起来的
"HelloWorld"
;- 符号引用就是一个类中包括其本身、类中属性、类中方法在JVM中存放的可解戏成为时机对象的一串字符。
又名字符串常量池(String Pool),处于JVM的方法区中,用于存放由编译器在编译期间确定的字符串字面量,存的是引用值而不是具体的实例对象,具体的实例对象是在堆中开辟的一块空间存放的。
在 HotSpot VM
里实现的 String Pool 功能的是一个 StringTable
类,它是一个哈希表,里面存的是驻留字符串(也就是我们常说的用双引号括起来,如"Java")的引用,也就是说在堆中的某些字符串实例被这个 StringTable
引用之后,就等同被赋予了”驻留字符串”的身份。又因为在JVM中只存在一份,因此字符串常量池是被全局共享的。
字符串常量池的作用主要是为了提高匹配速度,也就是为了更快地查找某个字符串是否在常量池中,Java 在设计常量池的时候,还搞了张 StringTable
,这个有点像我们的 HashTable
,根据字符串的 HashCode
定位到对应的桶,然后遍历数组查找该字符串对应的引用。如果找得到字符串,则返回引用,找不到则会把字符串常量放到常量池中,并把引用保存到 StringTable
了里面。
class文件常量池并不是处于JVM中的,是Class文件结构中的一部分,处于本地的。主要用于存放编译器生成的各种字面量(Literal)和符号引用(Symbolic References)。在编译期间,将类、方法、字段等符号引用信息存储到class文件常量池中。在程序运行期间,JVM通过该常量池解析符号引用,生成实际的对象、方法或字段。
注意区分 class文件常量池 和 字符串常量池 两者,前者是存放了类相关的符号引用,以及在程序运行期间将其中的符号引用进行解析,生成实际的对象、方法或字段,而后者也就是 字符串常量池,存放的是编译期间确定的字符串字面量。
运行时常量池(Runtime Constant Pool),其存放位置与 JVM
版本有关,在 JVM1.6
内存模型中位于方法区,JVM1.7
内存模型中位于堆,在 JVM1.8
内存模型中位于元空间(本地内存)。在程序运行期间,JVM
将从class文件常量池中获取的常量复制一份到运行时常量池中,并且为运行时生成的各种字面量和符号引用分配空间。
与字符串常量池不同的是,运行时常量池可以存储各种类型的常量,包括基本类型、字符串、类和接口的符号引用等。
通过字面量直接赋值的方式能在编译期间对 a
变量来说是可以确定具体值的,因此编译期间就会将字面量 "hello"
添加到常量池中,并且使变量 a
指向常量池中的 "hello"
。
String a = "hello";
通过字符串构造函数,且构造函数中放的是一个字面量而不是一个变量,因此在编译期间 a
是可以确定具体值的,因此会将字面量 "hello"
添加到常量池中(如果常量池中没有的话),同时这里是通过 new
关键字创建的对象,因此会在堆中开辟一个空间来存放字符串 a
,堆中指向的是常量池中的 "hello"
,并使变量 a
指向堆中 new
出来的地址。这里提多一嘴,使用这种构造函数创建字符串时,每次创建都会在堆中生成一个新的字符串对象,相对于字面量直接赋值,同样会造成多余的空间浪费。
String a = new String("hello");
这里用字符数组来举例,通过字符数组构造函数来创建字符串时,编译期间 a
是无法确定具体值的,因此常量池中并不会存放 "hello"
的引用,同时因为是通过 new
关键字创建的,因此变量 a
指向的是堆中的地址。这里提多一嘴,使用这种构造函数创建字符串时,每次创建都会在堆中生成一个新的字符数组对象,相对于字面量直接赋值,同样会造成多余的空间浪费。
String a = new String(new char[]{'h', 'e', 'l', 'l', 'o'});
对于字面量拼接在编译期间JDK就会对其进行优化成 "hello"
,等同于第一种字面量直接赋值。
String a = "hel" + "lo";
对于这种情况,如果常量池中既没有 hel
,也没有 lo
,那么将会创建5个对象,其中4个String
对象,1个StringBuilder
对象。具体如下:
StringBuilder
对象**(堆中)**hel
(常量池中)StringBuilder
对象调用append方法拼接hel
String
对象**(堆中)**lo
(常量池中),并使用常量池中的lo
构建第4步的String对象;StringBuilder
对象调用append方法拼接StringBuilder
对象调用toString
方法转成String,实际上StringBuilder.toString()
方法实际上调用的是 public String(char value[], int offset, int count)
构造方法,即使用 char数组
又创建了一个String对象**(堆中)**。总的来说,一共5个对象,其中String
对象有4个,常量池和堆中各有2个,StringBuilder
对象存放在堆中。
String a = "hel" + new String("lo");
String 类的 intern()
方法跟 JVM 内存模型设计息息相关:
值得注意的是
JDK6
中,常量池和堆是物理隔离的,常量池在永久代分配内存,永久代和 Java 堆的内存是物理隔离的。此处的 intern() ,是将在堆上对象存的内容"abc"拷贝到常量池中;JDK7及之后
,常量池和堆已经不是物理分割了,字符串常量池已经被转移到了 java Heap 中了。此处的 intern() 则是将在堆上的地址引用拷贝到常量池里。通过下面代码可以进行测试:
String x =new String("def");
String y = x.intern();
System.out.println(x == y);
String a =new String(new char[]{'a','b','c'});
String b = a.intern();
System.out.println(a == b);
在JDK6时,输出的是false和false,第一个false是因为使用的字面量构造器,在x
实例化的时候就已经将def
放进常量池中了,所以y
指向的是常量池中创建的字符串引用。第二个false是因为在JDK6中调用intern方法将在堆上对象存的内容"abc"拷贝到常量池中,所以b
是接收的是常量 abc
在常量池中创建的地址。
在JDK7及之后,输出的是false和true,第一个false和上面的原因是一样的,第二个为 true
是因为调用 intern
方法时,是将堆中的地址引用拷贝到常量池中的,所以两个的地址都是指向堆中的地址,输出的是true。