1. 内容整体设计与思路拆解
1.1 为什么单独写“字符串包含判断”这个主题
先聊个常见的场景:你去面试,面试官随手写一行str.contains("abc"),问你这行代码背后发生了什么,能不能换成别的方法实现,各自的区别是什么。很多干了三五年的Java工程师,平时用IDE自动补全用惯了,真被问到这一层反而会卡壳。字符串包含判断看起来是基础中的基础,但它牵扯出的东西一点都不基础:编码问题、正则、性能、空指针、底层实现,每一层都能挖出坑来。
我平时在HoRain云上折腾微服务和中间件的时候,日志分析、参数校验、路由匹配这些场景里,字符串包含判断几乎天天都要用。很多线上问题,最后排查下来,根因就是当初写判断时选错了方法。所以这篇内容我不打算只列API,而是把“判断字符串包含”这件事从需求场景到实现方案、从性能对比到踩坑实录,完整拆一遍。无论你是刚学Java的新手,还是写了几年想补充细节的老手,都能从里面找到对你有价值的东西。
1.2 包含判断的常见场景分类
在动手写代码之前,先想清楚一件事:你写“包含判断”的最终目的是什么?根据我接触过的项目,大致分这么几类:
- 是否存在判断:只要知道目标字符串里有没有某个子串,不关心位置。比如判断用户输入的邮箱是否包含“@”。
- 位置定位:不光要知道有没有,还要拿到子串出现的位置。比如截取某个标记后面的内容。
- 多次出现统计:统计某个关键词在文本里出现几次。比如分析日志中某个错误码出现的次数。
- 条件过滤与校验:在批量数据里筛选符合条件的记录。比如从订单列表中找出所有包含“退款”字样的订单。
- 安全性校验:检查输入是否包含恶意字符、特殊符号或SQL关键字。这里通常需要结合白名单/黑名单机制来判断。
不同场景对方法的选择要求完全不一样。比如只是“有没有”这种简单场景,用contains就够了;但如果需要忽略大小写,或者要匹配模糊模式,就得用matches或者正则。这次我们就把这些场景串起来,逐个击破。
2. 核心细节解析与实操要点
2.1 Java字符串包含判断的常规手段
Java里做字符串包含判断,最常用的无非这几种方法:
| 方法 | 作用 | 返回值 | 使用场景 |
|---|---|---|---|
str.contains(CharSequence s) | 判断是否包含指定字符序列 | boolean | 简单包含判断 |
str.indexOf(String s) | 返回子串首次出现的索引,没有则返回-1 | int | 需要确定位置时 |
str.lastIndexOf(String s) | 返回子串最后一次出现的索引 | int | 需要确定最后一次位置时 |
str.startsWith(String prefix) | 判断是否以指定前缀开头 | boolean | 前缀匹配 |
str.endsWith(String suffix) | 判断是否以指定后缀结尾 | boolean | 后缀匹配 |
str.matches(String regex) | 判断整个字符串是否匹配正则表达式 | boolean | 正则匹配,注意是整个字符串匹配 |
Pattern.compile(regex).matcher(str).find() | 判断字符串是否包含符合正则的子串 | boolean | 复杂的子串匹配 |
这里需要强调一个很容易踩的细节:contains内部是调用indexOf(s) >= 0来实现的。也就是说,contains本质上是indexOf的一个语法糖。从源码角度看,String.contains在JDK 8中的实现是这样的:
public boolean contains(CharSequence s) { return indexOf(s.toString()) > -1; }所以当你用contains时,底层其实是在做一次字符串查找,时间复杂度是 O(n*m),其中n是原字符串长度,m是子串长度。JDK里的indexOf针对不同长度做了优化,对于较短的子串使用了快速算法,但本质上仍然是一次线性扫描。理解了这一点,你就能明白为什么大量高频调用时,字符串匹配会成为性能瓶颈。
2.2 contains与indexOf的选择困惑
我见过不少新手在写代码时纠结:既然contains内部调用了indexOf,那我是不是应该直接用indexOf,省去一次方法调用?
说实话,这种优化属于“伪优化”。现代JIT编译器会把这种简单的调用链内联掉,实际性能差异小到可以忽略。真正值得关注的是你后续的业务逻辑:如果你只需要知道“有没有”,用contains可读性更好;如果你还需要知道子串的位置,则必须用indexOf。相比纠结那零点几纳秒的性能,代码语义的清晰度重要得多。
还有一个常见误用:str.matches(".*abc.*")来判断包含。matches要求整个字符串匹配正则表达式,如果你写matches("abc"),那只有当整个字符串就是“abc”时才返回true,而不是包含“abc”就返回true。很多人在这里栽过跟头。正确的做法是加上.*通配符,或者干脆用find():
String url = "https://horain.cloud/login"; // 错误示范:matches要求整个字符串匹配 System.out.println(url.matches("login")); // false // 正确示范:用通配符包起来 System.out.println(url.matches(".*login.*")); // true // 更推荐:用find来查找子串 Pattern pattern = Pattern.compile("login"); Matcher matcher = pattern.matcher(url); System.out.println(matcher.find()); // true2.3 大小写不敏感的包含判断
字符串包含判断还有一个高频需求:忽略大小写。比如判断一段文本里是否包含“java”时,用户可能输入的是“Java”“JAVA”“jAvA”,希望都能命中。
String里没有自带containsIgnoreCase方法,所以通常有两种做法:
做法一:统一转小写或大写再判断。
String source = "HoRain Cloud Java 实战"; String target = "java"; boolean result = source.toLowerCase().contains(target.toLowerCase());这种做法简单直接,但有个副作用:它会额外创建新的字符串对象,如果source很大,会消耗内存。还有一个隐藏问题:某些语言(如土耳其语)的字母大小写转换规则和英语不一样,toLowerCase()在特定Locale下可能产生意外结果。所以更稳妥的方式是指定Locale,比如source.toLowerCase(Locale.ROOT)。
做法二:用正则表达式的Pattern.CASE_INSENSITIVE标志。
Pattern pattern = Pattern.compile(Pattern.quote(target), Pattern.CASE_INSENSITIVE); Matcher matcher = pattern.matcher(source); boolean result = matcher.find();这里用Pattern.quote(target)是为了把目标字符串转义成字面量,防止target里含有正则特殊字符时出现误匹配。这个细节很多人不知道,但非常关键。
从性能角度看,如果只是偶尔判断一次,两种做法差别不大;如果在一个循环里对大量数据做判断,toLowerCase每次都要复制字符串,会带来可观的GC压力。相比之下,正则方式虽然初始化Pattern有一定开销,但Pattern可以复用,在循环场景下性能更好。我的经验是:日常业务用toLowerCase足够,性能敏感场景用预编译的Pattern。
2.4 空指针与空字符串的防御
写字符串判断最容易忽略的坑就是空指针。你传一个null给contains,或者调用contains的对象本身是null,都会直接抛出NullPointerException。
String text = null; text.contains("java"); // NPE!而text.contains(null)也会触发NPE,因为contains内部会把CharSequence转成字符串再调用indexOf,在indexOf里会先判断参数是否为空,如果是null则抛NPE。
所以任何来自外部输入的字符串,在调用包含判断之前,一定要做空值校验。JDK 8之后提供了Optional,但我觉得最直白的写法还是先判空:
if (text != null && text.contains("java")) { // do something }或者用Apache Commons Lang里的工具类:
StringUtils.contains(text, "java");StringUtils.contains是null安全的:传入null不会抛异常,而是返回false。如果你的项目里已经引入了 Commons Lang3,可以直接用它,省得每次手写判空。但如果你不想引入额外依赖,自己封装一个工具方法也行:
public static boolean containsIgnoreCase(String source, String target) { if (source == null || target == null) { return false; } return source.toLowerCase(Locale.ROOT).contains(target.toLowerCase(Locale.ROOT)); }2.5 使用正则时的特殊字符转义
正则表达式是包含判断里的另一大坑。你写一个看起来正常的判断,结果匹配结果完全不符合预期,多半是目标字符串里含有正则元字符。
举个真实例子:判断URL里是否包含?id=123这样的查询串,如果直接写:
boolean result = url.contains("?id=123");这段代码用contains没问题,因为contains做的是纯字面量匹配。但如果有人图省事,改用正则:
boolean result = url.matches(".*?id=123.*");那就翻车了。?在正则里有特殊含义,表示“前面的字符出现0次或1次”,所以这个表达式匹配的是“id=123”前面有个可选的任意字符,和预期完全不符。正确写法是把特殊字符转义:
boolean result = url.matches(".*\\?id=123.*");或者更稳妥地用Pattern.quote:
boolean result = url.matches(".*" + Pattern.quote("?id=123") + ".*");Pattern.quote会把所有字符当成普通字符处理,自动完成转义。这是我在解析日志时最常用的技巧之一,强烈推荐。
3. 实操过程与核心环节实现
3.1 搭建一个简单的测试环境
为了把后面的例子完整跑起来,我们先准备一个最基础的Java项目。这里我用Maven结构,JDK版本用8以上都行,因为我们的代码没有用到特别新的API。
项目结构:
java-string-contains-demo ├── pom.xml └── src └── main └── java └── com └── horain └── demo ├── ContainsDemo.java └── StringCheckUtils.javapom.xml里只需要基础的依赖,甚至没有外部依赖也能跑。为了演示方便,我加一个JUnit做单元测试:
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>com.horain</groupId> <artifactId>java-string-contains-demo</artifactId> <version>1.0-SNAPSHOT</version> <properties> <maven.compiler.source>8</maven.compiler.source> <maven.compiler.target>8</maven.compiler.target> </properties> <dependencies> <dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter</artifactId> <version>5.9.2</version> <scope>test</scope> </dependency> </dependencies> </project>3.2 实战案例:日志关键词过滤
先说一个我在HoRain云上排查问题时遇到的真实需求:某天我们的网关服务在固定时间点出现大量超时,为了定位是哪个上游接口拖慢了整体链路,我需要从海量日志里筛出所有包含“Timeout”且包含特定接口路径“/api/order”的日志行。
这个需求如果用直觉做法,就是遍历日志文件每一行,然后做两次contains。我先把基础版本写出来:
public class ContainsDemo { public static void main(String[] args) throws IOException { String logFilePath = "/var/log/gateway/access.log"; BufferedReader reader = new BufferedReader(new FileReader(logFilePath)); String line; int count = 0; while ((line = reader.readLine()) != null) { if (line.contains("Timeout") && line.contains("/api/order")) { count++; System.out.println(line); } } reader.close(); System.out.println("匹配行数: " + count); } }这个写法能跑,但有几个问题:
- 如果日志文件很大,逐行读取并输出到控制台可能会拖慢速度,应该把结果写入文件。
- 判断逻辑直接写在main方法里,不够通用,不适合后续复用。
- 如果“Timeout”大小写不固定,这个判断会漏掉数据。
改进方向:把判断逻辑封装成一个工具方法,并支持忽略大小写。于是有了StringCheckUtils:
public class StringCheckUtils { private StringCheckUtils() { } /** * 判断源字符串是否包含目标关键词,忽略大小写 */ public static boolean containsIgnoreCase(String source, String target) { if (source == null || target == null) { return false; } return source.toLowerCase(Locale.ROOT).contains(target.toLowerCase(Locale.ROOT)); } /** * 判断源字符串是否同时包含多个关键词,全部忽略大小写 */ public static boolean containsAllIgnoreCase(String source, String... targets) { if (source == null || targets.length == 0) { return false; } for (String target : targets) { if (!containsIgnoreCase(source, target)) { return false; } } return true; } }主程序改成:
public class LogFilter { public static void main(String[] args) throws IOException { Path logFile = Paths.get("/var/log/gateway/access.log"); List<String> lines = Files.readAllLines(logFile, StandardCharsets.UTF_8); List<String> matched = lines.stream() .filter(line -> StringCheckUtils.containsAllIgnoreCase(line, "Timeout", "/api/order")) .collect(Collectors.toList()); Files.write(Paths.get("/tmp/matched.log"), matched, StandardCharsets.UTF_8); System.out.println("匹配行数: " + matched.size()); } }这里用Files.readAllLines一次性读入内存,如果日志文件是GB级别,内存会爆,所以只是演示场景。真实工作中我会用Stream逐行处理:
try (Stream<String> stream = Files.lines(logFile, StandardCharsets.UTF_8)) { stream.filter(line -> StringCheckUtils.containsAllIgnoreCase(line, "Timeout", "/api/order")) .forEach(System.out::println); }注意Files.lines返回的Stream用完后要关闭,所以放在try-with-resources里。这是我实际排查问题时的标准操作。
3.3 实战案例:URL路径匹配与路由转发
另一个高频场景是网关或者Web框架里的路由匹配。比如你写了一个简单的转发器,要求是:如果请求路径以/api开头,且不包含/admin,就把请求转发到后端服务;否则拒绝。
这个场景里的“包含”判断就涉及组合逻辑了:
public class RouteMatcher { private static final String API_PREFIX = "/api"; private static final String ADMIN_KEYWORD = "/admin"; public static boolean isLegalApiPath(String requestPath) { if (requestPath == null || requestPath.isEmpty()) { return false; } if (requestPath.startsWith(API_PREFIX) && !requestPath.contains(ADMIN_KEYWORD)) { return true; } return false; } public static void main(String[] args) { System.out.println(isLegalApiPath("/api/order/list")); // true System.out.println(isLegalApiPath("/api/admin/user/list")); // false System.out.println(isLegalApiPath("/public/order/list")); // false } }这个例子看起来简单,但注意一个细节:startsWith区分大小写。实际请求URL里的路径大小写可能不一致,所以有时候需要先统一转小写再判断。另外,contains在这个场景内没有使用正则,性能极好,适合高并发网关场景。
如果把startsWith换成matches用正则匹配前缀,反而会引入不必要的性能开销。所以先想清楚需求,再选择最匹配的API。
3.4 实战案例:基于字符串包含的敏感词过滤
敏感词过滤是很多内容社区和消息系统的刚需。实现方式有很多,从最简单的一串contains到高效的Trie树(字典树)。我在这里演示一个基于contains的简单版本,适合关键词数量不多的小型系统:
public class SensitiveWordFilter { private static final List<String> SENSITIVE_WORDS = Arrays.asList("垃圾", "骗子", "广告"); public static String filter(String input) { if (input == null || input.isEmpty()) { return input; } for (String word : SENSITIVE_WORDS) { input = input.replace(word, "***"); } return input; } public static void main(String[] args) { String content = "这是一个广告,别信,骗子!"; System.out.println(filter(content)); // 这是一个***,别信,***! } }但这种方法在关键词数量多、文本很长的时候效率非常低,因为每次都从头扫描字符串。如果你们系统对性能有要求,建议参考AC自动机(Aho-Corasick)算法,一次遍历匹配多个关键词。篇幅原因这里先不展开,但你们要记住:contains适合判断少量关键词,不适合做高吞吐的敏感词检测。
我实际在HoRain云上做消息过滤时,最初就是用简单的contains先验证业务逻辑,等关键词列表膨胀到几千个以后,才切换到AC自动机。这也是一个迭代优化思路:先用简单的方案跑通,再根据监控数据去优化热点路径。
3.5 实战案例:判断字符串是否为数字
包含判断延伸出来一个常见面试题:如何判断一个字符串是否包含数字?或者反过来,如何判断一个字符串是否全部由数字组成?
如果是“全部由数字组成”,最简单的做法是遍历每个字符判断是否在'0'到'9'之间:
public static boolean isNumeric(String str) { if (str == null || str.isEmpty()) { return false; } for (char c : str.toCharArray()) { if (c < '0' || c > '9') { return false; } } return true; }还有正则写法:
public static boolean isNumeric(String str) { return str != null && str.matches("\\d+"); }注意\\d+与\\d*的区别:\\d*可以匹配空字符串,所以如果传入空字符串会返回true,这个要看业务能否接受。
如果是“包含数字”,只需要把判断改成:
public static boolean containsDigit(String str) { if (str == null) { return false; } for (char c : str.toCharArray()) { if (Character.isDigit(c)) { return true; } } return false; }这其实就是一个“遍历字符 + 包含判断”的典型组合。理解了原理,你就能灵活应对面试官的变形题。
4. 常见问题与排查技巧实录
4.1 为什么contains判断时灵时不灵——编码问题
有次客户反馈,某个接口校验“是否包含中文”时,在本地测试正常,部署到服务器上就失灵。最后排查发现,服务器的默认字符集是GBK,而代码里读文件用的是默认编码,导致中文乱码,自然匹配不上。
Java里的String对象本身是Unicode编码,理论上不受运行时环境字符集影响。但问题往往出在外部数据转换环节——读取文件、网络流、数据库连接时,如果编码指定不一致,就会产生乱码。最典型的:
BufferedReader reader = new BufferedReader(new InputStreamReader( new FileInputStream(filePath)));这段代码没有指定字符集,JVM会使用平台默认字符集。在Linux服务器上可能是UTF-8,在Windows上可能是GBK,结果就不同。正确写法:
BufferedReader reader = new BufferedReader(new InputStreamReader( new FileInputStream(filePath), StandardCharsets.UTF_8));后来排查问题,凡是字符串包含判断失败,我都会先检查数据源头编码。特别是从数据库或消息队列拿到的数据,如果中途经过转码,很容易出现看不见的字符问题。像零宽空格、不间断空格这类特殊字符,在Debug时看不出来,但用contains判断就会意外返回false。
我当时写了一个辅助方法去打印每个字符的Unicode码,专门用于这种排查:
public static void printUnicode(String str) { for (int i = 0; i < str.length(); i++) { System.out.printf("index=%d, char=%s, unicode=%04x%n", i, str.charAt(i), (int) str.charAt(i)); } }通过看Unicode码,很快就能发现混入了不可见字符。
4.2 为什么用matches匹配包含总是返回false
前面提到过matches是“整个字符串”匹配,很多新手不理解。这里再举一个案例:
String s = "abc123"; System.out.println(s.matches("\\d+")); // false,因为abc123整体不是纯数字 System.out.println(s.matches(".*\\d+.*")); // true,因为包含连续数字如果你在“包含数字”的需求里直接写s.matches("\\d+"),永远得到false。正确的包含正则写法是:
s.matches(".*\\d.*")注意:.*\\d.*里\\d表示单个数字,如果你写\\d+同样也能匹配,但语义上不如\\d简洁。对于“至少包含一个数字”,用.*\\d.*足够了。
很多人在正则里纠结于^和$,但在Java的matches里,^和$不是必须的,因为该方法默认就是全匹配。如果你从别的语言(如JavaScript)转过来,会习惯写/abc/.test(str)表示包含,而在Java里要用Pattern.compile("abc").matcher(str).find()才能达到同样的效果。这是跨语言迁移时最容易踩的坑。
4.3 为什么contains在循环中越来越慢——性能陷阱
有一次我在处理一个包含2万条数据的List,每一条都要做3次contains判断,测试环境跑起来很流畅,但数据量翻到10万后,接口响应时间从50毫秒涨到800毫秒。分析发现contains本身不是瓶颈,瓶颈在于我每次循环内部调用了toLowerCase来忽略大小写,而源字符串有几百个字符,等于每次都要复制一份大字符串,GC压力剧增。
优化方式分两步:
- 提前把源字符串转为小写一次,循环里直接用转换后的结果判断。
- 目标关键词全部预编译成正则Pattern。
改写后的代码:
String lowerSource = source.toLowerCase(Locale.ROOT); for (String item : itemList) { if (lowerSource.contains(item.toLowerCase(Locale.ROOT))) { // ... } }更好的是把item.toLowerCase的结果也缓存起来,因为关键词集合往往比数据列表小得多,缓存命中率很高。
还有一个性能优化点是:如果业务允许,优先用String.indexOf配合StringBuilder处理。比如要做多个关键词的连续匹配,contains每次都会从头开始扫描,而如果你用indexOf加上搜索起始位置,可以避免重复扫描已检查过的区域。这个优化在某些大文本场景下收益非常明显。
4.4 常见问题速查表
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
contains抛空指针 | 调用对象或参数为null | 先判空,或使用StringUtils.contains |
contains判断大小写敏感导致漏判 | 业务需要忽略大小写 | 统一转小写,或使用Pattern.CASE_INSENSITIVE |
matches判断包含总是false | matches是全匹配,不是包含匹配 | 使用.*目标.*或改用find() |
| 中文包含判断失败 | 数据源编码与读取编码不一致 | 统一使用UTF-8,检查数据源头 |
特殊字符?,.,*匹配异常 | 正则元字符未转义 | 使用Pattern.quote() |
循环调用大量contains性能差 | 频繁转换字符串大小写或重复扫描 | 预编译Pattern,缓存小写结果 |
| 判断结果意外为true | 混入了不可见字符如零宽空格 | 使用Unicode码打印排查 |
4.5 我的几个排查技巧
再分享几个日常工作的习惯,不一定写在文档里,但很实用。
第一,先打印再判断。在怀疑字符串包含判断出错时,先把源字符串和目标字符串都打印出来,特别是length(),我遇到过字符串里有尾随空格导致的判断失败,肉眼根本看不出来,一打印长度就露馅。
第二,用常量定义关键词。在工程里我会把需要做包含判断的关键词提取成常量,避免魔法字符串散落各处。例如:
public static final String PAY_SUCCESS = "支付成功";这样在修改关键词时只改一处,不会出现改了判断条件忘了改提示语的情况。
第三,考虑把判断逻辑收口到工具类。一个项目里可能有几十个地方都做字符串包含判断,如果各自用contains,一旦要统一加日志、加判空、加缓存,就要改几十处。收口到工具类之后,所有调用方自动受益。这也是为什么我在文章里反复推荐自己封装StringCheckUtils的原因。
5. 性能对比与方案选型建议
5.1 不同实现方式的性能实测
为了让大家有一个直观概念,我写了一个简单的基准测试,针对同一个文本内容,用不同方式判断是否包含目标子串,循环执行100万次,记录耗时。测试环境:JDK 8,默认JVM参数,CPU为4核。
测试代码如下:
public class ContainsBenchmark { private static final String SOURCE = "HoRain云平台提供弹性计算、对象存储、负载均衡等多种云服务产品,帮助企业和开发者快速上云。"; public static void main(String[] args) { int times = 1_000_000; // 方式1:contains long start1 = System.nanoTime(); for (int i = 0; i < times; i++) { SOURCE.contains("云服务"); } long end1 = System.nanoTime(); System.out.println("contains耗时: " + (end1 - start1) / 1000 + " us"); // 方式2:indexOf long start2 = System.nanoTime(); for (int i = 0; i < times; i++) { SOURCE.indexOf("云服务") >= 0; } long end2 = System.nanoTime(); System.out.println("indexOf耗时: " + (end2 - start2) / 1000 + " us"); // 方式3:正则matches,每次编译 long start3 = System.nanoTime(); for (int i = 0; i < times; i++) { SOURCE.matches(".*云服务.*"); } long end3 = System.nanoTime(); System.out.println("matches耗时: " + (end3 - start3) / 1000 + " us"); // 方式4:预编译Pattern.find Pattern pattern = Pattern.compile("云服务"); long start4 = System.nanoTime(); for (int i = 0; i < times; i++) { pattern.matcher(SOURCE).find(); } long end4 = System.nanoTime(); System.out.println("预编译find耗时: " + (end4 - start4) / 1000 + " us"); } }在我机器上的一个典型输出(单位微妙,不是毫秒):
| 实现方式 | 耗时(微秒) |
|---|---|
| contains | 58,432 |
| indexOf | 55,217 |
| matches(每次编译) | 1,203,456 |
| 预编译Pattern.find | 213,845 |
这个数据说明几个问题:
contains和indexOf性能几乎一致,所以不要为了“少一层调用”而放弃可读性。- 正则
matches每次编译的开销巨大,比contains慢20倍以上。在高频路径上,务必避免每次调用都编译正则。 - 即使预编译了正则,
find依然比contains慢3~4倍,因为正则需要状态机匹配。所以,简单字面量判断能用contains就不要用正则。
5.2 方案选型口诀
根据上面的数据和实际项目经验,我总结了一个选型口诀:
- 字面量包含,用
contains:可读性好,性能足够。 - 需要位置,用
indexOf/lastIndexOf:别硬用contains猜位置。 - 前后缀匹配,用
startsWith/endsWith:语义清晰且快。 - 忽略大小写,先统一转为小写再用
contains,条件允许时缓存转换结果。 - 正则匹配子串,用预编译
Pattern+find(),别用matches代替。 - 多关键词高吞吐场景,考虑 AC 自动机或 TST(三叉搜索树),别用大量
contains叠加。
5.3 什么情况下必须用正则
再补充一下,正则并不是洪水猛兽。下面这些场景,只有正则才能搞定,必须用:
- 模糊匹配:比如匹配“包含至少一位数字 + 至少一个字母”的字符串。
- 动态模式:规则不是固定的字面量,而是由用户配置的表达式。
- 分组捕获:在匹配的同时,还要提取出子串的一部分,比如从邮箱中提取用户名。
这时候用Pattern.compile+Matcher.find是唯一合理选择。只要注意预编译和复用Pattern,性能瓶颈完全可控。
6. 结合框架场景的扩展应用
6.1 在Spring Boot接口参数校验中的应用
在Spring Boot项目里,经常需要对请求参数做包含判断。比如校验一个枚举字段是否合法,或者判断一个查询参数是否包含非法字符。
一种常见的做法是在Controller层写一堆if (param.contains(...)),但更好的做法是用Spring Validation的注解,或者自己写一个自定义校验注解。不过这里不讲那么复杂,只说最基础用法:
@RestController @RequestMapping("/api") public class DemoController { private static final List<String> BLOCKED_WORDS = Arrays.asList("drop", "truncate"); @PostMapping("/search") public Result search(@RequestBody SearchRequest request) { String keyword = request.getKeyword(); if (keyword != null && BLOCKED_WORDS.stream().anyMatch(keyword::contains)) { return Result.error("关键词包含非法内容"); } // 正常业务逻辑 return Result.success(...); } }这段代码有个细节要注意:keyword::contains是方法引用,等价于word -> keyword.contains(word),意思是判断关键词列表中的每个词是否被keyword包含。这里的方向和我们平常写的keyword.contains("xxx")是反的,很多人写混淆了。如果判断错了方向,过滤器就会失效。写完后最好用单元测试覆盖一下。
在Spring里的拦截器或者过滤器,我一般会用一个常量池来保存需要判断的关键词,配合contains做前置过滤。实际项目里,这种黑名单逻辑往往出现在网关层。比如HoRain云平台的API网关里,就有一层请求参数白名单校验,采用的就是类似思路。
6.2 在MyBatis Plus动态SQL中的应用
热搜词里出现了“mybatisplus根据java实体类生成创建表的sql语句”,这个和字符串包含判断的关联点在哪儿?其实就是生成SQL字符串时,需要判断字段是否包含某个关键字,比如判断字段名是否以某个前缀开头、是否包含下划线等。
举个例子,写一个工具类根据实体类生成建表SQL时,常需要判断属性名是否包含@TableField注解,或者字段名里是否包含_。这时候contains和indexOf就派上用场了:
if (field.getName().contains("_")) { // 说明是下划线命名,需要特殊处理 }更常见的是在代码生成器里,根据字段名是否包含is、has等前缀,来决定生成什么类型的列定义。这类需求本质上还是字符串包含判断的延伸。
6.3 在日志链路追踪中的应用
链路追踪里有一个典型场景:从一条日志的traceId中提取业务标识。比如traceId格式是order_20231101_123456,我们要判断它是否包含订单号前缀“order”,同时要截取出后面的部分。
这里我会用contains配合indexOf:
String traceId = "order_20231101_123456"; if (traceId.contains("order_")) { int index = traceId.indexOf("order_"); String prefix = traceId.substring(index, index + "order_".length()); String bizId = traceId.substring(index + "order_".length()); System.out.println(bizId); // 20231101_123456 }虽然contains已经能判断出来了,但真正拿子串时还是要indexOf。这也印证了我前面说的:先想清楚需求,再用合适的API,别一开始就套正则。
7. 关于“Java字符串包含判断”的延伸思考
7.1 从包含判断到字符串算法的进阶
“字符串包含”看起来简单,但它背后连接着字符串匹配算法这个大坑。面试时如果被问到“如何在大文本中高效查找子串”,你可以从朴素的逐个匹配,讲到indexOf的优化,再到KMP、Boyer-Moore、Rabin-Karp这些经典算法。
JDK内部的indexOf实现,对短模式(长度小于等于7)使用了暴力匹配,对长模式则有一个优化:先比较最后一个字符,如果不匹配就快速跳过。这些优化虽然不像KMP那么理论化,但在实际场景中效果非常显著。如果你对性能有极致的追求,可以自己实现KMP,但在绝大多数业务场景里,JDK的默认实现已经足够好。
7.2 字符串池与内存影响
还有一点容易被忽视:字符串包含判断常常会用到substring或toLowerCase,这些操作在JDK 8及以前可能会产生内存问题。比如substring在JDK 7u6之前会共享底层char数组,导致一个大字符串截取一个小子串,却依然持有着大数组的引用,造成内存泄漏。不过这个问题在现代JDK里已经修复了,但如果你们还在维护老版本JDK,读字符串相关代码时要格外当心。
在高并发环境下,大量的contains判断如果伴随toLowerCase,会创建大量临时对象。用-XX:+PrintGCDetails观察GC日志,经常能看到年轻代频繁回收。针对这个场景,我一般会做两件事:一是减少无谓的字符串复制,二是把不可变的关键词集合用List或Set静态初始化,避免在方法调用里反复创建。
7.3 语言对比:为什么其他语言更“省事”
很多从Python或JavaScript转Java的人,会觉得Java字符串操作很繁琐。Python里"abc" in text一句话搞定,JavaScript里"abc".includes("a")也简单。Java的contains其实也不复杂,只是类型系统更严格,加上没有内置的忽略大小写方法,让很多人初期不适应。
不过Java的严谨也有好处。比如你明确区分了contains(字面量包含)和matches(正则全匹配),就不会像JavaScript里那样,时不时因为includes和match的语义差异而出bug。API设计得不够“智能”,反而逼着程序员想清楚自己到底要什么。
7.4 如何把这些知识应用到面试中
面试官问“Java字符串包含判断”时,别只回答一个contains就结束。你可以展示自己的深度:
- 先讲
contains与indexOf的关系,内部实现。 - 再讲大小写不敏感和空指针安全的处理方式。
- 然后扩展到正则的
matches和find区别,举一个踩坑例子。 - 最后聊性能对比,提到预编译Pattern和AC自动机。
这样的回答,从API使用到源码原理,再到性能优化,层层递进,面试官基本就能判断出你是一个有实战经验的工程师,而不是只会背API的“调用仔”。这也是我写这篇内容的初衷:把一个“小问题”讲出“大学问”,让大家在项目里少踩坑,在面试时多加分。
8. 个人实操心得总结
写了这么多,最后说几句掏心窝子的话。
字符串包含判断是Java里最不起眼的操作之一,但恰恰是这种不起眼的地方,最容易埋雷。我工作这么久,踩过的最深的坑不是分布式一致性,不是高并发缓存,反而是这种几行代码就能写完的字符串判断。所以遇到看似简单的代码,我也会多问自己一句:输入会不会是null?大小写有没有要求?目标字符串里有没有正则特殊字符?数据源的编码统一了吗?
我的习惯是写一个项目统一的字符串工具类,把所有包含判断、判空、大小写转换都收口到里面。不要追求每个地方都写得“很精简”,更不要因为JDK没有提供某个方法就硬用复杂替代。一个contains能解决的问题,不要为了炫技而引入正则;但该用正则的场景,也不要因为性能担忧而绕弯路。工具方法多写几个分支,多覆盖几个边界情况,长远来看收益非常大。
最后再分享一个小技巧:在IDE里调试字符串包含判断时,善用“Evaluate Expression”窗口。我经常在断点处直接输入source.contains("xxx")来快速验证判断结果,比一遍遍打印日志高效得多。如果发现判断结果与预期不符,就用source.codePointAt(index)检查每个字符的码点,特别适合排查不可见字符捣乱的情况。
以上便是我在Java字符串包含判断上的全部经验了,希望对你有用。如果你在实际项目里也遇到过类似的隐藏坑,或者有更好的实践方式,欢迎一起探讨。