ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java三大易错点:时间日期、包装类与正则表达式实战全解

Java三大易错点:时间日期、包装类与正则表达式实战全解 不绕弯子直接说结论。这三个东西——时间与日期、包装类、正则表达式——是Java进阶路上特别容易“看着会了、一用就废”的三块内容。很多自学的朋友学到集合、IO之后就觉得基础差不多结束了结果一去面试或者一接手老项目立刻被SimpleDateFormat的并发问题、Integer的比较、还有那段能匹配身份证号码的正则打得晕头转向。我写这篇文章的出发点很简单把这三大块内容放到一起从头到尾捋一遍。它们表面上互不相干实际上都在解决同一个问题——怎么让Java程序在处理真实世界的数据时少踩坑、不返工。时间日期是数据的时间维度包装类是基本类型和对象之间的桥梁正则表达式是文本处理的瑞士军刀。这篇文章适合三类人看正在准备Java面试的人、自学到进阶阶段想系统补课的人以及在项目里被日期格式化和字符串校验折磨过的开发朋友。下面直接进入正题。1. 这三个主题为什么值得放到一起啃先聊一个很现实的问题市面上的Java教程多如牛毛单独讲日期的、讲正则的、讲包装类的到处都是。但把它们串起来讲的东西不多。而我个人的体会是这三个知识点恰好对应了Java开发中三类最高频的“脏活累活”处理时间、处理数值对象、处理文本校验。哪个项目能离得开这三样几乎没有。从面试的角度看这三个主题也是“八股文”的重灾区。你去搜“java面试题”翻到基础部分翻来覆去就是那几个问题Integer的缓存范围是多少和equals到底怎么比SimpleDateFormat为什么线程不安全正则表达式里贪婪匹配和懒惰匹配有什么区别这些问题单独拎出来都能说两句但很少有文章把它们放在一个上下文里讲清楚它们背后依赖的底层机制。这正是这篇文章想补齐的部分。还有一个更重要的原因这三块内容的学习方式是一样的——光看不练假把式。时间日期的API你不写几个LocalDate的链式调用就体会不到它比Calendar好在哪包装类你不亲自跑几个比较的代码永远记不住缓存池的边界值正则表达式你不去匹配几段真实的日志文本就分不清matches和find的区别。所以这篇文章里的每个部分我都会给出可以直接复制运行的代码看完之后建议你立刻动手试一遍。2. 时间与日期从Calendar到java.time一次讲透2.1 老API的痛点Date和Calendar为什么被诟病如果你写过一点老Java代码一定见过这种场面Date date new Date(2024, 11, 25); // 月份从0开始11代表12月这个代码实际上是2024年12月25日因为java.util.Date的月份是从0开始计算的。而且更离谱的是这个带参构造方法在Java 1.1之后就被标记为废弃了但很多老教材还在教。再往下走Calendar的坑更隐蔽Calendar calendar Calendar.getInstance(); calendar.set(2024, Calendar.DECEMBER, 25, 10, 30, 0);月份又变成了从0开始的int常量。同一个体系里一会儿用11表示12月一会儿用Calendar.DECEMBER这种混乱的设计让无数初学者望而生畏。但真正致命的问题不是API难记而是线程安全。SimpleDateFormat内部有一个Calendar引用多个线程共享同一个实例去parse或format时会出现数据错乱。我曾在生产环境遇到过一个问题一个全局的SimpleDateFormat实例被多个线程调用解析出来的时间偶尔会差几个小时排查了很久才定位到是线程安全问题。这种问题在并发量上来之后几乎是必现的。2.2 Java 8新时间API的核心设计逻辑Java 8推出的java.time包彻底改变了这个局面。它有几条极其重要的设计原则理解了这些你就掌握了这个API的使用精髓不可变性。LocalDate、LocalDateTime、Instant这些类都是不可变的每次操作都返回一个新的对象线程安全成为默认属性。比如date.plusDays(1)不会修改原对象而是返回一个加了一天的新对象。人机分离。机器看时间用Instant时间戳从1970-01-01T00:00:00Z开始算秒和纳秒人看时间用LocalDate/LocalDateTime年、月、日、时、分、秒带时区就用ZonedDateTime。这种分离避免了老API里“一个对象既要表达时间戳又要表达人类日历”的混乱。流畅的API设计。以日期操作为例LocalDate today LocalDate.now(); LocalDate nextMonth today.plusMonths(1).withDayOfMonth(1); // 下个月第一天 DayOfWeek dayOfWeek today.getDayOfWeek(); // 直接拿到星期几再看时间间隔的计算。两个日期之间差多少天、多少月用Period两个时间之间差多少小时、多少秒用DurationLocalDate start LocalDate.of(2024, 1, 1); LocalDate end LocalDate.of(2024, 12, 31); Period period Period.between(start, end); System.out.println(period.getMonths()); // 11注意这是整月差值这里有个容易忽略的点Period.between返回的是“年、月、日拆分”的结果如果你想算“总共多少天”需要用ChronoUnit.DAYS.between(start, end)。2.3 格式化与解析的正确姿势老代码里用SimpleDateFormat的地方现在全部换成DateTimeFormatter。它是线程安全的可以安全地作为常量复用DateTimeFormatter formatter DateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss); LocalDateTime now LocalDateTime.now(); String text now.format(formatter); // 格式化 LocalDateTime parsed LocalDateTime.parse(text, formatter); // 解析关于格式模式有一个特别容易踩的坑yyyy和YYYY的区别。yyyy是日历年份YYYY是“周周年份”Week-Based Year。在跨年那一周YYYY可能会显示错误的年份。比如2024年12月30日用YYYY-MM-dd格式化可能输出2025-12-30因为这一周属于2025年的第一周。记牢日常业务统一用yyyy。再说一个实用技巧解析字符串时DateTimeFormatter默认要求解析严格比如2024-02-30会被判定为非法日期并抛出异常而不是自动进位。这是好事可以在数据入库前就拦住脏数据。3. 包装类基本类型和对象之间的那座桥3.1 为什么需要包装类对应关系一览Java是面向对象语言但int、double、boolean这些基本类型不是对象。为了让它们在集合、泛型、反射等场景中“变身”为对象就有了包装类。最简单的例子ListInteger里不能放int只能放Integer。八种基本类型与包装类的对应关系是面试常客基本类型包装类默认值包装类intIntegernulllongLongnullshortShortnullbyteBytenullcharCharacternullfloatFloatnulldoubleDoublenullbooleanBooleannull注意看默认值这一列。基本类型int的默认值是0但包装类Integer的默认值是null。这就引出了一个实战中特别容易犯的错误从数据库或远程接口拿到一个Integer字段直接拆箱赋值给int变量结果因为值是null抛出NullPointerException。我见过太多人在这上面栽跟头。3.2 自动装箱拆箱的机制与陷阱自动装箱和拆箱是Java编译器提供的语法糖。所谓自动装箱就是把int自动转换为Integer底层调的是Integer.valueOf(int)拆箱就是把Integer自动转换为int底层调的是Integer.intValue()。这看起来很方便但藏在背后的缓存机制往往让人困惑。Integer内部有一个缓存池默认缓存了-128到127之间的Integer对象。当你在代码里写Integer a 100时Java会调用valueOf如果值在缓存范围内直接返回缓存中的同一个对象如果超出范围才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超出缓存范围是两个不同的对象在没有理解缓存池之前这个结果非常反直觉。底层的逻辑其实就是valueOf内部的一个if判断low i i high。high的值可以通过JVM参数-XX:AutoBoxCacheMax调整但在默认情况下记住-128到127就够了。还有一种更绕的写法Integer e new Integer(100); Integer f 100; System.out.println(e f); // falsenew出来的对象和缓存对象不是同一个引用原因很简单new Integer(100)强制创建了一个新对象不参与缓存。所以日常开发中坚决写Integer.valueOf或者直接赋值不要写new Integer。3.3 包装类比较的正确方法equals与parseXxx既然对于包装类这么不可靠那比较两个包装类对象的内容就应该用equals方法Integer x 128; Integer y 128; System.out.println(x.equals(y)); // true比较的是值但要特别注意混合比较的场景。当一个包装类和一个基本类型用比较时包装类会自动拆箱变成基本类型所以结果是基于数值比较的Integer m 128; int n 128; System.out.println(m n); // truem自动拆箱为128再比较这个规则和“Integer之间用”不一样千万别混为一谈。除了比较之外包装类还提供了一组parseXxx和valueOf方法用来在字符串和基本类型之间转换这也是高频考点int num1 Integer.parseInt(123); // 返回int Integer num2 Integer.valueOf(123); // 返回Integer有缓存逻辑注意区别parseInt返回基本类型intvalueOf返回包装类Integer。在不需要对象的时候用parseInt更省内存。类似的还有Double.parseDouble、Boolean.parseBoolean等。解析时如果字符串格式不对会抛出NumberFormatException所以调用之前最好确认或预先做格式校验。4. 正则表达式文本处理的瑞士军刀4.1 正则基础元字符、字符类与量词正则表达式Regular Expression是一种用于匹配字符串的“小语言”。在Java里核心就两个类Pattern和Matcher。Pattern是正则表达式的编译表示线程安全可以复用Matcher是执行匹配的状态器非线程安全每次匹配应该创建新的。先搭建一个基础框架后面所有正则验证都在这个框架上进行import java.util.regex.Matcher; import java.util.regex.Pattern; Pattern pattern Pattern.compile(\\d); Matcher matcher pattern.matcher(我的电话是12345邮编100000); while (matcher.find()) { System.out.println(matcher.group()); } // 输出12345 和 100000最基本的元字符我整理了一个速查表你可以直接收藏元字符/表达式含义示例.匹配任意单个字符换行除外a.c匹配abc、adc\d匹配数字等价于[0-9]\d{3}匹配三位数字\w匹配字母、数字、下划线\w匹配一个单词\s匹配空白字符a\sb匹配a b[]字符类匹配其中一个[abc]匹配a、b或c^匹配字符串开头^Java匹配以Java开头的字符串$匹配字符串结尾Java$匹配以Java结尾的字符串*前面的元素出现0次或多次ab*c匹配ac、abc、abbc前面的元素出现1次或多次abc匹配abc、abbc不匹配ac?前面的元素出现0次或1次ab?c匹配ac、abc{n,m}前面的元素出现n到m次\d{2,4}匹配2到4位数字注意在Java字符串里写正则反斜杠要转义。正则里写\d在Java源码里要写成\\d。这是新手最容易忽视的问题漏掉一个反斜杠编译器直接报错。4.2 常用的几种实际校验场景先说最容易被问到的身份证号码校验。身份证号是18位前6位是地区第7到14位是出生日期第15到17位是顺序码第18位是校验码可以是数字或X。一个完整的正则如下String idCardRegex ^[1-9]\\d{5}(19|20)\\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\\d|3[01])\\d{3}[\\dXx]$; Pattern pattern Pattern.compile(idCardRegex); System.out.println(pattern.matcher(110101199003077419).matches()); // true这段正则看起来很长但拆开就很清晰[1-9]\\d{5}匹配前6位地区码不以0开头(19|20)\\d{2}限制出生年份是1900-2099(0[1-9]|1[0-2])限制月份是01-12(0[1-9]|[12]\\d|3[01])限制日期是01-31\\d{3}[\\dXx]匹配最后4位。这样已经能过滤掉大量错误数据了比如月份是13、日期是32这种。更进一步最后一位校验码可以用加权因子算法在Java代码里再验证一次正则负责格式代码负责数学校验。然后是手机号校验。国内手机号目前以1开头第二位是3-9。一个兼容性较好的写法是String phoneRegex ^1[3-9]\\d{9}$;它匹配11位数字第一位为1第二位为3到9后面9位任意数字。对大部分校验场景来说这个正则够用了。如果你要精确到某家运营商的号段就需要维护一个当前号段列表正则本身无法完全替代号码段数据。最后是日志文本解析。在服务端处理访问日志时用正则提取关键数据非常高效。比如从一行日志中提取IP和时间String log 192.168.1.100 - - [25/Dec/2024:10:30:00 0800] \GET /index.html\ 200; Pattern ipPattern Pattern.compile((\\d{1,3}\\.){3}\\d{1,3}); Matcher ipMatcher ipPattern.matcher(log); if (ipMatcher.find()) { System.out.println(IP: ipMatcher.group()); } Pattern timePattern Pattern.compile(\\[(.*?)\\]); Matcher timeMatcher timePattern.matcher(log); if (timeMatcher.find()) { System.out.println(时间: timeMatcher.group(1)); // 25/Dec/2024:10:30:00 0800 }这里用了(.*?)这是一个典型的非贪婪匹配后面详说。4.3 贪婪匹配与懒惰匹配一个改变结果的细节正则的量词默认是贪婪的。这意味着.*会尽可能多地匹配字符。看这个例子String html nameJava/namenamePython/name; Pattern greedyPattern Pattern.compile(name(.*)/name); Matcher greedyMatcher greedyPattern.matcher(html); if (greedyMatcher.find()) { System.out.println(greedyMatcher.group(1)); // Java/namenamePython }贪婪模式下.*会一直匹配到最后一个/name才停止结果把两段内容全吞进去了。这时候你需要在量词后面加一个?变成懒惰模式Pattern lazyPattern Pattern.compile(name(.*?)/name); Matcher lazyMatcher lazyPattern.matcher(html); while (lazyMatcher.find()) { System.out.println(lazyMatcher.group(1)); // Java 然后 Python }懒惰模式非贪婪模式下.*?会尽可能少地匹配找到最近的/name就停下来。这个区别在解析HTML标签、JSON片段、日志中被双引号包裹的字段时尤其重要。我自己的习惯是只要是从包裹结构里提取内容先用非贪婪匹配然后再校验结果是否符合预期。注意正则中.不会匹配换行如果要跨行匹配需要结合模式标志Pattern.DOTALL。4.4 String类中的正则应用matches、replaceAll、splitJava的String类里也有几个直接支持正则的方法实战中使用频率很高。matches()是全文匹配要求整个字符串都匹配正则String email userexample.com; System.out.println(email.matches(^[\\w.%-][\\w.-]\\.[A-Za-z]{2,}$)); // truereplaceAll()按正则替换还能引用捕获分组。比如把一串字符串里的手机号中间四位打码String phone 13812345678; String masked phone.replaceAll((\\d{3})\\d{4}(\\d{4}), $1****$2); System.out.println(masked); // 138****5678这里$1和$2分别引用第一组和第二组捕获的内容这是正则反向引用在实际工作中最常见的用法。注意$1用的是数字分组序号不是名称。split()按正则拆分字符串但有一个小坑如果分隔符是正则元字符比如.、|、*需要转义。比如按点号拆分IP地址String ip 192.168.1.1; String[] parts ip.split(\\.); // 不能写 split(.) System.out.println(Arrays.toString(parts)); // [192, 168, 1, 1]如果写split(.)结果会是一个空数组因为.在正则里匹配任意字符整个字符串被拆没里了。这类问题在面试题里反复出现也是实际编码中容易忽略的地方。5. 高频面试题与实战避坑指南5.1 日期API面试问答要点为什么老的Date初始化时年份要减1900因为Date的老设计是直接对底层C语言时间结构体的包装那个结构体里tm_year字段存的就是从1900年起的偏移量。这个设计在今天看来完全不合理所以后来被废弃了。面试官喜欢问这个问题主要是看面试者有没有真正研究过老API的设计背景而不只是背API。SimpleDateFormat 线程不安全是什么导致的因为SimpleDateFormat内部有一个共享的Calendar对象实例多个线程同时调用format或parse时会操作同一个Calendar导致相互污染。解决办法有三个每次调用时新建实例用ThreadLocal为每个线程保存一份或者更好的办法是直接用DateTimeFormatter它天然线程安全。时间戳和时间日期有什么关系Instant是机器时间LocalDateTime是带年月日时分秒的人类时间。后端存储和日志里用Instant或者long时间戳接口返回和展示用LocalDateTime格式化后的字符串一般不要直接用Date在国内外接口间传值时区问题很容易在跨时区场景里埋雷。5.2 包装类的几个经典面试场景面试官最爱问的一个是Integer缓存范围上面已详述另一个是BigDecimal和包装类的选择。浮点类型float、double在金额计算中会产生精度问题比如0.1 0.2在double里根本不是0.3。所以涉及金额、汇率等精度敏感的字段应该使用BigDecimal构造时推荐用字符串构造方法new BigDecimal(19.99)而不是new BigDecimal(19.99)后者依然会带入浮点误差。另外一个常见的坑是Boolean和boolean的默认值。数据库里的boolean字段查询出来如果用基本类型boolean接收碰到数据库里NULL的值会直接抛异常。用包装类Boolean接收则可以得到null然后在代码里判断if (Boolean.TRUE.equals(flag))。这一点在写实体类时尤其重要很多ORM框架要求你使用包装类型。关于比较我再总结一个口诀两个都是包装类要内容比较请用equals包装类和基本类型混合比较会自动拆箱。把这句话记牢能少写很多冤枉的调试时间。5.3 正则表达式开发中的疑难杂症正则表达式的维护性是一个大问题。在团队项目里一句没有任何注释的^[1-9]\\d{5}(19|20)\\d{2}(0[1-9]|1[0-2])...除了写它的人没人能看懂。我的建议是把正则拆解成带有命名的片段用常量拼接这样后续维护改起来也方便private static final String ID_CARD_REGEX ^[1-9]\\d{5} // 地区码 (19|20)\\d{2} // 出生年份 (([0]?[0-9])|(1[0-2])) // 月份 ...另一个容易出问题的点是正则的matches()和find()的混淆。matches()要求整个字符串完全匹配正则相当于隐含了^和$find()则是扫描字符串找到其中任一匹配的子串即可。如果你用matches()去一个长文本里找手机号永远返回false因为文本长度超过了手机号的位数。这是我见过最多的一类误用。还有性能问题。如果一段正则会在高并发下被反复编译一定要编译成Pattern并复用。String.matches()每次调用都会重新编译一次正则在高频场景下会有明显的性能开销。正确做法是定义静态常量private static final Pattern PHONE_PATTERN Pattern.compile(^1[3-9]\\d{9}$); public boolean isPhone(String input) { return PHONE_PATTERN.matcher(input).matches(); }5.4 项目实战中的综合运用案例最后把这三个知识点融合到一起模拟一个具体场景解析一段“包含时间戳、手机号、订单号”的日志提取数据并校验格式。这类场景在运营后台的数据清洗任务中很常见也是综合检验上述内容的好例子。import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.regex.*; public class LogDataParser { private static final Pattern ORDER_PATTERN Pattern.compile(ORD(\\d{10})); private static final Pattern PHONE_PATTERN Pattern.compile(1[3-9]\\d{9}); private static final DateTimeFormatter FMT DateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss); public static void main(String[] args) { String logLine 2024-12-25 10:30:00 ORD20241225001 13812345678 下单成功; // 1. 提取订单号 Matcher orderMatcher ORDER_PATTERN.matcher(logLine); if (orderMatcher.find()) { System.out.println(订单号: orderMatcher.group(1)); } // 2. 提取并校验手机号 Matcher phoneMatcher PHONE_PATTERN.matcher(logLine); if (phoneMatcher.find()) { String phone phoneMatcher.group(); System.out.println(手机号: phone 格式合法: isPhoneValid(phone)); } // 3. 解析日志头部的时间字符串 String timePart logLine.substring(0, 19); LocalDateTime time LocalDateTime.parse(timePart, FMT); System.out.println(解析时间: time.plusDays(1)); } private static boolean isPhoneValid(String phone) { return PHONE_PATTERN.matcher(phone).matches(); } }这个案例里正则负责提取和格式校验新时间API负责解析和计算包装类虽然没有显式出现但LocalDateTime.parse内部依赖了大量包装类逻辑在幕后完成了数据转换。一条日志从原始字符串变成可操作的结构化数据整个过程清晰可控。6. 我踩过的坑和给你的一点建议最后说几句实在的。这三个主题我当年都是靠踩坑才真正记住的。印象最深的是有一次接手一个老项目里面用SimpleDateFormat做订单时间的格式化上线之后每到整点订单量暴涨的时候就会出现几个订单时间错乱。那次排查花了两天最后定位到线程安全问题换成DateTimeFormatter之后彻底消停了。从那以后我就形成了一个习惯见到老代码里的SimpleDateFormat和new Integer条件反射地警惕起来。还有一次是正则表达式当时要写一个校验邮箱的正则从网上抄了一段看起来很全的表达式结果上线后发现一个用户的邮箱被误拦了——那是一个带有号的企业邮箱。后来我把正则改成^[\\w.%-][\\w.-]\\.[A-Za-z]{2,}$专门给号留了位置。这个经历告诉我正则表达式的边界情况比想象中多测试一定要覆盖异常输入包括空字符串、超长字符串、包含特殊字符的字符串。至于学习建议我认为最有效的路径是先把这个基础知识点过一遍然后立刻去写代码最后再回头对照面试题查漏补缺。时间日期、包装类、正则表达式这三块内容熟练之后会极大减少你处理脏数据的成本。后续我还会整理BigDecimal的完整使用细节以及java.time在数据库交互中的注意事项等写出来再分享。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进