☰
Java常用工具类实战:集合、字符串与日期处理指南
2026/10/8 3:34:39 网站建设 项目流程

做开发这些年,我有个习惯:看别人家的代码好不好,先看他怎么处理字符串、集合、空对象这些“小事”。很多看起来高大上的业务代码,最后都栽在没用好常用工具类上。这里的“常用工具类”不是指某个神秘框架,而是那些每天都会碰到的、把脏活累活提前封装好的静态方法集合:把一个List拼成逗号分隔的字符串、判断字符串是不是真的为空、给集合排序、安全地比较两个对象是否相等。这篇文章是系列教程第十三讲的第一篇,专门聊日常开发里最常用的一组工具类:集合与数组、字符串、日期时间、对象相等性这几个方向。我会结合真实场景给出能直接抄的写法,也会把版本冲突、空指针这类容易踩的坑一并说清楚。适合想减少重复代码、提升代码可读性和健壮性的后端开发者,新手能照着用,老手也能重新审视自己封装工具类的方式。

1. 工具类到底解决了什么问题

1.1 工具类的本质:把重复劳动交给经过验证的代码

先从一个最常见的场景说起。业务系统里经常要把一个用户ID列表拼成字符串,用来查数据库或者拼接口参数。新手最常见的写法是手动循环:

StringBuilder sb = new StringBuilder(); for (int i = 0; i < list.size(); i++) { if (i > 0) { sb.append(","); } sb.append(list.get(i)); } String result = sb.toString();

这段代码能跑,但问题很多:边界条件要靠自己记,list为空的时候会不会出错、要不要去重、元素是不是null,全凭当时的临场发挥。而且这种循环逻辑一旦散落在十几个类里,每个人写出来的风格都不一样。有人用String.join,有人用Collectors.joining,还有人自己拼。长此以往,代码库就成了“风格博物馆”。

工具类存在的意义,就是把这些重复且容易出错的操作收敛到一处。你直接调用StringUtils.join(list, ","),内部处理了空集合、null元素、拼接性能这些细节。这里的关键不是“少写几行代码”,而是“少承担几个隐患”。成熟工具类的每一个方法都经过了大量项目的验证,边界情况比你临时想的周全得多。

我在代码评审中经常说一句话:如果你发现自己写了一个循环,而且这个循环里做的事情和另一个方法很相似,先停下来想一想,JDK或者你已经在用的第三方工具类里是不是已经有现成的方案。大部分情况下答案是“有”。这不是懒惰,而是把精力留给真正有业务逻辑的代码。

1.2 设计原则:为什么工具类通常是静态方法集合

顺着上面这个话题,你会发现绝大多数工具类都是“静态方法集合”,比如java.util.Collections、org.apache.commons.lang3.StringUtils。这种设计不是拍脑袋决定的,背后有三个非常实际的理由。

第一,工具类的方法一般没有内部状态。它只是“输入一堆参数,处理一下,返回结果”,不需要保存调用之间的任何上下文。既然没有状态,就不需要维护实例,用静态方法最直接。第二,静态方法调用成本低,不需要先new一个对象再调方法。第三,从使用者的角度看,静态方法的语义更清晰:StringUtils.isBlank(str)一眼就知道是“判断字符串是不是空白”,而一个叫StringHelper的对象方法反而要多想一层。

但静态方法集合同样有它的代价:如果设计不好,很容易变成“上帝类”,什么方法都往里塞。我见过一些项目的工具类,从日期格式化到JSON解析再到加密解密全在一个类里,几千行代码,谁都不敢动。这类问题后面我会专门讲,这里先记住一个判断标准:一个工具类应该只围绕“一类主题”提供方法,比如DateUtils只做日期相关,StringUtils只做字符串相关。

另外,因为工具类不需要实例化,好的实践是私有化构造方法,防止别人new出无意义的对象。这一点在Java里尤其重要,否则工具类被别人当成普通类实例化,既浪费资源又让代码意图变得模糊。很多静态代码检查工具也会把“工具类缺少私有构造函数”当成一个问题来报。

2. 最容易上手的集合与数组操作

2.1 Arrays:数组操作的标准答案

数组在Java里是个“二等公民”,既没有List那种丰富的方法,又不如集合框架好用。但项目里偶尔还是会遇到数组,比如解析CSV的某一行、接收可变参数、对接老接口。这时候java.util.Arrays就是最后的安全网。

最常用的几个方法,我先列出来:

// 数组转List,注意不能增删 List<String> list = Arrays.asList("a", "b", "c"); // 数组排序 Arrays.sort(intArray); // 二分查找,数组必须先排序 int index = Arrays.binarySearch(intArray, 5); // 数组复制,常用于扩容 int[] newArr = Arrays.copyOf(oldArr, oldArr.length * 2);

这里面有一个特别容易踩的坑:Arrays.asList返回的List是“固定大小”的视图,直接add或者remove会抛UnsupportedOperationException。因为asList底层还是那个数组,它只是给你套了一个List的外壳,并没有真的复制成一个ArrayList。如果你需要一个可以增删的新List,正确做法是:

List<String> list = new ArrayList<>(Arrays.asList("a", "b", "c"));

还有一个细节:Arrays.asList的参数如果传的是基本类型数组,比如int[],得到的List元素类型是int[]而不是Integer。也就是说,Arrays.asList(intArray)返回的长度是1,里面装着一个整个数组对象。这几乎是每个Java开发者都会踩一次的坑。要处理基本类型数组,建议直接用Stream:

int[] arr = {1, 2, 3}; List<Integer> list = Arrays.stream(arr).boxed().collect(Collectors.toList());

2.2 Collections与List排序的隐藏细节

集合框架里,Collections是操作集合的元老级工具类。排序、反转、查找最大最小值、线程安全包装,都是它的拿手好戏。但我在实际项目中更推荐直接用List.sort()或者Stream的sorted(),因为Collections.sort的风格偏老,可读性不如List自己的方法。

真正值得留意的是排序时的比较器细节。比如按照某个字段排序,新手常犯的错误是把比较结果直接返回:

// 不推荐,Integer比较可以更简洁 list.sort((a, b) -> a.getAge().compareTo(b.getAge())); // 推荐 list.sort(Comparator.comparing(User::getAge));

如果你想倒序,不要试图把减法逻辑写进比较器里面:

// 错误示范:用相减方式比较数值,有溢出风险 list.sort((a, b) -> b.getAge() - a.getAge()); // 正确示范 list.sort(Comparator.comparing(User::getAge).reversed());

Comparator.comparing加reversed()这种方式可读性更好,而且规避了相减溢出和拆箱空指针的问题。如果需要多条件排序,可以链式调用thenComparing,这是我在真实项目里经常用的写法,比手写多重if判断清晰太多。

另外,Collections.unmodifiableList、unmodifiableMap这些方法可以快速生成只读视图。很多人以为这样就能保证集合不可变,实际上它只是“不能通过这个引用修改”,如果原集合还能改,只读视图里的内容也会跟着变。要真正不可变,优先用List.copyOf、Map.copyOf等Java 9之后的API,或者Guava的ImmutableList。

2.3 集合判空、转换与不可变集合

集合操作里最高频的需求是判空。业务代码里到处是if (list != null && list.size() > 0)这种写法,看多了真的头疼。如果你用Spring,CollectionUtils.isEmpty(list)可以一行搞定;如果用Apache Commons Collections,也有同名方法。我自己的习惯是只要能确定集合不是null,就用list.isEmpty(),只有在可能为null的场景下才用工具类。

判断完空之后就是转换。把一个对象列表转换成另一个对象列表,程序员每天都要干。经典写法是手动for循环加new对象,现在更多人用Stream:

List<String> names = users.stream() .map(User::getName) .filter(Objects::nonNull) .collect(Collectors.toList());

这行代码里隐藏了三个细节:第一,map用来提取字段;第二,filter(Objects::nonNull)过滤掉null;第三,collect收集结果。这三步对应了日常开发里最常见的“清洗数据”流程,比for循环加if判断不知道高到哪里去了。

集合转换时还要注意空集合的处理。Stream在空集合上调用完全没问题,不会抛异常,这比老式循环安全得多。很多人不知道这一点,还在方法开头写一个if (list == null) return new ArrayList<>();,其实Stream已经帮你处理了空集合的遍历,代码可以更精简。当然,如果后面要用集合数据,还是要确保返回非null集合,避免调用方继续NPE。

3. 字符串处理:这些方法真的能救命

3.1 空安全判断与截断

字符串是最容易出现空指针的对象,没有之一。业务系统里最常见的Crash就是String.equals调用时变量为null。我见过太多因为str.equals("xxx")直接NPE的生产事故,其实只需要换一下调用顺序就能解决:"xxx".equals(str)。但这治标不治本,更稳妥的方式是用工具类统一判断。

Spring的StringUtils.hasText(str)能判断字符串是否有实际内容,commons-lang3的StringUtils.isBlank(str)则更严格:null、空串、纯空格都会返回true。我强烈建议团队统一用isBlank而不是str == null || str.isEmpty(),因为用户输入里带空格的情况太常见了,只判断空串根本堵不住问题。

截断字符串也是高频需求。比如日志里打印超长报文,还有列表页展示商品名称需要限制长度。手写截断非常简单,但难的是“如果截断了要加省略号”以及“不要按半个字符截断”这类细节。StringUtils.abbreviate(str, maxWidth)直接帮你处理了这些,它保证结果长度不会超过maxWidth,并且会在末尾加上省略号,非常适合列表展示场景。

我踩过一个相关的坑:用substring截断字符串时没有判断长度,结果StringIndexOutOfBoundsException直接打爆了线上接口。从那以后,凡是可能超长的字符串截断,我全部改用工具类,或者至少先判断length()再截。这种低级错误往往不是技术问题,而是习惯问题。

3.2 Join、Split与正则的取舍

字符串拼接是另一个重灾区。String.join是Java 8提供的原生方法,但它不支持null元素的自动过滤。如果list里有null,String.join会拼出“1,null,3”,这可不是你想要的。用Collectors.joining配合过滤可以解决:

String result = list.stream() .filter(Objects::nonNull) .collect(Collectors.joining(","));

或者直接用StringUtils.join(list, ","),Apache的实现在拼接时会跳过null元素。这里有个小知识点:StringUtils.join在底层会先判断集合类型,如果是ArrayList会转成数组用StringJoiner拼接,性能还算可以。但在非常追求性能的循环体里,还是建议手动用StringBuilder,避免频繁创建Stream对象。

Split的坑比Join更多。String.split接收的是正则表达式,所以遇到.、|、$这些字符时必须转义。比如按点号分割IP地址,写成"192.168.1.1".split(".")只会得到空数组,因为.在正则里匹配任意字符。正确写法是split("\\.")。这个梗在Java社区经久不衰,就是因为几乎每个新手都会踩。

如果只是按固定分隔符分割,我更推荐用StringUtils.split(str, ','),它按普通字符处理,不需要转义,而且自动忽略空字符串和开头结尾的分隔符,行为比String.split更符合业务直觉。不过要注意:StringUtils.split和String.split在“空字符串处理”上行为不同,前者默认跳过空的部分,后者不会。需要具体场景具体判断。

3.3 正则表达式处理与性能提醒

正则表达式是字符串处理的万能钥匙,同时也是性能黑洞。很多团队在写校验手机号、邮箱、身份证时,直接在方法内Pattern.compile一次,每调用一次就编译一次。正则编译其实很费时,尤其在高并发场景下,这个开销会被放大。

正确做法是把Pattern定义为static final常量,或者用工具类提供的预编译方法。如果你的项目里没有现成的正则工具类,自己封装也不难:

public class RegexUtils { private static final Pattern MOBILE_PATTERN = Pattern.compile("^1[3-9]\\d{9}$"); public static boolean isMobile(String mobile) { return mobile != null && MOBILE_PATTERN.matcher(mobile).matches(); } }

另外一个容易被忽视的细节:正则匹配中的matches()要求整个字符串完全匹配,而find()只要找到子串就算匹配。业务校验时用matches()更严格,但如果你只是判断“是否包含数字”,find()就够用了。两个方法语义不同,用错了就是线上bug。

4. 日期时间与对象相等性

4.1 日期时间工具类:别再用过时API

Java的日期时间API一直是重灾区。老代码里到处是SimpleDateFormat,它本身线程不安全,如果在多线程环境下被多个线程共享,格式化结果会出现错乱甚至抛异常。我见过一个老系统,明明用了static SimpleDateFormat,上线后发现偶尔会有几条数据的日期完全不对,排查了半天才定位到线程安全问题。

所以现在做新项目,我基本只用java.time包下的LocalDate、LocalDateTime、DateTimeFormatter。这些类是不可变的、线程安全的,用起来非常安心。DateTimeFormatter也是线程安全的,可以放心地定义为常量:

private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); LocalDateTime now = LocalDateTime.now(); String formatted = now.format(DATE_TIME_FORMATTER); LocalDateTime parsed = LocalDateTime.parse("2024-05-20 12:30:00", DATE_TIME_FORMATTER);

如果有人还在用SimpleDateFormat,我通常会建议他把所有相关代码换掉,哪怕只是个小工具方法。因为这不是“能用就行”的问题,而是线程安全这个雷早晚会爆。

如果你不想跟DateTimeFormatter的繁琐API较劲,可以直接用Hutool的DateUtil。它对日常格式化、转换、计算做了极简封装,比如DateUtil.format(date, "yyyy-MM-dd"),底层其实还是DateTimeFormatter。这类工具类的好处是API直觉化,坏处是引入了一个不算小的依赖,项目里需要权衡。

4.2 Objects工具类:空安全的比较与哈希

Objects是Java 7就有的工具类,但它存在感一直不高,很多人只知道Objects.equals和Objects.hash,其实这两个方法已经足够解决掉一大半和对象相等性相关的坑。

先看Objects.equals(a, b):它在内部自动处理了null值,两个参数都为空时会返回true,其中一个为空时返回false。所以你可以放心地写:

if (Objects.equals(user.getPhone(), employee.getPhone())) { // 两个手机号相等 }

如果手写user.getPhone().equals(employee.getPhone()),只要getPhone()返回null,立刻NPE。别小看这个细节,我在项目里见过很多因为直接equals导致的线上事故,最后修起来也就是多包一层Objects.equals。

Objects.hash则是生成哈希值的便捷方法,适合用在重写hashCode()时。比如你有一个包含多个字段的实体类,手写一个复杂的hashCode还不如直接Objects.hash(field1, field2, field3)来得干净。它能保证相同字段组合产生的hash一致,同时规避了数组、null的边界问题。

还有一个容易被忽略的方法:Objects.requireNonNull。它可以在方法入口处做快速失败校验,如果参数为null立刻抛出NullPointerException,并且可以自定义报错信息。很多时候,与其让参数在业务代码深处才暴露NPE,不如在方法入口就把它拦下来,错误信息清楚,排查省力很多。

4.3 实体类与比较器:避免循环引用和性能陷阱

日期和对象的相等性看完,再补充一个实体类操作里常见的坑:toString。很多人重写toString时图省事,直接把关联对象也打进去,结果两个对象互相引用,打印日志时直接栈溢出。用Objects.toStringHelper或Hutool的StrUtil.builder都能在一定程度上缓解,但最根本的方法是不要打印双向关联对象,只打印ID即可。

还有比较器。对象数组或列表要排序时,光靠Comparable接口可能不够灵活,这时Comparator.comparing是首选。我记得有一次要对订单按金额倒序、再按创建时间正序排列,用链式比较器一行搞定:

list.sort(Comparator.comparing(Order::getAmount).reversed() .thenComparing(Order::getCreateTime));

如果用手写compareTo,同样的逻辑至少五六行,而且很容易漏掉“金额相同再比时间”这个分支。比较器链的可读性和正确性远好于手工嵌套if。这个经验对任何做报表、列表排序的人来说都通用。

5. 自己封装工具类的正确姿势

5.1 封装前的三问

看到这里,你可能会想:既然第三方工具类这么强,干脆把所有公共逻辑都交给它们就好了。但实际项目里,你总会遇到一些定制化逻辑:特定业务的脱敏规则、统一的分页参数转换、公司内部接口的签名算法。这时候就需要自己封装工具类。

动手封装前,先问自己三个问题。第一个问题:这个逻辑是否真的会被多处复用?如果只有一个地方用,强行封装成工具类只会增加抽象层级,别人还得跨文件找。第二个问题:JDK或已有依赖里是否已经存在类似实现?重复造轮子不仅浪费精力,还会引入不一致行为。第三个问题:这个逻辑是否适合放在领域模型里而不是通用工具类?比如订单金额计算这类和业务强相关的逻辑,放到OrderUtils里比放到CommonUtils里更合理。

我见过最夸张的封装是把“根据用户类型拼接欢迎语”做成一个静态方法,放在一个叫CommonUtils的类里,结果这个类被几十个服务引用,改一次影响几十处。这种都不是工具类,是代码债。

真正适合封装成工具类的特征很清晰:通用性强、无业务语义、有明确的输入输出、实现细节比较繁琐。比如“手机号脱敏”“身份证校验”“金额保留两位”这类,就很典型。

5.2 设计规范与命名

自己写工具类时,设计规范和命名决定了别人愿不愿意用、敢不敢用。我整理了一份自己团队内部的定义,每一条都是从实际教训里总结出来的。

第一,工具类必须是final类,并且构造函数私有。这样既防止被继承,也防止被实例化,是一个强烈的“我是静态工具类”的信号。第二,方法全部static,并且尽量无副作用。传进去的参数不要修改,需要修改时返回新对象。这是最重要的约定,否则调用方很难推断行为。第三,命名要有统一前缀。要么按功能命名DateUtils、StringUtils、MoneyUtils,要么按统一品牌命名,比如项目名前缀XxxUtils。最忌讳的是混用,一个叫StringUtil,另一个叫CommonUtils,找起来全靠缘分。

常量定义也要讲究。正则表达式、格式化模板、分隔符这些都要定义为private static final常量,避免散落在方法里。这样不仅提高性能,还能在改模板时只改一处。

方法命名建议参考成熟库的习惯:isXxx返回布尔、toXxx做类型转换、formatXxx做格式化。保持一致性和可预测性,比花哨的命名重要得多。很多团队用Checkstyle或SonarQube来约束这些规则,这是个好办法,能把约定固化到CI流程里。

5.3 依赖管理:直接引用还是封装一层

最后一个关于封装的问题:项目里到底该直接依赖Apache Commons还是自己再封装一层?我的看法是,看团队的替换成本和可控性要求。

如果项目刚开始,完全可以放心直接用commons-lang3、Hutool或者Guava。它们成熟、维护活跃、坑相对少。但如果你已经在用某个库,后来因为各种原因想换掉它,麻烦就来了:全项目到处都是StringUtils.xxx,换库等于全项目重构。这时候,提前自己封装一层薄薄的XxxUtils,内部委托给具体第三方库,会安全很多。

但薄封装不是无脑包一层。你要保证封装的API本身就是你业务需要的抽象,而不是把第三方方法单纯重命名。比如你封装一个TextUtils.maskPhone(phone),内部用字符串截断加星号实现,和依赖哪个第三方库完全没有关系。这才是封装价值所在。反之,如果你只是写MyStringUtils.isEmpty,内部调用StringUtils.isEmpty,那不过是给别人的方法起了个别名,以后维护成本反而更高。

我的习惯是:通用的、稳定的方法直接用成熟库;带有业务含义的、可能会变的方法自己封装。比如脱敏规则,不同阶段可能不同,封装起来方便改;而“判断字符串是否为空”这种,一百年不会变,直接用库方法,没有必要再套一层。

6. 常见问题与排查技巧实录

6.1 版本冲突与NoSuchMethodError

工具类用得多了,版本冲突就是躲不开的坑。最典型的一幕:代码编译没问题,一启动就报NoSuchMethodError,或者NoClassDefFoundError。原因通常是项目里有多个间接依赖引用了不同版本的工具库,最终被ClassLoader加载到的是旧版本,而你的代码调用了新版本才有的方法。

我处理这一类问题通常分三步。第一步,在IDE中查找类所在的jar包,确认当前生效的版本。第二步,用mvn dependency:tree查依赖树,找到传递依赖的版本坐标。第三步,在pom.xml中对冲突的依赖显式声明你想要的版本,或者用依赖管理dependencyManagement统一锁定版本。如果是Gradle项目,则用dependencyInsight和resolutionStrategy来查看和强制版本。

遇到NoSuchMethodError时,还有一个容易忽略的点:直接依赖和传递依赖的版本不一致,但Maven默认采用的是最近定义原则。位置不当会导致你以为自己指定了版本,实际却被另一条路径抢先。所以排查时一定要看依赖树,不要靠猜。

6.2 空指针与逻辑陷阱

空指针排在Java线上异常榜首,已经很多年了。工具类能帮你规避一部分,但规避不了所有。比如Objects.equals(a, b)能处理a、b为null的情况,但如果a是集合,你想比较两个集合内容是否相同,直接equals其实是正确的。真正要注意的是:集合里的元素是否为null,会影响equals的结果吗?答案是取决于元素类型的equals实现。

我见过一个业务bug:两个List中都有null元素,比较结果时好时坏。原因是某些实体类重写equals时没有先判空,内部调用字段的equals直接NPE。后来我们用Comparator结合Objects.equals解决了一部分,但根本办法还是让实体类的equals实现本身足够健壮,或者直接改用Objects.deepEquals。这个坑提醒我们:工具类只是帮手,你自己的对象设计才是根因。

字符串相关的逻辑陷阱也不少。比如StringUtils.isBlank判断的是“可打印字符”,如果你传入Unicode零宽空格,它可能不会认为这是空白。这在处理用户输入、外部接口数据时尤其危险。我遇到过一次用零宽空格伪造空内容提交表单的攻击,后来加了自定义校验才堵住。工具类不是万能的,遇到边界业务必须补齐逻辑。

6.3 排查工具类问题的心法

最后分享几个排查工具类相关问题的独门经验,都是实际里一点点磨出来的。

第一,拿到报错先看栈顶。大部分工具类问题会直接暴露方法名和行号,比如String.substring越界、Collectors.toList中元素为null,栈顶一目了然。不要一上来就翻配置、重启服务,先看代码。第二,出问题时写一个最小的复现测试,不要拿真实接口一遍遍试。用JUnit把输入数据构造出来,通常几行代码就能定位到是工具类边界没处理好,还是业务逻辑调用错了。第三,别迷信框架自带的工具类。有些框架内部工具类没有完整API,也没有长期维护,比如某老项目的内部DateUtil。能用标准库或成熟第三方库就别自己造,除非你愿意持续维护。

还有一个小技巧:给团队做一个“工具类使用规范”文档,把常用方法的最佳实践和常踩的坑写进去。比如“一律用StringUtils.isBlank判断空”“禁止使用new SimpleDateFormat”“排序用Comparator.comparing链”。这种文档的价值不在于它多长,而在于它把散落在代码评审里的经验沉淀下来,减少重复踩坑。我自己的团队做了这件事之后,工具类相关的问题数量下降得非常明显。

我个人在实际操作里的体会是:工具类听起来很基础,但它直接影响代码的健壮性和可维护性。不要在项目快上线时才想起Java代码里还有一堆手写循环和裸奔的SimpleDateFormat,平时写每一行调用时多想想这个场景是不是已经有标准答案。你真正要做的是把工具类当成项目的基础设施来对待,选好、用好、封装好自己那部分,剩下的时间就能安心去处理真正棘手的业务逻辑。后面如果大家感兴趣,我可以再接着聊聊第二期:IO流、文件操作与异常处理里的工具类实践。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询