Java ,一门广受赞誉,却又饱受诟病的语言,在从其诞生至今,便无时不刻的被于其他语言对比,有时候这种对比是空穴来风的诽谤,但更多的是对这门语言未来的担心,而近 10 年来涌现的一个又一个新生的程序语言更是让 Java 一次又一次地被推上风口浪尖,使公众一次又一次的质疑:Java ,是否真的停滞不前了?
2024 年,从大街上随便抓一个 Java 程序员,询问其 Java 有哪些槽点,我相信你的这个下午大概是别想离开这个人的声音了 —— 从泛型不支持基本数据类型到各种各样令人抓耳挠腮的奇怪问题,你绝对可以听这个人滔滔不绝地说上一整天。那么这些问题 Java 官方知道吗?当然知道,他们在解决吗? Ummm ,至少我们可以说,他们一直以来都正在积极的为解决这些问题而努力,并且有些槽点,其实早已在最新版本的 Java 中被解决。
因此,本篇文章的目的,便是带领读者从过去走向现在,再走向未来,回顾并前瞻 Java 已经推出,或是即将推出的全新特性,这些特性再 Java 的历史中都扮演着决定性的作用,为 Java“赶 Go 超 Rust”贡献着自己的努力。
碍于篇幅所限,我们将只重点提及几个 Java 语言史上的重大改动,而其他小的(但不代表不重要)更新,我们姑且一概掠过。若要了解 Java 从过去到现在全部的特性更新,也许你可以看看 OpenJDK 的 Java 特性提案索引页 JEP 0: JEP Index,了解更多。
Java8:Lambda 表达式和 Stream API
Java 8 无论是从 JVM 层面的变动,还是 Java 语法和标准库的变动,都可以说是 Java 有史以来第一次大规模的增补,毋庸置疑的,这次更新也为 Java 带来了第二春,使之焕发新生,而其长达近 20 年的 LTS 支持,也使其成为了 Java 历史上使用率最高,最经久不衰的 Java 版本。
在这次更新中,Java 自然是引入了全新且复杂的 Date & Time API ,看起来好像有点用但实际上很鸡肋的 Optional API 这类谈不上小但是也很难说重大的标准库修补。但是更为被人津津乐道,且在本人看来是 Java 8 最重要的两个更新,便是 Lambda 表达式和 Stream API 。
Lambda 表达式
也许是考虑到兼容性,也许就是纯粹 Java 开发者懒,自 Java 7 以前,Java 虚拟机( JVM )基本没有什么重大改动,纵然 Java 语言已经引入了诸如自动拆装箱、参数化类型(泛型)这样的重大语言特性,JVM 依然不动如山,全靠 javac 衬托。
然而到了 Java 7 ,天塌了。JVM 引入了一个全新的指令 invokedynamic,其可以在运行时动态的分派一个函数调用,这个指令最初并没有被 Java 语言本身所使用,相反,它的出现是为了解决基于 JVM 的动态类型语言(例如 Groovy )在运行时由于 JVM 无法支持函数类型动态分派而导致的巨大性能问题。
而这个指令第一次在 Java 语言中登场,便是神奇的 Lambda 表达式了。
即使你不知道 Lambda 表达式,或者他背后的函数式接口,我相信你一定写过这样的代码:
new Thread(() -> Foo.bar()).start(); // 更好的一个写法其实是 new Thread(Foo::bar).start();
这很自然,就像你可能不会泛型编程,但一定也用过带泛型的 Java 容器一样。但如果我告诉你,在过去的 Java 版本中,人们只能这么写:
new Thread(new Runnable() {
@Override
public void run() {
Foo.bar();
}
}).start();
是不是会有一种天然的碰见庞然大物的恐惧感。而事实上,在 Java 8 以前,函数式编程是不可能的,这主要源自于 Java 的一个语法缺陷:在 Java 中,函数(方法)不是一等公民。
什么是“一等公民”?来看看在 JavaScript 中大家习以为常的一段代码:
function foo(){
console.log("foo!");
}
function bar(barFoo){
barFoo();
}
bar(foo);
最后一行中,我们为 bar 函数直接传入 foo 函数作为其实参,并在 bar 函数中调用这个函数。我们可以将一个函数(或者说,函数指针)作为参数传入到函数中,就像其他数据类型一样。
但是 Java 是没有办法直接传入函数指针的,如果你了解 C# 的话,C# 用 Delegate(委托)机制解决这个问题,而 Java 则绕的更远一些,选择了 Functional Interface(函数式接口)作为其函数式编程的解决方案。那么,什么是函数式接口?
通俗的来讲,任意一个仅有一个抽象方法的接口,都是函数式接口(无论其是否标注 @FunctionalInterface 注解),例如我们上边看到的 Thread 构造方法中的 Runnable 接口:
@FunctionalInterface
public interface Runnable {
/**
* When an object implementing interface Runnable is used
* to create a thread, starting the thread causes the object's
* run method to be called in that separately executing
* thread.
*
* The general contract of the method run is that it may
* take any action whatsoever.
*
* @see java.lang.Thread#run()
*/
public abstract void run();
}
这个接口只有一个名为 run 的抽象方法,并没有任何返回值。我们可以为需要函数式接口实例的地方传入 Lambda 表达式,在运行时,Lambda 表达式会被转换为对应函数式接口的实例,就像我们为 Thread 传入构造函数参数所做的那样一样。
当然,请不要误解我的意思,并不是自 Java 8 引入函数式接口这个概念之后,才有了 Runnable 接口,相反,Runnable 接口古早有之,是函数式接口的概念被引入后,Runnable 也正巧成为了函数式接口的一部分。
Stream API
Lambda 表达式的一大创新之处,就是为在 Java 语言进行函数式编程提供了可能,由此,Stream (流) API 应运而生。这里所说的“流”并不是指 I/O 流,而是一种数据流动的管道。举个例子,现在有一个包含 10000 个数字的 int 数组:
int[] array = new int[10000];
我想找出该数组中所有数字大于 5000 的数字,然后让他们加一个不大于 500 的随机数,最后求和。在不使用 Stream API 的情况下我们会这么写:
public int sumRandomNumber(int[] array, Random random){
int rst = 0;
for (int i : array) {
if (i > 5000) {
rst += i + random.nextInt(500);
}
}
return rst;
}
但如果有了 Stream API ,只需要一行代码就可以解决:
public int sumRandomNumberWithStreamAPI(int[] array, Random random) {
return Arrays.stream(array).filter(i -> i > 5000).map(i -> i + random.nextInt(500)).sum();
}
在上述代码中,我们通过调用 Arrays.stream 方法将 array 转换为一个 IntStream 流对象,然后顺次调用 filter 和 map 流中间方法,过滤和映射数据,最终调用 sum 流终结方法,获得求和结果。
一种特定类型的数据经过流中间方法的加工处理,最终经过流终结方法收集为我们想要的形式,这极大地提高了开发效率,而在以前的 Java 中,想要达成这样的操作,会使代码变得极度复杂。
Project Loom:Java 迈向现代化的第一步
相信各位对“Coroutine (协程)”这个名词一定不陌生,被称为“轻量级线程”的它,在 I/O 密集型的应用程序开发领域可谓是如日中天。所谓“协程”,便是一种用户态的线程,它们构建于线程之上,由用户程序负责调度,而不是操作系统。比起原生的操作系统线程,他更轻量,而比起 Event Loop (事件循环)的解决方案,它又能保证对用户程序足够透明,降低开发过程中的心智负担。
许多现代语言都配备了协程的原生支持,尽管它们各自的实现方式并不相同,例如 Go 的 Goroutine,Kotlin 的 Kotlin Coroutines 或是 C++ 20 的 Coroutines。在早期版本的 Java 中,其实有一个名为“Green thread (绿色线程)”的协程实现,但因为各种原因,最终被替换回了现在的操作系统线程。
但是我们确实需要协程,于是 2017 年,Project Loom 应运而生,它的使命就是为 Java 提供自己的有栈协程实现,早期被称为“Fiber (纤程)”,后被称为“Virtual thread (虚拟线程)”,经过两个大版本的预览,其终于在 Java 21 中正式推出,这意味着 Java 平台也拥有了自己的原生协程实现。
于是现在,你可以通过 Thread 对象的静态工厂方法 ofVirtual 创建一个虚拟线程:
Thread.ofVirtual().start(()->{
// some heavy IO stuff
});
就是这么简单,如果你在用 Spring Boot 3 ,只需要一行配置便可以在你的项目中启用虚拟线程支持:
spring.threads.virtual.enabled=true
很简单对不?现在就去试试看吧,我保证带来的性能提升是立竿见影的。
当然有关并发编程,另一个绕不开的话题便是异步编程了,Java 目前原生的异步编程由 Future 等对象支持,用起来不能说十分好用,只能说味同嚼蜡。在 Java 19 引入的 Structured Concurrency (结构化并发)事实上在一定程度上为异步编程提供了更好的解决方案,篇幅所限,在这里我们也不再展开。
Project Panama:外地人向本地人的妥协
长期以来,Java 一直以“一次编写,到处运行”作为自己的卖点,然而很不幸的是,Java 没能向开发者提供所有他们想要的原材料,因此,开发者们决定自己做,最终,在各种 JNI 函数和 Unsafe 调用的狂轰滥炸下,Java 最终还是变成了“一次编写,到处调试”的样子。
JNI 好用吗?我相信没人会说好用,不然也不可能会有 JNA 一类的库出现,JNI 看似提供了 Java 向 native 调用的接口,但实际上它完全不够灵活,无法在运行时根据程序的需要动态的链接不同的函数。自 Java 1.1 引入 JNI 开始,这个东西就基本没什么变化,大家只能捏着鼻子用这样一套并不好用的东西,或者只能叹叹气,然后另寻他法。
再回过头来看看 Unsafe ,在过去版本的 Java 中,管理堆外内存是非常复杂且危险的,尤其是当我们通过 hacky 的方式获取 sun.misc.Unsafe 类实例,并使用其中的 allocateMemory 方法来分配堆外内存时。这意味着,我们需要手动管理这些堆外内存的分配和释放,一不小心,就可能造成 JVM 虚拟机和 GC 无法处理的内存泄漏。
有些人可能会说:JVM 本来就不希望你使用堆外内存,你为什么要这么用,这不是自找没趣吗?但是很遗憾的是,有时要想获得高性能的数据吞吐或是确保数据的一致性,我们不得不这么做,例如在 Java 中使用 mmap, CAS ,或是设置内存屏障。在 Java 8 ,如果你想设置一个操作系统级别的重量级锁,你可以使用 LockSupport.park;自 Java 9 开始,如果你想对一个对象中的字段 CAS 写入,则可以用 VarHandle.compareAndSet 方法;但是其他 JVM 未能提供的操作,也许你只能像使用 JNI 一样,绕一个大圈,或是看看社区上有没有已经做好的,也许可能充满各种漏洞的小玩具。
但是事情还是需要解决的,最终这场争端以 Java 这个外地人向本地人的妥协而告终:Project Panama 应运而生。经过三个大版本的预览,Project Panama 的一个重要特性,The Foreign Function & Memory (FFM) API 终于在 Java 22 正式落地。FFM API 有什么用?首先,它可以提供灵活的本地库访问:
Linker linker = Linker.nativeLinker();
SymbolLookup stdlib = linker.defaultLookup();
MethodHandle strlen = linker.downcallHandle(
stdlib.find("strlen").orElseThrow(),
FunctionDescriptor.of(ValueLayout.JAVA_LONG, ValueLayout.ADDRESS)
);
try (Arena arena = Arena.ofConfined()) {
MemorySegment cString = arena.allocateFrom("Hello");
long len = (long)strlen.invokeExact(cString); // 5
}
上述代码创建了一个操作系统标准库的链接器,在其中查找 strlen 函数并以一个从 JVM 创建的堆外字符串作为参数执行,获取结果。在这个过程中,还需要告诉 JVM 函数和参数的内存布局,以便 JVM 可以正确传入他们。
接下来,FFM API 向我们提供了更适合 Java 宝宝的堆外内存 API ,Arena ,你可以通过这种方式创建一个会自动被 GC 清理的堆外内存:
MemorySegment segment = Arena.ofAuto().allocate(100, 1);
...
segment = null; // the segment region becomes available for deallocation after this point
或者,你可以直接创建一个基于作用域的堆外内存,并使用 try-with-resource 语法包裹,只要离开 try 作用域,则分配的堆外内存会被自动释放:
MemorySegment segment = null;
try (Arena arena = Arena.ofConfined()) {
segment = arena.allocate(100);
...
} // segment region deallocated here
segment.get(ValueLayout.JAVA_BYTE, 0); // throws IllegalStateException
别忘了 mmap,现在 FFM API 可以直接提供这种支持,只需要调用 FileChannel.map 方法即可:
Arena arena = Arena.ofAuto();
try {
try (FileChannel channel = FileChannel.open(Path.of("large_file"), StandardOpenOption.READ)) {
MemorySegment segment = channel.map(FileChannel.MapMode.READ_ONLY, 0, FILE_SIZE, arena);
// use segment in your way
}
} catch (IOException e) {
throw new RuntimeException(e);
}
是不是简简又单单呢?有了 FFM API 这把瑞士军刀,相信以后 Java 能做的事情会更有趣和疯狂。
Project Valhalla:走向未来
至此,我们已经介绍完了 Java 走向现代化三座大山中已经落地的前两座,如你所见的是,他们每一个都充满诱惑,十分大胆,令 Java 焕发新生,但是 Project Valhalla 将带给我们的,比前面我讲过的那些特性更加疯狂,更加颠覆:为 Java 引入值类型对象,补上长久以来 Java 泛型编程的缺陷,并为 JVM 虚拟机提供运行时可见的泛型参数。
让我们先来回忆一下泛型的前世今生:泛型于 Java 1.5 被首次引入,其更官方、也更直观的名称应该是 Parameterized Type (参数化类型),其允许将类型作为类或函数的参数提供,以便于更好的进行类型检查或是根据不同的泛型特化代码实现,然而后者并不被 Java 泛型所支持,因为 Java 泛型采用的方案于 C++, Go, Rust 这些语言的泛型方案有本质不同:Java 的泛型只是编译器语法糖,在运行时并没有影响代码执行,这意味着,当你在 C++ 中使用 Vector 和 Vector 时,C++ 编译器事实上会生产两个不同版本的 Vector 类(这也是其名称“模板”的由来),但 Java 并不会改变这一点,List[B] 和 List[I] 和其未泛化原始类型 List 没有任何差别,编译器会在需要提供或返回泛型参数时帮你做类型安全检查或自动类型转换,而 JVM 不会感知到泛型的存在。
泛化泛型和具化泛型的争端从未停止,本文也无心讨论此两者之间各自的优劣,但是不可否认是,泛化泛型确实为 Java 引入了一个难以逾越的语法鸿沟:那就是参数化类型无法接受基本数据类型作为参数,这意味着在 C# 程序员严重看起来十分正常的代码:
List list = new List();
在 Java 中是不可能的。而长久以来,Java 程序员只能被迫在需要将基本数据类型放入集合的场景下进退两难:要么把 int 装箱成 Integer,忍受额外的对象创建开销;要么自行构建,或者使用各种工具库提供的特化集合类型(例如 IntArrayList, DoubleArrayList 等)。而事实上,这种语法鸿沟在 Java 中由来已久,例如 switch 语句不支持 double 等类型,instanceof 关键字不支持针对基本数据类型的模式匹配等,颇令新手疑惑,好在在最近的版本( Java 23 )中,这些问题都逐步得到完善,进入预览的流程。
再回过头来看看基本数据类型的装箱机制,这实际上是十分不明智的,因为基本数据类型这种可能被程序大量使用的数据,他们本应将其数值直接存储到内存中,而不是被包装一个含有比他们实际内容更为复杂的对象和对象头,这无疑增加了系统的内存压力。而参数化类型对基本数据类型的缺位更是加剧了这一问题。
为此,Project Valhalla 横空出世,直指这些痛点问题,并推出了它们的解决方案:值类型和通用泛型。
在未来的 Java 版本中,我们将可以通过 value class 标识创建一个值类型类:
value record Color(byte red, byte green, byte blue) {} // 值记录类型
这种类型没有对象头,其 hashCode 直接据其所含字段计算,这同时也意味着,对值类型进行 == 比较将会比较其值,而不是其地址。在未来,所有的基本数据类型包装类都会被升级为这种值类型。而原本的类型将会被称为 Identity class ,意为具有身份的类型。
而通用泛型(这是一个早前叫法,但我觉得放到这里更直观,所以接着沿用下来)将允许我们在未来在泛型中直接使用基本数据类型作为泛型参数,而这种实现有可能依然是通过自动拆装箱实现的。
如果你恰巧用过 Java 16 及以上的版本,你可能会发现有一个新特性和上述特性有点类似,那就是 Record (记录)类型,该特性允许你通过简单的语法创建一个不可变的 POJO 对象,并为其实现 equals/hashCode/toString 方法和构造函数,而不必由你手动实现,或是使用额外的 @lombok.Data 和 @lombok.AllArgsConstructor注解:
record Point(int x, int y) { } // 通过 new Point(0, 0) 构造,通过 point.x()/point.y() 访问
但实际上,record 和 value class 是有本质区别的。Record 本质上还是一个对象,他依然是一个特殊的语法糖,并没有改变对象的本质;而 value class 则彻底颠覆了 Java 原有的对象模型。
除此之外,Project Valhalla 还有一些很有意思的提案,例如为 JVM 添加可 null 和非 null 类型,就将 C# 和 Kotlin 所做的那样;亦或者在运行时保留泛型参数,提供特化类型的实现等。
最后要说的是,Project Valhalla 的相关提案仍在不断更新,早在草案时期,相关提案就已被推翻重置了多次,因此对于该提案的相关描述在未来可能会是不准确的,希望读者悉知。
引用
JEP 444: Virtual Threads (openjdk.org)
JEP 454: Foreign Function & Memory API (openjdk.org)
JEP 471: Deprecate the Memory-Access Methods in sun.misc.Unsafe for Removal (openjdk.org)
java.lang.foreign (Java SE 22 & JDK 22) (oracle.com)
Arena (Java SE 22 & JDK 22) (oracle.com)
FileChannel (Java SE 22 & JDK 22) (oracle.com)
Project Valhalla (openjdk.org)
JEP 395: Records (openjdk.org)
JEP 401: Value Classes and Objects (Preview) (openjdk.org)
JEP 402: Enhanced Primitive Boxing (Preview) (openjdk.org)
JEP 455: Primitive Types in Patterns, instanceof, and switch (Preview) (openjdk.org)