十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

Java String方法实战:不可变性、常量池与高频API避坑指南

Java String方法实战:不可变性、常量池与高频API避坑指南 早上排查线上问题最后又绕到字符串处理上。这种情况在Java开发里太常见了——接口签名校验、日志解析、Excel导入、JSON转换哪一步都躲不开和字符串(String)打交道。String是Java里使用频率最高的类但很多同学对它的认知停留在用的时候查一下API用完就忘。这篇整理是我这些年写Java沉淀下来的字符串方法使用笔记。我不打算按官方API文档的顺序罗列而是按日常开发的实际使用场景重新分类把常用方法、参数细节、返回值规则、以及踩过的坑都写在里面。适合刚学Java的读者直接当手册用也适合准备面试的同学查漏补缺老手偶尔翻一翻也能回忆起几个平时不太注意的边界行为。网上关于String的教程其实很多但大多是API列表式罗列只告诉你这个方法做这个不聊边界情况不说性能差异更不讲为什么这么设计。而实际开发里最容易出问题的恰恰就是那些你以为你知道的边角细节。1. 理解String的两个基石不可变与字符串常量池1.1 不可变性给开发者带来的三个好处先看源码。JDK8里String的核心是一个private final char value[]JDK9之后换成了private final byte[] value配合COMPACT_STRINGS做了LATIN1/UTF16两种编码的压缩存储。不管哪个版本value都是final的String类本身也是final的不允许被继承。这是String一切行为的起点。这个设计带来三个实际好处第一线程安全。同一个String可以被多个线程共享不需要任何同步手段。这和SimpleDateFormat那种线程不安全、得放ThreadLocal里才敢用的类形成鲜明对比。多个线程同时读一个字符串内容不会有人改了它自然也不会出现数据竞争。这个特性让String能放心地作为HashMap的key、作为日志里的公共上下文对象到处传递。第二hashCode可以缓存。String的哈希值只在首次调用hashCode()时遍历一次字符内容之后直接返回缓存结果。正因为不可变哈希值不会失效所以String在HashMap、HashSet里当key时性能非常稳定。你想想如果String可变一个对象放进HashMap之后再被改掉那这个key的哈希桶就全乱了——不可变从根本上避免了这种问题。第三字符串常量池可以放心复用。正因为字符串创建后不会被修改JVM才能在常量池里缓存相同内容的字符串引用让内容相同的字面量共享同一份数据。如果String可变常量池复用一个字符串对象某个地方一改所有引用它的地方全跟着变后果不堪设想。面试里被问String为什么设计成不可变我建议就按这三条主线答安全性、哈希缓存、常量池复用。这么答比干背因为它是final的要立体得多。1.2 abc和new String(abc)的差别以及和equals字符串常量池的位置在不同JDK版本里也有变化。JDK7之前它放在方法区永久代JDK7开始被移到了堆里。这个变化对开发者的实际影响是JDK7之后常量池里的字符串也能被正常GC回收不再那么容易把永久代撑爆。两种创建方式的区别非常核心双引号直接写字面量比如String s1 abcJVM会去字符串常量池里找有没有内容为abc的字符串有就直接复用引用没有就创建一个放进去。用new String(abc)不管常量池里有没有都会在堆上创建一个新的String对象。如果常量池里还没有abc构造参数的双引号字面量会顺带在常量池里也创建一个。所以new String(abc)在常量池里没有abc时会创建两个对象一个在常量池一个在堆里。这个创建了几个对象是老牌面试题了答案就看常量池里是否已存在。看段代码就清楚了String s1 abc; String s2 abc; String s3 new String(abc); System.out.println(s1 s2); // true常量池里同一个引用 System.out.println(s1 s3); // false堆上新建的对象地址不同 System.out.println(s1.equals(s3)); // true内容相同再补两个的隐藏考点String s4 ab c; // 编译期常量折叠s4 s1 为 true final String sA ab; String s5 sA c; // 变量是final常量也走编译期折叠s5 s1 为 true String s6 new String(ab) c; // 运行时才拼接s6 s1 为 false另外equals只接受String类型参数想拿一个StringBuilder和String比较内容equals会返回false。这时候可以用contentEquals(CharSequence cs)它能和StringBuilder、StringBuffer等CharSequence实现类比较内容。这个API平时用得少但在某些框架接口返回值是CharSequence的场景下非常实用。1.3 intern()的适用边界intern()的作用是把一个字符串加入常量池并返回常量池里的引用。JDK7之后有个变化如果常量池里不存在该字符串JVM不会复制一份字符数组进常量池而是直接记录堆中这个String对象的引用。也就是说intern之后常量池里存的可能就是一个指向堆内对象的指针。真正能用上intern的场景是内存里有大量内容重复的字符串对象。比如大批量解析Excel、CSV每行都有相同的字段名或者日志统计时反复出现相同的状态值。这种场景下用intern可以把成千上万份相同内容的String收敛成少数几个对象内存收益是可观的。但intern不是免费的。每次调用都要做哈希查找有CPU开销字符串种类如果很多StringTable会被撑大哈希冲突概率上升反而拖慢所有intern和字面量查询。我的做法是先评估重复度只有确认内容种类有限、重复率极高时才考虑。绝大多数业务代码根本轮不到靠它优化别为了炫技引入不确定的性能波动。只要记住一条日常开发比较字符串内容一律用equals只在判断是否是同一个引用、或者明确知道两边都来自常量池时才可以用。intern能不用就不用。2. 高频方法速查按业务场景分类比死记API更有效2.1 判空与内容比较从isEmpty到contentEquals流程校验、入参判断最常用的就是判空和比较。下面这张表把相关方法归在一起方法说明返回isEmpty()判断字符串长度是否为0 返回falsebooleanisBlank()JDK11是否为空或仅含空白字符空白包括空格、\t、\n、全角空格等booleanequals(Object)内容相等比较参数为null时返回falsebooleanequalsIgnoreCase(String)忽略大小写比较booleancompareTo(String)按Unicode码点字典序比较相等返回0小于返回负数大于返回正数intcompareToIgnoreCase(String)忽略大小写的字典序比较intcontentEquals(CharSequence)与任意CharSequence实现类比较内容booleanregionMatches(...)比较两个字符串的指定区间boolean几个实际使用建议isEmpty()判空前必须确认对象不是null。没初始化和空串是两码事null调用任何方法都会抛NullPointerException。所以判空要么先写str ! null !str.isEmpty()要么直接用工具类比如commons-lang3的StringUtils.isEmpty()它同时处理null和长度为0两种情况。isBlank()比isEmpty()更符合业务直觉。用户留空一个输入框传过来的可能是一串空格这时候isEmpty拦不住isBlank能拦住。JDK版本如果不是11可以用StringUtils.isBlank()替代。compareTo返回值只保证正负零不保证具体数值。判断是否相等永远用equals不要用compareTo(x) 0这种绕弯子的写法虽然结果一样但语义不清晰代码评审时会被同事吐槽。regionMatches能指定两个字符串各自的起始位置和比较长度可以带boolean忽略大小写。比如比较一段URL的路径部分、比较文件名后缀时如果只需要检查局部内容它比先substring再equals更高效也少一次字符串创建。2.2 查找定位indexOf、contains、startsWith/endsWith查找类方法在解析日志、提取信息时特别常用归个类方法说明返回charAt(int index)返回指定索引处的字符charindexOf(String/char)第一次出现的位置找不到返回-1intindexOf(String/char, int fromIndex)从指定索引开始往后找intlastIndexOf(String/char)最后一次出现的位置intcontains(CharSequence s)是否包含指定子序列底层就是indexOf(s) -1booleanstartsWith(String prefix)是否以指定前缀开头booleanstartsWith(String prefix, int toffset)从指定偏移位置开始判断booleanendsWith(String suffix)是否以指定后缀结尾boolean两个容易被忽略的细节indexOf()返回0。因为空串被认为存在于任意字符串的起始位置。如果你拿indexOf的结果做后续substring要注意这个边界比如str.indexOf() 1可能不是你想象的位置。charAt越界会直接抛StringIndexOutOfBoundsException取出字符前如果索引值来自外部输入先判断index 0 index str.length()再取。startsWith(String prefix, int toffset)这个带偏移量的版本见过的人不多。它可以直接从中间某个位置判断是否以某个前缀开头不需要先substring。比如判断一段文本从第5个字符开始是否是一个日期标记用它最干净。2.3 截取与拆分的边界行为substring(int beginIndex)和substring(int beginIndex, int endIndex)是截取的主力区间是左闭右开。hello.substring(1, 3)得到el不是ell。索引越界会抛StringIndexOutOfBoundsException。这里有个非常经典的历史坑JDK 7 Update 6之前的substring实现会复用原字符串内部的char[]只是记录了不同的偏移量。如果你用一个大字符串截取了一小段这个小字符串会把整个大char[]牢牢拽住GC无法回收极易引发内存泄漏。当年解析大文本、大日志时这个坑踩的人不在少数。JDK7u6之后改成了复制新数组空间换时间这个问题才算解决。如果你还在维护十年前的JDK版本处理大字符串截取一定要警惕。split的坑更多单独拎出来说。split有两个重载split(String regex)和split(String regex, int limit)。第二参数的limit直接决定空字符串的去留三个区间分别对应三种行为limit值行为示例按,切分a,b,limit 0最多应用limit-1次分割最后一段保留剩余内容split(,, 2)→[a, b,]limit 0默认应用全部分割但丢弃尾部空字符串split(,)→[a, b]limit 0应用全部分割保留所有尾部空字符串split(,, -1)→[a, b, ]三个关键结论直接记split参数是正则表达式。想用英文句点切分写,的人我见过太多了正确写法是str.split(\\.)。竖线要写成\\|星号要写成\\*反斜杠本身要写成\\\\。这些特殊字符的转义大概是String相关最高频的低级错误。默认split会丢弃尾部空字符串a,b,切完只有两个元素。如果你需要保留字段位次用负limit。空字符串调用.split(,)返回的数组内容是[]长度是1不是0。这个也容易误解。2.4 替换、去空白、大小写语义差异决定正确用法替换类方法看起来像一家子语义差得很远方法第一个参数行为replace(char oldChar, char newChar)字符替换所有出现的该字符replace(CharSequence target, CharSequence replacement)字面量把所有目标子串替换成新子串不是正则replaceAll(String regex, String replacement)正则按正则匹配替换所有replaceFirst(String regex, String replacement)正则只替换第一个匹配项最常见的坑集中在replaceAll上。第一个第一个参数是正则你想把字符串里的句点替换成下划线写replaceAll(., _)会把所有字符都替换成下划线因为正则里句点匹配任意字符。正确写法是replaceAll(\\., _)或者干脆用replace(., _)后者是字面替换不需要转义。第二个replaceAll第二参数里的$和\有特殊语义。你从外部读了一段文本想拼进模板结果替换结果出现$1之类的异常内容十有八九是触发了分组引用。如果确实要原样替换用Matcher.quoteReplacement(replacement)包一层或者改用replace()。去空白方法也有一对容易混的trim()和strip()。trim()判断标准是字符的Unicode码值是否小于等于\u0020空格所以它去掉的是ASCII空格、制表符、换行符但全角空格\u3000它删不掉。strip()JDK11用的是Character.isWhitespace判断按Unicode空白标准处理全角空格也在范围内。新项目直接拥抱strip别再抱着trim不放了。大小写转换有个环境相关的坑toLowerCase()和toUpperCase()无参版本使用的是系统默认Locale。在土耳其语环境下I.toLowerCase()会变成带点的ı这是经典的土耳其问题。跨环境部署时字符串大小写处理建议显式传Locale.ROOT或Locale.ENGLISH。拼接和格式化再补几个concat(String)参数为null直接抛NullPointerException用拼接时null会被转成字符串null。这是两者最大的语义差异。String.join(delimiter, elements)JDK8适合用分隔符拼接集合元素不用手动处理首尾多余分隔符。repeat(int count)JDK11生成重复n次的字符串负载均衡里生成定长分隔线挺好用。String.format(format, args)注意%号转义要用%%%n代表平台换行符。3. 字符串类型转换的重灾区与真实报错3.1 toCharArray与valueOf的重载陷阱str.toCharArray()复制一份底层字符数组出来修改返回的数组不会影响原字符串。这是很多人忽略的安全设计——你拿到的是副本不是内部引用。String.valueOf(char[] data)是静态方法把字符数组转成字符串。但这里藏了一个重载决议的坑String.valueOf(null);这段代码编译能过运行却会抛NullPointerException。原因null同时可以匹配valueOf(Object)和valueOf(char[])编译器会选择更具体的char[]版本进去访问数组长度时直接NPE。而String.valueOf((Object) null)会返回字符串null。类似的坑还有char[] arr {a, b}; System.out.println(arr); // 打印出 ab因为PrintWriter有println(char[])重载 System.out.println(data: arr); // 打印出地址字符串拼接走valueOf(Object)同一个数组直接打印和拼进字符串结果完全不一样。新人调试时看到这个现象常常一脸懵本质就是重载决议在起作用。3.2 getBytes与编码问题getBytes()无参版本使用平台默认字符集。Windows上往往是GBKLinux上往往是UTF-8。同一套代码换环境部署字符串转字节数组的结果就变了。凡是涉及跨环境传输、文件读写、加密签名一定要显式指定编码。正确的姿势byte[] bytes str.getBytes(StandardCharsets.UTF_8); String text new String(bytes, StandardCharsets.UTF_8);为什么推荐传StandardCharsets.UTF_8而不是字符串UTF-8因为getBytes(String charsetName)需要捕获UnsupportedEncodingException检查异常而传Charset对象没有这个负担代码更干净。这个坑在签名验签场景尤其常见。同一段字符串一端用UTF-8转字节签名另一端用平台默认编码转字节验签两边的字节数组都对不上签名必定失败。排查起来又是看配置又是看日志最后发现只是编码没写死。3.3 数值与String互转数字转字符串三兄弟String a String.valueOf(123); // 123 String b Integer.toString(123); // 123 String c 123; // 123底层调用valueOf字符串转数字int i Integer.parseInt(123); // 返回基本类型int Integer j Integer.valueOf(123); // 返回包装类型Integer各包装类型的parse方法共通的坑是字符串不是合法数字时抛NumberFormatException。判断字符串是否为数字不要自己想当然123好说负数、小数、科学计数法、前面带正号各种边界都要考虑。如果你写正则\d去判断负数和浮点数直接就给你拒了。这种需求优先考虑使用成熟的工具方法比如commons-lang3的NumberUtils.isCreatable()或者hutool的NumberUtil.isNumber()比自己抠边界稳得多。3.4 从Excel单元格和JSON日期反序列化看字符串互转两个我在真实项目里处理过的报错都和字符串互转有关。第一个Cannot get a STRING value from a ERROR cell。来自Apache POI读取Excel。用cell.getStringCellValue()之前没有判断单元格类型单元格实际是错误类型或数字类型就会抛这个异常。正确的处理方式是先判断类型再取值Cell cell row.getCell(index); switch (cell.getCellType()) { case STRING - value cell.getStringCellValue(); case NUMERIC - value String.valueOf(cell.getNumericCellValue()); case BOOLEAN - value String.valueOf(cell.getBooleanCellValue()); case ERROR - value String.valueOf(cell.getErrorCellValue()); default - value ; }把任意单元格安全地转成字符串值是个很值得封装的工具方法。大数据量导入时有一行单元格类型异常就直接让整个任务中断非常影响体验。第二个Cannot deserialize value of type java.util.Date from string 2026-09。Jackson反序列化JSON时字符串转Date失败。原因通常是JSON里的日期字符串格式和Jackson期望的格式不一致比如只传了2026-09表示年月Jackson默认却期望完整时间戳。解决方式字段只包含年月要么用YearMonth这种更精确的类型承接要么在字段上加JsonFormat(pattern yyyy-MM)。处理Java 8时间类型时还要注意引入JavaTimeModule并关闭WRITE_DATES_AS_TIMESTAMPS之类的配置。这两个报错看起来和String方法没有直接关系本质都是字符串与其他类型互转时的边界问题。面试时能把这类真实报错讲清楚比背二十个方法名有说服力得多。4. 正则相关方法matches、split、replaceAll的隐藏行为4.1 三个方法的使用边界String类里直接支持正则的方法一共就这几个matches(String regex)整个字符串必须完全匹配返回boolean。底层是Pattern.matches(regex, this)等价于matcher.matches()。split(String regex)按正则切分参数是正则表达式。replaceAll(String regex, String replacement)/replaceFirst(String regex, String replacement)按正则替换。注意matches和Pattern里的find()语义不同matches要求整个字符串完全匹配正则find是查找子串是否存在。很多人拿str.matches(\\d)校验字符串是不是纯数字这个没问题因为matches是整串匹配但如果正则本身只是匹配一部分比如str.matches(123)那字符串123456就返回false因为它不是整体等于123。理解这个整体匹配语义能少踩很多坑。这三个方法的共同痛点每次调用都会重新编译一次Pattern。单次调用无所谓但在循环里对大量字符串做匹配或替换开销会被明显放大。4.2 高频正则套路示例实际开发里用正则处理字符串有几个高频需求可以直接抄提取一段文本里的所有数字String text 订单号AB20260101金额188.5元; String digits text.replaceAll(\\D, ); // 202601011885 String amount text.replaceAll(.*?(\\d\\.\\d).*, $1); // 188.5按中英文逗号混合拆分String line 北京,上海广州,深圳; String[] cities line.split([,]);按任意空白拆分String words hello world java; String[] parts words.split(\\s);判断字符串是否由纯数字组成boolean isNumeric str.matches(-?\\d(\\.\\d)?);提取URL中的域名String domain url.replaceAll(.*?://([^/]).*, $1);这些片段看着简单实际都是从生产代码里抽出来的。正则这种东西不需要背但一定要会查、会组装更要会转义。4.3 预编译Pattern与性能优化如果同一个正则要复用多次用Pattern.compile预编译再通过matcher去匹配性能差距在数据量大时非常可观Pattern pattern Pattern.compile(\\d); Matcher matcher pattern.matcher(text); while (matcher.find()) { // ... }String.replaceAll每次内部都会调用Pattern.compile(regex)这在日志清洗、大表字段校验这类场景下是不必要的开销。同样的正则在循环外预编译一次循环里只做匹配操作性能提升是数量级的。另外如果你只需要判断是否包含某个普通子串用contains()别用正则。正则引擎做的是模式匹配比简单的字符查找重得多。能用普通字符串方法解决的场景没必要上正则。5. StringBuilder与StringBuffer性能差异与选型判断5.1 底层实现决定并发行为String不可变StringBuilder和StringBuffer都继承AbstractStringBuilder底层是可变的字符数组所有拼接修改都在原数组上进行不会反复创建新对象。StringBuffer和StringBuilder的API几乎一模一样差别在于StringBuffer的公共方法用synchronized修饰是线程安全的。代价是每次调用都要经历加锁、同步、释放锁的过程单线程环境下这部分开销纯属浪费。StringBuilder没有同步机制单线程下性能更好。实际开发建议方法内部的局部变量拼接直接上StringBuilder如果要把拼接后的对象传给其他线程先toString()转成不可变的String再传递。这比让一堆线程共享同一个StringBuffer更符合Java的并发习惯——共享可变对象本身就是高风险设计靠StringBuffer的锁来解决不如从根上消除共享。5.2 字符串拼接方式对比不同拼接方式的性能差异非常大场景推荐方式原因编译期可确定的字面量拼接a b c编译期已折叠成abc零运行时开销少量运行时变量拼接或concat()代码可读性高性能差异可忽略循环内大量拼接StringBuilder避免反复创建中间String对象用分隔符合并集合String.join()语义清晰不必手写循环拼接看一个反面教材String result ; for (int i 0; i 10000; i) { result i ,; }这个写法每次都会创建一个新的StringBuilder、一个String中间对象循环一万次就创建上万个对象。换成StringBuilder之后内存分配和GC压力下降几个量级。这段代码在代码评审里是我必圈出来的问题。注意一点号拼接在单条语句里是没问题的javac会把它翻译成StringBuilder的append链。问题只出在循环体内反复拼接。5.3 常用API与容量初始化StringBuilder/StringBuffer常用API方法说明append(String/char/int/...)追加内容返回this支持链式调用insert(int offset, String)在指定位置插入delete(int start, int end)删除区间内容左闭右开deleteCharAt(int index)删除指定位置字符reverse()反转字符串replace(int start, int end, String)替换区间内容setCharAt(int index, char ch)修改指定位置字符toString()转换为String字符串转StringBuilder用构造器new StringBuilder(str)StringBuilder转String就是toString()。经常有人问StringBuffer怎么转String答案就是这一个方法调用。容量初始化值得多说一句StringBuilder默认底层数组容量是16超过之后会扩容扩容策略是原容量*22涉及数组复制。如果提前知道拼接结果的大概长度直接指定初始容量能省掉多次扩容的数组复制开销// 预估结果长度约2000 StringBuilder sb new StringBuilder(2000);这在拼接SQL、拼JSON、拼大批量报文时效果明显。我做过简单对比初始化容量合适的StringBuilder和不指定容量的在拼接几万次时性能差距能到百分之几十。6. 面试高频点、代码Review与我的踩坑复盘6.1 高频面试题的答题要点把String相关的高频面试题盘点一遍每道题的答题要点列出来String、StringBuilder、StringBuffer的区别String不可变另外两个可变StringBuffer线程安全方法级synchronizedStringBuilder线程不安全但性能更好三者底层都是字符数组存储String的数组是final的另两个会扩容。和equals的区别比较引用地址equals比较内容。String重写了equals比较的是字符内容。配合常量池abc abc为truenew String(abc) abc为false。new String(abc)创建了几个对象常量池没有abc时创建两个常量池有则只创建一个。这个回答能体现对常量池的理解。String为什么设计为不可变线程安全、哈希缓存、常量池复用三件套前面讲过。substring在旧版本JDK中的内存泄漏JDK7u6之前substring共享原数组大字符串截取小片段会导致GC无法回收大数组。这是冷门题但能答出来说明真有深度。switch支持String的底层原理javac会将String转换为hashCode匹配加equals确认所以switch一个null字符串会抛NullPointerException。这也从侧面说明了hashCode缓存的意义。循环中用拼接为什么慢每次迭代都创建StringBuilder和String中间对象造成大量无用内存分配和GC压力。这些题基本覆盖了String的面试考察面。准备面试时可以把每道题当成一个小专题去讲不要只背结论。6.2 代码Review时的String自查清单我Review代码时遇到String的改动会下意识地过一遍这些检查点判空先判null再判isEmpty/isBlank或者直接用StringUtils这种工具类。比较内容比较一律equals不写忽略大小写用equalsIgnoreCase。替换字面替换用replace正则替换用replaceAll别把两个混了。截取substring左闭右开用之前确认索引不越界。拆分split参数是正则特殊字符要转义尾部空串需要保留时用负limit。拼接循环内禁止用统一StringBuilder能指定容量就指定。编码getBytes/new String显式指定UTF-8不依赖平台默认编码。大字符串字符串替换、拼接频繁时考虑是否可以用StringBuilder/StringBuffer。6.3 一次模板替换的线上复盘最后分享一个我自己的真实踩坑经历。之前在做一个模板渲染功能需要把模板里的{{date}}占位符替换成当天日期同时模板里还有大量$符号是业务数据的一部分。第一版我图省事直接用了String result template.replaceAll(\\{\\{date\\}\\}, dateStr);本地测试一切正常一到线上就出问题。排查发现dateStr里包含了$字符——某天的日期格式带上了货币符号相关的业务前缀。replaceAll的第二个参数里$是有特殊含义的它代表分组引用结果替换出来的内容完全错乱。后来改成String result template.replace({{date}}, dateStr);用replace做字面替换问题瞬间消失。如果必须用replaceAll也需要先用Matcher.quoteReplacement(dateStr)把替换串包一层把$和\都转成字面量。这个坑很小但线上出问题往往就是这类看似没问题的API使用。替换需求里如果替换内容是外部传入的、不可信的优先用replace能避掉一大半坑。这些年我的体会是String相关的问题很少是不知道API更多是以为知道但不知道边界。上面这些方法和坑大部分我都曾在代码评审、线上排查或者面试场景里遇到过。如果你在准备面试建议把第1章和第5章的内容讲清楚如果主要写业务代码重点关注第2章的split和replaceAll、第3章的编码和类型转换、第5章的循环拼接。整理这份笔记花了不少时间但每次回看都有收获希望对你也有帮助。
返回列表