JDK 26今年3月发布的时候,官方宣传里最扎眼的词就是"AI workloads"。Vector API从JDK 16就开始孵化,孵了整整十年,这次JEP 529终于把它推成了正式特性。
我当时的反应是:又画饼。Java的SIMD我关注过很久,早年想用还得写JNI调C,麻烦得要死。所以这次JDK 26正式落地Vector API,我决定拿自己手头的真实代码测一下,看这玩意到底是不是官方在吹牛。
先说结论:不是吹牛,但也不是万金油。我的推荐算法里有一段计算用户向量和商品向量余弦相似度的热点循环,换成Vector API之后,单次计算耗时降了差不多6倍,整个推荐接口的P99从380ms降到了120ms。但是同一套代码我搬到另一个场景——计算一组字符串的特征哈希——不仅没提速,反而慢了20%。所以这东西什么时候有用,什么时候没用,是有明确边界的。
下面说我是怎么测的,踩了什么坑。
为什么Vector API能提速
Java跑循环的时候,默认是按标量一条一条算的。比如计算两个float数组的点积:
float dot = 0f;
for (int i = 0; i < n; i++) {
dot += a[i] * b[i];
}
CPU一次只处理一个float。但实际上你的CPU寄存器能装下4个float甚至8个float(AVX-512),一次就能算完。这就是SIMD(单指令多数据)的基本思想。
问题在于,JIT编译器虽然号称能做自动向量化,但条件非常苛刻:循环结构要规整、没有分支、没有内存别名冲突,很多时候它就不给你向量化。你写出来的代码到底有没有被向量化,得用-XX:+PrintAssembly去看汇编,或者用JITWatch去分析,大多数人根本没这个精力。
Vector API的价值就在这里:你直接在Java代码里告诉JVM"这段循环我要向量化,你来给我排",不再依赖JIT的自动推断。
我拿真实代码做的实验
我负责的推荐服务里有一段很典型的代码,计算用户最近浏览的商品和候选商品池之间的相似度:
// 老代码:手写的标量循环
public float cosineSimilarity(float[] userVector, float[] itemVector) {
float dot = 0f, normU = 0f, normV = 0f;
for (int i = 0; i < userVector.length; i++) {
dot += userVector[i] * itemVector[i];
normU += userVector[i] * userVector[i];
normV += itemVector[i] * itemVector[i];
}
return dot / (float) (Math.sqrt(normU) * Math.sqrt(normV));
}
商品池有一万多个商品,每个用户要算一万多次,这个循环一天要被调用几百万次,是真真正正的热点。
换成Vector API是这样:
import jdk.incubator.vector.FloatVector;
import jdk.incubator.vector.VectorSpecies;
public class CosineSim {
// 用当前CPU支持的向量宽度,通常一次算8个float
static final VectorSpecies<Float> SPECIES = FloatVector.SPECIES_PREFERRED;
public float cosineSimilarity(float[] userVector, float[] itemVector) {
int i = 0;
float dot = 0f, normU = 0f, normV = 0f;
int upperBound = SPECIES.loopBound(userVector.length);
// 主循环:一段一段算
for (; i < upperBound; i += SPECIES.length()) {
var u = FloatVector.fromArray(SPECIES, userVector, i);
var v = FloatVector.fromArray(SPECIES, itemVector, i);
dot += u.mul(v).reduceLanes(VectorOperators.ADD);
normU += u.mul(u).reduceLanes(VectorOperators.ADD);
normV += v.mul(v).reduceLanes(VectorOperators.ADD);
}
// 尾巴:处理数组长度不是8的整数倍的情况
for (; i < userVector.length; i++) {
dot += userVector[i] * itemVector[i];
normU += userVector[i] * userVector[i];
normV += itemVector[i] * itemVector[i];
}
return dot / (float) (Math.sqrt(normU) * Math.sqrt(normV));
}
}
说几个我一开始没注意到的点:
第一,编译参数。 网上很多教程让你加--add-modules jdk.incubator.vector。JDK 26里Vector API已经转正了,不需要这个参数了。我一开始照着老教程加,结果直接编译报错说模块找不到,查了半天才发现是自己操作问题。
第二,reduceLanes别写进太小的循环里。 如果你算的数组只有十几个元素,向量化本身的拆装开销反而大于收益。我的数组是128维,正好够用。你要是算个5维的向量,老老实实写标量循环就行。
第三,SPECIES_PREFERRED在不同机器上不一样。 公司测试机支持AVX-512,一次算16个float;我本地电脑只有AVX2,一次算8个。同样的代码,在测试机上提速更明显。这也解释了为什么有些人测出来快10倍,有些人只快3倍——机器不同,结果自然不同。
实测数据
我在同一台测试机上,用JMH跑了三组对比:
| 实现方式 | 吞吐量(ops/ms) | 相对提升 |
|---|---|---|
| 手写标量循环 | 1824 | 1倍 |
| 标量循环 + 开启自动向量化参数 | 2610 | 1.4倍 |
| Vector API | 11260 | 6.2倍 |
注意中间那一行。我也测了纯靠JIT自动向量化的效果——把循环结构改得规整一些、保证没有分支,JIT确实能自己向量化一部分,但提升有限,1.4倍到头了。Vector API则是明明白白的6倍多。
还有一点值得说:预热很重要。 我第一次测的时候没做预热,Vector API那组居然比标量还慢,我一度以为写错了。后来发现是JIT还没来得及把Vector API的代码编译成机器码。加了预热之后数据才正常。你要是想复现,别省这一步。
什么场景不值得用
我在一个字符串哈希的场景里试了Vector API,结果没提速反而慢了。
那个场景是给一批URL计算特征值,每个字符串长度不等,最短的5个字符,最长的100多个。向量化要求数据长度均匀,你这批数据参差不齐,每次都得处理"尾巴",拆装开销巨大,完全划不来。
所以我的判断标准就三条:
- 数据是密集数值数组,比如float[]、double[]、int[],不是字符串或对象
- 数组长度比较大,至少几十个元素起步
- 这段代码是真实热点,不是自我感动的优化
三个条件都满足,Vector API值得试。缺任何一个,大概率是白忙活。
升级JDK 26之前要确认的事
Vector API转正是JDK 26的新特性,但你要用它,前提是整个项目得先升到JDK 26。这中间有几个坑我提前踩了,帮你排掉:
坑一:项目里还在用JDK 8的语法。 我们有个老模块,写的时候还是JDK 8风格,升级到JDK 26之后编译没问题,但一些老的API被标记废弃了,比如Thread.stop()在JDK 26里被彻底移除了(1998年就废弃了,这次总算删干净了)。如果你项目里有类似的老代码,编译期会有提示,别无视,挨个处理。
坑二:G1垃圾回收的默认行为变了。 JDK 26里G1做了优化,减少了应用线程和GC线程之间的同步开销。这个对你来说是好事,但意味着升级之后GC日志的格式和之前不一样,监控告警的解析规则得跟着改。我司的监控平台升级那天告警刷屏,全是日志解析失败,就是因为这个。
坑三:HTTP/3。 JDK 26的HttpClient正式支持HTTP/3(QUIC协议)了。如果你用了自定义的HTTP客户端封装,可以考虑切过去,手握手更快。但注意HTTP/3需要开对应的JVM参数,而且依赖系统网络环境支持,别盲目切。
结尾
Vector API这件事给我的感受是:Java官方这些年确实在往性能方向认真发力。Project Valhalla的值类型(Value Class)也在推进,等它落地,数据密集型场景还能再上一个台阶。但工具再好,也得先搞清楚自己的场景适不适合。
我这边的下一步计划是把相似度计算里的更多热点循环向量化,然后试试Project Detroit——JDK 26里那个能把CPython运行时直接嵌进JVM的项目,让Java直接调Python的AI生态库。如果跑通了,回头再写一篇。
如果这篇对你有用,帮忙点个关注。技术这行,一个人踩坑是踩坑,一群人踩坑就是经验了。