
文章目录一、开场档案卡为什么不能直接涂改二、String 变量、引用和对象不是一回事三、不可变所谓修改其实是创建新卡片四、字面量、new String、 与 equals4.1 为什么字符串比较不能用 五、字符串池与 intern领取统一标准卡5.1 intern 不是免费的去重按钮六、编译期折叠与运行期拼接6.1 一个容易忽略的从左到右规则七、JDK 25 的 invokedynamic 拼接路径八、Compact Stringsbyte[] 加 coder九、length、char 与 Emoji两个格子不一定是两个字符十、StringBuilder 与 StringBuffer先在草稿板上写十一、五个高频 API 陷阱11.1 trim 和 strip 不完全相同11.2 split 的参数是正则表达式11.3 substring 使用的是下标不是码点数量11.4 getBytes 不要依赖默认字符集11.5 大小写转换可能与地区有关十二、完整实验观察标准卡、复印卡与内部编码十三、常见误区十四、总结卡片不能改编号牌可以换参考资料一、开场档案卡为什么不能直接涂改想象你走进一座数字档案馆。每一张正式档案卡一旦盖章就不能拿橡皮擦修改。姓名写错了怎么办工作人员不会在原卡上涂改而是重新制作一张新卡再让你手里的编号牌指向新卡。Java 的String很像这种盖章后的标准卡片String 变量像你手里的编号牌它保存的是引用String 对象像档案柜中的卡片卡片内容创建后不能修改字面量池像“标准卡片柜”相同的标准内容可以共享new String(...)像拿标准卡复印出一张独立卡片intern()像交回复印件领取标准卡片柜里的规范引用StringBuilder 像一块可以反复书写的草稿板定稿时再生成 String。这篇文章以 JDK 25 为背景从日常使用一路走到字符串池、运行期拼接、Compact Strings 和 Unicode。先记住一句总纲String 变量可以改指向String 对象的内容不能改看是不是同一张卡equals看卡片上写的内容是否相同。二、String 变量、引用和对象不是一回事初学者看到下面代码容易说“字符串被修改了”Stringcard档案卡;card新档案卡;真正发生的事情是档案卡对应一个 String 对象变量card先引用它新档案卡对应另一个 String 对象card改为引用新对象原来的档案卡没有被涂改。所以要把三个概念分开概念档案馆类比能否改变String 变量手里的编号牌可以改为指向另一张卡String 引用编号牌记录的卡片位置可以被重新赋值String 对象内容盖章后的卡片文字创建后不能修改final String card 档案卡;只是让变量card不能重新指向另一张卡并不是给 String 增加了新的不可变能力。String 对象本来就是不可变的。三、不可变所谓修改其实是创建新卡片String API 中很多方法看起来像在修改内容例如concat、replace、toUpperCase和substring但它们不会改写原对象而是返回结果字符串。Stringoriginalarchive;Stringchangedoriginal.concat(-2026);System.out.println(original);// archiveSystem.out.println(changed);// archive-2026System.out.println(originalchanged);// false不可变让字符串能够安全共享也让它适合作为 Map 的 Key内容不变equals和hashCode的语义才稳定。类名、路径等信息也不会在校验后被原地篡改。但不可变不代表每个方法都必定创建新对象结果未变化时当前实现可能返回this。这是实现优化业务代码只应依赖结果内容并用equals比较文本。四、字面量、new String、 与 equals档案馆的标准卡片柜会保存规范卡片。源代码中的字符串字面量例如Java会使用池中的规范实例。StringaJava;StringbJava;StringcnewString(Java);System.out.println(ab);// trueSystem.out.println(ac);// falseSystem.out.println(a.equals(c));// trueStringactualnewString(OPEN);System.out.println(OPEN.equals(actual));// true这三行输出分别表示a b两个编号牌是否指向同一张标准卡a c标准卡和复印出来的新卡是否为同一对象a.equals(c)两张卡片写的字符序列是否相同。4.1 为什么字符串比较不能用 比较引用身份。只要字符串来自网络、数据库、文件、用户输入、运行期拼接或显式new就不应该猜测它是否恰好与池中对象共用引用。常量放在左边还可以自然处理actual null例如boolean matched OPEN.equals(actual);。如果两边都可能为空可以使用Objects.equals(left, right)。五、字符串池与 intern领取统一标准卡String.intern()返回字符串池中的规范表示池中已经存在相同内容时返回池中的对象不存在时把该字符串的规范表示加入池并返回对任意非空字符串s、t只要s.equals(t)就有s.intern() t.intern()。Stringstandardarchive-card;StringcopiednewString(archive-card);System.out.println(copiedstandard);// falseSystem.out.println(copied.intern()standard);// true字面量和“值为字符串的常量表达式”会被驻留这是 Java 语言规范的一部分。不过不要把字符串池简单背成“固定在方法区”“固定在永久代”之类的物理地址结论。内存布局会随 JVM 和版本变化业务代码只应依赖规范公开的身份语义。5.1 intern 不是免费的去重按钮intern()更适合少量、重复度高且生命周期明确的规范文本。海量随机内容盲目驻留可能增加内存压力若只是想让“能用”应直接改用equals。是否采用 intern必须用真实数据测量。六、编译期折叠与运行期拼接下面四个表达式看起来都得到Java对象身份却不完全一样StringliteralJava;StringfoldedJava;finalStringconstantPartJa;StringfoldedByConstantconstantPartva;StringruntimePartJa;StringruntimeruntimePartva;System.out.println(literalfolded);// trueSystem.out.println(literalfoldedByConstant);// trueSystem.out.println(literalruntime);// JDK 25 实验中为 falseSystem.out.println(12 张卡);// 3 张卡System.out.println(卡片 12);// 卡片 12System.out.println(合计 (12) 张卡);Ja va是常量表达式编译器可以在编译期直接折叠成Java。constantPart是常量变量也参与折叠。runtimePart不是常量变量拼接需要在运行期完成。不要根据这次输出形成“所有运行期拼接一定产生某种特定对象”的业务依赖判断内容仍然使用equals。6.1 一个容易忽略的从左到右规则字符串拼接从左到右计算。上例第一行先完成整数加法第二行从第一个字符串开始后续操作都变成字符串拼接。需要表达数学含义时应主动加括号。七、JDK 25 的 invokedynamic 拼接路径“Java 的一定编译成 StringBuilder”已经不够准确。拼接实现由编译器决定当前javac通常为运行期拼接生成invokedynamic调用点再由StringConcatFactory组织策略。JEP 280 让这条路径可以独立演进。publicclassConcatBytecodeDemo{staticStringmakeCard(Stringowner,intnumber){returnowner-CARD-number;}}编译后执行javac ConcatBytecodeDemo.java javap-c-vConcatBytecodeDemoJDK 25 输出中可以看到InvokeDynamic和makeConcatWithConstants。这只是当前工具链的结果不是跨编译器契约。日常少量拼接用循环追加用 StringBuilder模板化输出可使用formatted或专门模板工具。八、Compact Stringsbyte[] 加 coder从 JEP 254 开始OpenJDK 把 String 的内部存储从固定的char[]改为更紧凑的表示。JDK 25 的精简结构可以理解为精简来看当前实现包含private final byte[] value和private final byte coder两个关键字段。coder用来区分内部采用 LATIN1 还是 UTF16内容都能用 Latin-1 表示时一个字符通常只需一个字节出现中文等字符时使用 UTF16 路径这是空间优化不会改变 String 对外的字符语义。可以通过反射做观察实验FieldvalueFieldString.class.getDeclaredField(value);FieldcoderFieldString.class.getDeclaredField(coder);valueField.setAccessible(true);coderField.setAccessible(true);byte[]value(byte[])valueField.get(Java2026);bytecodercoderField.getByte(Java2026);System.out.println(value.length);System.out.println(coder0?LATIN1:UTF16);运行时需要显式开放模块java--add-opens java.base/java.langALL-UNNAMED StringArchiveCardDemo--add-opens只用于学习实验。业务代码不应反射访问 String 私有字段因为字段名、编码标记和布局都不是公开 API。还有一个非常重要的区分String API 对外把文本表示为 UTF-16 字符序列Compact Strings 是内部存储优化。不能因为内部使用byte[]就说“Java String 已经变成 UTF-8”。九、length、char 与 Emoji两个格子不一定是两个字符String 的索引和length()以 UTF-16 的char代码单元为基础。一个基本多文种平面中的字符通常占一个char而某些补充字符需要一对代理项。Stringemoji;System.out.println(emoji.length());// 2System.out.println(emoji.codePointCount(0,emoji.length()));// 1System.out.printf(U%X%n,emoji.codePointAt(0));// U1F600“用户看到一个符号”不一定等于length() 1。处理 Emoji、罕见汉字或昵称截断时可使用codePointAt、codePoints()、offsetByCodePoints。但肤色、国旗等 Emoji 还可能由多个码点组成真正按字素簇截断需要更高层的 Unicode 工具。十、StringBuilder 与 StringBuffer先在草稿板上写String 不可变。如果在循环中反复写Stringresult;for(inti1;i1000;i){resultresult卡片i;// 每轮产生新的拼接结果}StringBuilderbuildernewStringBuilder(16_000);for(inti1;i1000;i){builder.append(卡片).append(i).append(\n);}Stringoptimizedbuilder.toString();第一段循环的中间对象和复制工作会不断增加第二段先在可变缓冲区中追加最后一次性生成结果 String。StringBuilder 与 StringBuffer 的主要区别是类型方法同步典型使用场景StringBuilder否局部变量、单线程拼接StringBuffer是确实需要共享同一缓冲区的旧式并发场景StringBuffer 的单方法同步不能保证多步骤业务原子性。多数场景让每个线程使用自己的局部 StringBuilder 更简单。十一、五个高频 API 陷阱下面先把五类问题放在同一个示例片段中已导入StandardCharsets与LocaleStringtext\u3000档案卡\u3000;System.out.println([text.trim()]);System.out.println([text.strip()]);String[]wrongA.B.split(.);String[]rightA.B.split(\\.);Stringsource档案卡;byte[]bytessource.getBytes(StandardCharsets.UTF_8);StringrestorednewString(bytes,StandardCharsets.UTF_8);Stringinputopen;Stringnormalizedinput.toUpperCase(Locale.ROOT);11.1 trim 和 strip 不完全相同trim()按较早的规则移除两端编码值不大于 U0020 的字符strip()使用 Unicode 空白判断。包含全角空格等 Unicode 空白时strip()通常更符合直觉。11.2 split 的参数是正则表达式.在正则中表示任意字符上例中的wrong得到空数组而转义后的right才得到[A, B]。分隔符来自用户输入时可考虑Pattern.quote(delimiter)。11.3 substring 使用的是下标不是码点数量substring(begin, end)左闭右开并按 UTF-16 下标操作。下标越界会抛出StringIndexOutOfBoundsException在代理项中间截断还可能产生不完整文本。当前 JDK 25 会为子串结果构造适当的新存储但这属于实现细节不应把“子串一定共享或一定不共享原数组”当成跨版本契约。11.4 getBytes 不要依赖默认字符集协议、文件和跨系统通信都应显式指定字符集。发送端用 UTF-8、接收端却按其他编码解码就像双方拿着不同的密码本。11.5 大小写转换可能与地区有关用于界面展示时可以采用用户地区用于协议关键字、机器标识符时通常显式使用Locale.ROOTequalsIgnoreCase也不是完整的地区化排序方案。需要语言学意义的比较时应使用Collator等更合适的工具。十二、完整实验观察标准卡、复印卡与内部编码下面的完整程序一次验证不可变性、对象身份、字符串池、常量折叠、Compact Strings、Unicode、StringBuilder、正则分割和 UTF-8 往返importjava.lang.reflect.Field;importjava.nio.charset.StandardCharsets;importjava.util.Arrays;publicclassStringArchiveCardDemo{publicstaticvoidmain(String[]args)throwsException{Stringoriginal档案卡;Stringchangedoriginal.concat(-2026);System.out.printf([不可变] original%s, changed%s, same%s%n,original,changed,originalchanged);StringliteralAJava;StringliteralBJava;StringcopiednewString(Java);System.out.printf([身份] literalSame%s, newSame%s, equals%s%n,literalAliteralB,literalAcopied,literalA.equals(copied));Stringstandardarchive-card;StringcopynewString(archive-card);System.out.printf([池] before%s, after%s%n,copystandard,copy.intern()standard);StringruntimePartJa;StringruntimeruntimePartva;System.out.printf([拼接] folded%s, runtime%s, intern%s%n,literalAJava,literalAruntime,literalAruntime.intern());inspectStorage(ASCII,Java2026);inspectStorage(中文,档案卡);Stringemoji;System.out.printf([Unicode] length%d, codePoints%d%n,emoji.length(),emoji.codePointCount(0,emoji.length()));StringBuilderbuildernewStringBuilder(32);for(inti1;i3;i){if(i1)builder.append( - );builder.append(卡片).append(i);}System.out.println([Builder] builder);Stringspaces\u3000档案卡\u3000;System.out.printf([空白] trimLength%d, strip%s%n,spaces.trim().length(),spaces.strip());System.out.println([split] Arrays.toString(A.B.split(\\.)));Stringsource档案卡;byte[]bytessource.getBytes(StandardCharsets.UTF_8);StringrestorednewString(bytes,StandardCharsets.UTF_8);System.out.println([UTF-8] roundTripsource.equals(restored));}privatestaticvoidinspectStorage(Stringlabel,Stringtext)throwsException{FieldvalueFieldString.class.getDeclaredField(value);FieldcoderFieldString.class.getDeclaredField(coder);valueField.setAccessible(true);coderField.setAccessible(true);byte[]value(byte[])valueField.get(text);bytecodercoderField.getByte(text);System.out.printf([Compact] %s coder%s, bytes%d, length%d%n,label,coder0?LATIN1:UTF16,value.length,text.length());}}运行命令javac StringArchiveCardDemo.javajava--add-opens java.base/java.langALL-UNNAMED StringArchiveCardDemo javap-c-vStringArchiveCardDemo预期结果是字面量共享规范引用new String内容相等但身份不同intern()返回规范引用ASCII 走 LATIN1、中文走 UTF16Emoji 的length()为 2、码点数为 1UTF-8 往返成功。javap结果中还应出现运行期拼接的InvokeDynamic。十三、常见误区误区正确认识String 不可变所以变量不能重新赋值不可变的是对象内容普通变量可以改指向文本相同就为 true内容比较用equals比较身份new String会改掉池中对象它创建独立对象不修改规范实例所有都等于手写 StringBuilder常量可折叠当前运行期拼接通常走invokedynamic内部是byte[]所以 length 返回字节数length()返回 UTF-16 代码单元数StringBuffer 能让任意组合操作原子单方法同步不覆盖整段业务逻辑intern 一定节省内存是否受益取决于重复度、生命周期和规模无参getBytes()到处结果相同跨系统数据应显式指定字符集十四、总结卡片不能改编号牌可以换最后把全文压缩成一组口诀变量是编号牌对象是档案卡卡片不能改改动就换卡。双等看同一张equals 看内容字面量进标准柜new 出复印件。常量拼接编译算运行拼接看工具链循环追加用 Builder共享缓冲慎用 Buffer。内部 byte 加 coder对外仍是 UTF-16Emoji 可能占两格编码传输写 UTF-8。选择建议场景推荐方式比较字符串内容equals或Objects.equals少量表达式拼接循环或分步骤构建StringBuilder多线程确实共享同一缓冲区评估 StringBuffer 或重新设计统一机器标识符大小写Locale.ROOT网络、文件、数据库二进制转换显式字符集处理 Emoji 和补充字符码点 API观察 String 私有字段仅限实验不进入业务代码把 String 想成档案馆的密封卡片引用可以换内容不能涂比较内容看文字比较身份看卡号。参考资料Oracle Java SE 25String APIJava Language Specification 253.10.5 String LiteralsJava Language Specification 2515.18.1 String Concatenation Operator OpenJDK JEP 254Compact StringsOpenJDK JEP 280Indify String ConcatenationOpenJDK JDK 25String.java说明文中的私有字段和拼接路径以 JDK 25 为背景仅用于理解当前实现实际开发应依赖公开 API 和语言规范。个人小游戏