
1. 从标题拆解Nashorn 性能实战到底在聊什么看到这个标题第一反应可能是“又一个讲 JVM 上动态语言性能的老话题”。但如果你正在处理遗留的 JavaScript 脚本引擎迁移、评估 JVM 上脚本执行的性能瓶颈或者好奇为什么有些动态语言在 JVM 上跑得时快时慢那这篇文章就值得往下看。“Nashorn War Stories”这个说法很形象它指的不是教科书式的性能对比而是实战中遇到的那些具体、棘手、甚至有点“坑”的性能故事。Nashorn 是 Java 8 到 Java 14 期间官方维护的 JavaScript 引擎它的核心价值在于让 Java 应用能直接嵌入和执行 JavaScript 代码。很多人接触它是因为历史项目里用了javax.scriptAPI或者需要动态配置、规则引擎这类场景。这篇文章要解决的不是告诉你 Nashorn 比 V8 快还是慢——这种笼统的比较没意义。而是要拆解在真实的 JVM 生产环境里当你决定用或不得不用 Nashorn或类似的 JSR-223 引擎时哪些因素真正决定了脚本的执行效率为什么同样的脚本有时飞快有时又卡得不行更关键的是当性能问题出现时你的排查链路应该从哪里开始我会结合常见的性能问题场景把重点放在可观测、可复现、可优化的实操层面上。如果你关心的是 JVM 内存模型、GC 调优、或者 Spring Boot 的 SQL 超时设置这些虽然是 JVM 生态的一部分但和 Nashorn 脚本引擎的运行时性能是不同层面的问题我们会在相关的地方提到联系但不会混淆主题。2. 理解 Nashorn 的性能上下文它不是一个独立的运行时很多人把 Nashorn 当成一个“黑盒”脚本丢进去结果吐出来慢了就认为是引擎不行。这个理解是性能调优的第一个障碍。Nashorn 的性能本质上是JVM 优化能力与JavaScript 动态特性之间的一场博弈。2.1 JVM 的“热身”与 Nashorn 的编译策略JVM 以 JIT即时编译闻名它通过运行时分析热点代码将其编译为高效的本地机器码。但这对 Nashorn 意味着什么Nashorn 本身是用 Java 写的它先要将 JavaScript 代码转换成 Java 字节码然后这些字节码才能被 JVM 的 JIT 编译器优化。这个过程不是一蹴而就的解释执行阶段脚本最初被解释执行速度最慢。字节码编译阶段频繁执行的代码路径热点被 Nashorn 编译成 Java 字节码。JIT 优化阶段JVM 发现这些字节码是热点再将其编译优化为本地代码。所以一个 Nashorn 脚本的“终极速度”取决于它能否顺利走完这三个阶段。如果你的脚本只执行一次比如每次请求都重新eval一段新脚本那么它永远停留在最慢的解释阶段。这就是为什么脚本的复用和预热Warm-up对 Nashorn 性能至关重要。实操建议对于频繁执行的脚本务必在应用启动后、承接真实流量前主动执行几遍核心脚本逻辑让 JIT 完成优化。这通常被称为“预热期”。使用CompiledScript对于不变的脚本使用ScriptEngine的compile方法得到CompiledScript对象。这个编译过程完成了上述的第2步可以避免重复的语法分析和初始编译开销。ScriptEngine engine new ScriptEngineManager().getEngineByName(nashorn); CompiledScript compiledScript ((Compilable) engine).compile(function add(a, b) { return a b; }); // 后续重复执行时直接调用 compiledScript.eval(context)性能更好2.2 动态类型与类型推断JavaScript 是动态类型语言一个变量可以随时持有不同类型的值。但这对于追求确定性和优化机会的 JVM JIT 来说是噩梦。Nashorn 内部有一个重要的优化器叫Type Inference类型推断。它会尝试在运行时分析变量的实际类型。如果一段代码中某个变量的类型始终是Number那么 Nashorn 和 JVM 就可以生成针对整数或浮点运算的优化代码。一旦变量的类型发生变化比如从Number突然变成String之前优化的代码就会“去优化”Deoptimization性能急剧下降。War Story 示例一个用于计算价格的函数大部分时间传入数字但偶尔因为前端传参错误传入了数字字符串。在绝大部分调用中函数飞快但偶尔那几次类型变化的调用会触发去优化不仅自身慢还可能短暂影响后续同类调用的性能。排查与优化保持类型稳定在脚本内部尽量避免改变变量的类型。使用typeof进行防御性检查或在接入层Java端确保传入参数的类型一致性。关注控制流分支条件如果导致变量在不同路径获得不同类型也容易引发去优化。尽量让函数内的变量类型在单次执行中保持唯一。2.3 Java 与 JavaScript 的互操作成本Nashorn 的强大在于它能无缝调用 Java 对象和方法反之亦然。但这种互操作不是免费的。方法调用从 JavaScript 调用一个 Java 方法Nashorn 需要处理类型转换、方法查找可能涉及反射、以及安全校验。频繁的跨界调用是性能热点。数据传递在 Java 和 JavaScript 之间传递大量数据如数组、集合会产生序列化/反序列化或包装对象的开销。优化策略批量操作与其在循环中一次次从 JS 调用 Java 方法不如在 Java 端提供一个批量处理的接口让 JS 调用一次完成所有工作。减少跨界频率如果可能将一段逻辑完全放在 Java 端或完全放在 JS 端完成减少两者之间的来回“穿梭”。使用原生类型在 JS 中尽量使用数字、布尔值等原生类型避免使用复杂的 Java 对象作为中间状态。3. 搭建可观测的性能测试与排查环境谈性能不能凭感觉必须能测量。对于 Nashorn你需要一套简单的观测方法。3.1 基础性能测量不要一开始就上复杂的 Profiler。先用最直接的方式测量脚本执行时间。import javax.script.*; public class NashornPerfTest { public static void main(String[] args) throws Exception { ScriptEngine engine new ScriptEngineManager().getEngineByName(nashorn); String script var sum 0; for (var i 0; i 1000000; i) { sum i; } sum;; // 预热 for (int i 0; i 100; i) { engine.eval(script); } // 正式测量 long start System.nanoTime(); for (int i 0; i 1000; i) { engine.eval(script); } long duration System.nanoTime() - start; System.out.println(平均耗时: (duration / 1000 / 1_000_000.0) ms); } }3.2 利用 JVM 参数打开性能观察窗口JVM 提供了大量参数来观察 JIT 编译和优化行为这对理解 Nashorn 性能至关重要。-XX:PrintCompilation打印 JIT 编译事件。你可以看到哪些方法包括 Nashorn 生成的字节码方法被编译了是客户端编译C1还是服务端编译C2。怎么看运行你的应用观察控制台输出。如果你的热点脚本函数对应的生成类方法被反复编译显示为made not entrant或made zombie很可能触发了去优化。-XX:UnlockDiagnosticVMOptions -XX:LogCompilation生成更详细的编译日志到文件通常是hotspot.log。可以用 JITWatch 等工具可视化分析看到内联、去优化等详细原因。-XX:TraceDeoptimization专门打印去优化事件。当 Nashorn 脚本因类型变化导致性能回退时这里会有明确日志。Nashorn 特定参数-Dnashorn.typeInfo.disabledtrue禁用类型推断。这是一个关键的诊断参数。如果禁用后性能变化不大说明类型推断在你的场景中没起到好作用或者类型本身很不稳定。如果禁用后性能显著下降说明类型推断优化是有效的。-Dnashorn.compiler.splitThresholdvalue控制编译器何时将大方法拆分成更小单元。调整这个可能影响内联决策。--dump-on-error当脚本出错时Nashorn 会输出更多上下文信息。实操流程在测试环境为你的应用加上-XX:PrintCompilation -Dnashorn.typeInfo.disabledtrue参数。运行典型负载观察控制台。关注是否有大量与jdk.nashorn.internal.*相关的方法编译活动。对比开启和禁用typeInfo的性能差异和编译日志差异。3.3 内存与 GC 的影响虽然 Nashorn 性能问题常表现为 CPU 消耗高或执行慢但 GC 压力会间接导致性能抖动。脚本缓存ScriptEngineManager和ScriptEngine本身会缓存编译后的脚本。确保你没有无意中创建大量引擎实例例如每个请求创建一个这会导致内存泄漏和额外的类加载开销。Java 对象包装在 JS 中访问的每个 Java 对象Nashorn 都会为其创建一个包装器NativeJavaObject。大量、短命的包装器对象会增加 Young GC 的频率。排查工具使用jmap -histo:live pid查看内存中对象实例寻找jdk.nashorn.internal.*或NativeJavaObject的实例数量是否异常。4. 典型“战争故事”场景与调优实战现在我们结合几个典型场景看看如何应用上述原理进行调优。4.1 场景一规则引擎中的脚本性能骤降现象一个用于风控的规则引擎使用 Nashorn 执行数百条规则脚本。平时运行良好但每当规则中某个条件分支涉及到一个偶尔为null或类型多变的变量时整个引擎的处理延迟就会出现周期性尖峰。分析定位热点脚本通过-XX:PrintCompilation发现某几个规则函数对应的编译方法频繁出现made not entrant。类型推断失效检查问题脚本发现类似这样的代码function checkValue(obj) { // obj.value 大部分时间是 Number偶尔是 String极少数情况是 null if (obj.value 100) { // 当 value 是 String 或 null 时这里会抛异常或类型转换 return true; } return false; }去优化触发当obj.value为 String 时之前基于 Number 类型优化的代码路径失效JIT 触发去优化回退到解释执行或重新编译导致本次及后续几次调用变慢。解决方案防御性类型转换在脚本入口处进行强制类型转换和空值检查。function checkValue(obj) { var val obj.value; if (val null) return false; // 处理 null // 明确转换为数字如果转换失败非数字字符串则按小于100处理 var numVal val; // 或 Number(val) if (isNaN(numVal)) return false; return numVal 100; }隔离不稳定代码如果可能将类型不稳定的变量判断逻辑提取到一个小函数中。即使它被去优化影响范围也较小。Java 端预处理在调用脚本前在 Java 端完成类型判断和清理保证传入脚本的参数类型是纯净、稳定的。4.2 场景二批量数据处理脚本内存增长过快现象一个用于数据清洗的批处理任务在 Nashorn 中循环处理一个巨大的 JavaList内存持续增长Full GC 频繁。分析对象包装开销脚本在循环中list.get(i)每个返回的 Java 对象都被 Nashorn 包装。脚本变量持有引用在 JS 中创建的变量如中间结果数组可能意外地持有了对 Java 对象的引用导致这些对象无法被及时回收。ScriptContext绑定泄漏将大的 Java 对象通过engine.put()绑定到脚本上下文且该上下文被长期持有。解决方案分批次处理不要在单个脚本执行中处理整个巨型列表。在 Java 端进行分页每次只传递一小批数据给脚本执行。及时清理上下文对于一次性的脚本执行使用新的SimpleScriptContext执行完毕后丢弃。避免在 JS 中缓存大型 Java 对象尽量让数据在 Java 端流动JS 只做无状态的纯计算。使用-Xmx和 GC 日志分析配合-Xlog:gc*日志分析 GC 频率和对象晋升情况确认内存压力来源。4.3 场景三高并发下脚本引擎成为瓶颈现象Web 服务中每个请求使用 Nashorn 渲染一个模板。QPS 上去后响应时间变长CPU 使用率高。分析引擎实例竞争多个线程共享同一个ScriptEngine实例。ScriptEngine不是线程安全的虽然 Nashorn 做了部分同步但高并发下竞争激烈。编译竞争即使使用CompiledScript高并发首次编译时也可能存在竞争。缺乏预热服务刚启动就承受高流量脚本没有经过充分 JIT 优化。解决方案使用ThreadLocal缓存引擎为每个线程分配一个独立的ScriptEngine实例。这消除了同步开销但增加了内存占用。private static final ThreadLocalScriptEngine ENGINE_HOLDER ThreadLocal.withInitial(() - { ScriptEngine engine new ScriptEngineManager().getEngineByName(nashorn); // 在这里进行公共脚本的编译和预热 ((Compilable) engine).compile(一些基础函数库...); return engine; });预编译与预热在服务启动时创建一个专门的预热线程用模拟请求反复执行核心脚本确保热点代码在流量到来前已被 JIT 优化。考虑轻量级替代方案对于极度简单的表达式求值可以考虑使用javax.script支持的其他轻量级引擎如某些表达式语言引擎或者评估是否真的需要完整的 JavaScript 运行时。5. 超越 Nashorn现代 JVM 动态语言生态与迁移考量Nashorn 在 Java 15 中被标记为废弃在 Java 17 中被移除。这是因为它背后的维护成本和性能、特性上与 V8 等现代引擎的差距。但这不代表 JVM 上运行动态脚本的需求消失了。当前的选项与性能考量GraalVM JavaScript (GraalJS)这是 Nashorn 的官方继任者。它基于 Truffle 框架和 GraalVM 编译器性能通常远超 Nashorn在某些场景下甚至可以接近 V8。优势更好的 ECMAScript 兼容性、出色的峰值性能、与 Java 互操作性同样优秀。注意点需要引入 GraalVM 相关依赖。它的“热身”时间可能比 Nashorn 更长因为涉及更多层次的编译优化但优化后的峰值性能更高。内存占用可能也更大。通过 JNI 集成 V8 (Chrome V8)一些项目通过 JNI 直接调用本地 V8 库。优势极致的 JavaScript 执行性能。劣势失去了纯 Java 部署的便利性需要处理本地库依赖、跨平台问题、以及更复杂的 Java/JavaScript 内存管理和对象传递。继续使用 Nashorn (JDK 11)对于无法立即迁移的遗留系统可以从 OpenJDK 的独立模块org.openjdk.nashorn:nashorn-core获取并继续使用。性能策略本文讨论的所有调优手段依然完全适用。你的重点应放在通过优化脚本写法、预热和资源配置来榨取现有架构的最大性能。迁移决策的关键点性能需求如果现有 Nashorn 性能已满足且迁移风险高可以暂缓。脚本复杂度如果脚本大量依赖 Nashorn 特有的、对 Java 内部类的访问这些在 GraalJS 中可能受限迁移成本会很高。环境约束能否接受 GraalVM 或本地库的部署要求长期维护选择有活跃社区和长期支持的技术栈。性能对比测试方法 如果你在评估迁移务必做对等的性能对比测试准备一组具有代表性的真实业务脚本。为 Nashorn 和候选引擎如 GraalJS分别编写相同的、优化过的调用代码使用CompiledScript、线程本地缓存等。在相同的 JVM 和硬件上进行充分的预热。测量稳定状态下的吞吐量Ops/sec和延迟P99 P95。同时监控内存占用和GC 活动。不要只看一次执行的耗时动态语言引擎的性能体现在持续运行的热点代码上。6. 总结把 Nashorn 性能问题转化为可操作的检查清单最后当你面对一个“Nashorn 脚本慢”的问题时不要盲目猜测。按照一个系统性的清单来排查确认问题范围是单个脚本慢还是所有脚本都慢是始终慢还是运行一段时间后变慢或是偶尔出现尖峰检查脚本执行模式脚本是否被重复执行是否使用了CompiledScript是否在每次请求/调用时都创建新的ScriptEngine观察 JIT 行为-XX:PrintCompilation热点脚本方法是否被成功编译是否有频繁的“去优化”made not entrant事件诊断类型稳定性-Dnashorn.typeInfo.disabledtrue关闭类型推断后性能是变好、变差还是没变化这能立刻告诉你类型问题是否是瓶颈。审查脚本代码是否存在动态改变变量类型的代码是否存在大量 Java/JavaScript 跨界调用能否批量处理循环内部是否进行了不必要的对象创建或类型转换检查资源与并发高并发下是否存在引擎实例竞争考虑ThreadLocal。内存使用是否正常检查是否有通过脚本上下文造成的大对象泄漏。实施针对性优化预热增加预热逻辑。类型稳定化修改脚本确保关键路径变量类型稳定。减少互操作重组逻辑减少跨界调用次数。资源管理确保引擎、上下文、绑定对象得到正确管理。Nashorn 的性能调优归根结底是理解 JVM 的优化原理并让动态的 JavaScript 代码尽可能地去适应这套静态优化体系。即使未来迁移到 GraalJS这些关于 JIT 热身、类型稳定性、互操作成本的思考依然是通用的宝贵经验。在 JVM 上运行动态语言永远是在灵活性与性能之间寻找最佳平衡点。