Java字符串底层机制与性能优化:从不可变性到常量池、拼接与正则
“String s new String(abc)到底创建了几个对象”这道题我面试过别人不下五十次能答对的不到三成。但说实话我从来不觉得这是道好题——它考察的不是背答案而是对Java字符串底层机制有没有真正思考过。字符串操作是Java开发里最基础也最高频的动作偏偏越基础的东西越容易在关键时刻掉链子线上日志打出来全是乱码、用split切分数据莫名其妙丢元素、循环里用加号拼字符串把接口拖垮。这篇文章我把这些年踩过的坑和沉淀下来的经验一次性讲透从底层原理到实操细节再到面试真题解析覆盖字符串操作的方方面面。适合刚入门想打牢基础的初学者也适合准备面试想要系统梳理的求职者老手如果能在里面捞到一两个平时没注意的细节那也算值了。1. 从String不可变性开始理解Java字符串1.1 为什么String要被设计成不可变String在Java里被设计成final类内部存放字符的数组也是final的——这意味着一个字符串对象创建之后它的内容就再也没有办法被修改。这不是拍脑袋决定的背后有非常实际的设计考量。第一个好处是线程安全。不可变对象天然可以在多线程环境下共享不需要任何同步措施。String被大量用作HashMap的key、日志输出的消息、配置项的取值如果它是可变的并发场景下数据随时可能被意外篡改整个系统的稳定性就无从谈起。第二个好处是字符串常量池能正常工作。正因为字符串不可变JVM才能放心地把相同内容的字符串引用指向同一个对象。如果字符串可变常量池里的对象被某处修改所有引用它的地方都会跟着变那简直是一场灾难。缓存、池化的基础就是对象内容不可变。第三个好处是hashCode可以安全地缓存。String重写了hashCode方法并且把计算出的哈希值缓存在成员变量里第一次调用时计算之后直接返回。这依赖不可变性——内容不会变哈希值自然也就不用重新计算。这也是String能成为HashMap最常用key的原因之一。有个细节值得一提在Java 8及以前String内部用char[]存储从Java 9开始改成了byte[]加一个编码标志位coder这是JEP 254引入的Compact Strings优化。纯Latin-1字符的字符串每个字符只占一个字节比原来节省一半内存。这就是为什么你在Java 9及以上版本的线上环境里用反射去查看String内部字段看到的是byte数组而不是char数组。1.2 字符串常量池字面量与new的本质区别字符串常量池英文叫String Constant Pool是JVM运行时数据区里一块特殊的内存区域。JDK 7之前它在永久代里JDK 7开始移到了Java堆中。这也是当年经常出现的PermGen OutOfMemoryError在字符串场景下彻底消失的原因——永久代的空间固定且很小大量字符串驻留很容易撑爆它。要理解这个池子的作用先看两个经典写法String a abc; String b abc; String c new String(abc); System.out.println(a b); // true System.out.println(a c); // false第一行代码执行时JVM会在常量池中查找是否存在内容为abc的对象。没有于是创建一个并放入池中有则直接复用。所以a和b指向的是同一个对象用比较返回true。第三行代码则不同。关键字new明确要求创建新对象无论常量池里有没有这个字符串它都会在堆上再new一个。所以c是独立的另一个对象a c当然返回false。这就是那八道经典面试题的第二个版本String s new String(abc)创建了几个对象。答案是如果常量池中原本没有abc创建两个——常量池一个、堆上一个。如果池中已有只创建一个——堆上那个。注意这题默认不讨论字符串拼接和intern的情况。字符串的intern()方法可以把一个字符串对象的内容尝试放入常量池。如果池中已有相同内容的字符串返回池中对象如果没有JDK 7之后的实现是把这个字符串的引用复制到池中返回这个引用。我对intern的使用一贯保持谨慎因为它在JDK 7之后虽然不复制字符串内容但字符串本身仍然无法被GC回收滥用intern会造成严重的堆内存膨胀。线上有一台老服务器就是因为某个同事在循环里大量调用String.valueOf(...).intern()把堆直接撑爆了排查了很久才找到根因。2. 常用API实操细节每天都会用但容易踩坑的方法2.1 字符串构建与转换valueOf、format、repeat字符串构建最直接的是字面量赋值和new但实际开发中更多时候是从其他类型转换而来。String.valueOf是数值转字符串的首选方法。它有多个重载版本分别处理int、long、float、double、boolean、char、char[]、Object。注意它的一个特性如果参数是对象且为null返回字符串null —— 这是有意为之的用起来反而安全不会抛NullPointerException。而String.valueOf(char[])如果传入null会直接抛NullPointerException因为底层调用了String.copyValueOf没有做null判断。这两个情况的差异我见过不止一个同事在线上栽过。除了拼接字符串格式化也是一个高频需求。String.format是类C语言printf风格的格式化方法String message String.format(订单号%s金额%.2f 元下单时间%tF %tT, orderId, amount, LocalDateTime.now());%s是字符串%d是整数%f是浮点数可以指定小数位%tF和%tT是时间格式化。实际项目中我建议尽量少用String.format它在每次调用时都会创建一个Formatter对象并走一遍完整解析流程性能比直接拼接差一个数量级。日志量大、调用频繁的地方用占位符拼接或者引入Slf4J的参数化日志才是更好的选择。Java 11引入了String.repeat(int)方法可以用一行代码实现字符串的重复拼接。比如需要生成一个包含100个-的分隔线直接-.repeat(100)就行。这个方法在底层做了一次数组分配和拷贝效率远高于循环拼接。面试官比较新潮的话可能会问到你有没有用过JDK 11中的新增APIrepeat确实是值得提的一个亮点。2.2 截取、替换、分割substring、replaceAll、split的使用边界substring方法的两个版本——substring(int beginIndex)和substring(int beginIndex, int endIndex)都要注意边界条件。Java的区间约定是左闭右开即包含beginIndex位置的字符不包含endIndex位置的字符。这个约定跟List.subList、Arrays.copyOfRange保持一致是Java体系的统一风格。截取最典型的坑是StringIndexOutOfBoundsException。beginIndex不能为负不能大于lengthendIndex不能大于lengthbeginIndex不能大于endIndex。用之前先检查字符串长度是个好习惯特别是处理外部传入的参数时。JDK 6及以前版本有一个著名的内存泄漏问题substring方法返回的新字符串会共享原字符串内部的char数组只通过调整offset和count来实现截取。这意味着原字符串哪怕有几十MB截取出几个字符的小字符串也会一直持有完整的大数组内存根本释放不掉。JDK 7之后修改了这个实现改为真正复制一份新数组问题才彻底解决。如果你维护的老项目还跑在JDK 6上遇到类似的内存问题第一反应应该检查substring的调用历史。replace和replaceAll是两个容易混淆的方法。replace(CharSequence target, CharSequence replacement)是纯字面量替换把所有匹配target的内容替换为replacementreplaceAll(String regex, String replacement)则把第一个参数当正则表达式处理。从JDK 5开始replace也支持CharSequence参数了所以大部分场景用replace就够了只有当第一个参数确实是正则表达式时才需要replaceAll。这里有个特别实际的坑replaceAll的第二个参数中反斜杠和美元符号有特殊含义。$1、${name}代表正则捕获组引用\用于转义。如果你的替换文本里包含这些符号且不想被解释需要对它们进行转义。我处理过一次线上事故某个功能是把用户留言里的某些敏感词替换成“***”结果留言里恰好有大段正则相关的文本直接触发了PatternSyntaxException。后来学乖了替换文本统一用Matcher.quoteReplacement(replacement)包裹一层彻底规避这个问题。split(String regex)是字符串分割的主力方法但它的行为有一堆细节。第一个陷阱是正则特殊字符。如果要按点号分割比如192.168.1.1.split(.)得到的是一个长度为0的数组——为什么因为点号在正则里是任意字符的通配符匹配到了每一个位置分割结果自然是空的。正确写法是\.。同样需要转义的还有|、*、、^、$、?等等。对用户输入作为分割符的情况我建议直接用Pattern.quote来包裹String[] parts input.split(Pattern.quote(userProvidedSeparator));第二个陷阱是尾随空字符串会被丢弃。举例子a,b,c,.split(,)得到的结果长度为3而不是4——最后一个逗号后的空串不保留。如果希望保留需要给split传第二个参数limit值为负数String[] parts a,b,c,.split(,, -1); // 长度为4最后一个元素是空串limit参数的含义是结果数组的长度不超过limit但如果limit是负数则不做任何限制。这个知识点在解析CSV文件时很常用CSV的一行末尾可能有多余的逗号直接split会把空字段丢掉导致列错位。2.3 判断与匹配equals、startsWith、matches的适用场景判断两个字符串内容是否相等用equals而不是。这个规则每个Java程序员都能背出来但深层原因不是所有人都说得清——equals比较的是两个字符串的内容逐字符是否一致而比较的是两个引用是否指向同一个对象。内容相同但分布在堆上不同位置的两个字符串对象用比较必然返回false。equalsIgnoreCase是忽略大小写的版本适合邮箱地址、用户名这类不区分大小写的业务场景。它内部的实现是逐字符比较时统一转成大写再对比所以没有额外的对象创建开销。前缀后缀判断用startsWith和endsWith。这两个方法我用得非常频繁比如判断文件名后缀、URL路径是否以某个前缀开头、是否包含特定标记。它们是专门为前缀后缀匹配设计的方法语义清晰性能也很稳定。matches(String regex)方法做的是全字符串正则匹配注意是全匹配而非部分匹配。这意味着正则表达式必须匹配整个输入字符串才能返回true。这和Pattern.matcher(...).find()的语义不同——find只要找到匹配的子串就会返回true。日常开发中用String.matches做格式校验比较方便比如校验手机号、邮箱、身份证号。但它的性能不容乐观因为每次调用都会重新编译正则表达式。如果校验逻辑在循环里执行我建议提前把Pattern编译好用Matcher的matches方法代替Pattern phonePattern Pattern.compile(^1[3-9]\\d{9}$); for (String phone : phoneList) { boolean valid phonePattern.matcher(phone).matches(); }3. 字符串拼接背后的性能账本3.1 加号拼接和StringBuilder的真实差距字符串拼接最简单的方式是加号Java语言层面也允许这样操作。但很多人写过这样的代码String result ; for (int i 0; i 10000; i) { result result i; }这段代码的性能非常差。每次执行result result i时因为String不可变都不能复用已有的result对象必须创建一个新的StringBuilder、把result内容append进去、再把整数append进去、最后toString生成新字符串然后丢弃旧对象。一万次循环就是一万次对象创建和数组复制GC压力巨大。在JDK 5到JDK 8时代编译器确实会把加号拼接转换为StringBuilder操作。但关键区别在于每次循环里的result result i都会被编译成独立的new StringBuilder加append流程StringBuilder对象在每次循环里都会新建和丢弃循环级别的拼接没法复用同一个StringBuilder对象。所以编译器优化解决的只是把两次拼接合并为一次并没有解决循环拼接的根本问题。JDK 9开始情况有变。字符串拼接引入了invokedynamic机制通过StringConcatFactory这个引导方法在运行时决定具体的拼接策略。编译器不再把加号拼接直接翻译成StringBuilder操作而是生成一个动态调用点JVM运行时会选择最合适的拼接策略——通常是直接创建字节数组一次性拷贝完成。这比原来的StringBuilder流程效率更高。但即便如此在循环里用加号拼接依然是反模式因为循环体里每执行一次拼接仍然要重新分配一次目标数组。正确的循环拼接姿势是显式使用StringBuilderStringBuilder sb new StringBuilder(); for (int i 0; i 10000; i) { sb.append(i); } String result sb.toString();3.2 StringBuilder的扩容机制与容量预分配StringBuilder内部维护一个字节数组Java 9之后是byte[]之前是char[]来存放拼接的内容。默认初始容量是16当append的内容超过当前容量时会触发扩容。扩容的算法很简单新容量等于旧容量的两倍加二。这个两倍加二的设计有点讲究——左移一位是乘以2加2是为了留一点余量避免恰好扩容到刚好够用。计算完成之后如果新容量仍然小于所需的最小容量就直接用最小容量。这里的最小容量是当前已有内容长度加新追加内容长度也就是下次扩容后至少要能装下全部内容。如果内容是确定量级我建议提前指定容量。StringBuilder和StringBuffer都有带初始容量的构造方法。比如预计要拼接1000条数据每条几十个字符直接new StringBuilder(50000)能省去多次扩容的数组复制开销。扩容本质上就是创建一个更大的新数组然后把旧数组的元素整体拷贝过去——这个拷贝操作如果频繁触发性能损耗非常明显。还有一个细节需要提醒append方法返回的是this支持链式调用。这种链式写法规避了中间变量可读性也更胜一筹。很多人写代码时习惯每行一个append其实可以写成StringBuilder sb new StringBuilder(64) .append({\id\:).append(id) .append(,\name\:\).append(name) .append(\});StringBuffer是StringBuilder的线程安全版本所有公开方法都加了synchronized关键字。但日常开发中字符串拼接几乎都发生在局部变量场景线程安全根本无从谈起强行用StringBuffer只是在白白支付锁的开销。只有在少数真正需要跨线程共享拼接对象的情况下比如多个线程往同一个缓冲区追加日志才应该考虑StringBuffer或者更优秀的替代方案。4. equals与hashCode面试高频的底层逻辑4.1 equals重写的标准姿势String重写了Object的equals方法实现了逐字符内容比较的逻辑。这段逻辑值得深入理解它是很多面试题的基础也是日常开发中重写equals时的参照模板。String.equals的实现大致是这样的首先用做一次引用比较如果两个引用指向同一个对象直接返回true这是短路优化然后判断对方是否为String类型不是就直接返回false最后逐字符比较字节数组。在JDK 9的Compact Strings实现下如果两个字符串的编码标志位不同会先把某个转换成统一的编码再比较过程略微复杂但对外表现仍然是逐字符内容比较。这里引出一个关键原则重写equals方法时必须先判断类型。Object.equals的通用约定包括自反性、对称性、传递性、一致性以及“非null对象的equals比较永远不会返回null结果”这一条。很多人写equals时忘了null判断或者没有先比较类型就直接强转极容易造成意外的ClassCastException。一个标准的equals重写模板Override public boolean equals(Object o) { // 引用相等直接返回true if (this o) return true; // 类型不匹配或null返回false if (o null || getClass() ! o.getClass()) return false; User user (User) o; // 逐个字段比较引用类型用Objects.equals避免null判断 return Objects.equals(name, user.name) age user.age; }注意一个细节用getClass() ! o.getClass()还是用o instanceof User判断各有讲究。getClass比较严格要求类型完全一致子类对象和父类对象互不相等instanceof则比较宽松子类对象也能匹配。如果实体类没有继承体系两者效果一致。但从对称性角度考虑如果父类用instanceof子类也重写了equals且同样用instanceof就容易出现“对称性破坏”的问题。简单场景下推荐getClass()判断。4.2 hashCode为什么选31作为乘数String的hashCode算法是所有Java开发者都接触过的经典int hash 0; for (byte b : value) { hash 31 * hash b; }也就是对字符串的每个字符执行hash 31 * hash charAt(i)最终得到这个字符串的哈希值。这个算法整个计算过程中只有一个常量那就是31。为什么偏偏是31我从网上看过很多分析综合下来主要有三点。第一31是奇素数作为乘法因子能减少哈希碰撞。第二31可以用移位加减法高效计算31 * i (i 5) - i在JVM中这个操作比普通乘法快很多。实际上JIT编译器会自动识别31乘法的模式并优化成移位操作。第三31的乘数在哈希的离散分布上表现不错很多经典的哈希算法都验证了它对字符串分布有较好的均匀性。hashCode和equals的搭配有一条铁律两个对象相等哈希值必须相等哈希值相等两个对象不一定相等。这对应HashMap的查找逻辑——先根据哈希值定位桶再在桶内用equals寻找目标。所以重写equals必须同时重写hashCode否则把对象放到HashSet或HashMap的key中时equals返回true的对象可能被分到不同的桶直接导致数据错乱。这条规则在面试中必被考察在实际开发中也是Bug高发区。4.3 intern的实战陷阱String.intern()这个方法在面试中经常出现实际生产环境中也偶有用到。它的作用是如果常量池中已经有与当前字符串内容相等的字符串返回常量池中的引用如果没有把当前字符串的引用加入常量池并返回这个引用。JDK 6及以前的intern实现是把字符串内容复制一份到永久代永久代空间小大量使用intern很容易抛出OutOfMemoryError: PermGen space。JDK 7之后永久代移除常量池挪到堆里intern的语义也调整为直接在常量池中保存字符串引用不再复制内容。这解决了内存爆掉的问题但引入了新的问题——被intern的字符串对象和它的引用一起被常量池强引用导致GC无法回收。实际开发中有一个可以利用的场景对大量内容相同的字符串去重。比如某些场景下系统会频繁创建内容完全一样的字符串对象通过intern可以让它们复用同一个对象节省内存。但我的经验是只在对去重收益有精确评估、且字符串内容集合规模可控的前提下使用。如果字符串的内容千变万化intern不仅达不到省内存的效果反而会让常量池里的对象越积越多最终成为内存泄漏。5. 编码、字节与正则字符串的高阶必修课5.1 字符编码导致乱码的根因与解决乱码问题几乎是所有Java后端开发者都会遇到的噩梦它的根源在于字符编码不一致。Java内部字符串统一使用Unicode字符集但JVM在读取外部数据、网络传输、文件写入时默认使用平台字符集。当数据的编码方式与JVM解码时使用的编码方式不匹配就有可能出现乱码。最常见的一个场景项目里用new String(bytes)把字节数组转成字符串但没有指定字符集。JVM会使用默认字符集通常是操作系统的字符集如果你的Linux服务器默认是UTF-8本地Windows是GBK同一套代码在不同环境里行为就不一致。正确的做法是任何时候都明确指定字符集String text new String(bytes, StandardCharsets.UTF_8); byte[] bytes text.getBytes(StandardCharsets.UTF_8);Java 7之后推荐直接使用StandardCharsets.UTF_8而不是字符串形式的UTF-8后者还需要捕获UnsupportedEncodingException。Java 10之后还有一个更省事的API——String新加了带Charset参数的构造方法重载但核心思想不变永远显式指定字符集。乱码问题还有一些冷门但常见的坑。比如String.getBytes()不带参数时如果环境默认编码不是UTF-8就会把字符串按GBK编码成字节数组再送到用UTF-8解码的地方立即出现乱码。再比如数据库连接串里如果没指定characterEncodingMySQL驱动会使用服务器的默认编码与Java应用的编码不一致中文写入后读出来就花了。HTTP请求响应头里的Content-Type没有明确charset浏览器和服务器之间也会出现编解码不一致。排查乱码问题的通用思路确认数据的源头编码是什么、传输过程中编码是否有转换、目标端的解码编码是否正确。用十六进制查看字节是最直接的判断方式——一个UTF-8编码的中文字符通常是三个字节GBK是两个字节中文标点与英文字符的区别也很明显。如果看到字节序列中混着大量的0xEF 0xBF 0xBD这表示已经出现了UFFFD替换字符也就是数据里的非法编码序列被替换掉了这种情况更麻烦说明原始字节已经部分丢失。5.2 正则表达式在字符串操作中的应用与转义坑正则表达式是字符串处理的绝对利器。Java里对正则的支持封装在java.util.regex包中核心是Pattern、Matcher两个类。Pattern负责编译正则表达式Matcher负责在具体字符串上执行匹配。很多初学者习惯直接用String.matches、String.replaceAll、String.split这些便捷方法它们内部确实都会创建Pattern对象但问题是每次调用都重新编译一次正则。正则在第一次编译后可以通过Pattern对象缓存起来避免重复编译带来的开销。性能敏感的场景尤其循环调用时一定要把Pattern提到循环外面。我自己维护过一个规则引擎里面运行着上百条正则校验规则最初版本就是直接在每个请求里调用matches一压测就发现CPU时间大部分都耗在正则编译上。后来加了Pattern缓存性能提升非常明显QPS直接翻了几倍。正则表达式的编译开销在短小正则场景下不明显但复杂正则的编译时间可能达到毫秒级加上调用频繁一下就暴露了。正则里还有一大类经典问题——转义地狱。正则本身有大量元字符Java字符串中反斜杠又需要用另一个反斜杠来表示于是很多正则表达式在Java代码里看起来像天书。比如匹配一个数字的表达式\d匹配一个反斜杠需要\\匹配一个点号需要\.。这个双重转义问题让正则的可读性雪上加霜。我的建议是对稍复杂的正则写成Pattern.compile时用注释或常量命名解释其含义必要时拆分成小的子正则分别验证。比如// 匹配IPv4地址 private static final Pattern IPV4_PATTERN Pattern.compile(^((25[0-5]|2[0-4]\\d|[01]?\\d\\d?)\\.){3}(25[0-5]|2[0-4]\\d|[01]?\\d\\d?)$);如果正则的匹配逻辑太复杂还可以考虑用多个简单正则组合代替可维护性更好。正则表达式的回溯陷阱也需要提防——某些带嵌套量词的模式比如(a)在匹配长字符串时可能触发灾难性回溯导致CPU飙升。这在实际的线上问题是真实存在的处理用户输入的正则表达式时尤其要小心必要时用Re2J这样的线性时间正则库替换JDK自带实现。6. 高频面试题与避坑指南6.1 高频面试题从String构造到输出把面试中最常碰到的字符串题目集中整理成清单逐个击破比漫无目的地刷题高效得多。第一题String、StringBuilder、StringBuffer的区别。标准答法分三点String是不可变的每次修改都创建新对象StringBuilder是可变的字符串构建类效率高但线程不安全StringBuffer在StringBuilder基础上加锁线程安全但性能稍差。最佳实践是局部变量拼接用StringBuilder全局共享且需要线程安全时用StringBuffer或考虑其他方案。第二题String s new String(abc)创建了几个对象。前面已经讲过分两种情况常量池没有abc时创建两个有则创建一个。这个问题的变形还会加上s.intern()需要回答清楚intern返回的是池中对象。第三题如何反转字符串。最直接的是StringBuilder的reverse方法new StringBuilder(str).reverse().toString()。如果面试官要求手写实现可以用双指针交换字符数组。注意reverse同样满足不了所有要求比如忽略Unicode代理对会导致部分emoji字符反转后乱码但面试阶段通常不考虑这个细节。第四题如何统计字符串中每个字符出现的次数。经典解法是用HashMapCharacter, IntegerJava 8之后可以用merge方法一行搞定MapCharacter, Integer count new HashMap(); for (char c : str.toCharArray()) { count.merge(c, 1, Integer::sum); }merge的第三个参数是重映射函数表示当key已存在时把旧值和新值合并的结果作为新值写入。这里有加分项如果不考虑Unicode补充平面char遍历没问题如果字符串可能包含emoji等需要两个char表示的内容应该用codePointAt逐码点遍历。第五题equals和hashCode的关系。必须重写equals时重写hashCode否则HashMap、HashSet等集合会出现逻辑错误。要能解释为什么——哈希定位依赖hashCodeequals相等但hashCode不等会导致相同对象被分到不同桶。6.2 线上字符串问题的排查思路字符串相关的线上故障最常见的几类我都遇到过整理成速查表供参考现象可能原因排查方向日志乱码控制台、日志文件编码与应用编码不一致检查JVM默认字符集、日志框架的编码配置数据库中文显示问号JDBC连接未指定characterEncoding检查连接串、表字符集、列字符集字符串替换无效替换内容包含正则特殊字符被误解析检查replaceAll参数改用quoteReplacementsplit结果不对分隔符是正则特殊字符或尾随空串被丢弃确认转义需要保留空串时使用负数limit大对象内存占用高字符串频繁substring和拼接旧版本JDK内存泄漏升级JDK 7避免不必要的大字符串持有接口响应很慢循环内大量字符串拼接/正则匹配用StringBuilder提前分配容量缓存Pattern除了表格里的常见场景还有一个容易被忽视的点字符串对象作为业务数据的载体在排查OOM时经常扮演隐藏角色。一张导出报表在内存里拼了几百万行字符串还没开始写文件堆就满了。我处理过的问题是某个定时任务从数据库捞出全量数据在内存中循环字符串拼接生成CSV三千万行数据让堆空间直接炸掉。最终方案是改成流式处理每攒够一万行就写一次文件内存占用从几个GB降到几十MB。字符串面向上线的问题排查核心思路永远是先确认数据流向中每一步的编码和存储方式再用最小化复现的方式定位具体是哪一段操作引发的异常。字符串本身不会出问题出问题的总是调用它的方式和环境。我在实际工作中对字符串操作有一个习惯凡是涉及编码转换、正则匹配、大量拼接的场景一律写单元测试覆盖边界条件——空串、null、特殊字符、超长字符串。这些边界情况最容易暴露问题也最难在代码评审中被人眼发现。字符串看似简单用好了是利器用不好就是线上事故的温床。最后再分享一个小技巧如果你不确定某个字符串方法的行为边界最快的验证方式是在本地写个main方法跑一遍看结果打印出来是什么比翻文档和猜快得多。