上周做报表需求时碰到一个很不起眼的问题:数据源从老系统导出来,日期字段全是String类型,规范统一是"2004-07-11"这种写法。统计口径要按年份分组,所以第一步就变成了一道经典 Java 小题——从 String 类型的"2004-07-11"提取年份2004,并且以Integer返回。
需求听起来很简单,真落到代码上才发现,实现方式至少有四五种,每种背后对应不同的格式假设、异常策略和性能取舍。这篇文章就当一次完整复盘,把从最朴素的substring到规范的java.time解析方案都梳理一遍,也会聊几个实际踩过的坑。
1. 拿到“2004-07-11”这种字符串,先别急着写 substring
1.1 这种字符串是怎么来的
很多刚接触的同学会以为日期就是Date或LocalDate类型,现实里大量系统为了兼容性,把日期直接存成String。常见的来源有三个:
- 老系统的数据库字段是
varchar,存的就是"2004-07-11",换库换表的时候没人敢动它。 - 外部接口返回的 JSON 里没有日期类型,前端拿到的就是字符串。
- Excel、CSV 文件导入时,单元格格式本身就是文本,程序读出来必然是
String。
在这些场景里,你没法要求上游“先转好类型再给我”,只能自己处理字符串。也正是因为字符串的不可控性,提取年份这件事才需要认真想清楚。
1.2 动手前先问自己三个问题
接到需求第一反应是substring(0, 4)没问题,但我会先确认三个问题,避免白写:
- 输入格式是不是永远是
"yyyy-MM-dd"? - 有没有可能出现
null、空字符串、前后空格? - 年份提取失败时,是返回
null还是抛异常?
这三个问题直接决定你选哪种写法。如果业务方一口咬定“格式绝对固定”,substring最省事;如果数据来自不可控的上游,那么规范的LocalDate.parse或者一个封装好的工具方法会稳妥得多。很多线上问题都出在“我以为格式固定”上,所以开头多问一句,比后续写一堆修补代码值。
2. 最直接的 substring 方案:三行代码,但前提是格式必须听话
2.1 代码确实简单
先看最直白的写法:
String dateStr = "2004-07-11"; String yearStr = dateStr.substring(0, 4); // "2004" int yearInt = Integer.parseInt(yearStr); // 2004 Integer year = Integer.valueOf(yearInt); // Integer 对象也可以合并成一行:
Integer year = Integer.valueOf(dateStr.substring(0, 4));为什么能这么写?因为"2004-07-11"这个格式里,年份永远占据前 4 个字符,而且这 4 个字符都是数字。这是最直接的字符串剪切思路,不需要引入任何额外对象,代码量最少,执行也快。
2.2 格式一变,substring 就崩给你看
substring最大的问题是它根本不知道自己在处理日期,它只是“切字符串”,所以数据的真实结构一旦发生变化,结果就不可控了。我给你列几个真实可能出现的变体:
| 输入内容 | substring(0, 4) 结果 | 后续 parseInt 表现 |
|---|---|---|
"2004-07-11" | "2004" | 正常得到 2004 |
"2004/07/11" | "2004" | 碰巧正常,但语义上你已经不再校验格式了 |
"2004年07月11日" | "2004年" | 抛出NumberFormatException |
"born:2004-07-11" | "born" | 抛出NumberFormatException |
"2004-7-11" | "2004" | 碰巧正常,但后续日期部分格式已经被忽略 |
"2004-13-99" | "2004" | 碰巧正常,但明显是非法日期 |
这表格里的后几种情况,substring都能“成功”提取出年份,但整个日期可能是错的。如果你只是做粗粒度统计,要的可能就是这四位数字;如果后面还有其他同学接手这段逻辑,他看到"2004-13-99"居然也能返回 2004,大概率会以为业务上允许这种脏数据。这个隐形风险比异常本身更麻烦。
2.3 Integer.parseInt 和 Integer.valueOf 的区别
在这个例子里有个小细节值得单独说一句:Integer.parseInt(String)返回的是基础类型int,而Integer.valueOf(String)返回的是包装类型Integer。题目要求返回Integer,所以直接写Integer.valueOf(dateStr.substring(0, 4))是语法上最贴近需求的。
但注意,Integer.valueOf(String)内部也会做一次解析,如果传入的不是纯数字字符串,它一样会抛NumberFormatException。它只负责“字符串到 Integer 的转换”,不负责“这个字符串是不是合法年份”。测试的时候可以自己写两行 main 验证一下:
System.out.println(Integer.valueOf("2004")); // 2004 System.out.println(Integer.valueOf("2004年")); // NumberFormatException第二个输出会直接抛异常,这就是把字符串处理和日期语义混在一起的最大代价。
3. LocalDate.parse + getYear:规范做法,但也要记住格式限制
3.1 为什么我更推荐 java.time
Java 8 引入java.time包之后,日期解析这件事就应当优先用它。LocalDate.parse默认使用ISO_LOCAL_DATE格式,也就是"yyyy-MM-dd",正好匹配"2004-07-11":
LocalDate date = LocalDate.parse("2004-07-11"); int year = date.getYear(); // 2004 Integer yearBoxed = Integer.valueOf(year);这段代码最大的价值在于:它不只完成了“截取前四位”,而是真正把整个字符串当作日期去解析。"2004-13-11"这种非法月份会被直接拦截,"2005-02-29"这种不存在的日期也不会放行。因为底层是完整的日期解析,读代码的人一眼就能明白:先把字符串转成日期对象,再从日期对象里取年份。语义清晰,不容易误导后来维护的人。
类似的,LocalDateTime.parse处理"2004-07-11T00:00:00"这种带时间的字符串也很方便,取年份的逻辑一样。
3.2 当“2004-7-11”出现时,默认解析会失败
LocalDate.parse默认要求月和日都是两位数。拿"2004-7-11"或"2004-07-1"去解析,会直接抛DateTimeParseException,因为默认格式里月份是两位、日期也是两位。这在某些手工录入的场景很常见,比如用户填表时写成了"2004-7-1"。
遇到这种需求,可以自己定义一个宽松一点的DateTimeFormatter:
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("uuuu-M-d", Locale.ROOT); LocalDate date = LocalDate.parse("2004-7-11", formatter); Integer year = Integer.valueOf(date.getYear());注意我用的是uuuu。uuuu和yyyy在解析公元年份时基本没有差别,但从语言规范角度,uuuu是“proleptic year”,更贴近底层日期计算的语义,社区里越来越多人推荐在日期解析时用uuuu而不是yyyy。新手可以直接记住:解析日期,写uuuu更保险。
3.3 带时间、带格式后缀怎么办
实际数据里还经常出现"2004-07-11 00:00:00"这种带空格带时间的写法。此时LocalDate.parse直接解析会失败,有两个方向:
// 方向一:按 LocalDateTime 解析再取日期 LocalDateTime dateTime = LocalDateTime.parse("2004-07-11T00:00:00"); Integer year = Integer.valueOf(dateTime.getYear()); // 方向二:自定义格式,只取日期部分 DateTimeFormatter formatter = DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm:ss", Locale.ROOT); LocalDateTime dateTime2 = LocalDateTime.parse("2004-07-11 00:00:00", formatter); Integer year2 = Integer.valueOf(dateTime2.getYear());很多人在这里会纠结“多一个 T 少一个空格”的问题。判断标准很简单:先看上游文档,确认到底有没有时间部分、分隔符是T还是空格。如果确实存在多种变体,可以准备两三个DateTimeFormatter依次尝试,而不是写一段脆弱的字符串剪切。
4. 其它“能跑”的方案:正则、SimpleDateFormat、split
4.1 split("-")[0]:取巧但同样依赖分隔符
还有一个常见的写法是:
String dateStr = "2004-07-11"; Integer year = Integer.valueOf(dateStr.split("-")[0]);这种方案比substring(0, 4)更进了一步:不假设年份固定在前四位,而是假设第一段是年份。对于"2004-07-11"它能正确返回,对于"2004/07/11"它会得到一整段"2004/07/11"然后解析失败。
它的问题在于:split是按分隔符切分,不是按日期语义切分。如果字符串里年份前有前缀,比如"born-2004-07-11",它取到的就是"born"。所以这种写法适合你已经确定数据“就是三段,以-分隔”,且不会变化的场景,本质上是substring的变种。
4.2 正则表达式:校验和提取一起做
正则适合“既要判断格式对不对,又要提取年份”的场景。比如:
private static final Pattern YEAR_PATTERN = Pattern.compile("^(\\d{4})-(\\d{2})-(\\d{2})$"); public static Integer extractYear(String dateStr) { Matcher matcher = YEAR_PATTERN.matcher(dateStr); if (matcher.matches()) { return Integer.valueOf(matcher.group(1)); } return null; }这段代码有两个好处:第一,它要求整个字符串完全匹配"yyyy-MM-dd",格式不对就返回null,不会把"2004-13-99"当合法数据处理;第二,捕获组直接提取第一段年份,不存在越界问题。
缺点是代码量偏大,而且正则只校验了结构,并没有校验日期的真实合法性,比如"2004-02-30"用上面这个正则也能匹配成功。如果你既想要正则的“格式校验”,又想要日期的“业务校验”,就必须在正则匹配通过后再调用LocalDate.parse。正则更适合做“预检”,不适合做最终裁决。
4.3 SimpleDateFormat + Calendar:老代码里经常见,坑也不少
在一些老项目里会看到这种做法:
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd"); Date date = sdf.parse("2004-07-11"); Calendar calendar = Calendar.getInstance(); calendar.setTime(date); Integer year = calendar.get(Calendar.YEAR);它的核心思路是:先解析成Date,再用Calendar取出年份。这个方案能跑,但我不推荐在新代码里用。原因有三个:
SimpleDateFormat不是线程安全的,把它定义成static后多线程并发解析会出现诡异结果,比如年份错乱、时间漂移。SimpleDateFormat默认是宽松解析模式,"2004-13-11"这种数据可能会被自动“进位”解析成2005-01-11,年份直接从 2004 变成 2005。- 代码里塞进一个
Calendar对象,结构上明显比LocalDate.getYear()笨重。
唯一建议使用它的场景是:项目还在用 Java 7 或更早版本,没法引入java.time,又不想额外加依赖。否则一律优先java.time。
4.4 方案横向对比
| 方案 | 代码量 | 能否校验日期合法性 | 线程安全 | 推荐度 |
|---|---|---|---|---|
substring+parseInt | 少 | 不能 | 安全(无共享状态) | 格式绝对固定时可用 |
split("-")[0] | 少 | 不能 | 安全 | 不如 substring 直观 |
| 正则表达式 | 中 | 只能校验结构 | 安全(Pattern 可复用) | 适合做预检 |
SimpleDateFormat | 中 | 宽松模式有坑 | 不安全 | 老项目维护再用 |
LocalDate.parse+getYear | 中 | 能 | 安全 | 默认推荐 |
这里说的“线程安全”,在substring和split方案里其实是指代码本身不依赖共享可变对象;而在SimpleDateFormat方案里,如果你把它定义为静态字段,风险就会冒出来。DateTimeFormatter相反,它是线程安全的,定义成static final复用完全没问题。
5. 封装一个可以直接抄的工具方法
5.1 常规工具方法长这样
项目里如果多处都要提取年份,不要每次到处写substring或者LocalDate.parse,封装一个工具方法是最好的选择。我提供一个可以直接抄的版本:
import java.time.LocalDate; import java.time.format.DateTimeFormatter; import java.time.format.DateTimeParseException; import java.util.Locale; public final class ExtractYearUtil { private static final DateTimeFormatter FLEXIBLE_FORMATTER = DateTimeFormatter.ofPattern("uuuu-M-d", Locale.ROOT); private ExtractYearUtil() { } public static Integer getYear(String dateStr) { if (dateStr == null) { return null; } String text = dateStr.trim(); if (text.isEmpty()) { return null; } LocalDate date; try { date = LocalDate.parse(text); } catch (DateTimeParseException e) { try { date = LocalDate.parse(text, FLEXIBLE_FORMATTER); } catch (DateTimeParseException ex) { return null; } } return Integer.valueOf(date.getYear()); } }逻辑并不复杂:先处理空值和前后空格,再尝试标准格式"2004-07-11",失败后尝试"2004-7-1"这类宽松格式,都失败就返回null。两个DateTimeFormatter都没有共享可变状态,工具类整体线程安全。
5.2 异常策略怎么定
上面工具方法选择返回null,这是我在统计类需求里的习惯:上游有一两条脏数据,不应该让整个批量任务崩溃。但如果你的业务要求“解析失败就是接口调用失败,必须让调用方感知”,那返回null就不合适,应该改抛IllegalArgumentException,把原始字符串和异常信息一起带出去:
throw new IllegalArgumentException("无法解析的日期字符串: " + dateStr, e);没有绝对正确的策略,只有适合当前业务的策略。封装时最好把这个决策暴露给调用方,比如再加一个getYearOrThrow方法。
5.3 简单验证用例
不引入测试框架,直接写main也能验证:
public static void main(String[] args) { System.out.println(ExtractYearUtil.getYear("2004-07-11")); // 2004 System.out.println(ExtractYearUtil.getYear("2004-7-11")); // 2004 System.out.println(ExtractYearUtil.getYear(" 2004-07-11 ")); // 2004 System.out.println(ExtractYearUtil.getYear("")); // null System.out.println(ExtractYearUtil.getYear(null)); // null System.out.println(ExtractYearUtil.getYear("2004-13-11")); // null System.out.println(ExtractYearUtil.getYear("2005-02-29")); // null }最后一个用例是关键:2005-02-29不是合法日期,因为 2005 不是闰年。工具方法返回null而不是 2005,这正是java.time方案相对substring方案的核心优势。
5.4 为什么我看到有人用 Integer 而不是 int
题目明确要求返回Integer,但很多项目代码里根本不需要包装类型。这里有个容易混淆的点:int year = date.getYear()返回的是基本类型,Integer.valueOf(date.getYear())返回的是包装类型。如果你的业务逻辑里没有集合泛型、没有数据库映射等强制要求,用int更轻量;只有当你要把年份放进List<Integer>、Map<String, Integer>,或者框架要求返回值非空时,才需要Integer。
还有一点要提醒:如果getYear()设计成在解析失败时返回null,那返回类型必须是Integer,因为int装不下null。这两者不是一个“心情好选哪个都行”的问题,而是由空值策略决定的。
6. 实际使用中的隐藏坑:前导零、性能、时区、维护
6.1 年份可能不是四位数
我遇到过从旧系统导出的数据,年份只有两位,比如"04-07-11"。用substring(0, 4)会取到"04-0"这种怪东西,直接炸;用LocalDate.parse默认格式也会因为年份不足四位而出问题。
这类数据要先确认业务口径:两位年份"04"到底代表 2004 还是 1904?如果你能确认它是 2004,可以自行补零:
String dateStr = "04-07-11"; String normalized = "20" + dateStr; // 简单拼接 LocalDate date = LocalDate.parse(normalized); Integer year = Integer.valueOf(date.getYear()); // 2004补零逻辑看起来粗暴,但至少它把“数据不规范”这件事暴露在代码里了。等将来数据源修复了,这里可以整体删掉,不会留下模糊不清的年份偏移。
6.2 解析性能:不是所有场景都应该上 LocalDate.parse
LocalDate.parse看起来很美,但它内部要做完整的格式化校验和字段解析,在高频循环里确实比单纯的substring + parseInt慢。我自己本地跑过简单对比,两者差距能到数倍甚至更多,但这个结论要看环境,不要拿它当金科玉律。
我的取舍标准很简单:
- 数据量小、格式乱、希望校验严格:用
LocalDate.parse或工具方法。 - 数据量极大、格式绝对固定、且已经确认上游稳定:用
substring(0, 4) + Integer.parseInt。 - 中等规模且上游有变数:写工具方法,先
substring快速路径,再兜底LocalDate.parse,把你的性能追求和健壮性要求组合起来。
多数业务场景里,真正的瓶颈在网络和数据库,解析省出来的那点时间可以忽略不计。只有在纯内存批量处理海量数据时,性能优化才有意义。
6.3 时区问题:字符串本身和“年份”的歧义
有人觉得字符串"2004-07-11"已经固定了,时区有什么关系?关系在于这个字符串是怎么生成的。如果字符串来自一个 UTC 时间戳,比如2004-07-11T16:00:00Z,在某个东八区系统里显示已经是2004-07-12,这时按字符串提取年份拿到的是2004,但如果你在代码里把它转成Instant,再按其他时区取年份,就可能变成2005或2004,取决于时区转换规则。
所以处理带时间部分的日期字符串时,必须明确:这个字符串是哪个时区的本地时间?如果上游文档说“所有时间都是服务器本地时间”,那解析时用ZoneId.systemDefault()即可;如果上游说“这是 UTC 时间”,你就得先按OffsetDateTime.parse解析,再转换到目标时区再取年份。最怕的是不明确时区约定,代码里随便写一个ZoneId,最终年份可能和业务预期差一年。
6.4 我的最终建议
如果是面试场景,给出substring方案可以体现基础功底,但紧接着展示LocalDate方案,并说明两者的边界差异,才是更完整的回答。
如果是生产代码,我会按这个顺序决策:
- 先确认上游格式和脏数据比例。
- 确认是否存在
null、空串、前后空格、单月单日、带时间等变体。 - 日常首选
LocalDate.parse + getYear,必要时配一个宽松DateTimeFormatter。 - 性能敏感且格式铁板钉钉,再用
substring,但一定要在代码注释里写清“不要乱动,依赖固定宽度”。 - 所有解析集中到一个工具方法里,避免团队里每个人都写一套自己的提取逻辑。
个人最深的体会是,这种小需求如果不把边界说清楚,看起来三行代码的事,后面一定会变成若干个if和异常补丁。把格式假设、空值策略、失败策略一次定下来,反而省心。下次你再遇到类似需求,希望能直接照这个思路落地,少走几步弯路。