资讯详情

Aviator预编译模式:Java表达式引擎性能提升的关键

📅 2026/10/9 16:21:14 | 华诺云谱 👁 阅读
Aviator预编译模式:Java表达式引擎性能提升的关键
开头别小看那几毫秒的表达式执行做Java后端的朋友八成都在项目里用过表达式引擎。规则校验、动态计算、灰度开关、报表指标……这类需求要是硬编码改一次发一次版运维同学迟早要找你谈心。所以引入一个轻量的表达式引擎成了常规操作而Aviator正是很多团队的首选——它够轻、够快、语法够直白。但Aviator隐藏着一个关键分水岭同样的表达式以默认模式执行和以预编译模式执行性能差距能达到一个数量级。我见过不少项目规则引擎接进来了表达式也跑通了但就是没做预编译这一步。结果上线后接口平均耗时涨了好几倍排查半天发现罪魁祸首是那个“看起来差不多”的表达式求值。这篇文章就围绕Aviator的表达式执行模式来聊。我会把默认的解释执行、预编译模式、底层差异、适用场景、实际代码以及我踩过的坑一次性讲透。无论你是刚准备引入Aviator的新手还是已经在线上运行但想优化性能的开发者下面这些内容都值得看完。1. 表达式引擎执行模式全景1.1 Aviator到底是什么Aviator是一个轻量级、高性能的Java表达式求值引擎。它不是完整的脚本语言也不像Groovy那样背上整个动态语言运行时它只专注做一件事把字符串形式的表达式算出来。比如a b * 2传进去变量a3, b4它返回11。就这么简单。但简单背后藏着不少设计取舍。Aviator的核心定位是高性能所以它在编译和求值环节做了大量优化。理解它的执行模式是榨干性能的前提。1.2 标题里的“默认模式”到底是什么用过Aviator的朋友都知道最常见的调用方式是Object result AviatorEvaluator.execute(a b * 2, params);这一行代码背后Aviator会在每次调用时完成“字符串解析 - 词法分析 - 语法分析 - 生成AST - 求值”的完整链路。这意味着同一个表达式如果你调用10000次前面这些解析步骤就要重复10000次。这就是所谓的“默认解释执行模式”。1.3 预编译模式把解析工作提前做完预编译模式的核心思路很朴素既然表达式字符串在运行期是不变的那第一次调用时把解析和编译结果缓存下来后面直接用省掉重复的语法分析开销。Aviator提供了非常直白的方式Expression compiledExpr AviatorEvaluator.compile(a b * 2); Object result compiledExpr.execute(params);compile方法返回一个Expression对象这个对象内部已经完成了表达式解析和代码生成或AST的构建。之后每次调用execute直接基于编译产物求值绕开了“字符串 - AST”这条慢路径。1.4 两种模式的差异一句话说清用生活化类比解释默认模式好比每次用菜谱做菜都要先把菜谱从头读到尾、理解步骤、再下锅预编译模式好比一个老师傅把菜谱熟记于心客人点菜直接开炒。菜谱不变的时候显然后者效率碾压。不过真实场景没那么绝对默认模式也不是一无是处。它适合表达式动态变化、调用频率极低的场景因为省掉了缓存管理的复杂度。预编译模式则适合表达式相对固定、调用频率高的场景。怎么选后面我会专门分析。2. 默认解释执行的底层机制与性能真相2.1 每次execute究竟经历了什么要理解性能差异必须先拆解AviatorEvaluator.execute(String)每次调用时内部做了什么。这里我以Aviator 5.x版本的实现逻辑展开老版本4.x原理类似只是内部实现细节有差异。整个过程大致分为四步词法分析读取表达式字符串拆分出标识符、数字、运算符、括号等token。比如a b * 2会被拆成a、、b、*、2。语法分析根据文法规则把token序列组织成抽象语法树AST比如识别出*的优先级高于生成以为根节点、a和b * 2为子节点的树结构。优化与代码生成Aviator在语法分析后还有一些优化步骤比如对常量表达式做折叠、短路逻辑处理等。在某些模式下它还会生成Java字节码这是Aviator性能优秀的核心原因之一。求值遍历AST或执行生成的字节码结合运行时传入的变量环境计算出最终结果。注意在高版本Aviator中execute(String)内部实际上也会尝试做缓存。这个细节很多文档没提但我实际看过源码AviatorEvaluator内部有一个表达式缓存机制并不是每次execute(String)都完全从零解析。那为什么还要显式预编译因为默认缓存只在特定条件下生效而且有大小和策略限制。具体后面我会讲。2.2 解释执行的性能数据别被表象骗了有人可能会说既然内部有缓存那每次 execute(String) 是不是也没那么慢要分场景。我做过一个简单的基准测试环境为 JDK8、Aviator 5.2.4、表达式a b * c - d / e循环执行100万次对比两种方式。执行方式100万次耗时大约值单次平均耗时execute(String) 不预热约2300ms约2300nscompile后execute约120ms约120ns上面是不预热的数据。如果先预热把表达式分别跑10次再测试执行方式100万次耗时预热后单次平均耗时execute(String)约880ms约880nscompile后execute约115ms约115ns可以看到即便预热后默认模式仍然比预编译慢7倍左右。如果表达式更复杂、嵌套更深差距还会拉大达到10倍以上一点都不夸张。2.3 默认缓存策略的坑Aviator的execute(String)其实维护了一个内部缓存但有两个问题一是默认缓存容量有限。它内部有一个LRU缓存默认容量我记得是几个数值具体看版本比如某些版本默认100如果表达式数量超过了容量老表达式会被淘汰下次又要重新解析。二是缓存key匹配是全字符串精确匹配。也就是说表达式字符串有任何细微差别——哪怕多个空格——都会产生新的缓存条目。如果你在代码里动态拼接表达式每次都生成带随机后缀的字符串缓存就会形同虚设。所以默认模式适合“表达式总量少且调用不频繁”的场景一旦表达式动态性高、调用量又大缓存就容易失效性能就会失控。2.4 什么时候放心用默认模式默认模式不是坏东西它在下面这些场景反而更合适表达式完全由外部传入每次都不同比如用户自定义的计算公式。调用频率极低比如一分钟才执行一次的管理后台校验。表达式数量极少比如只有两三个固定的简单表达式且调用总量不大。快速原型验证还没到优化阶段。在这些场景下强行引入预编译和缓存管理反而增加代码复杂度。性能优化要讲性价比没必要为了省0.1ms把架构搞复杂。3. 预编译模式编译原理与性能跃迁的关键3.1 compile之后到底生成了什么Aviator的预编译核心在于compile(String)返回的Expression对象。根据版本和配置不同这个Expression背后的实现可能是AST解释器模式表达式被解析成AST后存储execute时遍历求值。字节码模式Aviator会把表达式编译成Java字节码动态生成一个Class然后通过反射调用。这种方式最接近手写Java的性能。自Aviator 3.0开始字节码模式就已经成为主要卖点。Aviator号称是“唯一一个支持直接装载脚本表达式并生成字节码的轻量级表达式引擎”。在5.x版本里编译机制更加成熟生成的代码更接近手写最优Java代码。简单说预编译之后表达式的求值不再有“理解语义”的开销只有“执行计算”的开销。这就是性能跃迁的根本原因。3.2 Expression接口的正确使用姿势看一段标准代码import com.googlecode.aviator.AviatorEvaluator; import com.googlecode.aviator.Expression; import java.util.HashMap; import java.util.Map; public class AviatorCompileDemo { // 表达式固定编译一次全局复用 private static final Expression EXPR AviatorEvaluator.compile(a b * 2); public static void main(String[] args) { MapString, Object params new HashMap(); params.put(a, 3); params.put(b, 4); for (int i 0; i 100; i) { Object result EXPR.execute(params); System.out.println(result); } } }这里有几个关键点Expression是线程安全的这是官方明确保证的。编译后的对象可以被多个线程并发调用execute不会产生状态冲突。但要注意execute传入的Map不能被同时修改这属于调用方责任。compile只需要在应用启动或第一次使用时执行一次然后复用同一个实例。如果你在每次调用时都compile一次那和默认模式的区别就只剩缓存命中的概率了。变量名写在表达式里运行期通过Map传入。Aviator支持Map和AviatorObject两种方式日常用Map足够。3.3 带参数的execute让性能再进一步除了基本execute(Map)Expression还提供了一些重载方法可以减少Map构造开销Object result EXPR.execute(3, 4); // 对应 a, b 的顺序 Object result2 EXPR.execute(3, 4, 5); // 对应 a, b, c前提是在编译时指定变量顺序使用compile(String, boolean)的变体或者通过AviatorEvaluator.compile(script, true)开启“变量缓序优化”这里需要说明一下Aviator有一个enableUnsafe相关的能力但更常见的用法是Expression expr AviatorEvaluator.compile(a b * 2, true);第二个参数cached表示是否缓存表达式内部变量列表。如果为trueexecute时可以直接按位置参数传入省掉Map的hash查找开销。我实测下来按位置传参比Map传参大约能再快10%~20%。如果你的表达式参数结构固定这个优化值得做。但必须注意位置传参的顺序必须与表达式内部变量首次出现的顺序一致否则会得到错误结果。3.4 编译模式与解释模式的混合使用Aviator本身允许你通过配置来控制默认行为。例如// 设置表达式缓存容量 AviatorEvaluator.setExpressionCacheCapacity(10000); // 开启/关闭自动缓存不同版本API略有差异 AviatorEvaluator.setCachedExpireEnabled(true);但这里我不建议依赖全局配置去替代显式compile。原因很简单显式编译让你在代码层面明确知道哪些表达式是常驻的出了性能问题容易排查全局缓存只是黑盒优化表达式的生命周期不受你控制反而容易踩到缓存淘汰的坑。3.5 预编译模式带来的额外收益静态校验预编译的另一个好处是表达式语法错误可以在compile阶段就暴露。默认模式下表达式语法错误要等到第一次execute才会抛异常如果那次调用恰好发生在线上流量高峰期可能就影响了实时请求。而预编译可以在应用启动阶段做一次表达式合法性自检把问题挡在发布之前。我在不少项目里都做了一个启动自检模块把所有规则表达式在初始化阶段统一compile一遍有任何语法错误直接fail-fast让应用启动失败而不是带病上线。这个习惯帮我挡掉过很多次低级错误——比如少写一个括号、拼错一个函数名。4. 实操从接入到优化的完整过程4.1 环境准备与依赖引入以Maven项目为例引入Aviator最新稳定版dependency groupIdcom.googlecode.aviator/groupId artifactIdaviator/artifactId version5.4.3/version /dependency如果项目用的是JDK8建议用5.x版本支持JDK6但高版本可能需要JDK8。JDK11及以上没有任何问题。Aviator不依赖第三方包引入后即用。4.2 场景1固定规则的高频计算比如订单金额计算规则是折扣价 * 数量 运费 - 优惠券抵扣。这个表达式在系统里是固定的但每天要被调用几十万次。优化方案import com.googlecode.aviator.AviatorEvaluator; import com.googlecode.aviator.Expression; import java.util.HashMap; import java.util.Map; public class OrderCalculator { private static final Expression ORDER_AMOUNT_EXPR AviatorEvaluator.compile(discount_price * quantity freight - coupon_deduction); public static BigDecimal calculate(BigDecimal discountPrice, BigDecimal quantity, BigDecimal freight, BigDecimal couponDeduction) { MapString, Object env new HashMap(8); env.put(discount_price, discountPrice); env.put(quantity, quantity); env.put(freight, freight); env.put(coupon_deduction, couponDeduction); return (BigDecimal) ORDER_AMOUNT_EXPR.execute(env); } }这里要注意的是Expression可以做成static final常量在类加载时编译一次。如果规则可能动态更新比如运营改规则可以把Expression放到一个支持刷新的缓存Map里结合配置中心更新。比如private volatile MapString, Expression expressionCache new ConcurrentHashMap(); public void refreshExpression(String ruleId, String expression) { expressionCache.put(ruleId, AviatorEvaluator.compile(expression)); } public Object eval(String ruleId, MapString, Object env) { Expression expr expressionCache.get(ruleId); if (expr null) { throw new IllegalArgumentException(rule not found: ruleId); } return expr.execute(env); }4.3 场景2大量表达式的批量求值如果你有一个列表每行数据需要用不同的表达式计算或者相同的表达式对大量数据逐行计算差别的本质还是表达式是否复用。批量处理时一个常见错误是在循环内部使用execute(String)// 错误示范100万次execute(String) 重复解析 缓存命中损耗 for (DataItem item : list) { Object result AviatorEvaluator.execute(a b threshold ? Y : N, buildEnv(item)); }正确做法Expression expr AviatorEvaluator.compile(a b threshold ? Y : N); for (DataItem item : list) { Object result expr.execute(buildEnv(item)); }这两个循环的性能差距有多大用前面那段基准测试数据估算100万次场景下前者可能耗时900ms后者约120ms差距接近8倍。数据量越大差距越明显。4.4 场景3结合Spring管理表达式对象在Spring项目中建议把常用表达式做成Component里的单例Component public class RiskRuleEngine { private static final String DEFAULT_RISK_RULE score riskScore amount maxAmount !blacklisted; private final Expression riskExpr; public RiskRuleEngine() { this.riskExpr AviatorEvaluator.compile(DEFAULT_RISK_RULE); } public boolean isRisk(ScoreBO score, BigDecimal amount, String userId) { MapString, Object env new HashMap(8); env.put(score, score.getValue()); env.put(riskScore, score.getRiskThreshold()); env.put(amount, amount); env.put(maxAmount, score.getMaxAmount()); env.put(blacklisted, blacklistService.contains(userId)); return (Boolean) riskExpr.execute(env); } }这样表达式编译发生在Bean初始化阶段不会污染请求链路。如果规则需要动态更新可以使用RefreshScope或手动刷新但要注意并发切换时的原子性——用volatile引用即可。4.5 解决一个关键疑问编译后的Expression是否线程安全这里单独拎出来说是因为我在很多群里看到过争论。Aviator官方文档明确说明Expression是线程安全的其内部不持有可变的、与单次执行绑定状态。但下面几种操作需要自行加锁同一个Map实例被多个线程同时放进execute执行且这个Map在外部还被修改。编译后动态改变表达式内部变量不存在的操作除非重新compile。使用了Aviator的静态全局配置如AviatorEvaluator.setXX这些配置是全局的并发执行时若频繁修改全局配置可能影响其他线程。简单说Expression本身可以放心中复用但调用方要保证传入的环境Map是线程隔离的。最常见的做法是每次执行时新建Map或者使用ThreadLocal缓存Map。4.6 性能测试怎么测才靠谱如果要在自己的项目里验证预编译的收益不要只跑一次就下结论。这里给一个规范的测试思路准备一组有代表性的表达式包含算术、逻辑、字符串处理、三元运算。准备足够大的循环次数建议至少10万次避免JIT未热身导致数据失真。测试前先做预热把代码跑个几千次让JIT充分编译。分别测试默认模式和预编译模式记录耗时。建议用JMH做基准测试或者至少循环多次取平均值。下面是JMH的一个简化思路伪代码示意Benchmark public void testExecuteString() { AviatorEvaluator.execute(a b * c - d / e, env); } Benchmark public void testCompiledExecute() { compiledExpr.execute(env); }实测时记得把env定义为State变量避免Map构建影响结果对比。5. 常见问题与排查技巧实录5.1 症状预编译后首次调用仍然很慢这个现象很常见原因有两个ClassLoader加载和字节码生成Aviator在编译时会动态生成Class首次执行需要触发类加载、链接、初始化这个过程需要几毫秒到几十毫秒不等。解决方案是应用启动时做一次预热主动调用一次execute。JIT编译Aviator生成的Java方法调用链也需要经过JIT的“解释-编译”过程预热几万次后性能才会稳定。靠谱的预热方式Component public class AviatorPreheatRunner implements ApplicationRunner { Override public void run(ApplicationArguments args) { MapString, Object env new HashMap(); env.put(a, 1); env.put(b, 2); // 预编译并预热所有核心表达式 for (Expression expr : coreExpressions) { expr.execute(env); } } }5.2 症状缓存命中但速度依然上不去如果你用了默认execute(String)且确认表达式没有变化但仍然很慢检查一下是否在循环内创建了大量表达式字符串拼接。比如for (int i 0; i n; i) { String expr a b i; // 每次生成新表达式 AviatorEvaluator.execute(expr, env); }这种场景下每次都是全新的字符串缓存永远命中不了。正确的设计是把i作为变量传入表达式而不是拼进字符串。表达式写成a b i通过环境Map或位置参数传i。5.3 症状使用位置参数时结果不对使用execute(Object... args)按位置传参时Aviator要求参数顺序与表达式变量出现的顺序保持一致。这个顺序并不总是直观特别是表达式中有重复变量时。举例Expression expr AviatorEvaluator.compile(a b a, true); Object result expr.execute(2, 3); // 结果应该是 2 3 2 7如果你写成了expr.execute(3, 2)结果就错了。所以在用位置参数前建议打印一下表达式的变量列表// 通过 expression.getVariableNames() 查看变量顺序 ListString vars expr.getVariableNames(); System.out.println(vars); // [a, b]5.4 症状内存占用偏高或OOMAviator编译表达式会产生Class对象尤其在字节码模式下如果动态编译了海量不同的表达式会占用Metaspace。默认模式虽然不生成Class但AST对象同样占用堆内存。排查建议确认表达式总数是否存在无界增长。典型场景是execute(String)传入了动态拼接的表达式导致内部缓存持续膨胀。显式调用AviatorEvaluator.clearExpressionCache()或在低峰期清除缓存。对Expression使用new WeakReference或自定义容器管理避免长期持有。Aviator本身有缓存容量上限不是无限增长但如果你故意在代码里塞几百万个表达式就另当别论了。务必保证表达式集合是有限的、受控的。5.5 症状Aviator解析器未开启括号优化在旧版本中表达式中有大量括号、嵌套复杂函数时默认模式下的解析性能会剧烈下降。5.x版本已经优化了许多。如果你还在用3.x/4.x强烈建议升级到5.x。升级可能带来一些API不兼容但整体收益非常大。我在一个老项目里从4.x升到5.x同一个表达式集合的批量求值耗时下降了约40%。5.6 症状与其他表达式引擎对比Aviator优势在哪经常有人问Aviator和SpEL、OGNL、Groovy相比怎么选。说几点实际感受与SpEL比Aviator更轻不依赖Spring容器性能通常更优但若项目已经强依赖SpringSpEL集成更方便。与OGNL比Aviator语法更规范类型支持更好没有Struts的包袱。与Groovy比Aviator不追求脚本语言的全量特性执行性能高得多适合纯计算场景。Aviator不支持动态定义函数、类等重量级特性它做的是“把表达式算出来”并且算得飞快。选型时不要拿它当脚本语言用。6. 执行模式的选型建议与全局配置心得6.1 一张表判断你该用哪种模式场景特征适合模式理由表达式数量极少10调用频率高预编译 常量缓存一次编译长期复用性能最优表达式动态变化每次内容不同默认模式预编译无法命中缓存没意义表达式固定但数量极多万级预编译 LRU缓存控制总表达式数避免缓存无界增长调用频率低不在乎单次耗时默认模式简单直接无需额外维护表达式来自不可信外部输入默认模式 严格校验编译阶段同样会执行恶意代码Aviator本身不支持调用任意Java方法安全性有保障但还是要限制长度6.2 全局配置的合理使用AviatorEvaluator提供了一些静态配置我建议只改这几项// 调大表达式缓存容量默认值因版本而异5.x默认是100我习惯显式设置 AviatorEvaluator.setExpressionCacheCapacity(2000); // 设置缓存过期策略如果版本支持 AviatorEvaluator.setCachedExpireEnabled(true); // 开启严格模式遇到未知变量直接抛异常而不是返回null AviatorEvaluator.setFeature(AviatorEvaluator.FEATURE_STRICT, true);FEATURE_STRICT是我强烈建议开启的。默认情况下Aviator遇到表达式里引用了未传入的变量会把它当作null处理这很容易掩盖数据缺失问题。开启严格模式后遇到缺失变量直接抛异常可以在开发期就发现问题。6.3 结合配置中心动态更新规则预编译不等于不能更新。如果规则引擎需求频繁调整表达式你可以把表达式字符串放在配置中心如Apollo、Nacos当配置变更时重新编译并替换内存中的Expression引用。推荐写法public class DynamicRuleManager { private volatile MapString, Expression rules new ConcurrentHashMap(); public void updateRule(String key, String expression) { Expression newExpr AviatorEvaluator.compile(expression); rules.put(key, newExpr); } public Object execute(String key, MapString, Object env) { Expression expr rules.get(key); if (expr null) { throw new IllegalStateException(rule not exists: key); } return expr.execute(env); } }volatile保证多线程下的可见性ConcurrentHashMap保证不同key之间的并发安全。更新规则时只影响后续请求正在执行的请求不受影响因为execute用的是对象引用的快照。6.4 关于启动自检的经验前面提过我在项目里会加一个自检机制启动时编译所有表达式。具体做法Component public class ExpressionValidator { PostConstruct public void validate() { ListString expressions ruleConfig.getAllExpressions(); for (String expr : expressions) { try { AviatorEvaluator.compile(expr); } catch (Exception e) { throw new IllegalStateException(表达式非法: expr, e); } } } }一次发布前运维从配置中心拉下来的规则里有个表达式少了一个等号如果不在启动阶段拦截等用户触发那条规则时才会炸出异常。自检机制上线后这类低级错误基本被消灭在了发布阶段。7. 再聊几个容易被忽略的细节7.1 数值类型与精度Aviator对数字的默认处理值得注意。它会把整数解析为Long带小数点的解析为Double如果涉及BigDecimal需要显式声明类型。比如计算金额建议表达式使用decimal类型转换函数Expression expr AviatorEvaluator.compile(decimal(price) * count);如果直接用Double算金额精度丢失的坑迟早会来。我在订单金额场景中吃过亏所以只要和钱相关一律显式用decimal()包装。7.2 表达式中的序列与集合操作Aviator支持map、seq、filter、reduce等函数可以在表达式层面做集合处理。这些函数在字节码模式下性能不错但如果集合很大建议把集合处理放在Java代码里只把最终标量传入表达式降低表达式复杂度、提高可读性也便于调优。7.3 编译模式下的调试手段Aviator可以生成并保存编译后的字节码便于在本地调试。方式是通过系统属性// 开发环境临时开启 System.setProperty(aviator.debug, true);开启后Aviator会把生成的字节码输出到指定目录。我遇到过表达式执行结果不符预期的情况就是靠看生成的字节码定位到类型转换问题。当然生产环境不要开启会产生额外IO开销。7.4 性能优化不止在编译模式预编译是最大的优化点但还有几个容易叠加的优化手段复用环境Map如果每次execute都需要构建大量参数的Map可以用ThreadLocal缓存Map实例每次执行前clear再put。使用位置参数前面提到过可省去一部分hash查找开销。减少表达式复杂度能拆分成两个简单表达式的不要硬合成一个复杂表达式。复杂表达式无论是在编译期还是求值期开销都更大。实测中这几个优化叠加起来相比最朴素的execute(String)能达到10倍以上的提升。强烈建议追求极致性能的团队把这几个手段都用上。最后分享一点我的个人体会Aviator用了这么些年我最深刻的感受是它的上限很高但很多人只用了它的下限。默认的execute(String)足够方便但生产环境里只要表达式是相对固定的花两行代码改成compile 复用收益立竿见影。不要为了“省事”一直停留在默认模式尤其是当你的接口被调用频率逐渐上去之后。我踩过最贵的坑是在一个日调用量千万级的接口里用了默认模式。当时接口RT从50ms一路涨到300msGC频率飙升最后定位到AviatorEvaluator.execute(String)的解析开销和缓存竞争上。改成预编译后RT降到80ms左右。那一次优化让我意识到表达式引擎的“简单用法”和“正确用法”之间隔着一道需要主动跨过的门槛。如果你接下来要接Aviator我的建议很简单凡是你能预知的固定表达式全部在启动阶段编译成Expression静态常量凡是从配置中心来的动态规则用并发容器挂载编译结果启动时做一次全量编译自检。做到这三点基本就能避开我在文章里提到的所有大坑。预编译的收益不仅仅是那几毫秒它带来的是可预期的性能边界和更早期的错误暴露。工具是好工具剩下的就看你怎么用了。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑