java.lang.String 常见用法与注意事项

最新新闻2小时前发布 jdkjadmin
0 00
java.lang.String 常见用法与注意事项

java.lang.String 常见用法与注意事项

java.lang.String 是 Java 中最常用的类型之一,用来表示一串字符。它看起来简单,实际使用时却常遇到一些容易忽视的问题:== 和 equals 的区别、字符串拼接的开销、split 把参数当成正则表达式、length() 不等于用户看到的字符数,以及字符串是否适合存放密码等敏感信息。

本文以日常开发为主线介绍 String 的特性与常见方法,并说明重要边界。示例以 Java 11 为基线;涉及 Java 11 才提供的方法时会特别标注。多数基础示例也能在较早版本中使用。

一、String 是什么:不可变的字符序列

String 表示字符序列。它是不可变对象:创建后,字符串内容不会被修改。诸如 toUpperCase()、replace() 和 trim() 这样看起来像“修改字符串”的方法,实际上会返回一个结果字符串,不会原地改动原对象。

public class StringImmutability {
    public static void main(String[] args) {
        String original = "java";
        String upper = original.toUpperCase();

        System.out.println(original); // java
        System.out.println(upper);   // JAVA
    }
}

不可变性让 String 适合用作映射键、配置值以及需要在多个位置传递的文本:拿到字符串引用的代码不能直接改变它的内容。不过,不可变不代表所有涉及它的操作都没有成本。反复拼接会创建中间结果;从外部读取的内容也仍须校验,不能因为类型是 String 就认为它可信。

String 的不可变性是公开的行为保证,具体内部表示属于实现细节。现代 Java 的实现可能采用紧凑字符串等优化,但应用代码不应依赖内部存储形式。也不要用锁住某个 String 来协调业务并发:字符串可能被共享或驻留,使用专用的私有锁对象更清晰可靠。

二、创建字符串与比较字符串

字符串字面量和 new

最常用的创建方式是字符串字面量:

String language = "Java";
String message = "Hello, " + language;

下面的写法也能创建 String 对象,但日常代码通常没有必要显式使用 new String(...):

String language = new String("Java");

字面量以及常量表达式可能被驻留(intern),运行时也会维护相应的字符串池机制。具体对象分配和复用行为不应成为业务逻辑依据。判断两个字符串的内容是否相同,应该使用 equals,而不是依赖它们是否指向同一个对象。

内容比较使用 equals

String first = new String("Java");
String second = new String("Java");

System.out.println(first == second);      // false
System.out.println(first.equals(second)); // true

== 比较引用是否指向同一个对象;equals 比较内容是否相同。比较忽略大小写时,可以使用 equalsIgnoreCase,但它并不等同于完整的 Unicode 语言学大小写匹配。如果业务涉及自然语言排序、区域规则或用户可见文本比较,应进一步明确所需规则,考虑 Collator 等专门工具。

如果左侧变量可能为 null,直接调用 value.equals(other) 会抛出 NullPointerException。可采用以下方式之一:

String input = null;

boolean matched1 = "admin".equals(input); // false,不会因 input 为 null 抛异常
boolean matched2 = java.util.Objects.equals(input, "admin"); // false

Objects.equals(a, b) 对两个参数都为 null 的情况返回 true。这并不代表所有场景都应该把 null 当成普通值处理;接口是否允许 null,仍应通过参数约定明确下来。

排序比较

compareTo 按 String 的自然顺序比较字符串,结果小于、等于或大于零,分别表示顺序在前、相等或在后。它适合实现基于自然顺序的排序,不适合作为语言相关的姓名或标题排序规则。除非 API 明确要求精确数值,不要依赖比较结果恰好为 -1 或 1。

System.out.println("apple".compareTo("banana") < 0); // true

三、null、空字符串与空白字符串

null、空字符串 "" 和只含空白的字符串是不同概念:

  • null 表示没有字符串对象。
  • "" 是长度为零的字符串对象。
  • " " 至少包含一个空格字符,不是空字符串。

可以用 isEmpty() 判断长度是否为零。Java 11 起可以用 isBlank() 判断字符串是否为空或只包含 Java 所定义的空白字符。两者都会在接收者为 null 时抛出异常,因此应先按接口约定处理 null。

String empty = "";
String spaces = " \t";

System.out.println(empty.isEmpty()); // true
System.out.println(spaces.isEmpty()); // false
System.out.println(spaces.isBlank()); // Java 11+:true

Java 11 之前可先按业务规则清理,再检查是否为空。不过,trim() 与 strip() 的空白字符定义并不相同,不能随意互换。

四、长度、索引与 Unicode 边界

String 使用从零开始的索引。charAt(i) 返回指定位置的一个 char;索引必须满足 0 <= i < length(),否则会抛出 IndexOutOfBoundsException。substring(begin, end) 使用左闭右开区间,要求 0 <= begin <= end <= length()。

String text = "Java";
System.out.println(text.charAt(0));       // J
System.out.println(text.substring(1, 3)); // av
System.out.println(text.substring(2));    // va

容易误解的是,length() 返回 UTF-16 代码单元的数量,不一定等于 Unicode 码点数量,更不一定等于用户视觉上看到的字符数量。常见 BMP 字符通常占一个 char;一些补充平面字符用一对代理项表示,占两个 UTF-16 代码单元。带组合附加符号的字形还可能由多个码点组成。

String text = "A\uD83D\uDE00"; // A 和一个补充平面字符
System.out.println(text.length()); // 3 个 UTF-16 代码单元
System.out.println(text.codePointCount(0, text.length())); // 2 个 Unicode 码点

如果需要逐 Unicode 码点处理,可使用 codePointAt、codePoints() 和 codePointCount。但码点数量仍不等于用户感知的“字符数”:例如一个显示出来的字形可能由基础字符和组合符号构成。涉及光标移动、截取可见字符或限制用户输入长度时,应先明确产品所说的长度是什么,必要时按字素簇处理,而不是直接把 length() 当作屏幕字符数。

不要把 char 一概视为完整 Unicode 字符,也不要随意拆分代理项对。按照固定索引切割字符串时,如果边界落在代理项对之间,得到的片段可能不再代表有效的成对代理项。

五、查找、判断与截取

日常查找通常使用 contains、startsWith、endsWith、indexOf 和 lastIndexOf。这些方法进行的是字面内容查找,不会把参数解释成正则表达式。

String path = "reports/2025/summary.csv";

System.out.println(path.startsWith("reports/")); // true
System.out.println(path.endsWith(".csv"));        // true
System.out.println(path.contains("summary"));     // true
System.out.println(path.indexOf('/'));            // 7

未找到时,indexOf 和 lastIndexOf 返回 -1。使用结果截取前应先检查返回值,避免错误索引导致异常,或者把未找到的情况误当成有效位置。

String address = "[email protected]";
int at = address.indexOf('@');

if (at > 0 && at < address.length() - 1) {
    String name = address.substring(0, at);
    String domain = address.substring(at + 1);
    System.out.println(name + " / " + domain);
}

这个例子只演示字符串查找与截取,不能用来验证邮箱地址是否合法。需要验证特定格式时,应根据业务规范使用合适的校验方案,而不是假设一个简单的字符判断足以验证所有合法地址。

六、替换、去空白与大小写转换

replace(char, char) 和 replace(CharSequence, CharSequence) 按字面内容替换;replaceAll 和 replaceFirst 则使用正则表达式。两类 API 名称相近,表达能力和转义规则不同。

String text = "a.b.c";

System.out.println(text.replace(".", "-"));    // a-b-c,字面替换
System.out.println(text.replaceAll("\\.", "-")); // a-b-c,正则中转义点号

正则表达式中的点号通常表示“任意字符”,所以直接写 replaceAll(".", "-") 会匹配每个字符,而不是只匹配字面句点。若正则本身不是常量,还要注意将其作为正则解释时的语义和复杂度,不能把动态输入未经处理就拼进正则表达式。

Java 11 起,strip()、stripLeading()、stripTrailing() 按 Unicode 空白规则去除两端空白;trim() 使用较旧且范围不同的规则。选择哪一个应由数据格式和兼容要求决定。

String value = "  hello  ";

System.out.println(value.trim());  // hello
System.out.println(value.strip()); // Java 11+:hello
System.out.println(value);         // 原字符串仍然是 "  hello  "

toUpperCase() 和 toLowerCase() 可能受到默认区域设置影响。如果用于程序标识符、协议字段名等不应随用户语言环境改变的场景,可显式指定 Locale.ROOT:

import java.util.Locale;

String key = "content-type";
System.out.println(key.toUpperCase(Locale.ROOT)); // CONTENT-TYPE

不要为了比较两个自然语言字符串而仅靠转大写或转小写实现全部规则;大小写映射可能与语言环境相关,也可能改变字符的形式。

七、split:参数是正则表达式

split(regex) 和 split(regex, limit) 的第一个参数是正则表达式,不是普通分隔符。分隔符若带有正则特殊含义,需要正确转义。对于固定字面分隔符,可以使用 Pattern.quote,避免手动处理复杂转义。

import java.util.Arrays;
import java.util.regex.Pattern;

String csvLike = "red|green|blue";
String[] parts = csvLike.split(Pattern.quote("|"));

System.out.println(Arrays.toString(parts)); // [red, green, blue]

不带 limit 的 split 会移除结果末尾的空字符串;需要保留末尾空字段时,使用负数的 limit:

import java.util.Arrays;

String row = "a,b,";
System.out.println(Arrays.toString(row.split(",")));    // [a, b]
System.out.println(Arrays.toString(row.split(",", -1))); // [a, b, ]

逗号分隔只是示范,不意味着 split(",") 就是完整的 CSV 解析器。真实 CSV 允许引号、转义符和字段内分隔符等结构,简单拆分可能产生错误结果;应使用符合相应格式规则的解析器。

八、拼接字符串:何时使用 +,何时使用 StringBuilder

少量拼接或一次性表达式可直接使用 +,代码通常更易读。编译器和运行时可能对拼接进行优化,但具体优化方式取决于 Java 版本和上下文,不应以某一种实现细节作为性能保证。

在循环中持续累加字符串,通常应使用 StringBuilder,避免每轮都生成新的完整字符串。对于大量数据,这能减少不必要的中间对象和复制成本。

public class JoinExample {
    public static void main(String[] args) {
        String[] words = {"Java", "String", "example"};
        StringBuilder result = new StringBuilder();

        for (int i = 0; i < words.length; i++) {
            if (i > 0) {
                result.append(' ');
            }
            result.append(words[i]);
        }

        String sentence = result.toString();
        System.out.println(sentence); // Java String example
    }
}

也可以在简单集合连接场景使用 String.join:

String joined = String.join(", ", "red", "green", "blue");
System.out.println(joined); // red, green, blue

StringBuilder 本身不是线程安全的。应在一个线程内或合适的局部范围使用;多个线程需要共享可变文本时,必须另行设计同步。通常更好的做法是让 Builder 保持局部,最后调用 toString() 得到不可变结果。

九、格式化字符串与拼接的边界

String.format 可用格式说明符组织输出,适合格式明确、参数较少的展示文本。格式说明符和参数的数量、类型必须匹配,否则可能出现格式错误或类型转换问题。

String name = "Mina";
int count = 3;

String message = String.format("Hello, %s. Count: %d", name, count);
System.out.println(message);

格式化面向的是文本展示,不会自动完成 SQL 参数绑定、HTML 转义、URL 编码或命令行安全处理。把不可信输入直接放进模板,不会因为使用了 String.format 就变得安全。SQL 应使用参数化查询;输出到 HTML、URL 或其他上下文时,应使用对应上下文的编码或转义机制。

十、字符串与字节:编码必须明确

String 表示字符序列;文件、网络等输入输出通常处理的是字节。字符和字节之间需要字符集编码。转换时应明确指定编码,不要依赖平台默认字符集,因为默认值可能随运行环境和 JDK 版本而不同。

import java.nio.charset.StandardCharsets;

String text = "你好,Java";
byte[] bytes = text.getBytes(StandardCharsets.UTF_8);
String decoded = new String(bytes, StandardCharsets.UTF_8);

System.out.println(decoded);

编码和解码必须使用一致且适用的字符集。把任意字节直接当作 UTF-8 解码,如果输入字节序列无效,通常会以替换字符等方式解码,而不是自动还原原始数据。若协议要求对非法输入严格报错,应使用配置了严格错误处理策略的 CharsetDecoder,并明确处理解码错误。

编码不是加密。Base64 只是编码形式,不提供机密性;UTF-8 也不负责隐藏数据。对文件名、路径或网络协议字段,还应依据具体上下文处理校验、编码和规范化。

十一、intern 与字符串池:通常无需手动调用

intern() 返回与当前字符串内容相等的驻留字符串引用。它适用于少数经过测量、确有大量重复值且能清楚管理内存影响的场景,不是普通字符串比较的替代品,也不是普遍的性能优化开关。

String a = new String("shared");
String b = a.intern();

System.out.println(a.equals(b)); // true:内容相等
System.out.println(a == b);      // 是否相同引用取决于实际创建与驻留关系;不要用它判断内容

实践中,手动驻留可能让代码依赖运行时的字符串池行为,并增加理解和维护成本。用 equals 判断内容,用缓存或规范化逻辑解决明确的业务问题;如果考虑 intern(),应以实际数据和基准测试为依据,并评估内存占用。

十二、字符串的大小限制与异常处理

String.length() 的返回类型是 int,因此它不是可以无限增长的容器。实际可创建字符串的最大大小受 JVM 实现、可用堆内存、数组限制及其他运行条件影响;没有一个适用于所有 JDK、虚拟机参数和运行环境的固定可用上限可供业务代码依赖。内存不足时,操作可能失败并抛出 OutOfMemoryError,这不是普通输入校验异常的同义词。

对外部输入设置长度上限是必要的防御措施,但上限应由协议、产品需求和资源预算确定,并明确单位:UTF-16 代码单元、码点、字节或可见字符。只检查 length() 并不能限制编码后的字节数,也不能直接表示用户看到的字符数量。处理大文件或大响应时,优先考虑流式读写,避免将全部内容一次性载入内存。

十三、String 与敏感数据安全

String 不适合作为需要可靠清除的敏感数据容器。它不可变,应用无法原地覆盖其字符内容;字符串可能被复制、缓存或在运行时中以不同方式管理,因此把变量设为 null 不能保证数据从内存中立即或彻底消失。若某种 API 提供可变字符数组或缓冲区,可根据其语义和威胁模型考虑使用,但清零同样不能被当作对所有内存副本的绝对保证。

密码处理应优先遵循安全方案:避免不必要地长期保存明文;服务端验证密码时使用适当的密码哈希方案及参数,而不是把密码作为普通字符串保存或使用快速摘要代替密码哈希。日志、异常信息和监控标签中也不应泄露密码、令牌或密钥。

普通的 String.equals 不承诺以恒定时间比较。比较高敏感度秘密时,应使用适用于具体数据类型和威胁模型的专门方法,例如对已编码字节使用 MessageDigest.isEqual,并遵循其 API 语义;不要把普通字符串比较描述为恒定时间操作。还要避免不必要的格式转换,因为转换可能产生更多难以清理的副本。

输入校验与安全编码也不能混为一谈。检查字符串是否符合业务格式,并不等于它可以安全输出到 HTML、SQL、Shell 或 URL;不同上下文需要不同的处理方式。

十四、一个可直接运行的综合示例

下面的程序汇总了常见操作,使用 Java 11 可编译运行。保存为 StringDemo.java,执行 javac StringDemo.java 和 java StringDemo 即可。

import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Locale;
import java.util.Objects;
import java.util.regex.Pattern;

public class StringDemo {
    public static void main(String[] args) {
        // 内容比较与 null 安全比较
        String first = new String("Java");
        String second = "Java";
        System.out.println(first.equals(second)); // true
        System.out.println(Objects.equals(null, second)); // false

        // 查找与截取
        String path = "reports/summary.csv";
        int slash = path.indexOf('/');
        System.out.println(path.substring(0, slash)); // reports

        // 字面分隔符:使用 Pattern.quote
        String[] colors = "red|green|blue".split(Pattern.quote("|"));
        System.out.println(Arrays.toString(colors));

        // 保留末尾空字段
        System.out.println(Arrays.toString("a,b,".split(",", -1)));

        // Java 11:判断空白与去除空白
        String padded = "  hello  ";
        System.out.println(padded.isBlank()); // false
        System.out.println(padded.strip());   // hello

        // 与默认区域设置无关的标识符转换
        System.out.println("content-type".toUpperCase(Locale.ROOT));

        // 显式指定字符集
        String original = "你好";
        byte[] encoded = original.getBytes(StandardCharsets.UTF_8);
        System.out.println(new String(encoded, StandardCharsets.UTF_8));

        // 循环拼接使用 StringBuilder
        StringBuilder builder = new StringBuilder();
        for (String color : colors) {
            if (builder.length() > 0) {
                builder.append(", ");
            }
            builder.append(color);
        }
        System.out.println(builder);
    }
}

十五、实践检查清单

  • 比较内容使用 equals 或 Objects.equals,不要用 == 判断字符串值。
  • 处理变量前确认是否可能为 null;空字符串和只含空白的字符串也要按业务定义区分。
  • 将 length() 理解为 UTF-16 代码单元数,不直接当作字节数、码点数或可见字符数。
  • 调用 split 时记住参数是正则表达式,并根据需要用负数 limit 保留尾部空字段。
  • 大量循环拼接使用 StringBuilder;少量简单拼接优先考虑可读性。
  • 字节转换显式指定字符集;大数据优先使用流式处理,并为外部输入设置合理限制。
  • 不要把格式化、Base64、普通相等比较或输入校验误认为安全编码、加密或恒定时间比较。

掌握这些边界后,String 的大多数用法都很直接:把它作为不可变文本值使用,根据操作选对 API,并且明确字符、字节、空值、输入可信度和运行环境之间的区别。遇到安全或性能要求时,再结合具体上下文选择专用工具,而不是依靠字符串 API 本身承担它并不提供的保证。

© 版权声明

相关文章