Java进阶必学:日期处理、包装类与正则表达式的实战避坑指南
发布时间:2026/10/6 19:41:11 锦皓数字建站

不少刚学完Java基础的朋友都会卡在同一个地方语法都看懂了但一到写日期处理、做数据校验、对比两个Integer是否相等就各种翻车。时间与日期、包装类、正则表达式这三块在Java里看着各不相干实际上经常被一起拿来做数据清洗、参数校验和日志解析。很多面试题也喜欢把这三个点揉在一起考比如“如何从一段文本中提取日期并校验合法性”。这篇文章就系统聊一聊这三个进阶点把原理、常见坑和实操经验一次讲透。1. 时间与日期从java.util.Date到java.time的演进1.1 旧时代的时间API为什么让人崩溃我刚开始写Java的时候处理日期用的是java.util.Date和java.util.Calendar。当时最直观的痛点是月份从0开始。你写着“7月”得写成Calendar.JULY或者Date里month填6。有过一次血泪经历我写了一个生日提醒功能把Calendar.MONTH直接存库结果所有1月出生的人都被推送到了2月。这种从0开始的设计对新手极其不友好。更致命的是Date是可变的。setTime()方法可以直接改对象内部时间导致同一个Date实例在别的线程里被悄悄改掉等你读到的时候日期已经变了。配合SimpleDateFormat用还会有一个著名问题它不是线程安全的。在多线程环境下共享同一个SimpleDateFormat实例会偶尔出现解析结果错乱甚至直接抛NumberFormatException。当年生产环境排查过一次最后定位就是静态的SimpleDateFormat在高并发下被多个线程同时用内部Calendar状态互相污染。Calendar也好不到哪去API设计得极其别扭想给某个日期加一天得add(Calendar.DAY_OF_MONTH, 1)读字段还要get(Calendar.YEAR)。代码写出来又长又容易错。所以Java 8推出了全新的java.time包JSR-310核心设计思想就是“不可变对象 清晰的分工”从根上治好了这些毛病。1.2 java.time核心类怎么选java.time包里的类很多但实际开发中90%的场景只需要掌握这几个类名含义适合场景LocalDate日期不含时间生日、节假日、排期LocalTime时间不含日期开门时间、提醒时间LocalDateTime日期时间不含时区本地业务时间、日志时间Instant时间戳绝对时间点跨时区通信、系统日志ZonedDateTime日期时间时区全球用户场景、定时调度我自己的选型经验是如果你的系统不涉及跨时区优先用LocalDateTime一旦涉及国际化或者要对接第三方接口带时区的时间立刻切换到Instant或ZonedDateTime。别图省事把所有时间都存成String或者long后面做比较和计算会非常痛苦。举例说明创建方式// 当前时间 LocalDate today LocalDate.now(); LocalDateTime now LocalDateTime.now(); // 指定时间 LocalDate dateOfBirth LocalDate.of(1995, 5, 20); LocalDateTime meeting LocalDateTime.of(2025, 12, 1, 14, 30); // 从字符串解析 LocalDate parsed LocalDate.parse(2025-10-01);这里有个细节LocalDate.parse默认只能解析yyyy-MM-dd格式LocalDateTime.parse默认解析ISO格式2025-10-01T14:30:00中间是T不是空格。如果碰到2025-10-01 14:30:00这种带空格的字符串直接parse会抛异常必须配合DateTimeFormatter。1.3 格式化与解析DateTimeFormatter的正确用法DateTimeFormatter是替代SimpleDateFormat的线程安全类。设计上它是不可变的所以可以放心定义为static final字段全局共享。推荐做法public static final DateTimeFormatter DATE_FORMAT DateTimeFormatter.ofPattern(yyyy-MM-dd); public static final DateTimeFormatter DATETIME_FORMAT DateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss); // 格式化 LocalDateTime now LocalDateTime.now(); String text now.format(DATETIME_FORMAT); // 解析 LocalDateTime parsed LocalDateTime.parse(2025-10-01 14:30:00, DATETIME_FORMAT);使用时有几个点要注意y表示年Y表示Week-Based Year基于周的年份这俩在跨年时可能会差一年。比如2025-12-31这一天如果用YYYY-MM-dd格式化可能会输出2026-12-31。这是超级经典的一个坑我在项目中就遇到过报表日期在年底突然多出来一笔未来数据。M和mm分别代表月份和分钟大小写敏感。写错只会在运行时报异常不会编译报错。遇到需要毫秒、时区的场景用yyyy-MM-dd HH:mm:ss.SSS XXX这种组合但XXX需要搭配带时区的对象如ZonedDateTime否则解析时会报错。1.4 日期时间计算与时间间隔旧API里做日期计算要写一堆Calendar.add新API则把操作直接暴露为方法LocalDateTime now LocalDateTime.now(); // 加一天、减一个小时、设置到月初 LocalDateTime tomorrow now.plusDays(1); LocalDateTime anHourAgo now.minusHours(1); LocalDateTime firstDayOfMonth now.withDayOfMonth(1); // 计算两个日期之间的天数 LocalDate start LocalDate.of(2025, 1, 1); LocalDate end LocalDate.of(2025, 12, 31); long days ChronoUnit.DAYS.between(start, end); System.out.println(days); // 364TemporalAdjusters更强大比如“下一个周二”“本月的最后一天”LocalDate nextTuesday LocalDate.now().with(TemporalAdjusters.next(DayOfWeek.TUESDAY)); LocalDate lastDayOfMonth LocalDate.now().with(TemporalAdjusters.lastDayOfMonth());计算两个时刻之间的时间差用Duration计算日期差年、月、日用Period。比如记录一个人从出生到现在过了几年LocalDate birth LocalDate.of(1995, 5, 20); LocalDate today LocalDate.now(); Period period Period.between(birth, today); System.out.println(period.getYears() 年 period.getMonths() 个月 period.getDays() 天);1.5 与旧API互转及最佳实践旧系统升级改造时经常需要Date和LocalDateTime互转。转换桥梁是Instant// Date - LocalDateTime Date oldDate new Date(); LocalDateTime local oldDate.toInstant() .atZone(ZoneId.systemDefault()) .toLocalDateTime(); // LocalDateTime - Date LocalDateTime localNow LocalDateTime.now(); Date newDate Date.from(localNow.atZone(ZoneId.systemDefault()).toInstant());从数据库拿到java.sql.Date、java.sql.Timestamp时也可以直接用它们自带的转换方法java.sql.Timestamp timestamp ...; LocalDateTime local timestamp.toLocalDateTime();使用新API这么久我的经验就是一句话新代码一律用java.time不要在代码里再新建SimpleDateFormat了。如果非要和旧代码对接只留在Controller边界或Repository边界做转换业务逻辑里全部用java.time的不可变对象这样能省掉无数线程安全、时区错乱、格式不一致的麻烦。2. 包装类自动装箱拆箱的底层逻辑与性能陷阱2.1 为什么需要包装类Java是面向对象语言但基本类型int、double、boolean不是对象。集合框架ListT、MapK,V的泛型要求元素必须是引用类型所以Listint这种写法在Java中不存在必须用Integer来“包装”。另外基本类型没有null的概念而数据库字段、接口返回值经常会“空”这时候就需要包装类的null来表达“没有值”。每个基本类型都有对应的包装类基本类型包装类父类byteByteNumbershortShortNumberintIntegerNumberlongLongNumberfloatFloatNumberdoubleDoubleNumbercharCharacterObjectbooleanBooleanObject注意char和boolean的父类是Object不是Number因为它们在概念上不是数值型。2.2 装箱与拆箱的字节码真相自动装箱和拆箱是编译器提供的语法糖。你写Integer a 100; // 自动装箱 int b a; // 自动拆箱编译后真正执行的是Integer a Integer.valueOf(100); int b a.intValue();也就是说装箱会调用包装类的valueOf静态方法拆箱会调用对应的xxxValue实例方法。如果你反编译class文件能看到invokestatic Integer.valueOf和invokevirtual Integer.intValue这两行字节码指令。这个底层机制解释了为什么包装类做加减运算是安全的Integer a Integer b实际上是先拆箱成int再相加结果再装箱成Integer。性能损耗就在这里一次拆箱一次装箱循环次数多了就会拖慢速度。2.3 缓存机制与比较陷阱这是Java面试必考题。Integer类内部维护了一个缓存池默认缓存-128到127之间的所有Integer对象。当你用Integer.valueOf(127)时拿到的是缓存池里的同一个对象当你用Integer.valueOf(128)时才会new一个新的对象。看这段代码Integer a 127; Integer b 127; System.out.println(a b); // true Integer c 128; Integer d 128; System.out.println(c d); // false因为是两个不同对象 System.out.println(c.equals(d)); // true比较的是数值这个设计基于一个统计认知大部分程序使用的小整数都在这个范围内缓存能显著减少对象创建。Long、Short也有类似缓存但Float和Double没有因为浮点数的数量太多缓存没有意义。Character缓存0~127。实际开发中只要是比较两个包装类的值一律用equals()不要用。倒不是说总是错的而是比较的是引用地址只有数值在缓存范围内才碰巧成立。一旦范围变大结果和你预期的完全不同代码里出现这种bug极难排查。2.4 高频陷阱空指针、性能与类型转换我整理过几个包装类最经典的坑每个都踩到过。第一个坑自动拆箱导致空指针异常。下面的代码看着人畜无害但运行一定会崩Integer count null; int result count 1; // 编译通过运行时抛NPE因为编译器会自动拆箱count.intValue()被执行时count是null直接抛NullPointerException。更隐蔽的版本是三元表达式Integer a null; boolean flag true; int result flag ? a : 0; // NPE为什么因为三元运算符要求两个分支的类型一致a是Integer0是int编译器会强制把a拆箱成int于是空指针了。这个bug在代码review时很难发现只有跑到某条分支才会炸。第二个坑循环内的高频装箱拆箱。比如把基本类型列表转包装类型列表或者做累加long start System.currentTimeMillis(); Integer sum 0; for (int i 0; i 10000000; i) { sum i; // 每次循环拆箱、计算、装箱 } long end System.currentTimeMillis(); System.out.println(耗时: (end - start) ms);同样的逻辑如果用int sum执行耗时可能是几十毫秒用Integer可能上千毫秒。在写高频累加、统计时不要为了“优雅”用包装类直接用基本类型。第三个坑parseXxx和valueOf混淆。Integer.parseInt(123)返回基本类型intInteger.valueOf(123)返回包装类型Integer。需要基本类型时用parseInt更高效需要包装类型时用valueOf。如果代码里被迫接收Object想取出int值先强转成Number再intValue()不要先转String再parseInt。第四个坑数值比较用equals不会错但要注意方法重载。比如List.remove(Object)和List.remove(int)传一个Integer进去如果列表里存的是Integer而你又想按索引删除很容易调错重载。这算是一道很刁钻的面试题如果遇到了先想清楚参数类型匹配哪个方法。实战经验补充一条接受用户输入的数字时尽量先用String接收再用Integer.parseInt校验不要直接用Integer类型接收非法输入。因为parseInt能抛出带有具体格式的异常方便你返回给前端“参数格式错误”而框架层如果直接把空串转Integer可能给你一个难看的类型转换错误。3. 正则表达式从入门到实战的Java写法3.1 Java正则的两大核心类Java处理正则表达式主要用java.util.regex包下的Pattern和Matcher。基本用法是三步用Pattern.compile(String regex)编译正则得到一个Pattern对象。用pattern.matcher(CharSequence input)得到Matcher对象。用Matcher的matches()、find()、lookingAt()等方法执行匹配。matches()要求整个字符串完全匹配正则find()是在字符串中查找是否有子串匹配lookingAt()是从开头开始匹配但不要求匹配到末尾。这个区别太重要了我就见过有人在校验手机号的时候用了find()结果一段乱文本里包含“138****”都校验通过了。Pattern pattern Pattern.compile(\\d{11}); String text 我的电话是13812345678另外一个是13900001111; Matcher matcher pattern.matcher(text); while (matcher.find()) { System.out.println(matcher.group()); } // 输出两个11位数字Pattern对象是线程安全的可以全局复用Matcher对象则不是线程安全的每个线程需要自己创建但通常用完即弃问题不大。3.2 常用语法速览与分组捕获正则的语法在不同语言里大同小异Java里最容易出错的地方是反斜杠转义。在Java字符串里\d必须写成\\d\\.必须写成\\.因为Java编译器会先把字符串里的\\解析成一个反斜杠正则引擎再识别。常用符号语法含义Java写法数字匹配一位数字\\d非数字匹配非数字\\D单词字符字母、数字、下划线\\w空白符空格、Tab等\\s任意字符换行除外.零次或一次可选?零次或多次任意匹配*一次或多次至少一个大括号指定次数\\d{3}/\\d{3,}/\\d{3,5}分组捕获内容()分组是实际开发中非常常用的能力。正则里的( ... )会把匹配到的子内容捕获供后续提取使用。看一个解析日志的例子String log 2025-10-01 14:30:00 ERROR [OrderService] 订单超时; Pattern pattern Pattern.compile((\\d{4}-\\d{2}-\\d{2}) (\\d{2}:\\d{2}:\\d{2}) (\\w) \\[([^\\]])]); Matcher matcher pattern.matcher(log); if (matcher.find()) { System.out.println(日期: matcher.group(1)); System.out.println(时间: matcher.group(2)); System.out.println(级别: matcher.group(3)); System.out.println(组件: matcher.group(4)); }Java 7开始还支持命名分组可读性更高Pattern pattern Pattern.compile((?year\\d{4})-(?month\\d{2})-(?day\\d{2})); Matcher matcher pattern.matcher(2025-10-01); if (matcher.matches()) { System.out.println(matcher.group(year)); System.out.println(matcher.group(month)); System.out.println(matcher.group(day)); }3.3 实战案例身份证号码、手机号、邮箱校验这里直接给出我项目中常用的几个正则已经过实际验证。手机号校验中国大陆11位以1开头第二位目前是3-9后面9位数字public static boolean isValidMobile(String mobile) { return mobile ! null mobile.matches(1[3-9]\\d{9}); }matches()方法内部其实是Pattern.matches(regex, input)等价于matcher.matches()要求全字符串匹配所以不需要首尾的^和$。身份证号码校验18位最后一位可能是Xpublic static boolean isValidIdCard(String idCard) { return idCard ! null idCard.matches((\\d{17}[0-9Xx])); }这个正则只是格式校验不校验出生日期合法性。如果要做强校验还需要结合LocalDate解析出生日期判断日期是否真实存在。比如2月30日这种日期正则认不出来但LocalDate.of(year, month, day)会抛异常。这就是标题里三个知识点组合使用的经典场景。邮箱校验平时要求不极高的场景用这套已经足够public static boolean isValidEmail(String email) { return email ! null email.matches(\\w\\w\\.\\w); }更严格的正则很长网上抄来的可能还有各种边界问题。我的建议是邮箱校验别追求“终极完美”能拦截明显不合法格式就行。服务端真正要做的是发送验证邮件靠回链确认邮箱有效性。3.4 爬虫场景与数据处理中的应用很多做数据采集的朋友喜欢用正则从网页里抽内容因为简单直接。比如从HTML里提取链接String html a hrefhttps://example.com/page?id1链接/a; Pattern pattern Pattern.compile(href[\]?([^\\\s])[\]?); Matcher matcher pattern.matcher(html); while (matcher.find()) { System.out.println(matcher.group(1)); }提取数字String text 商品价格¥299库存 10 件销量 2034; Pattern pattern Pattern.compile(\\d); Matcher matcher pattern.matcher(text); while (matcher.find()) { System.out.println(matcher.group()); }这里要提醒一句正则解析HTML只能用于简单、可控的页面。真实网页结构复杂、标签嵌套、属性顺序不固定盲目用正则会写出又臭又长、维护性极差的表达式。如果是生产级数据采集还是推荐用专门的HTML解析器比如Jsoup用CSS选择器定位节点比正则清晰可靠得多。正则适合用来做“提取文本碎片”不适合做“结构化文档解析”。另外爬虫场景里正则和Java的String.replaceAll也是绝配。比如清洗文本中的HTML标签String cleanText html.replaceAll([^], );但这个表达式对带script标签的页面会留下内容建议先单独剔除script和style块再剔除剩余标签效果会好很多。3.5 正则性能与安全预防灾难性回溯正则表达式最大的隐藏风险是灾难性回溯Catastrophic Backtracking。比如这个表达式(a)$如果用它去匹配一串“a”后面跟一个“b”的字符串比如aaaaaaaaaaaaaaaaaaaaab正则引擎会尝试无数种分组方式导致CPU占用飙升甚至卡死整个应用。攻击者可以利用这种“正则漏洞”发起DDoS也就是ReDoS攻击。日常开发中怎么避免尽量使用非贪婪匹配比如用.*?代替.*。避免嵌套的量词比如(a)、(\\d*)*这种写法。能用indexOf、startsWith、split解决的就不要上正则。对用户传入的正则表达式字符串不经过严格校验不要直接使用。实在担心性能可以给匹配操作加上超时控制在Java里用线程或CompletableFuture配合Future.get(timeout)。一条经验我写正则时习惯先在本地写一个小的测试类把边界情况空字符串、超长字符串、包含特殊字符的字符串都跑一遍确认匹配结果和耗时才放进业务代码。正则非常考验细节一个字符的差异可能导致完全不同的行为。4. 综合运用与避坑指南三板斧如何组合使用4.1 案例从一段文本中提取订单信息假设有这么一个需求从一段用户留言中提取日期、手机号、金额并且格式化输出。这正好把时间与日期、正则、包装类三块都用上。我写一个简单的示例import java.time.LocalDate; import java.time.format.DateTimeFormatter; import java.util.regex.Matcher; import java.util.regex.Pattern; public class OrderInfoExtractor { private static final Pattern MOBILE_PATTERN Pattern.compile(1[3-9]\\d{9}); private static final Pattern AMOUNT_PATTERN Pattern.compile((\\d(\\.\\d{1,2})?)元); private static final DateTimeFormatter DATE_FORMAT DateTimeFormatter.ofPattern(yyyy年M月d日); public static void main(String[] args) { String text 我于2025年10月8日下单联系手机13812345678一共支付599.00元请尽快发货。; // 提取日期先用正则抓出yyyy年M月d日片段再用LocalDate校验 Matcher dateMatcher Pattern.compile(\\d{4}年\\d{1,2}月\\d{1,2}日).matcher(text); LocalDate orderDate null; if (dateMatcher.find()) { try { orderDate LocalDate.parse(dateMatcher.group(), DATE_FORMAT); } catch (Exception e) { System.out.println(日期格式不合法); } } // 提取手机号 Matcher mobileMatcher MOBILE_PATTERN.matcher(text); String mobile mobileMatcher.find() ? mobileMatcher.group() : null; // 提取金额用BigDecimal/包装类处理精度 Matcher amountMatcher AMOUNT_PATTERN.matcher(text); if (amountMatcher.find()) { // 注意这里用BigDecimal.valueOf避免double的精度问题 java.math.BigDecimal amount new java.math.BigDecimal(amountMatcher.group(1)); System.out.println(金额: amount); } System.out.println(订单日期: (orderDate ! null ? orderDate : 未识别)); System.out.println(手机号: (mobile ! null ? mobile : 未识别)); } }输出金额: 599.00 订单日期: 2025-10-08 手机号: 13812345678这段代码有几个值得注意的点正则提取的“日期”文本必须交给LocalDate.parse做二次校验因为正则本身不一定能识别2月30日。amountMatcher.group(1)返回的是599.00这样的字符串转BigDecimal比转double更稳妥避免浮点误差。手机号提取用的是find()因为手机号出现在文本中间如果校验一整段输入是不是手机号才用matches()。4.2 常见面试题突击整理几道我面试别人时经常问的题或者说面试中被问烂的题给大家做个自查。第一道Integer a 100; Integer b 100; a b输出什么如果改成1000呢答案100时是true1000时是false。因为Integer.valueOf在-128到127之间返回缓存对象超出范围新建对象。但注意如果通过new Integer(100)创建的就没有缓存效果始终为false。第二道如何将String 2025-10-01 08:30:00解析成LocalDateTime要自定义DateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss)因为LocalDateTime.parse默认接受ISO格式中间带T。同时问一句用YYYY还是yyyy能答出yyyy是年、YYYY是week-based-year的人不多。第三道给定一个字符串如何判断里面是否包含3个连续的数字两种方式用String.matches(.*\\d{3}.*)或者用Pattern.compile(\\d{3}).matcher(input).find()。回答时顺带提一下matches是全匹配find是子串匹配这就是加分项。第四道正则表达式中.*和.*?有什么区别.*是贪婪匹配会尽可能多地匹配字符.*?是懒惰匹配会尽可能少地匹配字符。比如对b1/bb2/b使用b.*/b贪婪模式匹配整个字符串而b.*?/b会匹配两个b.../b。这个例子非常好记。4.3 实战心得少踩坑的几个习惯这三块知识在实际项目中用得好不好差距往往不在“会不会”而在“习惯对不对”。我个人的几条习惯第一包装类判空永远放在第一位。只要看到Integer、Long类型的字段先判断null再拆箱。哪怕是数据库非空字段经过中间层传递也可能变成null。我会在工具类里写一个安全取值方法比如IntegerUtils.parseInt(String)内部完成空白判断、格式校验和默认值处理。第二日期时间统一走java.time不要和Date混用。如果项目还在用Date我会建议先在实体层改用LocalDateTime然后在ORM映射层做转换。MyBatis和JPA从Java 8开始都直接支持LocalDateTime不需要手动转换。一旦统一格式化、加减、比较都清爽很多。第三正则表达式必须写注释。正则可读性极差几个月的代码回头看自己都不认识。我现在的做法是每个复杂正则旁边都写一段注释说明匹配格式、边界行为和典型示例。比如手机号的正则注释里写明“第二位3-9第三位开始9位数字严格11位”。第四用单元测试覆盖正则和包装类的边界。正则的边界情况太多了空字符串、null、超长输入、非法字符我都用JUnit参数化测试跑一遍。包装类的缓存范围测试也很简单写一个assertEquals判断不同数值下的和equals防止自己以后犯迷糊。第五性能意识要绷紧。一个简单的正则如果用在循环里匹配大量日志可能成为性能瓶颈。一个Integer累加如果是千万级循环也会被GC盯上。记住一句话高频场景用基本类型不可变语义用其他的值对象正则先考虑是否真有必要。最后再分享一个小技巧写正则之前先想清楚“我要匹配的是整段文字还是其中的一部分”。如果是校验用户输入“必须是手机号”用matches()如果是“从一篇文章里把手机号都捞出来”用find()循环。开发中很多逻辑错误都源于没分清这个区别。这三个知识点说到底是Java进阶的“基本功”基本功扎实了后面学框架、做设计、搞性能优化都会轻松很多。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。