在网上看到很多初学者对于 String 类都不太清楚,提出很多问题,然后问题下面的回答也是不清不楚。甚至有些工作了好几年的开发也没有搞懂 String 类,回答的也不清楚。所以本人呕心沥血以 JDK8 的版本尽量透彻的分析Java 的 String 类。
String 与基本数据类型
String 在 Java 开发中使用非常广泛,可以直接通过字面量的形式声明。但是值得注意的是它并不是基本数据类型,是引用类型。所以 String 对象存储在堆空间,它的引用在栈空间。
当 Java 中对引用类型变量赋值时,可以理解为将这个变量(引用)指向一块内存地址。当执行这段代码时,实际上是栈中一个 str 指向堆中一块内存空间。
String str = "Hello";
不可变 String
在谈不可变 String 之前,我们先了解一下 Java 中的不可变对象。
- 不可变对象:如果对象创建完成之后,其状态不会被修改,那么这个对象就是不可变对象。那对象的状态又如何理解呢?
- 对象状态:类里面定义的成员变量叫做属性,运行时创建出来的对象的属性的具体值就是该对象的状态。
查看 String 类的源代码会发现,源码中 String 类的字面量内容是由一个 final char 类型的 value[] 数组维护的。我们都知道被 final 关键字修饰的变量只能被赋值一次,也就是指向一块内存地址之后,不能再指向另一块内存地址。所以 String 对象一旦被创建就不会被更改(这里不考虑反射)。所以 String 对象状态不会被修改,是不可变对象。通过下面的内存图理解
String str = "Hello";
还是以这行代码为例,粗略的画出运行时关于这个对象在内存中的布局,这个 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……如果哪位大哥对这块熟悉,请私信教教我,感激不尽)
程序开始,执行 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 对象。
以上就是 Java 中 String 类相关知识,如果这篇文章对你有帮助,可以给作者点赞加关注,你的支持就是我继续创作的动力!