String的再深入

一、引言

上文中,分析了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 < 100; i++) { 
                s += "a"; 
            } 
    }
}

会被编译器编译为:

public class Test
{
    public Test(){}
    
    public static void main(String args[])
    {
        String s = null;
        for (int i = 0; i < 100; i++)
            s = (new StringBuilder(String.valueOf(s))).append("a").toString();
    }
}

也就是说,每做一次字符串连接操作 “+” 就产生一个 StringBuilder 对象,然后 append 后就扔掉。下次循环再到达时,再重新 new 一个 StringBuilder 对象,然后 append 字符串,如此循环直至结束。事实上,如果我们直接采用 StringBuilder 对象进行 append 的话,我们可以节省 N - 1 次创建和销毁对象的时间。所以,对于在循环中要进行字符串连接的应用,一般都是用StringBulider对象来进行append操作。

四、 字符串与正则表达式:匹配、替换和验证

正则表达式:用一个字符串来描述一个特征,然后去验证另一个字符串是否符合这个特征。

使用正则表达式,我们能够以编程的方式,构造复杂的文本模式,并对输入的字符串进行搜索。Java 内置了对正则表达式的支持,其相关的类库在 java.util.regex 包下,感兴趣的读者可以去查看相应的 API 和 JDK 源码。在使用过程中,有两点需要注意以下:

1、Java转义与正则表达式转义
要想匹配某些特殊字符,比如 “\”,需要进行两次转义,即Java转义与正则表达式转义。
2、使用 Pattern 与 Matcher 构造功能强大的正则表达式对象
Pattern 与 Matcher的组合就是Java对正则表达式的主要内置支持

五、String 总结

1、使用字面值形式创建字符串时,不一定会创建对象,但其引用一定指向位于字符串常量池的某个对象;

2、使用 new String(“…”)方式创建字符串时,一定会创建对象,甚至可能会同时创建两个对象(一个位于字符串常量池中,一个位于堆中);

3、String 对象是不可变的,对String 对象的任何改变都会导致一个新的 String 对象的产生,而不会影响到原String 对象;

4、StringBuilder 与 StringBuffer 具有共同的父类,具有相同的API,分别适用于单线程和多线程环境下。特别地,在单线程环境下,StringBuilder 是 StringBuffer 的替代品,前者效率相对较高;

5、字符串比较时用的什么方法,内部实现如何?

使用equals方法 : 先比较引用是否相同(是否是同一对象),再检查是否为同一类型(str instanceof String), 
最后比较内容是否一致(String 的各个成员变量的值或内容是否相同)。这也同样适用于诸如 Integer 等的八种包装器类。
  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

止步前行

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值