深入探索 Java 21 的核心特性

Java 的持续影响力: 语言演进的深度解析

我并非一直都是 Java 的拥趸, 但近年来我逐渐开始欣赏这门语言及其生态系统, 尤其是在决定使用 Java 21 进行一个新的个人项目后. 当谈到进入 JVM 世界, 我最初选择的是 Scala------一种将面向对象编程与函数式编程概念融合的精简语言, 其核心在于类型安全.

那么, 为什么我没有选择Scala呢? 我决定使用Java的部分原因在于, 我了解到该语言近期有一些令人兴奋的改进, 同时我也希望借此机会进一步探索Java当前的生态系统, 框架和库的现状. 虽然针对JVM平台的其他新语言层出不穷, 但目前获得最多关注的仍是Scala和Kotlin, 这两种语言与Java一样, 都是强类型和静态类型的编程语言.

Java 与 Scala, Kotlin 的对比

在Scala和Kotlin之间, Kotlin的语法对已有Java经验的开发者来说更熟悉, 其主要目标是成为一种更现代, 更简洁的语言, 同时与Java完全兼容, 并修复Java的一些缺陷. 目前, Kotlin在Android开发领域更受欢迎.

另一方面, Scala 的流行主要集中在数据工程(如 Apache Spark 等)和后端应用开发领域. Scala 的语法更为简洁, 有人甚至将其比作"强化版 Python"(尤其是 Scala3), 并且拥有更强大的类型系统, 有助于在编译时消除更多错误.

开发者更青睐 Java

让我们通过查看Stack Overflow调查以及TIOBE和Redmonk语言流行度指数, 来了解Scala, Kotlin和Java的采用率和流行度.

下图基于Stack Overflow对最流行语言的调查结果. 这里我只挑选了上述三种语言.

Redmonk语言指数

以下是截至2023年1月的Redmonk指数, 该指数基于Github和Stack Overflow数据. 我们可以看出Java位居榜首, Scala和Kotlin也排名相对靠前.

来源: redmonk.com/sogrady/202...

TIOBE编程指数

现在来看TIOBE指数, 该指数考虑了25个不同搜索引擎中的搜索词. Java同样位列顶级语言之列. Scala和Kotlin未进入前10名(图表中未显示), 但2023年它们分别排名第15和第36位.

来源: www.tiobe.com/tiobe-index...

如你所见, 尽管面临 Kotlin 和 Scala 的强劲竞争, Java 仍保持着极高人气. 近年来, 其市场份额确实有所下降, 部分原因在于其他编程语言(包括其他生态系统, 如 Python, Go, Rust)的崛起.

探索 Java: 对向后兼容性的承诺

让我们看看Java在过去几年中做了什么, 以及其他因素如何贡献于其强大的流行度.

部分流行度源于Java的基因, 即对强向后兼容性的承诺. 是的, 多年来确实有一些有意的兼容性中断, 以支持语言和工具的改进, 但此类更改通常经过深思熟虑并有充分理由. 这是 Java 在企业环境中如此受欢迎的一个重要原因. JVM 平台的稳定性意味着更多工程师能够专注于解决业务问题和交付代码, 而非与工具作斗争.

考虑 Java 相关性的因素

自Java 9起, OpenJDK将发布周期从"随时准备好就发布"改为每6个月一次------3月和9月. 此举旨在缓解因某些增强功能未准备就绪而导致的新版本发布延迟. 为了实现这一目标, 引入了"预览功能"概念. 如果某项功能已完全实现但仍可能引发兼容性问题, 则会在下一版本中以预览形式引入.

这使开发者社区能够提供反馈并帮助完善实现, 以便在后续常规版本中最终确定. 每隔几年, Oracle 会将其中一个版本标记为 LTS(长期支持)版本, 其他 JDK 供应商也会跟进. 例如, 亚马逊网络服务(AWS)维护着自己的 JDK 构建版本, 名为 Corretto JDK, AWS 将为其提供长期支持.

来源: dev.java/evolution/

Java 相关性的另一个因素是后发优势. 其他 JVM 语言如 Scala 发展速度相当快. 我继续欣赏并乐于使用 Scala, 但其对向后兼容性的承诺和工具链的完善程度仍有很大提升空间(但正在改善!). 另一方面, Scala 处于技术前沿, 推动语言和编译器设计达到新高度.

Java 则采取长远策略, 花时间观察行业演变, 评估其他语言的实践, 挑选有效方案并有针对性地改进语言. 让我们快速回顾 Java 8 之后引入的若干语言改进.

Java 10 的var关键字对冗余性的影响

当与那些已经精通其他现代语言并正在学习 Java 的开发者交谈时, 当被问及他们最不喜欢什么时, 冗余性往往位列前茅. 在 Java 10 发布之前, 我们必须在赋值语句的左侧显式声明变量类型. 在 Java 10 中, 引入了一个新的特殊var关键字, 用于替代实际类型. 在编译阶段, Java 编译器会根据赋值语句右侧表达式推断出的实际类型进行插入.

ini 复制代码
//Java 8
HashMap map = new HashMap();

DatabaseEngineColumnCacheImpl cache = new DatabaseEngineColumnCacheImpl();

Optional accessRole = user.getUserAccessRole();

//Java 10, no need to convince compiler anymore of what are the actual types
var map = new HashMap();

var cache = new DatabaseEngineColumnCacheImpl();

var accessRole = user.getUserAccessRole();

以上是一些简单的示例, 但这确实大大减少了阅读代码时的冗余性. 然而, 这在行业中并非新鲜事. 其他现代静态类型编程语言的开发者早已享受类型推断的便利.

例如, Scala 自 2004 年诞生以来就拥有更高级的类型推断功能. 尽管如此, 这仍是 Java 的一项非常受欢迎的功能. 类型推断仅限于局部变量声明, 例如在方法体内, 从实际应用角度来看, 这正是最关键的场景.

通过 instanceof 和模式匹配克服类型检查挑战

instanceof 是用于检查给定对象是否为特定类型的语言关键字. 例如, 给定一个类型为 Object 的对象(可能代表任何类型), 我们可能需要在运行时检查其底层类型, 以便执行针对该底层类型特定的操作.

以下是一个示例, 虽然有些牵强, 但足以说明问题. 假设我们有一个 Shape 接口和多个表示特定形状的类. 在代码的某个位置, 我们希望获取形状的周长信息. 不幸的是, 接口并未指定每个继承类应实现的周长计算方法, 且出于示例考虑, 我们也无法重构此代码.

这使我们只剩下一个选择: 将周长计算实现为某种实用方法, 该方法会检查具体类型(如RectangleCircle), 并根据类型计算周长.

java 复制代码
interface Shape {}

public class Rectangle implements Shape {
    final double length;
    final double width;
    public Rectangle(double length, double width) {
        this.length = length;
        this.width = width;
    }
}

public class Circle implements Shape {
    final double radius;
    public Circle(double radius) {
        this.radius = radius;
    }
}

public static double getPerimeter(Shape shape) {
    if (shape instanceof Rectangle) {
        Rectangle r = (Rectangle) shape;
        return 2 * r.length + 2 * r.width;
    } else if (shape instanceof Circle) {
        Circle c = (Circle) shape;
        return 2 * c.radius * Math.PI;
    } else {
        throw new RuntimeException("Unknown shape");
    }
}

查看getPerimeter方法的实现, 尽管我们已经检查了类型, 但仍需进行强制类型转换并声明新变量才能执行操作. 这是因为编译器仍然将 shape 视为 Shape 的实例.

instanceof 的模式匹配允许我们在 if-else 块的作用域内声明一个变量, 该变量类型与我们检查的类型一致. 在 Java 14 中, 相同的 if-else 块将如下所示.

typescript 复制代码
  public static double getPerimeter(Shape shape) {
    if (shape instanceof Rectangle r) {
        return 2 * r.length + 2 * r.width;
    } else if (shape instanceof Circle c) {
        return 2 * c.radius * Math.PI;
    } else {
        throw new RuntimeException("Unknown shape");
    }
}

这是编译器的一项不错改进, 使其逐步变得更加智能. instanceof 的模式匹配是一项更大的努力, 在 Java 的后续版本中持续扩展, 包括对Record类的模式匹配, 这是我接下来想讨论的下一个重要功能.

从冗长到简洁, Java 的Record重塑data

好吧, 这对我很重要. 我真的很喜欢 Scala 如何轻松地使用data类来建模事物. Java 以领域建模而闻名, 但在引入Record类之前, 定义数据容器(即所谓的 POJO)非常冗长, 这导致生态系统中出现了各种库, 这些库通过代码生成来替代手动编写重复代码(例如 Lombok).

让我们看看以下示例.

arduino 复制代码
public class Person {
    public final String id;
    public final String name;
    public final Integer age;

    public Person(String id, String name, Integer age) {
        this.id = id; 
        this.name = name;
        this.age = age;
    }
}

var p1 = new Person("a1b", "Frank", 30)
var p2 = new Person("a1b", "Frank", 30)

p1.equals(p2) //false, oh?

首先, 与其他高级编程语言相比, 这已经是一个相当冗长的语法来定义一个简单的data类. 此外, 当我们希望以这种方式建模数据时, 通常希望根据对象的内容进行比较, 而在这个示例中, 人们可能会认为(如果他们对 Java 不熟悉)p1 应该等于 p2, 但事实并非如此.

这是因为在 Java 中, 对象是内存的引用, 如果不明确告诉编译器如何比较对象, equals() 方法的默认策略是比较内存地址. 这就是 == 运算符所做的事情. 那么, 我们需要做什么才能使我们的 Person 对象与同一类型的另一个实例可比较?

kotlin 复制代码
public class Person {
    public final String id;
    public final String name;
    public final Integer age;

    public Person(String id, String name, Integer age) {
      this.id = id;
      this.name = name;
      this.age = age;
    }

    @Override
    public boolean equals(Object o) {
      if (o == this) return true;
      if (o == null || !(o instanceof Person)) return false;

      Person p = (Person) o;
      return Objects.equals(p.id, this.id) && Objects.equals(p.age, this.age) && Objects.equals(p.name, this.name);
    }

    @Override
    public int hashCode() {
      int hash = 7;
      hash = 31 * hash + Objects.hashCode(id);
      hash = 31 * hash + Objects.hashCode(name);
      hash = 31 * hash + age;
      return hash;
    }
}

原来还有很多事情要做. 我们需要重写 equalshashCode 方法来定义对象比较的规则. 像 Lombok 这样的库会为我们生成这些方法, 这样我们就不用手动编写它们了, 但现在 Java 已经提供了原生支持.

引入Record. 自 Java 17 起(自 14 版起预览), 我们可以通过 record 关键字以一种新的方式定义类. 基于之前的示例, 让我们看看如何使用Record来改进它.

ini 复制代码
record Person(String id, String name, Integer age) {};

var p1 = new Person("1ab", "Frank", 30);
var p2 = new Person("1ab", "Frank", 30);

p1.equals(p2); //true, yes sir!

p1 == p2; //false, which is expected as these are still two different instances

探索 Java Record中的 with 语句

就这样! Java编译器会自动为我们生成字节码, 并考虑所有定义的参数来实现equalshashCode方法. Record类还提供了toStringgetter方法, 但没有setter方法. Record类设计为不可变的, 这意味着一旦实例创建, 我们只能读取对象成员, 而不能更改其值.

不可变性有助于减少并发程序中的错误, 并使代码更易于理解. 目前, 创建更新后的实例有点麻烦, 需要将所有参数从一个Record复制到另一个Record, 但未来随着新功能Recordwith的推出, 这一情况有望得到改善, 该功能已在项目 Amber 的草稿部分中概述的新功能With for records 中有所改变. 最终, 我们将能够通过修改Record对象的一个或多个成员来创建新的实例, 例如:

ini 复制代码
 var p3 = p2 with { name = "Joe" };

目前已有一些库可协助实现此功能, 你可以查看这个 GitHub 项目 record-builder. 除了解决修改Record对象的限制外, 它还能生成构建器, 这对处理更复杂的data类非常有用.

Java Record的功能远不止这个示例所展示的. Record的其他特性包括能够定义自定义构造函数, 并且我们仍然可以定义常规方法来操作底层数据. 由于Record设计上是不可变的, 因此Record的序列化比普通类实例的序列化更简单, 更安全. Record还可以被分解为值, 这一特性被下一项功能------Record的模式匹配所利用.

Record的模式匹配

Record的模式匹配在 Java 21 中最终确定. 作为 Amber 项目的一部分, 模式匹配和Record的引入开启了新的编程范式------数据导向编程(DOP). DOP 强调将问题建模为不可变的数据结构, 并使用不修改数据的通用函数进行计算. 我之前听说过这个概念, 没错, 这是函数式编程中广为人知的概念! 值得一提的是, 引入支持DOP范式的特性并非为了取代面向对象编程(OOP), 而是为了更优雅地解决特定任务. 在大型系统中, OOP擅长定义和管理代码边界, 而DOP在处理小型任务时更具优势, 可用于建模和操作数据.

让我们直奔主题. 模式匹配究竟是什么? 我们可以将其视为类构造函数的相反概念. 类构造函数允许我们通过提供一些数据来构造对象, 而模式匹配则允许我们分解或提取在对象构造过程中使用的数据. 让我们看看实际应用中是怎样的.

模式匹配示例

在此示例中, 我们使用Record类来建模不同的交易类型, 目标是编写一个方法, 该方法将消耗一组交易并计算账户余额. 如果交易类型为"购买", 则需要增加余额; 如果为"支付", 则减少余额; 如果为"支付退款", 则再次增加余额.

vbnet 复制代码
 public interface Transaction {
    String id();
}

record Purchase(String id, Integer purchaseAmount) implements Transaction {}

record Payment(String id, Integer paymentAmount) implements Transaction {}

record PaymentReturned(String id, Integer paymentAmount, String reason) implements Transaction {}

List transactions = List.of(
    new Purchase("1", 1000),
    new Purchase("2", 500),
    new Purchase("3", 700),
    new Payment("1", 1500),
    new PaymentReturned("1", 1500, "NSF")
);

假设我们无法访问定义交易的代码库以进行重构, 或者这部分代码不应负责计算余额. 在我们的代码中实现 calculateAccountBalance 的一种方式如下.

typescript 复制代码
public static Integer calculateAccountBalance(List transactions) {
  var accountBalance = 0;
  for (Transaction t : transactions) {
    if (t instanceof Purchase p) {
      accountBalance += p.purchaseAmount();

    } else if (t instanceof Payment p) {
      accountBalance -= p.paymentAmount();

    } else if (t instanceof PaymentReturned p) {
      accountBalance += p.paymentAmount();
    } else {
 throw new RuntimeException("Unknown transaction type");
    }
  }

  return accountBalance;
}

这种实现方式还不错, 可读性较强, 但如果需要处理更多类型的交易, 代码可能会变得较为冗长. Record的模式匹配通过以下实现方式对此进行了改进.

csharp 复制代码
   public static Integer calculateAccountBalance(List transactions) {
  var accountBalance = 0;

  for (Transaction t: transactions) {
    switch(t) {
      case Purchase p -> accountBalance += p.purchaseAmount();
      case Payment(var id, var amt) -> accountBalance -= amt;
      case PaymentReturned(var id, var amt, var reason) -> accountBalance += amt;
 default -> throw new RuntimeException("Unknown transaction type");
    }
  }

  return accountBalance;
}

这看起来更简洁且不冗余. 注意 switch 关键字------它在 Java 中已存在很长时间. 在 Java 17 中, switch 得到了增强, 支持与 lambda 表达式类似的语法, 而在 Java 19 和 21 中, switch 进一步增强, 支持对Record进行模式匹配.

在使用模式匹配时, 我们可以像第一个示例所示那样引用类型的实例, 或者像第二个和第三个示例那样将类型分解为其组成部分. 开关语句的模式匹配还允许我们使用新的 when 关键字基于布尔表达式匹配模式. 例如, 我们可以使用不同的谓词多次匹配同一类型并执行不同的操作.

csharp 复制代码
 switch(t) {
      case PaymentReturned p when p.reason.equals("NSF") -> ...
      case PaymentReturned p -> ...
}

如果你仍然对模式匹配的实用性持怀疑态度, 还有一件事需要说明. 假设过了一段时间, 我们引入了一种新的交易类型, 暂且称之为Credit, 用于表示反向购买交易. 通过添加一个实现Transaction接口的新Record类型, 我们的代码在两种实现中仍然可以编译通过. 我们只会在运行时发现问题, 即我们的逻辑遇到一个它不知道如何处理的类型, 并抛出异常.

模式匹配对Record的可用性进一步得到了另一个语言特性的增强, 该特性在 Java 17 中引入, 即密封类和密封接口(JEP409). 将接口标记为密封, 会向编译器传达Transaction的实现数量是有限的, 因此编译器可以验证模式匹配中所有情况均已处理. 实现类必须与密封接口位于同一文件中(例如在接口内部), 或通过在接口上使用permits关键字指定封闭的类型层次结构(详见JEP).

现在, 如果我们遗漏了其中一个情况的处理, 代码将无法编译. 为了确保这一点, 我们需要移除默认情况, 该情况通常会捕获任何缺失的模式.

csharp 复制代码
 public sealed interface Transaction {
    String id();

    // define in here records extending interface
}


public static Integer calculateAccountBalance(List transactions) {
  var accountBalance = 0;

  for (Transaction t: transactions) {
    switch(t) {
      case Purchase p -> accountBalance += p.purchaseAmount();
      case Payment(var id, var amt) -> accountBalance -= amt;
      case PaymentReturned(var id, var amt, var reason) -> accountBalance += amt;
    }
  }

  return accountBalance;
}

现在编译器会显示以下友好的错误信息------"编译失败: switch语句未覆盖所有可能的值".

接口上的 sealed 关键字在我们最初使用 if-else 块的实现中无法帮助我们, 因此模式匹配在这里更具优势. 密封接口/类还有更多内容超出了本文的范围, 但这是一个示例, 展示了不同语言特性如何协同工作并提升代码的健壮性.

值得一提的是, 可以使用访问者设计模式来解决上述问题. 然而, 该模式会引入更多复杂性和需要编写的代码量. 随着 Java 21 引入密封接口和模式匹配, 访问者设计模式已基本过时.

Java 21 对开发环境的影响

Java 21是一个功能丰富的版本, 基于Java 17以来的功能进行扩展. Java 21被供应商选为下一个长期支持(LTS)版本, 这对通常只允许使用LTS版本的大型企业来说是个好消息.

本文未提及的 Java 21 还有许多令人兴奋的新特性. 其中最受期待的或许是最终在 Java 21 中定稿的虚拟线程(Virtual Threads, 即项目 Loom), 但这是一个更庞大的话题, 需要单独撰写博客文章. 你可通过 OpenJDK 官网 了解更多关于 JDK 21 的最新发布信息.

Java 始终以稳健且负责任的方式演进. 它密切关注软件行业的发展趋势, 并通过谨慎实施新特性, 确保向后兼容性, 来保持语言的适用性.

Java 21 版本的使用体验令人愉悦, 这一重大里程碑将确保 Java 在未来数年内继续在行业中占据重要地位.

好吧, 今天的内容就分享到这里啦!

一家之言, 欢迎拍砖!

Happy coding! Stay GOLDEN!

相关推荐
陈阿土i1 分钟前
SpringAI 1.0.0 正式版——利用Redis存储会话(ChatMemory)
java·redis·ai·springai
安全系统学习9 分钟前
【网络安全】Qt免杀样本分析
java·网络·安全·web安全·系统安全
SoFlu软件机器人35 分钟前
智能生成完整 Java 后端架构,告别手动编写 ControllerServiceDao
java·开发语言·架构
写bug写bug1 小时前
如何正确地对接口进行防御式编程
java·后端·代码规范
Cyanto2 小时前
Java并发编程面试题
java·开发语言·面试
在未来等你2 小时前
互联网大厂Java求职面试:AI大模型与云原生技术的深度融合
java·云原生·kubernetes·生成式ai·向量数据库·ai大模型·面试场景
sss191s2 小时前
Java 集合面试题从数据结构到 HashMap 源码剖析详解及常见考点梳理
java·开发语言·数据结构
LI JS@你猜啊2 小时前
window安装docker
java·spring cloud·eureka
书中自有妍如玉2 小时前
.net 使用MQTT订阅消息
java·前端·.net