搞定String

String类详解

通过反编译理解java String和intern

String的不可变性

1. 概述

String 类代表字符串。Java 程序中的所有字符串字面值(如 "abc" )都作为此类的实例(对象)实现
字符串是常量;它们的值在创建之后不能更改。因为 String 对象是不可变的,所以可以共享。字符串缓冲区支持可变的字符串

2. String的实现以及不可变设计

String是不可变类,所谓的不可变类是指这个类的实例一旦创建完成后,就不能改变其成员变量值;

James Gosling 说迫使 String 类设计成不可变的一个原因是安全,当你在调用其他方法时,比如调用一些系统级操作指令之前,可能会有一系列校验,如果是可变类的话,可能在你校验过后,它的内部的值又被改变了,这样有可能会引起严重的系统崩溃问题

同时使用 final能够缓存结果,当你在传参时不需要考虑谁会修改它的值;如果是可变类的话,则有可能需要重新拷贝出来一个新值进行传参,这样在性能上就会有一定的损失

不可变类在设计的时候一般有以下特点,并且他们在String类的设计中都有体现

① 类添加final修饰符,保证类不被继承
如果类可以被继承会破坏类的不可变性机制,只要继承类覆盖父类的方法并且继承类可以改变成员变量值,那么一旦子类以父类的形式出现时,不能保证当前类是否可变

对于这一点我们看String的类的签名,的确如此

public final class String
    implements java.io.Serializable, Comparable<String>, CharSequence {
}

② 保证所有成员变量必须私有,并且加上final修饰
通过这种方式保证成员变量不可改变,String 内部实际存储结构为 char 数组,而这个char数组就是final修饰的,也就是说这个变量只可读,不能修改,初始化在最初或构造方法完成(但是这里final修饰的是数组,只是数组的引用不可改变,数组的内容可能改变,这个问题的解决下面再说)

public final class String
    implements java.io.Serializable, Comparable<String>, CharSequence {
    // 用于存储字符串的值
    private final char value[];
    // 缓存字符串的 hash code
    private int hash; // Default to 0
    // ......其他内容
}

③ 通过构造器初始化所有成员,进行深拷贝(deep copy)

在第②点中,如果是对象成员变量有可能在外部改变其值,因为他和外部指向的是同一块内存地址;为了保证内部的值不被修改,可以采用深度copy来创建一个新内存保存传入的值

String提供了多种构造方法

String() //初始化一个新创建的 String 对象,使其表示一个空字符序列

String(byte[] bytes) //通过使用平台的默认字符集解码指定的 byte 数组,构造一个新的 String。 

String(byte[] bytes, Charset charset) // 通过使用指定的 charset 解码指定的 byte 数组,构造一个新的 String。  

String(byte[] bytes, int offset, int length) //通过使用平台的默认字符集解码指定的 byte 子数组,构造一个新的 String。 

String(byte[] bytes, int offset, int length, Charset charset) // 通过使用指定的 charset 解码指定的 byte 子数组,构造一个新的 String。 

String(byte[] bytes, int offset, int length, String charsetName) //通过使用指定的字符集解码指定的 byte 子数组,构造一个新的 String。 

String(byte[] bytes, String charsetName) // 通过使用指定的 charset 解码指定的 byte 数组,构造一个新的 String。 

String(char[] value) //分配一个新的 String,使其表示字符数组参数中当前包含的字符序列。 

String(char[] value, int offset, int count) //分配一个新的 String,它包含取自字符数组参数一个子数组的字符。 

String(int[] codePoints, int offset, int count) //分配一个新的 String,它包含 Unicode 代码点数组参数一个子数组的字符。 

String(String original) //初始化一个新创建的 String 对象,使其表示一个与参数相同的字符序列;换句话说,新创建的字符串是该参数字符串的副本。 

String(StringBuffer buffer) //分配一个新的字符串,它包含字符串缓冲区参数中当前包含的字符序列。 

String(StringBuilder builder) //配一个新的字符串,它包含字符串生成器参数中当前包含的字符序列。

这里可以看到,如果构造方法传递的是一个char数组那么为了保证他的不可变性,实际上是创建了新的char数组,这样外界的char数组改变就不会影响内部的成员变量的char数组,这个也叫做保护性拷贝的设计模式

④ 不提供改变成员变量的方法,包括setter
避免通过其他接口改变成员变量的值,破坏不可变特性。

⑤ 在getter方法中,不要直接返回对象本身,而是克隆对象,并返回对象的拷贝
为了说明这一点,我看和他性质类似的subString()方法

public String substring(int beginIndex) {
        if (beginIndex < 0) {
            throw new StringIndexOutOfBoundsException(beginIndex);
        }
        int subLen = length() - beginIndex;
        if (subLen < 0) {
            throw new StringIndexOutOfBoundsException(subLen);
        }
        if (beginIndex == 0) {
            return this;
        }
        return new String(value,beginIndex,subLen);
        
    }

并没有改变原始的字符串,而是创建了新的字符串,创建新字符串对象也使用了保护性拷贝

String对象的不可变性的优点

① 字符串常量池的需要(下面说)
字符串常量池可以将一些字符常量放在常量池中重复使用,避免每次都重新创建相同的对象、节省存储空间。

但如果字符串是可变的,此时相同内容的String还指向常量池的同一个内存空间,当某个变量改变了该内存的值时,其他遍历的值也会发生改变。所以不符合常量池设计的初衷。

常量池是一个固定大小的HashTable实现的

② 线程安全考虑
同一个字符串实例可以被多个线程共享。这样便不用因为线程安全问题而使用同步。字符串自己便是线程安全的。

③ 类加载器要用到字符串,不可变性提供了安全性,以便正确的类被加载

④ 支持hash映射和缓存
因为字符串是不可变的,所以在它创建的时候hashcode就被缓存了,不需要重新计算。这就使得字符串很适合作为Map中的键,字符串的处理速度要快过其它的键对象。这就是HashMap中的键往往都使用字符串

补充说明

上面的代码对于Jdk1.8来说的,Jdk1.9以后就变了,内部不再使用一个char[]数组,而是使用一个byte[]数组来存储String
在这里插入图片描述
这是因为一个char占两个字节,大部分的String对象包含的都是拉丁字符,一个字节就存的下,这样再使用char就会浪费一半的空间,所以对于ISO-8859-1Latin-1编码的就用一个字节去存;
除此之外,还有汉字,汉字需要使用两个字节去存储,所以除了byte数组以外还补充了一个字符编码集的标识,如果是UTF-16之类的需要两个字节存储的就用两个byte一组存储

3. 享元模式

因为String太过常用,JAVA类库的设计者在实现时做了个小小的变化,即采用了享元模式;

每当生成一个新内容的字符串时,他们都被添加到一个共享池(字符串常量池)中,当第二次再次生成同样内容的字符串实例时,就共享此对象,而不是创建一个新对象,但是这样的做法仅仅适合于通过=符号进行的初始化;

包装类BooleanShortIntegerLong等也使用了享元模式

String 常见的创建方式有两种,new String() 的方式和直接赋值的方式;

直接赋值的方式会先去字符串常量池中查找是否已经有此值,如果有则把引用地址直接指向此值,否则会先在常量池中创建,然后再把引用指向此值;

new String() 的方式一定会先在堆上创建一个字符串对象,然后再去常量池中查询此字符串的值是否已经存在,如果不存在会先在常量池中创建此字符串,然后把引用的值指向此字符串,如下代码所示:

String s1 = new String("Java");
String s2 = s1.intern();
String s3 = "Java";
System.out.println(s1 == s2); // false
System.out.println(s2 == s3); // true

在这里插入图片描述
在上面说过迫使 String 类设计成不可变的一个原因是安全,当你在调用其他方法时,比如调用一些系统级操作指令之前,可能会有一系列校验,如果是可变类的话,可能在你校验过后,它的内部的值又被改变了,这样有可能会引起严重的系统崩溃问题

只有字符串是不可变时,我们才能实现字符串常量池,字符串常量池可以为我们缓存字符串,提高程序的运行效率,如下图所示:

在这里插入图片描述
试想一下如果 String 是可变的,那当 s1 的值修改之后,s2 的值也跟着改变了,这样就和我们预期的结果不相符了,因此也就没有办法实现字符串常量池的功能了

一个面试题

public class Apple {
    public static void main(String[] args) {
        String a = "abc";
        String b = "abc";
        String c = new String("abc");
        System.out.println(a==b);  //true
        System.out.println(a.equals(b));  //true
        System.out.println(a==c);  //false
        System.out.println(a.equals(c));  //true
    }
}

4. String对"+"的重载

Java 是不支持重载运算符,String 的 “+” 是 java 中唯一的一个重载运算符

public static void main(String[] args) {
     String string = "hello";
     String string2 = string + "world";
}

实际执行如下

public static void main(String args[]){
     String string = "hello";
     String string2 = (new StringBuilder(String.valueOf(string))).append("world").toString();
}

再看下一个

public class TestClass3 {
    public static void main(String[] args) {
        String str = new String("a")+new String("b");
    }
}

实际执行如下

public static void main(String args[]){
     String string = "hello";
     String str = (new StringBuilder()).append("hello").append("world").toString();
}

反编译字节码如下

Code:
      stack=4, locals=2, args_size=1
         0: new           #2                  // class java/lang/StringBuilder 
         3: dup
         4: invokespecial #3                  // Method java/lang/StringBuilder."<init>":()V
         7: new           #4                  // class java/lang/String
        10: dup
        11: ldc           #5                  // String a
        13: invokespecial #6                  // Method java/lang/String."<init>":(Ljava/lang/String;)V
        16: invokevirtual #7                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
        19: new           #4                  // class java/lang/String
        22: dup
        23: ldc           #8                  // String b
        25: invokespecial #6                  // Method java/lang/String."<init>":(Ljava/lang/String;)V
        28: invokevirtual #7                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
        31: invokevirtual #9                  // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
        34: astore_1
        35: return

可以看到
首先为StringBuilder对象开辟空间,并调用初始化方法(0-4)
然后创建String("a"),这里和上面的分析一样,在常量池中和堆空间中分别有一个对象(7-13)
调用append方法(16)
再初始化String("b"),也是堆里一个常量池里一个,并调用StringBuilderappend方法(19-28)
最后调用StringBuildertoString()方法,toString方法也会返回一个对象,看一下他的源码

@Override
public String toString() {
    // Create a copy, don't share the array
    return new String(value, 0, count);
}

但是这里有一个问题,那就是toStirng("ab")的时候并不会在常量池中创建"ab",这一点和new String("ab")有所不同
所以这里一个有6个对象

可以看到变量和常量的拼接是在堆里面的,但是编译器还会对 String 字符串做一些优化,常量与常量的拼接是在常量池中的

String s1 = "Ja" + "va";
String s2 = "Java";
System.out.println(s1 == s2);//true

虽然 s1 拼接了多个字符串,但对比的结果却是 true,我们使用反编译工具,看到的结果如下

Compiled from "StringExample.java"
public class com.lagou.interview.StringExample {
  public com.lagou.interview.StringExample();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return
    LineNumberTable:
      line 3: 0

  public static void main(java.lang.String[]);
    Code:
       0: ldc           #2                  // String Java
       2: astore_1
       3: ldc           #2                  // String Java
       5: astore_2
       6: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
       9: aload_1
      10: aload_2
      11: if_acmpne     18
      14: iconst_1
      15: goto          19
      18: iconst_0
      19: invokevirtual #4                  // Method java/io/PrintStream.println:(Z)V
      22: return
    LineNumberTable:
      line 5: 0
      line 6: 3
      line 7: 6
      line 8: 22
}

从编译代码 #2 可以看出,代码 "Ja"+"va" 被直接编译成了 "Java" ,因此 s1==s2 的结果才是 true,这就是编译器对字符串优化的结果

总结一下

  • 常量与常量的拼接的结果是在常量池中的,原理是编译期的优化
  • 只要其中有一个变量,结果就在堆中(非常量池),变量拼接的结果是StringBuilder

5. Stringintern() 方法有什么含义?

当调用 intern 方法时,如果池已经包含一个等于此 String 对象的字符串(该对象由 equals(Object) 方法确定),则返回池中的字符串,否则,将此 String 对象添加到池中,并且返回此 String 对象的引用

再jdk1.6和1.7以后不太一样,总结起来如下

jdk1.6中,将这个字符串对象尝试放入串池

  • 如果串池中有,则不会放入,返回已有的串池中的对象地址
  • 如果没有,会把对象赋值一份,放入串池,返回串池中的对象的地址

jdk1.7起,将这个字符串对象尝试放入串池

  • 如果串池中有,则不会放入,返回已有的串池中的对象地址
  • 串池中没有,而会把这个对象的引用地址复制一份,放入串池,并返回串池中的引用地址

出现这个变化的原因主要就是字符串常量池在jdk1.7后从方法区移到了堆里,为了节省堆里的内存,直接重用了堆里的对象

String str1 = "a";
String str2 = "b";
String str3 = "ab";
String str4 = str1 + str2;
String str5 = new String("ab");
 
System.out.println(str5.equals(str3));//true
System.out.println(str5 == str3);//false
System.out.println(str5.intern() == str3);//true
System.out.println(str5.intern() == str4);//false

intern确保了字符串在内存中只有一份拷贝,可以节省内存空间,加快字符串操作任务的执行速度

** new String(“abc”)究竟会创建几个对象**

Code:
      stack=3, locals=2, args_size=1
         0: new           #2                  // class java/lang/String
         3: dup
         4: ldc           #3                  // String ab
         6: invokespecial #4                  // Method java/lang/String."<init>":(Ljava/lang/String;)V
         9: astore_1
        10: return

首先使用new就会在堆空间开辟一块空间,ldc就是把常量池中的字符串"ab"拿出来,之后再调用String的构造器初始化堆中的String对象;

对于intern方法对于不同jdk版本的体现看下面代码

public class TestClass3 {
    public static void main(String[] args) {
        String s3 = new String("1") + new String("2");//s3的地址为堆中的地址
        //执行完上述的代码,字符串常量池有1 2 但是没有12
        s3.intern();//在字符串常量池生成12
        String s4 = "12";//地址是常量池中的地址
        System.out.println(s3 == s4);
        //jdk1.6:false 这很好理解,一个指向堆中,一个指向常量池,地址不一样
                                     
  		//jdk1.7:true 在jdk1.7之后,字符串常量池从方法区放到了堆里,而堆中为了节省内存,使用intern方法时发现堆中已经有对象12了,会把这个对象的引用地址复制一份,放入串池,并返回串池中的引用地址
    }
}

6. equals() 比较两个字符串是否相等

在对字符串比较的时候,对比的是内存地址,而equals比较的是字符串内容,在开发的过程中,equals()通过接受参数,可以避免空指向

String str = null;
if(str.equals("hello")){//此时会出现空指向异常
	...
}
if("hello".equals(str)){//此时equals会处理null值,可以避免空指向异常
	...
}

源码如下

public boolean equals(Object anObject) {
    // 对象引用相同直接返回 true
    if (this == anObject) {
        return true;
    }
    // 判断需要对比的值是否为 String 类型,如果不是则直接返回 false
    if (anObject instanceof String) {
        String anotherString = (String)anObject;
        int n = value.length;
        if (n == anotherString.value.length) {
            // 把两个字符串都转换为 char 数组对比
            char v1[] = value;
            char v2[] = anotherString.value;
            int i = 0;
            // 循环比对两个字符串的每一个字符
            while (n-- != 0) {
                // 如果其中有一个字符不相等就 true false,否则继续对比
                if (v1[i] != v2[i])
                    return false;
                i++;
            }
            return true;
        }
    }
    return false;
}

7. StringStringBuilderStringBuffer 的区别

因为 String 类型是不可变的,所以在字符串拼接的时候如果使用 String 的话性能会很低,因此我们就需要使用另一个数据类型 StringBuffer,它提供了 appendinsert 方法可用于字符串的拼接,它使用 synchronized 来保证线程安全,如下源码所示:

@Override
public synchronized StringBuffer append(Object obj) {
    toStringCache = null;
    super.append(String.valueOf(obj));
    return this;
}

@Override
public synchronized StringBuffer append(String str) {
    toStringCache = null;
    super.append(str);
    return this;
}

因为它使用了 synchronized 来保证线程安全,所以性能不是很高,于是在 JDK 1.5 就有了 StringBuilder,它同样提供了 appendinsert 的拼接方法,但它没有使用 synchronized 来修饰,因此在性能上要优于 StringBuffer,所以在非并发操作的环境下可使用 StringBuilder 来进行字符串拼接

8. String对象的是否真的不可变

虽然String对象将value设置为final,并且还通过各种机制保证其成员变量不可改变。但是还是可以通过反射机制的手段改变其值。例如:

//创建字符串"Hello World", 并赋给引用s
String s = "Hello World"; 
System.out.println("s = " + s);	//Hello World

//获取String类中的value字段
Field valueFieldOfString = String.class.getDeclaredField("value");
//改变value属性的访问权限
valueFieldOfString.setAccessible(true);

//获取s对象上的value属性的值
char[] value = (char[]) valueFieldOfString.get(s);
//改变value所引用的数组中的第5个字符
value[5] = '_';
System.out.println("s = " + s);  //Hello_World
//s = Hello World
//s = Hello_World

9. 空串与Null

空串 "" 是长度为 0 的字符串。可以调用以下代码检查一个字符串是否为空:

if (str.length() == 0)
//或者
if (str.equals("")) 

空串是一个 Java 对象, 有自己的串长度(0 ) 和内容(空)
不过,String 变量还可以存 放一个特殊的值, 名为 null, 这表示目前没有任何对象与该变量关联,要检查一个字符串是否为 null, 要使用以下条件:

 if (str == null)

有时要检查一个字符串既不是 null 也不为空串,这种情况下就需要使用以下条件:

if (str != null && str.length() != 0) 

首先要检查str 不为 null。在第 4 章会看到, 如果在一个 null 值上调用方法, 会出现 错误

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值