Java字符串

146 阅读6分钟

Java中提到字符串,离不开这三个对象:

  • String
  • StringBuilder
  • StringBuffer

作为背锅面试题的,一旦谈到字符串,字符串拼接,单线程用StringBuilder,多线程用StringBuffer这些特点信口拈来。但对于背后的实现原理,我却一概不知。

String是如何实现的?

Java有八种基本类型和引用类型。基本类型也不包含字符串类型。所以JDK自己用对象实现了字符串(String)对象。从String对象来看,对象内部使用private final char value[];来存储字符串的数据。既然是对象,String可以通过new String()的方式进行创建。由于代码中很多地方都会用到String,所以,Java对字符串做了特殊支持"String a = "abc""<直接可以创建String对象,甚至提供了“+”符号来对字符串进行拼接操作。

为什么可以String a = "abc"而不是"new String("abc")"

对于正常对象来说,String a = "abc"从语法上根本说不通。所以JDK肯定是做了特殊的操作,什么操作呢?我的第一个想法是在编译期,JVM将""编译为"new String("abc")",突然发现构造参数也是字符串,OMG这不是套娃嘛,所以编译器这条路走不通。所以这个特殊操作只有在JVM运行期实现了,为了更加方便的便于理解,我将以下代码转换成了虚拟机字节码:

public class M {
    
    public static void main(String[] args) {
        String a2 = "de";
    }
}

java M.java,javap -c M.class 获取对应的虚拟机字节码,已经将虚拟机指令注释在代码后面:

public class com.sanjin.基础代码.M {
  public com.sanjin.基础代码.M();
    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                  // 将常量池中#2的String压如栈顶 String de
       2: astore_1       // 将栈顶引用类型存入第二个本地变量 
       3: return
}

从虚拟机字节码看不出有关"abc"的任何东西,直接是从常量池压入栈顶,得不出任何答案,从两种实例化字符串的区别来看:

  • new String("abc"): 走正常的new对象,会分配新的内存地址
  • String a = "abc": 会先查找字符串常量池是否含有"abc",如果存在会直接返回"abc"对象的引用,不存在会为"abc"字符串分配内存,然后保存到常量池,然后将引用赋值给 变量a

从上面过程看,`String a = "abc"应该是JVM会进行特殊处理,具体处理的源代码还不太清楚位于哪个位置,有知道的朋友可以留下言。

为什么拼接字符串建议用StringBuilder(单线程)

先来看一段虚拟机字节码: 源代码:

public class M {
    
    public static void main(String[] args) {
        String a2 = "de"+"bc";
        String a3 = "de"+a2;
        String a4 = "de"+new String("bbb") + a3;
    }
}

编译后代码:

public class M {
    public M() {
    }

    public static void main(String[] var0) {
        String var1 = "debc";
        String var2 = "de" + var1;
        (new StringBuilder()).append("de").append(new String("bbb")).append(var2).toString();
    }
}

字节码:

public class com.sanjin.基础代码.M {
  public com.sanjin.基础代码.M();
    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               //常量池#2压入栈 String debc
       2: astore_1 // 将栈顶引用赋值给局部变量表第二个变量
       3: new           #3   // 创建对象,将引用压入栈顶 class java/lang/StringBuilder
       6: dup            复制栈顶数值并将复制的值压入栈顶
       7: invokespecial #4                  // Method java/lang/StringBuilder."<init>":()V
      10: ldc           #5                  // String de
      12: invokevirtual #6                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      15: aload_1
      16: invokevirtual #6                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      19: invokevirtual #7                  // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
      22: astore_2
      23: new           #3                  // class java/lang/StringBuilder
      26: dup
      27: invokespecial #4                  // Method java/lang/StringBuilder."<init>":()V
      30: ldc           #5                  // String de
      32: invokevirtual #6                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      35: new           #8                  // class java/lang/String
      38: dup
      39: ldc           #9                  // String bbb
      41: invokespecial #10                 // Method java/lang/String."<init>":(Ljava/lang/String;)V
      44: invokevirtual #6                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      47: aload_2
      48: invokevirtual #6                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      51: invokevirtual #7                  // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
      54: astore_3
      55: return
}

字节码比较多,我是买了本《深入理解Java虚拟机》附录有字节码表对着注释的。字节码很多,不懂也可以跳过去,对照编译后的代码,

public class M {
    public M() {
    }

    public static void main(String[] var0) {
        String var1 = "debc";
        String var2 = "de" + var1;
        (new StringBuilder()).append("de").append(new String("bbb")).append(var2).toString();
    }
}

总结一下就是:

  1. 直接的拼接操作会在编译时候被优化为"debc"
  2. 对于有变量的拼接操作会被转换为StringBuilder#append进行拼接,最后通过StringBuilder#toString()将字符串赋值给变量,当然StringBuilder#toString()中会进行new String()

一般开发中,字符串拼接都是"abc"+var1的方式,所以如果有过多的变量拼接操作,一般不建议使用"+"进行拼接,更好的选择是使用StringBuilder#append,因为每次+拼接都会生成一个新的String对象。

不知道大家有没有一个问题,+操作符也是和"abc"是一个特殊存在吗?它为什么能够拼接字符串?其实+并没有对字符串做特殊的解析处理,而是转换为StringBuilder。让我们通过一个例子在验证一下。 源代码:

public class M {
    public M() {
    }

    public static void main(String[] var0) {
        String var1 = new Integer(1) + "bc";
    }
}

虚拟机字节码:

public class com.sanjin.基础代码.M {
  public com.sanjin.基础代码.M();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public static void main(java.lang.String[]);
    Code:
       0: new           #2                  // class java/lang/StringBuilder
       3: dup
       4: invokespecial #3                  // Method java/lang/StringBuilder."<init>":()V
       7: new           #4                  // class java/lang/Integer
      10: dup
      11: iconst_1
      12: invokespecial #5                  // Method java/lang/Integer."<init>":(I)V
      15: invokevirtual #6                  // Method java/lang/StringBuilder.append:(Ljava/lang/Object;)Ljava/lang/StringBuilder;
      18: ldc           #7                  // String bc
      20: invokevirtual #8                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      23: invokevirtual #9                  // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
      26: astore_1
      27: return
}

从字节码可以看出也是String var1 = new Integer(1) + "bc"也是通过创建StringBuilder的方式进行拼接。

哦原来如此!下一个问题。

String类以及存储字符串的变量为什么使用final关键字

final加在变量上: 表明该变量不可被修改。 看了下知乎回答,说是变量加final是为了下面这段代码的安全性。

public class M {
    public static String appendStr(String s){
        s +="bbb";
        return s;
    }
    
    public static void main(String[] args) {
        String s = new String("aaa");
        System.out.println(s);// aaa 不是 aaabbb
    }
}

OMG,这和final有一丁点一丁点关系吗?在main方法中,String s相当于main方法栈帧的局部变量表的一个局部变量。进入到appendStr方法后,appendStr方法栈帧的局部变量表也会有一个变量s,这个变量的值等于mian方法中变量的引用。所以两个s是没有一丁点关系的。然后进行s += "bbb",还记得变量与字符串拼接会发生什么吗?当然是使用StringBuilder#append进行拼接,然后调用StringBuilder#toString()生成新的String对象,然后将对象的引用赋值给appendStr局部变量表的第一个变量。所以很多人都没有想一下没有final会发生什么就在xjbbb。

那么究竟是为什么呢? 字符串常量池还记得么?它是所有线程共用的,既然公用就会出现一个问题:

假设现在有个字符串"abc"在常量池,如果线程A正在执行synchorized(String.value),然后线程B执行String a = "abc",a.value=new char[]{'b','c','d'},我们暂时忽略private修饰符问题。很明这样将导致原本我是想锁"abc"最终却锁住了"bcd",并发情况很可能会导致线程安全问题。你可能会说没人会这样写啊synchorized(String.value),但实际上这种写法等效于synchorized("abc")。所以加上final关键字会避免发生这种神秘的bug产生。

final加在类上: 表明该类不能被继承。 加载类上的final更多的是为了一种约束,不希望有人能够改变String的语义。你可以试想,什么情况下你需要重写String?这种基本类型已经是非常完善了,所以作为JDK开发者不希望有人再去继承String重写某个方法。

第二个是性能问题。

当你继承String,创建了一个名为SubString的类,然后写了如下方法: String a = new SubString(); a.length();

这种父类引用指向子类,在执行方法时候会更慢一些,因为它需要额外处理a.length()需要执行哪个子类的方法,性能会降低一些。