一. 面试题及剖析
1. 今日面试题
String类是我们开发时很常用的知识点,所以它也是我们面试时的一个高频提问点,既有关于String用法的面试题,也有关于String底层原理类的面试题。今天 壹哥 就带大家结合源码,来探究String的底层原理,从而可以回答如下面试题。
你有没有看过Sting的源码?
String字符串为什么不可被改变?
String的底层原理是什么?
......
2. 题目剖析
以上几道面试题,我们仔细分析一下,就会发现考察的其实都是String的底层原理,这几道题目的答案其实都差不多。有的同学只要一听到“底层”,“原理”,“源码”这种词,脑子就直接休克了,就觉得这个题目肯定很难!那么真的是这样的吗?你为什么一定会觉得底层原理很难呢?其实我们只要认真阅读一下源码,再结合平时对API的使用,这些问题并没有什么难回答的,不要把“底层原理”和“难于上青天”划等号,并不是这样的!
接下来请跟着 一一哥 来看看Spring的底层原理怎么回事吧。
二. Spring源码详解
1. final的特点
为了更好的理解String相关的内容,在学习String的内容之前,我们先来复习final这个关键词有哪些特点,因为在String中会涉及到final相关的内容。
- final关键词修饰的类不可以被其他类继承,但是该类本身可以继承其他类,通俗的说就是这个类可以有父类,但是不能有子类;
- final关键词修饰的方法不可以被覆盖重写,但是可以被继承使用;
- final关键词修饰的基本数据类型变量称为常量,只能被赋值一次;
- final关键词修饰的引用数据类型的变量值为地址值,地址值不能改变,但是地址内的数据对象可以被改变;
- final关键词修饰的成员变量,需要在创建对象前赋值,否则会报错(即需要在定义时直接赋值,如果是在构造方法中赋值,则多个构造方法均需赋值)。
2. String类源码解读
为了更好的回答今天的面试题,壹哥 先来给各位详细的讲解一下String的源码,从源码中我们就可以明白很多底层的设计原理,请跟着 壹哥 一点点学习这些核心源码吧。
2.1 final修饰的String类
public final class String
implements java.io.Serializable, Comparable, CharSequence {
......
壹哥 先对上面的源码及其注释进行简单的解释:
- final:请参考第1小节对final特点的介绍;
- Serializable:用于序列化;
- Comparable
:默认的比较器; - CharSequence: 提供对字符序列进行统一、只读的操作。
从这一段源码及注释中,我们可以得出如下结论:
- String类用final关键字修饰,说明String不可被继承;
- String字符串是常量,字符串的值一旦被创建,就不能被改变;
- String字符串缓冲区支持可变字符串;
- 因为String对象是不可变的,所以它们是可以被共享的。
2.2 final修饰的value[]属性
public final class String
implements java.io.Serializable, Comparable, CharSequence {
private final char value[];
......
从源码中可以看出,value[]是一个私有的字符数组,String类其实就是通过这个char数组来保存字符串内容的。简单的说,我们定义的字符串会被拆成一个一个的字符,这些字符都被存放在这个value字符数组里面。
这里的value[]数组被final修饰,初始化之后就不能再被更改。但是大家注意,我们这里说的value[]不可变,指的是value的引用地址不可变,但是value数组里面的数据元素其实是可变的!这是因为value是数组类型,根据我们之前学过的知识,value的引用地址会分配在栈中 ,而其对应的数据是在常量池中保存的。所以我们说String不可变,指的就是value在栈中的引用地址不可变,而不是说常量池中数组本身的数据元素不可变。
另外我们要注意,Java中的字符串常量池,用来存储字符串字面量! 但是由于JDK版本的不同,常量池的位置也不同:
JDK 6 及以下版本的字符串常量池是在方法区(Perm Gen)中,此时常量池中存储的是字符串对象;在 JDK 8.0 中,方法区(永久代被元空间取代了;
JDK 7、8以后的字符串常量池被转移到了堆中,此时常量池存储的就是字符串对象的引用,而不是字符串对象本身。
2.3 hash属性与hashCode()方法
public final class String
implements java.io.Serializable, Comparable, CharSequence {
private final char value[];
private int hash; // Default to 0
......
String中还有一个hash属性,它的值是一个32位的整型数值,默认值为0。hash值的具体计算是利用hashCode()方法来实现的,我们来看看hashCode()方法具体是怎么实现的。
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;
}
从上面hashCode()方法的源码中可以知道,String类会利用hashCode()方法对hash重新赋值,这个hash值是利用如下公式来实现:
s[0]*31^(n-1) + s[1]*31^(n-2) + ... + s[n-1]
hashCode()方法工作原理(重点):
这个hashCode()方法实际上只会执行一次计算,属于惰性计算,计算后会把结果缓存到内部的私有变量 int hash中,等到再次调用hashCode()方法时,则直接返回之前计算好的hash值。因为String类被设计成了不可变的,这样hashCode()因为只会执行一次,所以每个String对象的hash值都被缓存了起来且始终不变。这就意味着,我们不需要在每次使用String的时候都去计算它的HashCode,这样做效率会比较高。比如在HashMap中经常以Srting作为key,当我们需要频繁地读取访问任意键值对时,就能够节省很多CPU的计算开销。
3. String的重要构造方法
String类中有16个构造方法,如下图所示:
我把这些构造方法分为几类,这几种不同的构造方法,对String对象的创建过程也是不同的。
3.1 字符串参数的构造方法
public String(String original) {
this.value = original.value;
this.hash = original.hash;
}
这个构造方法会创建一个与源String内容一样的新的字符串对象。这种构建方式是直接将源String字符串的value和hash两个属性,赋值给目标String,所以源String与目标String的内容完全一样,从而达到了字符串复制的目标。由于String不可变,所以不用担心源String的值会影响目标String的值,但是我们开发时除非特殊情况,一般没必要使用该方法创建新的String对象。
3.2 字符数组参数的构造方法
public String(char value[]) {
this.value = Arrays.copyOf(value, value.length);
}
该方法使用外部的字符数组初始化出一个新的String对象,本质上是把外部char数组的内容复制到了新的String中,但是外部字符数组的序列号与新String对象的hash序列化并不一样,所以外部数组内容的改变不会影响到新的String对象。这里有点难以理解,我通过下面这个案例给大家展示一下效果:
这个案例中,我们可以看到,arr的第3个元素值发生了改变,但是str的内容并没有发生改变,因为str这个字符串和arr属于两个完全不同的部分,在str对象构建完毕后,arr的修改并不会影响到str。
3.3 StringBuffer和StringBuider参数的构造方法
public String(StringBuffer buffer) {
synchronized(buffer) {
this.value = Arrays.copyOf(buffer.getValue(), buffer.length());
}
}
public String(StringBuilder builder) {
this.value = Arrays.copyOf(builder.getValue(), builder.length());
}
这两个构造方法用的很少,一般使用StringBuider的toString()方法即可得到一个String对象,效率也比StringBuffer要高,所以这两个方法了解即可。
3.4 字节数组参数的构造方法
public String(byte bytes[], int offset, int length, Charset charset) {
if (charset == null)
throw new NullPointerException("charset");
checkBounds(bytes, offset, length);
this.value = StringCoding.decode(charset, bytes, offset, length);
}
通过指定Charset编码,根据byte[]数组来构建出一个新的String对象。
3.5 特殊的内部构造方法
String(char[] value, boolean share) {
// assert share : "unshared not supported";
this.value = value;
}
这是一个私有的构造方法,主要是供String类内部调用。其中,share只能为true,因为它只是用于区分String(char[])方法而设计的参数。这种方法会直接使用引用来赋值,不需要复制数组,速度快,同时可以节省内存。但是该方法为包私有,外部不可访问,并且该方法影响了String类的不可变性,所以该方式存在“性能问题”。比如JDK 6中的substring(),会直接持有源String的字符数组,如果源String很长,而目标String很短,就会有性能问题。
4. String类中的其他成员方法
4.1 equals()方法
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;
}
用于将当前字符串与指定的字符串进行比较,如果两个字符串对比相同则返回true,否则返回false。
4.2 hashCode()方法
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;
}
该方法用于返回String字符串的hash code值,这个hash code是利用如下算法计算出来的:
s[0]*31^(n-1) + s[1]*31^(n-2) + ... + s[n-1]
4.3 valueOf()方法
String类中有9个valueOf()方法,可以将其他类型转为Sting字符串。
4.4 intern()方法(重点)
public native String intern();
从上面的源码注释中我们可以知道,intern()是由C语言实现的native底层方法,用于从String缓存池中获取一个与该字符串内容相同的字符串对象。当这个intern()方法被执行的时候,如果缓存池中已经有这个String内容,则直接从这个缓存池中获取该String内容对象;如果缓存池中没有这个String内容对象,则把这个String内容对象放到缓存池中,并返回这个字符串对象的引用。现在我们知道了intern()方法的功能,但是该方法的底层原理是什么样的呢?接下来 壹哥 给结合一段代码案例,给各位详细说一下:
//常量池与堆的关系
String str1="yiyige";
String str2=new String("yiyige");
System.out.println("str1==str2的结果==> " +(str1==str2));
String str3 = str2.intern();
System.out.println("str1==str3的结果==> " +(str1==str3));
执行结果如下图所示:
intern()方法的底层原理如下(重点):
Java专门为String类设计了一个缓存池intern pool,intern pool是在方法区中的一块特殊存储区域,当我们通过 String str="yiyige" 这样的方式来构造一个新的字符串时,String类会优先在缓存池中查找是否已经存在内容相同的String对象。如果有则直接返回该对象的地址引用,如果没有就会构造一个新的String对象,然后放进缓存池,再返回该字符串的地址引用。因此,即使我们构造一万个String str = "yiyige",但实际上得到的都是同一个地址引用,这样就避免了很多不必要的空间开销。注意:intern池不适用new String("yiyige")的构造形式!
注意:
因为字符串常量池存放位置发生了变化,String类对intern()方法也进行了一些修改:
JDK 6 版本中执行intern()方法时,首先会判断字符串常量池中是否存在该字符串字面量,如果不存在则拷贝一份字符串字面量存放到常量池中,最后返回该字符串字面量的唯一引用。如果发现字符串常量池中已经存在,则直接返回该字符串字面量的唯一引用。
JDK 7 以后执行intern()方法时,如果发现字符串常量池中不存在该字符串字面量,则不会再拷贝一份字面量,而是拷贝字面量对应堆中的一个地址引用,然后返回这个引用。
现在我们知道了,原来当一个String对象被创建时,如果发现当前String对象已经存在于String Pool中了,就会返回一个已存在的String对象引用而不会新建一个对象。比如以下代码只会在常量池中创建一个String对象。
String str1 = "yiyige"; String str2 = "yiyige";
创建过程如下图所示:
如果一个String是可变的,当改变了A引用指向的String时,可能就会导致其他的B引用得到错误的值,所以Sting就被设计为不可变的。String底层主要是使用intern缓存池将字符串缓存起来,同时允许把一个String字符串的地址赋值给多个String变量来引用,这样就可以保证多个变量安全地共享同一个对象。如果Java中的String对象可变的话,一个字符串引用的操作改变了对象的值,那么其他的变量就会受到影响。
至此,壹哥 就带各位把String类中的核心源码分析完了,接下来我们再进一步分析String不可变的原因,及其他底层原理设计。
三. String的不可变
了解了上面的这些核心源码之后,接下来 壹哥 再带各位来验证一下,看看String到底能不能变!我先给各位来一段案例代码,代码案例如下图所示。
结果s的内容变了,好像是啪啪打脸了???!!!咋回事,壹哥 不是说了String不可变吗?怎么这么快就翻车打脸了?别急,让我们好好来分析一下。
首先我们从结果上来看String s 变量的结果好像改变了,但为什么我们又说String是不可变的呢?
要想明白这个问题,我们得先弄清楚一个点,即引用和值的区别!在上面的代码中,我们先是创建了一个 "yiyige" 为内容的字符串引用s,s其实先是指向了value对象,而value对象则指向存储了 "y,i,y,i,g,e" 字符的字符数组。因为value被final修饰,所以value的值不可被更改。因此,上面代码中改变的其实是s的引用指向,而不是改变了String对象的值。换句话说,上面实例中 s的值 只是 value的引用地址,并不是String内容本身!当我们执行 s = "yyg"; 语句时,Java中会创建一个新的字面量对象 "yyg",而原来的 "yiyige" 字面量对象依然存在于内存的intern缓存池中。在Java中,因为数组也是对象, 所以value中存储的也只是一个引用,它指向一个真正的数组对象。在执行了String s = “yiyige”; 这句代码之后,真正的内存布局应该是下图这样的:
因为value是String封装的字符数组,value中的所有字符都属于String这个对象。由于value是private的,且没有提供setValue等公共方法来修改这个value值,所以在String类的外部是无法修改value值的,也就是说一旦初始化就不能被修改。此外,value变量是final的, 也就是说在String类内部,一旦这个值初始化了,value这个变量所引用的地址就不会改变了,即一直引用同一个对象。正是基于这一层,所以说String对象是不可变的对象。但其实value所引用对象的内容完全可以发生改变,我们可以利用 反射来消除String类对象的不可变特性。
所以String的不可变,指的是value在栈中的引用地址不可变,而不是说常量池中array本身的数据元素不可变!
而String对象的改变实际上是通过内存地址的“断开-连接”变化来完成的,这个过程中原字符串中的内容并没有任何的改变。String s = "yiyige"; 和 s = "yyg"; 实质上是开辟了2个内存空间,s 只是由原来指向 "yiyige" 变为指向 "yyg" 而已,而其原来的字符串内容,是没有改变的,如下图所示。
因此,我们在以后的开发中,如果要经常修改字符串的内容,请尽量少用String,因为字符串的指向“断开-连接”会大大降低性能,建议使用:StringBuilder、StringBuffer。
那么String一定不可变吗?有没有办法让String真的可变呢?我们继续往下学习!
四. String真的不可变吗?
我在前面的章节中给大家说,String的不可变,其实指的是String类中value属性在栈中的引用地址不可变,而不是说常量池中array本身的数据元素不可变!也就是说String字符串的内容其实是可变的!那怎么实现呢?利用反射就可以实现,我们通过一个案例来证明一下。
try {
String str = "yyg";
System.out.println("str=" + str + ", 唯一性hash值=" + System.identityHashCode(str));
Class stringClass = str.getClass();
//获取String类中的value属性
Field field = stringClass.getDeclaredField("value");
//设置私有成员的可访问性,进行暴力反射
field.setAccessible(true);
//获取value数组中的内容
char[] value = (char[]) field.get(str);
System.out.println("value=" + Arrays.toString(value));
value[1] = 'z';
System.out.println("str=" + str + ", 唯一性hash值=" + System.identityHashCode(str));
} catch (NoSuchFieldException | IllegalAccessException e) {
e.printStackTrace();
}
执行结果如下图所示:
我们可以看到,String字符串的字符数组可以通过反射进行修改,导致字符串的“内容”真的发生了变化!并且我们又利用底层的java.lang.System#identityHashCode()方法(不管是否重写了hashCode方法)获取了对象的唯一哈希值,该方法获取的hash值与hashCode()方法是一样的。我们可以看到两个字符串的唯一性hash值是一样的,证明字符串引用地址没有发生改变!所以在这里,我们并不是像之前那样创建了一个新的String字符串,而是真的改变了String的内容。这个代码案例进一步说明,String类的不可变指的是中value属性在栈中的引用地址不可变,而不是说常量池中array本身的数据元素不可变!也就是说String字符串的内容其实是可变的!
五. String类不可变性的优、缺点
String作为Java中使用最为广泛的一个类,之所以设计为不可变,主要是出于效率与安全性方面考虑。这种设计有优点,也有缺点,如下:
1. 不可变性的优点
- 只有当字符串是不可变的,字符串池才有可能实现。字符串池的实现可以在运行时节约很多heap空间,因为不同的字符串引用都可以指向池中的同一个字符串。但如果字符串是可变的,如果一个引用变量改变了字符串的值,那么其它指向这个值的变量内容也会跟着一起改变。
- 如果字符串是可变的,那么可能会引起很严重的安全问题。譬如,数据库的用户名、密码都是以字符串的形式传入数据库,以获得数据库的连接;或者在socket编程中,主机名和端口都是以字符串的形式传入。因为字符串是不可变的,所以它的值是不可改变的,否则黑客们可以钻到空子,改变字符串指向的对象值,造成安全漏洞。
- 因为字符串是不可变的,在物理上是绝对的线程安全,所以同一个字符串实例可以被多个线程共享。由于不可变对象不可能被修改,因此能够在多线程中被任意自由访问而不导致线程安全问题,不需要多余的同步操作。即在并发场景下,多个线程同时读一个资源,并不会引发竞态条件,只有对资源进行读写才有危险。不可变对象不能被写,所以线程安全。
- 类加载器要用到字符串,不可变性也提供了安全性,以便正确的类可以被加载。譬如你想加载java.sql.Connection类,而这个值被改成了myhacked.Connection,那么会对你的数据库造成不可知的破坏。
- 因为字符串是不可变的,所以在字符串对象创建的时候hashCode()就被执行并把执行结果缓存了,不需要重新计算。这就使得字符串很适合作为Map中的键,所以字符串的处理速度要快过其它的键对象,这就是HashMap中的键往往都使用字符串的原因,当我们需要频繁读取访问任意键值对时,能够节省很多的CPU计算开销。
- Sting的不可变性会提高执行性能和效率,基于Sting不可变,我们就可以用缓存池将String对象缓存起来,同时把一个String对象的地址赋值给多个String引用,这样可以安全保证多个变量共享同一个对象。因此,构造一万个string s = "xyz",实际上得到都是同一个字符串对象,避免了很多不必要的空间开销。
2. 不可变性的缺点
丧失了部分灵活性。我们平时使用的大部分都是可变对象,比如内容变化时,只需要利用setValue()更新一下就可以了,不需要重新创建一个对象,但是String很难做到这一点。当然,我们完全可以使用StringBuilder来弥补这个缺点。
脆弱的不可变性,String其实可以利用JNI或反射来改变其不可变性。
六. 总结
- 只有当字符串是不可变的,字符串池才有可能实现。字符串池的实现可以在运行时节约很多heap空间,因为不同的字符串引用都可以指向池中的同一个字符串。但如果字符串是可变的,如果一个引用变量改变了字符串的值,那么其它指向这个值的变量内容也会跟着一起改变。
- 如果字符串是可变的,那么可能会引起很严重的安全问题。譬如,数据库的用户名、密码都是以字符串的形式传入数据库,以获得数据库的连接;或者在socket编程中,主机名和端口都是以字符串的形式传入。因为字符串是不可变的,所以它的值是不可改变的,否则黑客们可以钻到空子,改变字符串指向的对象值,造成安全漏洞。
- 因为字符串是不可变的,在物理上是绝对的线程安全,所以同一个字符串实例可以被多个线程共享。由于不可变对象不可能被修改,因此能够在多线程中被任意自由访问而不导致线程安全问题,不需要多余的同步操作。即在并发场景下,多个线程同时读一个资源,并不会引发竞态条件,只有对资源进行读写才有危险。不可变对象不能被写,所以线程安全。
- 类加载器要用到字符串,不可变性也提供了安全性,以便正确的类可以被加载。譬如你想加载java.sql.Connection类,而这个值被改成了myhacked.Connection,那么会对你的数据库造成不可知的破坏。
- 因为字符串是不可变的,所以在字符串对象创建的时候hashCode()就被执行并把执行结果缓存了,不需要重新计算。这就使得字符串很适合作为Map中的键,所以字符串的处理速度要快过其它的键对象,这就是HashMap中的键往往都使用字符串的原因,当我们需要频繁读取访问任意键值对时,能够节省很多的CPU计算开销。
- Sting的不可变性会提高执行性能和效率,基于Sting不可变,我们就可以用缓存池将String对象缓存起来,同时把一个String对象的地址赋值给多个String引用,这样可以安全保证多个变量共享同一个对象。因此,构造一万个string s = "xyz",实际上得到都是同一个字符串对象,避免了很多不必要的空间开销。
丧失了部分灵活性。我们平时使用的大部分都是可变对象,比如内容变化时,只需要利用setValue()更新一下就可以了,不需要重新创建一个对象,但是String很难做到这一点。当然,我们完全可以使用StringBuilder来弥补这个缺点。
脆弱的不可变性,String其实可以利用JNI或反射来改变其不可变性。
六. 总结
最后我们再把上面的内容梳理总结一下。
1. String如何保证不可变?
首先将 String 类声明为 final 类,这就意味着String类是不可被继承的,防止出现程序员通过继承重写String类的某些方法使得String类出现“可变的”的情况;
然后,内部重要的字符数组value属性被private 和 final修饰,它是String的底层数组,用于存贮字符串内容。又因为数组是引用类型,所以只能限制引用不被改变,也就是说数组元素的值是可以改变的,我们在上面的案例中已经证明过了;
然后,所有修改的方法都返回新的字符串对象,保证修改时不会改变原始对象的引用;
最后,不同的字符串对象都可以指向缓存池中的同一个字符串字面量。
2. String不可变的优点
首先将 String 类声明为 final 类,这就意味着String类是不可被继承的,防止出现程序员通过继承重写String类的某些方法使得String类出现“可变的”的情况;
然后,内部重要的字符数组value属性被private 和 final修饰,它是String的底层数组,用于存贮字符串内容。又因为数组是引用类型,所以只能限制引用不被改变,也就是说数组元素的值是可以改变的,我们在上面的案例中已经证明过了;
然后,所有修改的方法都返回新的字符串对象,保证修改时不会改变原始对象的引用;
最后,不同的字符串对象都可以指向缓存池中的同一个字符串字面量。
.....
3. String的内容可以被改变
......



