深入解析 Spring SpEL 实现机制
1. 引言
在 Spring 框架中,Spring Expression Language (Spel) 是一个强大的表达式语言,它支持在运行时查询和操作对象图。虽然 Spel 可以独立使用,但它最常见的应用场景之一是在 Spring Cache、Spring Security 等模块中,用于动态解析注解参数。
本文将以一个典型的 Spring Cache 注解为例:
@Cacheable(value = "users", key = "#user.id")
public User getUser(User user) {
// ... logic
}
这里的 key = "#user.id" 就是一段 Spel 表达式。Spring 是如何将字符串 "#user.id" 解析并最终获取到方法参数 user 对象中的 id 属性值的?本文将通过源码分析(为了更直观,源码删去了部分异常抛出、非必要注解、断言等,源码版本为 JDK17),阐述其内部实现。
2. 整体架构概览
Spel 的执行过程可以粗略分为三个阶段:
- 解析(Parsing):将字符串表达式转换为计算机可理解的结构(抽象语法树 AST)。
- 上下文准备(Context Setup):准备表达式运行所需的环境,包括根对象(Root Object)和变量(Variables)。
- 求值(Evaluation):根据上下文,对 AST 进行遍历求值。在此过程中,Spring 提供了两种模式:
- 解释执行模式:利用 Java 反射机制。
- 编译执行模式:利用 ASM 生成字节码(性能优化)。
3. 第一阶段:解析 (Parsing)
解析的目标非常明确:将可读的字符串 "#user.id" 转换成计算机可操作的对象树(AST)。
整个解析过程的入口是 SpelExpressionParser,但它只是一个门面。真正的核心调度发生在 InternalSpelExpressionParser 类的 doParseExpression 方法中。
3.1 解析入口
我们在业务代码中或者框架中通常这样调用:
ExpressionParser parser = new SpelExpressionParser();
Expression exp = parser.parseExpression("#user.id");
SpelExpressionParser 继承自 TemplateAwareExpressionParser。为什么要有这层继承?因为 Spel 支持 #{...} 这种模板写法。当调用 parseExpression 时,父类会判断:这是模板吗?
- 如果是模板(包含
#{和}),走模板解析逻辑。 - 如果不是(像我们的例子
"#user.id"),则直接调用doParseExpression。
什么是模板?
在 Spring EL (SpEL) 的语境下,模板指的是字符串中混合了静态文本和 SpEL 表达式的写法,格式为
#{...}。
- 纯 SpEL 表达式:
"#user.id":整个字符串都是表达式,没有其他文本。- 模板表达式:
"用户ID是: #{#user.id}":包含静态文本"用户ID是: "和 包裹在#{}中的 SpEL 表达式#user.id。
// org.springframework.expression.common.TemplateAwareExpressionParser
public Expression parseExpression(String expressionString, ParserContext context) {
if (context != null && context.isTemplate()) {
return this.parseTemplate(expressionString, context);
} else {
return this.doParseExpression(expressionString, context);
}
}
接下来进入 SpelExpressionParser 实现的 doParseExpression:
// org.springframework.expression.spel.standard.SpelExpressionParser
protected SpelExpression doParseExpression(String expressionString, ParserContext context) {
// 委托给 InternalSpelExpressionParser 进行处理
return new InternalSpelExpressionParser(this.configuration).doParseExpression(expressionString, context);
}
3.2 核心调度:InternalSpelExpressionParser
InternalSpelExpressionParser.doParseExpression 其实是一个总调度方法,它清晰地将解析分成了两个步骤。
- Tokenizer:先把字符串切碎(词法分析)。
- eatExpression:把切碎的片段组装成树(语法分析)。
// org.springframework.expression.spel.standard.InternalSpelExpressionParser
// 必要属性声明
private List<Token> tokenStream = Collections.emptyList();
public SpelExpression doParseExpression(String expressionString, ParserContext context) {
this.checkExpressionLength(expressionString);
try {
// 步骤一:创建词法分析器并处理
// 将字符串 "#user.id" 拆解成一个个 Token
this.expressionString = expressionString;
Tokenizer tokenizer = new Tokenizer(expressionString);
this.tokenStream = tokenizer.process();
// 处理完后,this.tokenStream 就是 List<Token> tokens
// 步骤二:构建抽象语法树 (AST)
// eatExpression() 方法会消费这些 Token,构建出 SpelNode 树
this.tokenStreamLength = this.tokenStream.size();
this.tokenStreamPointer = 0;
this.constructedNodes.clear();
SpelNodeImpl ast = this.eatExpression();
if (ast == null) {
throw new SpelParseException(this.expressionString, 0, SpelMessage.OOD, new Object[0]);
} else {
// 步骤三:检查是否还有剩余 Token
// 如果 AST 构建完了,Token 还没用完,说明表达式格式不对(例如 "#user.id garbage")
Token t = this.peekToken();
if (t != null) {
throw new SpelParseException(this.expressionString, t.startPos, SpelMessage.MORE_INPUT, new Object[]{this.toString(this.nextToken())});
} else {
// 步骤四:封装结果
return new SpelExpression(expressionString, ast, this.configuration);
}
} catch (InternalParseException ex) {
throw ex.getCause();
}
}
3.3 步骤一:词法分析 (Tokenizer)
词法分析器 Tokenizer 负责遍历输入字符串。它会识别特定的字符并将其分类。
对于表达式 "#user.id":
- 遇到
#,识别为变量前缀。 - 读取
user,识别为TokenKind.IDENTIFIER。 - 遇到
.,识别为TokenKind.DOT。 - 读取
id,识别为TokenKind.IDENTIFIER。
源码中 Tokenizer.process() 方法通过一个巨大的 switch-case 或 if-else 逻辑来处理字符流:
// org.springframework.expression.spel.standard.Tokenizer (简化版逻辑)
public void process() {
while (this.pos < this.max) {
char ch = this.expressionString.charAt(this.pos);
if (isAlphabetic(ch)) {
// 处理标识符
lexIdentifier();
} else if (ch == '.') {
pushToken(TokenKind.DOT);
this.pos++;
} else if (ch == '#') {
// 处理变量引用逻辑
// ...
}
// ... 其他符号处理
}
}
最终生成的 Token 流类似于:[HASH, IDENTIFIER(user), DOT, IDENTIFIER(id)]。
3.4 步骤二:语法分析 (AST 构建)
拿到 Token 流后,InternalSpelExpressionParser 会通过递归下降分析法(Recursive Descent Parsing)构建 AST。
对于 "#user.id",解析器首先识别出这是一个复合表达式。
- 识别变量:解析器读取到
#user,创建VariableReference节点。 - 识别属性访问:解析器读取到
.和id,意识到这是一个属性访问操作,创建PropertyOrFieldReference节点。注:Spel 会自动将点号后的标识符识别为属性引用)。
在 Spel 中,所有的节点都继承自 SpelNodeImpl。最终生成的 AST 结构如下:
- CompoundExpression (组合表达式,代表整体)
- children[0]: VariableReference (代表
#user) - children[1]: PropertyOrFieldReference (代表
id)
- children[0]: VariableReference (代表
eatExpression() 返回的是那个最外层的 CompoundExpression 对象。
这个对象树结构如下:
CompoundExpression
├── VariableReference ("user")
└── PropertyOrFieldReference ("id")
最后,doParseExpression 方法将这个根节点包装进 SpelExpression 对象中返回。至此,解析阶段彻底完成,字符串已经变成了可执行的 Java 对象结构。
CompoundExpression 对象在哪里创建?eatExpression() 是整个语法分析的最顶层入口。在这个方法里,它只处理优先级最低的操作符:
- TokenKind.ASSIGN (赋值操作符 = )
- TokenKind.ELVIS (Elvis 操作符 ?: )
- TokenKind.QMARK (三元操作符 ? : )
由于表达式#user.id里面既没有等号,也没有问号,所以代码在判断这些 if 之前,必须先去解析左边的内容,也就是去调用eatLogicalOrExpression()。最终层层调用,在eatPrimaryExpression()中创建出CompoundExpression对象。
private SpelNodeImpl eatPrimaryExpression() {
// ...
if (start != null && nodes != null) {
return new CompoundExpression(start.getStartPosition(), ((SpelNodeImpl)nodes.get(nodes.size() - 1)).getEndPosition(), (SpelNodeImpl[])nodes.toArray(new SpelNodeImpl[0]));
} else {
return start;
}
}
4. 第二阶段:上下文准备 (Context Setup)
经过第一阶段的解析,我们得到了抽象语法树(AST)。但是,AST 是静态的,它只包含逻辑结构(比如它知道要去查找一个名为 “user” 的变量,并读取它的 “id” 属性),却不知道 “user” 具体的对象实例是什么。
为了让 AST 运行起来,我们需要为它提供运行时的环境数据,这就是 EvaluationContext(评估上下文)的作用。
虽然在实际开发中,像 Spring Cache 这样的框架在底层为我们自动构建了诸如 MethodBasedEvaluationContext 的复杂上下文对象,但所有的框架封装最终都基于最核心的通用实现:StandardEvaluationContext。因此,要真正理解 Spel 的机制,我们需要从程序员手动调用 Spel 的场景入手,看一看上下文内部到底装了什么。
4.1 手动调用 Spel 的标准步骤
当程序员在业务代码中直接使用 Spel 时,通常会遵循以下标准步骤:
// 1. 创建解析器 (对应第一阶段)
ExpressionParser parser = new SpelExpressionParser();
Expression exp = parser.parseExpression("#user.id");
// 2. 准备上下文 (本阶段核心)
StandardEvaluationContext context = new StandardEvaluationContext();
// 手动将一个 User 对象绑定到名为 "user" 的变量上
User myUser = new User();
myUser.setId("1001");
context.setVariable("user", myUser);
// 3. 执行求值 (对应第三阶段)
String id = exp.getValue(context, String.class);
在这个过程中,StandardEvaluationContext 扮演了关键的数据仓库角色。我们来看看它的内部源码实现。
4.2 StandardEvaluationContext 的核心成员
打开 StandardEvaluationContext 的源码,可以看到它维护了几个非常重要的组件:
// org.springframework.expression.spel.support.StandardEvaluationContext
public class StandardEvaluationContext implements EvaluationContext {
// 根对象
private TypedValue rootObject;
// 变量容器
private Map<String, Object> variables;
// 属性访问器列表
private List<PropertyAccessor> propertyAccessors;
// 类型转换器
private TypeConverter typeConverter;
// 类型定位器(用于查找类)
private TypeLocator typeLocator;
// ... 省略其他成员(如 MethodResolver 等)
}
这些成员变量支撑了 Spel 表达式的各种高级特性。下面我们逐一详细说明它们的作用和源码机制。
4.3 变量管理与 TypedValue
我们在代码中调用 context.setVariable("user", myUser) 时,底层其实就是将这个对象放入了一个 Map 结构中:
// org.springframework.expression.spel.support.StandardEvaluationContext
@Override
public void setVariable(String name, Object value) {
if (name != null) {
if (value != null) {
this.variables.put(name, value);
} else {
this.variables.remove(name);
}
}
}
变量 (Variables) 是通过 # 符号来访问的。在 AST 的求值阶段(后面会详细讲解),当遇到 VariableReference(变量引用)节点时,Spel 就会调用上下文的 lookupVariable 方法,直接从这个 Map 中按名称(如 “user”)把对象取出来。
除了普通变量,上下文还有一个根对象 (Root Object)。根对象不需要加 # 前缀就可以直接访问。
你可以通过构造函数或 setRootObject() 方法设置根对象:
StandardEvaluationContext context = new StandardEvaluationContext(myUser);
// 此时不需要 #,直接访问 id
Expression exp = parser.parseExpression("id");
在源码中,根对象被包装成了 TypedValue 类型:
// org.springframework.expression.spel.support.StandardEvaluationContext
public StandardEvaluationContext(@Nullable Object rootObject) {
this.rootObject = new TypedValue(rootObject);
// ...
}
为什么需要 TypedValue?TypedValue 是 Spel 内部非常关键的一个数据结构。它不仅仅保存了对象实例本身(value),还保存了该对象的类型信息(TypeDescriptor)。在 Java 存在泛型擦除的情况下,TypedValue 能够保留字段或方法返回值上的复杂泛型信息,确保后续在进行类型转换或反射调用时不会丢失类型精度。
4.4 属性访问器的注册
当 AST 拿到 user 对象后,它需要读取 id 属性。Java 原生是没有直接读取对象的属性这个概念的(只有调用 getter 方法或者直接访问 field)。
StandardEvaluationContext 通过维护一个 PropertyAccessor 列表来解决这个问题。在它的初始化代码中,可以看到默认注册的访问器:
// org.springframework.expression.spel.support.StandardEvaluationContext
private List<PropertyAccessor> initPropertyAccessors() {
List<PropertyAccessor> accessors = this.propertyAccessors;
if (accessors == null) {
accessors = new ArrayList(5);
((List)accessors).add(new ReflectivePropertyAccessor());
this.propertyAccessors = (List)accessors;
}
return (List)accessors;
}
ReflectivePropertyAccessor 是最常用的访问器,它懂得如何利用 Java 反射去寻找匹配的 getId() 方法。
这种设计提供了极大的扩展性。比如,如果你的 user 对象不是一个普通的 JavaBean,而是一个 Map 集合,或者是一个 JSONObject,你完全可以通过手动调用 context.addPropertyAccessor(...) 将针对特定类型的自定义访问器注册到上下文中,从而让 Spel 支持对各种非标准对象的点(.)操作,比如如果是 Map 集合可以使用 MapAccessor。
4.5 类型定位与类型转换
在 Spel 中,我们不仅可以读取属性,还可以直接使用 Java 类或进行类型转换。例如表达式 T(java.util.UUID).randomUUID().toString()。
为了支持这种操作,上下文需要知道如何根据字符串 “java.util.UUID” 找到对应的 Class 对象。这就是 TypeLocator 的职责。
StandardEvaluationContext 默认会初始化一个 StandardTypeLocator:
// org.springframework.expression.spel.support.StandardTypeLocator
public class StandardTypeLocator implements TypeLocator {
@Nullable
private final ClassLoader classLoader;
private final List<String> importPrefixes;
private final Map<String, Class<?>> typeCache;
private final List<String> knownPackagePrefixes = new ArrayList<>(1);
public StandardTypeLocator(@Nullable ClassLoader classLoader) {
this.importPrefixes = new ArrayList(1);
this.typeCache = new ConcurrentHashMap();
this.classLoader = classLoader;
// 默认将 java.lang 包注册为已知前缀
this.registerImport("java.lang");
}
public void registerImport(String prefix) {
this.importPrefixes.add(prefix);
}
@Override
public Class<?> findType(String typeName) throws EvaluationException {
// 利用 ClassLoader 尝试加载类
// 如果找不到,还会尝试拼接 importPrefixes (如 java.lang.String) 继续查找
// ...
}
}
正因为 StandardTypeLocator 默认注册了 java.lang 包,我们在 Spel 中使用 T(String) 时才不需要写完整的 T(java.lang.String)。
另外,当我们执行 exp.getValue(context, Long.class) 要求 Spel 将结果转换为特定类型时,或者在对对象属性进行赋值时,上下文内部的 TypeConverter 就会介入。
默认的 StandardTypeConverter 底层其实直接引用了 Spring Core 模块中的 ConversionService,复用了 Spring 强大的内置类型转换机制,支持诸如字符串转日期、字符串转枚举等复杂的转换逻辑。
总结来说,在第二阶段,无论是业务开发人员手动编写代码,还是底层的框架,都在做同一件事:组装 StandardEvaluationContext 及其相关的子类。通过向这个上下文中填充变量、指定根对象、配置各种访问器和定位器,
5. 第三阶段:求值 (Evaluation) - 解释执行模式
在默认情况下,Spel 采用的是解释执行模式(Interpreted Mode)。所谓解释执行,就是按照解析阶段生成的抽象语法树(AST)的结构,从根节点开始,一层一层地向下遍历,并在运行期利用 Java 反射机制去动态获取或设置对象的值。
调用的总入口非常简单,就是 Expression 接口的 getValue 方法:
// org.springframework.expression.spel.standard.SpelExpression
@Override
@Nullable
public Object getValue(EvaluationContext context) throws EvaluationException {
// 这里的 this.ast 就是解析阶段生成的根节点(SpelNodeImpl)
// 注意:这里并没有直接把 context 传给 ast,而是包装成了一个 ExpressionState
ExpressionState expressionState = new ExpressionState(context, this.configuration);
Object result = this.ast.getValue(expressionState);
// ... 省略异常处理和类型转换
return result;
}
这里有一个关键的设计细节:为什么要将 EvaluationContext 包装成 ExpressionState?
因为 EvaluationContext 是静态的配置(我们在第二阶段塞进去的变量、访问器等),而在遍历 AST 时,上下文状态是会动态变化的。比如计算 "#user.id" 时,先计算出 user 对象,接着在计算 id 时,当前的“活动对象”就变成了 user。ExpressionState 就是用来维护这种表达式求值期间的动态状态游标的。
5.1 AST 的串联执行逻辑
在解析 "#user.id" 时,AST 根节点会将其拆解为两个顺序执行的动作。前一个动作的输出,会自动成为后一个动作的输入。
动作一:提取变量 #user
首先,AST 负责处理 #user 的节点开始工作。它的逻辑非常简单:直接去 EvaluationContext 内部维护的变量 Map 中,查找 Key 为 “user” 的数据。
查找到之后,它会将这个真实的 User 对象实例提取出来。此时,第一步完成。
动作二:读取属性 .id
接下来,AST 负责处理 .id 的节点开始工作。Spel 会将上一步提取出来的 User 对象作为当前的“活动目标”传递给这个节点。
这个节点面临的问题是:它现在手里只有一个 User 对象实例,以及一个字符串 "id"。在 Java 这种强类型语言中,如何在运行期动态地从一个对象里读取指定名称的属性呢?这就需要用到反射。
5.2 属性访问与反射查找机制
为了解决动态读取属性的问题,Spring 内部提供了一个专门的组件叫做 PropertyAccessor(属性访问器)。默认情况下,Spel 使用的是基于反射的访问器,即前面提到过的 ReflectivePropertyAccessor 。
当 AST 要求从 User 对象中读取 "id" 属性时,反射访问器会在底层执行以下逻辑:
-
寻找匹配的方法或字段:
访问器首先会去“猜”你要调用的真实方法。它会根据标准的 Java Bean 规范,将属性名"id"的首字母大写,拼接上get前缀,尝试去User类中查找是否存在getId()方法。
如果是 boolean 类型,它还会尝试查找isId()方法。
如果实在找不到相关的 Getter 方法,作为兜底策略,它还会去检查User类中是否存在直接公开的public id字段。 -
执行反射调用:
一旦通过上述规则在User类中成功找到了getId()这个Method对象,访问器就会使用 Java 原生的反射 API(即method.invoke(target)),将当前的User对象传入,从而获取到真实的 ID 值。
至此,经过这两步动作,Spel 成功将字符串 "#user.id" 转换成了真正的属性值。
5.3 解释模式内部的缓存优化
如果每次执行表达式,都要去执行上面提到的 “寻找匹配的方法” 这一步,性能是非常差的。因为在 Java 中,通过反射去遍历类的所有方法(如 Class.getMethod(...))是一个极其消耗 CPU 资源的操作。。为了减轻反射带来的性能损耗,Spring 在 ReflectivePropertyAccessor 内部做了非常细致的缓存优化。
Spring 内部维护了一个 ConcurrentHashMap,它的 Key 是一个复合对象 PropertyCacheKey(由目标类的 Class 类型和属性名 “id” 构成)。而 Value 则是之前找到的 Method 对象(即 getId())。
每次访问前,它都会先查缓存,看看之前是否已经解析过这个类的这个属性:
// org.springframework.expression.spel.support.ReflectivePropertyAccessor
public TypedValue read(EvaluationContext context, Object target, String name) {
// 1. 构建缓存 Key (如: User.class + "id")
PropertyCacheKey cacheKey = new PropertyCacheKey(target.getClass(), name);
// 2. 尝试从缓存中获取解析好的反射方法
InvokerPair invoker = this.readerCache.get(cacheKey);
if (invoker == null) {
// 如果缓存没命中,说明是第一次访问,进入耗时的反射解析流程
// ...
}
// ...
}
实际的执行流程变成了这样:
当表达式第一次执行时,缓存是空的,Spring 会去遍历类方法,找到 getId() 并执行,同时将这个 Method 对象存入缓存。
当表达式第二次、第三次被调用时(比如在一个高频访问的缓存注解中),只要传进来的对象依然是 User 类型,Spring 就会直接拿着 User.class + "id" 去缓存里找。瞬间命中缓存后,直接跳过耗时的查找阶段,拿出 Method 对象直接执行 invoke 调用。
5.4 解释执行模式的局限性
通过上述的逻辑串联和内部缓存优化,Spel 的解释执行模式已经将反射查找的开销降到了最低,足以应对绝大部分常规的业务场景。它最大的优势在于极度的灵活,因为一切都是在运行期动态决定的。
但是,无论底层的缓存做得多么好,最终执行数据获取的那一步,依然是 Java 的反射调用(method.invoke)。在底层 JVM 层面,反射调用本身依然伴随着参数数组的封装、安全权限检查等固定开销。如果你的代码处于每秒几万次甚至更高并发的极端核心链路中,这种反射调用的 CPU 损耗依然会慢慢积少成多,成为系统的性能瓶颈。
那么,有没有一种方法,既能保留 Spel 表达式的灵活性,又能彻底抛弃反射,达到和原生 Java 代码一模一样的执行效率呢?这就是:编译器模式(Compiler Mode)。
6. 第四阶段:性能优化 - 编译器模式
虽然 Spel 对反射对象进行了缓存,但反射调用本身(参数封装、安全检查、动态分派)在极高并发下仍有性能损耗。为了解决这个问题,Spring 4.1 引入了 Spel Compiler。
6.1 编译触发条件
Spel 并不是一开始就编译表达式,而是采用 混合模式(Mixed Mode)。所谓混合模式,就是:先解释执行一段时间,然后再进行编译。
那么就会有疑问:既然编译执行快,为什么不一开始解析完 AST 就立刻把字节码生成出来呢?
原因在于:Spel 表达式在运行前是缺乏类型信息的。
当写下 "#user.id" 时,Spring 解析出的 AST 只知道要去寻找 user 变量并调用其 id 属性,但它根本不知道这个 user 到底是 User 类的实例,还是 Admin 类的实例。如果没有明确的类型,就无法生成精确的 Java 字节码。
因此,Spring 采用了一种类似 JVM 即时编译器(JIT)的“预热”策略:
- 探测阶段:前几次调用,强制使用上一节讲的“解释执行模式”。在这个过程中,底层的
ReflectivePropertyAccessor不仅会缓存反射Method,还会默默记录下传入对象的真实类型(比如确认为User.class)。 - 计数器:每个
SpelExpression对象内部都维护着一个interpretedCount计数器,每解释执行一次,计数器加一。 - 阈值触发:当解释执行的次数超过设定的阈值(默认是 100 次,可以在
SpelExpression对象的源码中看到),Spring 认为这个表达式的类型状态已经足够稳定了,此时就会正式触发编译流程。
6.2 触发编译与动态字节码生成
当计数器达到阈值,SpelExpression 内部的 compileExpression() 方法就会被调用。
// org.springframework.expression.spel.standard.SpelExpression
// 伪代码,省去了一些不必要的校验
public boolean compileExpression() {
// 获取当前上下文配置中的 Spel 编译器
SpelCompiler compiler = SpelCompiler.getCompiler(this.configuration.getCompilerClassLoader());
CompiledExpression compiledAst = compiler.compile(this.ast);
if (clazz != null) {
try {
this.compiledAst = compiledAst;
return true;
} catch (Exception ex) {
// 实例化失败则放弃编译
}
}
return false;
}
这里隐藏着一个核心组件:SpelCompiler。
Spring 在底层引入了著名的字节码操作库 ASM(在源码中被重新打包为 org.springframework.asm 以避免冲突)。SpelCompiler 会遍历当前的 AST 树,不再是去获取值,而是让每个 AST 节点调用自己的 generateCode 方法,向内存中一点点写入 Java 字节码指令。
6.3 字节码生成细节
为了直观理解编译器到底做了什么,我们直接来看看对于 "#user.id" 这个表达式,AST 节点最终利用 ASM 生成的字节码,如果反编译回 Java 代码,大概是什么样子(伪代码):
// 这是 Spring 在内存中动态生成的一个匿名类,实现了 CompiledExpression 接口
public class SpelExpressionGenerated extends CompiledExpression {
@Override
public Object getValue(Object target, EvaluationContext context) {
// 1. 从上下文中获取 user 变量
Object var = context.lookupVariable("user");
// 2. 类型强转:因为经过前 100 次的预热,Spring 知道这个变量肯定是 User 类型
User user = (User) var;
// 3. 直接调用原生方法!没有任何反射!
return user.getId();
}
}
重点就在第 3 步。
在 AST 的 PropertyOrFieldReference(属性访问节点)的源码中,具体的生成逻辑在 generateCode 方法中。
通过 ASM 写入指令,意味着这行代码的执行效率,已经和程序员自己在业务代码里手写 user.getId() 毫无区别了。运行期的反射损耗被彻底抹平。
6.4 编译后的执行与“优雅降级”
编译成功后,Spring 会将实例化出来的动态类对象赋值给 this.compiledAst。
此后,外界再次调用该表达式获取值时,执行路径就会发生改变:
// org.springframework.expression.spel.standard.SpelExpression
public Object getValue(EvaluationContext context) throws EvaluationException {
// 如果编译过,优先执行动态生成的字节码类
if (this.compiledAst != null) {
try {
// 直接调用 SpelExpressionGenerated.getValue()
return this.compiledAst.getValue(context.getRootObject().getValue(), context);
}
catch (Throwable ex) {
// 【核心安全网】:如果执行生成的类发生异常,如何处理?
// 在 MIXED 模式下,直接将 compiledAst 置空,回退到解释模式
if (this.configuration.getCompilerMode() == SpelCompilerMode.MIXED) {
this.interpretedCount = 0;
this.compiledAst = null;
} else {
// 如果是 IMMEDIATE 强行编译模式,则直接向外抛出异常
throw new SpelEvaluationException(ex, ...);
}
}
}
// 如果尚未编译,或者刚刚发生了降级回退,则继续使用 AST 解释模式
ExpressionState expressionState = new ExpressionState(context, this.configuration);
return this.ast.getValue(expressionState);
}
为什么执行编译后的代码会抛出异常?
这是混合模式中最精妙的安全设计。设想一下:前 100 次调用,"#user" 传进来的都是 User 对象,Spring 放心地生成了强转为 User 并调用 getId() 的字节码。
但在第 101 次调用时,业务逻辑发生改变,上下文中绑定的 "#user" 变成了一个 VIPUser 对象(且不继承自 User),这时候生成的字节码执行到 CHECKCAST 指令时,必然会抛出 ClassCastException。
面对这种运行时的类型动态变化,Spring 通过外层的 try-catch 捕获异常。一旦发现生成的静态字节码无法适应新的类型,它会立刻清空 compiledAst,将计数器清零,平滑地降级回退到反射解释模式。
7. 总结
理解了 Spel 的底层机制,不仅能帮助我们在日常开发中更高效、更安全地编写 @Cacheable 或 @PreAuthorize 中的表达式语句;更重要的是,Spring 源码中关于抽象语法树的设计、上下文状态的传递、以及利用混合模式在性能与灵活度之间做出的权衡都值得我们借鉴。
- 高度解耦的分层架构
Spring 将表达式的处理严格划分为 解析(静态结构) 与 求值(动态状态) 两个独立阶段。AST 树一旦构建完成便可复用,而运行时的所有可变因素(变量、对象、类型定位器等)都被统一封装在EvaluationContext中。这种设计让表达式引擎能够轻松嵌入到 Spring Cache、Spring Security 等各个迥异的业务场景中。 - 反射机制的精细化缓存
在默认的解释执行模式下,Spring 深知 Java 反射(特别是方法查找)的性能代价。因此,在ReflectivePropertyAccessor底层实现了基于“类 + 属性名”的二级缓存。通过缓存寻址结果(Method对象),将反射调用的开销降到了最低,完美满足了中低并发下的业务需求。 - 引入类似 JIT 的混合编译优化
当反射调用的固有开销成为影响极限性能的关键因素时,Spring 没有选择彻底推翻重来,而是借鉴了 JVM 的 JIT(即时编译)思想。通过 ASM 字节码操作库,将反射调用直接“硬编码”为底层的INVOKEVIRTUAL指令,让表达式的执行速度直接比肩原生 Java 代码。 - 兼顾动态与安全的“优雅降级”
这是 Spel 编译器最精妙的一环。面对运行期不可预知的类型变化,Spring 放弃了复杂的类型推导体系,转而采用“基于统计预热(100 次执行记录类型)”+“强转失败异常捕获”的组合拳。一旦发现预设的类型条件被打破,立刻平滑降级回解释模式。既保住了静态编译的性能,又兼顾了动态语言的灵活性。
更多推荐




所有评论(0)