深入 Ruby E03:JRuby 与 JVM
文章卡
| 项目 | 本篇范围 |
|---|---|
| 先修 | 21、30、34–36:依赖、线程、GC 与执行路径 |
| 核心问题 | 同一语言契约在两种实现下是否一致,Java 互操作增加哪些边界? |
| 实验入口 | examples/ruby/labs/E03/run.rb,安装条件见同目录 INSTALL.md |
| 验收 | 两进程契约相等;JVM 双线程屏障与关闭;Java 集合、十进制和异常断言 |
| 版本边界 | CRuby 3.4.11、JRuby 10.0.5.0(Ruby 3.4.5)、OpenJDK 21.0.10;不声明全部应用兼容 |
相同脚本通过,兼容结论仍有边界
任务筛选脚本在 CRuby 与 JRuby 上都返回「写作」,是否意味着整个 Taskbook 可以直接切换解释器?这个结果只覆盖本次调用。参数绑定、异常、线程同步、依赖平台和运行时扩展各自需要证据。迁移判断应从应用依赖的契约开始,再检查实现特有的能力。
本章固定 CRuby 3.4.11、JRuby 10.0.5.0 和 OpenJDK 21.0.10。JRuby 的固定版本发布说明声明目标为 Ruby 3.4 兼容;实际程序中的 RUBY_VERSION 为 3.4.5,JRUBY_VERSION 为 10.0.5.0。三个数字分别标识语言兼容基线、实现版本和 JVM,不应合并成一个「Ruby 版本」。
JRuby 10 将最低 Java 版本提升到 21,不能因系统默认 java 指向 Java 8 就直接运行。实验使用已有的 Homebrew OpenJDK 21.0.10,不替换系统 JDK。选用固定版本是为了重放本章实验,不代表它是当前最新版本,也不是对任意应用依赖的兼容承诺。
用独立进程运行共同契约
实验的 contract.rb 只使用两种实现都支持的语言与核心库能力。输入为两条带状态的中文任务,方法要求显式关键字参数,并限制 limit 为正整数。父脚本分别调用 CRuby 和 JRuby,解析结果 JSON,再比较 result 字段;实现名称和版本单独保留,不参与业务值相等判断。
1 | |
反例把关键字参数改为位置 Hash,断言 ArgumentError;另一个反例传入零,检查参数校验失败后 ensure 仍执行。浅复制实验则修改副本中共享的字符串,确认原数组也观察到变化。只比较正常输出会漏掉这些调用者可见差异,错误类型、清理路径和可变对象关系都属于契约。
这组实验未调用 RubyVM、YJIT 或 CRuby 的 ObjectSpace 内部接口。它没有先调用专属 API,再把 JRuby 报错记为兼容通过。需要跨实现的模块应明确公共边界;实现探针适合留在诊断层,通过能力检测决定使用路径,但能力缺失不能计入语义测试成功数。
本次两个进程得到相同结果:筛选标题为「写作」,两个参数错误均被捕获,ensure 事件存在,浅复制后的嵌套字符串为 title!。这证明列出的有限案例一致,没有证明全部 Ruby 标准测试、所有 gem 或整个 Taskbook 的迁移完成。
没有 GVL 也需要业务同步
CRuby 的 GVL 不能替代读改写的业务锁,JRuby 的并行执行能力也不会自动使共享状态安全。共同契约先用两个 Queue 固定交错:两个线程均读取旧值零,报告就绪,然后等待主线程释放;两次写回都得到一。两个解释器的结果均为一。
修复版本把每次计数增量放入同一个 Mutex,两线程各更新一千次,最终均为两千。这里验证的是受保护的复合操作,而非任意一次 Integer 赋值的内部实现。只锁写入、把读取留在锁外,仍可能使用已经过期的旧值。
JRuby 的并发文档区分可见性与原子性,并明确提醒可变核心集合的并发访问风险。一个字段的新值能够被另一个线程观察,不意味着「读取、计算、写回」已经成为不可分割操作。迁移前应寻找共享 Hash、缓存初始化和集合迭代中的复合约束,而不只搜索 Thread.new。
从 Ruby 线程观察 JVM 线程
interop.rb 使用 Java CountDownLatch 建立两个阶段。两个 Ruby 工作线程各记录当前 Java 线程 ID,减少 ready 计数,然后等待 start。主线程只有在两个工作线程都已就绪且仍存活时才释放 start。每个线程随后调用 AtomicInteger.incrementAndGet 一千次。
1 | |
完整文件还包含五秒等待上限、异常检查以及 ensure 中的关闭逻辑。线程异常通过 value 传回调用者,不因主线程得到了一个数字就忽略。一次性 latch 不能当作可重复使用的循环屏障,每轮新任务需要新的同步协议。
sequenceDiagram
participant A as Ruby 工作线程 A
participant B as Ruby 工作线程 B
participant Main as 主线程
participant Start as Java start latch
A->>Main: 记录 JVM 线程 ID,ready 减一
B->>Main: 记录 JVM 线程 ID,ready 减一
Note over A,B: 各自在 start.await 等待
Main->>Start: ready 归零后 countDown
Start-->>A: 返回
Start-->>B: 返回
Note over A,B: 各调用原子增量一千次
A-->>Main: value 返回
B-->>Main: value 返回
本次 ID 为 38 与 37,事件顺序是两个 ready 后出现两个 done,结果为 2000,退出后线程均不再存活。不同 JVM 线程 ID 与受控等待说明本次确实使用了不同 JVM 线程;ID 不等于操作系统线程号,也不能据此断言特定时刻占用了两个 CPU 核。
本章没有把这段短循环计时,没有给出 JRuby 相对 CRuby 的倍率。吞吐比较还需相同任务、预热、重复样本、GC 与内存记录,并区分启动时间和稳态执行。启动 JVM 的成本不能与一个已经运行很久的服务请求直接比较。
Java 对象进入 Ruby 后仍保留类型约束
Java 互操作可以直接复用已有库。实验创建 ArrayList,添加「写作」和「校验」,通过 to_a 转换后核对内容。边界转换值得显式检查:方法重载、数值宽度、字符编码和 null 都可能影响调用结果,不能把代理对象外观看起来像 Ruby 对象视为语义完全相同。
十进制示例从字符串构造两个 java.math.BigDecimal,执行 0.1 与 0.2 相加,toPlainString 返回 0.3。这里主动避免先把十进制输入转换为二进制浮点数,再把近似值交给 Java。跨语言调用可以保留精度,也可以把入口处的误差原样带入另一套数值类型。
1 | |
Integer.parseInt 接收非法字符串时抛出 Java NumberFormatException,Ruby 通过对应的 Java 异常类捕获。实际日志记录完整 Java 类名。业务接口通常应在边界处把外部异常翻译为稳定错误协议,同时保留原因供诊断;本章保留原始类型,是为了展示实际映射而非隐藏差异。
Java 对象由 JVM 的对象与垃圾回收体系管理,跨边界引用仍可能延长生命周期。把对象放入 Ruby 全局缓存不会因为它来自 Java 就自动释放。若 Java 库持有文件、连接或线程等外部资源,应遵循该库的关闭协议,GC 不能代替业务关闭操作。本实验只使用内存对象和明确结束的线程。
从局部契约扩大到应用边界
目标版本相同也不能省略依赖矩阵。一个 gem 可能只包含 Ruby 源码,也可能带有 CRuby C 扩展或专门的 Java 平台实现。应用调用表面相同,加载的实现和底层资源却可能不同。迁移清单应记录实际平台包、版本及加载路径,然后对已安装产物运行业务场景,不从 gem 名称推断平台支持。
共同契约刻意没有加载 Taskbook 的主线依赖。这让语言行为比较保持独立,也意味着不能把本章结果填成 Taskbook 的 JRuby 支持声明。若要扩大结论,应先在隔离的 JRuby 依赖环境安装应用,再验证导入、筛选、错误输出与关闭路径;某个依赖无法安装时,应把失败归属于那一层。
线程实验同样需要扩大输入覆盖。当前整数增量没有事务关系,AtomicInteger 足够;若业务要求同时更新计数与任务列表,两个字段分别使用原子容器仍不能保证整体不变量。可以用一个锁覆盖共同状态,或把更新作为消息交给单一所有者。同步粒度要由业务约束决定,不能由刚好可用的原子类决定。
异常映射应按具体失败定义恢复动作。数字解析失败通常属于输入错误,不应通过无限重试掩盖;资源库返回临时连接错误可能允许有界重试,但必须检查是否已经产生副作用。本章只捕获明确的 NumberFormatException,没有把所有 Java 异常或 Ruby Exception 一并吞掉,这样未知失败仍会使实验退出非零。
固定下载与复现入口
JRuby complete JAR 来自项目发布到 Maven Central 的 org.jruby 坐标,下载文件和官方 .sha256 核对一致。仓库只保存校验值与 Ruby 源码,JAR 留在临时目录。run.rb 每次启动前再次核对 SHA256,避免路径下的文件被换成另一版后继续沿用原结论。
从仓库根目录执行,具体安装命令见同目录 INSTALL.md:
1 | |
调用该父脚本的 ruby 必须是本章 CRuby 3.4.11。脚本清除子进程的 Bundler 和额外 Ruby 加载路径,避免主线工程依赖影响这个独立实验。它没有安装 Java 平台 gem,也没有更改主线 Gemfile.lock;真实应用迁移还需为目标平台重新验证依赖及打包结果。
练习与验收
把筛选输入增加缺失 status 字段的任务,为两种实现定义相同错误契约并补断言。再增加带换行和中文标点的标题,检查 JSON 输出往返;测试应该覆盖错误与正常输入,而非简单地扩大循环次数。
把 Java 原子增量改成分离的 get 与 set,并用新建的 latch 固定两个线程读取同一旧值后再写入。先预测结果,再记录实际事件,最后恢复原子操作。不得用偶然未出现丢更新来证明拆分操作安全。
| 观察对象 | 本次证据 | 仍需另测的范围 |
|---|---|---|
| 语言契约 | 两实现 result 完全相同 | 全应用及依赖兼容 |
| JVM 线程 | 不同 ID、屏障与关闭事件 | CPU 加速与长期吞吐 |
| Java 互操作 | 集合、十进制、异常类型 | 任意重载与资源库 |
参考资料与实验附件
- JRuby 10.0.5.0 发布说明
- JRuby 10 的 Java 基线与实现说明
- JDK 21 CountDownLatch
- JDK 21 AtomicInteger
- 固定 JRuby 源码版本
系列导航
导读 · 上一篇:E02:RSpec、契约测试与可重放的生成式测试 · 下一篇:E04:C扩展与 FFI · 完整源码包
