十七、String 和 StringBuilder

本文详细解析了Java中的String和StringBuilder类,包括String的基本用法、不可变性原理、编码转换机制,以及StringBuilder的高效操作。特别关注了String的+和+=运算符背后的工作原理,以及何时选择StringBuilder以提高性能。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

String 和 StringBuilder

本文为书籍《Java编程的逻辑》1和《剑指Java:核心原理与应用实践》2阅读笔记

3.1 String基本用法

可以通过常量定义String变量,也可以通过new创建String变量,还可以直接使用 + + + + = += +=​运算符,如:

    @Test
    public void testNewString() {
        String newConstant = "constant string";
        String newConstructor = new String("constructor new string");
        String addString = newConstant + " " + newConstructor;
        assertTrue("constant string constructor new string".equals(addString));
    }

String类包括很多方法,以方便操作字符串,比如:

public boolean isEmpty() // 判断字符串是否为空
public int length() // 获取字符串长度
public String substring(int beginIndex) // 取子字符串
public String substring(int beginIndex, int endIndex) // 取子字符串
public int indexOf(int ch) // 查找字符,返回第一个找到的索引位置,没找到返回 -1
public int indexOf(String str) // 查找子串,返回第一个找到的索引位置,没找到返回 -1
public int lastIndexOf(int ch) // 从后面查找字符
public int lastIndexOf(String str) // 从后面查找子字符串
public boolean contains(CharSequence s) // 判断字符串中是否包含指定的字符序列
public boolean startsWith(String prefix) // 判断字符串是否以给定子字符串开头
public boolean endsWith(String suffix) // 判断字符串是否以给定子字符串结尾
public boolean equals(Object anObject) // 与其他字符串比较,看内容是否相同
public boolean equalsIgnoreCase(String anotherString) // 忽略大小写比较是否相同
public int compareTo(String anotherString) // 比较字符串大小
public int compareToIgnoreCase(String str) // 忽略大小写比较
public String toUpperCase() // 所有字符转换为大写字符,返回新字符串,原字符串不变
public String toLowerCase() // 所有字符转换为小写字符,返回新字符串,原字符串不变
public String concat(String str) // 字符串连接,返回当前字符串和参数字符串合并结果
public String replace(char oldChar, char newChar) // 字符串替换,替换单个字符
public String replace(CharSequence target, CharSequence replacement) // 字符串替换,替换字符序列,返回新字符串,原字符串不变
public String trim() // 删掉开头和结尾的空格,返回新字符串,原字符串不变
public String[] split(String regex) // 分隔字符串,返回分隔后的子字符串数组

3.2 String底层

String类内部用一个字符数组表示字符串,实例变量定义为:

private final char value[];

String中的大部分方法内部也都是操作的这个字符数组。比如:

  1. length()方法返回的是这个数组的长度。
  2. substring()方法是根据参数,调用构造方法String(char value[], int offset, int count)新建了一个字符串。
  3. indexOf()方法查找字符或子字符串时是在这个数组中进行查找。

String中还有一些方法,与这个char数组有关:

public char charAt(int index) // 返回指定索引位置的 char
public char[] toCharArray() // 返回字符串对应的 char 数组, 注意,返回的是一个复制后的数组,而不是原数组
public void getChars(int srcBegin, int srcEnd, char dst[], int dstBegin) // 将 char 数组中指定范围的字符复制入目标数组指定位置

Character类似,String也提供了一些方法,按代码点对字符串进行处理:

public int codePointAt(int index)
public int codePointBefore(int index)
public int codePointCount(int beginIndex, int endIndex)
public int offsetByCodePoints(int index, int codePointOffset)

3.3 不可变性

与包装类类似,String类也是不可变类,即对象一旦创建,就没有办法修改了。String类也声明为了final,不能被继承,内部char数组value也是final的,初始化后就不能再变了。String类中提供了很多看似修改的方法,其实是通过创建新的String对象来实现的,原来的String对象不会被修改。比如,concat()方法的代码:

    public String concat(String str) {
        int otherLen = str.length();
        if(otherLen == 0) {
            return this;
        }
        int len = value.length;
        char buf[] = Arrays.copyOf(value, len + otherLen);
        str.getChars(buf, len);
          return new String(buf, true);
      }

通过Arrays.copyOf方法创建了一块新的字符数组,复制原内容,然后通过new创建了一个新的String,最后一行调用的是String的另一个构造方法。

因为字符串的不可变性,JVM专门为字符串提供了一个常量池,凡是放在常量池中的字符串对象都可以共享,查看下面的代码:

    @Test
    public void testConstantPool() {
        String name1 = "测试字符串常量池";
        String name2 = "测试字符串常量池";
        assertTrue(name1 == name2);
    }

    @Test
    public void testNotConstantPool() {
        String name1 = new String("测试字符串常量池");
        String name2 = new String("测试字符串常量池");
        assertFalse(name1 == name2);
    }

看上面代码,通过常量定义name1 == name2得到的是true,但是通过new得到的字符串得到的结果是false。那么,现在有一个问题,哪些方式是使用常量池中的数据呢,哪些是新建放在堆中的呢?

  1. 直接使用"..."得到的字符串对象放在常量池。
  2. 直接"..."+"..."拼接的字符串对象放在常量池。
  3. 两个指向"..."的final常量拼接结果放在常量池。
  4. 所有字符串对象.intern()方法得到的结果放在常量池。
  5. 除以上四种方式,其他方式得到的字符串结果都在堆中。
    @Test
    public void testCreateConstantPool() {
        String s1 = "helloworld"; // 1、常量池中
        String s2 = "hello" + "world"; // 2、常量池中
        final String s3 = "hello";
        final String s4 = "world";
        String s5 = s3 + s4; // 3、指向"..."的 final 常量 + 指向"..."的 final 常量在常量池
        String s6 = new String("hello");
        String s7 = new String("world");
        String s8 = s6 + s7;
        String s9 = s8.intern();// 4、字符串对象.intern() 的结果都在常量池
        assertTrue(s1 == s2 && s1 == s5 && s1 == s9);

        String s10 = "hello";
        String s11 = "world";
        String s12 = s10 + s11;
        assertFalse(s1 == s12);
        String s13 = s10 + "world";
        assertFalse(s1 == s13);
        String s14 = s6 + "world";
        assertFalse(s1 == s14);
        String s15 = String.valueOf(new char[] { 'h', 'e', 'l', 'l', 'o', 'w', 'o', 'r', 'l', 'd' });
        assertFalse(s1 == s15);
        String s16 = "hello".concat("world");
        assertFalse(s1 == s16);
    }

3.4 编码转换

String内部是按UTF-16BE处理字符的,对BMP字符,使用一个char,两个字节,对于增补字符,使用两个char,四个字节。我们知道有各种编码,不同编码可能用于不同的字符集,使用不同的字节数目,以及不同的二进制表示。如何处理这些不同的编码呢?这些编码与Java内部表示之间如何相互转换呢?Java使用Charset类表示各种编码,它有两个常用静态方法:

public static Charset defaultCharset()
public static Charset forName(String charsetName)

第一个方法返回系统的默认编码,第二个方法返回给定编码名称的Charset对象,其charset名称可以是US-ASCIIISO-8859-1windows-1252GB2312GBKGB18030Big5UTF-8等,比如:

Charset charset = Charset.forName("GB18030");

String类提供了如下方法,返回字符串按给定编码的字节表示:

public byte[] getBytes()
public byte[] getBytes(String charsetName)
public byte[] getBytes(Charset charset)

第一个方法没有编码参数,使用系统默认编码;第二个方法参数为编码名称;第三个方法参数为Charset

String类有如下构造方法,可以根据字节和编码创建字符串,也就是说,根据给定编码的字节表示,创建Java的内部表示。

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

3.5 StringBuilder基本用法

如果字符串修改操作比较频繁,应该采用StringBuilderStringBuffer类,这两个类的方法基本是完全一样的,它们的实现代码也几乎一样,唯一的不同就在于StringBuffer类是线程安全的,而StringBuilder类不是。

StringBuilder的基本用法很简单。使用new创建StringBuilder对象,通过append方法添加字符串,然后通过toString方法获取构建后的字符串:

    @Test
    public void testStringBuilder() {
        StringBuilder stringBuilder = new StringBuilder();
        stringBuilder.append("hello");
        stringBuilder.append("world");
        assertFalse("helloworld" == stringBuilder.toString());
        assertTrue("helloworld".equals(stringBuilder.toString()));
    }

3.6 StringBuilder基本实现原理

StringBuilder类是怎么实现的呢?我们来看下它的内部组成,以及一些主要方法的实现,代码基于Java 7。与String类似,StringBuilder类也封装了一个字符数组,定义如下:

char[] value;

String不同,它不是final的,可以修改。另外,与String不同,字符数组中不一定所有位置都已经被使用,它有一个实例变量,表示数组中已经使用的字符个数,定义如下:

/**
 * The count is the number of characters used.
 */
int count;

StringBuilder继承自AbstractStringBuilder,它的默认构造方法是:

    public StringBuilder() {
        super(16);
    }

调用父类的构造方法,父类对应的构造方法是:

AbstractStringBuilder(int capacity) {
    value = new char[capacity];
}

也就是说,new StringBuilder()代码内部会创建一个长度为 16 16 16的字符数组,count的默认值为 0 0 0。来看append方法的代码:

public AbstractStringBuilder append(String str) {
    if(str == null) str = "null";
    int len = str.length();
    ensureCapacityInternal(count + len);
    str.getChars(0, len, value, count);
    count += len;
    return this;
}

append会直接复制字符到内部的字符数组中,如果字符数组长度不够,会进行扩展,实际使用的长度用count体现。具体来说,ensureCapacityInternal(count+len)会确保数组的长度足以容纳新添加的字符,str.getChars会复制新添加的字符到字符数组中,count+=len会增加实际使用的长度。

ensureCapacityInternal的代码如下:

private void ensureCapacityInternal(int minimumCapacity) {
    //overflow-conscious code
    if(minimumCapacity - value.length > 0)
        expandCapacity(minimumCapacity);
}

如果字符数组的长度小于需要的长度,则调用expandCapacity进行扩展,其代码为:

void expandCapacity(int minimumCapacity) {
    int newCapacity = value.length * 2 + 2;
    if(newCapacity - minimumCapacity < 0)
        newCapacity = minimumCapacity;
    if(newCapacity < 0) {
        if (minimumCapacity < 0) //overflow
            throw new OutOfMemoryError();
        newCapacity = Integer.MAX_VALUE;
    }
    value = Arrays.copyOf(value, newCapacity);
}

扩展的逻辑是:分配一个足够长度的新数组,然后将原内容复制到这个新数组中,最后让内部的字符数组指向这个新数组。这里主要看下newCapacity是怎么算出来的。参数minimumCapacity表示需要的最小长度,需要多少分配多少不就行了吗?不行,因为那就跟String一样了,每append一次,都会进行一次内存分配,效率低下。这里的扩展策略是跟当前长度相关的,当前长度乘以 2 2 2,再加上 2 2 2,如果这个长度不够最小需要的长度,才用minimumCapacity。比如,默认长度为 16 16 16,长度不够时,会先扩展到 16 ∗ 2 + 2 16*2+2 162+2 34 34 34,然后扩展到 34 ∗ 2 + 2 34*2+2 342+2 70 70 70,然后是 70 ∗ 2 + 2 70*2+2 702+2 142 142 142,这是一种指数扩展策略。为什么要加 2 2 2?这样,在原长度为 0 0 0​时也可以一样工作。

除了appendtoString方法, StringBuilder还有很多其他方法,包括更多构造方法、更多append方法、插入、删除、替换、翻转、长度有关的方法。

3.7 String的+和+=运算符

Java中,String可以直接使用 + + + + = += +=运算符,这是Java编译器提供的支持,背后,Java编译器一般会生成StringBuilder++=操作会转换为append。比如,如下代码:

String hello = "hello";
for(int i=0; i<3; i++){
	hello+=", world";
}
System.out.println(hello);

背后,Java编译器一般会转换为:

String hello = "hello";
for(int i=0; i<3; i++){
    StringBuilder sb = new StringBuilder(hello);
    sb.append(", world");
    hello = sb.toString();
}
System.out.println(hello);

既然直接使用 + + + + = += +=就相当于使用StringBuilderappend,那还有什么必要直接使用StringBuilder呢?在简单的情况下,确实没必要。不过,在稍微复杂的情况下,Java编译器可能没有那么智能,它可能会生成过多的StringBuilder,尤其是在有循环的情况下,在循环内部,每一次 + = += +=操作,都会生成一个StringBuilder。所以,对于简单的情况,可以直接使用String + + + + = += +=​,对于复杂的情况,尤其是有循环的时候,应该直接使用StringBuilder


  1. 马俊昌.Java编程的逻辑[M].北京:机械工业出版社,2018. ↩︎

  2. 尚硅谷教育.剑指Java:核心原理与应用实践[M].北京:电子工业出版社,2023. ↩︎

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值