String 源码解析

String 源码解析

困而学,学而知

说到源码,相信大家的第一个念头就是很难、难啃,比如说Spring源码。从而每每都对源码望而却步,只要是能用就可以了。但是这样真的可以了吗?为什么在代码评审的时候,别人能够在代码中看出问题并提出优化意见,而自己却不能?为什么别人的代码bug总是很少...都是别人,我决定了,我也要做这个别人,那么就和我一起来看Java源码吧。

String可以说是我们在代码开发中用的最大的类之一,本着从常用的开始说起的原则,我们就先从String的源码开始吧。本文主要说String的一些特性和常用的一些类。

String 类

public final class String
    implements java.io.Serializable, Comparable<String>, CharSequence {
	  /** 用来保存字符的数组. */
    private final char value[];

    /** 缓存String的hash值 */
    private int hash; // Default to 0
}

使用 final 修饰 String 类

首先来说说final,这个关键字决定了String的不可变性。我们先来看一个例子。

String s = "hello";
s = "world";

s = hello

s = world

虽然只是重新赋值了,但是s的内存地址却发生了改变。也就是说s重新被分配了一个内存空间,原先存储hello 的内存依然还存在。可以从文章开头的源码中看出,String类和存储字符数据的char[]数组都是被final关键字修饰的。我们先从final关键的作用来看这样做的作用。

  • final修饰变量,该变量初始化之后就不能修改
  • final修饰方法,该方法不能被重写
  • final修饰类,则该类就是一个不可变类,不能别重写,不能被重新分配地址。

使用final来修饰String类,说明了String类是一个不可变类,不可被继承和重写String的方法。请注意这句话,这句话十分重要。后面我们会讲到String如何保证不可变的。

使用final来修饰value属性,也就是说 value 一旦被赋值,内存地址是绝对无法修改的,而且 value 的权限是 private 的,外部绝对访问不到,String 也没有开放出可以对 value 进行赋值的方法,所以说 value 一旦产生,内存地址就根本无法被修改。

为什么要让String是不可变类呢?

这是一道面试题,也有其他的问发:String类型为什么设计成final且不可变的?

  1. String Pool 的需要

    如果一个 String 对象已经被创建过了,那么就会从 String Pool 中取得引用。只有 String 是不可变的,才可能使用 String Pool

    String pool
  2. 缓存HashCode

    String类型的值经常用来作为hash值。使String不可变性可以保证hash值一直不变而不用进行重复运算。

  3. 安全性

    String常被用作参数。比如说网络连接、打开文件等。网络连接中,如果String是可变的,就有可能会在连接过程中改变从而导致连接到其他主机。

    可变的字符串数组在反射中也可能造成安全问题?这个没有想到例子,后期看到了再说

  4. 线程安全

    不可变的类本身就是线程安全的,因为初始化就不可能任何线程修改

Serializable, Comparable, CharSequence

分别来说这几个接口的作用

  • Serializable

    说明String是可序列化的

  • Comparable

    是一个排序接口

    若一个类实现了Comparable接口,就意味着***该类支持排序***。 即然实现Comparable接口的类支持排序,假设现在存在“实现Comparable接口的类的对象的List列表(或数组)”,则该List列表(或数组)可以通过 Collections.sort(或 Arrays.sort)进行排序。

    此外,“实现Comparable接口的类的对象”可以用作“有序映射(如TreeMap)”中的键或“有序集合(TreeSet)”中的元素,而不需要指定比较器。

  • CharSequence

    说明String是一个可读的字符数组,这个接口为不同的字符数组操作提供了统一可读的接口。charAtlengthsubSequencetoString

String 的数据容器 value[]

再看看String类中最重要的属性,我们可以看到这个属性其实也是被final修饰,说明value[]一旦被初始化了,就不会太修改了。请注意,final修饰对象,说明对象的内存地址是不能改变,但是对象中的属性是可变的。也就是说,我们在对value[]初始化之后就不能再new一次了(分配地址)。

我们可以直接把value[]直接当成String的值,我们对String的任何操作其实都是对value[]的操作。

hashCode

// s[0]*31^(n-1) + s[1]*31^(n-2) + ... + s[n-1]
public int hashCode() {
        int h = hash;
        if (h == 0 && value.length > 0) {
            char val[] = value;

            for (int i = 0; i < value.length; i++) {
                h = 31 * h + val[i];
            }
            hash = h;
        }
        return h;
}

其实这个我们在平常工作中用得很少,或者说根本都没有用,但是他的作用确实巨大的。最重要的一个做法就是来判断两个String是否相等。之前有过一篇写HashMap一些概念的文章,详细说了说Hash的概念。需要详细了解Hash的概念可以参考这篇文章。

比较两个字符串:equals

说了hashCode当然要说说equals,谁叫它俩是一对呢。

equals==区别在于他们的用处是不同的。equals用于比较引用类型,==用于比较基本类型。究其原因是equals是比较两个对象的地址是否相等(或者每个对象域是否相等),==用于比较两个的值是否相等。

先来说说要实现一个高效的equals的原则。

  1. 使用==操作符检查「参数是否为这个对象的引用」,也就是说两个对象是否相等,如果是直接返回true。这一步是为了性能的考虑。
  2. 使用instanceof操作符检查「参数是否为正确的类型」,如果不是直接返回false。
  3. 将参数转换成正确的类型,因为经过instanceof检查,会一直正确。
  4. 对比对象的中个关键域,检查每个关键域是否相等,如果有一个关键域不相等,则返回false。否则返回true。

引用《Effecttive Java 第三版 》 第三章

通过上面实现高效equals的原则,我们来看String#equals就很简单的。

 public boolean equals(Object anObject) {
   			// 比较两个对象的值是否相等
        if (this == anObject) {
            return true;
        }
   	 		// 判断anObject是否是String类型
        if (anObject instanceof String) {
           // 转换类型
            String anotherString = (String)anObject;
            int n = value.length;
          	// 下面就是对比每个关键域是否相等,由于String本质是一个value[], 
            // 这里也就是对比数组中的每一个值是否相等, 只有有一个不等,直接返回false
            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;
    }

截取字符串:substring

substring的作用是在当前的字符串的基础上截取一个字符串。请注意,调用该方法必须要接收返回值,不然也没有用呀。

substring有两个重载函数:substring(int beginIndex)substring(int beginIndex, int endIndex)。两个方法的原理都是一样的,都是调用String的构造函数新建一个String返回。

// 用参数多的类说明
public String substring(int beginIndex, int endIndex) {
        if (beginIndex < 0) {
            throw new StringIndexOutOfBoundsException(beginIndex);
        }
        if (endIndex > value.length) {
            throw new StringIndexOutOfBoundsException(endIndex);
        }
        int subLen = endIndex - beginIndex;
        if (subLen < 0) {
            throw new StringIndexOutOfBoundsException(subLen);
        }
  			// 上面都是必要的一些校验
  			// 如果截取的长度是字符串的长度且起点是字符串起点,则直接返回当前字符串
  			// 否则新建一个子字符串,返回
  			// 这里我们也可以进一步印证String的不可变特性,原始的字符串其实并没有改变。
        return ((beginIndex == 0) && (endIndex == value.length)) ? this
                : new String(value, beginIndex, subLen);
    }
// 这个是String的一个构造函数,
// value[]: 用来构建字符串的原始字符数组
// offset: 初始偏移量,也就是说从数组的那个位置开始构建字符串,   0<=offset<value.length
// count: 要构建过长的字符串. 这个大小要根据offset变化
public String(char value[], int offset, int count) {
        if (offset < 0) {
            throw new StringIndexOutOfBoundsException(offset);
        }
        if (count <= 0) {
            if (count < 0) {
                throw new StringIndexOutOfBoundsException(count);
            }
            if (offset <= value.length) {
                this.value = "".value;
                return;
            }
        }
        // Note: offset or count might be near -1>>>1.
        if (offset > value.length - count) {
            throw new StringIndexOutOfBoundsException(offset + count);
        }
  			// 其实这里是调用了
        // System.arraycopy(Object src,  int  srcPos, Object dest, int destPos, int length);
  			//  也就是将一个数组复制到一个新的数组
        this.value = Arrays.copyOfRange(value, offset, offset+count);
    }

用法

public static void main(String[] args) {
  	String str = "The movie NeZha is awesome.";
    // 从第五个字符开始截取,也就是"ovie NeZha is awesome."
    System.out.println(str.substring(5));
    // 截取第2到第5个字符, 也就是"NeZha"
    System.out.println(str.substring(10, 15));
    System.out.println(str);
}
ovie NeZha is awesome.
NeZha
The movie NeZha is awesome.
// 这里截取了两次,我们后面输出str,还是没有变,也印证了String的不变特性

replace和replaceAll

replace有两个重载方法: replace(char oldChar, char newChar)String replace(CharSequence target, CharSequence replacement)

replaceAll只有一个方法: String replaceAll(String regex, String replacement)

replace(char oldChar, char newChar)

这个方法其实比较简单。也就是遍历String中的value[]每个值,找到相同的,替换掉。

public String replace(char oldChar, char newChar) {
  			// 两个值相等就不需要替换了
        if (oldChar != newChar) {
            int len = value.length;
            int i = -1;
          	// 避免:获取指定类的实例域,并将其值压入栈顶
            // 在一个方法中需要大量引用实例域变量的时候,
            // 使用方法中的局部变量代替引用可以减少getfield操作的次数,提高性能
            char[] val = value; /* avoid getfield opcode */
						// 找到oldChar第一次出现的位置
            while (++i < len) {
                if (val[i] == oldChar) {
                    break;
                }
            }
            // 只有字符串中存在原始字符,才会替换
            if (i < len) {
              	// 缓存字符串,用于保存替换后的数组
                char buf[] = new char[len];
                for (int j = 0; j < i; j++) {
                    buf[j] = val[j];
                }
                // 遍历替换
                while (i < len) {
                    char c = val[i];
                    buf[i] = (c == oldChar) ? newChar : c;
                    i++;
                }
              	// 重新返回一个字符串,又是不可变性
                return new String(buf, true);
            }
        }
        return this;
    }

replace(CharSequence target, CharSequence replacement) 和replaceAll(String regex, String replacement)

public String replace(CharSequence target, CharSequence replacement) {
        return Pattern.compile(target.toString(), Pattern.LITERAL).matcher(
                this).replaceAll(Matcher.quoteReplacement(replacement.toString()));
}

public String replaceAll(String regex, String replacement) {
        return Pattern.compile(regex).matcher(this).replaceAll(replacement);
}

为什么要放到一起讲,大家其实可以从源码都可以看出来了。

看了两个两个源码,我们可以看出来什么?

那就是repace(char, char)比replace(CharSequence, CharSequence)和replaceAll(String, String)的性能普遍要好,因为后面两个用了正则,而第一个只是遍历替换而已。

startWith和endsWith

其实不管是startWith和endsWith最终的都是调用下面的方法:

public boolean startsWith(String prefix, int toffset) {
  			// 原始字符串数组
        char ta[] = value;
   			// 对比开始位置 startWith是0, endsWith是value.lenght-prefix.length
  			// 用于指向value数组的下一个位置,每次遍历都会+1
        int to = toffset;
  			// 字符串的数组
        char pa[] = prefix.value;
  			// 字符串, 用于指向prefix数组的下一个位置,每次遍历都会+1
        int po = 0;
  			// 字符串的长度
        int pc = prefix.value.length;
        // Note: toffset might be near -1>>>1.
        if ((toffset < 0) || (toffset > value.length - pc)) {
            return false;
        }
  			// 遍历prefix
        while (--pc >= 0) {
          	// 对比从ta[toOffset]和prefix[0]开始的每一个位置上面的值是否相等。
          	// 如果有一个不相等,就直接返回false
            if (ta[to++] != pa[po++]) {
                return false;
            }
        }
        return true;
}

public boolean endsWith(String suffix) {
        return startsWith(suffix, value.length - suffix.value.length);
}

 public boolean startsWith(String prefix) {
        return startsWith(prefix, 0);
}

上面算是我理解的关于String的源码,其实String还有很多可以探究的地方,一篇文章太短,无法全部说的明明白白。学无止境,其他的还需要继续摸索。

Copyright: 采用 知识共享署名4.0 国际许可协议进行许可

Links: https://baozi.fun/2019/09/15/java-string-source

Buy me a cup of coffee ☕.