Java,一门广受赞誉,却又饱受诟病的语言,在从其诞生至今,便无时不刻的被于其他语言对比,有时候这种对比是空穴来风的诽谤,但更多的是对这门语言未来的担心,而近 10 年来涌现的一个又一个新生的程序语言更是让 Java 一次又一次地被推上风口浪尖,使公众一次又一次的质疑:Java,是否真的停滞不前了?
2024 年,从大街上随便抓一个 Java 程序员,询问其 Java 有哪些槽点,我相信你的这个下午大概是别想离开这个人的声音了 —— 从泛型不支持基本数据类型到各种各样令人抓耳挠腮的奇怪问题,你绝对可以听这个人滔滔不绝地说上一整天。那么这些问题 Java 官方知道吗?当然知道,他们在解决吗?Ummm,至少我们可以说,他们一直以来都正在积极的为解决这些问题而努力,并且有些槽点,其实早已在最新版本的 Java 中被解决。
因此,本篇文章的目的,便是带领读者从过去走向现在,再走向未来,回顾并前瞻 Java 已经推出,或是即将推出的全新特性,这些特性再 Java 的历史中都扮演着决定性的作用,为 Java“赶 Go 超 Rust”贡献着自己的努力。
碍于篇幅所限,我们将只重点提及几个 Java 语言史上的重大改动,而其他小的(但不代表不重要)更新,我们姑且一概掠过。若要了解Java 从过去到现在全部的特性更新,也许你可以看看 OpenJDK 的 Java 特性提案索引页 JEP 0: JEP Index,了解更多。
Java 8 无论是从 JVM 层面的变动,还是 Java 语法和标准库的变动,都可以说是 Java 有史以来第一次大规模的增补,毋庸置疑的,这次更新也为 Java 带来了第二春,使之焕发新生,而其长达近 20 年的 LTS 支持,也使其成为了 Java 历史上使用率最高,最经久不衰的 Java 版本。
在这次更新中,Java 自然是引入了全新且复杂的 Date & Time API,看起来好像有点用但实际上很鸡肋的 Optional API 这类谈不上小但是也很难说重大的标准库修补。但是更为被人津津乐道,且在本人看来是 Java 8 最重要的两个更新,便是 Lambda 表达式和 Stream API。
也许是考虑到兼容性,也许就是纯粹 Java 开发者懒,自 Java 7 以前,Java 虚拟机(JVM)基本没有什么重大改动,纵然 Java 语言已经引入了诸如自动拆装箱、参数化类型(泛型)这样的重大语言特性,JVM 依然不动如山,全靠 javac 衬托。
而这个指令第一次在 Java 语言中登场,便是神奇的 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 中,函数(方法)不是一等公民。
function foo(){ console.log("foo!"); } function bar(barFoo){ barFoo(); } bar(foo);
最后一行中,我们为 bar 函数直接传入 foo 函数作为其实参,并在 bar 函数中调用这个函数。我们可以将一个函数(或者说,函数指针)作为参数传入到函数中,就像其他数据类型一样。
但是 Java 是没有办法直接传入函数指针的,如果你了解 C# 的话,C# 用 Delegate(委托)机制解决这个问题,而 Java 则绕的更远一些,选择了 Functional Interface(函数式接口)作为其函数式编程的解决方案。那么,什么是函数式接口?
@FunctionalInterface public interface Runnable { /** * When an object implementing interface <code>Runnable</code> is used * to create a thread, starting the thread causes the object's * <code>run</code> method to be called in that separately executing * thread. * <p> * The general contract of the method <code>run</code> 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 也正巧成为了函数式接口的一部分。
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 中,想要达成这样的操作,会使代码变得极度复杂。
相信各位对“Coroutine(协程)”这个名词一定不陌生,被称为“轻量级线程”的它,在 I/O 密集型的应用程序开发领域可谓是如日中天。所谓“协程”,便是一种用户态的线程,它们构建于线程之上,由用户程序负责调度,而不是操作系统。比起原生的操作系统线程,他更轻量,而比起 Event Loop(事件循环)的解决方案,它又能保证对用户程序足够透明,降低开发过程中的心智负担。
Thread.ofVirtual().start(()->{ // some heavy IO stuff });就是这么简单,如果你在用 Spring Boot 3,只需要一行配置便可以在你的项目中启用虚拟线程支持:
spring.threads.virtual.enabled=true很简单对不?现在就去试试看吧,我保证带来的性能提升是立竿见影的。
当然有关并发编程,另一个绕不开的话题便是异步编程了,Java 目前原生的异步编程由 Future 等对象支持,用起来不能说十分好用,只能说味同嚼蜡。在 Java 19 引入的 Structured Concurrency(结构化并发)事实上在一定程度上为异步编程提供了更好的解决方案,篇幅所限,在这里我们也不再展开。
长期以来,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 一样,绕一个大圈,或是看看社区上有没有已经做好的,也许可能充满各种漏洞的小玩具。
Linker linker = Linker.nativeLinker(); SymbolLookup stdlib = linker.defaultLookup(); MethodHandle strlen = linker.downcallHandle( stdlib.find("strlen").orElseThrow(), FunctionDescriptor.of(ValueLayout.JAVA_LONG, ValueLayout.ADDRESS) ); // 堆代码 duidaima.com try (Arena arena = Arena.ofConfined()) { MemorySegment cString = arena.allocateFrom("Hello"); long len = (long)strlen.invokeExact(cString); // 5 }
上述代码创建了一个操作系统标准库的链接器,在其中查找 strlen 函数并以一个从 JVM 创建的堆外字符串作为参数执行,获取结果。在这个过程中,还需要告诉 JVM 函数和参数的内存布局,以便 JVM 可以正确传入他们。
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 能做的事情会更有趣和疯狂。
至此,我们已经介绍完了 Java 走向现代化三座大山中已经落地的前两座,如你所见的是,他们每一个都充满诱惑,十分大胆,令 Java 焕发新生,但是 Project Valhalla 将带给我们的,比前面我讲过的那些特性更加疯狂,更加颠覆:为 Java 引入值类型对象,补上长久以来 Java 泛型编程的缺陷,并为 JVM 虚拟机提供运行时可见的泛型参数。
让我们先来回忆一下泛型的前世今生:泛型于 Java 1.5 被首次引入,其更官方、也更直观的名称应该是 Parameterized Type(参数化类型),其允许将类型作为类或函数的参数提供,以便于更好的进行类型检查或是根据不同的泛型特化代码实现,然而后者并不被 Java 泛型所支持,因为 Java 泛型采用的方案于 C++, Go, Rust 这些语言的泛型方案有本质不同:Java 的泛型只是编译器语法糖,在运行时并没有影响代码执行,这意味着,当你在 C++ 中使用 Vector<bool> 和 Vector<int> 时,C++ 编译器事实上会生产两个不同版本的 Vector 类(这也是其名称“模板”的由来),但 Java 并不会改变这一点,List<Boolean> 和 List<Integer> 和其未泛化原始类型 List 没有任何差别,编译器会在需要提供或返回泛型参数时帮你做类型安全检查或自动类型转换,而 JVM 不会感知到泛型的存在。
List<int> list = new List<>();
在 Java 中是不可能的。而长久以来,Java 程序员只能被迫在需要将基本数据类型放入集合的场景下进退两难:要么把 int 装箱成 Integer,忍受额外的对象创建开销;要么自行构建,或者使用各种工具库提供的特化集合类型(例如 IntArrayList, DoubleArrayList 等)。而事实上,这种语法鸿沟在 Java 中由来已久,例如 switch 语句不支持 double 等类型,instanceof 关键字不支持针对基本数据类型的模式匹配等,颇令新手疑惑,好在在最近的版本(Java 23)中,这些问题都逐步得到完善,进入预览的流程。
再回过头来看看基本数据类型的装箱机制,这实际上是十分不明智的,因为基本数据类型这种可能被程序大量使用的数据,他们本应将其数值直接存储到内存中,而不是被包装一个含有比他们实际内容更为复杂的对象和对象头,这无疑增加了系统的内存压力。而参数化类型对基本数据类型的缺位更是加剧了这一问题。
value record Color(byte red, byte green, byte blue) {} // 值记录类型
这种类型没有对象头,其 hashCode 直接据其所含字段计算,这同时也意味着,对值类型进行 == 比较将会比较其值,而不是其地址。在未来,所有的基本数据类型包装类都会被升级为这种值类型。而原本的类型将会被称为 Identity class,意为具有身份的类型。
而通用泛型(这是一个早前叫法,但我觉得放到这里更直观,所以接着沿用下来)将允许我们在未来在泛型中直接使用基本数据类型作为泛型参数,而这种实现有可能依然是通过自动拆装箱实现的。
但实际上,record 和 value class 是有本质区别的。Record 本质上还是一个对象,他依然是一个特殊的语法糖,并没有改变对象的本质;而 value class 则彻底颠覆了 Java 原有的对象模型。
除此之外,Project Valhalla 还有一些很有意思的提案,例如为 JVM 添加可 null 和非 null 类型,就将 C# 和 Kotlin 所做的那样;亦或者在运行时保留泛型参数,提供特化类型的实现等。