Java字符串处理:从JDK1到JDK21的变化

自 1995 年诞生以来,Java 一直是软件工程领域的重要参与者。多年来,它经历了重大发展。在它的众多特性中,一个关键方面是 Java 如何处理文本。事实上,String是 Java 程序中大量使用的对象。平均而言,典型 Java 堆的 50% 可能被String对象消耗,这是相当大的。
本文探讨了 Java 中字符串处理的演变,从第一个版本到最新版本 Java 21。

字符串处理的早期 Java
在 JDK 1 中,Java 将字符串类作为不可变的字符序列引入,这是考虑到可靠性和安全性而做出的选择。不可变字符串是线程安全的,允许在多线程应用程序中跨多个线程安全使用,其可预测性和抗篡改性确保了网络地址和文件路径等敏感数据的安全。Java 的字符串池(string pooling)由这种不可变性所支持,可有效地只存储每个唯一字符串的一个副本,从而减少内存使用量。

JDK 1 中的字符串连接主要使用 + 运算符。例如
String greeting = "Hello, " + name + "!".

然而,这种方法存在效率问题,尤其是在多重连接时。在连接过程中,每次使用 + 运算符都会创建一个新的字符串对象。在循环等情况下,这种方法的效率尤为低下,因为在循环中连接字符串列表时,每次迭代都会创建一个新的字符串对象,从而导致大量的性能开销和内存使用量增加。

String[] words = {"Java", "is", "cool"};
String sentence =
"";

for (String word : words) {
   sentence = sentence + word +
" "; // Inefficient concatenation
}

System.out.println(sentence.trim()); 

上述代码将创建七个字符串对象来构建一个字符串。

JDK 1 到 JDK 5:引入 StringBuffer 和 StringBuilder
为了解决效率问题,Java 引入了 StringBuffer 类,该类提供了一个可变的字符序列。这改变了字符串的操作方式,尤其是在涉及频繁修改的情况下。例如

public class StringBufferExample {
   public static void main(String[] args) {
       StringBuffer stringBuffer = new StringBuffer();
       String[] words = {"Java", "evolves", "with", "time"};

       for (String word : words) {
           stringBuffer.append(word).append(
" ");
       }

       System.out.println(stringBuffer.toString().trim());
   }
}

JDK 5 引入 StringBuilder 后,字符串操作又向前迈进了一步。它在提供可变字符序列方面与 StringBuffer 相似,但在一个关键方面有所不同。

两者的主要区别在于,StringBuilder 的速度更快一些,更适合单线程情况,因为它不是线程安全的。相比之下,StringBuffer 是线程安全的,但由于采用了同步方法,速度稍慢,因此非常适合多线程环境。两者都提供类似的 API,可以根据线程安全要求轻松互换。

增强的字符串处理能力
接下来,JDK 6 和 JDK 7 继续改进字符串处理,更侧重于性能优化,而不是引入新的 API。JDK 8 引入了 lambda 表达式和 Stream API,彻底改变了开发人员处理数据(包括字符串)的方式。

有了 lambda 表达式和流,JDK 8 对字符串集合的操作变得更加简洁和富有表现力。

List words = Arrays.asList("Java", "is", "evolving");
String combined = words.stream()
                       .map(String::toUpperCase)
                       .collect(Collectors.joining(
" "));

// Output: "JAVA IS EVOLVING"

在本例中,我们将一个字符串列表转换为一个串联字符串。每个单词都被转换为大写字母,然后将它们连接起来。这种方法更具可读性,而且无需手动迭代和字符串连接。

此外,JEP 192 还通过增强 G1 垃圾收集器来减少 Java 堆的实时数据集,从而自动、持续地重复删除 String 的重复实例。

JDK 9 到 11:紧凑字符串和 API 增强功能
在 Java 9 中,字节码级别处理字符串连接的方式有了重大改进。invokedynamic 是一种特殊的字节码指令,它的引入改变了游戏规则。

在使用 + 运算符连接字符串时,Java 9 及其后续版本使用 invokedynamic,将优化责任委托给 java.lang.invoke.StringConcatFactory#makeConcatWithConstants。这种方法能更有效地优化字符串连接。

请看以下代码:
 

public class StringConcatenation {
    public static void main(String[] args) {
        String[] words = {"Java", "is", "cool"};
        String sentence =
"";

        for (String word : words) {
            sentence = sentence + word +
" "; // Inefficient concatenation
        }

        System.out.println(sentence.trim());
    }
}

上述代码的等效字节码(连接发生时)为

46: aload         6
48: invokedynamic #7,  0              // InvokeDynamic #0:makeConcatWithConstants:(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;
53: astore_2

这一优化是一项重大的底层改进,减少了字符串连接的内存和性能开销。

对于那些对这一改进的技术细节感兴趣的人,JEP 280 提供了深入的解释。本 Java 增强提案详细介绍了 invokedynamic 为字符串连接带来的更改和优化。

除此之外,JDK 9 还通过 JEP 254 引入了紧凑字符串,从而在字符串的内部表示方面引入了另一个重大变化。

在 Java 8 及更早的版本中,字符串以字符数组(char[])的形式表示,每个字符占用两个字节的内存。这种表示方法并不总是高效的,特别是考虑到西方语言中的许多字符只需一个字节即可编码。

请看字符串 :

  • char[] 数组对象的大小:8 字节(对象头)
  • 5 个字符(char)的大小:5 * 2 字节 = 10 字节
  • 数组长度(整数): 4 字节4 字节
  • 总大小:8 字节(头)+ 10 字节(字符)+ 4 字节(长度)= 22 字节

基于这种想法,新的紧凑型字符串用 8 位字节数组代替字符数组对字符串进行编码。除非明确需要 16 位字符,否则这些字符串都称为紧凑型字符串。因此,Java 9 中普通字符串的大小大约是 Java 8 中相同字符串大小的一半。

平均而言,典型 Java 堆的 50% 可能被字符串对象占用。这因应用程序而异,但平均而言,使用 Java 9 运行的此类程序的堆需求仅为使用 Java 8 运行的同一程序的 75%。

这是一笔巨大的节省。

尽管如此,JDK 11 继续扩展字符串 API,引入了以下方法strip()stripLeading()stripTrailing()repeat(), 和isBlank().

String text = "   Hello Java!   ";
String trimmed = text.strip();    
// "Hello Java!"
String repeated = text.repeat(2);
// "   Hello Java!      Hello Java!   "
boolean blank = text.isBlank();  
// false

这些方法使常见的字符串操作更加简单明了,减少了对外部库或自定义实用程序方法的需求。

JDK 12 到 15:渐进改进
在 JDK 12 到 15 版本期间,Java 重点对字符串处理进行了逐步改进和完善。这些版本为字符串类引入了多个新方法和增强功能,使字符串操作更加直观和高效。

JDK 12 为 String 类引入了新方法,进一步简化了常见的字符串操作。

String text = "Java\nEvolution";
String indentedText = text.indent(4);  
// Adds four spaces to the beginning of each line
// Result: "    Java\n    Evolution"

String transformed = text.transform(s -> new StringBuilder(s).reverse().toString());
// Result: "noitulovE\navaJ"


indent()方法可以添加或删除字符串中每一行的空格,而 transform()方法则可以对字符串应用函数。

JDK 15:文本块
JDK 15 中最重要的新增功能之一是引入了文本块,大大增强了多行字符串字面量的处理能力。

String html = """
              <html>
                  <body>
                      <p>Hello, Java 13!</p>
                  </body>
              </html>


文本块简化了多行字符串的创建,无需转义序列即可保留预期格式。

String name = "John";
String greeting =
"""
                 Hello,
                 Dear %s,
                 Welcome to our service.
                 
""".formatted(name);


文本块可与其他字符串或变量轻松连接,保持可读性和结构。

使用文本块后,在 Java 中创建复杂的 SQL 查询变得更易于管理和阅读。举个例子,我们需要构建一个 SQL 查询,以便从数据库中检索数据。该查询涉及多个连接、条件和潜在的复杂逻辑:

String complexSQL = """
    SELECT 
        u.name AS UserName,
        p.title AS PostTitle,
        c.name AS CategoryName
    FROM 
        Users u
    INNER JOIN 
        Posts p ON u.id = p.user_id
    LEFT JOIN 
        Categories c ON p.category_id = c.id
    WHERE 
        u.status = 'active'
        AND p.published_date >= '2022-01-01'
        AND (
            c.name = 'Technology'
            OR c.name = 'Science'
        )
    ORDER BY 
        p.published_date DESC
    LIMIT 10;
   
""";

JDK 21:字符串模板
JEP 430 在 Java 21 中引入了字符串模板作为预览功能。该增强功能允许将字面文本与嵌入式表达式和模板处理器相结合,从而简化 Java 编程。对于包含运行时计算值或由用户提供的值组成的字符串(如数据库系统),该功能非常有用。

有了这项功能,Java 开发人员现在可以使用字符串模板来增强语言的字符串字面量和文本块。这项新功能旨在简化 Java 程序的编写,提高混合文本和表达式的表达式的可读性,并增强 Java 程序的安全性,尤其是那些由用户提供的值组成字符串的程序。

让我们深入探讨一下。

1、模板表达式
我们引入了一种称为模板表达式的新型表达式,它允许开发人员安全、高效地执行字符串插值和组合字符串。模板表达式是可编程的,其功能不仅限于组成字符串,还能根据特定领域的规则将结构化文本转换为各种类型的对象。

 

String name = "Joan";
String info = STR.
"My name is \{name}";
assert info.equals(
"My name is Joan"); // true


在此示例中,模板表达式带有前缀,并与嵌入式表达式相结合,提供了一种安全、高效的字符串组合方式。

传统的字符串插值会产生安全漏洞,而 Java 的模板表达式则不同,它要求对带有嵌入式表达式的字符串进行验证和消毒。这种方法会自动应用特定于模板的规则,从而实现更安全、更高效的字符串组合。

例如,请看这段带有嵌入式表达式 ${name} 的假设 Java 代码:

String query = "SELECT * FROM Person p WHERE p.last_name = '${name}'";
ResultSet rs = connection.createStatement().executeQuery(query);

如果name有麻烦的值:

Smith' OR p.last_name <> 'Smith

那么查询字符串将是:

 

SELECT * FROM Person p WHERE p.last_name = 'Smith' OR p.last_name <> 'Smith'

代码会选择所有行,从而可能暴露机密信息。

为避免此类漏洞,Java 采用了更安全的方法。例如,在编写 SQL 语句时,内嵌表达式值中的任何引号都必须转义,整个字符串必须有平衡引号。

2、STR 模板处理器
STR 是 Java 平台定义的一种模板处理器。它执行字符串插值,将模板中的每个内嵌表达式替换为转换为字符串的表达式值。

让我们再看一个例子:

String title = "My Web Page";
String text  =
"Hello, world";
String html = STR.
"""
        <html>
          <head>
            <title>\{title}</title>
          </head>
          <body>
            <p>\{text}</p>
          </body>
        </html>

本例演示了如何使用模板表达式安全高效地创建结构化 HTML 内容。

STR 是一个公共静态最终字段,会自动导入到每个 Java 源文件中。

3、FMT 模板处理器
除了 STR,Java 还引入了 FMT,它是另一种具有额外功能的模板处理器。与 STR 类似,FMT 也执行插值,但它能独特地解释嵌入式表达式左侧的格式规范。这些格式说明符与 java.util.Formatter 中定义的格式说明符一致,为习惯使用 Java 标准格式化实用程序的用户提供了熟悉的语法。

FMT 处理器尤其适用于创建结构化和格式化的输出,其中对齐和数字格式化至关重要。

举个例子,我们定义了一个矩形记录,并创建了一个矩形对象数组。使用 FMT,我们可以格式化一个表格,整齐地显示每个矩形的属性和计算面积。

record Rectangle(String name, double width, double height) {
    double area() {
        return width * height;
    }
}

Rectangle[] zone = new Rectangle[] {
    new Rectangle("Alfa", 17.8, 31.4),
    new Rectangle(
"Bravo", 9.6, 12.4),
    new Rectangle(
"Charlie", 7.1, 11.23),
};

String table = FMT.
"""
    Description     Width    Height     Area
    %-12s\{zone[0].name}  %7.2f\{zone[0].width}  %7.2f\{zone[0].height}     %7.2f\{zone[0].area()}
    %-12s\{zone[1].name}  %7.2f\{zone[1].width}  %7.2f\{zone[1].height}     %7.2f\{zone[1].area()}
    %-12s\{zone[2].name}  %7.2f\{zone[2].width}  %7.2f\{zone[2].height}     %7.2f\{zone[2].area()}
    \{
" ".repeat(28)} Total %7.2f\{zone[0].area() + zone[1].area() + zone[2].area()}
   
""";

//Output:
Description     Width    Height     Area
Alfa            17.80    31.40      558.92
Bravo            9.60    12.40      119.04
Charlie          7.10    11.23       79.73
                             Total  757.69

该代码片段创建了一个结构良好的表格,展示了 FMT 在处理复杂字符串格式化情况时的强大功能。

4、用户定义的模板处理器
除了内置模板处理器 STR 和 FMT,Java 还允许开发人员创建自定义模板处理器。这种灵活性为满足特定应用需求的字符串操作提供了无限可能。

模板处理器本质上是功能接口 StringTemplate.Processor 的实例。它实现了 process 方法,该方法接收一个 StringTemplate 并返回一个对象。像 STR 这样的静态字段只是存储此类类的实例。

StringTemplate 表示模板表达式中使用的模板。它公开了嵌入式表达式的文本片段和值。片段和值这两个组件是自定义模板处理器运行的关键。

开发人员可以定义自己的模板处理器,利用 StringTemplate 类创建专门的字符串合成行为。
 

var INTER = StringTemplate.Processor.of((StringTemplate st) -> {
    StringBuilder sb = new StringBuilder();
    Iterator<String> fragIter = st.fragments().iterator();
    for (Object value : st.values()) {
        sb.append(fragIter.next());
        sb.append(value);
    }
    sb.append(fragIter.next());
    return sb.toString();
});

int x = 10, y = 20;
String s = INTER."\{x} plus \{y} equals \{x + y}";
// Output: "10 plus 20 equals 30"

在本例中,自定义处理器 INTER 交替使用片段和数值来构建最终字符串。

让我们考虑另一种情况,即我们希望在文本中嵌入代码片段,使其与周围的文本明显区分开来。这在技术写作、文档或教育材料中特别有用。

为此,我们可以定义一个自定义模板处理器 CODE,它可以处理模板,使用特定语法(如 Markdown 中的回车键)格式化和嵌入代码片段。

CODE 处理器可在常规文本中嵌入 Java 类名或代码片段,并将其格式化。

var CODE = StringTemplate.Processor.of((template) -> {
   List<Object> values = template.values();
   Iterator<String> fragIter = template.fragments().iterator();
   StringBuilder builder = new StringBuilder();
   for (Object value : values) {
       String next = fragIter.next();
       builder.append(next);
       builder.append(STR."`\{value}`");
   }
   builder.append(fragIter.next());
   return builder.toString();
});

String output = CODE.
"Use the \{String.class.getName()} class in Java for text manipulation.";
System.out.println(output);

// Output: Use the `java.lang.String` class in Java for text manipulation.

在本例中,CODE 处理器将 String.class.getName() 表达式包在了回车键中,将其明确标记为文本中的代码片段。

除了简单的字符串操作外,Java 的模板处理器 API 强大到足以容纳创建更复杂的数据结构。返回 JSONObject 实例的模板处理器就是一个很好的例子。

在许多现代应用程序(尤其是涉及数据交换和 Web API 的应用程序)中,以结构化和安全的方式动态创建 JSON 对象的能力至关重要。利用 Java 的模板处理器可以高效地实现这一目标。

下面介绍如何创建自定义模板处理器,解释模板表达式以生成 JSONObject:

import org.json.JSONObject; // Assuming the use of a common JSON library

var JSON = StringTemplate.Processor.of((StringTemplate st) -> {
            JSONObject json = new JSONObject();
            Iterator<Object> valueIterator = st.values().iterator();
            for (String string : st.fragments()) {
                String key = string.trim();
                if (!key.isEmpty() && valueIterator.hasNext()) {
                    Object value = valueIterator.next();
                    json.put(key, value);
                }
            }
            return json;
        });

        String name =
"Java";
        int version = 21;
        JSONObject jsonObject = JSON.
"name: \{name}, version: \{version}";
        System.out.println(jsonObject.toString());

// Output: {"name":"Java","version":21}


在此实现中,JSON 从模板中提取键和值,并使用它们来构建 JSONObject。这种方法对于动态构建 JSON 对象特别有用,因为数据来自应用程序中的各种来源。

需要注意的是,StringTemplate 目前只是预览版功能。希望尝试字符串模板和自定义模板处理器的开发人员必须明确启用这些功能。具体方法是在编译和运行 Java 应用程序时添加 --enable-preview 标志。例如: javac --release 21 --enable-preview Example.java javac --enable-preview Example