Java源码分析——String的设计

因为学习分析源码,所以借鉴了HollisChuang成神之路的大部分内容,并在此基础上对源码进行了学习,在此感谢。


等风来

问题的引入

关于String字符串,对于Java开发者而言,这无疑是一个非常熟悉的类。也正是因为经常使用,其内部代码的设计才值得被深究。所谓知其然,更得知其所以然。

举个例子,假如想要写个类去继承String,这时IDE提示String为final类型不允许被继承。


 

此时最先想到的肯定是java中类被final修饰的效果,其实由这一点也可以引出更多思考:
比如说String类被设计成final类型是出于哪些考虑?

在Java中,被final类型修饰的类不允许被其他类继承,被final修饰的变量赋值后不允许被修改


定义

查看String类在jdk7源码中的定义:

publicfinalclassStringimplementsjava.io.Serializable,Comparable<String>,CharSequence{...}

可以看出String是final类型的,表示该类不能被其他类继承,同时该类实现了三个接口:java.io.Serializable Comparable<String> CharSequence

对于Sting类,官方有如下注释说明:

/*Stringsareconstant;theirvaluescannotbechangedafterthey
arecreated.Stringbufferssupportmutablestrings.
BecauseStringobjectsareimmutabletheycanbeshared.Forexample:*/

String字符串是常量,其值在实例创建后就不能被修改,但字符串缓冲区支持可变的字符串,因为缓冲区里面的不可变字符串对象们可以被共享。(其实就是使对象的引用发生了改变)


属性

/**Thevalueisusedforcharacterstorage.*/
privatefinalcharvalue[];

这是一个字符数组,并且是final类型,用于存储字符串内容。从fianl关键字可以看出,String的内容一旦被初始化后,其不能被修改的。

看到这里也许会有人疑惑,String初始化以后好像可以被修改啊。比如找一个常见的例子:
String str = “hello”; str = “hi”
其实这里的赋值并不是对str内容的修改,而是将str指向了新的字符串另外可以明确的一点:String其实是基于字符数组char[]实现的。

/**Cachethehashcodeforthestring*/
privateinthash;//Defaultto0

缓存字符串的hash Code,其默认值为 0

/**useserialVersionUIDfromJDK1.0.2forinteroperability*/
privatestaticfinallongserialVersionUID=-6849794470754667710L;

/**ClassStringisspecialcasedwithintheSerializationStreamProtocol.*/
privatestaticfinalObjectStreamField[]serialPersistentFields=  newObjectStreamField[0]

因为String实现了Serializable接口,所以支持序列化和反序列化支持。Java的序列化机制是通过在运行时判断类的serialVersionUID来验证版本一致性的。在进行反序列化时,JVM会把传来的字节流中的serialVersionUID与本地相应实体(类)的serialVersionUID进行比较,如果相同就认为是一致的,可以进行反序列化,否则就会出现序列化版本不一致的异常(InvalidCastException)。


构造方法

空的构造器

  this.value="".value;
}

该构造方法会创建空的字符序列,注意这个构造方法的使用,因为创造不必要的字符串对象是不可变的。因此不建议采取下面的创建String对象:
String str = new String()
str = "sample";

这样的结果显而易见,会产生了不必要的对象。

使用字符串类型的对象来初始化

publicString(Stringoriginal){
  this.value=original.value;
  this.hash=original.hash;
}

这里将直接将源String中的value和hash两个属性直接赋值给目标String。因为String一旦定义之后是不可以改变的,所以也就不用担心改变源String的值会影响到目标String的值。

使用字符数组来构造

publicString(charvalue[]){
    this.value=Arrays.copyOf(value,value.length);
}
publicString(charvalue[],intoffset,intcount){
  if(offset<0){
    thrownewStringIndexOutOfBoundsException(offset);
  }
  if(count<=0){
    if(count<0){
      thrownewStringIndexOutOfBoundsException(count);
    }
    if(offset<=value.length){
      this.value="".value;
      return;
    }
  }

 //Note:offsetorcountmightbenear-1>>>1.
  if(offset>value.length-count){
      thrownewStringIndexOutOfBoundsException(offset+count);
  }
 this.value=Arrays.copyOfRange(value,offset,offset+count);
}
`

这里值得注意的是:当我们使用字符数组创建String的时候,会用到Arrays.copyOf方法或Arrays.copyOfRange方法。这两个方法是将原有的字符数组中的内容逐一的复制到String中的字符数组中。会创建一个新的字符串对象,随后修改的字符数组不影响新创建的字符串。

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

String(byte[] bytes, Charset charset)

该构造方法是指通过charset来解码指定的byte数组,将其解码成unicode的char[]数组,够造成新的String。
这里的bytes字节流是使用charset进行编码的,想要将他转换成unicode的char[]数组,而又保证不出现乱码,那就要指定其解码方式**

同样使用字节数组来构造String也有很多种形式,按照是否指定解码方式分的话可以分为两种:

publicString(bytebytes[]){
  this(bytes,0,bytes.length);
}
publicString(bytebytes[],intoffset,intlength){
  checkBounds(bytes,offset,length);
    this.value=StringCoding.decode(bytes,offset,length);
}

如果我们在使用byte[]构造String的时候,使用的是下面这四种构造方法(带有charsetName或者charset参数)的一种的话,那么就会使用StringCoding.decode方法进行解码,使用的解码的字符集就是我们指定的charsetName或者charset。

String(byte bytes[]) String(byte bytes[], int offset, int length)
String(byte bytes[], Charset charset)
String(byte bytes[], String charsetName)
String(byte bytes[], int offset, int length, Charset charset)
String(byte bytes[], int offset, int length, String charsetName)

们在使用byte[]构造String的时候,如果没有指明解码使用的字符集的话,那么StringCoding的decode方法首先调用系统的默认编码格式,如果没有指定编码格式则默认使用ISO-8859-1编码格式进行编码操作。主要体现代码如下:

staticchar[]decode(byte[]ba,intoff,intlen){
    Stringcsn=Charset.defaultCharset().name();
  try{ //usecharsetnamedecode()variantwhichprovidescaching.
         returndecode(csn,ba,off,len);
  }catch(UnsupportedEncodingExceptionx){
   warnUnsupportedCharset(csn);
  }
  try{
    returndecode("ISO-8859-1",ba,off,len);  }catch(UnsupportedEncodingExceptionx){
    //IfthiscodeishitduringVMinitialization,MessageUtilsis
    //theonlywaywewillbeabletogetanykindoferrormessage.
    MessageUtils.err("ISO-8859-1charsetnotavailable:"+x.toString());
    //IfwecannotfindISO-8859-1(arequiredencoding)thenthings
    //areseriouslywrongwiththeinstallation.
    System.exit(1);
    returnnull;
  }
}

使用StringBuffer和StringBuider构造一个String
作为String的两个“兄弟”,StringBuffer和StringBuider也可以被当做构造String的参数。

public String(StringBuffer buffer) {
    synchronized(buffer) {
    this.value = Arrays.copyOf(buffer.getValue(), buffer.length());
    } 
} 
public String(StringBuilder builder) {
     this.value = Arrays.copyOf(builder.getValue(), builder.length());
}

当然,这两个构造方法是很少用到的,因为当我们有了StringBuffer或者StringBuilfer对象之后可以直接使用他们的toString方法来得到String。

关于效率问题,Java的官方文档有提到说使用StringBuilder的toString方法会更快一些,原因是StringBuffer的toString方法是synchronized的,在牺牲了效率的情况下保证了线程安全。

StringBuilder的toString()方法:

@Override
publicStringtoString(){
  //Createacopy,don'tsharethearray
  returnnewString(value,0,count);
}

StringBuffer的toString()方法:

@Override
publicsynchronizedStringtoString(){
  if(toStringCache==null){
    toStringCache=Arrays.copyOfRange(value,0,count);
  }
  returnnewString(toStringCache,true);
}

一个特殊的保护类型的构造方法
String除了提供了很多公有的供程序员使用的构造方法以外,还提供了一个保护类型的构造方法(Java 7),我们看一下他是怎么样的:

String(char[] value, boolean share) {
 // assert share : "unshared not supported";
 this.value = value;
}

从代码中我们可以看出,该方法和String(char[] value)有两点区别:

  • 第一个,该方法多了一个参数:boolean share,其实这个参数在方法体中根本没被使用。注释说目前不支持false,只使用true。

    那可以断定,加入这个share的只是为了区分于String(char[] value)方法,不加这个参数就没办法定义这个函数,只有参数是不能才能进行重载。

  • 第二个区别就是具体的方法实现不同。我们前面提到过,String(char[] value)方法在创建String的时候会用到Arrays的copyOf方法将value中的内容逐一复制到String当中,而这个String(char[] value, boolean share)方法则是直接将value的引用赋值给String的value。

    那么也就是说,这个方法构造出来的String和参数传过来的char[] value共享同一个数组。

为什么Java会提供这样一个方法呢?

  • 性能好

    这个很简单,一个是直接给数组赋值(相当于直接将String的value的指针指向char[]数组),一个是逐一拷贝。当然是直接赋值快了。

  • 节约内存

    该方法之所以设置为protected,是因为一旦该方法设置为公有,在外面可以访问的话,如果构造方法没有对arr进行拷贝,那么其他人就可以在字符串外部修改该数组,由于它们引用的是同一个数组,因此对arr的修改就相当于修改了字符串,那就破坏了字符串的不可变性。

  • 安全的

    对于调用他的方法来说,由于无论是原字符串还是新字符串,其value数组本身都是String对象的私有属性,从外部是无法访问的,因此对两个字符串来说都很安全。

Java7加入的新特性

在Java 7 之前有很多String里面的方法都使用上面说的那种“性能好的、节约内存的、安全”的构造函数。
比如:substring replace concat valueOf等方法

实际上他们使用的是public String(char[], ture)方法来实现。

但是在Java 7中,substring已经不再使用这种“优秀”的方法了

publicStringsubstring(intbeginIndex,intendIndex){
  if(beginIndex<0){
    thrownewStringIndexOutOfBoundsException(beginIndex);
  }
  if(endIndex>value.length){
    thrownewStringIndexOutOfBoundsException(endIndex);
  }
  intsubLen=endIndex-beginIndex;
  if(subLen<0){
    thrownewStringIndexOutOfBoundsException(subLen);
  }
  return((beginIndex==0)&&(endIndex==value.length))?this :newString(value,beginIndex,subLen);
}

为什么呢?
虽然这种方法有很多优点,但是他有一个致命的缺点,对于sun公司的程序员来说是一个零容忍的bug,那就是他很有可能造成内存泄露
看一个例子,假设一个方法从某个地方(文件、数据库或网络)取得了一个很长的字符串,然后对其进行解析并提取其中的一小段内容,这种情况经常发生在网页抓取或进行日志分析的时候。
下面是示例代码。

StringaLongString="...averylongstring...";
StringaPart=data.substring(20,40);
returnaPart;

在这里aLongString只是临时的,真正有用的是aPart,其长度只有20个字符,但是它的内部数组却是从aLongString那里共享的,因此虽然aLongString本身可以被回收,但它的内部数组却不能释放。
这就导致了内存泄漏。如果一个程序中这种情况经常发生有可能会导致严重的后果,如内存溢出,或性能下降。

新的实现虽然损失了性能,而且浪费了一些存储空间,但却保证了字符串的内部数组可以和字符串对象一起被回收,从而防止发生内存泄漏,因此新的substring比原来的更健壮。

其他方法

length() 返回字符串长度

publicintlength(){
  returnvalue.length;
}

isEmpty() 返回字符串是否为空

publicbooleanisEmpty(){
  returnvalue.length==0;
}

charAt(int index) 返回字符串中第(index+1)个字符(数组索引)

publiccharcharAt(intindex){
  if((index<0)||(index>=value.length)){
    thrownewStringIndexOutOfBoundsException(index);
  }
  returnvalue[index];
}

char[] toCharArray()转化成字符数组
trim()去掉两端空格
toUpperCase()转化为大写
toLowerCase()转化为小写

需要注意
String concat(String str) //拼接字符串
String replace(char oldChar, char newChar) //将字符串中的oldChar字符换成newChar字符

以上两个方法都使用了String(char[] value, boolean share);concat方法和replace方法,他们不会导致元数组中有大量空间不被使用,因为他们一个是拼接字符串,一个是替换字符串内容,不会将字符数组的长度变得很短,所以使用了共享的char[]字符数组来优化。

boolean matches(String regex) //判断字符串是否匹配给定的regex正则表达式
boolean contains(CharSequence s) //判断字符串是否包含字符序列s
String[] split(String regex, int limit) 按照字符regex将字符串分成limit份
String[] split(String regex) 按照字符regex将字符串分段

getBytes
在创建String的时候,可以使用byte[]数组,将一个字节数组转换成字符串,同样,我们可以将一个字符串转换成字节数组,那么String提供了很多重载的getBytes方法。

publicbyte[]getBytes(){
  returnStringCoding.encode(value,0,value.length);
}

但是,值得注意的是,在使用这些方法的时候一定要注意编码问题。比如:
String s = "你好,世界!"; byte[] bytes = s.getBytes();
这段代码在不同的平台上运行得到结果是不一样的。由于没有指定编码方式,所以在该方法对字符串进行编码的时候就会使用系统的默认编码方式。

在中文操作系统中可能会使用GBK或者GB2312进行编码,在英文操作系统中有可能使用iso-8859-1进行编码。这样写出来的代码就和机器环境有很强的关联性了,为了避免不必要的麻烦,要指定编码方式。

publicbyte[]getBytes(StringcharsetName) throwsUnsupportedEncodingException{
  if(charsetName==null)thrownewNullPointerException();
  returnStringCoding.encode(charsetName,value,0,value.length);
}

比较方法

booleanequals(ObjectanObject);

booleancontentEquals(StringBuffersb);

booleancontentEquals(CharSequencecs);

booleanequalsIgnoreCase(StringanotherString);

intcompareTo(StringanotherString);

intcompareToIgnoreCase(Stringstr);

booleanregionMatches(inttoffset,Stringother,intooffset,intlen) //局部匹配

booleanregionMatches(booleanignoreCase,inttoffset,Stringother,intooffset,intlen) //局部匹配

字符串有一系列方法用于比较两个字符串的关系。 前四个返回boolean的方法很容易理解,前三个比较就是比较String和要比较的目标对象的字符数组的内容,一样就返回true,不一样就返回false,核心代码如下:

int n = value.length; 
while (n-- != 0) {
   if (v1[i] != v2[i])
     return false;
     i++;
 }

v1 v2分别代表String的字符数组和目标对象的字符数组。 第四个和前三个唯一的区别就是他会将两个字符数组的内容都使用toUpperCase方法转换成大写再进行比较,以此来忽略大小写进行比较。相同则返回true,不想同则返回false

equals方法:

public boolean equals(Object anObject) {
     if (this == anObject) {
         return true;
     } 
    if (anObject instanceof String) {
       String anotherString = (String) anObject;
       int n = value.length;
       if (n == anotherString.value.length) {
           char v1[] = value;
           char v2[] = anotherString.value;
           int i = 0;
           while (n-- != 0) {
             if (v1[i] != v2[i])
             return false;
             i++;
           }
           return true;
       }
   } 
   return false;
}

该方法首先判断this == anObject ?,也就是说判断要比较的对象和当前对象是不是同一个对象,如果是直接返回true,如不是再继续比较,然后在判断anObject是不是String
类型的,如果不是,直接返回false,如果是再继续比较,到了能终于比较字符数组的时候,他还是先比较了两个数组的长度,不一样直接返回false,一样再逐一比较值。 虽然代码写的内容比较多,但是可以很大程度上提高比较的效率。值得学习!!!

contentEquals有两个重载:
StringBuffer需要考虑线程安全问题,加锁之后再调用contentEquals((CharSequence) sb)方法。
contentEquals((CharSequence) sb)则分两种情况,一种是cs instanceof AbstractStringBuilder,另外一种是参数是String类型。具体比较方式几乎和equals方法类似,先做“宏观”比较,在做“微观”比较。

下面这个是equalsIgnoreCase代码的实现:

 public boolean equalsIgnoreCase(String anotherString) {
 return (this == anotherString) ? true : (anotherString != null) && (anotherString.value.length == value.length) && regionMatches(true, 0, anotherString, 0, value.length);
 }

看到这段代码,眼前为之一亮。使用一个三目运算符和&&操作代替了多个if语句。

hashCode

publicinthashCode(){
  inth=hash;
  if(h==0&&value.length>0){
    charval[]=value;
    for(inti=0;i<value.length;i++){
      h=31*h+val[i];
    }
    hash=h;
  }
  returnh;
}

hashCode的实现其实就是使用数学公式:s[0]31^(n-1) + s[1]31^(n-2) + ... + s[n-1]

所谓“冲突”,就是在存储数据计算hash地址的时候,我们希望尽量减少有同样的hash地址。如果使用相同hash地址的数据过多,那么这些数据所组成的hash链就更长,从而降低了查询效率。
所以在选择系数的时候要选择尽量长的系数并且让乘法尽量不要溢出的系数,因为如果计算出来的hash地址越大,所谓的“冲突”就越少,查找起来效率也会提高。

现在很多虚拟机里面都有做相关优化,使用31的原因可能是为了更好的分配hash地址,并且31只占用5bits。

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

hashCode可以保证相同的字符串的hash值肯定相同,但是hash值相同并不一定是value值就相同。

substring
前面我们介绍过,java 7 中的substring方法使用String(value, beginIndex, subLen)方法创建一个新的String并返回,这个方法会将原来的char[]中的值逐一复制到新的String中,两个数组并不是共享的,虽然这样做损失一些性能,但是有效地避免了内存泄露。

replaceFirst、replaceAll、replace区别
StringreplaceFirst(Stringregex,Stringreplacement)
StringreplaceAll(Stringregex,Stringreplacement)
Stringreplace(CharSequencetarget,CharSequencereplacement)

publicStringreplace(charoldChar,charnewChar){
  if(oldChar!=newChar){
    intlen=value.length;
    inti=-1;
    char[]val=value;/*avoidgetfieldopcode*/
    while(++i<len){
      if(val[i]==oldChar){
        break;
      }
    }
    if(i<len){
      charbuf[]=newchar[len];
      for(intj=0;j<i;j++){
        buf[j]=val[j];
      }
      while(i<len){
        charc=val[i];
        buf[i]=(c==oldChar)?newChar:c;
        i++;
      }
      returnnewString(buf,true);
    }
   }
  returnthis;
}
  • replace的参数是char和CharSequence,即可以支持字符的替换,也支持字符串的替换
  • replaceAll和replaceFirst的参数是regex,即基于规则表达式的替换

    比如可以通过replaceAll(“\d”, “*”)把一个字符串所有的数字字符都换成星号;

相同点是都是全部替换,即把源字符串中的某一字符或字符串全部换成指定的字符或字符串,如果只想替换第一次出现的,可以使用 replaceFirst(),这个方法也是基于规则表达式的替换。另外,如果replaceAll()和replaceFirst()所用的参数据不是基于规则表达式的,则与replace()替换字符串的效果是一样的,即这两者也支持字符串的操作。

copyValueOf 和 valueOf
String的底层是由char[]实现的,早期的String构造器的实现呢,不会拷贝数组的,直接将参数的char[]数组作为String的value属性。字符数组将导致字符串的变化。
为了避免这个问题,提供了copyValueOf方法,每次都拷贝成新的字符数组来构造新的String对象。

现在的String对象,在构造器中就通过拷贝新数组实现了,所以这两个方面在本质上已经没区别了。

valueOf()有很多种形式的重载,包括:

 public static String valueOf(boolean b) {
       return b ? "true" : "false";
 } 

public static String valueOf(char c) {
       char data[] = {c};
       return new String(data, true);
 }

 public static String valueOf(int i) {
       return Integer.toString(i);
 }

 public static String valueOf(long l) {
       return Long.toString(l);
 }

 public static String valueOf(float f) {
       return Float.toString(f);
 } 

public static String valueOf(double d) {
     return Double.toString(d);
}

可以看到这些方法可以将六种基本数据类型的变量转换成String类型。

intern()方法
public native String intern(); 该方法返回一个字符串对象的内部化引用。
String类维护一个初始为空的字符串的对象池,当intern方法被调用时,如果对象池中已经包含这一个相等的字符串对象则返回对象池中的实例,否则添加字符串到对象池并返回该字符串的引用。

String对“+”的重载

我们知道,Java是不支持重载运算符,String的“+”是java中唯一的一个重载运算符,那么java使如何实现这个加号的呢?我们先看一段代码:

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

然后我们将这段代码的实际执行情况:

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

看了反编译之后的代码我们发现,其实String对“+”的支持其实就是使用了StringBuilder以及他的append、toString两个方法。

String.valueOf和Integer.toString的区别
接下来我们看以下这段代码,我们有三种方式将一个int类型的变量变成呢过String类型,那么他们有什么区别?

int i = 5;
String i1 = "" + i;
String i2 = String.valueOf(i);
String i3 = Integer.toString(i);

第三行和第四行没有任何区别,因为String.valueOf(i)也是调用Integer.toString(i)来实现的。
第二行代码其实是String i1 = (new StringBuilder()).append(i).toString();

首先创建了一个StringBuilder对象,然后再调用append方法,再调用toString方法。


switch对字符串支持的实现

还是先上代码:

public class switchDemoString {
     public static void main(String[] args) {
         String str = "world";
         switch (str) {
         case "hello": 
              System.out.println("hello");
              break;
         case "world":
             System.out.println("world");
             break;
         default: break;
       }
    }
}

对编译后的代码进行反编译:

public static void main(String args[]) {
       String str = "world";
       String s;
       switch((s = str).hashCode()) {
          case 99162322:
               if(s.equals("hello"))
                   System.out.println("hello");
               break;
          case 113318802:
               if(s.equals("world"))
                   System.out.println("world");
               break;
          default: break;
       }
  }

看到这个代码,你知道原来字符串的switch是通过equals()和hashCode()方法来实现的。记住,switch中只能使用整型,比如byte,short,char(ackii码是整型)以及int。
还好hashCode()方法返回的是int而不是long。

通过这个很容易记住hashCode返回的是int这个事实。仔细看下可以发现,进行switch的实际是哈希值,然后通过使用equals方法比较进行安全检查,这个检查是必要的,因为哈希可能会发生碰撞。

因此性能是不如使用枚举进行switch或者使用纯整数常量,但这也不是很差。因为Java编译器只增加了一个equals方法,如果你比较的是字符串字面量的话会非常快,比如”abc” ==”abc”。如果你把hashCode()方法的调用也考虑进来了,那么还会再多一次的调用开销,因为字符串一旦创建了,它就会把哈希值缓存起来。
因此如果这个siwtch语句是用在一个循环里的,比如逐项处理某个值,或者游戏引擎循环地渲染屏幕,这里hashCode()方法的调用开销其实不会很大。

其实swich只支持一种数据类型,那就是整型,其他数据类型都是转换成整型之后在使用switch的。

总结

  • 一旦string对象在内存(堆)中被创建出来,就无法被修改。

    特别要注意的是,String类的所有方法都没有改变字符串本身的值,都是返回了一个新的对象。

  • 如果你需要一个可修改的字符串,应该使用StringBuffer 或者 StringBuilder。

    否则会有大量时间浪费在垃圾回收上,因为每次试图修改都有新的string对象被创建出来。

  • 如果你只需要创建一个字符串,你可以使用双引号的方式,如果你需要在堆中创建一个新的对象,你可以选择构造函数的方式。



文/keep_thinking(简书作者)
原文链接:http://www.jianshu.com/p/799c4459b808
著作权归作者所有,转载请联系作者获得授权,并标注“简书作者”。
posted @ 2016-12-17 20:19  天涯海角路  阅读(227)  评论(0编辑  收藏  举报