1. 先搞清楚包含这个词在不同场景下的含义1.1 从String的存储结构看判断的本质很多人在写Java字符串包含判断时脑子里只有一个方法str.contains(关键字)写完就以为万事大吉。可一旦代码上了生产环境你会发现包含这个词背后藏着好几种完全不同语义的需求用错一个API轻则判断失效重则线上误告警或者把非法请求放进来。先从底层聊起。Java里的String对象从JDK 9开始底层结构变成了byte[]加一个coder标记也就是所谓的Compact Strings。纯ASCII字符用1个字节存非ASCII字符用2个字节。这个细节大多数人写业务代码时完全无感但在做包含判断时它决定了一个方法到底会发生多少次内存访问。一个字符串包不包含另一段字符序列本质就是问在主串的连续位置上能不能找到一段与目标子串完全一致的片段。这里有两个关键词连续和完全一致。连续意味着abc.contains(ac)永远是false哪怕两个字符都出现过完全一致意味着默认区分大小写Java和java是两个完全不同的东西。搞清楚这个本质你就能理解为什么String类提供了indexOf、contains、startsWith、endsWith、regionMatches、matches这么一大套方法。它们不是重复造轮子而是各自对应不同的包含判定规则。1.2 真实业务里的四种包含诉求我带过的项目里凡是字符串判断写得很乱的地方基本都是因为没有先搞清楚需求属于哪一类。实际场景中的包含诉求归纳起来就四种。第一种叫精确子串包含。典型场景是日志关键字告警线上日志里出现OutOfMemoryError就触发告警这时候要求的就是原样匹配大小写敏感反而更符合预期。第二种叫忽略大小写的包含。白名单配置、协议开关这类用户可能输入on也可能输入ON的场景必须忽略大小写否则就会漏判。第三种是前缀或后缀匹配。文件上传接口限定扩展名只允许.jpg、.png路由规则里判断路径是否以/api/开头。这类需求如果笼统用contains做myjpgdata.txt这种中间位置出现jpg的文件名也会被放进来属于典型的看似能用、实则埋雷。第四种是模式匹配。判断字符串里是否包含数字、是否包含任意一个敏感词、是否符合某种格式。这种已经脱离了找子串的范畴属于正则的领域。需求推荐API注意事项精确子串是否出现contains/indexOf区分大小写忽略大小写是否出现regionMatches/StringUtils.containsIgnoreCase慎用toLowerCase前缀 / 后缀startsWith/endsWith不要用contains替代模式匹配Patternfind()不要用matches()做包含判断下面逐个展开顺便把我在真实项目里踩过的坑一起说清楚。2. indexOf与contains最基础也最容易用错的组合2.1 contains的源码为什么是indexOf 0打开String类的源码contains方法朴素得让人意外public boolean contains(CharSequence s) { return indexOf(s.toString()) 0; }它做的事就是把参数转成String然后交给indexOf最后把返回值跟0比较一下。换句话说contains只是indexOf的一个布尔包装它不额外做任何匹配优化也不负责忽略大小写。所以当你只需要有没有这个结论时用contains当你还想知道在哪个位置进行下一步处理时就必须用indexOf拿下标。举个实际场景从一段HTML源码里提取所有图片链接你需要不断调imgTag.indexOf(src, lastIndex)来推进游标这活儿contains干不了。这里有一个非常容易踩的坑contains的入参类型是CharSequence不是String。你把StringBuilder传进去方法内部调s.toString()如果它当前内容恰好包含目标文本没问题但如果你把StringBuilder当成可变化的字符串容器指望它后续追加内容后自动参与匹配那就大错特错了。StringBuilder不会动态参与任何字符串运算它只是一个缓冲容器。2.2 indexOf的签名与fromIndex的实战价值indexOf家族一共有六个常用签名int indexOf(int ch) int indexOf(int ch, int fromIndex) int indexOf(String str) int indexOf(String str, int fromIndex) int lastIndexOf(int ch) int lastIndexOf(String str)fromIndex这个参数大概是日常使用率最低的但它是做扫描类逻辑的核心。统计一段文本里某个关键词出现多少次正确写法是循环调用indexOf每次把起始位置挪到上一次命中位置之后String text Java从入门到放弃Java再入门Java再放弃; String target Java; int count 0; int idx 0; while ((idx text.indexOf(target, idx)) ! -1) { count; idx target.length(); }为什么这里要idx target.length()而不是idx因为连续匹配场景下比如aaaaa里找aa如果每次只加1会把重叠部分算成第二次命中统计出4次。业务上通常只关心不重叠的独立出现次数所以要直接跳到目标长度之后。这个语义差异写单元测试的时候最容易漏。还有一个细节很多人不知道indexOf(String)传空字符串时返回0lastIndexOf(String)传空串返回字符串长度。这是JDK的既定行为。如果你在业务里依赖包含空串的判定结果永远是true而这个true往往不是你想要的结果空关键字不比直接短路返回。2.3 大小写处理别一上来就toLowerCase我见过太多代码这么写if (value.toLowerCase().contains(error)) { // ... }这段代码有两个问题。第一个是性能toLowerCase()会为value创建一整个新的字符串对象原来的value还在内存里高频路径上这种写法会给GC制造不小的压力。第二个是国际化不带Locale参数的toLowerCase()行为取决于JVM默认区域在土耳其语环境下字母I的小写形式不是i而是带点的ı经典的locale问题就是这么来的。如果一定要用转换大小写再比较的方案务必带上Locale.ROOT。更稳妥的忽略大小写方案是后面要讲的regionMatches。如果业务只在ASCII范围内也可以自己写一个逐字符的equalsIgnoreCase式扫描性能远好于创建两个新字符串再去contains。3. 正则匹配matches、find与Pattern的边界3.1 matches是全匹配contains是子串匹配很多新手会把String.matches当成包含匹配来用这是最典型的误区之一。matches的语义是整个字符串是否完全匹配正则只要字符串里还有多余字符它就会返回false。订单号20240115A.matches(\\d) // false因为字符串里还有订单号三个字想判断字符串里是否包含数字正确姿势是Pattern DIGIT Pattern.compile(\\d); boolean hasDigit DIGIT.matcher(text).find();find()才是在输入序列中查找下一个匹配子串对应的是包含语义。记住这条对应关系matches 整串匹配find 包含匹配lookingAt 前缀匹配。三个方法分别对应三种不同的判断需求日常用得最多的是前两个。3.2 正则表达式别在循环里频繁编译Pattern.compile是个重操作它要解析语法、构建内部状态机。如果每个请求都创建一个新Pattern在高并发接口里就是明显的性能浪费。正确做法是把正则编译成static final常量或者维护一个缓存Map。private static final Pattern HAS_IP Pattern.compile(\\d{1,3}(?:\\.\\d{1,3}){3});还有一个容易被忽略的风险用户可控的输入拼进正则表达式可能引发灾难性回溯。正则引擎在匹配失败时会把所有可能的分支都尝试一遍某些写法在特定输入下回溯次数会呈指数级增长。在自己的系统里做敏感词过滤如果允许用户配置正则规则一定要做白名单约束或者控制单次匹配时长。更实用的原则是indexOf能解决的判断就不要用正则。正则的灵活是有代价的这一点在批量文本处理里体现得特别明显。4. startsWith、endsWith与regionMatches前后缀与精准区间4.1 前后缀判断为什么不能用contains文件扩展名校验是最典型的例子。判断文件名是否以.jpg结尾用contains(jpg)会把myjpgdata.txt也放进来因为这串文本中间就包含jpg但它的后缀根本不是.jpg。正确的API是endsWithboolean isJpg filename.endsWith(.jpg);前缀判断同理路径路由用startsWith(/api/)而不是contains(/api/)。因为contains会把路径中间任何位置出现的/api/都算进去线上因为这种写法把不该放行的请求放进来过事故级别还不低。startsWith还有一个带偏移量的重载startsWith(String prefix, int toffset)意思是从主串的toffset位置起是否以prefix开头。这个重载在做分段解析时很好用比如按分隔符切完一段后直接判断下一段是否是指定关键字。4.2 regionMatches忽略大小写的最佳方案如果你要比较两个字符串的某个特定区域并且需要忽略大小写regionMatches是最值得用的API。签名是boolean regionMatches(boolean ignoreCase, int toffset, String other, int ooffset, int len)参数从左到右依次是是否忽略大小写、主串起始下标、比较对象、对象起始下标、比较长度。看一个实际用法。校验上传文件扩展名要求大小写不敏感boolean isImage(String name, String ext) { int idx name.length() - ext.length(); return idx 0 name.regionMatches(true, idx, ext, 0, ext.length()); }这个方法不会创建任何临时字符串内部直接逐字符比较遇到大小写差异时做字符级转换再判断。它在忽略大小写的固定区域比较场景下比toLowerCase后contains快得多也没有locale问题。唯一的限制是它只解决固定区域的比较没法直接回答整串包含某个子串且忽略大小写的问题。真要这个能力要么用第三方的containsIgnoreCase要么手写一个扫描循环。手写扫描的思路其实不复杂外层循环遍历主串每个可能的起始位置内层循环逐字符比较目标串大小写不敏感时用字符级转换去判断。这种朴素匹配的复杂度是O(n*m)字符串短的时候完全够用。子串很长且数据量很大时可以考虑KMP这类线性算法但Java自带的indexOf针对短串做了大量优化常规业务里自己写KMP往往收益有限反而容易引入边界bug。这里说明一下这段属于我的个人实践总结不是JDK文档的推荐具体场景具体取舍。5. 第三方面包与性能实测别什么都自己写5.1 Apache Commons Lang与Guava的主要方法几乎每个Java项目里都能看到commons-lang3和Guava的影子。commons-lang3的StringUtils提供了一整套成熟的包含判断工具日常最常用的是StringUtils.containsIgnoreCase(order, ORDER) // true StringUtils.containsAny(abc123, 1, 2, 3) // true StringUtils.indexOfAny(abc123, abc, 123) // 0containsIgnoreCase内部实现是有讲究的它会先尝试用indexOf快速判断找不到再退化到逐字符区域匹配比先toLowerCase再contains省内存也比手写循环更稳。containsAny接收可变参数用于判断字符串里是否包含任意一个指定字符这种需求自己写循环也能实现但工具方法可读性更好。Guava这边常用的是CharMatcher系列。判断字符串里是否包含数字boolean hasDigit CharMatcher.inRange(0, 9).matchesAnyOf(text);引入依赖本身也有成本如果项目里只是偶尔用一两个方法自己封装个小工具类就够了但如果字符串处理逻辑密集commons-lang3几乎是标配不建议为了少一个依赖而手搓一堆边界条件不全的方法。工具类之所以存在是因为边界情况和性能细节已经被成熟团队验证过了。5.2 各种实现方式的实际性能画像字符串包含判断通常是高频路径值得做一次粗略压测。我拿实际代码比较过几类方案String.contains、预编译Pattern的find、StringUtils.containsIgnoreCase以及手写的逐字符朴素匹配。结论在长字符串上差距明显预编译Pattern.find在复杂正则下最慢因为正则引擎要维护自动机状态contains和朴素匹配接近JDK的indexOf对字符操作做了不少硬件级优化containsIgnoreCase因为要做忽略大小写的区域比较比contains大概慢一倍左右。但这些差距落在单次几微秒的尺度上对大多数Web业务来说完全可以忽略。真正的性能杀手不是方法之间的差距而是循环里反复创建新字符串对象。比如拿100个关键词逐个调用toLowerCase每次循环都是新对象分配。遇到这种场景先把关键词列表预处理成统一形式再直接走contains收益远大于纠结用哪个API。6. 实战场上最容易翻车的五个细节6.1 null和空字符串的前置防御abc.contains(null)会直接抛NullPointerException这是很多线上问题的根源。调用链越深越难定位A方法把对象引用传给BB在判断前忘了判空等到C方法执行contains时NPE已经离真正的空值来源隔了好几层。空字符串又是另一回事。abc.contains()返回true因为任何字符串都包含空子串。有些业务本意是用户输入了空关键字就不该匹配任何内容那就必须显式处理isEmpty或isBlank不能把空串直接传进contains。我的习惯是封装一个统一工具方法public static boolean containsText(String text, String keyword) { if (text null || keyword null || keyword.isEmpty()) { return false; } return text.contains(keyword); }这一层封装把null一律不匹配空关键字一律不匹配的约定固化下来避免每个调用点各写各的判断。项目里多个模块都在做字符串判断时统一走一个工具类排查NPE能省下大量时间。6.2 字符串比较别误用包含判断本身返回的是boolean不太会犯的错但经常有人在写判断扩展名判断关键字的时候误用比如if (suffix jpg)。字符串值比较必须用equals需要忽略大小写用equalsIgnoreCase。这个知识点大家都清楚但代码审查时几乎每次都能看到。我的经验是凡是表示值的字符串比较一律写成equals如果确实需要比引用不但要注释原因还要确保逻辑能自圆其说。6.3 流式集合过滤的正确姿势Java 8之后集合过滤经常这么写ListString matched list.stream() .filter(s - s ! null s.contains(error)) .collect(Collectors.toList());这里有两个注意点。第一个s ! null必须放在contains前面stream里只要有一个null元素整个管道就会崩掉。第二个如果一个条件里既有包含判断又有正则判断正则务必用预编译Pattern不要用String.matches()这种全匹配方法。流式写法看起来简洁但调试成本偏高条件复杂时我更推荐普通for循环加注释代码意图一目了然。6.4 拼接后再判断的隐藏开销有些代码为了省事把几个字段拼起来再判断if ((orderId customerName remark).contains(异常)) { // ... }这段代码会生成至少一个中间字符串对象看似没毛病但如果出现在循环里内存分配量就很可观。更严重的是拼接把不同字段的边界抹掉了某些边界情况会误匹配。比如orderId是100异常001customerName是张三你本意是看remark里有没有异常结果orderId里的异常也被判定进来了。正确做法是逐字段判断任意字段命中即可boolean hit orderId.contains(异常) || customerName.contains(异常) || remark.contains(异常);既避免多余对象创建又消除了跨字段误匹配逻辑边界清清楚楚。6.5 日志埋点里的toString延迟成本最后一类翻车场景在日志系统里。slf4j的占位符{}是延迟求值的只要日志级别没开启对应的字符串拼接根本不会执行。但如果你把包含判断的结果直接写进日志参数这个优势就没了log.info(text {} contains {}, text, text.contains(error));无论日志级别是否开启text.contains(error)都会先执行一遍。在日志特别稠密的系统里这种无谓开销叠加起来很可观。更合理的做法是先用log.isInfoEnabled()判断级别或者把包含结果作为业务变量传入。同理如果为了做判断而把整个大对象toString()出来再去contains相当于把整个对象的字符串表示全量留在堆里GC压力远大于判断本身。最后再说一点个人习惯。我现在写任何字符串包含判断之前都会先问自己三个问题这个判断区分大小写吗匹配位置有要求吗输入可能为null吗三个问题答清楚了选哪个API基本都是顺理成章的事。这个习惯帮我避开了不少线上事故也推荐你试试。