Java 8(也就是常说的 JDK 8)在 Java 历史上几乎是一个分水岭。我见过不少团队后来都升级到了更高版本,但打开项目代码一看,用的东西绝大部分还是 Java 8 那批新特性:Lambda、Stream、Optional、新的日期时间 API。甚至有些朋友刚接触 Java SE,第一个要装的开发环境就是 JDK 8,工作中最常被问到的也是“Java 8 新特性有哪些”。这篇内容就是我基于实际项目从旧版本切到 JDK 8 的梳理,从下载安装、语法层面到并发和 JVM 层面的变化一次讲透。如果你是准备校招、刚转 Java,或者想把基础打牢,这篇文章都值得跟着过一遍。
我不会把所有内容整成一本说明书,而是挑实际开发里真正会用、面试也常问的点展开。你可以把这篇当成一张 Java 8 实战地图,每段都有对应的代码示例、踩坑提醒和为什么这样做的解释。
1. 先从 JDK 8 的安装和 Java SE 概念说起
1.1 下载安装 JDK 8:第一件要做对的事
很多人会忽略第一步,直接在搜索引擎搜“java8下载安装”,结果下了一个带广告的捆绑包,装完半天找不着javac。这里给两条实用建议。
第一,选择 OpenJDK 8 还是官方发行版。现阶段新项目完全可以直接用 OpenJDK 8,生产环境也建议优先考虑开源版本,因为长期更新和商业条款都更省心。当然,某些公司内部会规定使用特定厂商的 JDK 发行版,这在企业环境里很常见,关键是看你团队的标准。
第二,下载后不要急着写代码,先做三件事:
- 正确设置
JAVA_HOME环境变量,指向 JDK 安装目录,而不是 JRE 目录。 - 把
%JAVA_HOME%\bin(Windows)或$JAVA_HOME/bin(Linux/macOS)加到PATH。 - 打开终端执行
java -version和javac -version,确认两个版本一致。
如果出现java -version显示 1.8,但javac提示找不到,基本都是PATH里还有别的 JDK 路径在前面。这种情况在机器上装了多个版本时特别常见,建议把当前要用的 JDK 路径放在最前面。
1.2 Java SE 和 JDK 8 的关系
Java SE 是标准版,JDK 8 则是这个标准版的一个具体版本号。当年 Java 的版本命名比较有意思,内部版本号是 1.8,对外就叫 Java 8。所以你在代码里写-source 1.8、-target 1.8,指的就是 Java 8。
JDK 8 的构成大致可以分为三层:
- 语言特性:Lambda、默认方法、重复注解等。
- API 层面:Stream、Optional、
java.time时间包、CompletableFuture。 - JVM 层面:元空间(Metaspace)取代永久代、Nashorn 脚本引擎等。
后面每个部分我都会结合一段真实场景来说,不是单纯背概念。
2. Lambda 表达式与函数式接口:Java 8 的语法地基
2.1 为什么需要 Lambda:从匿名内部类的痛苦说起
Java 8 之前,想在 Java 里表达一个“函数”非常累。拿最常见的排序举例。给一个List<String>按长度排序,以前要写:
List<String> names = Arrays.asList("Tom", "Jerry", "Alice"); Collections.sort(names, new Comparator<String>() { @Override public int compare(String o1, String o2) { return Integer.compare(o1.length(), o2.length()); } });一个简单的行为,被匿名内部类的模板代码包围。读代码时真正重要的只有一行Integer.compare(o1.length(), o2.length()),其他全是噪音。
Lambda 出现之后,同样的逻辑可以写成:
names.sort((o1, o2) -> Integer.compare(o1.length(), o2.length()));如果你使用的是 Java 8 的List.sort默认方法,甚至可以进一步缩写。
很多人第一次看到 Lambda 会觉得是语法糖。这个判断没有错,但只说对了一半。Lambda 不只是帮你少写几个字母,它改变了你思考代码的方式:以前你要“描述怎么做”,现在可以“描述要什么”。比如filter、map,其实都是在声明“我要满足条件的元素”“我要把元素转换一下”,具体遍历交给库去做。
2.2 函数式接口:Lambda 背后的协议
Lambda 能工作的前提是函数式接口。函数式接口的定义很简单:只有一个抽象方法的接口。Java 8 专门加了@FunctionalInterface注解来标记这类接口,加了这个注解之后,如果接口里出现第二个抽象方法,编译器会直接报错。
内置的四个函数式接口是所有流式操作的基础:
| 接口 | 方法 | 用途 | 典型场景 |
|---|---|---|---|
Predicate<T> | boolean test(T t) | 判断真假 | Stream 的 filter |
Function<T, R> | R apply(T t) | 输入 T 返回 R | Stream 的 map |
Consumer<T> | void accept(T t) | 消费一个值,无返回 | forEach |
Supplier<T> | T get() | 生产一个值 | 延迟加载 |
举个例子。我们要从订单列表里筛出金额大于 100 的订单,传统写法是写一个filter方法,内部用if判断。用Predicate可以这样:
public static List<Order> filterOrders(List<Order> orders, Predicate<Order> predicate) { List<Order> result = new ArrayList<>(); for (Order order : orders) { if (predicate.test(order)) { result.add(order); } } return result; } // 调用 List<Order> bigOrders = filterOrders(orderList, order -> order.getAmount() > 100);这其实是“策略模式”的一种简化实现。以前你为了传入一个判断逻辑,往往要定义一个接口、写一个实现类,现在一个 Lambda 全搞定。
这里有个非常重要的细节:Lambda 可以访问外部变量,但该变量必须是 effectively final,也就是初始化之后不再重新赋值。哪怕你只是在 Lambda 里给外部变量加一,编译器也会报错。这不是故意刁难,而是为了并发安全考虑。如果允许 Lambda 修改外部变量,多线程环境下会产生可见性问题,Java 干脆在语法层面禁止。
3. Stream API:集合操作的正确打开方式
3.1 从集合到流:不是替代,是转换
Stream 是 Java 8 里另一大核心。很多初学者把 Stream 当成集合来用,其实两者思维不同。集合关注的是“数据的存储和访问”,Stream 关注的是“对数据的计算”。
看一个简单场景。有一组客户名称,需要把所有名称转成大写,去重,再按字母顺序输出。传统写法要用临时变量、循环、HashSet去重、Collections.sort,每一步都是命令式的。Stream 写法:
List<String> customerNames = Arrays.asList("tom", "jerry", "Tom", "alice"); customerNames.stream() .map(String::toUpperCase) .distinct() .sorted() .forEach(System.out::println);流的操作分成三类:创建流、中间操作、终端操作。中间操作返回值仍然是 Stream,所以可以链式调用;终端操作一执行,流就关闭了,不能再用。比如上面例子里的.forEach就是终端操作。
有个最常见的坑:刚接触时容易忘记写终端操作,结果发现什么输出都没有。原因是 Stream 的中间操作是惰性的,没有终端操作时不会真正开始计算。这个设计是为了性能,可以避免遍历整个数据源。
3.2 常用中间操作与终端操作
filter用于按条件过滤,map用于一对一转换,flatMap用于一对多转换,三者是最常用的。flatMap相对难理解,我举个例子。
假设有一个方法返回List<String>,而每个客户有多个订单号:
// 伪代码:根据客户名查订单号列表 List<List<String>> orderIdGroups = customerNames.stream() .map(this::getOrderIdsByCustomer) .collect(Collectors.toList());得到的是嵌套 List。如果你希望把所有订单号拍平成一个大列表,这时候用flatMap:
List<String> allOrderIds = customerNames.stream() .flatMap(customer -> this.getOrderIdsByCustomer(customer).stream()) .collect(Collectors.toList());记忆技巧:map映射出来一个值,flatMap映射出来一个流,再把这个流摊平。
终端操作里,collect(Collectors.toList())是最常见的。更强大的是Collectors.groupingBy,它实现的是 SQL 里group by的效果:
Map<String, List<Order>> ordersByStatus = orders.stream() .collect(Collectors.groupingBy(Order::getStatus));按状态分组之后,还能嵌套下游操作。比如按状态分组后统计每组的订单数量:
Map<String, Long> countByStatus = orders.stream() .collect(Collectors.groupingBy(Order::getStatus, Collectors.counting()));如果你的场景只需要分成两组,可以用partitioningBy,它返回Map<Boolean, List<T>>,适合“达标/不达标”“有效/无效”这类二分判断。
注意:
Collectors.toMap遇到重复 key 会直接抛IllegalStateException,在实际业务中经常因为脏数据翻车。建议遇到这种需求时用第三个参数指定合并策略,比如(v1, v2) -> v1,保留下第一个值。
3.3 parallelStream:用不好就是性能杀手
Java 8 提供了parallelStream(),底层用的是公共线程池。ArrayList的流式操作在并行时确实能加速,但有两个前提:数据量大、单个元素处理耗时不短。
如果处理逻辑只是简单加法,并行流反而因为线程切换和拆分的开销变得更慢。更危险的是,公共线程池是所有并行流共享的,如果你的应用里已有其他占用线程池的任务,两者会互相干扰,极端情况下可能出现任务迟迟不执行。
我的经验是:并行流很适合纯计算、无状态、无锁的场景;一旦涉及IO操作,比如读写数据库、调远程接口,就别用了。用CompletableFuture配合自定义线程池去做异步更可控,这个后面会专门讲。
4. Optional、接口默认方法与新的日期时间 API
4.1 Optional:不是让你告别 if null
Optional是一个容器,专门用来表达“值可能不存在”的情况。它试图帮你避开一大堆if (obj != null)的判断,但如果没用对,代码反而更难懂。
先看一个被称为反模式的写法:
Optional<User> optionalUser = userRepository.findById(userId); if (optionalUser.isPresent()) { User user = optionalUser.get(); Address address = user.getAddress(); if (address != null) { return address.getCity(); } } return "未知";这种写法只是把null判断换成了isPresent,跟以前没有任何区别,甚至更啰嗦。
正确的思路是链式调用,把每一步可能为空的情况交给 Optional 自己处理:
String city = userRepository.findById(userId) .map(User::getAddress) .map(Address::getCity) .orElse("未知");map方法会在值不存在时直接跳过后续操作,最终返回一个空的 Optional,orElse负责给兜底值。这种写法把三个判断压缩成一次链式调用,逻辑也更贴近“取城市,拿不到就用默认值”。
再看两个容易混淆的方法:orElse和orElseGet。区别在于orElse里的值已经算好了,orElseGet是延迟到需要时才执行。如果兜底值的计算很贵,比如要查数据库或做复杂计算,一定要用orElseGet,否则即使 Optional 里有值,也会白做一次开销很大的计算。
还有一个高频坑:Optional本身不能序列化。如果你把Optional用作实体类的字段,底层 ORM 框架或者 RPC 序列化时很容易出问题。正确用法是作为方法返回值,而不是字段类型。
4.2 接口默认方法:给老接口做加法
Java 8 在接口里新增了default方法,也就是接口方法可以有方法体了。这个设计最初是为了给原有接口增加新方法而不用破坏所有实现类。比如List接口增加了sort默认方法,老实现类不用改就能直接用。
public interface Animal { void eat(); default void run() { System.out.println("Animal is running"); } }因为有了默认方法,一个接口可以同时包含抽象方法和默认方法。加上 Java 8 之后接口里还允许写静态方法,所以接口能承担的“公共逻辑”比以前多了很多。
这里有一个面试常问的点:一个类实现了两个接口,两个接口里都有同名的默认方法,会发生什么?答案是必须在该类中重写这个方法,否则编译报错。规则是“类优先”,如果父类和接口都有同名方法,则父类的方法生效;如果两个接口冲突,则需要手动解决。我在实际编码里尽量避免这种“多接口默认方法同名”的设计,否则团队里每个人记规则都够呛。
4.3 java.time:终于有靠谱的日期时间库了
Java 8 以前的Date和SimpleDateFormat算是老开发者心里的痛。Date的可变性、SimpleDateFormat的线程不安全,让每次日期操作都小心翼翼。JDK 8 的java.time直接把这三块问题一次解决:
- 日期时间对象不可变,所有操作返回新对象。
- 线程安全,
DateTimeFormatter可以直接作为静态常量复用。 - 类设计更清晰:
LocalDate只管年月日,LocalTime只管时分秒,LocalDateTime是两者组合,Instant表示时间戳。
实际项目里最常用的几个操作,比如当前时间格式化:
LocalDateTime now = LocalDateTime.now(); DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); String text = now.format(formatter);字符串解析:
LocalDateTime parsed = LocalDateTime.parse("2024-05-20 10:30:00", formatter);两个时间差:
Duration duration = Duration.between(startTime, endTime); long minutes = duration.toMinutes();还有一个很容易忽略的细节:LocalDateTime没有时区概念。如果你的系统需要做全球化部署,跨时区用户的“今天”不能直接用LocalDate.now(),应该先转成带时区的ZonedDateTime,再用指定时区取日期。否则服务器在上海、用户在纽约,生日提醒、报表日期这类功能很容易差一天。
5. CompletableFuture:异步编程的实用派
5.1 异步编排不再靠回调地狱
Java 8 之前写异步主要靠Future加线程池,但Future有个问题:拿到结果之前只能阻塞等待,或者轮询isDone。想要让一个异步任务完成后自动接着做另一个任务,得非常费劲地写回调。
CompletableFuture的出现改变了这个局面。它既能像Future一样表示一个异步任务的结果,又能用一套方法把多个异步任务串联或并联起来。
最基础的用法是:
CompletableFuture.supplyAsync(() -> queryOrderList()) .thenApply(orders -> filterValidOrders(orders)) .thenAccept(orders -> System.out.println(orders.size()));这里supplyAsync是提交一个带返回值的异步任务,thenApply在上一步完成后对结果做转换,thenAccept接收结果并消费。每一步返回的都是新的CompletableFuture,所以可以链式写下去。
如果上一步执行中抛了异常,整条链会中断。可以用exceptionally捕获:
CompletableFuture.supplyAsync(this::queryOrderList) .exceptionally(ex -> { log.error("query order list failed", ex); return Collections.emptyList(); });5.2 多个异步任务组合:不要用线程池默认参数
实际业务里最常见的需求是:并行查三个服务,最后合并结果。最稳妥的写法是用allOf:
CompletableFuture<List<Order>> orderFuture = CompletableFuture.supplyAsync(this::queryOrders); CompletableFuture<List<User>> userFuture = CompletableFuture.supplyAsync(this::queryUsers); CompletableFuture<List<Product>> productFuture = CompletableFuture.supplyAsync(this::queryProducts); CompletableFuture.allOf(orderFuture, userFuture, productFuture).join(); List<Order> orders = orderFuture.get(); List<User> users = userFuture.get(); List<Product> products = productFuture.get();这里我要专门提一个新手常踩的坑:CompletableFuture默认使用ForkJoinPool.commonPool(),这个公共线程池默认线程数是 CPU 核数减一。如果多个业务模块都直接用supplyAsync不带自定义线程池,很容易把公共线程池打满,导致某个接口响应变慢、其他使用并行流的功能也受牵连。
正确的姿势是给CompletableFuture传一个自定义线程池:
ThreadPoolExecutor executor = new ThreadPoolExecutor( 8, 16, 60L, TimeUnit.SECONDS, new LinkedBlockingQueue<>(200), new ThreadFactoryBuilder().setNamePrefix("biz-async-").build() ); CompletableFuture.supplyAsync(() -> queryOrders(), executor);这样做的核心原因有两个:一是任务隔离,不同业务的线程池互不影响;二是队列有界,不会在流量突增时无限积压任务,把内存打爆。线程池参数没有万能公式,我一般先按机器的可用 CPU 数估算核心线程数,再结合接口耗时和 QPS 做调整。
5.3 JVM 层面:PermGen 到 Metaspace
Java 8 除了语言层面的更新,JVM 也有一个直接影响运维的重要变化:永久代(PermGen)被移除,换成元空间(Metaspace)。
之前永久代存放类的元数据、常量池等,大小受-XX:MaxPermSize限制,动态生成的类一多就容易OutOfMemoryError: PermGen space。JDK 8 之后这些类元数据放到了元空间,而元空间默认使用本地内存,不占用 JVM 堆内存。
这个变化带来的好处很直接:不少依赖动态生成类的框架在 JDK 8 上没有那么容易“内存不够”。但要留意,元空间默认是没有上限的,如果应用里动态生成类太多,仍然可能耗尽机器内存。所以生产环境建议显式设置一个合理上限:
-XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m在排查老项目从 JDK 7 升到 JDK 8 时,如果发现启动参数里还有PermSize和MaxPermSize,直接删掉,这两个参数在新版本里已经没有任何作用了。
6. 常见问题与排查心得
6.1 高频报错速查表
写代码久了就会发现,Java 8 相关的报错翻来覆去就那么几类。我整理了一个速查表,按工作中出现的频率排序:
| 报错信息 | 原因 | 解决方案 |
|---|---|---|
Error: java: invalid source release: 8 | IDE 或构建工具没有真正使用 JDK 8 | 检查项目编译级别、JAVA_HOME、Maven/Gradle 使用的 JDK |
local variables referenced from a lambda expression must be final or effectively final | Lambda 引用的外部变量被重新赋值 | 简化代码,去掉对外部变量的修改,或改用数组/包装类但不要这样做 |
SimpleDateFormat is not thread safe | 多个线程共享同一个SimpleDateFormat | 改用DateTimeFormatter和java.time |
Metaspace OOM | 动态生成类太多,元空间被占满 | 调大MaxMetaspaceSize,同时排查类加载器泄漏 |
java.util.stream相关并行任务无响应 | 公共线程池被占满或任务死锁 | 避免并行流中调用阻塞方法,改用独立线程池 |
6.2 从 JDK 7 升级到 JDK 8 的实操步骤
如果你维护的是老项目,直接编译代码大概率会报一堆错。我的经验是先做静态检查,不要一上来就改代码。
第一步,先看依赖里有没有使用sun.misc或sun.*包下的类。JDK 8 把一些内部包做了调整,直接依赖这些的代码需要重构。
第二步,全局搜索PermSize、MaxPermSize,这些 JVM 参数已经失效。
第三步,把“匿名内部类 + 循环”的典型代码块列出来,优先替换成 Stream。这一步不是必须,但如果新代码里还到处是手写循环,就享受不到 Java 8 带来的红利。
第四步,单元测试要重点覆盖日期相关和异步相关的代码,这部分最容易出线上问题。
6.3 一个我踩过的坑:并行流里写数据库
最后分享一个印象最深的教训。有一次我负责优化一个报表任务,数据量十几万条,单线程处理要 5 分钟。我当时图省事,给 Stream 加了.parallel(),再把每条数据insert到数据库。本地测试确实快了,但上线后数据库连接池直接被打满,下游服务也跟着抖动。
原因很简单:并行流的每个线程都在执行数据库插入,连接池大小有限,大量线程阻塞等待连接,反而拖垮了整个任务。排查后我把并行流拆掉,改成CompletableFuture配合自定义线程池,控制并发插入的线程数,同时把单条插入改成分批批量提交。最后任务耗时不仅没增加,数据库压力也恢复正常。
从那以后我对并行流的态度就很明确:数据量不够大不要用,涉及外部 IO 不要用,无法控制并发数不要用。
7. 关于 Java 8 后续扩展的几个建议
对于刚开始学 Java SE 的朋友,我特别想强调一点:Java 8 不是终点,但它是最好的起点。你把 Lambda、Stream、Optional 这些基础打牢了,后面看List的removeIf、Map的computeIfAbsent,会觉得非常自然。
这里再分享一个实际工作中很实用的组合:用computeIfAbsent搭配ArrayList实现按 key 分组收集。这个方法虽然不是 Java 8 独有的,但在 Java 8 项目里被广泛使用:
Map<String, List<Product>> map = new HashMap<>(); for (Product product : products) { map.computeIfAbsent(product.getCategory(), k -> new ArrayList<>()) .add(product); }它比putIfAbsent更安全,因为putIfAbsent即使 value 已经存在,也会先把新 value 构造出来,存在无谓的开销;而computeIfAbsent只会在 key 不存在时才执行后面的 Lambda 创建新 List。
个人经验里还有一条很值得说:新特性不是越新越好,但也不是越稳越好。判断一个项目能否使用某个 Java 版本,除了看团队熟悉度,还要看构建工具、部署环境、依赖库的兼容性。我用 Java 8 写过很多线上项目,也用它带过团队,整体感受是:只要把 Stream 的惰性求值、并行流的坑、Optional 的正确用法这几个关键点吃透,Java 8 带来的收益远大于踩坑成本。
如果你刚开始准备环境,装完 JDK 8 别急着跑“Hello World”,先花半天时间把 Lambda、函数式接口、Stream 这三个概念串起来,再找个真实的小需求练一练。比如“从订单列表里统计每个用户的消费总额”这种,既练了groupingBy,又练了Collectors.summingDouble。等你把这些写顺手了,Java 8 在你手里就真正成为生产力工具了。