Project Valhalla:Java 值类型的英灵殿 您所在的位置:网站首页 valhalla英灵殿歌曲 Project Valhalla:Java 值类型的英灵殿

Project Valhalla:Java 值类型的英灵殿

#Project Valhalla:Java 值类型的英灵殿| 来源: 网络整理| 查看: 265

我正在参加「掘金·启航计划」

本文以 CC-BY-SA 4.0 发布。

Project Valhalla 是 Java 近几年的大项目之一,旨在让用户能够自定义值类型。 我们都知道 Java 里面除了 int 这些原生数据类型之外其它全是引用类型。 原生数据类型按值传递,赋值和函数传参都会把值给复制一份,复制之后两份之间就再无关联; 引用类型无论什么情况传的都是指针,修改指针指向的内容会影响到所有的引用。

原生类型和引用类型的比较(来自 Brian Goetz 的邮件):

原生类型 Primitives引用对象 Objects直接表现为内存里储存的形式表现为间接的引用没有 null可 null默认值为 0默认值为 null有对应的引用形式(装箱)它本身就是引用

当然,毕竟是正在开发中的东西,所有这里提到的之后都有可能改动的可能。

两种类型的性能差异:以 AtomicInteger 为例

对于在意性能的同学来说,原生类型和引用类型的最大差别其实是其性能开销。 举一个后端同学可能相对熟悉的例子:Netty 的 ByteBuf 的引用计数。

Netty 里面的 ByteBuf 都是需要用户手动引用计数的,而因为存在多线程同时修改计数器的可能性,计数器必须使用原子操作实现。 Java 里 AtomicInteger 和 AtomicIntegerFieldUpdater 都可以用来进行 int 的原子操作—— Netty 选择的是后者的 AtomicIntegerFieldUpdater。它们的不同点是:

AtomicInteger 的 int 值储存在它这个对象里,一个 AtomicInteger 对应一个 int。

class MyClass { private final AtomicInteger i = new AtomicInteger(); }

这样,每 N 个 MyClass 对象就会有 N 个 AtomicInteger。

AtomicIntegerFieldUpdater 的 int 值储存在它外面,一个 AtomicIntegerFieldUpdater 可以对应无数个 int。

class MyClass { private static final AtomicIntegerFieldUpdater i = AtomicIntegerFieldUpdater.newUpdater(MyClass.class, "i"); private volatile int i; }

此时,N 个 MyClass 对象只需要一个 AtomicIntegerFieldUpdater,如图所示,少了一个 object_header 和一个指针的空间占用。

classDiagram class MyAtomicUpdater{ object_header* int i } class MyAtomic { object_header* AtomicInteger } class AtomicInteger { object_header* int i } MyAtomic *-- AtomicInteger 性能测试 测试项结果单位使用 AtomicInteger 递增146626503.962ops/s使用 AtomicIntegerFieldUpdater 递增146085426.441ops/s使用 int 储存88825249.975ops/s(内存使用)16.000B/op使用 AtomicInteger 对象储存63407327.012ops/s(内存使用)32.000B/op

测试结果解释:

使用 AtomicInteger 比使用 int 内存占用更多:

多了 16 字节,猜测是 12 字节的对象头以及 4 字节的压缩指针。也就是使用 AtomicInteger 会比使用 4 字节的 int 多四倍的内存占用。

二者的速度近乎一致:

使用 int 的 AtomicIntegerFieldUpdater 的 Java 实现用的可能主要是反射,未编译时的性能会慢一些。 但进行 JIT 编译优化之后二者的速度基本一致。

总的来说就是下面的表格这样,原生类型的内存占用更有优势,性能一致(有些情况下会更好)。 这也可能是 Netty 使用 AtomicIntegerFieldUpdater + int 的原因之一。

原生类型引用类型空间占用只有值值 + 对象头 + 指针使用性能最多读一次内存指针间接访问传参性能可以直接放寄存器需要指针 Project Valhalla 的介绍

上面提到的性能问题也是 Valhalla 希望将用户自定义值类型引入 Java 的原因之一。 其它更细致的原因包括:

上面提到的 12 字节的对象头里面包括了锁的状态(用于 synchronized (obj))、Object 的哈希值等等。 很多时候我们是用不到这些东西的,例如我们需要用 hashCode 的时候一般会自己写一个而不是用 Object::hashCode。 引用类型会给 GC 添加负担,而值类型可以直接整个放在栈上,如果优化成这样了的话 GC 操作的时候不需要考虑各种指针重定向。

因为 Java 里 GC 会移动对象,所以我们不能用指针作为默认的哈希值。 目前 JVM 里一般是使用随机数来生成哈希值,并把哈希值在对象头里存起来从而保证一个 Object 前后哈希值一致。

Valhalla 大体上包括下面的特性:

引入值类型 引入用户自定义的原生类型 对应的泛型功能

但是,Valhalla 引入的并不能以值类型一言贯之,至少 Valhalla 项目的领头 Brian Goetz 是这么说的。 实际上,Valhalla 对要把值类型、原生类型到底要做成什么样看起来也是一直在摸索,各种定义和术语也经历了一些变更。 下面的内容是我对大半个月前 Brian Goetz 的邮件的个人的一些解读。

Valhalla 引入的类型

虽然说是引入“值类型”,但是实际上引入类型分为两种程度不同的“值类型”,两种分别在原有的引用类型的基础上进行了一些取舍。

放弃对象唯一身份(Identity)

程度较轻的一种类型放弃了对象身份的唯一性(Identity)。

原本,引用类型中 Object a = new Object(); 和 Object b = new Object(); 分别得到的 a 和 b 我们可以很清楚地知道它们对应两个对象: 它们创建时间不同,引用/指针不同,a != b,哈希 hashCode() 也大概率不相同。 这对于 Java 使用者来说是很自然的事。

但是,这样的区分真的有什么意义吗?例如 Optional 或是 LocalDate 或者是 Integer 等装箱类,我相信绝大部分人使用的都是 a.equals(b),而不是 a == b。 也就是说,对于这样的对象,我们只关心它们的“值”,此时,值相同的对应就应被判定为相同。 同时,这样的对象的默认 equals 和 hashCode 方法也会被改写为基于值而不是基于引用或是随机数。

放弃初始化安全性

原生类型和引用类型的另一点区别就是原生类型没有 null 值,而引用有 null 之分。 但是,之所以会有这样的区别,Goetz 认为主要是 null 可以在一定程度上保证对象的初始化安全。

原生类型不需要考虑初始化安全,或者说在大多数架构上它们的初始化安全性可以得到充分的保证: 所有原生类型的二进制全零值都是有意义的。 整形类型或是 char 和 boolean 不用多说,浮点类型的各种 IEEE 标准里二进制全零对应的也正好就是 0.0 的值。 此时,直接把未初始化原生类型直接拿来用也是安全的。

当然,原生类型没有 null 的另一点原因就是性能考虑了。 int 是 32 位,而要编码有 null 的 int? 那就至少需要 33 位,再字节对齐一下基本肯定增长到 64 位。 而 long 就更可怕,64 位到 65 位最终可能需要 128 位。 同时加上上面提到的“唯一身份”问题——我们不关心这个 long 是不是那个 long,如果不用 null 来指代特别的状态的话, 那么没有 null 才是最自然而性能最好的。

但是,对于引用类型就不同了,就算我们不关心 null,要保证对象初始化安全性的话,我们也必须加上一个 null 值, 用于区分对象有没有被初始化,构造函数有没有被执行,并在还没有初始化的时候提供一个默认值。 考虑一些有 final 值的对象,如果能在初始化之前就进行访问,这不仅不安全,也和 JVM 的内存模型对不上。 ——JVM 要提供自己在内存模型里承诺的种种安全性,那么 null 值就是必要的。 但是,换而言之,如果不需要这些安全性保证的话,我们就完全可以把 null 值扔掉。

实际上,在并法时各种处理不谨慎的话,我们也有可能会接触到没有完全初始化的对象的引用。 如果没有经验的话可能会导致看起来非常奇妙的 bug。 下面的例子来自 Close Encounters of The Java Memory Model Kind。

@JCStressTest @State public class VolatileMeansEverythingIsFine { static class C { volatile int x; // 要解决的话可以把这里换成 final C() { x = 42; } } C c; // 要解决的话或者也可以把这里换成 volatile @Actor void thread1() { c = new C(); } @Actor void thread2(IntResult1 r) { C c = this.c; // 这里的 c.x 有可能是未初始化的 0! r.r1 = (c == null) ? -1 : c.x; } } 是否需要 null 值

写 Java 的人对 null 可以说是再熟悉不过了。 而 NullPointerExceptions(NPE)也肯定是最常见的异常之一。 因为什么引用都有可能是 null,不小心处理的话久而久之代码里就会充斥着各种的 if (obj == null) 或是 if (obj != null)。 目前应对的办法要不就是再加一层 Optional,要不就是加上 @Nullable 或是 @NonNull 然后交给 IDE 或者 NullAway 等插件来辅助检查。 但是这些方法或是麻烦或是没法根除 NPE,总之如何处理 null 依然是个永恒的问题。

这些问题其实不全是上面提到的安全性问题——我们很多时候从逻辑上也需要一个 null 值来区分不同状态: 懒加载是否已经加载、请求里是缺失值还是带上了默认值等等。 所以是否可 null 和上面的两种类型是不同维度的问题,无论是值类型还是引用类型都可能需要 null 值也有可能都不希望 null 值出现。

语法

从上面的说明也可以看出,“是否保留对象身份唯一性”“是否需要初始化安全性”“是否需要 null 值”是三个完全不同的东西。 也就是说,Java 的 Project Valhalla 不是把值类型当作一种独立的新类型进行引进,而是把值类型分为上面三种特点,允许用户自行取舍。 当然,三种特点那当然就对应了三种新语法。

新语法当然还没确定下来,下面也只是从邮件里的一些设想和目前的实现情况,还有可能进一步改动。

值类 value class

“不保留对象身份唯一性”这一点来自我们只关心对象的“值”。因此,Valhalla 现在直接把这种类型称作“值类”(value classes)。 用起来也很简单,定义类时把一般用的 class 改为 value class 即可。 其它用法和普通的对象没有差异,只是现在的 hashCode 的默认实现和 == 的语义发生了变化。

value class Integer { private int i; // ... } void main() { assert Integer.valueOf(0xffff) == Integer.valueOf(0xffff); assert Integer.valueOf(0xffff).hashCode() == Integer.valueOf(0xffff).hashCode(); }

有一点需要提醒一下。“不保留对象身份唯一性”里隐含了“对象不可变”的意思,因为在逻辑上 改变后的对象 != 原来的对象(对于值类我们只能进行基于值的比较)。 也就是说,值类和 record 记录类型相似,里面所有的成员变量都应该是 final 的。 最容易理解的就是把每一次的修改操作想象为创建新的对象再覆盖原有的对象——这其实反而和实际硬件指令执行更为接近,也可以避免一些并发的误区。

int i = 0; i = i.add(1); 隐式初始化类 implicit

“不需要初始化安全性”也就是说这个类的各个成员变量的默认值都是合法值。 这时,把其默认构造函数声明为隐式的(implicit)即可。 隐式构造函数会把所有成员变量初始化为二进制全零值。 注意,目前只有值类可以有隐式构造函数。

value class Integer { private int i; public implicit Integer(); } void main() { assert new Integer() == Integer.valueOf(0); }

用过 Go 的同学大概会发现 Go 里有些结构可以直接用其零值(如 sync.Mutex),但是有些就需要用其对应的 New 函数(list.List)。 要不是现在 IDE 查看文档比较容易,不然我是真的记不得这种东西。 这个和 Java 这里提到的默认值初始化安全性其实是一体两面的东西,但我反正更喜欢 Java 这种把默认值和构造函数语法统一的做法。

无 null 类型

我们需要值类 value class + 隐式构造函数 implicit + 无 null 类型才能把 Java 里的对象和 C 或 C++ 里的值类型等同起来。 但是,有些时候,我们的确需要 null,例如一个常见用法可以用 null 的 Integer 来区分请求里有没有缺失某个参数。 无论是从向后兼容还是从给程序员更多自由的角度来说,我们都肯定不能一棒子把 Integer 的 null 值给取消掉。

目前邮件里的想法与编译器 + JVM 直接支持的 @Nullable 类似,邮件里的写法看起来与 Kotlin 有些类似: 类型后面加上问号 ? 表示可以 null,而加上 ! 表示类型不可为 null。 另外,为了向后兼容,什么符号都不加的表示 null 属性未知。

Integer! i = new Integer(); // 不可 `null`,基本上就是 `int i;` 了 Integer? j = null; // 可以 `null` i = j; // 会抛 NPE,和拆箱一样 Integer k = null; // “未知”,出于向后兼容只能这样 Integer![] array; // ... 实现与问题 Flattening:借由值类 + 隐式构造 + 无 null 实现的优化

引入值类型之后就可以对各种代码进行各种优化了。(当然这还得看 JVM 的实现。) 总之,比较常见的一种优化叫做 flattening,硬要翻译过来的话就是把对象平坦化。

flowchart TD subgraph 树状结构 A --> B A --> C --> D end subgraph 平坦化 subgraph AA[A] BB[B] subgraph CC[C] DD[D] end end end

什么意思呢?原来的对象,不考虑循环引用的话,可以说是一种树状结构。 例如 A 对象里包含着两个引用,分别指向 B 类和 C 类,而 C 类又有引用指向 D 类。 平坦化则是把 D 的值嵌入到 C 里,把 B 和 C 的值嵌入到 A 里,一方面节省了指针占用的空间, 一方面避免了一些指针引起的性能损耗。 另外,把数据更密集地存放起来也可以让 JVM 进行更多的优化。

比较典型的可以优化的例子是把密集的运算用 SIMD 指令集优化。 现代 CPU 的 SIMD 指令可以同时进行多数字的运算, 例如同时给 arr[0], arr[1], arr[2], arr[3] 都加 1 这种操作或是下面的循环都很可能会被编译器编译为 SIMD 指令来加快运算:

for (int i = 0; i < length; i++) { a[i] += b[i]; }

但是,SIMD 指令一般会要求操作数在内存上需要连续,在 Java 里面的内存连续的例子就是 int[]。 而如果是 Integer[] 这种隔了一个指针引用的,它们的内存实际是分开存放的,就没办法使用上面的 SIMD 优化了。 这也是很多库都会提供原生类型的 List(如 Apache Commons 的 ArrayIntList)的原因: ArrayList 实际上是 Integer[],性能和 int[] 没法比。

flowchart TD R[引用] --> Arr subgraph Arr["Integer[]"] 0 1 2 ... n end 0 --> A 1 --> B 2 --> C n --> N flowchart TD R[引用] --> NonNull subgraph NonNull["Integer![]"] AA[A] BB[B] CC[C] E[...] NN[N] end String!:类似 @NonNull

上面的例如 Integer 可以变成 Integer! 从而进行平坦化等各种优化,但是它的前提是值类 + 隐式构造函数。 例如 String 等的普通的引用类型,因为没有放弃身份唯一性或是初始化安全性,它们就算加上了感叹号变成 String! 也没有多大意义甚至是无效的。 最简单的例子是 new String![2],出于初始化安全性的考量,数组必须把成员初始化为 null,也就是说 String![] 的类型至少在用 new 时会是非法的。

这点邮件里似乎有两种思路。一种是考虑套用类型擦除的想法,编译之后 String! 和 String? 和 String 就是同一个东西, 基本上就是把它当作 @Nullable 和 @NonNull 来用,只在编译时进行检查。 另一种就是暂时放弃,只允许 Integer! 的用法,不允许普通的类的 String!。

总结

Project Valhalla 里把“值类型”分成了三层。对于每一层 Valhalla 都会引入一种语法。

value class:对象的唯一“身份”只有它的值(== 比较变成基于值了) implicit 构造:默认值(二进制全零)的安全性 不允许 null 值

这三层每层都可以允许 JVM 进行更多的优化,例如标准库的 Optional 和 LocalDate 等各种类都可以变成部分的值类型,就算用户的代码一行不改整体的性能肯定也会提升。 另外 String! 或者 String? 的用法如果能至少进行编译时检查的话,那么至少可以稍微统一一下现在八百种 @Nullable 注解的天下,也差强人意。 但 Valhalla 要真正发布大概还得挺久的,上面的各种东西都有可能再改,就让我们等着看着吧。



【本文地址】

公司简介

联系我们

今日新闻

    推荐新闻

    专题文章
      CopyRight 2018-2019 实验室设备网 版权所有