String、StringBuffer、StringBuilder、StringJoiner,论字符串四兄弟的 Java 江湖地位

337 阅读9分钟

在 Java 的江湖中,字符串处理是最常见的“内功修炼”。其中,String、StringBuffer 和 StringBuilder 这三位“字符串三兄弟”各自有着鲜明的性格和擅长场景。 不过,随着 Java 8、Java 9 的演进,江湖里也出现了“串串新秀”——StringJoiner 和 String.join()。它们像是带着现代化工具的串串师傅,专为优雅拼接而生。 今天,我们就来全面盘点这几位角色的特点、用法、性能与适配场景,顺便讲几个他们“出道以来的传闻与黑历史”。

前言

String:就像一个一次性密封的罐头,罐头里的食物一旦装好了,就不能修改、添加或移除内容。即使只是加一点盐(拼接字符串),你也只能重新制作一个新的罐头,而原来的罐头保持不变。。

StringBuffer:就像一个加锁的大锅,只有一把钥匙可以开锁。所有人(线程)都必须排队才能往里面加菜或捞菜(线程安全)。这个锁确保了每个人按照顺序操作,顺序是正确的,结果不会出错,由于大家都要排队操作,等锁的时间会导致效率较低(性能损耗),尤其当有很多人(线程)需要同时用锅时。

StringBuilder:这是一个私人小锅,本来是专供你一个人使用的。你可以随时加东西、捞东西,效率很高,而且没有锅盖(非线程安全),如果突然有其他人(另一个线程)也跑来用你的锅,没有锅盖的保护,可能会加错菜、漏掉菜,甚至导致“炸锅” (线程不安全)。

StringJoiner:这是一台智能烤串机器人,不仅能按你选的竹签款式(分隔符)自动穿食材(字符串),还能给整串烤物包上定制包装纸(前缀/后缀)——比如“豪华礼盒装”[🍢, 🍡, 🍢]!更妙的是,它支持中途加菜(add())、合并两串(merge()),甚至设定“空盒彩蛋”(setEmptyValue())。适合组装那些需要精致摆盘的字符串大餐,比如JSON数组或日志格式化输出。

String.join():这是便利店关东煮机——“老板,用番茄酱(分隔符)把这些鱼丸(字符串列表)淋一下!” 不用操心竹签怎么穿、盒子怎么装,一键搞定整碗🍢🍡🍢。虽然不能加包装纸或中途添料,但胜在速战速决,适合简单拼接如路径、CSV等“即食需求”。


一锅端类比图:

类/方法比喻核心特点适用场景
String一次性密封罐头内容不可变,修改需换新罐头静态文本、常量
StringBuffer加锁大锅线程安全但效率低(排队等锁)多线程环境下的字符串操作
StringBuilder私人小锅高效但线程不安全(可能炸锅)单线程频繁修改字符串
StringJoiner智能烤串机器人自定义分隔符+包装盒,支持动态加料复杂格式化拼接(JSON、日志等)
String.join()便利店关东煮机一键批量串联,简单直接快速列表拼接(路径、CSV等)

知识解析

1、String 的特性

  • 不可变性String 是不可变的,任何对 String 的操作(如拼接、裁剪)都会生成一个新的 String 对象。
  • 字符串常量池String 对象会被存储在字符串常量池中,相同的字符串字面量会共享同一个对象。
  • 性能问题:频繁的字符串操作会导致大量临时对象的创建,影响性能。
/**
 * String 示例
 */
public class StringExample {
    public static void main(String[] args) {
        String str1 = "Hello"// 存储在字符串常量池
        String str2 = "Hello"// 复用常量池中的对象
        String str3 = new String("Hello"); // 创建新的对象

        System.out.println(str1 == str2); // true,引用相同
        System.out.println(str1 == str3); // false,引用不同

        // 字符串拼接
        String result = str1 + " World"// 生成新的 String 对象
        System.out.println(result);
    }
}

2、StringBuffer 的特性

  • 可变性StringBuffer 是可变的字符序列,支持动态修改。
  • 线程安全StringBuffer 的方法都使用了 synchronized 关键字,保证了线程安全。
  • 性能开销:由于线程安全机制,StringBuffer 的性能较低。
/**
 * StringBuffer 示例
 */
public class StringBufferExample {
    public static void main(String[] args) {
        StringBuffer buffer = new StringBuffer("Hello");
        buffer.append(" World"); // 直接修改原对象
        System.out.println(buffer.toString()); // 输出 "Hello World"
    }
}

3、StringBuilder 的特性

  • 可变性StringBuilder 是可变的字符序列,支持动态修改。
  • 非线程安全StringBuilder 没有线程安全机制,性能更高。
  • 适用场景:单线程环境下的字符串操作。
/**
 * StringBuilder 示例
 */
public class StringBuilderExample {
    public static void main(String[] args) {
        StringBuilder builder = new StringBuilder("Hello");
        builder.append(" World"); // 直接修改原对象
        System.out.println(builder.toString()); // 输出 "Hello World"
    }
}

4、StringJoiner 的特性

  • 可指定分隔符、前缀和后缀。
  • 自动处理多个元素之间的拼接逻辑。
import java.util.StringJoiner;

public class StringJoinerExample {
    public static void main(String[] args) {
        StringJoiner joiner = new StringJoiner(", ", "[", "]");
        joiner.add("Java");
        joiner.add("Python");
        joiner.add("Go");

        System.out.println(joiner.toString()); // 输出:[Java, Python, Go]
    }
}

5、String.join() 的特性

  • StringJoiner 的简化封装。
  • 通常用于将数组、集合等连接为一个字符串。
import java.util.Arrays;

public class StringJoinExample {
    public static void main(String[] args) {
        String result = String.join(" | ", "Java", "Kotlin", "Scala");
        System.out.println(result); // 输出:Java | Kotlin | Scala

        // 与集合一起使用
        var list = Arrays.asList("Red", "Green", "Blue");
        System.out.println(String.join(" - ", list)); // 输出:Red - Green - Blue
    }
}

知识拓展

1、性能对比

  • String:每次操作都会生成新对象,性能最差。
  • StringBuffer:线程安全,性能中等。
  • StringBuilder:非线程安全,性能最好。
/**
 * 性能对比示例
 */
public class PerformanceComparison {
    public static void main(String[] args) {
        int n = 100000;
        long startTime;

        // String 性能测试
        startTime = System.currentTimeMillis();
        String str = "";
        for (int i = 0; i < n; i++) {
            str += i; // 每次拼接生成新对象
        }
        System.out.println("String 耗时: " + (System.currentTimeMillis() - startTime) + "ms");

        // StringBuffer 性能测试
        startTime = System.currentTimeMillis();
        StringBuffer buffer = new StringBuffer();
        for (int i = 0; i < n; i++) {
            buffer.append(i); // 直接修改原对象
        }
        System.out.println("StringBuffer 耗时: " + (System.currentTimeMillis() - startTime) + "ms");

        // StringBuilder 性能测试
        startTime = System.currentTimeMillis();
        StringBuilder builder = new StringBuilder();
        for (int i = 0; i < n; i++) {
            builder.append(i); // 直接修改原对象
        }
        System.out.println("StringBuilder 耗时: " + (System.currentTimeMillis() - startTime) + "ms");
    }
}
  • String:适用于字符串内容不经常变化的场景,例如常量字符串。
  • StringBuffer:适用于多线程环境下的字符串操作。
  • StringBuilder:适用于单线程环境下的字符串操作,性能最优。

3、内部实现

从 JDK 9 开始,StringBuffer 和 StringBuilder 的底层实现从 char 数组改为 byte 数组,并结合一个编码标志字段(coder)来支持 Latin-1 和 UTF-16 两种编码。这一设计的主要目的是优化内存使用和性能,但也带来了一些优缺点。


设计的背景

在 JDK 9 之前,StringBufferStringBuilder 使用 char 数组存储字符数据,每个字符占用 2 个字节(UTF-16 编码)。然而,许多应用程序主要处理 Latin-1 字符集(单字节字符),这导致内存浪费。

为了优化内存使用,JDK 9 引入了 紧凑字符串(Compact Strings) 设计,将底层存储改为 byte 数组,并根据字符内容动态选择编码方式:

  • 如果字符串仅包含 Latin-1 字符,则使用单字节存储。
  • 如果字符串包含 非 Latin-1 字符(如中文、日文等),则使用双字节存储(UTF-16)。

优点

  • 节省内存:对于仅包含 Latin-1 字符的字符串,内存占用减少一半。例如,字符串 "Hello" 在 JDK 9 之前需要 10 字节(5 个 char),而在 JDK 9 之后只需要 5 字节(5 个 byte)。

  • 提高性能:减少内存占用可以降低垃圾回收的频率,从而提高性能。对于单字节字符串,操作(如复制、比较、拼接)的速度更快,因为数据量更小。

  • 动态编码:根据字符串内容动态选择编码方式,兼顾内存效率和功能完整性。


缺点

  • 复杂性增加:实现逻辑变得更加复杂,需要处理两种编码方式(Latin-1 和 UTF-16)。编码转换(如从 Latin-1 到 UTF-16)可能引入额外的性能开销。

  • 性能波动:对于包含非 Latin-1 字符的字符串,性能可能不如 JDK 9 之前的实现,因为需要处理双字节编码。编码标志(coder)的检查和切换可能引入额外的分支判断,影响性能。

  • 内存碎片:由于编码方式可能动态变化,字符串的内存布局可能不如之前紧凑,导致内存碎片增加。


方面优点缺点
内存使用节省内存(Latin-1 字符串减少 50% 内存占用)编码切换可能导致内存碎片
性能单字节字符串操作更快,垃圾回收压力减小双字节字符串操作可能变慢,编码切换有额外开销
兼容性对外部 API 透明,无需修改代码依赖于底层实现的代码可能不兼容
实现复杂度动态编码优化内存使用实现逻辑更复杂,维护成本增加
  • 适用场景:适用于大多数字符串操作,尤其是以 Latin-1 字符为主的场景。
  • 注意事项:在性能敏感的场景中,需要测试不同编码下的性能表现,确保优化效果符合预期。

通过这一设计,JDK 在内存使用和性能之间取得了更好的平衡,同时保持了功能的完整性和兼容性。

编码标志字段(coder)和编码切换

1、coder 的值

coder 是一个 byte 类型的字段,取值范围为 01

  • 0:表示字符串使用 Latin-1 编码(单字节)。
  • 1:表示字符串使用 UTF-16 编码(双字节)。

2、coder 的初始化

在创建字符串时,JDK 会根据字符串的内容初始化 coder

  • 如果字符串中的所有字符都可以用 Latin-1 编码表示(即字符值在 0x000xFF 之间),则 coder 被设置为 0
  • 如果字符串中包含任何无法用 Latin-1 编码表示的字符(如中文、日文等),则 coder 被设置为 1

3、动态切换编码

当对字符串进行修改(如追加字符)时,JDK 会检查新字符的编码是否与当前 coder 兼容:

  • 如果新字符可以用当前编码表示,则直接追加。
  • 如果新字符无法用当前编码表示,则触发编码切换,将底层 byte 数组从 Latin-1 转换为 UTF-16。更新 coder1

代码示例

以下是一个代码示例,展示 StringBuilder 如何根据字符内容动态切换编码:

public class StringBuilderEncodingExample {
    public static void main(String[] args) {
        StringBuilder sb = new StringBuilder();

        // 添加 Latin-1 字符
        sb.append("Hello");
        System.out.println("当前编码: " + getEncoding(sb)); // Latin-1

        // 添加非 Latin-1 字符(中文)
        sb.append(",世界");
        System.out.println("当前编码: " + getEncoding(sb)); // UTF-16
    }

    // 通过反射获取 StringBuilder 的 coder 字段(仅用于演示,不推荐在生产环境中使用)
    private static String getEncoding(StringBuilder sb) {
        try {
            java.lang.reflect.Field coderField = StringBuilder.class.getDeclaredField("coder");
            coderField.setAccessible(true);
            byte coder = coderField.getByte(sb);
            return coder == 0 ? "Latin-1" : "UTF-16";
        } catch (Exception e) {
            throw new RuntimeException("无法获取编码信息", e);
        }
    }
}

运行结果

当前编码: Latin-1
当前编码: UTF-16

编码切换需要重新分配内存并复制数据,因此会引入一定的性能开销。为了避免频繁切换,JDK 会尽量延迟切换,直到确实需要时才进行。