1. 为什么每个 Java 项目里都留着 commons-lang3 这个依赖
大部分资深 Java 工程师的代码里,commons-lang3 都是那个“一进来就在”的依赖。你很少主动想起它,但一旦需要判断字符串是不是空白、想把数组转成打印友好的字符串、或者从一大段日志里抠出某个键的值时,你第一个翻开的工具类八成就是它。
我在工作中接触过不少老项目,第一眼看pom.xml或build.gradle,如果里面没有 commons-lang3,我基本能猜到后面的代码风格:满屏的== null、!= null、equals("")、手写 for 循环拼字符串,以及各种自己封装但功能相近的工具类。倒不是说这样不行,而是刻意避开一个成熟、稳定、被几百万项目验证过的基础库,纯属重复造轮子。
这篇文章就把 commons-lang3 里那些高频方法过一遍,重点关注三个东西:每个方法解决什么问题、怎么写最顺手、有什么隐藏的坑。
先说说为什么在 JDK 已经很完善的今天,它依然有存在感。JDK 的java.lang提供的是最基础的对象、包装类、字符序列,而实际业务里我们更多处理的是偏业务形态的数据:null 安全的字符串、数组辅助操作、数值解析容错、实体类的equals/toString重建等。commons-lang3 做的事情正是给这些最基础的操作补上“安全网”,让你不用每次写工具方法时都跟 NPE 较劲。
Maven 引入很简单,目前稳定版本我习惯用 3.17.0:
<dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-lang3</artifactId> <version>3.17.0</version> </dependency>Gradle 对应写法:
implementation 'org.apache.commons:commons-lang3:3.17.0'有一个容易踩的坑先说在前面:3.x 的包名是org.apache.commons.lang3,不是老的org.apache.commons.lang。有同事从老项目 copy 代码到新项目,import 没改,直接NoClassDefFoundError,就是因为 2.x 和 3.x 的包名不同,两者不能直接替换。
有人会问,项目里已经用了 Guava,还需要 commons-lang3 吗?我的看法是两者定位有重叠但不完全一样。commons-lang3 专注于 java.lang 层面的增强,体量轻,类名直观;Guava 提供的是集合、缓存、事件总线这类更大的能力。我一般两个都引,字符串判空和数组操作交给 lang3,集合处理和缓存交给 Guava,并不冲突。
它的核心类大致覆盖这些场景:
| 类名 | 覆盖范围 |
|---|---|
| StringUtils | 字符串判空、截取、拼接、替换、补全 |
| ObjectUtils | 空值安全、默认值、比较 |
| ArrayUtils | 数组增删改查、转 Map、排序判断 |
| NumberUtils | 数值解析、比较、BigDecimal 处理 |
| RandomStringUtils | 随机字符串生成 |
| Validate | 入参校验 |
| ExceptionUtils | 异常堆栈处理 |
| DateFormatUtils / DurationFormatUtils | 日期格式化和时长格式化 |
下面按使用频率逐个拆开讲。
2. StringUtils:判空、截取、拼接、替换这些高频操作的推荐写法
StringUtils 是 commons-lang3 里出场率最高的类,没有之一。它解决的问题很纯粹:让字符串操作在 null 输入下不抛异常,同时把高频操作封装成一个方法调用。
2.1 判空全家桶:isBlank 与 isEmpty 千万别混用
先看最基础的判空。这两个方法长得像,语义差别很大:
String s = " "; StringUtils.isEmpty(s); // false," " 不算空 StringUtils.isBlank(s); // true,空白字符都算空 StringUtils.isNotBlank(s); // falseisEmpty只认null和"";isBlank在null和""之外,还会把空格、\t、\n等空白字符当成空。做表单校验、入参校验的时候,几乎总是该用isBlank/isNotBlank,因为用户输入一个纯空格,从业务角度看它就是没填。
配套使用的还有 trim 家族:
StringUtils.trimToNull(" "); // null StringUtils.trimToNull(" abc "); // "abc" StringUtils.trimToEmpty(null); // ""trimToNull我很常用。数据库入库之前,把纯空白字符串转成 null,可以避免表里存一堆意义不明的空格,后续查询条件也不用到处trim。
再往后是默认值家族:
StringUtils.defaultString(null); // "" StringUtils.defaultIfEmpty("", "默认"); // "默认" StringUtils.defaultIfBlank(" ", "默认"); // "默认"defaultIfBlank的逻辑是:字符串为 null、空串、纯空白时都返回默认值。需要从配置里读值、从外部接口拿字符串、展示时想给一个兜底文案的场景,这几个方法非常合适。注意一点:defaultString(null, null)的返回值就是 null,虽然很少这么传,但别以为它一定保证非 null。
2.2 提取与截取:substringBetween 比 indexOf 组合拳省心
我在解析日志、报文、模板占位符时,经常需要“取出某两个标记之间的内容”。手写的话是indexOf+substring三件套,还要处理找不到标记时返回 -1 的边界情况。StringUtils 直接给了现成方法:
String msg = "当前用户: user_123,角色: admin"; StringUtils.substringBetween(msg, "当前用户: ", ","); // user_123 StringUtils.substringBefore("abc:def", ":"); // abc StringUtils.substringAfter("abc:def", ":"); // defsubstringBetween找的是第一个左标记和第一个右标记之间的内容,输入为 null 时返回 null,不会抛异常。解析那种“键: 值”格式的数据时,用substringBefore+substringAfter组合,明显比手写indexOf可读性好。
还有一个场景是日志脱敏。比如手机号中间四位打码:
String phone = "13812345678"; String masked = StringUtils.substring(phone, 0, 3) + "****" + StringUtils.substring(phone, 7);当然这只是示例,具体脱敏规则还要看业务要求。重点是这类拼接和截取操作,StringUtils 的方法名就是你的代码注释,可读性非常好。
2.3 拼接与切分:join 没错,但 split 有个容易踩的坑
列表转逗号分隔字符串,很多人用String.join或手写循环。StringUtils 的join有一个优势:它天然 null 安全,集合里含 null 元素也不会崩溃,而是输出空串占位:
StringUtils.join(new Object[]{"a", null, "c"}, ","); // a,,c如果你想让 null 元素显示成固定文本,还有重载方法可以传自定义 null 占位文本。这在拼接 CSV、构造日志字段时很实用。
split的坑我一定要重点说。Java 自带的String.split和 StringUtils 的split行为不完全一样,尤其处理连续分隔符时:
String csv = "a,,b"; csv.split(","); // [a, , b],JDK 会保留中间空串,但会丢尾部空串 StringUtils.split(csv, ","); // [a, b],默认把空 token 过滤掉了 StringUtils.splitPreserveAllTokens(csv, ","); // [a, , b],要保留空位得用它实测下来,用StringUtils.split切 CSV 时,连续逗号之间的空字段会被静默丢掉,导致“第 3 列变成第 2 列”这种诡异问题。如果对列顺序有要求,比如解析 CSV、解析协议报文,一定要用带PreserveAllTokens的版本。这是我实际踩过坑的地方,排查半天最后发现是过滤空 token 导致的。
2.4 替换、补位与归一化:这些方法能帮你少写循环
项目里最常见的替换需求有两种:替换所有匹配,或者只替换第一个。StringUtils 分别对应replace和replaceOnce:
StringUtils.replace("a-b-c", "-", ":"); // a:b:c,全文替换,字面量匹配 StringUtils.replaceOnce("a-b-c", "-", ":"); // a:b-c,只换第一个 StringUtils.remove("a-b-c", "-"); // abc StringUtils.removeStart("hello world", "hello "); // world StringUtils.removeEnd("index.html", ".html"); // indexString.replaceAll默认走正则,处理带特殊字符的分隔符时要写转义;StringUtils 的replace是字面量替换,处理 URL 参数里的?、&、=这类字符时省心很多。removeStart/removeEnd按前缀后缀截断,很适合处理路径拼接和文件后缀。
normalizeSpace是 3.9 之后提供的,把所有连续空白折叠成一个空格,同时去掉首尾空白:
StringUtils.normalizeSpace("我 是 开发者 "); // 我是开发者用户从 Excel 复制文本、从富文本编辑器粘贴内容时经常带各种空白,用它做文本归一化再合适不过。
补位方法在格式化输出时很常用:
StringUtils.repeat("ab", 3); // ababab StringUtils.leftPad("7", 3, '0'); // 007,订单号补零 StringUtils.rightPad("name", 8, '-'); // name---- StringUtils.center("title", 11, '*'); // ***title***订单号固定宽度、控制台对齐、日志缩进,这几个方法基本够用。另外abbreviate可以按宽度截断并补省略号:
StringUtils.abbreviate("abcdefghij", 5); // ab...做列表标题截断显示的时候很方便,不用自己写“长了就裁一刀”的逻辑。
2.5 判断类方法:isNumeric 之类的方法别凭直觉用
StringUtils 提供了一堆判断字符串特征的布尔方法:isAlpha、isAlphanumeric、isNumeric、containsAny、startsWithAny等。
这里特别提醒isNumeric的一个直觉陷阱:
StringUtils.isNumeric("123"); // true StringUtils.isNumeric("12.3"); // false,不认小数点 StringUtils.isNumeric("-1"); // false,不认负号它只认纯数字字符串,不认正负号和小数点。如果你想判断一个字符串是不是“合法数字”,用后面的 NumberUtils.isCreatable 更靠谱。isAlpha同理,只认纯字母,空格和数字都不行。判断白名单、前缀匹配时,containsAny/startsWithAny这类方法比手写 for 循环清晰:
StringUtils.startsWithAny("user_123", "admin_", "user_"); // trueStringUtils 方法太多了,没必要全背,记住三条核心经验足够:判空用isBlank系列;切分注意PreserveAllTokens;替换优先用字面量版本而不是正则版本。
3. ObjectUtils 与 Validate:写工具类之前先翻翻这两个类
很多项目里自己封装的工具方法,其实就是在处理三件事:判断空、取默认值、校验参数。这三件事 commons-lang3 已经帮你做好了,类名分别是ObjectUtils和Validate。
3.1 ObjectUtils.isEmpty:一个方法处理四种空
ObjectUtils.isEmpty是个非常通用的入口,它对 null、空字符串、空数组、空集合、空 Map、空 Optional 都返回 true:
ObjectUtils.isEmpty(null); // true ObjectUtils.isEmpty(""); // true ObjectUtils.isEmpty(new int[]{}); // true ObjectUtils.isEmpty(Collections.emptyList()); // true ObjectUtils.isEmpty(Optional.empty()); // true,3.9 之后支持注意它只认空字符串,不认纯空白,isEmpty(" ")是 false,这点和 StringUtils.isBlank 不一样。我一般会根据自己的场景决定:入参校验用StringUtils.isNotBlank,统一判空用ObjectUtils.isEmpty。
这种统一判空入口最大的价值是让代码风格一致。一个项目里如果有人用list == null || list.size() == 0,有人用CollectionUtils.isEmpty,还有人用ObjectUtils.isEmpty,代码审查成本很高。定一个规范,统一走ObjectUtils.isEmpty,理解成本一下子就降下来了。
3.2 firstNonNull 与 defaultIfNull:配置兜底的标准姿势
读取配置时,配置项可能缺失,可能需要多级兜底。手写的话是嵌套三元表达式,嵌套三层以上基本没法看。ObjectUtils 给了一套很清晰的方案:
ObjectUtils.defaultIfNull(map.get("timeout"), 30); ObjectUtils.firstNonNull(systemProp, configFile, defaultVal);defaultIfNull很好理解,目标值为 null 就返回默认值。firstNonNull接收多个参数,按顺序返回第一个非 null 的值,适合“系统参数 > 配置文件 > 默认值”这类多级优先级的场景。写配置加载、环境变量覆盖逻辑时,这个方法的可读性比三层三元表达式强太多了。
另外还有一批批量判空的方法:
ObjectUtils.anyNull(id, name, age); // 只要有一个 null 就返回 true ObjectUtils.allNull(id, name, age); // 全部为 null 才返回 true批量校验入参、检查一组必要的参数是否齐全时,比逐个== null判断干净得多。
3.3 Validate:入参守卫就该这么写
Validate 是那种“用了就回不去”的工具类。它的作用是在方法入口做前置条件校验,校验失败直接抛异常,不需要手写if + throw new IllegalArgumentException。
Validate.notNull(user, "user cannot be null"); Validate.isTrue(count > 0, "count must be positive, but got %s", count); Validate.notBlank(name, "name is required"); Validate.matchesPattern(phone, "^1\\d{10}$", "invalid phone");几个特点很明显:
第一,异常类型明确。Validate.notNull失败抛NullPointerException,Validate.isTrue失败抛IllegalArgumentException,不会到处出现含义模糊的 Runtime 异常。
第二,消息模板支持占位符,错误信息可以携带具体值,排查问题时直接能看到是哪个参数、什么值导致的失败。
第三,代码意图一目了然。方法名本身就是断言语义,读代码的人不需要把if条件在心里翻转一遍才能理解。
我的经验是:Controller 或 Service 入口先放几个 Validate 断言,再往下传数据,比层层if + throw短得多。但也不要滥用,一个方法里超过四五个断言,通常意味着入参对象建模有问题,该封装一个入参校验对象了。
4. ArrayUtils 与 RandomStringUtils:数组操作和随机字符串的正确姿势
数组在 Java 里用起来不如集合顺手,原因是它没有自带的方法。ArrayUtils 就是把数组操作缺失的能力补回来。
4.1 增删改查都返回新数组:记住这个核心认知
先立一个最重要的认知:ArrayUtils 的add、addAll、remove、removeElement返回的是新数组,不会修改原数组;而reverse、swap、shift是原地修改。这一点和集合 API 的习惯很不一样,用混了容易出现“调完方法发现原数组没变”的疑惑。
int[] arr = {1, 2, 3}; int[] added = ArrayUtils.add(arr, 4); // {1,2,3,4},arr 不受影响 int[] removed = ArrayUtils.remove(arr, 0); // {2,3},arr 不受影响 ArrayUtils.reverse(arr); // 原地修改,此时 arr 是 {3,2,1} ArrayUtils.swap(arr, 0, 2); // 原地修改,交换第 0 和第 2 位日常开发中,数组长度固定、需要“增删”的场景,通常应该优先考虑ArrayList,但有些场景必须用数组:性能敏感的数值计算、第三方 SDK 方法签名要求、反射调用时构造参数数组等。这种时候 ArrayUtils 提供的增删改查就很关键。
还有一个很常用的判断方法:
ArrayUtils.isSorted(new int[]{1, 2, 3}); // true ArrayUtils.contains(new int[]{1, 2, 3}, 2); // trueisSorted在检查数据是否有序时比手写循环省事。
4.2 数组打印、转 Map、判有序:调试配置两相宜
打印数组内容是调试时的高频操作。Arrays.toString能用,但有两个小差异值得注意:
ArrayUtils.toString(new int[]{1, 2, 3}); // {1,2,3} ArrayUtils.toString(null); // {},不会输出 null数组为 null 时,ArrayUtils.toString返回{}而不是字符串"null",排查问题时更容易看出“数组是空的”而不是“对象本身不存在的”。当然这只是个人偏好,两者都能用。
数组转 Map 也是一个不多见但很实用的方法:
String[][] kv = {{"key1", "val1"}, {"key2", "val2"}}; Map<String, String> map = ArrayUtils.toMap(kv);做简单的配置表转换、批量设定初始 KV 时,比一行行map.put清晰。注意键不能重复,重复会抛IllegalArgumentException。
还有包装数组和基本类型数组的互转:
Integer[] wrapperArray = ArrayUtils.toObject(new int[]{1, 2, 3}); int[] primitiveArray = ArrayUtils.toPrimitive(wrapperArray);跟 JSON 序列化库、第三方 SDK 打交道时,经常遇到Integer[]和int[]互相转换的需求,这两个方法直接解决。
4.3 随机字符串的坑:生成安全令牌一定用 secure 版本
RandomStringUtils 提供随机字符串生成,常见用法如下:
RandomStringUtils.randomAlphabetic(8); // 纯字母 RandomStringUtils.randomAlphanumeric(8); // 字母+数字 RandomStringUtils.randomNumeric(6); // 纯数字最需要注意的坑:它默认基于java.util.Random,属于非加密安全随机。生成订单号随机片段、验证码这类“对外、可枚举价值不高”的字符串没问题,但如果是密码重置 token、API key、支付相关的安全令牌,绝对不能直接用默认版本,要用 3.6 引入的 secure 工厂方法:
RandomStringUtils.secure().randomAlphanumeric(16);secure 版本基于SecureRandom,安全性有保障,代价是生成速度慢一些,但安全令牌场景完全值得。
还有一个容易忽略的业务细节:randomNumeric(6)可能以 0 开头。如果业务要求 6 位验证码不能是 0 开头或不能重复,需要在生成后做一次判断或补位,不能直接上送。
5. NumberUtils:配置文件字符串转数字的稳妥姿势
项目中经常要从配置文件、环境变量、外部接口参数里拿数字,而这些源头几乎都是字符串。直接用Integer.parseInt解析,遇到空串、空格、格式异常,一个NumberFormatException就能让服务挂在你面前。NumberUtils 的价值就在这里。
5.1 toInt/toLong:解析失败有默认值就不慌
最常用的几个转换方法自带默认值:
NumberUtils.toInt("123", 0); // 123 NumberUtils.toInt("abc", -1); // -1,解析失败返回默认值 NumberUtils.toLong(props.get("timeout"), 3000L); // 读配置失败默认 3 秒 NumberUtils.toDouble(score, 0.0); // 非数字默认 0.0这里的关键不是“少写几行”,而是不让解析异常往外抛。读取配置时,一个配置项格式写错不应该导致整个应用启动失败;外部接口返回一个脏数据也不应该让核心链路挂掉。带默认值的解析方法把这种基础设施层的容错做得非常优雅。
5.2 isCreatable 与 isNumber:改名带来的 NoSuchMethodError
判断一个字符串是不是合法数字时,网上的老例子会告诉你用StringUtils.isNumeric,也有人提到NumberUtils.isNumber。这里必须叮嘱一句:3.x 里isNumber已经改名为isCreatable,新版本调用isNumber会直接NoSuchMethodError。
isCreatable的判定范围比 StringUtils.isNumeric 宽得多:
NumberUtils.isCreatable("123"); // true NumberUtils.isCreatable("12.3"); // true,支持小数点 NumberUtils.isCreatable("-1"); // true,支持负号 NumberUtils.isCreatable("0x1F"); // true,支持十六进制 NumberUtils.isCreatable("1_000"); // true,支持下划线如果你只是想判断字符串能不能被Integer.parseInt解析,用isParsable更严格:
NumberUtils.isParsable("123"); // true NumberUtils.isParsable("0x1F"); // false,isCreatable 才认十六进制实际开发中我的习惯是:表单校验字段是不是“正整数”用 StringUtils.isNumeric;判断字符串整体能不能转成某种 Number 用isCreatable;只关心能不能过parseInt用isParsable。三个方法的判定范围完全不一样,用之前想清楚业务语义。
5.3 createBigDecimal:字符串转金额字段的推荐入口
金额、利率、费率这类字段在业务代码里经常以字符串形式传输,转成BigDecimal时最稳妥的做法是:
BigDecimal amount = NumberUtils.createBigDecimal("0.1"); BigDecimal scaled = NumberUtils.toScaledBigDecimal("12.345", 2, RoundingMode.HALF_UP); // 12.35千万不要Double.parseDouble之后再转 BigDecimal,浮点数的精度问题会在金额计算时给你上深刻一课。createBigDecimal 直接基于字符串解析,从源头避免精度损失。toScaledBigDecimal可以在解析的同时完成 scale 设置和舍入规则指定,一步到位。
NumberUtils 还有一组min/max重载方法:
NumberUtils.min(1.5, 2.5, 0.5); // 0.5 NumberUtils.max(3, 9, 4); // 9不用自己写Math.min嵌套了,尤其是多个参数取极值的时候。
6. 容易被忽略的隐藏工具类,以及升级到 3.x 时必须记住的差异
StringUtils、ObjectUtils、ArrayUtils、NumberUtils 这四类已经覆盖了大部分日常需求,但 commons-lang3 里还藏着一些不在头条但关键时刻很有用的类。
6.1 异常、时长、日期格式化:这三个类平时不起眼但很实用
ExceptionUtils 在处理异常日志时很能救命:
String stackTrace = ExceptionUtils.getStackTrace(exception); Throwable root = ExceptionUtils.getRootCause(exception);把异常堆栈转成字符串后,可以拼进业务日志、写入告警消息、发到消息队列,比依赖日志框架自己的printStackTrace格式可控得多。找根因时getRootCause比一层层getCause循环省事。
DurationFormatUtils 用于把毫秒数格式化成可读的时长:
DurationFormatUtils.formatDuration(123456L, "HH:mm:ss"); // 00:02:03批处理任务耗时统计、接口性能监控、定时任务日志,都可以用这个方法来输出“这次跑了多久”,而不是一长串毫秒数字。
DateFormatUtils 和 FastDateFormat 则是日期格式化场景的补充:
DateFormatUtils.format(new Date(), "yyyy-MM-dd HH:mm:ss");它内部基于 FastDateFormat,是 SimpleDateFormat 的线程安全替代。高并发环境里格式化大量时间,用 SimpleDateFormat 要小心线程安全,FastDateFormat 则没有这个问题。如果项目里还在用SimpleDateFormat做静态工具方法,建议直接换成它。
6.2 equals/hashCode/toString 的 Builder 家族
手写实体类的equals/hashCode/toString是非常枯燥且容易出错的活。commons-lang3 的 Builder 家族可以流程化生成:
String toStringResult = ToStringBuilder.reflectionToString(obj); boolean equalsResult = EqualsBuilder.reflectionEquals(obj1, obj2); int hashResult = HashCodeBuilder.reflectionHashCode(obj);反射版适合快速生成,性能不是最优但胜在简单。如果实体类的字段有特殊格式要求,也可以用 Builder 链式逐字段构建。虽然现在很多项目用 Lombok 注解生成这些方法,但老项目不能升级 Lombok、或者对输出格式有特殊要求时,这些 Builder 类就是很好的替代。
6.3 lang2 到 lang3 的迁移差异与版本踩坑
最后说升级和迁移。如果你维护的是老项目,从 commons-lang 2.x 升级到 3.x,下面几个差异要特别注意:
第一,包名从org.apache.commons.lang改为org.apache.commons.lang3。所有 import 都要改,这个没商量。
第二,3.x 要求 Java 8 及以上。如果你的环境还在 Java 7 或更老,升级前先确认 JDK 版本。
第三,StringUtils.EMPTY的语义变了。在 lang2 里它是 null,在 lang3 里它是空字符串""。老代码如果依赖EMPTY做判空逻辑,迁移后行为可能完全反过来,这是最容易产生隐蔽 bug 的地方。
第四,3.x 移除了 DateUtils,日期解析和计算建议直接用 java.time 包。迁移时不需要找替代工具类,直接用 LocalDate / LocalDateTime 重写相关代码,既干净又符合现代 Java 写法。
第五,RandomStringUtils的安全性问题在 3.6 之后才正式提供 secure 版本,如果你是 3.5 及以前的老版本,生成 token 时千万别图省事直接用默认方法。
这篇内容写到最后,说一点我的个人习惯:接到一个新项目,第一件事就是看一眼 commons-lang3 的版本,顺手把 pom 里版本提到一个长期维护的稳定版;写新工具方法前先在 StringUtils、ObjectUtils、ArrayUtils 里翻一遍,能直接用就绝不自造。工具库这种东西,平常看不见它存在,但 null 指针、解析异常、格式化错乱出现的时候,你会发现它已经替你挡了太多脏活累活。上面这些方法是我在日常开发里反复用到的,照着用基本不会踩坑。如果你也有自己私藏的 commons-lang3 用法,欢迎在评论区补充交流。