乐趣区

深度分析面试腾讯阿里面试官都喜欢问的String源码看完你学会了吗

前言

最近花了两天时间,整理了一下 String 的源码。这个整理并不全面但是也涵盖了大部分 Spring 源码中的方法。后续如果有时间还会将剩余的未整理的方法更新到这篇文章中。方便以后的复习和面试使用。如果文章中有地方有问题还请指出。

简述

字符串广泛应用 在 Java 编程中,在 Java 中字符串属于对象,Java 提供了 String 类来创建和操作字符串。字符串缓冲区支持可变字符串。因为 String 对象是不可变的,因此可以共享它们。

String 类代表字符串,Java 程序中的所有字符串字面值如 ”abc” 都是这个类的实例对象。String 类是不可改变的,所以你一旦创建了 String 对象,那它的值就无法改变了。如果需要对字符串做很多修改,那么应该选择使用 StringBuilder 或者 StringBuffer。

最简单的创建字符串的方式:String qc = “qiu chan” 编译器会使用该值创建一个 对象。我们也可以使用关键字 New 创建 String 对象。
String 类型的常量池比较特殊。它的主要使用方法有两种:
直接使用双引号声明出来的 String 对象会直接存储在常量池中。
如果不是用双引号声明的 String 对象,可以使用 String 提供的 intern 方法。intern 方法会从字符串常量池中查询当前字符串是否存在,若不存在就会将当前字符串放入常量池中。

继承 / 实现关系

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

String 是 final 修饰的不能够被继承和修改。

源码

String 的底层使用的是 char 数组用于存储。

private final char value[];

缓存字符串的哈希码默认值为 0

private int hash;

无参数构造函数

public String() {this.value = "".value;}

解析:初始化一个新创建的 String 对象,使其代表一个空字符序列。注意,由于 String 是不可变的,所以不需要使用这个构造函数。

参数为字符串的构造函数

public String(String original) {
    this.value = original.value;
    this.hash = original.hash;
}

解析:初始化一个新创建的 String 对象,使其代表与参数相同的字符序列。换句话说,新创建的字符串是参数字符串的副本。除非需要参数字符串的显式拷贝,否则不需要使用这个构造函数,因为 String 是不可变的。

参数为 char 数组的构造函数

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

解析:分配一个新的 String,使其代表当前字符数组参数中包含的字符序列。使用 Arrays.copyOf 方法进行字符数组的内容被复制。字符数组的后续修改不会影响新创建的字符串。

参数为 char 数组并且带有偏移量的构造方法

// value[]:作为字符源的数组,offset:偏移量、下标从 0 开始并且包括 offset,count:从数组中取到的元素的个数。public String(char value[], int offset, int count) {
  // 如果偏移量小于 0 抛出 IndexOutOfBoundsException 异常
    if (offset < 0) {throw new StringIndexOutOfBoundsException(offset);
    }
  // 判断要取的元素的个数是否小于等于 0
    if (count <= 0) {
    // 要取的元素的个数小于 0,抛出 IndexOutOfBoundsException 异常
        if (count < 0) {throw new StringIndexOutOfBoundsException(count);
        }
    // 在要取的元素的个数等于 0 的情况下,判断偏移量是否小于等于数组的长度
        if (offset <= value.length) {
      // 偏移量小于等于数组的长度,返回一个空字符串数组的形式
            this.value = "".value;
            return;
        }
    }
    // 如果偏移量的值大于数组的长度减去取元素的个数抛出 IndexOutOfBoundsException 异常
    if (offset > value.length - count) {throw new StringIndexOutOfBoundsException(offset + count);
    }
  // 复制元素
    this.value = Arrays.copyOfRange(value, offset, offset+count);
}

解析:分配一个新的 Sting,来源于给定的 char 数组中的字符。offset 参数是子数组中第一个字符的索引,count 参数指定子数组的长度。子数组被被复制以后,对字符数组的修改不会影响新创建的字符串。

参数为 StringBuffer 的构造方法

public String(StringBuffer buffer) {
  // 这里对 StringBuffer 进行了加锁,然后再进行拷贝操作。这里对其进行加锁正是为了保证在多线程环境下只能有一个线程去操作 StringBuffer 对象。synchronized(buffer) {this.value = Arrays.copyOf(buffer.getValue(), buffer.length());
    }
}

解析:分配一个新的字符串,该字符串包含当前字符串缓冲区参数中包含的字符序列。Arrays.copyOf 方法进行字符串缓冲区中内容的复制。这里对 StringBuffer 进行了加锁,然后再进行拷贝操作。这里对其进行加锁正是为了保证在多线程环境下只能有一个线程去操作 StringBuffer 对象。

参数为 StringBuilder 的构造方法

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

解析:参数是 StringBuilder,这个是线程不安全的,但是性能相对于 StringBuffer 有很大的提升,源码的注释中说通过 toString 方法从字符串构建器中获取字符串可能会运行得更快,通常是首选。

length 方法

public boolean isEmpty() {
    // 底层的 char 数组的长度是否为 0 进行判断
        return value.length == 0;
}

// 举例
@Test
public void test_string_isEmpty(){System.out.println(" ".isEmpty());// false
  System.out.println("".isEmpty());// true
}

解析:返回此字符串的长度。查看源码发现,这个 value 是一个 char 数组,本质获取的是字符串对应的 char 数组的长度。

isEmpty 方法

public boolean isEmpty() {
    // 底层的 char 数组的长度是否为 0 进行判断
        return value.length == 0;
}

// 举例
@Test
public void test_string_isEmpty(){System.out.println(" ".isEmpty());// false
  System.out.println("".isEmpty());// true
}

解析:判断给定的字符串是否为空,底层实现是根据 char 数组的长度是否为 0 进行判断。

charAt 方法

public char charAt(int index) {
  // 给定的索引小于 0 或者给定的索引大于这个字符串对应的 char 数组的长度抛出角标越界异常
    if ((index < 0) || (index >= value.length)) {throw new StringIndexOutOfBoundsException(index);
    }
  // 获取当前的指定位置的 char 字符
    return value[index];
}

解析:根据给定的索引获取当前的指定位置的 char 字符。如果给定的索引否小于 0,或者给定的索引是大于这个字符串对应的 char 数组的长度抛出角标越界异常。index 是从 0 开始到 length- 1 结束。序列的第一个 char 值在索引 0 处,下一个在索引 1 处,依此类推,与数组索引一样。

getChars 方法

// srcBegin:要复制的字符串中第一个字符的索引【包含】。srcEnd:要复制的字符串中最后一个字符之后的索引【不包含】。dst[]:目标数组。dstBegin:目标数组中的起始偏移量。public void getChars(int srcBegin, int srcEnd, char dst[], int dstBegin) {
  // 校验起始索引小于 0 抛出角标越界异常
    if (srcBegin < 0) {throw new StringIndexOutOfBoundsException(srcBegin);
    }
  // 校验结束索引大于原始字符串的长度抛出角标越界异常
    if (srcEnd > value.length) {throw new StringIndexOutOfBoundsException(srcEnd);
    }
  // 校验结束索引大于起始索引抛出角标越界异常
    if (srcBegin > srcEnd) {throw new StringIndexOutOfBoundsException(srcEnd - srcBegin);
    }
  // 数组的拷贝
    System.arraycopy(value, srcBegin, dst, dstBegin, srcEnd - srcBegin);
}

// 案例
@Test
public void test_string_codePointAt(){
  // 原始字符串
    String h = "ahelloworld";
  // 目标 char 数组
    char[] data = new char[4];
  // 执行拷贝
    h.getChars(2, 6, data, 0);
    System.out.println(data);
}

解析:将字符串中的字符复制到目标字符数组中。索引包含 srcBegin,不包含 srcEnd。

equals 方法

// anObject:与此 String 进行比较的对象。public boolean equals(Object anObject) {
  // 引用相同直接返回 true
    if (this == anObject) {return true;}
    // 判断给定的对象是否是 String 类型的
    if (anObject instanceof String) {
    // 给定的对象是字符串类型的转换为字符串类型
        String anotherString = (String)anObject;
    // 获取当前字符串的长度
        int n = value.length;
    // 判断给定字符串的长度是否等于当前字符串的长度
        if (n == anotherString.value.length) {// v1[] 代表当前字符串对应的 char 数组
            char v1[] = value;
      // v2[] 代表给定的字符串对应的 char 数组
            char v2[] = anotherString.value;
      // 遍历原始 char 数组,并且与给定的字符串对应的数组进行比较
            int i = 0;
            while (n-- != 0) {if (v1[i] != v2[i])
          // 任意一个位置上不相等返回 false
                    return false;
                i++;
            }
      // 都相等返回 true
            return true;
        }
    }
  // 不是 String 类型,或者长度不一致返回 false
    return false;
}

解析:这个方法重写了 Object 中的 equals 方法。方法中的将此字符串与指定对象进行比较。接下来附赠一个手写的 String 字符串 equals 方法。

手写 equals 方法

private boolean mineEquals(String srcObject, Object anObject){
  // 比较引用是否相同
    if (srcObject == anObject){return true;}
  // 引用不相同比较内容
    if (anObject instanceof String){String ans = (String) anObject;
        char[] srcChar = srcObject.toCharArray();
        char[] anChar = ans.toCharArray();
        int n = srcChar.length;
        if (n == anChar.length){
            int i = 0;
            while (n-- != 0){if (srcChar[i] != anChar[i])
                    return false;
                i++;
            }
            return true;
        }
    }
    return false;
}

// 测试我们自己写的 equals 方法
    @Test
    public void test_string_mine(){String s = new String("aaa");
    // 走的是引用的比较
        System.out.println(s.equals(s));// true 
        boolean b = mineEquals(s, s);
        System.out.println(b);// true
    }

equalsIgnoreCase 方法

public boolean equalsIgnoreCase(String anotherString) {
  // 引用相同返回 true。引用不相同进行长度、各个位置上的 char 是否相同
    return (this == anotherString) ? true
            : (anotherString != null)
            && (anotherString.value.length == value.length)
            && regionMatches(true, 0, anotherString, 0, value.length);
}

解析:将此字符串与另一个字符串进行比较,而忽略大小写注意事项。regionMatches 方法的源码很有趣的,源码里面有一个 while 循环,先进行未忽略大小的判断,然后进行忽略大小的判断,在忽略大小的判断中,先进行的是大写的转换进行比较,但是可能会失败【这种字体 Georgian alphabet】。所以在大写转换以后的比较失败,进行一次小写的转换比较。

startsWith 方法

// 判断是否以指定的前缀开头
public boolean startsWith(String prefix) {
  // 0 代表从开头进行寻找
  return startsWith(prefix, 0);
}

endsWith 方法

// 判断是否以指定的前缀结尾
public boolean endsWith(String suffix) {
  // 从【value.length - suffix.value.length】开始寻找,这个方法调用的还是 startsWith 方法
  return startsWith(suffix, value.length - suffix.value.length);
}

startsWith 和 endsWith 最终的实现方法

// prefix: 测试此字符串是否以指定的前缀开头。toffset: 从哪里开始寻找这个字符串。public boolean startsWith(String prefix, int toffset) {// 原始的字符串对应的 char[]
    char ta[] = value;
  // 开始寻找的位置
    int to = toffset;
  // 获取指定的字符串对应的 char[]
    char pa[] = prefix.value;
    int po = 0;
  // 获取指定的字符串对应的 char[] 长度
    int pc = prefix.value.length;
    // 开始寻找的位置小于 0,或者起始位置大于要查找的长度【value.length - pc】返回 false。if ((toffset < 0) || (toffset > value.length - pc)) {return false;}
  // 比较给定的字符串的 char[] 里的每个元素是否跟原始的字符串对应的 char 数组的元素相同
    while (--pc >= 0) {if (ta[to++] != pa[po++]) {
      // 有一个 char 不相同返回 false
            return false;
        }
    }
  // 相同返回 true
    return true;
}

substring 方法

// 返回一个字符串,该字符串是该字符串的子字符串。beginIndex 开始截取的索引【包含】。public String substring(int beginIndex) {
  // 校验指定的索引,小于 0 抛出角标越界
    if (beginIndex < 0) {throw new StringIndexOutOfBoundsException(beginIndex);
    }
  // 子字符串的长度
    int subLen = value.length - beginIndex;
  // 子字符串的长度小于 0 抛出角标越界
    if (subLen < 0) {throw new StringIndexOutOfBoundsException(subLen);
    }
  // 开始位置为 0,返回当前字符串,不为 0,创建一个新的子字符串对象并返回
    return (beginIndex == 0) ? this : new String(value, beginIndex, subLen);
}

解析:返回一个字符串,该字符串是该字符串的子字符串。子字符串以指定索引处的字符开头【包含】,并且扩展到该字符串的末尾。

substring 方法

// 返回一个字符串,该字符串是该字符串的子字符串。beginIndex 开始截取的索引【包含】。public String substring(int beginIndex) {
  // 校验指定的索引,小于 0 抛出角标越界
    if (beginIndex < 0) {throw new StringIndexOutOfBoundsException(beginIndex);
    }
  // 子字符串的长度
    int subLen = value.length - beginIndex;
  // 子字符串的长度小于 0 抛出角标越界
    if (subLen < 0) {throw new StringIndexOutOfBoundsException(subLen);
    }
  // 开始位置为 0,返回当前字符串,不为 0,创建一个新的子字符串对象并返回
    return (beginIndex == 0) ? this : new String(value, beginIndex, subLen);
}

解析:返回一个字符串,该字符串是该字符串的子字符串。子字符串从指定的 beginIndex 开始【包含】,并且扩展到索引 endIndex- 1 处的字符【不包含】。

concat 方法

public String concat(String str) {
  // 获取给定的字符串的长度
    int otherLen = str.length();
  // 长度为 0,直接返回当前的字符串
    if (otherLen == 0) {return this;}
  // 获取当前字符串的长度
    int len = value.length;
  // 构建一个新的长度为 len + otherLen 的字符数组,并且将原始的数据放到这个数组
    char buf[] = Arrays.copyOf(value, len + otherLen);
  // 这个底层调用是 System.arraycopy 这个方法的处理是使用 c 语言写的
    str.getChars(buf, len);
    return new String(buf, true);
}

将指定的字符串连接到该字符串的末尾。字符串拼接。

format 方法

// 使用指定的格式字符串和参数返回格式化的字符串。public static String format(String format, Object... args) {return new Formatter().format(format, args).toString();}

// 案例,这里是使用 %s 替换后面的如 "-a-"
@Test
public void test_start(){System.out.println(String.format("ha %s hh %s a %s h", "-a-", "-b-", "-c-"));
}

trim 方法

public String trim() {
  // 指定字符串的长度
    int len = value.length;
  // 定义一个开始位置的索引 0
    int st = 0;
  // 定义一个 char[] val,用于避免使用 getfiled 操作码,这个可以写段代码反编译一下看看
    char[] val = value;
  // 对于字符串的开头进行去除空格,并记录这个索引
    while ((st < len) && (val[st] <= ' ')) {st++;}
  // 对于字符串的尾部进行去除空格,也记录这个索引,这个索引就是去除尾部空格后的索引
    while ((st < len) && (val[len - 1] <= ' ')) {len--;}
  // 根据上面记录的长度判断是否要截取字符串
    return ((st > 0) || (len < value.length)) ? substring(st, len) : this;
}

返回一个字符串,其值就是这个字符串,并去掉任何首部和尾部的空白。

join 方法

// 返回一个新的 String,该字符串由给定的分隔符和要连接的元素组成。delimiter:分隔每个元素的分隔符。elements:连接在一起的元素。public static String join(CharSequence delimiter, CharSequence... elements) {
  // delimiter 和 elements 为空抛出空指针异常,null 会被拦截,"" 不会被拦截
    Objects.requireNonNull(delimiter);
    Objects.requireNonNull(elements);
    // 
    StringJoiner joiner = new StringJoiner(delimiter);
  // 遍历给定的要拼接的元素,拼接的元素允许为 null
    for (CharSequence cs: elements) {
    // 执行拼接方法
        joiner.add(cs);
    }
    return joiner.toString();}

// 拼接方法
public StringJoiner add(CharSequence newElement) {// prepareBuilder() 方法首次调用会创建 StringBuilder 对象,后面再调用会执行拼接分隔符
    prepareBuilder().append(newElement);
    return this;
}

// 未进行拼接创建 StringBuilder 对象,已经拼接以后 value != null 执行拼接分隔符
private StringBuilder prepareBuilder() {
  // 判断拼接的 value 是否为空
    if (value != null) {
    // 不为空执行拼接分隔符
        value.append(delimiter);
    } else {
    // 最开始使用拼接的时候,调用这个方法创建一个空的 StringBuilder 对象,只调一次
        value = new StringBuilder().append(prefix);
    }
    return value;
}

// 上面是调用的这个拼接元素方法
@Override
public StringBuilder append(CharSequence s) {
  // 这里啥都没处理,调用的是父类的 append 方法,设计模式为建造者模式
    super.append(s);
    return this;
}

// 上面的 prepareBuilder 方法是拼接分隔符,这个方法是将分隔符和给定的元素拼接的方法
@Override
public AbstractStringBuilder append(CharSequence s) {
  // 以下 3 个判断根据类型和是否为空进行区别拼接
    if (s == null)
        return appendNull();
    if (s instanceof String)
        return this.append((String)s);
    if (s instanceof AbstractStringBuilder)
        return this.append((AbstractStringBuilder)s);
  // 拼接
    return this.append(s, 0, s.length());
}

将给定的字符串以给定的分割符分割并返回分隔后的字符串。

replace 方法

// target:要被替换的目标字符串。replacement:替换的字符串
public String replace(CharSequence target, CharSequence replacement) {return Pattern.compile(target.toString(), Pattern.LITERAL).matcher(this).replaceAll(Matcher.quoteReplacement(replacement.toString()));
}

解析:用指定的字符串替换这个字符串中与之匹配的每个子字符串。替换从字符串的开头到结尾,例如,在字符串 “aaa “ 中用 “b “ 替换 “aa “ 将导致 “ba “ 而不是“ab”。

replaceAll 方法

// regex:这个支持正则表达式,也可以是要被替换的目标字符串。public String replaceAll(String regex, String replacement) {return Pattern.compile(regex).matcher(this).replaceAll(replacement);
}

问题:replace 和 replaceAll 方法的区别是啥?
replaceAll 支持正则表达式。

针对 char 的 replace 方法

// oldChar:要被替换的字符,newChar:替换的字符
public String replace(char oldChar, char newChar) {
  // oldChar 不等于 newChar
    if (oldChar != newChar) {
    // 当前字符串的长度
        int len = value.length;
    // 这个用于下面的 while 循环里的条件比较,val[i] 中的 i 是从 0 开始的
        int i = -1;
    // 定义一个 char[] val,用于避免使用 getfiled 操作码,这个可以写段代码反编译一下看看
        char[] val = value; /* avoid getfield opcode */
    // 这个用于记录这个 i 的值,并且判断是否有要替换的,这个循环有利于性能的提升
        while (++i < len) {// val[i] 中的 i 是从 0 开始的
            if (val[i] == oldChar) {
        // 有要替换的直接跳出循环
                break;
            }
        }
    // 上面的 while 循环中如果有要替换的 i 肯定小于 len,如果没有下面这个判断就不会执行
        if (i < len) {
      // 能进到这个循环肯定是有要替换的,创建一个长度为 len 的 char 数组
            char buf[] = new char[len];
      // 上面的 i 是记录第一个可以替换的 char 的索引,下面这个循环是将这个 i 索引前的不需要被替换的填充到 buf[] 数组中
            for (int j = 0; j < i; j++) {// 填充 buf[] 数组
                buf[j] = val[j];
            }
      // 从可以替换的索引 i 开始将剩余的字符一个一个填充到 buf[] 中
            while (i < len) {
        // 获取要被替换的字符
                char c = val[i];
        // 判断这个字符是否真的需要替换,c == oldChar 成立就替换,否则不替换
                buf[i] = (c == oldChar) ? newChar : c;
                i++;
            }
      // 返回替换后的字符串
            return new String(buf, true);
        }
    }
  // oldChar 等于 newChar 直接返回当前字符串
    return this;
}

案例

@Test
public void test_matches(){
    String a = "adddfdefe";
    System.out.println(a.replace('d', 'b'));// abbbfbefe
}

仿写 replace 方法参数针对 char

仿写

// 和源码给的唯一不同的是参数传递, 其他的都和源码一样,自己写一遍可以加深记忆和借鉴编程思
public String replace(String source, char oldChar, char newChar) {char[] value = source.toCharArray();
    if (oldChar != newChar) {
        int len = value.length;
        int i = -1;
        char[] val = value; /* avoid getfield opcode */
        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);
        }
    }
    return new String(value);
}

intern 方法

public native String intern();

这是一个 native 方法。调用 String#intern 方法时,如果池中已经包含一个由 equals 方法确定的等于此 String 对象的字符串,则返回来自池的字符串。否则,将此 String 对象添加到池中,并返回这个 String 的引用。

退出移动版