qinghuan
← Java SE / 全部笔记

单元 07 / Java SE

P0 · 必会主线L0 · 零基础

字符串与 StringBuilder:文本长度并不总是字符数

分清修改变量和修改字符串,再理解为什么一个表情的 length 可能大于 1,以及何时使用 StringBuilder。

本篇目录 · 按需要跳转

读完这篇,你能做什么

能正确处理切分、截取和拼接,并说明字符数量与 length 的差别。

学习理由、准备要求与知识点

为什么学它

接口、文件和页面都在处理文本,必须理解字符串操作与编码边界。

先读这些内容

知识点清单

  • String 不可变结果
  • 字符串内容比较
  • substring 半开区间
  • UTF-16 代码单元与码点
  • split 末尾空字段
  • 普通替换与正则替换
  • StringBuilder 累积文本
  • 业务文本规范化边界

运行前先确认Java 17;源码在本机 Microsoft OpenJDK 17.0.20.1 src.zip 核验,在线链接为上游 jdk-17.0.20-ga。深入模块可在掌握基础后回读。

01先弄懂它是什么

String 创建后,内容不会被原地修改。拼接、替换或截取会给出结果字符串;变量可以改为保存这个结果,但原字符串本身没变。比较文字内容用 equals,== 只检查是不是同一个对象。某些字符串因常量池而让 == 恰好为 true,也不能把它当成通用的文本比较方法。

length 并不总是用户看到的字符数。Java 字符串按 UTF-16 代码单元计算下标,length 数这些单元,charAt 取其中一个;部分表情需要两个单元。Unicode 码点又是另一层,一个可见字符还可能由多个码点组合,例如字母加重音。统计标题长度或截取文本前,要先明确想数存储单元、码点,还是用户看到的字符。

大量循环拼接可使用 StringBuilder,把多次追加集中到可变缓冲区,最后转为 String。一次简单表达式拼接通常直接用加号更清楚,无需机械替换全部代码。StringBuilder 适合单线程局部构建,不应在多个线程无保护共享。处理用户名、标题或搜索词时,还需区分大小写规则、空白处理与 Unicode 规范化,这些都是业务契约而不是字符串类自动替你决定的事。

文中这几个词,先认一认

代码单元
UTF-16 中一个 16 位单位,对应 Java 的一个 char,但未必构成完整可见字符。
码点
Unicode 编码空间里的位置有各自的编号,码点就是这样的编号,例如 A 对应 U+0041。编号不一定已分配给字符;Java 用 UTF-16 表示时,一个码点可能占一个或两个 char,不能直接把码点数当成 char 数。
字素簇
用户通常看成一个字符的一组文本,例如字母和它的组合重音,可能含多个码点。
不可变
对象创建后所表示的值不会改变;让变量指向另一个对象不算修改原对象。

还有陌生词?到术语小抄里看一个具体例子 ↗

02看看它怎么工作

有些字符在 Java 中需要两个 char 合起来表示,这两个 char 叫代理对。直接按 char 下标截取,可能把它们切开。如果要求按码点截取,就先用 offsetByCodePoints 算出截取位置,再调用 substring。

如果要求按人眼看到的字符限制长度,还要注意一个可见字符可能由多个码点组成,需要采用字符边界算法,并确认 Unicode 版本。只用几个汉字测试,不能说明所有语言和表情都已覆盖。

字符串与字节之间还隔着字符编码。UTF-8 编码长度不是 String.length,网络或文件字段限制若按字节计数,必须在指定编码后判断。反过来,把一个 UTF-8 字节随意转成 char 并不能还原文本。这里聚焦字符串内部索引,编码与解码错误策略将在文件和字符集篇进一步展开。

滑动窗口处理文本前同样需要选定单位。如果题目说不重复码点,就先得到码点数组,窗口左右边界使用码点下标;最后要截出原字符串时再映射回 UTF-16 下标。图将这两层索引分开,避免窗口长度算的是码点、截取位置却直接当成 char 下标,造成结果错位。

正在加载流程图…

先选择文本单位,再选择 API;三个层次的长度不能直接互换。
查看流程图源码(Mermaid)
flowchart TD
 A["String 文本"] --> B["确认长度单位"]
 B --> C["UTF-16 下标:length 与 charAt"]
 B --> D["码点序列:codePoints"]
 B --> E["字素边界:用户可见字符"]
 D --> F["算法在码点下标上计算"]
 F --> G["需要截取时映射回 UTF-16 边界"]

按顺序看,每一步做了什么

  1. 定义比较与长度的业务语义。
  2. 用 equals 比较内容,避免依赖常量池。
  3. 明确代码单元、码点和字素边界。
  4. 循环构建优先使用局部 StringBuilder。
  5. 用补充字符、组合字符和空字符串测试。

03对照实现代码

码点计数如何越过一个代理对 (新标签页)↗

Microsoft OpenJDK 17.0.20.1;java.base/java/lang/String.java · java.lang.String#codePointCount(int,int)

以下为原创 Java 17 教学实现,不是原样 JDK 源码;只保留文中明确讨论的机制,省略优化、兼容及内部接口。

核心机制代码 · java
public final class SourceModel {
    public static int count(String text, int begin, int end) {
        if (text == null) throw new NullPointerException("text");
        if (begin < 0 || begin > end || end > text.length())
            throw new IndexOutOfBoundsException();
        int count = 0;
        for (int i = begin; i < end; count++) {
            char first = text.charAt(i++);
            // 只有范围内紧跟低代理的高代理,才与下一单元合成一个码点。
            if (Character.isHighSurrogate(first) && i < end
                    && Character.isLowSurrogate(text.charAt(i))) i++;
        }
        return count;
    }
}

真实 String.codePointCount 首先检查范围,随后依据内部编码表示选择快捷路径或 UTF-16 辅助实现。本机源码中的 Latin-1 路径可以直接用区间长度,因为这些字符不需要代理对;模型统一按公开 char 接口扫描,避免依赖 String 私有存储字段,也没有尝试绕过模块封装。

扫描遇到高代理且范围内下一单元为低代理时,同时消耗两个单元,但只把码点计数加一。其它单元单独计数,包括没有配对的代理单元。这个规则符合 Java 字符串 API 对未配对代理的计数约定,却不能说明文本一定是合法 Unicode 标量序列,计数与校验是不同任务。

范围端点可以人为切断代理对,此时函数只针对指定范围计数,不会自动扩大到完整字符。调用者若希望截取完整码点,应先计算合适边界;若希望完整字素簇,还需要更高层分段。方法名称中的 codePoint 并不意味着它能理解所有人类书写组合。

不可变字符串使读取某个对象时无需担心内容被另一段代码原地替换,但围绕字符串的变量、集合或缓存仍可能可变。StringBuilder 则需要把可变构建和最终值分开,创建最终字符串后才能把它当作稳定文本使用。源码阅读应帮助识别这个边界,而不是推断某一内存布局永远不变。

代码先调用谁,再调用谁

  1. codePointCount 检查公开索引范围
  2. 内部编码标记选择计数路径
  3. UTF-16 路径识别有效代理对
  4. 返回指定区间内码点数量

04动手写一个例子

最长不重复码点子串的长度

输入 String,返回连续子串中不含重复码点的最大码点数。比较按码点数值,不忽略大小写、不做规范化,也不把多个码点的字素簇合成一项。

输入 1
abcabcbb
输出 1
3
输入 2
😀a😀b
输出 2
3
输入 3
输出 3
0

把字符串转成码点数组之后,每个下标代表同一种单位。维护当前窗口左边界 left,以及每个码点最后一次出现的位置。读到右端位置 right 时,如果该码点之前在当前窗口中出现过,就把 left 跳到旧位置后一项;否则保留 left,不需要从头重新扫描窗口。

左边界必须取原值和旧位置加一的较大者。旧位置可能已经在窗口左侧,例如 abba 的最后一个 a;若盲目把 left 改成旧位置加一,就会向后退,把已排除的重复字符重新包含进来。窗口只前进这一性质是正确性与线性扫描的重要基础。

每轮更新当前位置后,窗口内码点恰好不重复,长度为 right-left+1,与当前最大值比较即可。只求长度无需保留最优文本。HashMap 操作的常见期望成本用于复杂度分析,不能把任意实现与任意输入的最坏情况都无条件说成常数;码点数组本身也有线性存储成本。

为什么这样写是对的处理右端后,[left,right] 内码点互不重复,且 left 从不后退;last 保存各已见码点的最近下标。

时间 期望 O(n),n 为码点数,另有按 UTF-16 单元扫描的转换成本。额外空间 O(n),包含码点数组与最多 n 个不同码点的位置表。

每个码点一次查询、一次更新;左边界通过跳转更新,不对重复区间二次遍历。HashMap 的期望常数操作为该时间结论的前提。

保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。

展开收起代码 · Main.java · 完整可运行程序Java · 33 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

Main.java · 完整可运行程序 · Java
import java.util.HashMap;
import java.util.Map;
public class Main {
    static int longest(String text) {
        if (text == null) throw new IllegalArgumentException("text");
        int[] points = text.codePoints().toArray();
        Map<Integer,Integer> last = new HashMap<>();
        int left = 0, best = 0;
        for (int right = 0; right < points.length; right++) {
            Integer previous = last.put(points[right], right);
            // 历史位置可能已离开窗口,左边界绝不能后退。
            if (previous != null) left = Math.max(left, previous + 1);
            best = Math.max(best, right - left + 1);
        }
        return best;
    }
    static void check(int expected, String input) {
        if (longest(input) != expected) throw new AssertionError(input);
    }
    public static void main(String[] args) {
        // 空、普通重复、边界不回退和补充字符分别验证。
        check(0, "");
        check(3, "abcabcbb");
        check(2, "abba");
        check(3, "😀a😀b");
        check(1, "aaaa");
        check(2, "e\u0301"); // 两个码点,不声称是两个可见字符。
        boolean rejected = false;
        try { longest(null); } catch (IllegalArgumentException e) { rejected = true; }
        if (!rejected) throw new AssertionError("null");
        System.out.println("PASS 7");
    }
}

这些特殊情况也要试一试

  • 空字符串返回零。
  • 重复码点离开窗口后不能使左边界倒退。
  • 表情代理对作为一个码点处理。
  • 组合重音仍是独立码点,本题不做字素分段。
进阶选读 · 可选深入:字素分段与规范化搜索

场景界面限制昵称可见长度,搜索又希望某些等价写法匹配。

做法先定义规范化和大小写折叠策略,再选择符合目标 Unicode 版本的分段实现;保留原文与规范化索引映射,避免高亮错位。

复杂度通常需线性扫描,但具体分段及索引存储取决于实现。

好处与代价规范化会改变比较语义,不能用于所有标识符;展示原文与搜索键应分开保存。

05项目里怎么用

文本处理的错误经常来自单位不一致。数据库列限制、传输字段长度和界面显示限制可能分别按字符、字节或字素解释;接口应明确单位并在对应层校验。日志截断也不能随意切 UTF-8 字节或 UTF-16 代理对,否则诊断文本本身可能损坏,给排错增加误导。

对短小表达式使用字符串加号通常最易读;对循环累计结果,局部 StringBuilder 避免反复形成越来越大的中间文本。容量预估要有上界,不能直接相信外部提供的巨大长度。共享构建器需要同步与所有权管理,通常让每次调用独立创建更简单。

文本比较规则要随用途确定:姓名搜索、协议关键字和安全标识不应都使用同一种模糊比较。忽略大小写时要明确 Locale,Unicode 规范化也要说明形式、版本和用途。按码点精确比较可以作为明确、可测试的起点;业务要把更多写法视为相同,就应另外写清规则。

容易踩的坑

  • 用 == 比较用户文本。
  • 把 length 当成 UTF-8 字节数或可见字符数。
  • 截取在代理对中间切开。
  • 共享 StringBuilder 却以为它天然线程安全。

几种做法,怎么选

表格有四列,窄屏可左右滑动查看代价和适用条件。

方案优势代价与限制适用条件
String值稳定且易共享反复累积可能产生中间对象最终文本与键值
局部 StringBuilder连续构建清晰可变且需明确生命周期循环拼接与格式化
规范化后的搜索键可统一指定等价关系可能丢失展示差异并需版本策略搜索、去重等明确约定的场景

06检验是否读懂了

先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。

先用自己的话解释

01不可变字符串为什么还能执行 s = s + x?

展开参考答案与推导

重新赋值改变的是变量保存的引用,原字符串对象没有被原地修改。其它变量若仍引用原值,会继续看到原内容;不可变性约束对象值,不是禁止所有变量赋值。

02😀 的 length 为什么是二?

展开参考答案与推导

它处于补充平面,在 UTF-16 中由一个高代理和一个低代理表示。length 数代码单元,所以得到二;codePointCount 对完整范围会把配对单元计成一个码点。

03码点数是否就是用户看到的字符数?

展开参考答案与推导

不一定。字母与组合重音、某些表情序列都可能由多个码点形成一个字素簇。必须根据需求选择码点或字素边界,不能把一个 API 的名称当成所有文本长度的答案。

换个情况,还会处理吗

01窗口算法在 abba 上返回三,可能哪里错?

展开参考答案与推导

最后一个 a 的历史位置已在窗口外,若直接用旧位置加一重置 left 会让窗口后退。使用 Math.max 保持左边界单调,并保留 abba 作为专门回归样本。

02昵称字符数合法却超过网络字节限制怎么办?

展开参考答案与推导

先确认协议按哪种编码计字节,再编码计算或安全地累计编码长度。不能仅用 String.length 代替字节长度,中文和补充字符的 UTF-8 占用与 ASCII 不同。

03同样看起来是 é 却不相等,是否 String 出错?

展开参考答案与推导

可能一种是预组字符,另一种是字母加组合重音,码点序列不同。若业务希望等价,可在明确规范化形式后比较;不要不加区分地改变所有标识符的语义。

写代码试一试

01返回最长窗口的文本

输入 / 条件
abba
输出 / 预期
ab 或 ba,明确选第一项

提示 记录 bestLeft 与 bestLength。

展开参考答案与推导

仅在新长度严格更大时更新最佳区间,再把码点下标通过 offsetByCodePoints 映射回 UTF-16 下标后 substring。不能直接把码点位置当成字符串下标。

02添加全部补充字符重复测试

输入 / 条件
😀😀
输出 / 预期
1

提示 按码点比较。

展开参考答案与推导

加入 check(1,"😀😀")。若误用 char 窗口,可能把高低代理当作不同项得到二;该用例直接检验算法选择的文本单位。

03限制到前两个码点

输入 / 条件
A😀B
输出 / 预期
A😀

提示 先计算终点。

展开参考答案与推导

取实际码点数与二的较小值,通过 text.offsetByCodePoints(0,count) 获得终点,再 substring。这个操作保证不切代理对,但仍不保证保留完整字素簇。

读完之后,独立完成

课后作业:自己做一遍

本篇共 10 道作业,先完成 8 道基础与综合任务,再按前置知识挑战进阶题。其中包含 1 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。

这次练什么

能正确处理切分、截取和拼接,并说明字符数量与 length 的差别。

本篇重点加练

重点加练文本计量与业务规范化,避免把编码层面的相同当成业务身份规则。

  • UTF-16 代码单元与码点
  • 业务文本规范化边界

重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。

先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。

基础必做

  1. 作业 01代码推演

    以字符串“ java ”开始,分别调用 trim、substring 和 replace,并保留或忽略返回值;逐行记录变量值、内容比较与索引边界,验证空子串的合同。

    提交内容
    推演表、最小 Java 代码和运行记录。
    怎样算完成
    • 不可变操作结果有明确去向
    • 半开区间边界完整

    练到的知识点String 不可变结果 · 字符串内容比较 · substring 半开区间

  2. 作业 02排错实验

    使用文本 A😀中,比较 length、逐 char 遍历与逐码点遍历的观测结果;构造一个截取边界落在代理项之间的实验,记录编码和展示行为但不只凭肉眼判断。

    提交内容
    代码单元/码点记录、自写边界测试及运行证据。
    怎样算完成
    • 计量单位清晰
    • 展示与底层值分别观察

    练到的知识点UTF-16 代码单元与码点

综合应用

  1. 作业 03原理问答

    输入 a,b,, 与含点号的文件名,分别提出保留空字段、按普通文本替换和按模式替换的需求;选择 API 前先写清哪些字符有结构意义。

    提交内容
    三项合同、API 选择依据与待验证样本。
    怎样算完成
    • 尾部空字段有明确政策
    • 普通字符与正则语法区分

    练到的知识点split 末尾空字段 · 普通替换与正则替换

  2. 作业 04编程实践

    实现把最多一百条课程名拼成编号清单的格式化器,名字允许为空但不允许 null;规定尾部是否换行、编号格式与空输入输出格式,使用 StringBuilder。

    提交内容
    Java 实现、自写空值/空输入/换行边界测试和运行证据。
    怎样算完成
    • 输出格式可逐字符检查
    • 不会在循环中丢失已有内容
    • 资源规模有界

    练到的知识点substring 半开区间 · split 末尾空字段 · StringBuilder 累积文本

  3. 作业 05设计评审

    用户名允许中文与拉丁字符,团队希望统一去空格、转小写并去重。评审该政策对显示名、登录名和原始审计数据的影响,列出需产品确认的等价关系。

    提交内容
    规范化合同、冲突样本与数据保存方案。
    怎样算完成
    • 身份规则与展示规则分开
    • 不静默改变未经约定的用户输入

    练到的知识点字符串内容比较 · 业务文本规范化边界

  4. 作业 06编程实践

    实现昵称长度检查器,分别提供按 UTF-16 单元与按码点计数两种模式;保留原始昵称,测试 ASCII、补充平面字符和前后空白。

    提交内容
    Java 实现、自写计量/空值/边界测试与运行证据。
    怎样算完成
    • 长度单位在调用处可见
    • 规范化和保留原文的政策一致

    练到的知识点UTF-16 代码单元与码点 · 业务文本规范化边界

  5. 作业 07排错实验

    选择含组合附加符的文本与外观相近的另一种编码序列,记录 length、码点序列和显示观察;不要将“看起来一样”直接作为程序相等依据。

    提交内容
    Java 实验、自写序列检查及显示记录。
    怎样算完成
    • 底层值与视觉观察分开
    • 记录文本的来源与表示

    练到的知识点UTF-16 代码单元与码点

  6. 作业 08设计评审

    学习标签同时用于显示、搜索和唯一键,产品希望忽略大小写却保留原始拼写。为三种用途分别规定转换范围与冲突反馈。

    提交内容
    文本流图、政策表和冲突样本。
    怎样算完成
    • 不同用途的等价关系显式
    • 原始输入保留责任明确

    练到的知识点业务文本规范化边界

进阶挑战 · 掌握前置后选做

  1. 作业 09编程实践

    完成集合后:实现按 Unicode 码点截取的摘要函数,最大长度按码点计,必要时添加省略标记;明确组合字符与表情序列不在本题保证范围。

    提交内容
    Java 实现、自写空文本/代理项/边界长度测试及运行证据。
    怎样算完成
    • 合同精确到计量单位
    • 不夸大字形簇处理能力
    • 源字符串保持不变

    练到的知识点UTF-16 代码单元与码点 · 普通替换与正则替换 · StringBuilder 累积文本 · 业务文本规范化边界

思维拓展

先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。

  1. 作业 10思维拓展 · 条件变化

    用户名长度限制原来按 char 计,现改为“用户眼中五个字符”。分析需要补充的定义与测试材料,比较码点计数是否足以表达新需求。

    提交内容
    需求澄清表、代表文本和验收方案。
    怎样算完成
    • 计量对象有精确定义
    • 不足之处以具体文本论证

    练到的知识点UTF-16 代码单元与码点 · 业务文本规范化边界

完成后,再检验一次

给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。

继续阅读

先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。

打开资料库,找下一份学习材料 ↗