天天看点

字符串的不可变性分析以及 String,StringBuffer,StringBuilder 的比较

之前有写过一篇关于Java字符串相关知识总结的博文,但是里面更多的是对 String 类的常用方法进行了简单介绍。对于字符串的不可变性以及与 StringBuilder 和 StringBuffer 的比较并没有太多的涉及,所以这一篇文章将重点针对字符串的不可变性分析以及 String,StringBuffer,StringBuilder 的比较和使用选择进行介绍。

字符串的不可变特性分析

简单来说,String 类中使用 final 修饰的字符数组保存字符串,

private final char value[]

,所以,String 类型的对象是不可变的。

StringBuffer 和 StringBuilder 都继承自 AbstractStringBuilder 类,也是使用字符数组保存字符串,不同的是,AbstractStringBuilder 类中的字符数组没有使用 final 修饰,所以 StringBuffer 和 StringBuilder 类型的对象是可变的。

那么,为什么要将 String 类的对象设置为不可变的呢?不可变性是怎样体现的呢?《Java 核心技术 卷1》中有这样的解释:

由于不能修改 Java 字符串中的字符,所以在 Java 文档中将 String 类对象称为不可变字符串,如同数字 3 永远是数字 3 一样,字符串 “Hello” 永远包含字符 H、e、l、l、o 的代码单元序列,而不能修改其中的任何一个字符。当然,可以修改字符串变量的引用值,让它引用另外一个字符串,这就如同将存放 3 的数值变量改成存放 4 一样。

不可变字符串有一个优点:编译器可以让字符串共享。

为了弄清楚具体的工作方式,可以想象将各种字符串存放在公共的存储池中。字符串变量指向存储池中相应的位置。如果复制一个字符串变量,原始字符串与复制的字符串共享相同的字符序列。

总而言之,Java的设计者认为共享带来的高效率远远胜过提取、拼接字符串所带来的低效率。查看一下程序会发现,很少需要修改字符串,而是往往需要对字符串进行比较(有一种例外情况,将来自于文件或者键盘的单个字符或者较短的字符串汇集成字符串。为此,Java提供了另外的独立类)。

线程安全性

String 中的对象是不可变的,也就可以理解为常量,是线程安全的。

AbstractStringBuilder 是 StringBuffer 和 StringBuilder 的公共父类,定义了一些字符串的基本操作。而在具体实现类中,StringBuffer 对方法添加了同步锁或者对调用的方法加了同步锁,所以是线程安全的,StringBuilder 并没有对方法添加同步锁,所以是线程不安全的。

使用选择

每次对 String 类型进行改变的时候,都会生成一个新的 String 对象,然后将引用指向新的 String 对象。所以性能相对较差。

StringBuffer 每次都会对 StringBuffer 对象本身进行操作,而不是生成新的额对象并改变对象的引用。而由于同步锁的关系,相同情况下,StringBuilder 相比 StringBuffer 能够提高些许效率,但是却增加了线程不安全的风险。

所以,当不需要对字符串进行修改的情况下,优先使用 String。如果需要对字符串进行修改操作,强制使用 StringBuffer 或者 StringBuilder。后者的选择原则是是否需要保证线程安全。如果是在单线程环境中,优先使用 StringBuilder。

字符串的比较

首先,分析一段简单的代码的运行结果:

String str1 = "Hello";
String str2 = "Hello";
System.out.println(str1 == str2);
           

直接说明结果,程序运行打印的结果是 true。

这样的结果对于刚刚接触Java的同学来讲,可能觉得很正常,但是对于大对数明确 String 为引用对象并且已经对 equals 方法习以为常的同学来说就有一点点意外了。

实际上,上面的程序比较的是 str1 和 str2 两个引用是否相等,即它们是否指向同一个对象。程序运行的结果是 true。说明 str1 和 str2 指向的 “Hello” 的确是同一个,这也和上面提到的字符串共享很吻合。既然这样,那为什么判断字符串的内容是否相等应该使用 equals 而不是 == 呢?

我们做这样一种假设:如果虚拟机始终将相同的字符串共享,就可以使用 == 比较字符串是否相等。那么,实际情况到底是怎样的?Java设计者宁愿降低修改效率也要追求的共享是一种怎样的共享?

对于Java程序中的直接量,JVM 会使用一个

字符串池

来保存。如果下一次用到同样的字符串,就会直接指过来而不再重新创建。即通过直接量创建 String 对象的时候,虚拟机会在常量池中查找有没有已经存在的值和要创建的值相同的对象,如果存在就赋值给当前引用,如果没有就在常量池中创建一个新的对象并赋值给引用。

但是很遗憾,通过 new 关键字创建的对象,是不同的对象,不会通过常量池共享。而且,不仅仅在通过 new 关键字创建的时候不会共享,通过字符串连接符 + 和 substirng 等方法处理过的字符串产生的结果都是不共享的。

String a = new String("a");
String b = new String("a");
String aa = "a";
String bb = "a";
System.out.println(a == b); //false new关键字创建的对象是不同的对象
System.out.println(aa == bb); //true 字符串直接量是共享的
System.out.println(a == aa); //false 再次证明new关键字创建对象时并不会查找常量池
System.out.println(a.equals(aa));//true 内容比较和地址无关
String str1 = "Hello World";
String s = "Hello ";
String str2 = s + "World";
System.out.println(str1 == str2);// false 拼接运算后的字符串也是新的字符串对象

           

关于字符串的拼接,还有一个需要注意的问题,就是 Java 对常量的

宏替换

.

宏替换

如果字符串连接表达式的值可以在编译期确定下来,那么 JVM 会在编译时计算该字符串变量的值,并让它指向字符串池中对应的字符串。

Java中,一个用 final 定义的变量,不管它是什么类型的变量,只要使用了 final 定义并同时指定了初始值,并且这个初始值在编译的时候可以被确定下来,那么,这个 final 变量就是一个宏变量。编译器会把程序中所有用到该变量的地方直接替换成该变量的值。也就是说,编译器能对宏变量进行宏替换。

通过字符串连接符拼接字符串常量,如果有通过标识符参与,看标识符所代表的字符是不是常量(是否被 final 修饰)。如果是常量参与的,能够在编译器确定值,就会发生宏替换,替换后产生的值也是可以共享的。

String str1 = "Hello World";
final String s = "Hello ";
String str2 = s + "World";
System.out.println(str1 == str2);// true
           
通过上面的描述,可以有如下结论:
  1. 尽量通过字符串直接量创建字符串对象而不是通过 new 关键字。基本数据类型及其包装类也是同样的道理,能通过直接量创建就尽量通过直接量创建。
  2. 拼接字符串时尽可能使用常量拼接,利用宏替换的原理实现字符串的共享。
  3. 对于Java中字符串内容是否相等的比较应该使用 equals 而不是 ==,因为不能保证用来做比较的字符串一定没有做过拼接运算。
思考:

下面的程序代码中涉及多少个对象?

String str = "Hello" + "!";
           

关于上面的语句,创建了一个 String 类型的对象 “Hello!”,“Hello” 和 “!” 都是字符串直接量。

由String变量赋值引发的内存泄漏

String str = "Hello";
str = "Java";
Str = "JS";
           

不断使 str 指向其他对象,会导致之前的对象成为没有引用的内存垃圾,但是由于字符串直接量都是存放在字符串池中的,不会被 JVM 回收,这样很容易造成内存泄漏。