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

Posted

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了深度分析:面试腾讯,阿里面试官都喜欢问的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的引用。

以上是关于深度分析:面试腾讯,阿里面试官都喜欢问的String源码,看完你学会了吗?的主要内容,如果未能解决你的问题,请参考以下文章

Vue3考点,99%面试官都会问的内容

99% 的面试官都爱问的 8个 HTTP 知识点!

阿里面试喜欢问源码?头条喜欢问算法?腾讯喜欢问原理?我信了!

946页神仙文档,连阿里P8面试官都说太详细了,搞懂这些直接是P7级

借花献佛!朋友干了四年阿里面试官,分享了一份阿里最喜欢问的Java面试题!

挂了字节阿里的面试之后,我把面试官最喜欢问的Redis面试题总结起来了!附答案解析