JDK新特性
开篇:Java 的进化之路
很多人对 Java 的印象还停留在"啰嗦"二字——写一个简单的数据类要几十行,处理集合离不开 for 循环,并发编程更是让人头大。但从 2014 年的 JDK 8 开始,Java 就像一辆老牌跑车换上了涡轮增压引擎,每半年一个版本,每个版本都在让代码变得更简洁、更安全、更高效。
本文将带你从 JDK 8 一路走到 JDK 21,用真实的代码对比来感受每一代新特性带来的变化。不求面面俱到,但求每个特性你都能看完就用。如果你还在写 JDK 8 之前风格的代码,读完这篇文章,你会发现自己错过了太多好东西。
一、JDK 8:改变游戏规则的版本
JDK 8 是 Java 历史上最重要的一次升级,没有之一。它引入的 Lambda、Stream、Optional 三驾马车,彻底改变了 Java 程序员的编码方式。
1.1 Lambda 表达式:告别匿名内部类
在 JDK 8 之前,如果你想给一个按钮添加点击事件,或者给一个列表做排序,代码往往是这样的:
// JDK 7:匿名内部类,又长又啰嗦
Collections.sort(names, new Comparator<String>() {
@Override
public int compare(String a, String b) {
return a.compareTo(b);
}
});有了 Lambda 表达式之后,同样的逻辑只需要一行:
// JDK 8:Lambda 表达式,简洁明了
Collections.sort(names, (a, b) -> a.compareTo(b));
// 更进一步,使用方法引用
names.sort(String::compareTo);Lambda 的本质是函数式接口的简写。所谓函数式接口,就是只有一个抽象方法的接口(用 @FunctionalInterface 注解标记)。Java 内置了大量常用的函数式接口:
| 函数式接口 | 方法签名 | 用途 |
|---|---|---|
Predicate<T> | T -> boolean | 条件判断 |
Function<T,R> | T -> R | 类型转换 |
Consumer<T> | T -> void | 消费数据 |
Supplier<T> | () -> T | 提供数据 |
Comparator<T> | (T, T) -> int | 比较排序 |
你可以把 Lambda 想象成一张便签纸:以前你要写一封完整的信(匿名内部类),现在只需要在便签上写几个字(Lambda)就能表达同样的意思。
1.2 Stream API:声明式数据处理
Stream 是 JDK 8 的另一个杀手级特性。它让你可以用"声明式"的方式处理集合数据,就像写 SQL 一样描述你想要什么,而不是一步步告诉计算机怎么做。
假设我们有一个订单列表,要找出金额大于 100 元的订单,按金额排序,然后提取订单编号:
// JDK 7:命令式风格,步骤繁琐
List<Order> filtered = new ArrayList<>();
for (Order order : orders) {
if (order.getAmount() > 100) {
filtered.add(order);
}
}
Collections.sort(filtered, (a, b) ->
Double.compare(a.getAmount(), b.getAmount()));
List<String> orderIds = new ArrayList<>();
for (Order order : filtered) {
orderIds.add(order.getId());
}// JDK 8:声明式风格,一条流水线搞定
List<String> orderIds = orders.stream()
.filter(o -> o.getAmount() > 100)
.sorted(Comparator.comparing(Order::getAmount))
.map(Order::getId)
.collect(Collectors.toList());Stream 的核心操作可以分为三类:
常用操作速查:
| 操作 | 类型 | 作用 | 示例 |
|---|---|---|---|
filter | 中间 | 按条件过滤 | .filter(x -> x > 0) |
map | 中间 | 元素转换 | .map(String::toUpperCase) |
flatMap | 中间 | 一对多展开 | .flatMap(list -> list.stream()) |
sorted | 中间 | 排序 | .sorted(Comparator.reverseOrder()) |
distinct | 中间 | 去重 | .distinct() |
limit | 中间 | 截取前 N 个 | .limit(10) |
collect | 终端 | 收集为集合 | .collect(Collectors.toList()) |
reduce | 终端 | 聚合为单值 | .reduce(0, Integer::sum) |
forEach | 终端 | 逐个消费 | .forEach(System.out::println) |
count | 终端 | 计数 | .count() |
记住一个原则:中间操作是懒加载的(不调用终端操作就不会真正执行),终端操作触发整条流水线。
1.3 Optional:优雅处理 null
NullPointerException 大概是每个 Java 程序员最熟悉的异常了。JDK 8 引入的 Optional 就是为了让你从源头上减少空指针问题。
// 传统写法:层层判空,代码像俄罗斯套娃
String cityName = "Unknown";
if (user != null) {
Address address = user.getAddress();
if (address != null) {
String city = address.getCity();
if (city != null) {
cityName = city;
}
}
}// Optional 写法:链式调用,清晰优雅
String cityName = Optional.ofNullable(user)
.map(User::getAddress)
.map(Address::getCity)
.orElse("Unknown");但是 Optional 也有常见的反模式需要避免:
// 反模式一:拿到 Optional 就 get(),等于没用
String name = Optional.ofNullable(user).get(); // 可能抛异常!
// 反模式二:用 isPresent() + get(),和 if-null 没区别
if (opt.isPresent()) {
return opt.get(); // 这不就是换了个写法的 if-null 吗?
}
// 正确姿势:用 orElse / orElseGet / map / flatMap
return opt.map(User::getName).orElse("default");1.4 其他亮点
全新的日期时间 API:终于告别了 Date 和 Calendar 这两个反人类的类。新的 LocalDate、LocalTime、LocalDateTime 不可变、线程安全,API 也非常直观:
LocalDate today = LocalDate.now();
LocalDate birthday = LocalDate.of(1995, 3, 15);
long age = ChronoUnit.YEARS.between(birthday, today);
// 日期计算也很方便
LocalDate nextWeek = today.plusWeeks(1);
LocalDate lastMonth = today.minusMonths(1);接口默认方法:接口里可以有方法实现了,用 default 关键字修饰。这让接口的演进不再是一件"牵一发动全身"的事——新增方法时不会破坏已有的实现类。
二、JDK 9-11:稳步迭代
这三个版本虽然没有 JDK 8 那样的轰动效应,但每个都带来了实用的改进。JDK 11 更是第一个 JDK 8 之后的 LTS(长期支持)版本。
2.1 模块化系统(JDK 9)
JDK 9 引入了 Java 平台模块化系统(JPMS),用 module-info.java 来声明模块的依赖和导出。它解决的核心问题是:让 JDK 本身可以"瘦身"——你的微服务可能只需要 JDK 中 10% 的类,现在可以只打包需要的模块,生成一个精简的运行时镜像。
// module-info.java
module my.application {
requires java.net.http; // 声明依赖
exports com.example.api; // 导出包
}用 jlink 工具可以基于模块依赖生成自定义 JRE:
jlink --module-path $JAVA_HOME/jmods \
--add-modules my.application \
--output custom-jre对于应用开发者来说,模块化的日常感知可能不强,但它对框架开发者和 JDK 自身的维护意义重大。JDK 9 之后的每次升级都受益于模块化带来的清晰边界。
2.2 var 局部变量推断(JDK 10)
var 让你在定义局部变量时可以省略类型声明,编译器会自动推断:
// 以前:类型名写两遍,又长又冗余
Map<String, List<String>> map = new HashMap<String, List<String>>();
// 现在:左边用 var,编译器自动推断
var map = new HashMap<String, List<String>>();
var stream = list.stream().filter(s -> s.length() > 3);本质上 var 是一颗语法糖——编译器在编译阶段会把 var 替换成真正的类型,JVM 完全不知道 var 的存在。你用 var 声明后编译出的 .class 文件和显式写类型是完全一样的。
注意:var 只能用于局部变量,不能用于方法参数、返回值或成员变量。另外,当 var 让代码可读性变差时(比如 var result = service.process(data),看不出 result 是什么类型),还是老老实实写类型名更好。
2.3 HttpClient(JDK 11)
终于有了现代化的 HTTP 客户端,支持 HTTP/2、WebSocket,支持同步和异步请求:
var client = HttpClient.newHttpClient();
var request = HttpRequest.newBuilder()
.uri(URI.create("https://api.example.com/users"))
.header("Content-Type", "application/json")
.GET()
.build();
// 同步请求
var response = client.send(request,
HttpResponse.BodyHandlers.ofString());
System.out.println(response.statusCode());
System.out.println(response.body());
// 异步请求
client.sendAsync(request, HttpResponse.BodyHandlers.ofString())
.thenApply(HttpResponse::body)
.thenAccept(System.out::println);再也不用为了发个 HTTP 请求就引入 Apache HttpClient 或 OkHttp 了(当然,在复杂场景下它们仍然更好用)。
三、JDK 14-17:现代 Java
JDK 17 是继 8、11 之后的第三个 LTS 版本,也是目前很多团队升级的首选目标。这个阶段的特性让 Java 在语言表达力上有了质的飞跃。
3.1 Records:不可变数据载体
在 JDK 14 之前,写一个简单的数据类需要多少代码?
// 传统 POJO:字段 + 构造器 + getter + equals + hashCode + toString
// 少说也要 40-50 行,就算用 Lombok 也需要一堆注解
public class Point {
private final int x;
private final int y;
public Point(int x, int y) { this.x = x; this.y = y; }
public int getX() { return x; }
public int getY() { return y; }
@Override
public boolean equals(Object o) { /* 省略十几行 */ }
@Override
public int hashCode() { return Objects.hash(x, y); }
@Override
public String toString() { return "Point[x=" + x + ", y=" + y + "]"; }
}// Record:一行搞定,编译器自动生成上面所有方法
record Point(int x, int y) {}Record 自动帮你生成:全参构造器、每个字段的访问方法(x() 而非 getX())、equals()、hashCode() 和 toString()。而且 Record 天生不可变,所有字段都是 final 的。
你还可以在 Record 中添加自定义方法和紧凑构造器(用于参数校验):
record Range(int start, int end) {
// 紧凑构造器:用于校验参数
Range {
if (start > end) throw new IllegalArgumentException();
}
// 自定义方法
int length() { return end - start; }
}Record 就像身份证:上面的信息在签发时就定死了,你不能自己改名字或改地址。
3.2 Sealed Classes:受控的继承
Java 一直以来都鼓励"开放式继承"——任何类都可以被继承。但现实世界中,很多时候我们希望继承是受控的。比如在一个支付系统中,支付方式只有"信用卡"、"支付宝"、"微信支付"三种,不应该被随意扩展。
// sealed 类:明确规定谁可以继承
public sealed interface Payment
permits CreditCard, Alipay, WechatPay {}
record CreditCard(String cardNo) implements Payment {}
record Alipay(String account) implements Payment {}
record WechatPay(String openId) implements Payment {}sealed 配合 permits 关键字,让编译器知道所有可能的子类。这带来两个好处:一是防止滥继承,二是为模式匹配中的穷尽性检查打下基础——编译器能知道你是否处理了所有情况。
3.3 Pattern Matching:更智能的类型判断
instanceof 模式匹配(JDK 16 正式)
以前做类型检查和转换需要两步,现在合成一步:
// 以前:先判断再强转,重复写类型名
if (animal instanceof Cat) {
Cat cat = (Cat) animal;
cat.miaow();
}
// 现在:判断的同时直接绑定变量
if (animal instanceof Cat cat) {
cat.miaow();
}switch 模式匹配(JDK 17 预览,JDK 21 正式)
当你需要对不同类型做不同处理时,switch 比一长串 if-else 优雅得多:
static String format(Object obj) {
return switch (obj) {
case Integer i -> String.format("整数 %d", i);
case Double d -> String.format("浮点数 %.2f", d);
case String s -> String.format("字符串 %s", s);
case null -> "null";
default -> obj.toString();
};
}注意看:这里的 switch 处理的是 Object 类型,case 里不再是精确的值匹配,而是类型模式匹配。这在以前是不可能做到的。
3.4 Text Blocks:多行字符串
写过 SQL 拼接或 JSON 模板的人都知道那种痛苦——一堆 \n 和 + 号让人眼花缭乱。Text Block 用三引号 """ 让多行字符串变得清清爽爽:
// 以前:转义地狱
String json = "{\n" +
" \"name\": \"张三\",\n" +
" \"age\": 25\n" +
"}";
// 现在:所见即所得
String json = """
{
"name": "张三",
"age": 25
}
""";SQL 也一样清爽:
String query = """
SELECT id, name, email
FROM users
WHERE status = 'ACTIVE'
ORDER BY created_at DESC
""";Text Block 会自动处理缩进——以结束的 """ 的位置为基准,去掉公共前导空格。这意味着你可以放心地在代码中正常缩进,不用担心多余的空格混进字符串。
四、JDK 19-21:面向未来
JDK 21 是目前最新的 LTS 版本,它带来的虚拟线程被认为是 Java 并发编程领域自 java.util.concurrent 以来最大的变革。
4.1 虚拟线程:轻量级并发革命
传统的 Java 线程(平台线程)是对操作系统线程的一对一封装。每个线程大约占用 1MB 的栈内存,创建和切换都有较高的开销。这意味着一台服务器能支撑的并发连接数受限于线程数量,通常在几千到一两万之间。
虚拟线程打破了这个限制。它由 JVM 自己调度,不直接绑定操作系统线程,一台服务器可以轻松创建数百万个虚拟线程。
// 传统方式:创建平台线程,资源开销大
ExecutorService executor = Executors.newFixedThreadPool(200);
for (int i = 0; i < 10000; i++) {
executor.submit(() -> handleRequest());
}
// 虚拟线程:每个请求一个虚拟线程,简单直接
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 1_000_000; i++) {
executor.submit(() -> handleRequest());
}
}两种线程的核心对比:
| 对比维度 | 平台线程 | 虚拟线程 |
|---|---|---|
| 内存占用 | ~1MB/线程 | ~几KB/线程 |
| 创建数量 | 通常数千个 | 可达数百万个 |
| 调度方式 | 操作系统调度 | JVM 调度(ForkJoinPool) |
| 适用场景 | CPU 密集型任务 | IO 密集型任务 |
| 线程池 | 需要精心配置池大小 | 通常不需要池化 |
| 阻塞代价 | 高(浪费一个 OS 线程) | 低(自动让出载体线程) |
打个比方:平台线程像出租车,数量有限且运营成本高,必须精心调度;虚拟线程像共享单车,便宜到你可以给每个请求都分配一辆,用完就还。
4.2 结构化并发
结构化并发(Structured Concurrency)让并发任务像普通的代码块一样有清晰的生命周期——任务在一个作用域内启动,必须在该作用域结束前完成:
// 同时查询用户信息和订单信息,任一失败则整体取消
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var userTask = scope.fork(() -> findUser(userId));
var orderTask = scope.fork(() -> findOrders(userId));
scope.join(); // 等待所有任务完成
scope.throwIfFailed(); // 如果有失败,抛异常
return new UserProfile(userTask.get(), orderTask.get());
}这比手动管理 CompletableFuture 的组合和异常处理要直观得多。传统方式下,如果 findUser 失败了,findOrders 可能还在执行,你需要手动取消它。结构化并发自动帮你处理这些——作用域关闭时,所有子任务都会被清理。
4.3 Switch 表达式增强
从 JDK 12 开始预览、JDK 14 正式推出的 Switch 表达式,让 switch 可以作为表达式返回值,并用箭头语法替代了冗长的 break:
// 传统 switch:容易忘 break,容易出 bug
int numLetters;
switch (day) {
case MONDAY: case FRIDAY: case SUNDAY:
numLetters = 6;
break;
case TUESDAY:
numLetters = 7;
break;
default:
numLetters = day.toString().length();
break;
}
// Switch 表达式:简洁,不会漏 break
int numLetters = switch (day) {
case MONDAY, FRIDAY, SUNDAY -> 6;
case TUESDAY -> 7;
default -> day.toString().length();
};如果某个分支需要多行逻辑,用 yield 关键字返回值:
int result = switch (input) {
case "1" -> 1;
case "2" -> 2;
default -> {
int len = input.length();
yield len; // yield 只跳出 switch 块,不像 return 跳出整个方法
}
};五、版本选择建议
面对这么多版本,实际项目中该选哪个?核心原则是:选 LTS 版本,跟随社区主流。
| 版本 | 类型 | 推荐场景 | 支持截止 |
|---|---|---|---|
| JDK 8 | LTS | 老项目维护,第三方库兼容性要求高 | 商业支持已结束,OpenJDK 社区仍维护 |
| JDK 11 | LTS | 已在 JDK 8 上做过升级评估的项目 | 2026 年 9 月 |
| JDK 17 | LTS | 新项目首选,Spring Boot 3.x 最低要求 | 2029 年 9 月 |
| JDK 21 | LTS | 追求虚拟线程等前沿特性的项目 | 2031 年 9 月 |
迁移建议:如果你还在 JDK 8,建议直接跳到 JDK 17(跳过 11)。Spring Boot 3.x 和 Jakarta EE 10 都以 JDK 17 为基线,生态已经非常成熟。如果你对虚拟线程有强烈需求(高并发 Web 服务),可以直接上 JDK 21。
关于发布节奏:Java 从 JDK 9 开始每半年发布一个版本(每年 3 月和 9 月),每两年出一个 LTS 版本。非 LTS 版本只有 6 个月的支持期,不建议用于生产环境。
六、常见面试题精选
1. Lambda 表达式的本质是什么?和匿名内部类有什么区别?
Lambda 是函数式接口的实例。与匿名内部类的区别在于:匿名内部类会生成一个额外的 .class 文件,而 Lambda 在运行时通过 invokedynamic 指令动态生成实现类,性能更好、内存占用更少。此外,Lambda 中的 this 指向外围类,匿名内部类的 this 指向自身。
2. Stream 的 parallel() 一定更快吗?
不一定。并行流在数据量小、操作简单、或存在线程安全问题时反而可能更慢。并行流底层使用 ForkJoinPool,适合数据量大(通常万级以上)、计算密集、且数据源支持高效拆分(如 ArrayList、数组)的场景。LinkedList 的并行流效果通常很差,因为它不支持随机访问,拆分成本高。
3. Optional 应该用在哪里?
Optional 设计用于方法返回值,表示"结果可能不存在"。不应该用作字段类型、方法参数或集合元素。也不要用 Optional.of(null) ——它会直接抛 NPE,用 Optional.ofNullable() 代替。JPA Entity 等需要可变性的场景也不适合用 Optional 包装字段。
4. Record 能替代所有的 POJO 吗?
不能。Record 是不可变的,所有字段都是 final,没有 setter。如果你需要可变对象(比如 JPA Entity 需要 setter)、继承(Record 隐式继承 java.lang.Record,不能再继承其他类)或者自定义字段逻辑,仍然需要传统类或 Lombok。Record 最适合做 DTO、值对象和临时数据载体。
5. 虚拟线程能完全替代平台线程吗?
不能。虚拟线程适合 IO 密集型场景(等待网络、数据库、文件)。对于 CPU 密集型任务(大量计算),虚拟线程没有优势,因为最终还是要占用平台线程来执行计算。另外,虚拟线程中应避免使用 synchronized 同步块(会 pin 住载体线程,阻止其服务其他虚拟线程),改用 ReentrantLock。
小结
从 JDK 8 到 JDK 21,Java 用十年时间完成了从"啰嗦的企业语言"到"现代化的通用语言"的蜕变。每一代特性都在解决开发者的真实痛点:Lambda 解决了代码冗余,Stream 解决了集合操作繁琐,Record 解决了数据类样板代码,虚拟线程解决了并发编程的复杂性。
学习新特性最好的方式不是死记硬背,而是在日常开发中逐步替换旧写法。下次写代码时,试试把 for 循环换成 Stream,把 null 判断换成 Optional,把数据类换成 Record——你会发现,写 Java 也可以很优雅。