ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java String底层原理与常量池:从不可变性到拼接性能的全面解析

Java String底层原理与常量池:从不可变性到拼接性能的全面解析 我前阵子帮一个朋友做代码评审他用比较两个从不同接口拿到的字符串结果线上出了诡异的 bug。他一脸懵地问我这两个字符串明明打印出来一模一样为什么就是false 我一听就知道这是 String 最经典的坑之一。如果你也觉得Java 的 String 不就是个字符串嘛那我建议你花点时间把这篇看完。这篇不是从零教你怎么concat而是把 String 的底层设计、常量池机制、拼接性能、以及那些面试和工程里反复出现的坑一次讲透。适合正在准备 Java 面试的人也适合写了两三年 Java 但对 String 还停留在会用阶段的工程师。1. 为什么String要被设计成不可变的这个决定到底保护了什么1.1 不可变的底层来源从 char[] 到 byte[]很多初学者只知道 String 是 final 的但不知道它为什么是 final 的。我们直接看源码。JDK 8 及之前的版本String 的核心存储是private final char value[];JDK 9 开始为了节省内存改成了private final byte[] value; private final byte coder;coder字段用来标识当前字符串是 LATIN1单字节还是 UTF16双字节编码。这个改动的背景是绝大多数应用程序里的字符串都是拉丁字符字母、数字、符号一个char占 2 个字节但真正用的只是低 8 位白白浪费了一半内存。改成byte[]之后纯拉丁字符串直接按单字节存储内存占用直接减半。后面我会专门讲这个 compact strings 的影响。关键点在于value是final的数组本身是私有的而且 String 类没有暴露任何可以修改数组内容的方法。这意味着一旦一个 String 对象被创建出来它内部的字符序列就不会再变了。这就是不可变性的底层保证。但注意final只是引用不可变数组内容理论上可以通过反射改掉。这一点我会在第五章里专门讲这里先留个钩子。1.2 如果 String 是可变的世界会乱成什么样很多人理解不可变性只停留在线程安全和缓存友好上其实它保护的东西比你想的多得多。第一个场景HashMap 的 key。String 是 Java 里使用频率最高的 Map key没有之一。HashMap 的查找逻辑是先计算 key 的 hashCode定位到桶再通过 equals 比较确认。如果 String 可变你把它放进 HashMap 之后有人偷偷改了这个 String 的内容那么它的 hashCode 就变了。下次你再用原来的引用去 getHashMap 会先算一遍新的 hashCode发现指向了另一个桶结果就找不到之前 put 进去的值了。这不是数据丢失是数据隐身。不可变性直接把这个灾难从根上杜绝了。第二个场景类加载和反射。JVM 加载类的时候类名、方法名、字段名这些信息都是 String。如果 String 可变一个恶意线程可以在类加载过程中篡改类名把java.lang.String改成别的那整个 JVM 的安全模型就崩了。Java 的安全体系大量依赖字符串的不可变来保证传进去的值就是最终的值。第三个场景网络协议和 IO。比如你写了一个 HTTP 服务器从请求头里取了一个 path 字符串准备做权限校验。如果 String 可变校验之后、真正访问资源之前字符串内容被改掉了那权限校验就形同虚设。不可变性保证了一个字符串在多个线程、多个方法之间传递时不会发生你看到的内容和我当初传的内容不一样的情况。1.3 不可变性的代价拼接性能当然任何设计都有代价。不可变性最直接的问题就是每次拼接都会产生新的 String 对象。String s a; s s b; s s c;这段代码一共创建了三个 String 对象a、ab、abc。如果你在循环里做这种操作比如拼 10 万次就会创建 10 万个中间对象。这些对象用完马上变成垃圾给 GC 带来巨大压力。所以 Java 才提供了 StringBuilder非线程安全和 StringBuffer线程安全这两个可变字符串类。原理很简单内部维护一个可扩容的char[]或byte[]你往里面 append它就在数组尾部写最后一次性toString()拷贝出结果。我记得有一个刚毕业的同事在循环里拼 SQL拼了几千条就把堆内存打爆了报的就是java.lang.OutOfMemoryError: Java heap space。后来改成 StringBuilder 就没事了。这个不是 String 的错是用的人没理解不可变性。2. 字符串常量池new String(abc)到底创建了几个对象2.1 字符串常量池在不同 JDK 版本里的位置变化字符串常量池这个概念面试必问。但你光记住常量池在堆里是不够的因为它在不同版本里位置不一样。JDK 6 及以前字符串常量池在方法区PermGen 永久代里。JDK 7移到了 Java 堆中。JDK 8方法区换成了 Metaspace元空间字符串常量池依然在堆中。为什么要移因为 PermGen 的大小是固定的字符串一旦多起来就容易OutOfMemoryError: PermGen space而且 PermGen 的 GC 效率很低。移到堆之后字符串对象可以由常规的 GC 机制管理内存不够了还能被回收。2.2 经典面试题拆解String s new String(abc);这行代码创建了几个对象标准答案分两种情况第一种如果常量池里还没有 abc 这个字符串那么创建两个对象一个是常量池里的 abc一个是堆里的 String 对象。注意这里的顺序。类加载阶段JVM 会把 abc 这个字面量放到字符串常量池里。执行new String(abc)时构造函数接收的 abc 参数直接指向常量池里的那个对象然后new又在堆里创建一个新的 String 对象。这个新对象的value数组内容和常量池里的 abc 一样但它们是两个不同的对象。第二种如果常量池里已经有 abc 了那么只创建一个对象堆里的那个 String 对象。构造函数里的 abc 直接复用常量池里已有的对象。可以通过来验证String s1 abc; String s2 new String(abc); System.out.println(s1 s2); // false System.out.println(s1 s2.intern()); // trues1指向常量池里的对象s2指向堆里的新对象所以是 false。intern()会返回常量池中的引用所以和s1相等。2.3 intern() 的机制与真实用途intern()这个方法很多人只在八股文里见过实际项目里用得不多但理解它对排查内存问题很有帮助。它的规则是如果字符串常量池中已经有一个字符串与当前字符串 equals 相等就直接返回常量池中那个字符串的引用否则把当前字符串加入常量池并返回池中的引用。在 JDK 6 及以前intern()如果发现池中没有会把当前字符串拷贝一份放进永久代。到了 JDK 7 之后由于字符串常量池在堆中intern()就不再拷贝了直接在池里记录当前堆对象的引用。一个典型的应用场景是数据去重。比如你从数据库读了一百万条日志每条日志里都有一个 timestamp 字段名。如果没有 intern这一百万个字符串对象里的内容都是 timestamp但每个对象都独占一份byte[]内存浪费巨大。如果你合理使用 intern注意不是无脑用可以让它们共享同一个池对象内存占用大幅下降。但我必须提醒一句intern()在 JDK 8 之前的版本里如果池中字符串非常多会给常量池带来很大压力甚至引发性能问题甚至 OOM。所以不是所有场景都适合 intern需要结合字符串重复率和池的实现来看。这个后面我们会聊到。2.4 字面量拼接的常量折叠还有一个点很多人容易忽略编译期的常量折叠。String s1 hello; String s2 hel lo; System.out.println(s1 s2); // true这里s2在编译期就被折叠成了 hello所以s1和s2指向常量池里的同一个对象为 true。但如果是下面这样String s1 hello; String s2 hel; String s3 s2 lo; System.out.println(s1 s3); // falses3是在运行时通过拼接得到的不会直接复用常量池对象。这就是比较 String 最坑的地方同样是看起来差不多的拼接一个 true 一个 false光靠肉眼根本看不出来。这也是为什么我一直强调永远不要用 比较 String。3. String拼接的真相编译器优化与StringBuilder的边界3.1 拼接到底编译成了什么很多 Java 程序员听到不要用 拼接字符串这句话就条件反射地点头但实际上JDK 5 之后的 javac 早就对做了优化。先看 JDK 8 及以前的行为。写一个简单的方法public String concat(String a, String b) { return a b; }用javap -c反编译会看到类似这样的字节码逻辑new StringBuilder invokespecial StringBuilder.init aload_1 invokevirtual StringBuilder.append aload_2 invokevirtual StringBuilder.append invokevirtual StringBuilder.toString也就是说JDK 5 之后的编译器会自动帮你 new 一个 StringBuilder然后 append、toString。你写的和手动写 StringBuilder 的效果在单次拼接时几乎没有区别。那为什么网上还到处说循环里不要用 因为在循环里编译器只能对你写的每一行代码做局部优化它不会聪明到把整个循环体合并成一个 StringBuilder。你写String result ; for (int i 0; i 10000; i) { result result i; }实际上每次循环都会经历new StringBuilder - append 旧 result - append i - toString - 把新字符串赋给 result。循环 10000 次就创建 10000 个 StringBuilder 和 10000 个 String 中间对象。这不仅是性能问题更是 GC 压力问题。所以正确姿势是StringBuilder result new StringBuilder(10000 * 4); for (int i 0; i 10000; i) { result.append(i); }这里我顺手给 StringBuilder 指定了预估容量这样能减少扩容带来的数组拷贝。3.2 JDK 9 之后的 invokedynamic 拼接到了 JDK 9Java 又改了一次。javac 不再直接生成new StringBuilder的字节码而是生成一个invokedynamic调用指向StringConcatFactory.makeConcatWithConstants。这个改造的目的有两个第一把拼接策略的决策从编译期推迟到运行期JVM 可以根据实际情况选择最优的拼接方案比如直接计算最终长度并预分配数组第二为未来可能的优化留出空间。但对我们开发者来说结论没有变循环拼接还是要手动用 StringBuilder甚至可以用StringJoiner或者Collectors.joining来做列表拼接。3.3 StringBuffer vs StringBuilder vs String这个属于面试必考但很多人的理解停留在StringBuffer 线程安全、StringBuilder 线程不安全这一层。我来补充点更实际的东西。类可变性线程安全性能适用场景String不可变天然安全拼接慢常量、不可变数据、Map keyStringBuffer可变安全方法加 synchronized一般多线程环境下需要拼接字符串StringBuilder可变不安全最快单线程环境下拼接字符串绝大多数场景StringBuffer的方法都加了synchronized保证同一时刻只有一个线程能修改它的内容。但说实话真正需要在多线程环境下共享同一个可变字符串的场景很少所以日常开发中StringBuilder 完胜。如果你只是在一个方法内部拼字符串用 StringBuffer 纯粹是给自己上枷锁。还有一个冷知识StringBuilder的默认初始容量是 16。如果 append 的内容超过 16它会扩容。扩容逻辑大致是int newCapacity (oldCapacity 1) 2;也就是大约变成原来的 2 倍再加 2。每次扩容都要把旧数组的内容拷到新数组里如果拼接量很大这个拷贝成本不可忽视。所以如果你大概知道最终字符串的长度最好在构造时指定容量new StringBuilder(1024)。3.4 字符串拼接的性能对比实测思路我以前在压测环境里对比过几种拼接方式的耗时结论和理论完全一致少量拼接几次以内、concat()、StringBuilder差异可以忽略。循环大量拼接StringBuilder明显领先的耗时和 GC 次数都会暴涨。大量元素用分隔符拼接可以直接用StringJoiner它内部也是 StringBuilder但帮你处理了分隔符和前后缀逻辑代码更清晰。顺带说一句String.concat()其实被大多数人忽略了。它专门为两个字符串的拼接做了优化内部会直接申请char[]拷贝不走 StringBuilder。只拼接一次的时候它的性能不比 StringBuilder 差代码还简洁。但如果拼接次数多还是要 StringBuilder。4. 工程中String最容易踩的坑编码、比较、split和隐藏的字符4.1 等值比较的致命误区文章开头那个 bug就是典型的比较 String。具体来说String s1 abc; String s2 new String(new char[]{a, b, c}); System.out.println(s1 s2); // false这里s2是通过new创建的指向堆里的对象s1指向常量池两者引用不同。反过来还有一个迷惑场景String s1 abc; String s2 a bc; System.out.println(s1 s2); // true因为编译期折叠如果你不了解常量折叠就会以为比较 String 是可靠的。结果换了一个场景就翻车。我的建议非常简单粗暴写 Java 代码凡是比较字符串内容一律用equals。不要管它是不是字面量不要管它来自哪里。养成肌肉记忆才能避免低级 bug。4.2 split 尾随空字符串丢失split这个坑我敢说大部分人都踩过只是没意识到。String s a,b,; String[] parts s.split(,); System.out.println(parts.length); // 结果不是 3而是 2Java 的split(String regex)默认会丢弃尾部的空字符串。如果想保留需要传负数的 limitString[] parts s.split(,, -1); System.out.println(parts.length); // 3最后一个是空字符串这个坑在解析 CSV 之类的数据时尤其致命。如果一行数据的最后一列是空的用默认split就会少一列后续按索引取字段时直接抛 ArrayIndexOutOfBoundsException。另外split的参数是正则表达式。如果你想按.分割直接s.split(.)得到的是一个空数组因为.在正则里匹配任意字符。正确写法是s.split(\\.)。类似的还有|、*、等正则元字符。4.3 replace 与 replaceAll 的正则陷阱replace(CharSequence target, CharSequence replacement)和replaceAll(String regex, String replacement)的区别面试也常问。replace的参数是普通字符串而replaceAll的参数是正则表达式。坑在哪replaceAll的替换字符串里$和\有特殊含义。比如你想把文本里的name替换成$nametext.replaceAll(name, $name);这段代码会抛IllegalArgumentException: Illegal group reference因为$name被当作正则的分组引用了。正确做法是Matcher.quoteReplacement($name)转义或者干脆用普通文本替换的replace。我自己的原则是如果不是真的要正则匹配一律用replace。它能达到同样的效果还不会引入正则的坑。4.4 String 在 JVM 里的编码真相Java 的 String 在内存中永远是 UTF-16 编码JDK 9 之后 compact strings 会优化为 Latin1但逻辑上你仍然可以认为它是 UTF-16。char是 UTF-16 的 code unit也就是一个char占 2 字节。这就是为什么中文、emoji 这些字符在 Java 里处理起来经常会遇到长度不对劲的问题String s ; System.out.println(s.length()); // 2不是一个 char因为这个 emoji 的码点超出了 BMP需要用两个 charsurrogate pair来表示。用s.codePointCount(0, s.length())才能得到真正的字符个数。编码问题的根因几乎永远是编解码字符集不一致。比如你从文件读字节流文件是 UTF-8 编码的你用new String(bytes, GBK)去解码出来的就是乱码。反过来一个 String 调用getBytes()不指定字符集它会用 JVM 默认字符集而 JVM 默认字符集在不同环境下可能不一样。所以任何涉及字节和字符串转换的地方都要显式指定字符集比如StandardCharsets.UTF_8。4.5 不可见字符与 trim 的局限trim()能去掉的只是一部分空白字符ASCII 码小于等于 0x20 的字符包括空格、制表符、换行等。但如果你处理的是全角空格U3000、不间断空格 NBSPU00A0这种字符trim()是去不掉的。我记得有一次处理用户上传的 Excel单元格里混入了全角空格导致我用trim().equals(abc)判断一直失败。后来排查了半天才发现是空格类型不对。解决办法是用正则或者Character.isWhitespace完整判断或者用 JDK 11 引入的strip()。strip()比trim()更智能它能识别 Unicode 标准中的空白字符包括全角空格。如果你的环境支持 JDK 11建议优先用strip()。4.6 substring 的历史内存泄漏这是一个非常有年代感的坑但特别值得知道。在 JDK 6 及以前String的构造和substring都是共享同一个底层char[]只记录offset和count。也就是说你从一个 100MB 的大字符串里substring(0, 10)得到的那个小字符串底层仍然引用着那个 100MB 的char[]。如果你把大字符串的引用置空只保留了这个小字符串那 100MB 内存也释放不了。在大量调用 substring 的场景下很容易触发 OOM。JDK 7 之后改掉了这个设计substring会重新拷贝一份数组。虽然拷贝有开销但避免了内存泄漏。现在你用substring不用担心这个问题了不过如果你是从一个超大字符串里截取很多小片段仍然要注意String对象的数量本身带来的内存压力。4.7 toUpperCase 的 Locale 陷阱这个坑知道的人更少了。String.toUpperCase()如果不传 Locale会使用 JVM 默认的 Locale。在土耳其语环境下i.toUpperCase()的结果不是I而是İ带点的 I。如果你的应用有多语言环境或者 JVM 默认 Locale 不是英语就可能在大小写转换时出现诡异 bug。最好总是显式指定 Localestr.toUpperCase(Locale.ROOT)。Locale.ROOT是一个中性的 locale不会引入任何地区的特殊规则。5. 高频String面试题底层拆解从八股到源码5.1 为什么 String 被设计成 final这个问题几乎每次面试都会出现。标准答案有四点第一安全。String 被广泛用于类名、网络地址、文件路径、数据库 URL 等敏感信息不可变可以防止被继承后改写行为。第二线程安全。多个线程共享同一个 String 对象不需要任何同步。第三缓存。不可变保证了hashCode只需要计算一次String 内部有一个hash字段做缓存。这也是 String 适合做 HashMap key 的原因。第四字符串常量池。如果 String 可变常量池里的对象可能被篡改整个池的复用机制就崩溃了。5.2 equals 和 hashCodeString 在 HashMap 里的表现String 重写了equals和hashCode。hashCode的算法很简单对每个 char 做hash 31 * hash charValue。为什么用 31因为 31 是个奇素数而且 JVM 对31 * i有优化等价于(i 5) - i位运算比乘法快。因为不可变String 计算一次 hashCode 后就能缓存此后每次调用hashCode()都是 O(1) 时间不需要重新遍历字符数组。所以 String 作为 HashMap 的 key 时性能非常好。如果你自定义类作为 key务必要保证equals相等的对象hashCode也必须相等。否则 HashMap 会把你精心放入的数据搞得找不到。我见过不少因为没重写 hashCode 导致存进去了但取不出来的问题。String 在这方面是绝对正确的范例。5.3 反射能不能修改 String理论上可以。因为value数组虽然被final修饰但反射可以绕过访问控制直接改数组内容String s abc; Field field String.class.getDeclaredField(value); field.setAccessible(true); char[] value (char[]) field.get(s); value[0] x; System.out.println(s); // xbc看上去很酷实际上非常危险。你改了一个字符串的值而它可能正好在字符串常量池里。改掉之后所有引用同一个池对象的代码看到的都会是被篡改后的值整个系统状态直接崩了。这种操作在生产环境里就是灾难。所以反射能改但绝对不能做。这就像你家的保险柜理论上可以用电锯切开但正常人不会这么干。5.4 int 和 String 互转的最快方式如果你要拿int转成String最常用的是三种写法String s1 Integer.toString(123); String s2 String.valueOf(123); String s3 123;String.valueOf(123)内部就是调Integer.toString(123)所以两者本质一样。 123编译后也会走 StringBuilder或者 invokedynamic性能上略慢一点点但在单次转换场景里可以忽略。我的建议是用String.valueOf或Integer.toString可读性更好也不会因为号产生额外对象。反过来字符串转 intint i Integer.parseInt(123);这个没什么花活唯一要注意的是提前用正则校验格式否则非数字字符串会抛NumberFormatException。用Integer.parseInt处理空字符串时会抛异常所以调用前最好确认输入。5.5 正则表达式一定要预编译String.matches()是一个看起来很方便的方法但它的实现是每次调用都Pattern.compile(regex)一次。如果在一个循环或高频方法里调用matches()性能会非常差。正确姿势是预编译private static final Pattern DIGIT_PATTERN Pattern.compile(^\\d$); public boolean isDigit(String s) { return DIGIT_PATTERN.matcher(s).matches(); }split和replaceAll也是一样参数是 String 类型但底层都会走Pattern。如果你在循环里用同一个正则做 split 或 replaceAll建议改成预编译Pattern后调用pattern.split(str)或matcher.replaceAll(replacement)。不少人会觉得这是微优化不值得。但我在一个日志解析工具里验证过原本用String.split解析 10 万行日志耗时几十秒改成预编译Pattern之后耗时直接降到几秒。正则编译的开销远比很多人想象得大尤其正则本身复杂的时候。5.6 字符串 equals 的编译器优化String 的equals源码里有一个非常有意思的细节先比较this anObject如果引用相同直接返回 true。这意味着当你比较两个指向同一个对象的字符串时根本不需要遍历字符数组。然后它还会比较anObject instanceof String接着比较长度最后才逐字符比较。这个先判断长度再逐字符的顺序是从后往前比的因为常见的字符串差异往往出现在末尾。这些细节平时写业务代码用不到但读源码的时候能感受到 JDK 作者的用心。关于 String 的一点个人经验收尾写了这么多其实就想说一句话String 是 Java 里最值得花时间读源码的类没有之一。我自己的习惯是每当团队里新同学进来我都会建议他们做一件事把String.java的源码完整读一遍不用精读但至少把equals、hashCode、substring、intern、split、replace这几个方法从头到尾看一遍。读完之后很多面试题不用背自然就理解了。比如为什么equals要先比较引用为什么split要丢弃尾随空字符串你都能从源码里找到答案。实际项目里我给自己的最硬性的一条准则就是字符串内容比较一律用equals字符串拼接看场景选工具任何涉及字节转换都显式指定字符集正则表达式一定要预编译。这几条做到位能避开 80% 以上的 String 相关 bug。另外如果你还在用 JDK 8建议关注一下 JDK 9 之后的 compact strings 和字符串拼接优化等将来升级版本的时候你会感谢这些改动帮你省下的内存和 CPU。
返回列表