有关Java字符串的一些知识

字符串实现

String内部由字符数组实现,不同于一般的字符数组,每个String都是由一个特殊字符\0结尾的

属性
private final char[] value;//一个final类型的字符数组,用于存储字符串的内容。从final的关键字可以看出,String的内容一旦被初始化后,是不能再更改的。
private int hash;//用于存储当前字符串的hash码
构造方法

传入字符串构造一个新的字符串

public String(String original) {
    this.value = original.value;//新创建的字符串对象是传入的字符串参数的一个副本
    this.hash = original.hash;
}

通过传入的一个字符数组来构建一个空的String对象,新创建的String对象是传入的字符数组的
一个副本,后续你对该字符数组对象的修改不会影响到当前新创建的String对象

public String(char value[]) {
    this.value = Arrays.copyOf(value, value.length);
}

在Java中,String实例中保存有一个char[]字符数组,String 和 char 为内存形式,byte是网络传输或存储的序列化形式。所以在很多传输和存储的过程中需要将byte[]数组和String进行相互转化。所以,String提供了一系列重载的构造方法来将一个字节数组转化成String,提到byte[]和String之间的相互转换就不得不关注编码问题。

/**
 *传入字节数组,通过decode方法将指定的字节数组按照指定的字符集进行解码。
 */
public String(byte bytes[], int offset, int length, String charsetName)
    throws UnsupportedEncodingException {
    this.value = StringCoding.decode(charsetName, bytes, offset, length);
}

/**
 *使用指定的字符集编码将String对象编码成一个字节数组
 */
public byte[] getBytes(String charsetName)
    throws UnsupportedEncodingException {
    return StringCoding.encode(charsetName, value, 0, value.length);
}
String s = "123";
其他主要方法
public String substring(int beginIndex, int endIndex) {
    ...
    return ((beginIndex == 0) && (endIndex == count)) ? this :
        new String(offset + beginIndex, endIndex - beginIndex, value);
}

public String concat(String str) {
    ...
    return new String(0, count + otherLen, buf);
}


public String replace(char oldChar, char newChar) {
    ...
    return new String(0, len, buf);
}

无论是substring、concat还是replace操作都不是在原有的字符串上进行的,而是重新生成了一个新的字符串对象。也就是说进行这些操作后,最原始的字符串并没有被改变。

JVM中的字符串

字符串常量池

在JVM运行时区域的方法区中,有一块区域是运行时常量池,主要用来存储编译期生成的各种字面量和符号引用。其中存放字符串字面量的,就叫字符串常量池。目的为了减少在JVM中重复创建相同字符串的次数。每当创建字符串常量时,JVM会首先检查字符串常量池,如果该字符串已经存在常量池中,那么就直接返回常量池中的实例引用。如果字符串不存在常量池中,就会实例化该字符串并且将其放到常量池中。

每个Java类编译后会生成对应的class文件,文件中的一部分重要内容就是ConstantPool,可以把它称作静态常量池,其中存放的就是在类加载阶段需要被放到运行时常量池的内容。包含字符串(数字)字面量,还包含类、方法的信息

创建字符串的两种形式分析
String s = "123";

首先,编译过程中,123会被加入到class文件的静态常量池。可以结合代码反编译查看。
在运行期,加载该类的时候JVM首先会去字符串池中查找是否存在"123"这个对象,如果不存在,则在字符串池中创建"123"这个对象,然后将池中"123"这个对象的引用地址返回给s,这样s会指向池中"123"这个字符串对象;如果存在,则直接将池中"123"这个对象的地址返回,赋给s。

String s = new String("123");

在编译期,符号引用s和字面量123都会被加入到Class文件的常量池中,在类加载阶段,被加载到方法区中的常量池。加载之前会进行判重。

所以,在编译期,Class文件的常量池中有123,在类加载阶段,如果常量池中已经有123,则不会再创建对象,反之,则会创建一个123的字面量对象。在执行阶段,new String("123")则一定会在堆中创建一个字符串对象,它的值指向常量池中的123,所以这一句代码根据情况不同,会创建1个或两个对象。

可以通过javap命令查看class文件,查看ConstantPool的情况

字符串的拼接

结合编译生成的字节码查看

String s1 = "1" + "2" + "3";//等价于"123"
String s2 = s1 + "4";//new StringBuilder(s1).append("4").toString();
String s3 = "0" + "0" + s2 + "5";//new StringBuilder("00").append(s2).append("5");

字面量"+"拼接是在编译期间进行的,拼接后的字符串存放在字符串常量池中;而字符串引用的"+"拼接运算是在运行时进行的,新创建的字符串存放在堆中。

  • 单独使用""引号创建的字符串都是常量,编译期就已经确定存储到常量池中;
  • 使用new String("")创建的对象会存储到heap中,是运行期新创建的;
  • 使用只包含常量的字符串连接符如"aa" + "aa"创建的也是常量,编译期就能确定,已经确定存储到常量池中;
  • 使用包含变量的字符串连接符如"aa" + s1创建的对象是运行期才创建的,存储在heap中;

查看一下每个class文件中ConstantPool的内容。会发现,如果拼接成员中有变量,会导致最终的拼接结果无法被加入常量池。这种情况下如何能手动的把想要重复使用但又无法在编译期确定的字符串加入到常量池?

需要用到String.intern()方法

intern

当调用 intern 方法时,如果常量池中已经该字符串,则返回池中的字符串;否则将此字符串添加到常量池中,并返回字符串的引用。

String s5 = "123";
String s6 = new String("1") + "23";
System.out.println(s5 == s6);//false
System.out.println(s5 == s6.intern());//true
//s6.intern()会返回常量池中的123的引用,而s5本身就指向常量池中的123,所以为true
应用

面对大量的、已知取值范围的字符串,可以使用intern,减少重复创建的次数。

public class Person{
    String name;
    public void setType(String paramString){
        name = paramString;
    }
}

比如我们需要给1w+个用户依次setType,type一共有三个可能的值,但是不知道具体是哪三个值,需要从数据库或配置文件中读取。这时采用intern方法可以在每次赋值的时候检查常量池,有相应的值即可重复利用,最终1w+个用户只会引用着常量池中的三个对象。

jdk7的优化及改变

jdk7开始,常量池被移到堆中,intern方法也做了一些相应的优化

String s1 = new StringBuilder("hello").append("world").toString();
System.out.println(s1.intern() == s1);//true
String s2 = new StringBuilder("hello").append("world").toString();
System.out.println(s2.intern() == s2);//false
System.out.println(s2.intern() == s1);//true

jdk7之前,intern方法会把首次遇到的字符串实例复制到常量池中,返回的也是常量池中这个字符串的引用。jdk7之后,intern方法不会再复制实例,而是在常量池中记录首次出现的实例引用,也就是s1.intern()会返回s1,所以第一个判断是true。
在执行到s2.intern()的时候,由于常量池中已经有s1了,会直接返回s1,所以判断为false。如果改成s2.intern() == s1则为true。

hashcode方法

hashcode计算公式

s[0] * 31^(n-1) + s[1] * 31^(n-2) + ... + s[n-1]

通用版
s[0] * x^(n-1) + s[1] * x^(n-2) + ... + s[n-1]

选择x=31的原因?
参考链接1
参考链接2

31=2^5-1,n*31=n<<5-1,计算效率高
减少hash冲突,即分布更均匀
31是一个质数 
//采用质数作为hash的乘数是数学上的一个传统,未查到可靠的资料证明质数一定更好,但作者明确表示过之所以选择31而不选择计算效率相近的33,就是因为31是质数

而偶数的缺点在于,如果出现乘法溢出的话,将导致信息丢失,因为乘2是移位操作

在Java中,整型数是32位的,也就是说最多有2^32= 4294967296个整数,将任意一个字符串,经过hashCode计算之后,得到的整数应该在这4294967296数之中。那么,最多有 4294967297个不同的字符串作hashCode之后,肯定有两个结果是一样的, hashCode可以保证相同的字符串的hash值肯定相同,但是,hash值相同并不一定是value值就相同。

所以不能使用String的hashcode作为唯一标识

上一篇:【Java必修课】String.intern()原来还能这么用(原理与应用)


下一篇:JVM笔记:Java虚拟机的常量池