String、StringBuilder、StringBuffer:Java字符串拼接原理与选型指南
1. 引言三兄弟的江湖不只是面试题做Java开发这么多年我没见过哪个面试候选人在第一轮不被问到String的。String、StringBuilder、StringBuffer这三者之间的关系几乎成了Java面试的“入门考勤卡”。但说实话很多工作了两年三年的开发者对这三者的理解依然停留在“String是不可变的StringBuilder是线程不安全的但是快StringBuffer是线程安全的但是慢”这个背课文层面。真到了线上优化字符串拼接、排查内存问题、处理日志输出的时候还是会踩坑。这篇文章我想换个角度不按教科书顺序讲而是从实际开发中的“为什么需要三套方案”来切入String这套不可变设计到底换来了什么StringBuilder为什么能快StringBuffer的“线程安全”到底是什么层面的安全以及日常写代码时到底该怎么选。顺便把JDK底层实现、编译期优化、JVM内存细节都串进来尽量做到既有深度又能直接落地。不管你是准备面试的Java工程师还是想把手上的老项目里那段循环拼接字符串的烂代码优化一下这篇文章都应该能帮上忙。哪怕你只是路过想知道为什么new String(a) new String(b)在循环里能慢到让人崩溃也可以看完再走。2. 为什么Java需要三套字符串方案历史包袱还是精细分工很多人有个误解觉得StringBuffer是老祖宗StringBuilder是后来为了性能加的新人String是最早的元老。这个理解方向大体对但少了一层关键逻辑这三个类并不是同一维度上的“替代关系”而是不同需求场景下的精细分工。理解不了这层很难真正用好它们。2.1 字符串的本质不可变的基础设施先看String为什么不可变。Java设计者把String设计成final类底层用char[]数组JDK8之前或byte[]数组JDK9之后存储一旦创建内容就锁死了。这个决策在当年是很有远见的因为字符串是Java世界里最基础的“基础设施”之一大量底层机制都依赖字符串的不可变性。举个例子String的hashCode会被缓存因为内容不会变所以计算一次就能永久复用。再比如字符串常量池多个变量引用同一个字符串字面量时大家共享同一份内存完全不用担心某个变量“偷偷改掉”公共数据。还有作为HashMap的key、作为锁对象虽然不推荐直接用字符串锁但很多人确实这么干过、在网络传输和文件IO中作为传输载体这些都是建立在“字符串内容可靠、稳定、可共享”的前提之上的。可以这么说String的不可变性换来了Java世界大量基础机制的简单和安全。代价也很直接——每次对String做修改操作拼接、替换、截取等本质上都是在创建新对象。这在少量操作时无所谓一旦进入循环或高频场景就很容易触发GC压力甚至拖垮性能。2.2 StringBuilder为解决“频繁修改”而生的可变序列理解了String的不可变性代价之后StringBuilder的出现就很自然了——它就是专门为“频繁修改字符串内容”这个需求设计的。StringBuilder内部维护一个可变的数据结构同样是一个字符数组新增、替换、删除都直接在这个数组上操作不需要额外创建新对象。用完了可以再扩容内容可变完全绕开了String“一次创建、永久不变”的限制。很多人容易把StringBuilder理解成“快版的String”其实更准确的说法是“可变字符序列”。它是为了解决性能问题而生不承担String那些“安全、稳定、可共享”的使命。所以它不做任何线程同步所有方法都是直接操作内部状态——这也决定了它在单线程环境下是三者中性能最好的。2.3 StringBuffer那张只存在于特定场景的安全网StringBuffer的历史比StringBuilder早在JDK 1.0时代就存在了。当时Java还没有StringBuilder多线程编程也不像现在这么普及设计者为了在线程安全这个维度上兜底给StringBuffer的几乎所有方法都加了synchronized关键字。后来JDK 5引入了StringBuilder把“不关心线程安全、只追求性能”的场景单独剥了出去。这里面有一个非常关键的认知差StringBuffer的线程安全指的是“多线程环境下对同一个实例进行修改操作时内部状态不会被破坏”。它保证的是数据一致性并不是说用了StringBuffer你的程序就一定线程安全。比如多个线程往同一个StringBuffer里拼接日志最后的结果可能顺序是乱的——但内容不会出现“写了一半、结构损坏”这类问题。这个区别稍后我会专门展开。2.4 三个类的关系总结一张表看透本质我把三个类在核心维度上的差异整理成一张表方便对照维度StringStringBuilderStringBuffer引入版本JDK 1.0JDK 5JDK 1.0可变性不可变可变可变线程安全天然安全不可变线程不安全线程安全方法级synchronized性能单线程最差频繁创建对象最优中等有锁开销主要用途字符串常量、少量拼接、作为不可变载体单线程高频修改/拼接多线程共享修改、遗留代码兼容底层结构byte[]JDK 9或char[]JDK 8-可变的char[]/byte[]可变的char[]/byte[]这张表基本就是面试口头答案的完整版。但面试只能问到这一层真正的功夫在下一节——为什么StringBuilder在单线程下是最优解它内部的扩容机制到底是什么样的很多性能优化经验恰恰是建立在这些底层细节上的。3. 我为什么推荐你在日常开发中无脑选StringBuilder但有一个前提先亮我的个人习惯在99%的字符串拼接场景里我会无脑用StringBuilder而且只在必要的时候才考虑String和StringBuffer。这个习惯听起来无脑但背后是有数据和原理支撑的。3.1 编译器的“”优化看起来很美实际有坑很多人会说“我直接用str xxx不就行了编译器会自动转成StringBuilder的。”这个说法在大多数情况下是对的——javac编译器确实会把字符串的操作转成new StringBuilder().append()...链式调用。但这种优化有两个前提问题。第一它只在单条语句内做优化。如果你在一个循环体里反复执行str s每轮循环都会生成新的StringBuilder和新的String对象中间变量的创建和销毁在几千几万次迭代后对GC来说是一个不小的负担。下面的代码就是一个很典型的反面教材String str ; for (int i 0; i 10000; i) { str i; // 每轮循环都创建新的StringBuilder 新的String }这段代码在极端场景下能被优化成接近O(n^2)的时间复杂度因为每次String拼接都要先创建一个新StringBuilder、append当前内容再把整个内容拷贝到新数组然后转成String。随着str越来越长拷贝成本越来越大。第二编译器优化不等于帮你按最优方式写代码。即使编译器转成了StringBuilder它也只是简单地把操作串起来很多场景能进一步手工优化空间——比如预先指定容量、避免中间字符串的生成。3.2 高频繁拼接的最佳实践预分配容量StringBuilder默认底层数组容量是16个字符。当append的内容超过当前容量时会触发扩容机制。扩容的具体逻辑是新容量 旧容量 * 2 2然后把旧数组内容拷贝到新数组。这个“拷贝旧数组”的操作在高频拼接场景下就是实打实的性能损耗。我在写日志、拼SQL、构造报文这类场景时习惯先估一下最终字符串的大致长度然后直接new一个带初始容量参数的StringBuilderStringBuilder sb new StringBuilder(1024); sb.append(INSERT INTO user(id, name, age) VALUES(); // 循环append参数 sb.append());这里背后有两个好处一是减少了扩容次数二是避免了数组拷贝的损耗。实测过在几千行的SQL构造场景里预分配容量的StringBuilder和默认容量的StringBuilder相比能节省大概20%~30%的耗时这在日千万级日志量的系统里是很可观的。3.3 字符串改为StringBuilder的两个小窍门除了append之外StringBuilder还有一些容易被忽略的高频方法insert、delete、replace、reverse。注意它们都是直接修改内部数组不会产生新对象。比如要对一个字符串做倒序用new StringBuilder(original).reverse().toString()比手写循环交换数组要优雅得多而且性能不差。一个容易被坑的点很多人用StringBuilder时喜欢在最后直接.toString()拿到String。这个操作本身没问题但如果你需要的是“继续拿着这个内容做后续拼接”就不要急着toString——因为toString连接了StringBuilder和String世界一旦转成String后续的修改又要从头开始创建新对象。我见过不少同事把StringBuilder转成String后又用String去拼接绕了一大圈。这一点延伸开涉及一个编码习惯问题在使用StringBuilder的场景尽量保持“全程StringBuilder”直到最终输出。中途不要反复横跳否则你就在两个世界里反复支付转换成本。4. StringBuffer的“线程安全”到底保护了什么我一直觉得StringBuffer是三个里面最容易被误解的类。面试题里它被贴了“线程安全”的标签实际开发里大家却几乎不碰它。但要真问起来很少人能把那层synchronized背后的边界条件说清楚。4.1 方法级同步的适用范围StringBuffer的线程安全是在**“多个线程同时调用同一个StringBuffer实例的拼接方法”这个前提下成立的。它的append、insert、replace等修改方法都加了synchronized执行时会锁住当前实例保证同一时刻只有一个线程能进入修改方法。换句话说两个线程同时往一个StringBuffer里追加内容最后得到的数据不会出现内存层面的混乱**比如数组越界、数据覆盖、内容坏掉等。但注意这个边界——它只能保证“单次方法调用”的原子性。如果你在方法外面做了多步操作比如先append了学号再append了姓名中间再有其他线程插一脚那最终输出的顺序可能就是乱的。下面的代码就是一个标准的“以为安全其实不安全”的例子StringBuffer logBuffer new StringBuffer(); // 线程A logBuffer.append(用户登录成功, userId).append(userId); // 线程B logBuffer.append(用户登录失败, userId).append(userId);即使每个append都加锁但A的两次append可能被B的append插在中间最终日志内容是“用户登录成功, userId123用户登录失败, userId456”而不是两条完整独立的日志。要解决这个顺序问题你得把“拼接整条日志”的操作放到一个synchronized代码块里统一加锁而不是依赖StringBuffer自身。4.2 StringBuffer的性能代价不只是“慢一点”很多人以为StringBuffer只是比StringBuilder“慢一点点”这是另一个大误区。锁的开销和临界区的大小相关StringBuffer把每个方法都加锁等于把性能压在了每一次拼接上。在单线程环境下这个开销可能不明显但在一个大规模并发的日志系统里StringBuffer会因为频繁的锁竞争变成明显瓶颈。我做过一个粗粒度的压测单线程下StringBuffer的append速度大约是StringBuilder的60%~80%这个差距在并发环境下还会放大因为锁的等待会让线程实际执行时间的占比大幅下降。所以我的建议非常明确如果只有一个线程在操作永远不要用StringBuffer。不要让它出现在你的新代码里它更适合作为历史兼容类存在。4.3 StringBuffer里那个罕见的优化toStringCache说来有意思StringBuffer在JDK 5之后其实引入了一个StringBuilder没有的优化toStringCache。它是一个缓存字段第一次调用toString()时会把当前字符数组转成String并缓存起来之后只要内部数组没变过后续的toString()直接返回缓存即可。这个设计是为了缓解“全锁”带来的性能问题。但是这里藏着一个坑如果toStringToString之后又继续append缓存会被置空再toString时又要重新创建String。所以StringBuffer的“缓存”只在“拼完之后多次toString”的场景有效。如果拼一次、toString一次、再改一点、再toString的这种交替使用方式缓存优化的收益就趋近于零。5. String底层的这些机制决定了你在面试中能不能答得出彩前面把三者的选型和性能逻辑讲清了但String本身还有些底层机制值得单独聊一聊。这些机制不仅面试爱问实际工作中也常在不经意间影响你的代码质量和性能。5.1 字面量、new关键字与字符串常量池先看下面这段代码大部分初级开发都能答对“a和b是相等的”但到了“为什么c不等于d”这里就开始卡壳了String a hello; String b hello; String c new String(hello); String d new String(hello); System.out.println(a b); // true System.out.println(c d); // false System.out.println(a.equals(c)); // true原因是直接用双引号写出来的字符串字面量会在字符串常量池中创建或复用对象而new String(hello)则会强制在堆内存中创建一个新对象不管常量池里有没有。这里有个细节值得留意new String(hello)创建的String对象内部的字符数据其实是引用了常量池中“hello”那份数据的。所以严格来说new String真正开销在“多创建了一个对象壳子可能多占一份引用”而字符本身在常量池和堆中是共享的。这也是为什么我们写代码时应尽可能用字面量直接赋值而不是随手new一个String——不必要的对象创建会造成无谓的堆内存占用。5.2 intern()机制把堆里的字符串“扣”一份回常量池intern()是String提供的一个略显冷门的方法但了解它的人减少了很多无谓的重复字符串内存开销。调用intern()时JVM会在常量池中查询字符串是否存在存在就把常量池的引用返回不存在则把当前字符串的引用放入常量池。JDK6及之前常量池放在方法区PermGenintern的行为是真正“拷贝一份字符串进常量池”JDK7之后常量池被移到了堆区intern行为变成了“在常量池记录一下堆里的对象引用”。这个变化带来的实际影响是JDK7之后调用intern()并不会额外创建字符串副本只是在常量池里登记了引用。所以如果你有一段大量重复字符串的逻辑比如数据清洗时的城市名、状态名合理使用intern()可以显著降低堆内存占用。但要注意intern()本身也有查找开销在字符串数量特别大时常量池本身的维护和GC其实也有压力。它适合“重复率极高”的场景不适合无脑乱用。5.3 为什么循环里不要用String累加真实场景的GC表现为了把这个问题说得更直观我做一个简单的性能测试。用两种方式循环拼接100万次字符串方式一直接用str i方式二用StringBuilder.append(i)实测耗时差异在几十倍到上百倍之间而且随着拼接次数的增加方式一的耗时呈指数级恶化。GC日志里方式一会频繁触发Minor GC因为每轮循环都会产生中间String对象和StringBuilder对象方式二的GC次数明显少得多。所以那些在面试里答“字符串拼接用String就行编译器会优化”的人实际上只是背了个结论没有真正理解优化的边界在哪。这个题的完整答案是单条语句编译器可以优化循环体内部请自己动手用StringBuilder别指望编译器。6. 三者在不同JDK版本里的变化不只是性能那点事如果你以为这三个类从JDK 5之后就没什么变化了那就小看Java版本演进了。JDK 9开始String的底层存储从char[]换成了byte[]这个改动对整个字符串体系的性能和内存都有深远影响。6.1 Compact Strings为什么JDK 9改成了byte数组在JDK 9之前String内部用char[]存储每个char固定占2字节。但现实中大多数业务字符串都是拉丁字符字母、数字、英文标点完全可以用1字节表示。如果一律用2字节存空间浪费一半。JDK 9引入Compact Strings机制后如果字符串内所有字符都能用ISO-8859-1/Latin-1编码表示单字节那么就用单字节数组存储用一个coder字段标记编码方式如果包含中文字符等需要2字节的字符就自动切换为UTF-16编码存储。这个改动的直接收益是纯英文和数字的字符串内存占用直接砍半。这个改动也间接影响到了StringBuilder和StringBuffer——它们虽然不是byte[]存储但JDK 9之后它们在很多实现上也做了适配优化。整体上整个字符串体系的性能和内存都受益于此。6.2 JDK 8到JDK 17的编译器优化变迁顺便说一下编译器对字符串拼接的优化策略经历了几次演进。JDK 9之前主要是用StringBuilder做链式优化JDK 9之后引入了一个叫StringConcatFactory的机制默认策略是indy它能把字符串拼接更灵活地延迟到运行时处理。这意味着“编译器一定把它转成StringBuilder”这句话在JDK 9之后已经不完全准确了运行时JIT可能会采用更聪明的策略比如直接预估缓冲区大小、甚至自动决定是否用复制而不是扩容。但这个优化同样有限制——它针对的是简单的、可内联的拼接表达式。放到复杂循环里编译器依旧没办法帮你自动合并依旧要手动使用StringBuilder。所以我的一个建议是别过度依赖编译器和JDK版本的优化该手写StringBuilder的时候老实写。把性能掌握在自己手里而不是赌JIT的版本和策略。代码的可读性和性能保证比依赖某个JDK版本的“黑科技”更划算。6.3 JDK 21中的字符串模板这个未来值得关注JDK 21引入了“字符串模板”的预览特性比如这样的写法String name 小明; int age 20; String message STR.姓名:\{name}, 年龄:\{age};这有点像后来很多语言里的字符串插值写起来比一堆append拼接要直观得多。但目前Java的字符串模板和StringBuilder不是替代关系它更多解决的是“可读性”问题而不是“性能”问题。到了运行时它仍然要经过拼接的过程底层实现依旧依赖字符数组的扩容和拷贝机制。我对字符串模板的态度是关注它但不必急着大规模使用。等它转正并稳定后配合模式匹配这些新特性确实能让代码写起来更舒服。但在老项目里StringBuilder依然是那个最踏实的选择。7. 实战避坑指南我在真实项目里踩过的几个字符串坑理论讲了一大堆最终还是要落到代码上。这一节我把自己在实际项目中踩过的、调过的、帮同事擦过屁股的几个字符串相关坑拿出来聊希望能帮你绕过。7.1 坑一StringBuilder.reverse()的大坑——小心代理无关的“中央反转”先说一个朋友在公司日志脱敏系统里踩的坑。需求是把身份证和手机号中的数字做倒序展示他们直接这样写String mobile 13800138000; String masked new StringBuilder(mobile).reverse().toString();乍看没问题反转后恰好是把尾号放在前面逻辑上确实做到了“倒序”。但问题是他们忘了需求里其实要求的是“只反转部分位”而不是“整串反转”。因为StringBuilder.reverse()是对整个字符序列做反转如果把“13800138000”整体反转就成了“00038100831”身份证里的归属地信息全乱了。这种坑特别隐蔽因为你肉眼看结果“确实是倒的”但业务含义却错了。所以用reverse()前一定要确认你的业务场景是不是“整个字符串整体反转”。如果你要的是“后四位放在前面”这种局部反转请先substring再反转拼接而不是直接reverse整个串。7.2 坑二substring的底层变化和内存泄漏隐患JDK 7之前JDK 7之前String.substring()返回的子串会共享原始字符串的底层char[]数组而不是拷贝。这在当时是为了节省内存但如果原始字符串很大、子串很小且被长期持有那么这个子串会“拽住”整个大数组不让GC回收——这就是旧JDK版本里非常有名的内存泄漏Case。JDK 7之后substring改为创建新数组并拷贝内容不再共享底层数据。如果你的项目还在用JDK 6或更早的版本虽然现在很罕见遇到大字符串频繁substring长驻内存时要格外小心。即使在新版本里频繁substring也会产生拷贝开销这也是在一些高性能场景用StringBuilder.delete()或subSequence()来替代的原因。7.3 坑三空字符串拼接导致的结果难排查常见的错误还有这类逻辑StringBuilder prefix new StringBuilder(); if (condition) { prefix.append(VIP_); } String finalName prefix.append(userName).toString();如果你在condition不满足时直接用了finalName最终拿到的其实是userName本身——因为prefix是空的。这类“空拼接”的问题在拼SQL时特别致命你拼了一个WHERE 11 AND ...开头但某个字段没传值最后拼出来的SQL谁能一眼看出问题我的经验是在StringBuilder拼接的场景每append完一个动态片段至少打一行debug日志确认当前sb的内容。尤其是刚接手别人的拼接代码、或在自己写了超过5个append的长链时。7.4 坑四格式化字符串别忘了String.format的隐藏成本String.format()是一个常被误当成“拼接优化”的方法。它的底层实现极其复杂解析格式字符串、生成Formatter、处理各种占位符和参数类型转换。在低并发场景下可读性带来的价值远大于性能损失但如果你在核心热路径里用它构造日志就要考虑换成手动append了。我处理过一个案例一个系统每秒要打上千条审计日志每条日志用String.format(user%s, action%s, cost%sms, ...)拼接。实际逻辑对比后用StringBuilder重构后耗时下降了接近一个数量级。核心热路径上的字符串构建真的没必要为了可读性牺牲这么多性能——可以用模板字符串或者分步append可读性和性能之间往往有更好的平衡点。7.5 坑五不要用StringBuffer做日志脱敏最后补充一个StringBuffer相关的坑。有人为了“线程安全”在日志脱敏系统里把每一次脱敏后的结果都往一个StringBuffer里append然后想着反正它是线程安全的多个线程写也没事。结果日志顺序完全乱成一锅粥。这里又把4.1节那个边界条件拿出来强调一遍StringBuffer的线程安全不等于业务顺序安全。如果需要保证“多条日志按提交顺序完整输出”你需要的不是StringBuffer而是队列单线程消费或者每个线程独立的缓冲区。线程安全是一个很基础的级别别把它当万能刀。8. 面试官看这段代码的眼神从String到StringBuilder再到StringBuffer的“送命题”写了这么多年Java我参加过不少面试也面过不少候选人。这部分梳理几个我在面试时最常用来考察字符串理解的“送命题”附上完整思路对你准备面试应该有帮助。8.1 题目一为什么String是不可变的常见回答因为用了final修饰、底层char[]/byte[]也被final修饰所以不能改内容。这个回答只能算及格。想答出彩要补充三方面的理由安全性字符串被大量用作参数、类名、配置项、网络协议字段不可变保证不会被恶意修改。性能优化基础字符串常量池、hash缓存、多线程安全共享都是建立在不可变前提上的如果可变这些机制全都会崩。线程安全不可变对象天然线程安全任意线程随意共享引用都没有数据竞争问题。8.2 题目二String、StringBuilder、StringBuffer怎么选完整答案应该拆成三个维度内容标识不变、需要共享复用用String。比如常量、配置值、方法参数。单线程下需要高频修改、拼接用StringBuilder。尤其循环体和日志场景要记得尽量预分配容量。多线程共享同一个可变缓冲区时考虑StringBuffer但还要想清楚顺序安全的边界更推荐用并发数据结构自行保证整体逻辑。如果只是每个线程各拼各的优先StringBuilder。8.3 题目三字符串拼接的“编译器优化”到底优化了什么面试时能答到“javac会把转成StringBuilder”算及格能往下说才行优化只局限在单条语句内部循环体内依旧会创建大量中间对象。JDK 9之后默认优化方式从“构造StringBuilder”变成了StringConcatFactory的indify机制到运行时JIT阶段再决定实际拼接策略。无论是哪种优化本质都是“可变缓冲区一次性拼接”和手写StringBuilder思路一致只是边界和策略不同。8.4 题目四字符串拼接性能和哪几个因素相关几个核心点按重要程度排序拼接次数每多一次拼接就多一分扩容和拷贝成本。初始容量预分配能显著减少扩容次数。JDK版本JDK 9之后的Compact Strings对纯拉丁字符节省一半内存。并发度StringBuffer的锁竞争在并发下会放大性能问题单线程用StringBuilder。这些问题实际上把前面聊的所有知识都串在了一起。能把这些讲清楚面试官通常就不会再在字符串这块纠缠了——你再深入一点比如讲讲intern()的JDK版本差异或者讲讲JDK 9底层byte[]的编码判断逻辑已经超过大多数候选人的深度了。9. 到底该怎么写代码我的一份可落地的String编码规范讲了这么多原理和坑是时候把这些沉淀成一套能直接落到团队里的编码规范了。下面是我自己在项目里会强制自己和团队遵守的一套String使用规则你可以直接抄走按需调整。原则一能用字面量绝不用new。// 错误 String s new String(hello); // 正确 String s hello;原则二单条语句的少量拼接用多条/动态拼接用StringBuilder。// 单条语句可读性优先编译器能处理好 String log user userId , action action; // 循环或复杂拼接 StringBuilder sb new StringBuilder(256); for (Item item : items) { sb.append(item.getName()).append(,); }原则三StringBuilder需要预分配容量时先算一个合理上界。比如你知道最大可能有100条记录、每条按50字符算就按100 * 50来初始化甚至可以加一点余量。原则四纯多线程共享且确实要安全修改时优先考虑用队列单线程消费而不是StringBuffer。StringBuffer只在“你确实只需要一个多线程共享的字节序列、且接受顺序乱的可能”时才使用。大多数场景用队列会得到更清晰的设计。原则五高性能路径上的日志/报文构建不要用String.format()和字符串拼接。直接在热路径上用StringBuilder把所有信息一步一步append进去。这个优化看起来很小但积少成多在高并发的服务端里能省下不少CPU和GC时间。10. 最后聊一点实际体验我自己写代码有个习惯在拿到一段新需求时先判断这个字符串将来会不会被修改。如果不会——比如一个请求参数、一条配置项、一个数据库字段名的字符串——就用String享受不可变带来的所有好处如果会——比如动态拼SQL、拼HTTP报文、拼日志——就直接上StringBuilder预分配容量全程不切String直到最终toString。这套思维模式就是String、StringBuilder、StringBuffer三兄弟在真实世界里的分工逻辑String负责当“稳定的载体”StringBuilder负责“高效的加工”StringBuffer只负责“历史兼容和多线程兜底”。记住这个分工面试题和实际代码就都跑不出你的掌心。如果这篇文章对你有帮助或者你在项目里也遇到过字符串拼接相关的性能坑、GC问题欢迎留言交流。顺手给自己提个醒下次看到负责拼接的代码先别急着写先想想——这个字符串会不会变