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 的执行过程可以粗略分为三个阶段:

  1. 解析(Parsing):将字符串表达式转换为计算机可理解的结构(抽象语法树 AST)。
  2. 上下文准备(Context Setup):准备表达式运行所需的环境,包括根对象(Root Object)和变量(Variables)。
  3. 求值(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 表达式的写法,格式为 #{...}

  1. 纯 SpEL 表达式"#user.id":整个字符串都是表达式,没有其他文本。
  2. 模板表达式"用户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 其实是一个总调度方法,它清晰地将解析分成了两个步骤。

  1. Tokenizer:先把字符串切碎(词法分析)。
  2. 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"

  1. 遇到 #,识别为变量前缀。
  2. 读取 user,识别为 TokenKind.IDENTIFIER
  3. 遇到 .,识别为 TokenKind.DOT
  4. 读取 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",解析器首先识别出这是一个复合表达式。

  1. 识别变量:解析器读取到 #user,创建 VariableReference 节点。
  2. 识别属性访问:解析器读取到 .id,意识到这是一个属性访问操作,创建 PropertyOrFieldReference 节点。注:Spel 会自动将点号后的标识符识别为属性引用)。

在 Spel 中,所有的节点都继承自 SpelNodeImpl。最终生成的 AST 结构如下:

  • CompoundExpression (组合表达式,代表整体)
    • children[0]: VariableReference (代表 #user)
    • children[1]: PropertyOrFieldReference (代表 id)

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 时,当前的“活动对象”就变成了 userExpressionState 就是用来维护这种表达式求值期间的动态状态游标的。

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" 属性时,反射访问器会在底层执行以下逻辑:

  1. 寻找匹配的方法或字段
    访问器首先会去“猜”你要调用的真实方法。它会根据标准的 Java Bean 规范,将属性名 "id" 的首字母大写,拼接上 get 前缀,尝试去 User 类中查找是否存在 getId() 方法。
    如果是 boolean 类型,它还会尝试查找 isId() 方法。
    如果实在找不到相关的 Getter 方法,作为兜底策略,它还会去检查 User 类中是否存在直接公开的 public id 字段。

  2. 执行反射调用
    一旦通过上述规则在 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)的“预热”策略:

  1. 探测阶段:前几次调用,强制使用上一节讲的“解释执行模式”。在这个过程中,底层的 ReflectivePropertyAccessor 不仅会缓存反射 Method,还会默默记录下传入对象的真实类型(比如确认为 User.class)。
  2. 计数器:每个 SpelExpression 对象内部都维护着一个 interpretedCount 计数器,每解释执行一次,计数器加一。
  3. 阈值触发:当解释执行的次数超过设定的阈值(默认是 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 源码中关于抽象语法树的设计、上下文状态的传递、以及利用混合模式在性能与灵活度之间做出的权衡都值得我们借鉴。

  1. 高度解耦的分层架构
    Spring 将表达式的处理严格划分为 解析(静态结构)求值(动态状态) 两个独立阶段。AST 树一旦构建完成便可复用,而运行时的所有可变因素(变量、对象、类型定位器等)都被统一封装在 EvaluationContext 中。这种设计让表达式引擎能够轻松嵌入到 Spring Cache、Spring Security 等各个迥异的业务场景中。
  2. 反射机制的精细化缓存
    在默认的解释执行模式下,Spring 深知 Java 反射(特别是方法查找)的性能代价。因此,在 ReflectivePropertyAccessor 底层实现了基于“类 + 属性名”的二级缓存。通过缓存寻址结果(Method 对象),将反射调用的开销降到了最低,完美满足了中低并发下的业务需求。
  3. 引入类似 JIT 的混合编译优化
    当反射调用的固有开销成为影响极限性能的关键因素时,Spring 没有选择彻底推翻重来,而是借鉴了 JVM 的 JIT(即时编译)思想。通过 ASM 字节码操作库,将反射调用直接“硬编码”为底层的 INVOKEVIRTUAL 指令,让表达式的执行速度直接比肩原生 Java 代码。
  4. 兼顾动态与安全的“优雅降级”
    这是 Spel 编译器最精妙的一环。面对运行期不可预知的类型变化,Spring 放弃了复杂的类型推导体系,转而采用“基于统计预热(100 次执行记录类型)”+“强转失败异常捕获”的组合拳。一旦发现预设的类型条件被打破,立刻平滑降级回解释模式。既保住了静态编译的性能,又兼顾了动态语言的灵活性。
Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐