在网上看到很多初学者对于 String 类都不太清楚,提出很多问题,然后问题下面的回答也是不清不楚。甚至有些工作了好几年的开发也没有搞懂 String 类,回答的也不清楚。所以本人呕心沥血以 JDK8 的版本尽量透彻的分析Java 的 String 类。

String 与基本数据类型

String 在 Java 开发中使用非常广泛,可以直接通过字面量的形式声明。但是值得注意的是它并不是基本数据类型,是引用类型。所以 String 对象存储在堆空间,它的引用在栈空间。

当 Java 中对引用类型变量赋值时,可以理解为将这个变量(引用)指向一块内存地址。当执行这段代码时,实际上是栈中一个 str 指向堆中一块内存空间。

String str = "Hello";

java 第二个形参需要加默认值 java string 形参_string

不可变 String

在谈不可变 String 之前,我们先了解一下 Java 中的不可变对象。

  • 不可变对象:如果对象创建完成之后,其状态不会被修改,那么这个对象就是不可变对象。那对象的状态又如何理解呢?
  • 对象状态:类里面定义的成员变量叫做属性,运行时创建出来的对象的属性的具体值就是该对象的状态。

查看 String 类的源代码会发现,源码中 String 类的字面量内容是由一个 final char 类型的 value[] 数组维护的。我们都知道被 final 关键字修饰的变量只能被赋值一次,也就是指向一块内存地址之后,不能再指向另一块内存地址。所以 String 对象一旦被创建就不会被更改(这里不考虑反射)。所以 String 对象状态不会被修改,是不可变对象。通过下面的内存图理解

String str = "Hello";

java 第二个形参需要加默认值 java string 形参_字符串_02

还是以这行代码为例,粗略的画出运行时关于这个对象在内存中的布局,这个 value[] 就是 String 的成员变量。由于 value 是 final 修饰的,我们都知道 final 修饰的变量只能被初始化一次,也就是常量,所以这个字符数组指向的内存空间就固定了,也就是上图的 2 号线不会变。当你对 str 重新赋值的时候

str = "World";

实际上只是 str 指向的内存空间变了,也就是 1 号指针断开,重新指向了其他地方。原来的 2 号指针还是没有变化。

说到这里你可能会有疑问,String 不是有 replace()、subString()、toLowerCase() 等方法吗?看下面代码

String str = "Hello";
str.toLowerCase();
str.substring(0,1);
str.replace("H","h");

这个问题非常简单,这些方法的确是返回了一个新的符合预期的 String 对象,但是你查看源码就会发现,它们方法内部都是 new 了一个新的 String 对象,并没有改动原来的 str 指向的对象内容。对于 String 的所有操作,都是新生成了一个对象,原对象没有改变。

下面我们来看一个练习题

public static void main(String[] args){
    String str = "Hello World";
    char[] arr = {"H","e","l","l","o"};
    change(str);
    changeArr(arr);
    System.out.println(str);//Hello World
    System.out.println(arr);//Wello
}
private static void change(String str){
    str = "World";
}
private static void changeArr(char[] arr){
    arr[0] = 'W';
    arr = new char[5];
}

可以尝试做一下这个题目,我相信有大多数初学者可能会做错。为什么 change() 方法没有改变 str ? 为什么 changeArr() 改掉了数组 arr 的内容?

在搞清楚这个问题前,先来科普一个知识。Java 程序是运行在 JVM 上的,JVM 有两个较为重要的内存区:虚拟机栈、堆。 Java 中所有方法的调用,都是一个栈帧在虚拟机栈中入栈操作,所有方法调用完成,都是栈帧在虚拟机栈中出栈的操作。那回过来看这个代码,总共三个方法:main、change、changeArr。它们的在执行过程中的出栈入栈是这样的。(别看这个动画简单,真是花了我很多时间,毕竟我是新手啊。我先学习 PPT 创建动画,然后又转为视频,再找视频转 GIF……如果哪位大哥对这块熟悉,请私信教教我,感激不尽)

java 第二个形参需要加默认值 java string 形参_java 第二个形参需要加默认值_03

程序开始,执行 main 方法,main 方法栈帧入栈,字符串引用 str 和数组引用 arr 指向堆内存。执行到 change 方法时,change 对应的栈帧入栈,我们都知道栈的数据结构特性,先入后出。此时 main 栈帧在栈底,change 栈帧在栈顶。change 方法把传递进来的形参 str 重新赋值。注意,在 change 方法中的形参 str,运行时是 main 方法中 str 的一个拷贝,你可以这么来理解 Java 中引用类型赋值的操作。

如果是将一个变量赋值给另一个变量

String str2 = str1;

它代表的含义就是让 str2 指向 str1 所指向的那块堆中的内存地址。

那再回到我们的 change 方法,调用时,实际上是让 change 栈帧中的 str 指向原来 main 方法中 str 所指向的那块内存地址。 (也就是将原来的 main 栈帧中的 str 拷贝一份,我怕大家不好理解,这样说比较清晰)

之后 change 方法执行结束,change 栈帧出栈,栈帧内部的变量(引用)也都被销毁。所以 main 输出的 str 内容当然没有变。如果你把第 6 行的输出放到 change 内部,输出的就是 change 栈帧里面的 str 指向的 "World",但是原来的 "HelloWorld" 仍然没有改变,只是新开了一块内存。所以这里也涉及到变量作用域的问题。

其实这里误导人的地方可能在于,change 方法的形参和 main 方法中的变量名字相同,你可以把 change 方法的形参换成其他名字,也许会清楚些。

再来看下为什么数组就改掉了原来的对象内容。当执行到 changeArr方法时,对应的栈帧在栈顶,main方法对应的栈帧在栈底,我们将 arr 传递给了 changeArr ,在changeArr 的栈帧里面复制了一个引用,通过这个引用,我们将数组的第一个元素改掉。那你都通过这个引用改掉原内容了,那肯定就变了呀!因为数组的内容本身就可变,它又不是新开了内存地址,这就相当于你用一把钥匙打开了仓库门,把西瓜换成了哈密瓜。那你以后开仓库看的时候当然变成了哈密瓜。

然后这行代码

arr = new char[5];

这个和上面 str 是一样的,我把我这个栈帧里面的引用重新指向了一块内存,关原来 main 方法中的 arr 啥事呢,当 changeArr 执行结束,栈帧出栈,栈帧里面引用的生命周期就到头了,被销毁了。

 

String不可变的好处

官方把 String 定义成不可变肯定是经过深思熟虑的,定义成不可变有哪些好处呢?

  • 状态不可变的对象妥妥的线程安全,不需要任何加锁操作保证线程安全,提高系统性能。
  • 不可变对象才能实现字符串常量池,将相同字面量放入常量池 同一块内存地址。

String 的比较

String 作为引用类型,可以使用 “==” 和 equals 方法来进行比较,对于引用类型来说,“==” 比较地址是否相同,当 String 调用 equals 比较时,会比较字符串字面量是否相同。这一点我们看查 String 类的源码就很清楚了。源码中 String 类将 equals 方法重写,判断两个字符串的每一个字符都相同则返回 true。

这里也有面试题会问 “==” 和 equals 的区别,很多人会回答引用类型 “==” 比较地址,equals 比较值。这是完全错误的认识。equals 比较值仅仅是因为 String 类重写了 equals,假如你自己定义一个类,调用它的 equals 方法,那你会发现它并不是你所理解的那样。

public class Test {
    private String name;
    public static void main(String[] args) {
        Test t1 = new Test("ceshi");
        Test t2 = new Test("ceshi");
        System.out.println(t1.equals(t2));//false
    }
    public Test(String name){
        this.name = name;
    }
}

你看这个例子,它的结果就是 false。可以点进去查看 Test 类的 equals 方法源码,发现它就是使用的父类 Object 的 equals 方法,源码

public boolean equals(Object obj) {
        return (this == obj);
    }

在根类 Object 中,equals 和 “==” 是等价的,所以 equals 会对比出什么结果,取决于子类如何重写,如果不重写,默认和 “==”是等价的。

 

String 常量池

String 常量池,也就是字符串常量池,这个东西要细说的话,涉及的 JVM 内存区域、类加载等相关知识很多。这里我们简单的理解 JVM 提供了一块内存用于存放 String 字符串对象。这样以后如果用到相同字符串就不用开辟新的空间,直接使用字符串常量池中的对象就可以了。

前面我们提到过 String 可以通过字面量形式直接声明一个对象,那么 String 作为引用类型,当然是可以通过 new 关键字来创建对象的。

  • 当使用字面量形式声明对象时,会先检查字符串常量池中是否已经存在该对象,如果已存在直接将引用指向已存在的对象,如果不存在,将该对象放入常量池。
  • 当使用 new 关键字声明对象时,会先检查字符串常量池中是否已经存在该对象,如果已存在直接将引用指向已存在的对象,如果不存在,将该对象放入常量池。此外还会在堆空间开辟一块内存地址并且将该引用指向堆中的地址
  • 由于 new 关键字会在堆中开辟空间,所以开发中一般情况不建议使用,直接用字面量形式声明即可
String str1 = "Hello";//产生1个对象放入常量池
String str2 = new String("World");//创建两个对象,一个在堆,一个在常量池
String str3 = new String("Hello");//创建一个对象在堆,由于常量池已经有 Hello 不会再创建

来看一个示例

public static void main(String[] args){
  String str1 = "Hello";
  String str2 = "Hello";
  System.out.println(str1 == str2);//true
  String str3 = new String("Hello");
  System.out.println(str1 == str3);//false
  String str4 = new String("Hello");
  System.out.println(str3 == str4);//false
}

上面代码的结果很简单,就不多说了。有些公司会出下面这行代码的面试题,问你下面这行代码创建了几个对象,现在你知道怎么回答了吧!

String str = new String("Hello");//如果前面已经声明过 Hello 字符串,这行代码只会在堆创建一个对象,否则会创建两个对象,一个在堆,一个在常量池

看到这里你是不是觉得自己无敌了,那么再来个升级版的,这两行代码创建了几个对象?请尝试

String str = "Hello" + "World";
---------------------------------------------------------
String str = "Hello" + new String("World");

String 类的“加法”

如果学过 C++ 的话。你应该知道,C++ 中是允许开发者重载操作符的,但是 Java 中并不允许。官方仅仅提供了对于 String 类的 “+” 和 "+=" 两个特殊重载,“+” 将两个字符串连接生成一个新的对象,也就是内存中新开辟了一块空间。你有没有想过为什么 String 对象能使用 “+” 操作符呢?毕竟 String 并不是 Java 里面 8 大基本数据类型和对应的装箱类型,它是引用类型,所以它能够使用 +“” 肯定是官方做过手脚呀!

看下面的示例

public static void main(String[] args) {
    String str1 = "Hello";
    String str2 = "World";
    String str3 = "Hello" + "World";
    String str4 = "HelloWorld";
    System.out.println(str3 == str4);//true
    String str5 = str1 + "World";
    System.out.println(str5 == str4);//false
    String str6 = str1 + str2;
    System.out.println(str6 == str4);//false
}

这里有必要思考,为什么第 6 行结果是 true ?当使用 "+" 对两个字符串常量连接时,这个结果在编译器就确定了,由于 "+" 号两边都是常量,因此会直接连接放入常量池。并且不会将 “+” 两边的常量放入常量池。

为什么第 8、10 行结果是 false 呢?因为第 7 、9 行,“+” 两边并不全是常量,有一个是变量,这样它的结果是不能在编译期间确定的,只有在运行时才知道。所以并不会像上面一样连接起来放入常量池。那么你可能想问,怎样在运行时动态的把变量放入常量池,恭喜你,String 给我们提供了一个方法 intern()。这个方法是个 native 方法,它的作用就是将调用它的对象尝试放入常量池,如果常量池已经有了,就返回指向常量池中的引用,如果没有就放入常量池,并且返回指向常量池中的引用。

public static void main(String[] args) {
        String str1 = "hello";
        String str2 = "helloworld";
        String str3 =str1 + "world";
        System.out.println(str3 == str2);//false
        str3 = str3.intern();//将str3放进常量池,并且将引用赋给原来的 str3
        System.out.println(str3 == str2);//true
    }

上面这段代码能够很好的反应 intern() 方法的作用。

String 、StringBuilder、StringBuffer

说到 String 类就自然而然的想到了两个与之关系密切的类。由于 String 对象不可变,每一个操作都会产生新的对象,这样似乎不太友好,可能会造成内存占用过大,而且会频繁创建对象。所以官方给我们提供了一个类 StringBuilder 。这个类可以在原字符串的基础上进行增删改,并且不会新开辟内存空间。这就弥补了有些场景下 String 的不友好性。它跟 String 非常类似,还记得之前我们说 String 内部维护了一个 final char[] value 吗?StringBuilder 内部维护了一个 char[] value 没有用 final 修饰,所以它是可以在原字符串基础上做修改的。

StringBuffer 其实和 StringBuilder 是一样的,只是 StringBuffer 对于字符串的增删改方法都加上了 synchronized 关键字,这样一来对于字符串的操作就是线程安全的,由于线程安全所以其性能也次于 StringBuilder。

也许平时你没有或者很少见过后面两个类,但是 StringBuilder 其实与我们息息相关,每一个 String 的 “+” 都离不开它。当你程序中有字符串使用 “+” 连接的时候,底层会给你 new 一个 StringBuilder 对象调用 append 来连接字符串。

例如下面代码:

public static void main(String[] args) {
        String str1 = "hello";
        String str2 = "hello";
        String str3 = str1 + str2;
    }

第四行在 JVM 底层其实会 new 一个 StringBuilder 调用其 append 方法来连接 str1 和 str2 然后生成一个新的 String 对象。这一点我们可以使用 javap -v 命令去反编译 class 文件来证明。所以代码中我们要善用 “+” 来连接字符串,如果你像下面这样再循环里面使用 “+”

public static void main(String[] args) {
        String str = "hello";
        for(int i = 0;i<1000;i++){
            str = str + i;
        }
    }

那这个问题就大了,这会在底层创建 1000 个 StringBuilder 对象,浪费堆空间内存。所以这种代码,我们把 StringBuilder 对象提前创建好放在循环外面,然后使用 append 方法来连接,会有更好的效果。

public static void main(String[] args) {
        String str = "hello";
        StringBuilder sb = new StringBuilder(str);
        for(int i = 0;i<1000;i++){
            sb.append(i);
        }
    }

这样的写法只会创建一个 StringBuilder 对象。