带你彻底认识String

String字符串是我们日常工作中常用的一个类,在面试中也是高频考点,下面我从五个方面带你彻彻底底的把String扒干净。

1.奇怪的 nullnull

我们先看一段代码:

public class Test1 {
    
    
    private static String s1;
    private static String s2;

    public static void main(String[] args) {
    
    
        String s= s1+s2;
        System.out.println(s);
    }
}
在分析这个结果之前,我先说一下为空null的字符串的打印原理,说是分析原理其实就是看一下PrintStream类的源码,print方法在打印null前进行了处理:
public void print(String s) {
    
    
    if (s == null) {
    
    
        s = "null";
    }
    write(s);
}

因此,一个为null的字符串就可以被打印在我们的控制台上了。
那么道理就好理解了,说明s1,s2是两个null对象,需要注意这里不是字符串的"null",而是空对象。

在String进行相加时,我们的编译器会进行优化,会把这一过程转化为StringBuilder的append方法,说到这儿就不得不看一下append方法的源码:

public AbstractStringBuilder append(String str) {
    
    
    if (str == null)
        return appendNull();
 //...
}

如果append方法的参数字符串为null,那么这里会调用其父类AbstractStringBuilder的appendNull方法:

private AbstractStringBuilder appendNull() {
    
    
    int c = count;
    ensureCapacityInternal(c + 4);
    final char[] value = this.value;
    value[c++] = 'n';
    value[c++] = 'u';
    value[c++] = 'l';
    value[c++] = 'l';
    count = c;
    return this;
}

这里的value就是底层用来存储字符的char类型数组,到这里我们就可以明白了,其实StringBuilder也对null的字符串进行了特殊处理,在append的过程中如果碰到是null的字符串,那么就会以"null"的形式被添加进字符数组,这也就导致了两个为空null的字符串相加后会打印为"nullnull"。

2.改变字符串的值

如何改变一个String字符串的值,这道题可能看上去有点太简单了,像下面这样直接赋值不就可以了吗?

String s="Hydra";
s="Trunks";

在回答这道题之前,我们需要知道String是不可变的,打开String的源码在开头就可以看到:

private final char value[];

可以看到,String的本质其实是一个char类型的数组,然后我们再看两个关键字。
先看final,我们知道final在修饰引用数据类型时,就像这里的数组时,能够保证指向该数组地址的引用不能修改,但是数组本身内的值可以被修改。

没关系,我们看一个例子:

final char[] one={
    
    'a','b','c'};
char[] two={
    
    'd','e','f'};
one=two

可以看到:编译器是会报错提示Cannot assign a value to final variable ‘one’,说明被final修饰的数组的引用地址是不可改变的。但是下面这段代码却能够正常的运行:

final char[] one={
    
    'a','b','c'};
one[1]='z';

也就是说,即使被final修饰,但是我直接操作数组里的元素还是可以的,所以这里还加了另一个关键字private,防止从外部进行修改。此外,String类本身也被添加了final关键字修饰,防止被继承后对属性进行修改。

到这里,我们就可以理解为什么String是不可变的了,那么在上面的代码进行二次赋值的过程中,发生了什么呢?

答案很简单,前面的变量s只是一个String对象的引用,这里的重新赋值时将变量s指向了新的对象。

其实是我们可以通过比较hashCode的方式来看一下引用指向的对象是否发生了改变,修改一下上面的代码,打印字符串的hashCode:

    public static void main(String[] args) {
    
    
        String s = "NineSun";
        System.out.println(s + ":  " + s.hashCode());
        s = "CSDN";
        System.out.println(s + ": " + s.hashCode());
    }

查看结果,发生了改变,证明指向的对象发生了改变。
那么,回到上面的问题,如果我想要改变一个String的值,而又不想把它重新指向其他对象的话,应该怎么办呢?

答案是利用反射修改char数组的值:

    public static void main(String[] args) throws NoSuchFieldException, IllegalAccessException {
    
    
        String s = "NineSun";
        System.out.println(s + ":  " + s.hashCode());
        Field field = String.class.getDeclaredField("value");
        field.setAccessible(true);
        field.set(s, new char[]{
    
    'C', 'S', 'D', 'N'});
        System.out.println(s + ": " + s.hashCode());
    }

其实我们这里看到的是jdk8中String的源码,到这为止还是使用的char类型数组来存储字符,但是在jdk9中这个char数组已经被替换成了byte数组,能够使String对象占用的内存减少。

3.创建了几个对象?

相信不少小伙伴在面试中都遇到过这道经典面试题,下面这段代码中到底创建了几个对象?

String s = new String("NineSun");

其实真正想要回答好这个问题,要铺垫的知识点还真是不少。首先,我们需要了解3个关于常量池的概念,下面还是基于jdk8版本进行说明:

  • class文件常量池:在class文件中保存了一份常量池(Constant Pool),主要存储编译时确定的数据,包括代码中的字面量(literal)和符号引用;
  • 运行时常量池:位于方法区中,全局共享,class文件常量池中的内容会在类加载后存放到方法区的运行时常量池中。除此之外,在运行期间可以将新的变量放入运行时常量池中,相对class文件常量池而言运行时常量池更具备动态性;
  • 字符串常量池:位于堆中,全局共享,这里可以先粗略的认为它存储的是String对象的直接引用,而不是直接存放的对象,具体的实例对象是在堆中存放。

可以用一张图来描述它们各自所处的位置:

接下来,我们来细说一下字符串常量池的结构,其实在Hotspot JVM中,字符串常量池StringTable的本质是一张HashTable,那么当我们说将一个字符串放入字符串常量池的时候,实际上放进去的是什么呢?

在了解这个问题之前,我先普及一下String创建的两种方式:

  • 字面量方式: String str1 = “ABCD”;
  • 通过构造器构造:String str = new String(“ABCD”);

以字面量的方式创建String对象为例,字符串常量池以及堆栈的结构如下图所示(忽略了jvm中的各种OopDesc实例):

实际上字符串常量池HashTable采用的是数组加链表的结构,链表中的节点是一个个的HashTableEntry,而HashTableEntry中的value则存储了堆上String对象的引用。

那么,下一个问题来了,这个字符串对象的引用是什么时候被放到字符串常量池中的?具体可为两种情况:

  • 使用字面量声明String对象时,也就是被双引号包围的字符串,在堆上创建对象,并驻留到字符串常量池中(注意这个用词);
  • 调用intern()方法,当字符串常量池没有相等的字符串时,会保存该字符串的引用

注意!我们在上面用到了一个词驻留,这里对它进行一下规范。当我们说驻留一个字符串到字符串常量池时,指的是创建HashTableEntry,再使它的value指向堆上的String实例,并把HashTableEntry放入字符串常量池,而不是直接把String对象放入字符串常量池中。简单来说,可以理解为将String对象的引用保存在字符串常量池中。

在类加载阶段,JVM会在堆中创建对应这些class文件常量池中的字符串对象实例,并在字符串常量池中驻留其引用。

这一过程具体是在resolve阶段(个人理解就是resolution解析阶段)执行,但是并不是立即就创建对象并驻留了引用,因为在JVM规范里指明了resolve阶段可以是lazy的。CONSTANT_String会在第一次引用该项的ldc指令被第一次执行到的时候才会resolve。

就HotSpot VM的实现来说,加载类时字符串字面量会进入到运行时常量池,不会进入全局的字符串常量池,即在StringTable中并没有相应的引用,在堆中也没有对应的对象产生。

在弄清楚上面几个概念后,我们再回过头来,先看看用字面量声明String的方式,代码如下:

public static void main(String[] args) {
    
    
    String s = "NineSun";
}

对其反编译生成的字节码文件:

public static void main(java.lang.String[]);
  descriptor: ([Ljava/lang/String;)V
  flags: ACC_PUBLIC, ACC_STATIC
  Code:
    stack=1, locals=2, args_size=1
       0: ldc           #2                  // String NineSun
       2: astore_1
       3: return

大家都读到这儿了,所以还请耐心的读完,一定会有收获的。
我解释一下上面的字节码指令:

  • 0: ldc,查找后面索引为#2对应的项,#2表示常量在常量池中的位置。在这个过程中,会触发前面提到的lazy resolve,在resolve过程如果发现StringTable已经有了内容匹配的String引用,则直接返回这个引用,反之如果StringTable里没有内容匹配的String对象的引用,则会在堆里创建一个对应内容的String对象,然后在StringTable驻留这个对象引用,并返回这个引用,之后再压入操作数栈中;
  • 2: astore_1,弹出栈顶元素,并将栈顶引用类型值保存到局部变量1中,也就是保存到变量s中;
  • 3: return,执行void函数返回

可以看到,在这种模式下,只有堆中创建了一个"NineSun"对象,在字符串常量池中驻留了它的引用。

并且,如果再给字符串s2、s3也用字面量的形式赋值为"NineSun",它们用的都是堆中的唯一这一个对象。

我对上面的信息总结一下:

public static void main(String[] args) {
    
    
        String s1 = "NineSun";
        String s2 = "NineSun";
        System.out.println(s1 == s2);
    }

上面的结果说明了s1和s2指向同一个引用,即只创建了一个对象。

好了,再看一下以构造方法的形式创建字符串的方式:

public static void main(String[] args) {
    
    
    String s = new String("NineSun");
}

同样反编译这段代码的字节码文件:

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 NineSun
       6: invokespecial #4                  // Method java/lang/String."<init>":(Ljava/lang/String;)V
       9: astore_1
      10: return

看一下和之前不同的字节码指令部分:

  • 0: new,在堆上创建一个String对象,并将它的引用压入操作数栈,注意这时的对象还只是一个空壳,并没有调用类的构造方法进行初始化;
  • 3: dup,复制栈顶元素,也就是复制了上面的对象引用,并将复制后的对象引用压入栈顶。这里之所以要进行复制,是因为之后要执行的构造方法会从操作数栈弹出需要的参数和这个对象引用本身(这个引用起到的作用就是构造方法中的this指针),如果不进行复制,在弹出后会无法得到初始化后的对象引用;
  • 4: ldc,在堆上创建字符串对象,驻留到字符串常量池,并将字符串的引用压入操作数栈;
  • 6: invokespecial,执行String的构造方法,这一步执行完成后得到一个完整对象

到这里,我们可以看到一共创建了两个String对象,并且两个都是在堆上创建的,且字面量方式创建的String对象的引用被驻留到了字符串常量池中。而栈里的s只是一个变量,并不是实际意义上的对象,我们不把它包括在内。

最后再看一下下面的这种情况,当字符串常量池已经驻留过某个字符串引用,再使用构造方法创建String时,创建了几个对象?

public static void main(String[] args) {
    
    
 String s1 = "ABCD";
 String s2 = new String("ABCD");
}

我们先看结果:

我们创建s1的时候,明明会在常量池里创建ABCD了,为何s2不去直接引用,反而还要去创建呢?

先看一下下面的这张图:

很明显两个之间的引用不是指向同一个地方,我在上面也提到了,不知道大家有没有注意,使用new String创建String时会先在堆上创建字符串对象,驻留到字符串常量池,并将字符串的引用压入操作数栈,所以在将字符串驻留到常量池时发现已经有ABCD这个字符串,于是直接返回ABCD的地址,但是这个地址只是保存在堆中,栈里存放的还是堆里创建的对象地址。

简而言之:str1保存的是常量池中的地址,而str2保存的是在堆中创建的String对象的地址,所以两者永远不可能相等。

于是我们给出以下结论:

  • String str1 = “ABCD”;最多创建一个String对象,最少不创建String对象。如果常量池中,已经存在“ABCD”,那么str1直接引用,此时不创建String对象,否则,先在常量池先创建“ABCD”内存空间,再引用。
  • String str2 = new string(“ABCD”);最多创建两个String对象,至少创建一个String对象。new关键字:绝对会在堆空间创建内存区域。
  • 单独使用 ” “ 引号创建的字符串都是直接量,编译期就已经确定存储到常量池中;
  • 使用new String(“”)创建对象会存储到对内存中,是运行期才创建;

4.烧脑的 intern

上面我们在研究字符串对象的引用如何驻留到字符串常量池中时,还留下了调用intern方法的方式,下面我们来具体分析。

从字面上理解intern这个单词,作为动词时它有禁闭、关押的意思,通过前面的介绍,与其说是将字符串关押到字符串常量池StringTable中,可能将它理解为缓存它的引用会更加贴切。

String的intern()是一个本地方法,可以强制将String驻留进入字符串常量池,可以分为两种情况:

  • 如果字符串常量池中已经驻留了一个等于此String对象内容的字符串引用,则返回此字符串在常量池中的引用
  • 否则,在常量池中创建一个引用指向这个String对象,然后返回常量池中的这个引用

我们下面看一下这段代码,它的运行结果应该是什么?

    public static void main(String[] args) {
    
    
        String s1 = new String("NineSun");
        String s2 = s1.intern();
        

        System.out.print("s1==s2:");
        System.out.println(s1 == s2);
        System.out.print("s1==NineSun:");
        System.out.println(s1 == "NineSun");
        System.out.print("s2=NineSun:");
        System.out.println(s2 == "NineSun");

    }

先看结果:

用一张图来描述它们的关系,就很容易明白了:

  • 在创建s1的时候,其实堆里创建了两个字符串对象StringObject1和StringObject2,并且在字符串常量池中驻留了StringObject2;
  • 当执行s1.intern()方法时,字符串常量池中已经存在内容等于"Hydra"的字符串StringObject2,直接返回这个引用并赋值给s2;
  • s1和s2指向的是两个不同的String对象,因此返回 fasle;
  • s2指向的就是驻留在字符串常量池的StringObject2,因此s2=="Hydra"为 true,而s1指向的不是常量池中的对象引用所以返回false。

上面是常量池中已存在内容相等的字符串驻留的情况,下面再看看常量池中不存在的情况,看下面的例子:

    public static void main(String[] args) {
    
    
        String s1 = new String("Nine") + new String("Sun");
        s1.intern();
        String s2 = "NineSun";
        System.out.println(s1 == s2);

    }

简单分析一下这个过程,第一步会在堆上创建"Hy"和"dra"的字符串对象,并驻留到字符串常量池中。

接下来,完成字符串的拼接操作,前面我们说过,实际上jvm会把拼接优化成StringBuilder的append方法,并最终调用toString方法返回一个String对象。在完成字符串的拼接后,字符串常量池中并没有驻留一个内容等于"Hydra"的字符串。

所以,执行s1.intern()时,会在字符串常量池创建一个引用,指向前面StringBuilder创建的那个字符串,也就是变量s1所指向的字符串对象。

在《深入理解Java虚拟机》这本书中,作者对这进行了解释,因为从jdk7开始,字符串常量池就已经移到了堆中,那么这里就只需要在字符串常量池中记录一下首次出现的实例引用即可。

最后,当执行String s2 = "Hydra"时,发现字符串常量池中已经驻留这个字符串,直接返回对象的引用,因此s1和s2指向的是相同的对象。

5.还是创建了几个对象?

解决了前面数String对象个数的问题,那么我们接着加点难度,看看下面这段代码,创建了几个对象?

String s="a"+"b"+"c";

先揭晓答案,只创建了一个对象!

只创建一个对象的原因来自于常量折叠

常量折叠会将编译期常量的加减乘除的运算过程在编译过程中折叠。编译器通过语法分析,会将常量表达式计算求值,并用求出的值来替换表达式,而不必等到运行期间再进行运算处理,从而在运行期间节省处理器资源。

那么问题来了,什么样子的常量才能被认为是编译期常量呢?

  • 被声明为final
  • 基本类型或者字符串类型
  • 声明时就已经初始化
  • 使用常量表达式进行初始化

下面我们通过几段代码加深对它的理解:

    public static void main(String[] args) {
    
    
        final String h1 = "hello";
        String h2 = "hello";
        String s1 = h1 + "NineSun";
        String s2 = h2 + "NineSun";
        System.out.println((s1 == "helloNineSun"));
        System.out.println((s2 == "helloNineSun"));
    }

代码中字符串h1和h2都使用常量赋值,区别在于是否使用了final进行修饰,对比编译后的代码,s1进行了折叠而s2没有,可以印证上面的理论,final修饰的字符串变量才有可能是编译期常量。

下面对本节内容进行一个简单的总结:

  • 使用只包含直接量的字符串连接符如”aa“+"bb"创建的也是直接量,编译期就能确定存储到常量池中;
  • 使用包含string直接量(无final修饰符)的字符串表达式(如”aa“+s1)创建的对象是运行期才创建的,存储在堆中(注意:变量在运行时候才知道值为多少,编译的时候是不知道的);
    通过变量/调用方法连接字符串,都只能在运行时候才能确定变量的值和方法的返回值,不存在编译优化操作。

猜你喜欢

转载自blog.csdn.net/zhiyikeji/article/details/125883773