java常见面试考点(六):深入理解String类型

java常见面试考点

往期文章推荐:
  java常见面试考点(一):自增变量
  java常见面试考点(二):单例模式
  java常见面试考点(三):类初始化与实例初始化
  java常见面试考点(四):方法的参数传递机制
  java常见面试考点(五):基本类型和包装类型的区别


【版权申明】未经博主同意,谢绝转载!(请尊重原创,博主保留追究权);

本博客的内容来自于:java常见面试考点(六):深入理解String类型

学习、合作与交流联系q384660495;

本博客的内容仅供学习与参考,并非营利;


一、String的不可变性

首先,我们先看一下String的源码

public final class String
    implements java.io.Serializable, Comparable<String>, CharSequence {
    /** The value is used for character storage. */
    private final char value[];

String 类被 final 关键字修饰,表示不可继承 String 类。

String 类的数据存储于 char[] 数组,这个数组被 final 关键字修饰,表示 String 对象不可被更改。

对于一个final变量,如果是基本数据类型的变量,则其数值一旦在初始化之后便不能更改;如果是引用类型的变量,则在对其初始化之后便不能再让其指向另一个对象,但是对象里面的内容是可以修改的,比如一个数组用final修饰,数组的值是可以修改的。
对于final的深入理解可以参考这篇文章深入理解Java—final关键字

为什么 Java 要这样设计?

(1)保证 String 对象安全性。避免 String 被篡改。

(2)保证 hash 值不会频繁变更。

(3)可以实现字符串常量池。通常有两种创建字符串对象的方式,一种是通过字符串常量的方式创建,如 String str=“abc”; 另一种是字符串变量通过 new 形式的创建,如 String str = new String(“abc”)。

字符串常量池实现的前提条件就是Java中String对象是不可变的,这样可以安全保证多个变量共享同一个对象。如果Java中的String对象可变的话,一个引用操作改变了对象的值,那么其他的变量也会受到影响,显然这样是不合理的。

  使用第一种方式创建字符串对象时,JVM 首先会检查该对象是否在字符串常量池中,如果在,就返回该对象引用,否则新的字符串将在常量池中被创建。这种方式可以减少同一个值的字符串对象的重复创建,节约内存。

  String str = new String(“abc”) 这种方式,首先在编译类文件时,“abc” 常量字符串将会放入到常量结构中,在类加载时,“abc” 将会在常量池中创建;其次,在调用 new 时,JVM 命令将会调用 String 的构造函数,同时引用常量池中的 “abc” 字符串,在堆内存中创建一个 String 对象;最后, str 将引用 String 对象。

本部分内容并非原创,摘自深入理解 Java String 类型,这位大佬的笔记总结的非常到位。

再看一些String类的方法实现

public String substring(int beginIndex, int endIndex) {
    if (beginIndex < 0) {
        throw new StringIndexOutOfBoundsException(beginIndex);
    }
    if (endIndex > count) {
        throw new StringIndexOutOfBoundsException(endIndex);
    }
    if (beginIndex > endIndex) {
        throw new StringIndexOutOfBoundsException(endIndex - beginIndex);
    }
    return ((beginIndex == 0) && (endIndex == count)) ? this :
        new String(offset + beginIndex, endIndex - beginIndex, value);
}

public String concat(String str) {
    int otherLen = str.length();
    if (otherLen == 0) {
        return this;
    }
    char buf[] = new char[count + otherLen];
    getChars(0, count, buf, 0);
    str.getChars(0, otherLen, buf, count);
    return new String(0, count + otherLen, buf);
}

public String replace(char oldChar, char newChar) {
    if (oldChar != newChar) {
        int len = count;
        int i = -1;
        char[] val = value; /* avoid getfield opcode */
        int off = offset;   /* avoid getfield opcode */

        while (++i < len) {
        if (val[off + i] == oldChar) {
            break;
        }
        }
        if (i < len) {
        char buf[] = new char[len];
        for (int j = 0 ; j < i ; j++) {
            buf[j] = val[off+j];
        }
        while (i < len) {
            char c = val[off + i];
            buf[i] = (c == oldChar) ? newChar : c;
            i++;
        }
        return new String(0, len, buf);
        }
    }
    return this;
}

  从上面的三个方法可以看出,无论是substring、concat还是replace操作都不是在原有的字符串上进行的,而是重新生成了一个新的字符串对象。也就是说进行这些操作后,最原始的字符串并没有被改变。

  在这里要永远记住一点:String对象一旦被创建就是固定不变的了,对String对象的任何改变都不影响到原对象,相关的任何change操作都会生成新的对象”

二、字符串常量池

1、工作原理

Java中的常量池,实际上分为两种形态:静态常量池和运行时常量池。
所谓静态常量池,即*.class文件中的常量池,class文件中的常量池不仅仅包含字符串(数字)字面量,还包含类、方法的信息,占用class文件绝大部分空间。
而运行时常量池,则是jvm虚拟机在完成类装载操作后,将class文件中的常量池载入到内存中,并保存在方法区中,我们常说的常量池,就是指方法区中的运行时常量池

  当代码中出现字面量形式创建字符串对象时,JVM首先会对这个字面量进行检查,如果字符串常量池中存在相同内容的字符串对象的引用,则将这个引用返回,否则新的字符串对象被创建,然后将这个引用放入字符串常量池,并返回该引用

  java中的常量池技术,是为了方便快捷地创建某些对象而出现的,当需要一个对象时,就可以从池中取一个出来(如果池中没有则创建一个)。这样可以在需要重复创建相等字符串时节省了很多时间。常量池其实也就是一个内存空间,不同于使用new关键字创建的对象所在的堆空间。 String类也是java中用得多的类,同样为了创建String对象的方便,也实现了常量池的技术。其他的基本数据类型的包装类型也实现了常量池技术,详情可参考我的这一篇文章java常见面试考点(五):基本类型和包装类型的区别

对于String常量,它的值是在常量池中的。而JVM中的常量池在内存当中是以表的形式存在的, 对于String类型,有一张固定长度的CONSTANT_String_info表用来存储文字字符串值,注意:该表只存储文字字符串值,不存储符号引用。说到这里,对常量池中的字符串值的存储位置应该有一个比较明了的理解了。在程序执行的时候,常量池会储存在Method Area,而不是堆中。常量池中保存着很多String对象; 并且可以被共享使用,因此它提高了效率。
字符串常量池存放的是对象引用,不是对象。在Java中,对象都创建在堆内存中。

2、举例说明

  1. 字面量创建形式
String str1 = "test";
String str2 = "test";
System.out.println(str1 == str2);

结果为true

  首先,JVM会检测这个字面量,JVM通过字符串常量池查找不到内容为test的字符串对象存在,那么会创建这个字符串对象,然后将刚创建的对象的引用放入到字符串常量池中,并且将引用返回给变量str1str2的时候,JVM在字符串常量池中找到了内容为test的字符串对象存在,则将已经存在的字符串对象的引用返回给变量str2。注意这里不会重新创建新的字符串对象。从结果上可以看出,str1str2是同一个引用。

  1. 使用new创建
String str3 = new String("test");
String str4 = new String("test");
System.out.println(str3 == str4);

结果为false

  当我们使用了new来构造字符串对象的时候,不管字符串常量池中有没有相同内容的对象的引用,新的字符串对象都会创建,因此结果为false

字符串常量池的好处就是减少相同内容字符串的创建,节省内存空间。
如果硬要说弊端的话,就是牺牲了CPU计算时间来换空间。CPU计算时间主要用于在字符串常量池中查找是否有内容相同对象的引用。不过其内部实现类似于HashTable,所以计算成本较低。

三、字符串的拼接

Java中,唯一被重载的运算符就是字符串的拼接相关的。+,+=。除此之外,Java设计者不允许重载其他的运算符。

1、性能代价

  既然Sting对象不可变,那么多个(三个及以上)字符串拼接必然产生多余的中间Sting对象。比如:

String userName = "Andy";
String age = "24";
String job = "Developer";
String info = userName + age + job;

  要得到上面的info,就会userNameage拼接生成临时一个Sting对象t1,内容为Andy24。然后有t1job拼接生成最终我们需要的info对象。这其中,产生了一个中间的t1,而且t1创建之后,没有主动回收,势必会占一定的空间。如果是一个很多字符串的拼接,那么代价就更大了,性能一下会降低很多。

2、编译器的优化处理

(1)字符串常量拼接

如下代码所示:

public static void main(String[] args) {
    // 本行代码在 class 文件中,会被编译器直接优化为:
    // String str = "abc";
    String str = "a" + "b" + "c";
    System.out.println("str = " + str);
}

反编译结果如下所示:

public class Test {
  public Test();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public static void main(java.lang.String[]);
    Code:
       0: ldc           #2                  // String abc
       2: astore_1
       3: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
       6: new           #4                  // class java/lang/StringBuilder
       9: dup
      10: invokespecial #5                  // Method java/lang/StringBuilder."<init>":()V
      13: ldc           #6                  // String str =
      15: invokevirtual #7                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      18: aload_1
      19: invokevirtual #7                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      22: invokevirtual #8                  // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
      25: invokevirtual #9                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      28: return
}

可以看出字符串常量的拼接,编译器会将其优化为一个常量字符串。

(2)字符串变量拼接

编译器会帮助我们在编译的时候对字符串变量的拼接进行优化。如下所示:

public class Concatenation {
  public static void main(String[] args) {
      String userName = "Andy";
      String age = "24";
      String job = "Developer";
      // 本行代码会被编译器优化为:
      // str = (new StringBuilder(String.valueOf(username))).append(age).append(job).toString();
      String info = userName + age + job;
      System.out.println(info);
  }
}

反编译后,如下所示:

17:22:04-androidyue~/workspace_adt/strings/src$ javap -c Concatenation
Compiled from "Concatenation.java"
public class Concatenation {
  public Concatenation();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return        

  public static void main(java.lang.String[]);
    Code:
       0: ldc           #2                  // String Andy
       2: astore_1
       3: ldc           #3                  // String 24
       5: astore_2
       6: ldc           #4                  // String Developer
       8: astore_3
       9: new           #5                  // class java/lang/StringBuilder
      12: dup
      13: invokespecial #6                  // Method java/lang/StringBuilder."<init>":()V
      16: aload_1
      17: invokevirtual #7                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      20: aload_2
      21: invokevirtual #7                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      24: aload_3
      25: invokevirtual #7                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      28: invokevirtual #8                  // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
      31: astore        4
      33: getstatic     #9                  // Field java/lang/System.out:Ljava/io/PrintStream;
      36: aload         4
      38: invokevirtual #10                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      41: return
}

我们可以看到上面有很多StringBuilder,但是我们在Java代码里并没有显示地调用,这就是Java编译器做的优化,当Java编译器遇到字符串拼接的时候,会创建一个StringBuilder对象,后面的拼接,实际上是调用StringBuilder对象的append方法。这样就不会有我们上面担心的问题了。

3、编译器优化的局限性

仅靠编译器的优化就可以了吗,下面我们看一段未优化性能较低的代码。

public void  implicitUseStringBuilder(String[] values) {
  String result = "";
  for (int i = 0 ; i < values.length; i ++) {
      result += values[i];
  }
  System.out.println(result);
}

反编译之后的结果如下:

public void implicitUseStringBuilder(java.lang.String[]);
    Code:
       0: ldc           #11                 // String 
       2: astore_2
       3: iconst_0
       4: istore_3
       5: iload_3
       6: aload_1
       7: arraylength
       8: if_icmpge     38
      11: new           #5                  // class java/lang/StringBuilder
      14: dup
      15: invokespecial #6                  // Method java/lang/StringBuilder."<init>":()V
      18: aload_2
      19: invokevirtual #7                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      22: aload_1
      23: iload_3
      24: aaload
      25: invokevirtual #7                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      28: invokevirtual #8                  // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
      31: astore_2
      32: iinc          3, 1
      35: goto          5
      38: getstatic     #9                  // Field java/lang/System.out:Ljava/io/PrintStream;
      41: aload_2
      42: invokevirtual #10                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      45: return

其中8: if_icmpge 38 35: goto 5构成了一个循环。8: if_icmpge 38 的意思是如果JVM操作数栈的整数对比大于等于(i < values.length的相反结果)成立,则跳到第38行(System.out)。35: goto 5则表示直接跳到第5行。

但是这里面有一个很重要的就是StringBuilder对象创建发生在循环之间,也就是意味着有多少次循环会创建多少个StringBuilder对象,这样明显不好。赤裸裸地低水平代码啊。

可以对代码稍微优化一下,如下所示:

public void explicitUseStringBuider(String[] values) {
  StringBuilder result = new StringBuilder();
  for (int i = 0; i < values.length; i ++) {
      result.append(values[i]);
  }
}

反编译结果如下:

public void explicitUseStringBuider(java.lang.String[]);
    Code:
       0: new           #5                  // class java/lang/StringBuilder
       3: dup
       4: invokespecial #6                  // Method java/lang/StringBuilder."<init>":()V
       7: astore_2
       8: iconst_0
       9: istore_3
      10: iload_3
      11: aload_1
      12: arraylength
      13: if_icmpge     30
      16: aload_2
      17: aload_1
      18: iload_3
      19: aaload
      20: invokevirtual #7                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      23: pop
      24: iinc          3, 1
      27: goto          10
      30: return

  从上面可以看出,13: if_icmpge 3027: goto 10构成了一个loop循环,而0: new #5位于循环之外,所以不会多次创建StringBuilder

  总的来说,我们在循环体中需要尽量避免隐式或者显式创建StringBuilder. 所以那些了解代码如何编译,内部如何执行的人,写的代码档次都比较高。

字符串拼接的正确方案:
  如果需要使用字符串拼接,应该优先考虑 StringBuilder append 方法替代使用 + 号。
  如果在并发编程中,String 对象的拼接涉及到线程安全,可以使用 StringBuffer。但是要注意,由于 StringBuffer 是线程安全的,涉及到锁竞争,所以从性能上来说,要比 StringBuilder 差一些。

四、String.intern()方法

jdk1.7后,intern方法会先去查询常量池中是否已经存在,如果存在,则返回常量池中的引用;如果常量池中找不到对应的字符串,则不会再将字符串拷贝到常量池,而只是常量池中生成一个对原字符串的引用。

String string2 = new String("JAVA") +new String("2");
string2.intern();
String string = "JAVA2";
System.out.println("string2 = " + (string2==string));

结果为true。因为常量池中一开始是没有"JAVA2",通过string2.intern()在常量池中生成了对"JAVA2"的引用。然后字面量赋值的时候就可以直接返回这个引用了,因此结果为true

String string2 = new String("JAVA") +new String("2");
String string = "JAVA2";
string2.intern();
System.out.println("string2 = " + (string2==string));

结果为false。其实只是调换了中间俩行的位置,因为在进行字面量赋值的时候,常量池中不存在,所以会先在常量池中添加"JAVA2",而堆中对象进行string2.intern()方法时候,直接返回了常量池的值,因此结果为false

五、String、StringBuffer、StringBuilder 的区别

StringJava 语言非常基础和重要的类,提供了构造和管理字符串的各种基本逻辑。它是典型的 Immutable 类,被声明成为 final class,所有属性也都是 final 的。也由于它的不可变性,类似拼接、裁剪字符串等动作,都会产生新的 String 对象。由于字符串操作的普遍性,所以相关操作的效率往往对应用性能有明显影响。

StringBuffer 是为解决上面提到拼接产生太多中间对象的问题而提供的一个类,我们可以用 append 或者 add 方法,把字符串添加到已有序列的末尾或者指定位置。StringBuffer 是一个线程安全的可修改字符序列。StringBuffer 的线程安全是通过在各种修改数据的方法上用 synchronized 关键字修饰实现的。

StringBuilderJava 1.5 中新增的,在能力上和 StringBuffer 没有本质区别,但是它去掉了线程安全的部分,有效减小了开销,是绝大部分情况下进行字符串拼接的首选。

StringBuffer StringBuilder 底层都是利用可修改的(charJDK 9 以后是 byte)数组,二者都继承了 AbstractStringBuilder,里面包含了基本操作,区别仅在于最终的方法是否加了 synchronized。构建时初始字符串长度加 16(这意味着,如果没有构建对象时输入最初的字符串,那么初始值就是 16)。我们如果确定拼接会发生非常多次,而且大概是可预计的,那么就可以指定合适的大小,避免很多次扩容的开销。扩容会产生多重开销,因为要抛弃原有数组,创建新的(可以简单认为是倍数)数组,还要进行 arraycopy

六、常见面试题

常见面试题可以参考这篇文章intern()方法详解,写的非常详细!

七、总结

本篇文章的内容并非全部原创,是对许多优质博主的总结,希望对大家有所帮助!

八、参考资料

深入理解Java中的String
深入理解 Java String 类型
深入理解String
深入解析String#intern
Java中的字符串常量池
Java细节:字符串的拼接
几张图轻松理解String.intern()
Java面试——从JVM角度比较equals和==的区别

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

夏天的爱人是绿色

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值