上文中,分析了String 类的源码,从源码上分析了String的不可变性,总结一句话为:String类的底层是用char []数字进行封装,当第一次为char[] 数组赋值后,数组的长度和内容都不能改变(文中提到使用反射的方法修改char[]数组的内容,可以忽略),并且 String 类没有为它的属性提供setter/getter方法,上述两个原因决定了String类一旦赋值后,就不能初始化的原因。
下面,我们继续分析String类。
二. 字符串常量池 1、字符串池上文中,我们并没有对字符串常量池做深入的介绍,因为它涉及JVM内存模型。如果想了解更多JVM的相关内容,可以参考上一篇文章。
简而言之,字符串常量池位于运行时常量池中,而运行时常量池用于存放编译期生成的各种字面常量和符号引用,这部分内容在类加载后存放到方法区的常量池中。
而对于字符串的分配,和其他的对象分配一样,会耗费高昂的时间与空间代价。JVM为了提高性能和减少内存开销,在实例化字符串字面值的时候进行了一些优化。为了减少在JVM中创建的字符串的数量,字符串类维护了一个字符串常量池,每当以字面值形式创建一个字符串时,JVM会首先检查字符串常量池:如果字符串已经存在池中,就返回池中的实例引用;如果字符串不在池中,就会实例化一个字符串并放到池中。Java能够进行这样的优化是因为字符串是不可变的,可以不用担心数据冲突进行共享。 例如:
public class Program{
public static void main(String[] args)
{
String str1 = "Hello";
String str2 = "Hello";
System.out.print(str1 == str2); // true
}
}
一个初始为空的字符串池,它由类 String 私有地维护。当以字面值形式创建一个字符串时,总是先检查字符串池是否含存在该对象,若存在,则直接返回。而通过 new 操作符创建的字符串对象不指向字符串池中的任何对象。
2、将字符串手动加入到常量池中一个初始为空的字符串池,它由类 String 私有地维护。当调用 intern() 方法时,如果常量池中已经包含一个等于此 String 对象的字符串(用 equals(Object) 方法确定),则返回池中的字符串。否则,将此 String 对象添加到池中,并返回此 String 对象的引用。特别地,手动入池遵循以下规则:
对于任意两个字符串 s 和 t ,当且仅当 s.equals(t) 为 true 时(表示内容相等,而不是引用地址相等),s.intern() == t.intern() 才为 true 。
public class TestString{
public static void main(String args[]){
String str1 = "abc";
String str2 = new String("abc");
String str3 = s2.intern();
System.out.println( str1 == str2 ); //false
System.out.println( str1 == str3 ); //true
}
}
所以,对于 String str1 = “abc”,str1 引用的是常量池(常量池位于方法区)中的对象;而 String str2 = new String(“abc”),str2引用的是 堆 中的对象,所以内存地址不一样。但是由于内容一样,所以 str1 和 str3 指向同一对象。
3、实例看下面几个场景来深入理解 String。
1) 情景一:字符串常量池JVM中存在着一个字符串常量池,其中保存着很多String对象(赋值完的String),并且这些 String 对象可以被共享使用,因此提高了效率。之所以字符串具有字符串常量池,是因为String对象是不可变的,因此可以被共享。字符串常量池由 String 类维护,我们可以通过 intern() 方法使字符串池手动加入到常量池中。
String s1 = "abc"; // 在字符串池创建了一个对象"abc"
String s2 = "abc"; // 字符串池中已经存在对象"abc"(共享),所以不会再创建对象
System.out.println("s1 == s2 : "+(s1==s2)); // true 指向同一个对象,
System.out.println("s1.equals(s2) : " + (s1.equals(s2))); // true 值相等
2) 情景二:关于new String("…")
String s3 = new String("abc"); // 创建了两个对象,一个存放在字符串常量池中,一个存在与堆区;
// 还有一个对象引用s3存放在栈中
String s4 = new String("abc"); // 字符串池中已经存在"abc"对象,所以只在堆中创建了一个对象
System.out.println("s3 == s4 : "+(s3==s4)); // false s3和s4栈区的地址不同,指向堆区的不同地址;
System.out.println("s3.equals(s4) : "+(s3.equals(s4))); // true s3和s4的值相同
System.out.println("s1 == s3 : "+(s1==s3)); // false 存放的地区都不同,一个方法区,一个堆区
System.out.println("s1.equals(s3) : "+(s1.equals(s3))); // true 值相同
通过上一篇文章我们知道,通过 new String("…") 来创建字符串时,在该构造函数的参数值为字符串字面值的前提下,若该字面值不在字符串常量池中,那么会创建两个对象:一个在字符串常量池中,一个在堆中;否则,只会在堆中创建一个对象。对于不在同一区域的两个对象,二者的内存地址必定不同。
3) 情景三:字符串连接符"+" String str2 = "ab"; //1个对象
String str3 = "cd"; //1个对象
String str4 = str2 + str3;
String str5 = "abcd";
System.out.println("str4 = str5 : " + (str4==str5)); // false
我们看这个例子,局部变量 str2,str3 指向字符串常量池中的两个对象。在运行时,第三行代码(str2+str3)实质上会被分解成五个步骤,分别是:
a. 调用 String 类的静态方法 String.valueOf() 将 str2 转换为字符串表示;
b. JVM 在堆中创建一个 StringBuilder对象,同时用str2指向转换后的字符串对象进行初始化;
c. 调用StringBuilder对象的append方法完成与str3所指向的字符串对象的合并;
d. 调用 StringBuilder 的 toString() 方法在堆中创建一个 String对象;
e. 将刚刚生成的String对象的堆地址存赋给局部变量引用str4。
而引用str5指向的是字符串常量池中字面值"abcd"所对应的字符串对象。由上面的内容我们可以知道,引用str4和str5指向的对象的地址必定不一样。这时,内存中实际上会存在五个字符串对象: 三个在字符串常量池中的String对象、一个在堆中的String对象和一个在堆中的StringBuilder对象。
4) 情景四:字符串的编译期优化 String str1 = "ab" + "cd"; //1个对象
String str11 = "abcd";
System.out.println("str1 = str11 : "+ (str1 == str11)); // true
final String str8 = "cd";
String str9 = "ab" + str8;
String str89 = "abcd";
System.out.println("str9 = str89 : "+ (str9 == str89)); // true str8为常量变量,编译期会被优化
String str6 = "b";
String str7 = "a" + str6;
String str67 = "ab";
System.out.println("str7 = str67 : "+ (str7 == str67)); // false str6为变量,在运行期才会被解析。
Java 编译器对于类似"常量+字面值"的组合,其值在编译的时候就能够被确定了。在这里,str1 和 str9 的值在编译时就可以被确定,因此它们分别等价于: String str1 = “abcd”; 和 String str9 = “abcd”;
Java 编译器对于含有 "String引用"的组合,则在运行期会产生新的对象 (通过调用StringBuilder类的toString()方法),因此这个对象存储在堆中。
4、小结(1)、使用字面值形式创建的字符串 与 通过 new 创建的字符串一定是不同的,因为二者的存储位置不同:前者在方法区(字符串常量池中),后者在堆;
(2)、我们在使用诸如String str = “abc”;的格式创建字符串对象时,总是想当然地认为,我们创建了String类的对象str。但是事实上, 对象可能并没有被创建。唯一可以肯定的是,指向 String 对象 的引用被创建了。至于这个引用到底是否指向了一个新的对象,必须根据上下文来考虑;
(3)、字符串常量池的理念是 《享元模式》;
(4)、Java 编译器对 “常量+字面值” 的组合是当成常量表达式直接求值来优化的;对于含有"String引用"的组合,其在编译期不能被确定,会在运行期创建新对象。
三、 String、StringBuilder 和 StringBuffer 1、String 与 StringBuilder简要的说, String 类型 和 StringBuilder 类型的主要性能区别在于:String 是不可变的对象。 事实上,在对 String 类型进行“改变”时,实质上等同于生成了一个新的 String 对象,然后将指针指向新的 String 对象。由于频繁的生成对象会对系统性能产生影响,特别是当内存中没有引用指向的对象多了以后,JVM 的垃圾回收器就会开始工作,继而会影响到程序的执行效率。所以,对于经常改变内容的字符串,最好不要声明为 String 类型。但如果我们使用的是 StringBuilder 类,那么情形就不一样了。因为,我们的每次修改都是针对 StringBuilder 对象本身的,而不会像对 String 操作那样去生成新的对象并重新给变量引用赋值。所以,在一般情况下,推荐使用 StringBuilder ,特别是字符串对象经常改变的情况下。
在某些特别情况下,String 对象的字符串拼接可以直接被 JVM 在编译期确定下来,这时,StringBuilder 在速度上就不占任何优势了。
因此,在绝大部分情况下, 在效率方面:StringBuilder > String 。
2、StringBuffer 与 StringBuilder首先需要明确的是,StringBuffer 始于 JDK 1.0,而 StringBuilder 始于 JDK 5.0;此外,从 JDK 1.5 开始,对含有字符串变量 (非字符串字面值) 的连接操作(+),JVM 内部是采用 StringBuilder 来实现的,而在这之前,这个操作是采用 StringBuffer 实现的。
JDK 的实现中 StringBuffer 与 StringBuilder 都继承自 AbstractStringBuilder。AbstractStringBuilder的实现原理为:
AbstractStringBuilder中采用一个 char数组 来保存需要append的字符串,char数组有一个初始大小,
当append的字符串长度超过当前char数组容量时,则对char数组进行动态扩展,即重新申请一段更大的内存空间,
然后将当前char数组拷贝到新的位置,因为重新分配内存并拷贝的开销比较大,所以每次重新申请内存空间都是采用申
请大于当前需要的内存空间的方式,这里是 2 倍。
StringBuffer 和 StringBuilder 都是可变的字符序列,但是二者最大的一个不同点是:StringBuffer 是线程安全的,而 StringBuilder 则不是。StringBuilder 提供的API与 StringBuffer 的API是完全兼容的,即,StringBuffer 与 StringBuilder 中的方法和功能完全是等价的,但是StringBuilder一般要比StringBuffer快。因此,可以这么说,StringBuilder 的提出就是为了在单线程环境下替换 StringBuffer 。在单线程环境下,优先使用 StringBuilder。
3、实例 (1)、编译时优化与字符串连接符的本质我们先来看下面这个例子:
public class Test2 {
public static void main(String[] args) {
String s = "a" + "b" + "c";
String s1 = "a";
String s2 = "b";
String s3 = "c";
String s4 = s1 + s2 + s3;
System.out.println(s);
System.out.println(s4);
}
}
由上面的叙述,我们可以知道,变量 s 的创建等价于 String s = “abc”; 而变量s4的创建相当于:
StringBuilder temp = new StringBuilder(s1);
temp.append(s2).append(s3);
String s4 = temp.toString();
但事实上,是不是这样子呢?我们将其反编译一下,来看看Java编译器究竟做了什么:
//将上述 Test2 的 class 文件反编译
public class Test2
{
public Test2(){}
public static void main(String args[]){
String s = "abc"; // 编译期优化
String s1 = "a";
String s2 = "b";
String s3 = "c";
//底层使用 StringBuilder 进行字符串的拼接
String s4 = (new StringBuilder(String.valueOf(s1))).append(s2).append(s3).toString();
System.out.println(s);
System.out.println(s4);
}
}
根据上面的反编译结果,很好的印证了我们在第六节中提出的字符串连接符的本质。
(2)、另一个例子:字符串连接符的本质由上面的分析结果,我们不难推断出 String 采用连接运算符(+)效率低下原因分析,形如这样的代码:
public class Test {
public static void main(String args[]) {
String s = null;
for(int i = 0; i
关注
打赏
最近更新
- 深拷贝和浅拷贝的区别(重点)
- 【Vue】走进Vue框架世界
- 【云服务器】项目部署—搭建网站—vue电商后台管理系统
- 【React介绍】 一文带你深入React
- 【React】React组件实例的三大属性之state,props,refs(你学废了吗)
- 【脚手架VueCLI】从零开始,创建一个VUE项目
- 【React】深入理解React组件生命周期----图文详解(含代码)
- 【React】DOM的Diffing算法是什么?以及DOM中key的作用----经典面试题
- 【React】1_使用React脚手架创建项目步骤--------详解(含项目结构说明)
- 【React】2_如何使用react脚手架写一个简单的页面?