MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Java单线程中StringBuilder的最佳实践案例

2021-01-291.9k 阅读

理解 StringBuilder 的基本概念

在 Java 编程中,StringBuilder 类是一个可变的字符序列。与不可变的 String 类不同,StringBuilder 的内容可以动态修改,这使得它在需要频繁拼接字符串的场景下表现得更加高效。

String 类的不可变性意味着每当对 String 对象进行修改操作(如拼接、替换等)时,实际上会创建一个新的 String 对象,原有的对象并不会改变。例如:

String str = "Hello";
str = str + " World";

在上述代码中,首先创建了字符串 "Hello",然后进行拼接操作时,又创建了新的字符串 "Hello World",原来的 "Hello" 字符串依然存在于内存中,只是 str 变量指向了新的字符串对象。这种操作在频繁拼接字符串时会导致大量的内存开销,因为会不断创建新的对象。

StringBuilder 类则通过提供一系列的方法来修改自身的字符序列,避免了频繁创建新对象的开销。例如:

StringBuilder sb = new StringBuilder("Hello");
sb.append(" World");

这里通过 append 方法直接在 sb 所指向的 StringBuilder 对象上进行修改,不会创建新的对象(除了最初创建 StringBuilder 对象本身),从而提高了性能。

StringBuilder 的内部实现

StringBuilder 类内部维护了一个字符数组来存储字符序列。这个字符数组的初始容量可以在创建 StringBuilder 对象时指定,如果没有指定,默认初始容量为 16。当向 StringBuilder 中追加字符时,如果当前字符数组的容量不足以容纳新的字符,StringBuilder 会自动扩容。

下面是 StringBuilder 类的部分关键源码,以便更好地理解其内部实现:

public final class StringBuilder
    extends AbstractStringBuilder
    implements java.io.Serializable, CharSequence
{
    public StringBuilder() {
        super(16);
    }

    public StringBuilder(int capacity) {
        super(capacity);
    }

    public StringBuilder(String str) {
        super(str.length() + 16);
        append(str);
    }

    @Override
    public StringBuilder append(String str) {
        super.append(str);
        return this;
    }
}

abstract class AbstractStringBuilder implements Appendable, CharSequence {
    char[] value;
    int count;

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

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

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

    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);
    }
}

从上述源码可以看出,StringBuilder 继承自 AbstractStringBuilderAbstractStringBuilder 中定义了字符数组 value 来存储字符序列,count 表示当前已经使用的字符个数。当调用 append 方法追加字符串时,首先会检查当前容量是否足够,如果不足则调用 expandCapacity 方法进行扩容。扩容的策略是将当前容量翻倍再加 2,然后通过 Arrays.copyOf 方法创建一个新的更大的字符数组,并将原数组的内容复制到新数组中。

在单线程环境下 StringBuilder 的最佳实践案例

字符串拼接

在日常开发中,字符串拼接是非常常见的操作。例如,从数据库中读取多条记录,需要将每条记录的某些字段拼接成一个完整的字符串输出。在这种情况下,使用 StringBuilder 可以显著提高性能。

假设我们要从一个包含学生信息的列表中,将每个学生的姓名和年龄拼接成一个字符串,并最终得到一个包含所有学生信息的大字符串。示例代码如下:

import java.util.ArrayList;
import java.util.List;

class Student {
    private String name;
    private int age;

    public Student(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public String getName() {
        return name;
    }

    public int getAge() {
        return age;
    }
}

public class StringBuilderExample {
    public static void main(String[] args) {
        List<Student> studentList = new ArrayList<>();
        studentList.add(new Student("Alice", 20));
        studentList.add(new Student("Bob", 22));
        studentList.add(new Student("Charlie", 21));

        StringBuilder result = new StringBuilder();
        for (Student student : studentList) {
            result.append("Name: ").append(student.getName()).append(", Age: ").append(student.getAge()).append("\n");
        }

        System.out.println(result.toString());
    }
}

在上述代码中,通过 StringBuilderappend 方法依次将每个学生的信息拼接起来,最后调用 toString 方法将 StringBuilder 对象转换为 String 类型输出。如果使用 String 类进行拼接,每次拼接都会创建一个新的 String 对象,性能会大打折扣。

SQL 语句构建

在数据库操作中,动态构建 SQL 语句是常见的需求。例如,根据不同的查询条件构建 SELECT 语句。使用 StringBuilder 可以更方便、高效地完成这个任务。

假设我们要根据用户输入的条件查询员工信息,可能需要根据员工姓名、部门等条件构建 SELECT 语句。示例代码如下:

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;

public class SQLBuilderExample {
    public static void main(String[] args) {
        String name = "John";
        String department = "Engineering";

        StringBuilder sql = new StringBuilder("SELECT * FROM employees WHERE 1 = 1");
        if (name != null &&!name.isEmpty()) {
            sql.append(" AND name =?");
        }
        if (department != null &&!department.isEmpty()) {
            sql.append(" AND department =?");
        }

        try (Connection conn = DriverManager.getConnection("jdbc:mysql://localhost:3306/mydb", "root", "password");
             PreparedStatement pstmt = conn.prepareStatement(sql.toString())) {
            int paramIndex = 1;
            if (name != null &&!name.isEmpty()) {
                pstmt.setString(paramIndex++, name);
            }
            if (department != null &&!department.isEmpty()) {
                pstmt.setString(paramIndex++, department);
            }

            ResultSet rs = pstmt.executeQuery();
            while (rs.next()) {
                System.out.println("Employee ID: " + rs.getInt("id") + ", Name: " + rs.getString("name") + ", Department: " + rs.getString("department"));
            }
        } catch (SQLException e) {
            e.printStackTrace();
        }
    }
}

在上述代码中,首先创建一个 StringBuilder 对象,并初始化一个基本的 SELECT 语句。然后根据传入的条件动态地向 StringBuilder 中追加条件子句。最后将 StringBuilder 转换为 String 并用于创建 PreparedStatement 对象执行 SQL 查询。这种方式不仅提高了性能,还避免了 SQL 注入的风险。

日志记录优化

在日志记录中,经常需要将不同的信息拼接成一条完整的日志信息。例如,记录用户操作日志,需要包含用户 ID、操作时间、操作内容等信息。使用 StringBuilder 可以提高日志记录的效率。

假设我们有一个简单的日志记录类,示例代码如下:

import java.util.Date;

class Logger {
    private static final StringBuilder logBuffer = new StringBuilder();

    public static void log(String userId, String operation) {
        Date now = new Date();
        logBuffer.setLength(0);
        logBuffer.append(now).append(" - User ID: ").append(userId).append(" - Operation: ").append(operation).append("\n");
        System.out.println(logBuffer.toString());
    }
}

public class LoggerExample {
    public static void main(String[] args) {
        Logger.log("12345", "Login");
        Logger.log("67890", "Logout");
    }
}

在上述代码中,Logger 类使用一个 StringBuilder 来构建日志信息。每次记录日志时,先通过 setLength(0) 方法清空 StringBuilder,然后重新拼接新的日志信息。这种方式避免了每次记录日志都创建新的 String 对象,提高了日志记录的性能。

性能测试与比较

为了更直观地展示 StringBuilder 在字符串拼接方面相对于 String 的性能优势,我们可以进行一个简单的性能测试。

public class PerformanceTest {
    public static void main(String[] args) {
        int iteration = 100000;
        long startTime, endTime;

        // 使用 String 进行拼接
        startTime = System.currentTimeMillis();
        String resultString = "";
        for (int i = 0; i < iteration; i++) {
            resultString += "a";
        }
        endTime = System.currentTimeMillis();
        System.out.println("Using String: " + (endTime - startTime) + " ms");

        // 使用 StringBuilder 进行拼接
        startTime = System.currentTimeMillis();
        StringBuilder resultBuilder = new StringBuilder();
        for (int i = 0; i < iteration; i++) {
            resultBuilder.append("a");
        }
        endTime = System.currentTimeMillis();
        System.out.println("Using StringBuilder: " + (endTime - startTime) + " ms");
    }
}

在上述代码中,我们分别使用 StringStringBuilder 进行 100000 次字符串拼接操作,并记录每次操作的时间。运行结果会显示 StringBuilder 的拼接操作明显比 String 快很多,这进一步证明了在频繁字符串拼接场景下 StringBuilder 的性能优势。

StringBuilder 的其他注意事项

容量设置的影响

在创建 StringBuilder 对象时,可以指定初始容量。合理设置初始容量可以减少扩容的次数,从而提高性能。如果初始容量设置过小,在追加字符时会频繁扩容,导致性能下降;而如果初始容量设置过大,又会浪费内存空间。

例如,如果我们知道要拼接的字符串长度大概在 1000 个字符左右,可以这样创建 StringBuilder 对象:

StringBuilder sb = new StringBuilder(1000);

这样可以避免在拼接过程中频繁扩容,提高性能。

线程安全性

需要注意的是,StringBuilder 是线程不安全的。在单线程环境下,这不是问题,因为不存在多个线程同时访问和修改 StringBuilder 对象的情况。但如果在多线程环境中使用 StringBuilder,可能会导致数据不一致等问题。

在多线程环境下,如果需要保证线程安全的字符串拼接,可以使用 StringBuffer 类。StringBuffer 类和 StringBuilder 类具有相似的功能,但 StringBuffer 的方法都是同步的,因此可以在多线程环境中安全使用。不过,由于同步操作会带来一定的性能开销,所以在单线程环境下,StringBuilder 是更好的选择。

例如,在多线程环境下使用 StringBuffer 进行字符串拼接:

class ThreadSafeExample implements Runnable {
    private static StringBuffer result = new StringBuffer();

    @Override
    public void run() {
        for (int i = 0; i < 1000; i++) {
            result.append(Thread.currentThread().getName()).append(": ").append(i).append("\n");
        }
    }

    public static void main(String[] args) {
        Thread thread1 = new Thread(new ThreadSafeExample());
        Thread thread2 = new Thread(new ThreadSafeExample());

        thread1.start();
        thread2.start();

        try {
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println(result.toString());
    }
}

在上述代码中,StringBufferappend 方法是同步的,多个线程同时调用 append 方法时不会出现数据不一致的问题。但如果在单线程环境中使用 StringBuffer,由于同步操作的开销,性能会比 StringBuilder 差。

StringBuilder 与其他字符串处理方式的结合使用

在实际开发中,StringBuilder 常常需要与其他字符串处理方式结合使用,以满足更复杂的需求。

与正则表达式结合

正则表达式在字符串处理中非常常用,例如验证字符串格式、提取特定子字符串等。StringBuilder 可以与正则表达式配合使用,对匹配的结果进行进一步处理。

假设我们有一个字符串,需要提取其中所有的数字,并将这些数字拼接成一个新的字符串。示例代码如下:

import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class RegexWithStringBuilderExample {
    public static void main(String[] args) {
        String input = "abc123def456ghi";
        Pattern pattern = Pattern.compile("\\d+");
        Matcher matcher = pattern.matcher(input);

        StringBuilder result = new StringBuilder();
        while (matcher.find()) {
            result.append(matcher.group());
        }

        System.out.println(result.toString());
    }
}

在上述代码中,首先使用正则表达式 \\d+ 匹配字符串中的所有数字,然后通过 StringBuilder 将匹配到的数字依次拼接起来。

与字符串格式化结合

Java 提供了 String.format 方法用于字符串格式化,StringBuilder 可以与 String.format 结合使用,以更灵活地构建格式化后的字符串。

例如,我们要将一些变量的值格式化为特定的字符串格式,并拼接成一个完整的字符串。示例代码如下:

public class FormatWithStringBuilderExample {
    public static void main(String[] args) {
        int number = 42;
        double price = 19.99;
        String name = "Product";

        StringBuilder result = new StringBuilder();
        result.append(String.format("The number is %d, the price is $%.2f, and the name is %s.", number, price, name));

        System.out.println(result.toString());
    }
}

在上述代码中,先使用 String.format 方法将变量格式化为特定的字符串,然后通过 StringBuilder 将格式化后的字符串添加到最终的结果中。这种方式结合了字符串格式化的灵活性和 StringBuilder 的高效拼接性能。

优化 StringBuilder 使用的技巧

在使用 StringBuilder 时,还有一些技巧可以进一步优化性能和代码质量。

减少不必要的方法调用

StringBuilder 的使用过程中,尽量减少不必要的方法调用。例如,在循环中,如果每次循环都调用 toString 方法,会导致性能下降,因为每次调用 toString 都会创建一个新的 String 对象。

// 不好的做法
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 1000; i++) {
    sb.append(i);
    String temp = sb.toString(); // 每次循环都调用 toString 创建新的 String 对象
    // 对 temp 进行其他操作
}

// 好的做法
StringBuilder sb2 = new StringBuilder();
for (int i = 0; i < 1000; i++) {
    sb2.append(i);
}
String finalResult = sb2.toString(); // 只在循环结束后调用一次 toString

在上述示例中,第二种做法只在循环结束后调用一次 toString 方法,避免了在循环中频繁创建新的 String 对象,提高了性能。

链式调用

StringBuilder 的大多数方法(如 appendinsert 等)都返回 this,这使得可以进行链式调用,使代码更加简洁。

StringBuilder sb3 = new StringBuilder();
sb3.append("Hello").append(" ").append("World");

相比之下,如果不使用链式调用,代码会显得更加冗长:

StringBuilder sb4 = new StringBuilder();
sb4.append("Hello");
sb4.append(" ");
sb4.append("World");

链式调用不仅使代码更简洁,而且在一定程度上也提高了代码的可读性。

重用 StringBuilder 对象

在某些情况下,可以重用 StringBuilder 对象,而不是每次都创建新的对象。例如,在一个方法中多次进行字符串拼接操作,可以使用同一个 StringBuilder 对象。

public class StringBuilderReuseExample {
    private static StringBuilder sb = new StringBuilder();

    public static String processData(String data1, String data2) {
        sb.setLength(0);
        sb.append(data1).append(" - ").append(data2);
        return sb.toString();
    }

    public static void main(String[] args) {
        String result1 = processData("Apple", "Red");
        String result2 = processData("Banana", "Yellow");

        System.out.println(result1);
        System.out.println(result2);
    }
}

在上述代码中,通过 setLength(0) 方法清空 StringBuilder 对象的内容,然后重用它进行不同数据的拼接,避免了每次都创建新的 StringBuilder 对象,提高了性能。

总结 StringBuilder 在单线程中的优势

在单线程环境下,StringBuilder 具有显著的性能优势,特别是在频繁进行字符串拼接、构建 SQL 语句、日志记录等场景中。通过合理设置初始容量、减少不必要的方法调用、使用链式调用和重用 StringBuilder 对象等技巧,可以进一步优化其性能和代码质量。同时,要注意 StringBuilder 是线程不安全的,在单线程环境中使用它是最佳选择,但在多线程环境下需要考虑使用 StringBuffer 或其他线程安全的方式。掌握 StringBuilder 的最佳实践,能够帮助开发者编写出高效、健壮的 Java 代码。