多年来,Java 一直是编程世界里绕不开的老面孔。从 90 年代末的企业级应用到如今的大型服务端系统,Java 凭借“写一次,到处跑”的理念,占据了无数开发者的日常。它稳定、成熟、生态庞大,但也常被人诟病——臃肿、语法冗长、速度不够快。
于是,Java 的性能一直是开发者茶余饭后的讨论话题:它真的慢吗?在现代硬件和新特性的加持下,老牌语言还能跑出惊人的速度吗?本文作者 David Gerrells 用 Java 构建了一个 2000 万粒子的二维粒子模拟,挑战 SIMD、多线程和实时渲染的极限。通过这个例子,我们不仅能看看 Java 在性能上的表现,也能重新认识它作为一门老牌语言的潜力和魅力。
来源:https://dgerrells.com/blog/how-fast-is-java-teaching-an-old-dog-new-tricks
Java,这门让人又爱又恨的语言。
很多人提到它的第一反应是:老旧、笨重、臃肿,只是因为某些大企业在上世纪 90 年代被炒作忽悠了,才不得不用它。长久以来,它都背着“企业老古董”的名号。
可现在还真是这样吗?
Java 是否已经僵化在那套老掉牙的面向对象思维中,注定被时代淘汰?还是说,这条“老狗”其实已经学会了一些新把戏?
这次的挑战就是:用尽 Java 的所有新特性,只靠 CPU,模拟尽可能多的粒子。看看能不能——干翻 Rust?
如果你懒得看完整篇,可以直接下载运行包(jar 文件,https://github.com/dgerrells/how-fast-is-it/blob/main/java-land/ParticleSim.jar)。代码也放在 GitHub 上(https://github.com/dgerrells/how-fast-is-it/tree/main/java-land)。你只需要在运行时要加上以下命令,启用 Java 的 Vector API(一个用于高性能计算的新功能)就行:
java --add-modules jdk.incubator.vector --enable-preview -jar ParticleSim.jar不妨一试!

开场热身
Java 对我来说有点特殊。它是我第一个真正深入学习的编程语言。主要原因,是因为当年我上大学时,学校几乎所有课程都用它作为标准语言。那已经是十多年前的事了。唉,时间真是残酷。
那时候我用 Java 做游戏开发,写过人生第一个多线程的粒子模拟器,还做过一个“致敬”Minecraft 的小游戏(虽然我本人从没玩过 Minecraft)。挺好玩的,当然按今天的标准看那代码相当“痛苦”,但当时的确很有成就感。
然后十年过去,我再一次翻出 Java,却看到一个“SIMD 实验性 API”的新闻。
我当时的反应是:“什么?Java 有 SIMD?太阳从西边出来了?”
没错,太阳真的从西边出来了。Java 现在真的支持 SIMD(Single Instruction Multiple Data,单指令多数据)了,还提供了一个抽象层,让你不用直接面对底层硬件那堆复杂指令。
对于初学者而言,如果你不清楚 SIMD 是啥,可以去搜一下,或者直接问个 AI,都行。
如果你懒得查,我简单说下:SIMD 是一套 CPU 指令,用来让处理器一次同时处理多个数字,而不是一个个来。很多现代编译器其实能自动帮你做这个优化,但不是每次都能做到完美。
麻烦在于:不同 CPU 的 SIMD 指令集各不相同。有的架构只能处理 128 位数据(比如一次 4 个 32 位浮点数),有的能到 256 位,甚至 512 位。这意味着如果你要手动写 SIMD 代码,往往得针对每种 CPU 写一份,或者用别人封装好的库——不过那样就少了点“折腾”的乐趣。
而 Java 的特别之处就在这里:它号称“写一次,到处跑”。
那它的 SIMD API 真的能跨平台、跑得又快又稳吗?这就得试试看了。好在我最近刚好用 Rust 和 Swift 写过多线程的 SIMD 粒子模拟器。以前也用 JavaScript 和 Go 写过类似的,但那俩语言本身就比较慢,也没有原生的 SIMD 支持(除非你仔细研究 V8 引擎那一套)。
另外,Java 还有挺多新的“花活”,比如 Lambda 表达式和多线程迭代器——理论上可以让并行编程变得非常轻松。挺棒的。
等会我会具体看看那些 API,但在那之前,我得先搞清楚一件事:
怎么在 Java 里把像素绘制到屏幕上。
在 Java 里绘制
我想保持和之前在 Rust、Swift 做的实验差不多的结构:
写一个小型二维粒子模拟,屏幕上有一个“重力点”,能吸引周围的粒子。每个粒子就是一个像素点。整个过程尽量只用 CPU,GPU 不参与。
那问题来了:在 Java 里,我要怎么在窗口上画像素?
之前在 Rust 里,我用的是一个窗口库,它帮我处理不同操作系统的窗口细节。Swift 则是“纯正苹果血统”,自带一套完善的图形 API,拿来就能用。而 Java 呢?它确实也提供了一组跨平台的 UI API,可以在所有系统上跑。虽然界面看起来不太“原生”,但至少能正常工作。
我上次写 Java UI 的时候,主流还是内置的 Swing 库。那时人们说它要被 JavaFX 替代。
结果现在一看……JavaFX “貌似” 成了标准,但你得手动去下载 jar 包,然后通过 Maven 或 Gradle 引入。
所以算了。我还是用 Swing 吧——因为我实在不想折腾 Maven 和 Gradle。
还是想吐槽一下:Java 到了 2025 年了还没有默认包管理器?我就不能像其他语言一样 jfaster add jfx 一键搞定?真让人失望。
当然,这事也没那么严重。因为就算用上 JavaFX,你要在窗口里画像素,最终还是得靠一个叫 BufferedImage 的类。顾名思义,BufferedImage 就是一块存放在内存里的像素缓冲区,你可以在上面“画”,再把它显示到屏幕上。
我就不贴 Swing 的那些啰嗦样板代码了。核心思路是:你创建一个 JFrame(窗口),往里面塞各种 UI 组件。然后写一个自定义的 Panel 类(这里用来显示粒子模拟),继承或实现 Swing 的接口,把自己的绘制逻辑加进去。典型的面向对象玩法。
顺带一提,现在 Java 其实有个更“现代”的 main 函数写法,可以不用写类。可惜它跟 Swing 不兼容。
public class ParticleSim {public static void main(String[] args) {new ParticleSim().createAndShowGUI();}private void createAndShowGUI() {JFrame frame = new JFrame("Sips Java");frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);int width = 1200;int height = 800;ParticlePanel particlePanel = new ParticlePanel(width, height);frame.add(particlePanel);frame.pack(); // resize child componentsframe.setLocationRelativeTo(null); // centerframe.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); // won't actual close without thisframe.setVisible(true); // ya i know, default is falseparticlePanel.startSimulation();}}
ParticlePanel 这个类很长,很啰嗦,很典型的 Java 风格。
一开始,我把逻辑放在事件分发线程上,通过重写 JPanel 的 paint 方法来画画,然后用一个 Timer 定时调用 tick 方法来触发粒子模拟。大概结构就是这样。
⚠️ 提醒一下,这段代码比较长,所以才说“很 Java 风格”。
// importspublic class ParticleSim {public static void main(String[] args) {new ParticleSim().createAndShowGUI();}private void createAndShowGUI() {JFrame frame = new JFrame("Vector API Particle Sim (Requires flags)");frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);int width = 1600;int height = 900;ParticlePanel particlePanel = new ParticlePanel(width, height);frame.add(particlePanel);frame.pack();frame.setLocationRelativeTo(null);frame.setVisible(true);particlePanel.startSimulation();}}class ParticlePanel extends JPanel implements ActionListener, MouseListener, MouseMotionListener {private static final int NUM_PARTICLES = 80_000_000;private static final int UPDATE_RATE = 1000 / 60;private float[] positionsX = new float[NUM_PARTICLES];private float[] positionsY = new float[NUM_PARTICLES];private float[] velocitiesX = new float[NUM_PARTICLES];private float[] velocitiesY = new float[NUM_PARTICLES];private final BufferedImage image;private final byte[] pixelArray;private final int panelWidth;private final int panelHeight;// other input variablespublic ParticlePanel(int width, int height) {// setup stateimage = new BufferedImage(width, height, BufferedImage.TYPE_BYTE_GRAY);initializeParticles();// setup input and time tracking}private void initializeParticles() {// init particles}public void startSimulation() {timer.start();}public void actionPerformed(ActionEvent e) {if (isTicking) {return;}isTicking = true;long now = System.nanoTime();float deltaTime = (now - lastTickTime) / 1_000_000_000.0f;lastTickTime = now;updatePhysics(deltaTime);renderToPixelArray();repaint();frames++;isTicking = false;}private void updatePhysics(float deltaTime) {// update logic}private void renderToPixelArray() {// render}protected void paintComponent(Graphics g) {super.paintComponent(g);g.drawImage(image, 0, 0, this);}// input overloads}
大部分都是标准的 Java Swing 代码。渲染部分稍微有点意思:我把每个粒子映射到一块扁平的像素缓冲区(flat buffer)里,然后再显示出来。
private void renderToPixelArray() {final int w = panelWidth;final int h = panelHeight;final byte empty = 0;Arrays.fill(pixelArray, empty);for (int i = 0; i < NUM_PARTICLES; i++) {int px = (int) positionsX[i];int py = (int) positionsY[i];int index = py * w + px;// stay in bounds.if (px < 0 || px >= w || py < 0 || py >= h) {continue;}int lu = pixelArray[index] & 0xFF;lu = Math.min(255, lu + 1);pixelArray[index] = (byte) lu;}}
真正有意思的部分是 updatePhysics 方法——这里才是“魔法发生的地方”。
不同 CPU,SIMD 通道大小不同
要记住,SIMD 在不同硬件上支持的“通道大小”(lane size)不同。为了效率,最好用支持的最大通道大小。Java 的 SIMD API 通过定义 “species”(种类) 来处理这个问题。每个 species 对应一种数据类型,比如 float 或 int,然后你就可以根据这个 species 的大小,把数据加载到 Vector 类型中,或者从 Vector 中取出数据。
大概就是这样工作的:
private static final VectorSpecies<Float> F_SPECIES = FloatVector.SPECIES_PREFERRED;private static final int LANE_SIZE = F_SPECIES.length();private static final FloatVector PULL_VEC = FloatVector.broadcast(F_SPECIES, 500f);
这就让代码可以比较通用地适配不同硬件。通常推荐使用 preferred,据说性能最好。还有一个 MAX,会用最大的通道大小,但不一定性能最好。在我的 M1 上,preferred 和 MAX 都输出 4 个浮点数(128 位)。我就用 preferred 就好。
顺便说一句,我不知道为什么,但我特别喜欢这里叫 species 这个名字,真让人忍俊不禁。我能想象一大群人开会讨论,最后才定下这个名字的场景。
多线程 SIMD
我一不小心就想跳过单线程,直接用 Java 的 stream API 弄多线程 SIMD 了。思路是这样的:用 IntStream.range 把粒子按照最大通道数切块,然后对这些块进行并行迭代,实现多线程计算。
final int w = this.panelWidth;final int h = this.panelHeight;final float wFloat = (float) w;final float hFloat = (float) h;final FloatVector DT_VEC = FloatVector.broadcast(F_SPECIES, deltaTime);// var works toofinal var MOUSE_X_VEC = FloatVector.broadcast(F_SPECIES, (float) mousePosition.x);final var MOUSE_Y_VEC = FloatVector.broadcast(F_SPECIES, (float) mousePosition.y);// more vectorsfinal int VECTOR_CHUNKS = NUM_PARTICLES / LANE_SIZE;final int SCALAR_START_INDEX = VECTOR_CHUNKS * LANE_SIZE;IntStream.range(0, VECTOR_CHUNKS).parallel().forEach(chunkIndex -> {int i = chunkIndex * LANE_SIZE;var px = FloatVector.fromArray(F_SPECIES, positionsX, i);var py = FloatVector.fromArray(F_SPECIES, positionsY, i);var vx = FloatVector.fromArray(F_SPECIES, velocitiesX, i);var vy = FloatVector.fromArray(F_SPECIES, velocitiesY, i);if (mouseIsPressed) {var dx = MOUSE_X_VEC.sub(px);var dy = MOUSE_Y_VEC.sub(py);var distSq = dx.mul(dx).add(dy.mul(dy));var gravityMask = distSq.compare(GT, MIN_DIST_SQ_VEC);if (gravityMask.anyTrue()) {var dist = distSq.sqrt();var forceX = dx.div(dist).mul(PULL_SCALED_VEC);var forceY = dy.div(dist).mul(PULL_SCALED_VEC);vx = vx.add(forceX, gravityMask);vy = vy.add(forceY, gravityMask);}}vx = vx.mul(FRICTION_DT_VEC);vy = vy.mul(FRICTION_DT_VEC);px = px.add(vx.mul(DT_VEC));py = py.add(vy.mul(DT_VEC));var maskLeftX = px.compare(LT, ZERO_VEC);var maskRightX = px.compare(GT, W_VEC);var maskBounceX = maskLeftX.or(maskRightX);vx = vx.blend(vx.mul(BOUNCE_MULTIPLIER_VEC), maskBounceX);px = px.blend(ZERO_VEC, maskLeftX);px = px.blend(W_VEC, maskRightX);var maskTopY = py.compare(LT, ZERO_VEC);var maskBottomY = py.compare(GT, H_VEC);var maskBounceY = maskTopY.or(maskBottomY);vy = vy.blend(vy.mul(BOUNCE_MULTIPLIER_VEC), maskBounceY);py = py.blend(ZERO_VEC, maskTopY);py = py.blend(H_VEC, maskBottomY);px.intoArray(positionsX, i);py.intoArray(positionsY, i);vx.intoArray(velocitiesX, i);vy.intoArray(velocitiesY, i);});
多线程的使用方式和 Rust、Swift 非常相似,更重要的是,它真的能跑起来。Java 的 var 关键字让代码看起来更简洁一些,但我这种老手通常还是喜欢写显式类型。这里有个问题,细心的人可能已经注意到了:粒子数量不一定能被 SIMD 的通道数整除。这意味着会有剩余的粒子暂时没被处理。
处理方法有两种:
继续用向量方式处理,给空余通道填充占位;
用一个非向量化的循环单独处理剩下的粒子。
我选了第二种方式,但为了简洁,这里就不展示代码了。
效果是可以的,这里展示 2000 万粒子的模拟效果。
速度不快,大概只能跑到 20 帧每秒,而且看起来也不太吸引人。我想我知道原因了。
大改造
慢的原因之一不是模拟本身,而是渲染。目前代码是随机访问像素缓冲区的,这样效率很低。在 Rust 和 Swift 的版本里,大部分时间也是耗在这里,Java 也不例外。
除了把这些操作分摊到更多线程之外,几乎没什么优化空间。多线程能提速,但不能解决缓存访问冲突的问题。
还有一些其他问题。首先,渲染循环现在运行在事件分发线程上,这必须改掉。虽然改了之后输入处理会更复杂。整体效果看起来也比较单调、无聊。
在 Rust 和 Swift 版本里,我是通过按粒子相对于窗口的 x/y 距离缩放 RGB 颜色来给像素上色的。
这次我想换个玩法:给每个粒子分配一个独立颜色,之后再加点花哨的效果,同时支持窗口缩放、平移,还能让粒子速度变慢,提升使用体验。
我会跳过中间的琐碎步骤,直接说最终方案。
第一步:脱离事件分发线程
我打算用 Java 的新 lambda 特性,把一个函数传给线程对象来执行。
public void startSimulation() {if (!running) {running = true;gameLoopThread = new Thread(this::gameLoop);gameLoopThread.start();}}
游戏循环会用一个“忙等待”的 while 循环——每次循环会暂停一小段时间,然后再检查是否可以渲染下一帧。我希望在这小段暂停时间里,从事件分发线程轮询输入,而不是等到下一帧渲染时才处理。这样可以让模拟更加响应及时。不过,有些事件仍然需要按照渲染帧率来处理,比如平移,因为这类操作需要考虑时间差来计算移动距离。
private void gameLoop() {lastTickTime = System.nanoTime();while (running) {long now = System.nanoTime();long timeElapsed = now - lastTickTime;this.processInputRequests();if (timeElapsed >= NS_PER_TICK) {float deltaTime = timeElapsed / (float) NS_PER_SECOND;lastTickTime = now;// little gross thisfor (var key : this.keysPressed) {float speed = 500;if (this.velInputMap.containsKey(key)) {this.panDeltaInput.x += velInputMap.get(key).x * speed * deltaTime;this.panDeltaInput.y += velInputMap.get(key).y * speed * deltaTime;}}long tickStart = System.nanoTime();tick(deltaTime);long tickEnd = System.nanoTime();long tickDuration = (tickEnd - tickStart);long renderStart = System.nanoTime();render();Graphics2D g = (Graphics2D) getGraphics();g.drawImage(image, 0, 0, this);g.dispose();Toolkit.getDefaultToolkit().sync();frames++;long renderEnd = System.nanoTime();long renderDuration = (renderEnd - renderStart);// log frame time info} else {try {Thread.sleep(1);} catch (InterruptedException e) {// handle error}}}}
关键的一行是:
if (timeElapsed >= NS_PER_TICK)它用来判断是否需要渲染下一帧。
这样做还可以让渲染速度超过 60 帧/秒——对于那些性能超强的电脑或者粒子数量较少的情况,非常有用。
你可能会注意到,我现在是在更新像素后再画图。
我关闭了 Swing 自带的被动渲染(passive rendering),改为主动渲染。这样逻辑更简单,也更易控制。
接下来就是 tick 函数了。
private void tick(float deltaTime) {final int vectorizedEndIndex = (NUM_PARTICLES / LANE_SIZE) * LANE_SIZE;final int chunkSize = vectorizedEndIndex / CPU_COUNT;final var futures = new ArrayList<Future<?>>(CPU_COUNT);// copy input datafinal int panDx = this.panDeltaInput.x;final int panDy = this.panDeltaInput.y;final float vScale = this.isSlowDownRequested ? this.inputVelScale : 0f;// reset for input thread, access is syncedthis.panDeltaInput.x = 0;this.panDeltaInput.y = 0;for (int i = 0; i < CPU_COUNT; i++) {int start = i * chunkSize;int end = (i == CPU_COUNT - 1) ? vectorizedEndIndex : start + chunkSize;ParticleUpdateTask task = tasks[i];task.updateParams(i, start, end, this, deltaTime, panDx, panDy, vScale);futures.add(executorService.submit(task));}for (Future<?> future : futures) {try {future.get();} catch (InterruptedException | ExecutionException e) {e.printStackTrace();}}}
这里有几个值得注意的点:
首先,借鉴 Rust 的做法,为了避免在多线程间频繁同步或加锁变量,我在进入核心模拟循环前,会先拷贝一份最新的输入数据。
大多数输入值会在主线程和事件分发线程之间同步。
这种加锁在非核心路径(非 hot path)下没问题,但对于粒子更新这种“热路径”,拷贝数据是必须的,否则性能会受很大影响。
另外,我对程序做了性能分析,发现大约 20% 的时间花在了 Java 的线程管理内部。这其实有些预料之中:
在 Swing、Rust 和 Go 中,使用那些花哨的线程迭代器会带来额外开销,既有内存压力,也有处理时间消耗。
相比之下,创建一个工作线程池并重复利用线程通常更快。
这也是一个机会,可以用 Java 的 Future 对象做一点异步控制。这里我没用太多,但确实挺有趣的。
ParticleUpdateTask 的代码大体没变,只是做了一些小调整。下面是几个关键部分。
class ParticleUpdateTask implements Runnable {// boiler plate variables, constructor, and param update functionpublic void run() {// many local variablesfor (int i = startIndex; i < vectorEndIndex; i += LANE_SIZE) {FloatVector px = FloatVector.fromArray(F_SPECIES, positionsX, i);FloatVector py = FloatVector.fromArray(F_SPECIES, positionsY, i);FloatVector vx = FloatVector.fromArray(F_SPECIES, velocitiesX, i);FloatVector vy = FloatVector.fromArray(F_SPECIES, velocitiesY, i);if (mouseIsPressed) {FloatVector dx = MOUSE_X_VEC.sub(px);FloatVector dy = MOUSE_Y_VEC.sub(py);FloatVector distSq = dx.mul(dx).add(dy.mul(dy));var gravityMask = distSq.compare(GT, minPullDist);if (gravityMask.anyTrue()) {FloatVector dist = distSq.sqrt();FloatVector forceX = dx.div(dist).mul(gf);FloatVector forceY = dy.div(dist).mul(gf);vx = vx.add(forceX, gravityMask);vy = vy.add(forceY, gravityMask);}}px = px.add(vx.mul(deltaTime)).add(ox);py = py.add(vy.mul(deltaTime)).add(oy);vx = vx.mul(FRICTION_DT_VEC);vy = vy.mul(FRICTION_DT_VEC);px.intoArray(positionsX, i);py.intoArray(positionsY, i);vx.intoArray(velocitiesX, i);vy.intoArray(velocitiesY, i);}// non vectorized versionvar pixels = panel.threadPixelBuffers[id];Arrays.fill(pixels, 0);for (int i = startIndex; i < endIndex; i++) {int px = (int) Math.min(Math.max(positionsX[i], 0), w - 1);int py = (int) Math.min(Math.max(positionsY[i], 0), h - 1);int index = py * w + px;pixels[index] = colors[i];}}}
我去掉了粒子的边缘反弹逻辑,并进一步整理了 SIMD 代码。最重要的是,我给每个线程分配了一个本地像素缓冲区来绘制粒子。
我尝试过用 SIMD 优化像素缓冲区的截断(clamping)和索引计算,但结果反而更慢。还试过用 Java API 中一些更高级的 SIMD 函数,比如 fma,结果同样不如预期。
最后一个重大改动是:把每个工作线程的本地像素缓冲区直接累加到 BufferedImage 的数据里,这样合并更高效。
private void render() {int[] buff = ((DataBufferInt) image.getRaster().getDataBuffer()).getData();Arrays.fill(buff, 0);final int PIXEL_COUNT = buff.length;IntStream.range(0, CPU_COUNT).parallel().forEach(chunkIndex -> {int chunkSize = PIXEL_COUNT / CPU_COUNT;int start = chunkIndex * chunkSize;int end = (chunkIndex == CPU_COUNT - 1) ? PIXEL_COUNT : start + chunkSize;for (int i = start; i < end; i++) {int color = 0;for (int localIndex = 0; localIndex < CPU_COUNT; localIndex++) {int col = threadPixelBuffers[localIndex][i];if (col != 0) {color = col;break;}}buff[i] = (0xFF << 24) | color;}});}
我对线程本地像素缓冲区采用了“最后写入者胜出”的策略,而在合并时则是“第一次写入者胜出”。虽然让所有线程直接写同一个像素缓冲区也能工作,但会引入一些缓存一致性问题。给每个线程分配独立缓冲区虽然会增加内存占用,但性能更稳定。
说到内存:Java 的基线开销相当高,1 百万粒子大概需要 300ms。不过它的扩展表现和 Rust 基本一致,1 亿粒子的耗时和 Rust 差不多,再加上 300MB 的基线开销。
性能表现如何?先等等……
像素的完美布局
我加了一个 colors 数组来存储粒子颜色,想让它填充出一些有趣的效果。一个朋友提议:根据粒子相对于中心点的角度上色,模拟一个色轮效果。我觉得这个主意挺酷的,但我想用 OKLAB 的方式来实现。
我对颜色格式了解不多,而且时间有限,所以让 AI 帮我写了段代码:根据粒子相对于屏幕中心点的角度,计算 OKLAB 色调。是不是完全正确?我怀疑。但我本想用现成库来做……你知道的,Gradle 的折腾。
我还增加了几种其他布局方式:一种按圆形排列(这段大部分代码是我写的)。另一种随机分布 N 个点,然后根据粒子到这些点的距离上色(这段我没写,但效果挺有意思)
好了,这里来个演示。
我觉得效果非常棒。你可以右键拖动来平移,或者用 WASD 控制视角。空格键可以让粒子减速。我特别喜欢在这里玩耍。如果我把用来调试 OKLAB 上色的时间,拿来像玩这些交互那样投入,可能早就不用 AI 自己搞定了。但唉,我脑子里那个贪玩的小恶魔必须得到满足。
说到这个“贪玩的小恶魔”,我选择用 32 位整型表示粒子颜色,而不是用单字节或简单的开/关标志,是有原因的。
如果我根据用户定义的图片来给粒子上色呢?我可以根据粒子数量放大或缩小图片,还要注意把多余的粒子放在重复的位置。想想就很酷,对吧?
好吧,现在按数字 4,选择一张图片。
这里展示的是 2000 万粒子,用的是我最喜欢的一张图。
真是太酷了。它会根据粒子数量放大或缩小图片。这张图是 4K 分辨率,被放大到 2000 万粒子,几乎是原来的三倍大小。
看着效果,我都想加个缩放功能了,但我觉得该停手了。
那么,性能如何呢?
Java 究竟有多快?
我会拿 Rust 来做对比,因为它目前表现最强。不过要注意,每个版本并不是完全一一对应的。Rust 每个像素只写 1 个字节,但相比于对像素缓冲区的随机访问,内存速度并不是渲染的瓶颈。Java 的 Vector API 仍处于实验性(incubator)阶段,未来可能会有所改进。
下面是 M1 Air 上的测试结果。
你看这个结果。Rust 在大多数规模下速度大约是 Java 的两倍。
两个版本都是在 tick 中填充像素缓冲区,这也是为什么渲染在不同粒子规模下变化不大。主要耗时是累加缓冲区并把它显示到屏幕,这个时间基本是固定的。
有意思的是,在 1 百万粒子时,Rust 似乎渲染时间更长,我这边多次测试都能复现,但原因我不清楚。
Rust 的内存分配速度快得多。这是因为 Java 都是在堆上分配内存。虽然可以用堆外内存(off-heap),创建速度通常快 2–3 倍,但我发现性能略微比堆上慢一些。差距不大,只有几个百分点,而且主要是在访问数据而非写入数据时的差距。
自从我上次用 Java 以来,它已经进步很多。没有借用检查的情况下,它的速度也只有 Rust 的一半左右!
可惜的是,虽然 Java 语言本身更友好、更易写,但整个生态系统仍有很多不足。
如果今天我要再写一款游戏,阻止我选择 Java 的,不是语言本身,而是搭建一个还算合理的构建系统、引入几个小库竟然这么麻烦。
我记得以前为了让所有 Vorbis 和 OpenGL 的 jar、dll 等都能正常工作,要写一个复杂的构建脚本,过程超级折腾,而这一点至今几乎没有改善。
我想,“老狗”是可以学新把戏的,但如果它还是在同一个灰尘满满、没有草地、也没有大黄球的老狗公园里混,它也没机会展示什么新本领。
不过,Java 永远在我心里占有一席之地。