What this post covers
This is the fifteenth post in the "Digging into Spring's Source" series. Part 14 followed the string "8080" from a configuration file into a bean's int field and asked who does the converting. At the end of that post we saw that doResolveDependency, the method that handles @Value, calls evaluateBeanDefinitionString right before the conversion (DefaultListableBeanFactory line 1682). A plain "8080" passes straight through, but a value wrapped in "#{…}" gets computed there. The tool doing the computing is SpEL (the Spring Expression Language), and this post goes inside it. On the map in Part 1 this is U13 in Phase 3 (foundations).
There are four questions to answer.
- How does a string become a tree? What tokens is
"T(java.lang.Math).max(a, 3) + 1"cut into, and what nodes make up the tree it turns into? Where in the code is the rule that*binds before+? - How does a tree become a value? When
getValue()is called, what does each node ask, and whom? Why are there two contexts,StandardEvaluationContextandSimpleEvaluationContext? - What is compiler mode? How is the path that produces bytecode instead of walking the tree switched on, and why is it off by default?
- How does it connect to the container? By which route does
@Value("#{…}")reach SpEL, and where do names likesystemPropertiesorotherBean.countcome from?
| Item | Details |
|---|---|
| Source | Spring Framework 7.1.0-SNAPSHOT |
| Commit | f447f3c310 (2026-10-01, main branch) |
| Modules read most | spring-expression, spring-context (context.expression), spring-beans (AbstractBeanFactory) |
| Classes read most | Tokenizer, InternalSpelExpressionParser, SpelExpression, ExpressionState, StandardEvaluationContext, SimpleEvaluationContext, SpelCompiler, StandardBeanExpressionResolver |
Every line number and every detail in this post refers to that commit. In other versions the line numbers will differ and some details may too. Part 1 explains how to get the source at the same commit.
My study notes go much deeper, line by line. This post keeps only the main thread that someone seeing Spring for the first time can follow. What was left out is named at the end, under "If you want to go deeper".
Words to know first
An expression language lets a program compute, at run time, a small expression written as a string. Something like "a > 5 and a < 10" is a string, not Java code, so it can live in a configuration file or an annotation attribute. SpEL is Spring's expression language, and the whole spring-expression module is it.
A token is a meaningful piece of a string. "max(a, 3)" is nine characters but six tokens: max, (, a, ,, 3, ). The thing that does the cutting is a tokenizer (or lexer).
A tree (AST, abstract syntax tree) is what you get when tokens are grouped according to the grammar. 1 + 2 * 3 is a "plus" node with 1 and a "times" node under it, and 2 and 3 under the "times". The thing that does the grouping is a parser. SpEL's parser is a recursive descent parser: each grammar rule is one method, and methods call methods on the way down. Section 2 is about this.
Evaluation is computing the actual value by walking the tree. In the source it is getValue(). The object that answers questions like "which object's a is this?" or "which class is T(Math)?" during evaluation is the EvaluationContext, and the object an expression refers to by default is the root object.
Compilation here means generating, as bytecode, a Java class that does the same thing the tree does, instead of interpreting the tree. Section 5 covers it.
${…} and #{…} are different things. ${…} is a placeholder resolved by the Environment from Part 12; it is not SpEL. #{…} is SpEL. Section 6 checks in the source which one is handled first.
1. From a string to tokens — Tokenizer
The entry point is SpelExpressionParser. Its Javadoc says "Instances are reusable and thread-safe" (line 28), and all it does is create a fresh InternalSpelExpressionParser for every parse and hand over.
@Override
protected SpelExpression doParseExpression(String expressionString, @Nullable ParserContext context) throws ParseException {
return new InternalSpelExpressionParser(this.configuration).doParseExpression(expressionString, context);
}
The inner parser's Javadoc says the opposite: "reusable but are not thread-safe" (line 85). It keeps the token list and "which token am I looking at" (tokenStreamPointer) in fields (lines 113–119). That is why the outer parser makes a new one every time.
Blue boxes are steps, grey boxes are intermediate products. Line numbers refer to InternalSpelExpressionParser.
The inner parser's doParseExpression shows the whole order.
@Override
protected SpelExpression doParseExpression(String expressionString, @Nullable ParserContext context)
throws ParseException {
checkExpressionLength(expressionString);
try {
this.expressionString = expressionString;
Tokenizer tokenizer = new Tokenizer(expressionString);
this.tokenStream = tokenizer.process();
this.tokenStreamLength = this.tokenStream.size();
this.tokenStreamPointer = 0;
this.constructedNodes.clear();
SpelNodeImpl ast = eatExpression();
if (ast == null) {
throw new SpelParseException(this.expressionString, 0, SpelMessage.OOD);
}
Token t = peekToken();
if (t != null) {
throw new SpelParseException(this.expressionString, t.startPos, SpelMessage.MORE_INPUT, toString(nextToken()));
}
return new SpelExpression(expressionString, ast, this.configuration);
}
catch (InternalParseException ex) {
throw ex.getCause();
}
}
Three steps. Tokenizer.process() builds the token list, eatExpression() eats those tokens and builds the tree, and if tokens are left over after eating, that is a MORE_INPUT error. The result is a SpelExpression bundling the string, the tree and the configuration.
What the tokenizer does
Tokenizer.process() looks at one character at a time and sorts it with a switch. It is long but the shape is simple.
public List<Token> process() {
while (this.pos < this.max) {
char ch = this.charsToProcess[this.pos];
if (isAlphabetic(ch)) {
lexIdentifier();
}
else {
switch (ch) {
// ...
case '#':
pushCharToken(TokenKind.HASH);
break;
// ...
case '@':
pushCharToken(TokenKind.BEAN_REF);
break;
// ...
case '$':
if (isTwoCharToken(TokenKind.SELECT_LAST)) {
pushPairToken(TokenKind.SELECT_LAST);
}
else {
lexIdentifier(); // '$' is another way to start an identifier
}
break;
// ...
}
}
}
return this.tokens;
}
A letter starts an identifier (lexIdentifier, lines 448–467), a digit starts a numeric literal (lines 236–247), a quote starts a string literal (lines 255–260), and the other symbols are one- or two-character tokens. On a +, for example, it peeks at the next character to decide between ++ (INC) and + (PLUS) (lines 96–103). Every kind of token is in the TokenKind enum (lines 25–123). A token is a small object holding its kind, its text and its start and end positions (Token lines 28–37).
Applying these rules to T(java.lang.Math).max(a, 3) + 1 gives the following. Tokenizer is package-private, so this table was worked out by hand from the code above rather than printed by a program.
| Characters | Token | Why |
|---|---|---|
T |
IDENTIFIER |
Starts with a letter. That T is special is known to the parser, not the tokenizer |
( ) , |
LPAREN RPAREN COMMA |
One-character symbols |
java . lang . Math |
IDENTIFIER DOT IDENTIFIER DOT IDENTIFIER |
. is not an identifier character (lines 538–540) |
max a |
IDENTIFIER |
|
3 1 |
LITERAL_INT |
|
+ |
PLUS |
The next character is not + |
| spaces | (none) | Skipped (lines 248–254) |
Easy to get wrong
$is an identifier character. Look atcase '$'above: unless it is$[(select-last), it goes tolexIdentifier(). So inside SpEL,${…}is not special syntax at all.${…}is resolved before anything reaches SpEL, and section 6 shows that order.#{is not a token either. It is missing from the table above because stripping the#{…}wrapper is done not by the tokenizer but by a template layer outside it.#is theHASHtoken used for variable references such as#rootand#this. Again, section 6.- Operators can be spelled out. If an identifier is two or three characters long,
lexIdentifierchecks it against the listDIV,EQ,GE,GT,LE,LT,MOD,NE,NOTand turns a match into an operator token (lines 47–48, lines 456–465). That is why you can writea lt bwhere<is awkward, as in XML.andandorare not on this list; the parser handles them separately (comment, lines 42–43). - There is a length limit.
checkExpressionLengththrows if the configured maximum is exceeded (lines 159–164); the default is 10,000 characters (SpelParserConfigurationline 62).
2. From tokens to a tree — recursive descent and precedence
Almost every method in the parser is called eatXxx, and each one calls the next. That call order is the operator precedence.
Each method is labelled with the operators it handles. The lower it is, the tighter it binds.
The two methods for + and * show the rule.
//sumExpression: productExpression ( (PLUS^ | MINUS^) productExpression)*;
@SuppressWarnings("NullAway") // Not null assertion performed in SpelNodeImpl constructor
private @Nullable SpelNodeImpl eatSumExpression() {
SpelNodeImpl expr = eatProductExpression();
while (peekToken(TokenKind.PLUS, TokenKind.MINUS, TokenKind.INC)) {
Token t = takeToken(); //consume PLUS or MINUS or INC
SpelNodeImpl rhExpr = eatProductExpression();
checkRightOperand(t, rhExpr);
if (t.kind == TokenKind.PLUS) {
expr = new OpPlus(t.startPos, t.endPos, expr, rhExpr);
}
else if (t.kind == TokenKind.MINUS) {
expr = new OpMinus(t.startPos, t.endPos, expr, rhExpr);
}
}
return expr;
}
// productExpression: powerExpr ((STAR^ | DIV^| MOD^) powerExpr)* ;
private @Nullable SpelNodeImpl eatProductExpression() {
SpelNodeImpl expr = eatPowerIncDecExpression();
while (peekToken(TokenKind.STAR, TokenKind.DIV, TokenKind.MOD)) {
Token t = takeToken(); // consume STAR/DIV/MOD
SpelNodeImpl rhExpr = eatPowerIncDecExpression();
checkOperands(t, expr, rhExpr);
if (t.kind == TokenKind.STAR) {
expr = new OpMultiply(t.startPos, t.endPos, expr, rhExpr);
}
// ...
}
return expr;
}
Feed it 1 + 2 * 3. eatSumExpression first calls eatProductExpression to get its left operand. That call eats only 1 and returns (the next token is +, not *, so the while is not entered). + is eaten, and eatProductExpression is called again; this time it takes 2, sees *, eats 3 as well, and returns OpMultiply(2, 3). So the result is OpPlus(1, OpMultiply(2, 3)). The deeper method eats as far as it can first, so it binds first. Parentheses are eatStartNode seeing ( and calling eatExpression() again (lines 913–930), so inside parentheses the chain starts from the top.
The comment above each method (sumExpression: productExpression ( (PLUS^ | MINUS^) productExpression)*;) is the grammar rule. This parser is those rules transcribed into methods by hand, which is why the Javadoc says "Handwritten SpEL parser" (line 85).
| Method | Handles | Where |
|---|---|---|
eatExpression |
=, ?: (Elvis), ? : (ternary) |
lines 173–214 |
eatLogicalOrExpression |
or, || |
lines 217–226 |
eatLogicalAndExpression |
and, && |
lines 229–238 |
eatRelationalExpression |
> < >= <= == != instanceof matches between |
lines 241–282 |
eatSumExpression |
+ - |
code above |
eatProductExpression |
* / % |
code above |
eatPowerIncDecExpression |
^, postfix ++ -- |
lines 324–340 |
eatUnaryExpression |
prefix ! + - ++ -- |
lines 344–372 |
eatPrimaryExpression |
a start node plus the dot-separated nodes after it → CompoundExpression |
lines 393–410 |
eatStartNode |
literals, parentheses, T(), null, new, names, #variables, @beans, selection/projection/indexer, {…} |
lines 543–566 |
Is a name a property or a method?
Both eatStartNode and eatDottedNode, which eats the node after a dot (lines 436–450), call this method.
private boolean maybeEatMethodOrProperty(boolean nullSafeNavigation) {
if (peekToken(TokenKind.IDENTIFIER)) {
Token methodOrPropertyName = takeToken();
SpelNodeImpl[] args = maybeEatMethodArgs();
if (args == null) {
// property
push(new PropertyOrFieldReference(nullSafeNavigation, methodOrPropertyName.stringValue(),
methodOrPropertyName.startPos, methodOrPropertyName.endPos));
return true;
}
// method reference
push(new MethodReference(nullSafeNavigation, methodOrPropertyName.stringValue(),
methodOrPropertyName.startPos, methodOrPropertyName.endPos, args));
return true;
}
return false;
}
An identifier followed by ( is a MethodReference; otherwise it is a PropertyOrFieldReference. The parser does not check whether the name exists. That is evaluation's job. T(…) is similar: maybeEatTypeReference builds a TypeReference only when the identifier is exactly T and a ( follows, and eatPossiblyQualifiedId gathers the IDENTIFIER and DOT tokens inside into one QualifiedIdentifier (lines 601–628, lines 759–778).
Example: printing the tree
Every node is a subclass of SpelNodeImpl (line 50) and has a children array (line 59). The spel.ast package has 53 files (including package-info.java). I took the root node from SpelExpression.getAST() and printed it recursively, children and all. This example was run on the release version, Spring Framework 7.0.9 (not on a self-built 7.1.0-SNAPSHOT, which is what this series reads).
// An example written for this post (not Spring's code)
SpelExpressionParser parser = new SpelExpressionParser();
Scores root = new Scores(7, null); // getA() = 7, getNickname() = null
StandardEvaluationContext ctx = new StandardEvaluationContext(root); // #root = root
for (String text : List.of("T(java.lang.Math).max(a, 3) + 1", "1 + 2 * 3", "(1 + 2) * 3",
"a > 5 and a < 10", "'a=' + a", "#root.a == a",
"nickname ?: '(별명 없음)'", "nickname?.length()", "{1, 2, 3}.?[#this > 1]")) {
System.out.printf("%-34s → %s%n", text, parser.parseExpression(text).getValue(ctx));
}
System.out.println();
for (String text : List.of("T(java.lang.Math).max(a, 3) + 1", "1 + 2 * 3")) {
SpelExpression expr = parser.parseRaw(text);
System.out.println("AST of \"" + text + "\" (toStringAST = " + expr.toStringAST() + ")");
printTree(expr.getAST(), 1); // prints each node's class name and toStringAST(), recursively
}
The examples print their headings and labels in Korean. In the output blocks of this English version those labels are translated; the values, the order, and anything printed by Spring are the actual output. Korean that is part of an expression stays as it is.
The output (the Korean string '(별명 없음)' means "(no nickname)").
T(java.lang.Math).max(a, 3) + 1 → 8
1 + 2 * 3 → 7
(1 + 2) * 3 → 9
a > 5 and a < 10 → true
'a=' + a → a=7
#root.a == a → true
nickname ?: '(별명 없음)' → (별명 없음)
nickname?.length() → null
{1, 2, 3}.?[#this > 1] → [2, 3]
AST of "T(java.lang.Math).max(a, 3) + 1" (toStringAST = (T(java.lang.Math).max(a,3) + 1))
OpPlus (T(java.lang.Math).max(a,3) + 1)
CompoundExpression T(java.lang.Math).max(a,3)
TypeReference T(java.lang.Math)
QualifiedIdentifier java.lang.Math
Identifier java
Identifier lang
Identifier Math
MethodReference max(a,3)
PropertyOrFieldReference a
IntLiteral 3
IntLiteral 1
AST of "1 + 2 * 3" (toStringAST = (1 + (2 * 3)))
OpPlus (1 + (2 * 3))
IntLiteral 1
OpMultiply (2 * 3)
IntLiteral 2
IntLiteral 3
The tree of our headline expression has OpPlus at the root; its left child is a CompoundExpression joining T(java.lang.Math) and max(a,3) with a dot, and its right child is IntLiteral 1. toStringAST() adds parentheses, so you can see the grouping with your own eyes.
Easy to get wrong
- Comparison operators cannot be chained.
eatSumExpressionhas awhile, buteatRelationalExpressionhas a singleif(lines 243–244). The grammar in the comment says(relationalOperator^ sumExpression)?with a question mark, meaning zero or one (line 240). Soa > b > cdoes not parse; join the two withandas in the example. ^has two meanings. In the tokenizer,^[is "select first" (SELECT_FIRST) and a lone^is power (POWER) (lines 157–164).- Parsing does not check names.
nickname?.length()returningnullis the result of?.(safe navigation) at evaluation time, not at parse time. A property name that does not exist also parses fine and fails at evaluation (section 3). - Nesting depth is limited too. Deeply nested parentheses or unary operators could overflow the stack with recursive calls, so this commit counts depth and throws past a default of 1,000 (
eatUnaryOperandlines 382–390,SpelParserConfigurationline 103). Both are@since 7.1, so 7.0.9 does not have them.
3. From a tree to a value — getValue, ExpressionState, EvaluationContext
Here is SpelExpression.getValue(EvaluationContext). The compilation branch at the top is left for section 5; only the last three lines matter now.
@Override
public @Nullable Object getValue(EvaluationContext context) throws EvaluationException {
Assert.notNull(context, "EvaluationContext must not be null");
CompiledExpression compiledAst = this.compiledAst;
if (compiledAst != null && context.isCompilationSupported()) {
// ...
}
ExpressionState expressionState = new ExpressionState(context, this.configuration);
Object result = this.ast.getValue(expressionState);
checkCompile(expressionState);
return result;
}
Two objects appear here. The EvaluationContext is shared across many evaluations; the ExpressionState lives for this one evaluation only. The Javadoc of ExpressionState spells out the difference: changes to it are not seen by other expressions, whereas changes to the EvaluationContext are (lines 40–45). What ExpressionState holds is the context, the root object, the configuration, and a stack of "the object currently before the dot" (contextObjects) (lines 55–74).
this.ast.getValue(state) calls the root node's getValueInternal, and each node calls its children's getValueInternal (SpelNodeImpl lines 113–115, line 205). Values travel as TypedValue, an object plus its type information (TypedValue lines 34–44).
The yellow box is the shared context; blue boxes are the steps of this one evaluation.
What a node asks the context
EvaluationContext is an interface that hands back, by kind, whatever a node needs to ask about.
| Method | Returns | Used by |
|---|---|---|
getRootObject() |
the root object | a bare name like a, and #root (line 73) |
getPropertyAccessors() |
accessors that read and write properties | PropertyOrFieldReference (lines 79–81) |
getMethodResolvers() |
resolvers that find a method | MethodReference (lines 105–107) |
getConstructorResolvers() |
resolvers that find a constructor | ConstructorReference (new) (lines 97–99) |
getTypeLocator() |
something that finds a class by name | TypeReference (T()) (line 118) |
getBeanResolver() |
something that finds a bean by name; may be null |
BeanReference (@bean) (line 112) |
getTypeConverter() |
something that converts a value's type | fitting the result type, fitting method arguments (line 123) |
lookupVariable(name) |
the #name variable |
VariableReference (line 172) |
Following our headline expression's tree from the top:
CompoundExpressionevaluates its pieces in turn, pushing each piece's value withpushActiveContextObjectso that it becomes the next piece's target (lines 52–86). TheClassobject thatT(java.lang.Math)evaluates to becomes the target ofmax(a,3).TypeReferencetakes the string"java.lang.Math"from itsQualifiedIdentifierchild and callsstate.findType()(lines 56–77), which is the context'sTypeLocator.findType()(ExpressionStatelines 179–181). The default implementation,StandardTypeLocator, registersjava.langas an import prefix in its constructor, soT(Math)works as a shorthand (lines 76–80, lines 117–130).MethodReferencefirst evaluates the argumentsaand3. While evaluating arguments it temporarily resets the target to the root (lines 254–260). That is why theainmax(a, 3)is a property of the root object, not ofMath. It then walks the context'sMethodResolverlist looking for a match (line 298) and throwsMETHOD_NOT_FOUNDif there is none (lines 220–231). The default resolver,ReflectiveMethodResolver, gathers the methods with that name and matches argument types; if they do not match exactly, it also asks the context'sTypeConverterwhether they could be converted (lines 116–123, line 179).PropertyOrFieldReferenceawalks thePropertyAccessorlist askingcanRead, and callsreadon the first one that says yes.
List<PropertyAccessor> accessorsToTry =
AccessorUtils.getAccessorsToTry(target, evalContext.getPropertyAccessors());
// Go through the accessors that may be able to resolve it. If they are a cacheable accessor then
// get the accessor and use it. If they are not cacheable but report they can read the property
// then ask them to read it.
try {
for (PropertyAccessor accessor : accessorsToTry) {
// First, attempt to find the property on the target object.
if (accessor.canRead(evalContext, target, name)) {
if (accessor instanceof ReflectivePropertyAccessor reflectivePropertyAccessor) {
accessor = reflectivePropertyAccessor.createOptimalAccessor(
evalContext, target, name);
}
this.cachedReadAccessor = accessor;
state.trackOperation();
return accessor.read(evalContext, target, name);
}
// ...
}
}
// ...
AccessorUtils.getAccessorsToTry puts accessors that specifically claim the target type first and accessors that accept any type last (comment, lines 59–66). The default accessor, ReflectivePropertyAccessor, reads a getter (getA(), isActive(), or the record-style a()) or a public field through reflection (Javadoc, lines 56–63). Once found, the accessor is stored in the node's cachedReadAccessor and used directly from the next evaluation on (line 83, lines 228–241). If nothing works, the result is PROPERTY_OR_FIELD_NOT_READABLE, that is "Property or field 'x' cannot be found on object of type 'y' - maybe not public or not valid?" (lines 289–295, SpelMessage lines 63–64).
OpPlustakes both children's values and, if both are numbers, adds them (the result follows the wider type, in the orderBigDecimal>double>float>BigInteger>long>int); if either is a string it concatenates; anything else goes to the context'sOperatorOverloader(lines 70–156). The branch that turned the example's'a=' + aintoa=7is lines 141–146. AnIntLiteraljust returns the value fixed when it was built (Literallines 50–53,IntLiterallines 35–45).
Where Part 14 connects — TypeConverter
If you ask for a result type, as in getValue(ctx, Integer.class), ExpressionUtils.convertTypedValue calls the context's TypeConverter at the very end (lines 49–64). SpEL's TypeConverter is a small interface with just canConvert and convertValue (TypeConverter lines 42, 58), and the default implementation, StandardTypeConverter, simply hands over to the ConversionService from Part 14. If none is given it uses DefaultConversionService.getSharedInstance() (lines 50–52); convertValue calls conversionService.convert(...) and wraps a ConversionException as TYPE_CONVERSION_ERROR (lines 79–89). In other words SpEL has no conversion rules of its own; it uses Part 14's 52 converters as they are.
Easy to get wrong
#thisand#rootare different.#rootis the root object;#thisis "the object currently before the dot" (VariableReferencelines 72–95). That is why#thiswas each element in turn in the example's{1, 2, 3}.?[#this > 1]. Selection opens a new scope per element withpushActiveContextObjectandenterScope(Selectionlines 186–187).- Reusing one
Expressionacross contexts needs care. Because nodes cache accessors and method executors, evaluating the sameExpressionfirst against aStandardEvaluationContextand later against a more restrictedSimpleEvaluationContextcan reuse what was cached under the permissive one. The Javadoc ofExpressionsays this is not supported and tells you to parse once per kind of context (lines 30–45). That is why the example in section 4 parses separately for each context. - There is a cap on the number of operations too. A node calls
state.trackOperation()whenever it does work, and reaching the configured maximum (default 10,000) throws (ExpressionStatelines 260–263,SpelParserConfigurationline 69). The Javadoc marks it@since 6.2.19.
4. StandardEvaluationContext and SimpleEvaluationContext — why two
The Javadoc of EvaluationContext says there are two default implementations (lines 29–39). The difference is in what each one hands back.
StandardEvaluationContext fills in a reflection-based default for everything it is asked about. The first time it is asked for accessors it creates a ReflectivePropertyAccessor; for constructor resolvers a ReflectiveConstructorResolver; for method resolvers a ReflectiveMethodResolver (lines 568–606). TypeLocator and TypeConverter likewise become a StandardTypeLocator and a StandardTypeConverter on first use (lines 379–384, lines 398–403). Only the BeanResolver has no default and must be set with setBeanResolver (line 112, lines 343–353). The Javadoc says this context "exposes the complete SpEL language, backed by reflection" and must never be used to evaluate an expression from an untrusted source (lines 67–73): it can reach any class, T(java.lang.Runtime) included.
SimpleEvaluationContext (since 4.3.15, line 119) is the opposite. You build it with a builder, putting in only what you choose, and whatever you did not choose is empty or blocked. This is where type references get blocked.
private static final TypeLocator typeNotFoundTypeLocator = typeName -> {
throw new SpelEvaluationException(SpelMessage.TYPE_NOT_FOUND, typeName);
};
// ...
/**
* {@code SimpleEvaluationContext} does not support use of type references.
* @return {@code TypeLocator} implementation that raises a
* {@link SpelEvaluationException} with {@link SpelMessage#TYPE_NOT_FOUND}
*/
@Override
public TypeLocator getTypeLocator() {
return typeNotFoundTypeLocator;
}
T(…) parses fine; what happens is that when TypeReference asks the TypeLocator at evaluation time, the locator that answers always throws TYPE_NOT_FOUND. Everything else works the same way.
| Asked for | StandardEvaluationContext |
SimpleEvaluationContext |
|---|---|---|
PropertyAccessor |
ReflectivePropertyAccessor |
only what you gave the builder. forReadOnlyDataBinding() gives one read-only DataBindingPropertyAccessor (lines 356–358) |
MethodResolver |
ReflectiveMethodResolver |
none by default. withInstanceMethods() adds a DataBindingMethodResolver (lines 464–467) |
ConstructorResolver |
ReflectiveConstructorResolver |
none (the interface's default empty list) |
TypeLocator |
StandardTypeLocator |
one that always throws TYPE_NOT_FOUND |
BeanResolver |
null (used if you set one) |
always null (lines 211–213) |
TypeConverter |
StandardTypeConverter |
StandardTypeConverter (replaceable) (line 163) |
assignment = ++ -- |
allowed | blocked by forReadOnlyDataBinding() (lines 304–306, Assign lines 44–45) |
| compilation (section 5) | allowed | not by default (lines 317–319) |
The two contexts answer the same questions differently. Green boxes are expressions that were evaluated, red boxes are ones that were blocked; the results come from the real output of the example below.
DataBindingPropertyAccessor is a subclass of ReflectivePropertyAccessor that excludes methods declared on Object and Class, and ClassLoader targets, so "technical" properties like getClass() are out of reach (line 50, lines 61–65). DataBindingMethodResolver has the same restriction and also drops static methods (lines 59–65). The builder throws IllegalArgumentException if you try to pass in a raw ReflectivePropertyAccessor or ReflectiveMethodResolver (lines 336–344, lines 445–454).
So the reason there are two is purpose. Where Spring itself evaluates expressions, such as container configuration, it needs T(), @bean and new, so it uses Standard. Where the data comes from users, such as form values or filter conditions, it uses Simple, which opens little more than property reads. The Javadoc of Simple says its target is "data binding scenarios" (lines 44–46).
Example: the same expressions in three contexts
With a Person (getter and setter for name) as the root, six expressions were run against a StandardEvaluationContext, a SimpleEvaluationContext.forReadOnlyDataBinding(), and the latter with withInstanceMethods() added. On failure, the constant name from SpelEvaluationException.getMessageCode() is printed. Run on Spring Framework 7.0.9.
// An example written for this post (not Spring's code)
EvaluationContext standard = new StandardEvaluationContext();
EvaluationContext readOnly = SimpleEvaluationContext.forReadOnlyDataBinding().build();
EvaluationContext withMethods = SimpleEvaluationContext.forReadOnlyDataBinding().withInstanceMethods().build();
System.out.printf("%-28s | %-22s | %-30s | %s%n", "expression", "Standard", "Simple(readOnly)", "Simple + withInstanceMethods");
for (String text : List.of("name", "name.length()", "T(java.lang.Math).max(1, 2)",
"new java.util.ArrayList()", "@someBean", "name = 'Lee'")) {
// the source warns against using one Expression with two kinds of context, so parse once per context
System.out.printf("%-28s | %-22s | %-30s | %s%n", text,
eval(parser.parseExpression(text), standard, kim),
eval(parser.parseExpression(text), readOnly, kim),
eval(parser.parseExpression(text), withMethods, kim));
}
System.out.println("name is now " + kim.getName());
The output (labels translated, as above).
expression | Standard | Simple(readOnly) | Simple + withInstanceMethods
name | Kim | Kim | Kim
name.length() | 3 | METHOD_NOT_FOUND | 3
T(java.lang.Math).max(1, 2) | 2 | TYPE_NOT_FOUND | TYPE_NOT_FOUND
new java.util.ArrayList() | ArrayList | CONSTRUCTOR_NOT_FOUND | CONSTRUCTOR_NOT_FOUND
@someBean | NO_BEAN_RESOLVER_REGISTERED | NO_BEAN_RESOLVER_REGISTERED | NO_BEAN_RESOLVER_REGISTERED
name = 'Lee' | Lee | NOT_ASSIGNABLE | NOT_ASSIGNABLE
name is now Lee
It matches the table. name.length() is METHOD_NOT_FOUND without a method resolver and comes back to life with withInstanceMethods(); T() and new stay blocked; assignment is NOT_ASSIGNABLE. The last line means name = 'Lee' under Standard really changed the object.
Easy to get wrong
@beandoes not just work underStandardeither.BeanReferencethrowsNO_BEAN_RESOLVER_REGISTEREDif the context'sBeanResolverisnull(lines 60–66).@otherBeanworks inside the container because section 6 plugs in aBeanFactoryResolver.Simpledoes not mean safe. Its Javadoc says the restriction is "best-effort", and that read-only only blocks assignment; it does not guarantee a getter is free of side effects (lines 91–98). The example in theReflectivePropertyAccessorJavadoc is thatFile.delete()has the same shape as a getter (lines 65–68).SpelExpression.getValue()without a context meansStandard.getEvaluationContext()creates anew StandardEvaluationContext()if there is none (lines 115–120).
5. Compiler mode — from a tree to bytecode
Section 3's approach walks the tree and calls reflection on every evaluation. For places that evaluate the same expression tens of thousands of times (the Javadoc names Spring Integration), there has been a compiler since 4.1 (SpelCompilerMode lines 23–38). There are three modes.
| Mode | Behaviour | Where |
|---|---|---|
OFF |
never compile. The default | lines 46–51 |
IMMEDIATE |
compile as soon as possible (the Javadoc says "typically after the first interpreted evaluation"; the code, as shown below, does it after the second). If the compiled expression fails, the caller gets the exception | lines 53–61 |
MIXED |
compile after a number of interpreted runs. On failure, silently fall back to interpreting and try again later | lines 63–78 |
The mode goes into SpelParserConfiguration; if you do not set one, the system property spring.expression.compiler.mode is consulted, and failing that it is OFF (line 114, lines 147–153, line 566). In this commit (7.1) the constructors are @Deprecated in favour of SpelParserConfiguration.builder() (lines 30–36, lines 210–213).
When does it compile — checkCompile
The last line of section 3's getValue was checkCompile.
private void checkCompile(ExpressionState expressionState) {
this.interpretedCount.incrementAndGet();
if (!expressionState.getEvaluationContext().isCompilationSupported()) {
return;
}
SpelCompilerMode compilerMode = expressionState.getConfiguration().getCompilerMode();
if (compilerMode != SpelCompilerMode.OFF) {
if (compilerMode == SpelCompilerMode.IMMEDIATE) {
if (this.interpretedCount.get() > 1) {
compileExpression();
}
}
else {
// compilerMode = SpelCompilerMode.MIXED
if (this.interpretedCount.get() > INTERPRETED_COUNT_THRESHOLD) {
compileExpression();
}
}
}
}
It counts interpreted runs: IMMEDIATE compiles after the 2nd, MIXED after more than INTERPRETED_COUNT_THRESHOLD = 100 (line 66). The isCompilationSupported() check before that is @since 7.0.9, and it is where section 4's SimpleEvaluationContext answers false (EvaluationContext lines 188–208).
The yellow box is where the compiled form is kept; the red box is the exception that reaches the caller in IMMEDIATE mode. Line numbers refer to SpelExpression.
What gets generated — SpelCompiler
compileExpression() obtains the SpelCompiler for its class loader (there is one per class loader) and calls compile(ast) (lines 506–552, SpelCompiler lines 248–256). The compiler first asks the root node isCompilable(), and if so builds a class with createExpressionClass (lines 101–122).
private @Nullable Class<? extends CompiledExpression> createExpressionClass(SpelNodeImpl expressionToCompile) {
// Create class outline:
// org.springframework.expression.spel.generated.CompiledExpression##### extends org.springframework.expression.spel.CompiledExpression
String className = "org/springframework/expression/spel/generated/CompiledExpression" + getNextSuffix();
String evaluationContextClass = "org/springframework/expression/EvaluationContext";
ClassWriter cw = new ExpressionClassWriter();
cw.visit(V17, ACC_PUBLIC, className, null, "org/springframework/expression/spel/CompiledExpression", null);
// ...
// Create getValue() method
mv = cw.visitMethod(ACC_PUBLIC, "getValue",
"(Ljava/lang/Object;L" + evaluationContextClass + ";)Ljava/lang/Object;", null,
new String[] {"org/springframework/expression/EvaluationException"});
mv.visitCode();
CodeFlow cf = new CodeFlow(className, cw);
// Ask the expression AST to generate the body of the method
try {
expressionToCompile.generateCode(mv, cf);
}
catch (IllegalStateException ex) {
// ...
return null;
}
// ...
byte[] data = cw.toByteArray();
// ...
return loadClass(StringUtils.replace(className, "/", "."), data);
}
Using ASM's ClassWriter it creates one class extending CompiledExpression (which has a single abstract method, getValue(target, context), lines 32–39), and the root node's generateCode fills in the body of that getValue. Nodes call their children's generateCode, so the tree is walked just as before, but this time instead of producing a value, each node writes the bytecode that produces the value. OpPlus, for example, emits StringBuilder.append for strings and instructions like IADD for numbers (lines 241–275). The resulting byte array is defined through a dedicated ChildClassLoader, and a fresh loader is started every 100 classes so that piled-up classes do not pin memory (line 71, lines 221–237).
Why it must interpret first — exitTypeDescriptor
SpelNode.isCompilable() defaults to false, and each node overrides it with, roughly, "the result type is known and the children are compilable" (lines 125–127). That "result type" is SpelNodeImpl.exitTypeDescriptor, and as its Javadoc says, a literal knows it immediately but a property access or method call only knows after one evaluation (lines 63–72). From the string name alone you cannot tell whether it is a String or an int. PropertyOrFieldReference.isCompilable is true only when the accessor cached in section 3 is a CompilablePropertyAccessor that says it is compilable (lines 369–372), and the optimal accessor created by ReflectivePropertyAccessor says so only when both the member and its declaring class are public (lines 698–701). MethodReference additionally requires that the target is not a proxy, that no argument conversion happened, and that the method is public (lines 360–378). Compiled code contains no checks, so if an object of a different type from the one seen during interpretation comes along it fails, much like a ClassCastException. The SpelCompiler Javadoc gives that as the reason it is not on by default (lines 46–61).
Example: watching the moment of compilation
compiledAst is a private field, so it is read through reflection. Run on Spring Framework 7.0.9, which does not yet have builder(), hence the constructor.
// An example written for this post (not Spring's code)
SpelParserConfiguration config = new SpelParserConfiguration(
SpelCompilerMode.IMMEDIATE, CompileExample.class.getClassLoader());
SpelExpression expr = new SpelExpressionParser(config).parseRaw("name.length() * 2");
Person kim = new Person("Kim"); // public static class, public getName()
for (int i = 1; i <= 3; i++) {
System.out.println("① IMMEDIATE, getValue #" + i + " = " + expr.getValue(kim)
+ ", compiledAst = " + compiledClassName(expr));
}
SpelExpression plain = new SpelExpressionParser().parseRaw("name.length() * 2"); // OFF
plain.getValue(kim); plain.getValue(kim);
System.out.println("② OFF, after 2 interpreted runs compiledAst = " + compiledClassName(plain));
System.out.println(" compileExpression() = " + plain.compileExpression() + ", compiledAst = " + compiledClassName(plain));
SpelExpression fresh = new SpelExpressionParser().parseRaw("name.length() * 2");
System.out.println("③ compileExpression() before any run = " + fresh.compileExpression());
SpelExpression simple = new SpelExpressionParser(config).parseRaw("name.length() * 2");
SimpleEvaluationContext readOnly = SimpleEvaluationContext.forReadOnlyDataBinding().withInstanceMethods().build();
for (int i = 1; i <= 3; i++) { simple.getValue(readOnly, kim); }
System.out.println("④ IMMEDIATE + SimpleEvaluationContext, after 3 runs compiledAst = " + compiledClassName(simple));
The output (labels translated, as above).
① IMMEDIATE, getValue #1 = 6, compiledAst = null
① IMMEDIATE, getValue #2 = 6, compiledAst = org.springframework.expression.spel.generated.CompiledExpression00001
① IMMEDIATE, getValue #3 = 6, compiledAst = org.springframework.expression.spel.generated.CompiledExpression00001
② OFF, after 2 interpreted runs compiledAst = null
compileExpression() = true, compiledAst = org.springframework.expression.spel.generated.CompiledExpression00002
③ compileExpression() before any run = false
④ IMMEDIATE + SimpleEvaluationContext, after 3 runs compiledAst = null
In ①, exactly as checkCompile's > 1 says, CompiledExpression00001 appeared after the second interpreted run. The five-digit number in the class name is getNextSuffix() from the code above (lines 124–126). ② shows that even in OFF mode you can call compileExpression() yourself; ③ that an expression never interpreted has no exitTypeDescriptor and cannot be compiled; ④ that under a SimpleEvaluationContext nothing is compiled even in IMMEDIATE mode.
Easy to get wrong
- Some expressions do not compile even with the mode on. Because of the conditions above: a getter on a non-
publicclass, selection or projection using#this(the comment inVariableReferencesays "not a compilable expression", lines 77–83), a method call that needs argument conversion, and aSimpleEvaluationContext.MIXEDthen quietly keeps interpreting, and after more than 100 failures stops trying (line 69, lines 512–515). - The container's
#{…}is not compiled (with default settings). Section 6'sStandardBeanExpressionResolverputs only the class loader and the maximum length into its configuration and leaves the mode alone (lines 121–124), so without the system property it isOFF. - When a compiled expression fails, the outcome depends on the mode. At the top of
getValue,MIXEDclearscompiledAstand goes back to interpreting, whileIMMEDIATEthrowsEXCEPTION_RUNNING_COMPILED_EXPRESSION(lines 258–274).
6. Connecting to the container — the path a @Value("#{…}") takes
So far it has all been spring-expression on its own. Now for where the container picks it up.
Left: when the container starts. Right: when one @Value is injected. Yellow boxes are caches.
① At start-up: plug in the resolver and register three names
This is the second line of prepareBeanFactory, the third step of refresh() from Part 7.
protected void prepareBeanFactory(ConfigurableListableBeanFactory beanFactory) {
// Tell the internal bean factory to use the context's class loader etc.
beanFactory.setBeanClassLoader(getClassLoader());
beanFactory.setBeanExpressionResolver(new StandardBeanExpressionResolver(beanFactory.getBeanClassLoader()));
// ...
// Register default environment beans.
if (!beanFactory.containsLocalBean(ENVIRONMENT_BEAN_NAME)) {
beanFactory.registerSingleton(ENVIRONMENT_BEAN_NAME, getEnvironment());
}
if (!beanFactory.containsLocalBean(SYSTEM_PROPERTIES_BEAN_NAME)) {
beanFactory.registerSingleton(SYSTEM_PROPERTIES_BEAN_NAME, getEnvironment().getSystemProperties());
}
if (!beanFactory.containsLocalBean(SYSTEM_ENVIRONMENT_BEAN_NAME)) {
beanFactory.registerSingleton(SYSTEM_ENVIRONMENT_BEAN_NAME, getEnvironment().getSystemEnvironment());
}
// ...
}
The systemProperties in #{systemProperties['os.name']} is not a magic word; it is the name of a singleton bean registered right here. The constants are "environment", "systemProperties" and "systemEnvironment" (ConfigurableApplicationContext lines 90, 96, 102). The Javadoc of StandardBeanExpressionResolver says the same: "All beans in the containing BeanFactory are made available as predefined variables with their common bean name" (lines 49–51). A bare BeanFactory has no resolver (the beanExpressionResolver field is null, AbstractBeanFactory line 133), so #{…} stays a literal string there. It is the ApplicationContext that plugs one in.
② Injecting one @Value: ${} first, #{} second
This is step 2 of doResolveDependency, which we saw in Part 14.
// Step 2: pre-defined value or expression, for example, from @Value
Object value = getAutowireCandidateResolver().getSuggestedValue(descriptor);
if (value != null) {
if (value instanceof String strValue) {
String resolvedValue = resolveEmbeddedValue(strValue);
BeanDefinition bd = (beanName != null && containsBean(beanName) ?
getMergedBeanDefinition(beanName) : null);
value = evaluateBeanDefinitionString(resolvedValue, bd);
}
TypeConverter converter = (typeConverter != null ? typeConverter : getTypeConverter());
try {
return converter.convertIfNecessary(value, type, descriptor.getTypeDescriptor());
}
Three steps, in order. resolveEmbeddedValue runs the registered StringValueResolvers in turn to resolve ${…} (AbstractBeanFactory lines 966–978). If nobody registered one, finishBeanFactoryInitialization adds a default that calls Environment.resolvePlaceholders (AbstractApplicationContext lines 961–966). Then evaluateBeanDefinitionString computes the #{…}, and finally Part 14's convertIfNecessary fits the result to the field type.
protected @Nullable Object evaluateBeanDefinitionString(@Nullable String value, @Nullable BeanDefinition beanDefinition) {
if (this.beanExpressionResolver == null) {
return value;
}
Scope scope = null;
if (beanDefinition != null) {
String scopeName = beanDefinition.getScope();
if (scopeName != null) {
scope = getRegisteredScope(scopeName);
}
}
return this.beanExpressionResolver.evaluate(value, new BeanExpressionContext(this, scope));
}
BeanExpressionContext is a small object bundling the bean factory and the scope; its containsObject and getObject look in the bean factory first and then ask the scope's resolveContextualObject (lines 29–66). This becomes the expression's root object. Strings that arrive through XML or PropertyValues reach the same evaluateBeanDefinitionString via BeanDefinitionValueResolver (lines 311–313), and even a bean's class name can be an expression (the comment says "supported as of 4.2", lines 1617–1622).
③ StandardBeanExpressionResolver.evaluate — four accessors and two caches
@Override
public @Nullable Object evaluate(@Nullable String value, BeanExpressionContext beanExpressionContext) throws BeansException {
if (!StringUtils.hasLength(value)) {
return value;
}
try {
Expression expr = this.expressionCache.computeIfAbsent(value, expression ->
this.expressionParser.parseExpression(expression, this.beanExpressionParserContext));
EvaluationContext evalContext = this.evaluationCache.computeIfAbsent(beanExpressionContext, bec -> {
ConfigurableBeanFactory beanFactory = bec.getBeanFactory();
StandardEvaluationContext sec = new StandardEvaluationContext(bec);
sec.addPropertyAccessor(new BeanExpressionContextAccessor());
sec.addPropertyAccessor(new BeanFactoryAccessor());
sec.addPropertyAccessor(new org.springframework.expression.spel.support.MapAccessor());
sec.addPropertyAccessor(new EnvironmentAccessor());
sec.setBeanResolver(new BeanFactoryResolver(beanFactory));
sec.setTypeLocator(new StandardTypeLocator(beanFactory.getBeanClassLoader()));
sec.setTypeConverter(new StandardTypeConverter(() -> {
ConversionService cs = beanFactory.getConversionService();
return (cs != null ? cs : DefaultConversionService.getSharedInstance());
}));
customizeEvaluationContext(sec);
return sec;
});
return expr.getValue(evalContext);
}
catch (Throwable ex) {
throw new BeanExpressionException("Expression parsing failed", ex);
}
}
This one method threads sections 1 to 4 together.
- Parsing is in template mode.
beanExpressionParserContexthasisTemplate()returningtrue, prefix#{and suffix}(lines 72–75, lines 88–101). For a template,TemplateAwareExpressionParser.parseExpressiongoes toparseTemplateinstead of section 1'sdoParseExpression(lines 55–65). That splits the string at#{and}, turns the text outside intoLiteralExpressions, parses only the inside as SpEL, and bundles two or more pieces into aCompositeStringExpression(lines 68–80, lines 100–143). The value of a composite is the pieces evaluated to strings and joined (CompositeStringExpressionlines 70–79). This is why section 1 said#{is not a token. The same shape with a configurable prefix and suffix isTemplateParserContext(lines 38–50). - With no
#{at all, the whole string becomes a singleLiteralExpression(lines 136–139) and the value comes back unchanged.@Value("8080")does go through this method; nothing happens to it. That is what "a plain"8080"passes straight through" at the top of this post really means. - There are two caches. The same string reuses its parsed
ExpressionfromexpressionCache(line 84), and the sameBeanExpressionContext(same bean factory and same scope; that is howequalsis defined, lines 69–73) reuses itsStandardEvaluationContextfromevaluationCache(line 86). - The root is the
BeanExpressionContext, with four extra accessors. TheotherBeaninotherBean.countis a property read on the root, andBeanExpressionContextAccessoranswers "if the target is aBeanExpressionContextandcontainsObject(name), I can read it", returning the bean fromgetObject(lines 36–45). That is the device by which a bean name becomes a property name.BeanFactoryAccessordoes the same when the target is aBeanFactory(lines 36–45),MapAccessorexposesMapkeys as properties (MapAccessorlines 64–70), andEnvironmentAccessorexposesEnvironment.getProperty(name)as a property (lines 35–54).addPropertyAccessorinserts before the defaultReflectivePropertyAccessor(lines 193–195, lines 608–610). @otherBeanis resolved byBeanFactoryResolverwithbeanFactory.getBean(beanName)(lines 48–55). This is the slot that was empty in section 4.T()uses the bean class loader, and conversion uses the container'sconversionService. TheStandardTypeLocatorgetsbeanFactory.getBeanClassLoader(), and theStandardTypeConvertergets aSupplierthat returns theConversionServiceplugged in by Part 14 section 6 if there is one, and the sharedDefaultConversionServiceotherwise.
otherBean (a name) and @otherBean (a bean reference) reach the same bean by different routes. Yellow boxes are the accessor and resolver the container plugged in; 42 and 21 are the real output of the example below.
Example: eight @Values
Run on Spring Framework 7.0.9. OtherBean is a bean whose getCount() returns 21.
// An example written for this post (not Spring's code)
public static class Holder {
@Value("${os.name}") String placeholder; // ${…}: resolved by the Environment (not SpEL)
@Value("#{systemProperties['os.name']}") String spel; // #{…}: computed by SpEL
@Value("#{environment['os.name']}") String viaEnvironment; // the environment bean + EnvironmentAccessor
@Value("#{'${os.name}'.toUpperCase()}") String both; // ${…} is resolved first, then SpEL computes the result
@Value("#{otherBean.count * 2}") int viaName; // a bean name as a property name
@Value("#{@otherBean.count}") int viaBeanRef; // @name: goes through the BeanResolver
@Value("#{T(java.lang.Math).max(otherBean.count, 100)}") int typeRef; // the container's context allows T() too
@Value("port #{8080 + 1}") String template; // a template mixed with text: the result is a string
}
The output.
${os.name} = Windows 11
#{systemProperties['os.name']} = Windows 11
#{environment['os.name']} = Windows 11
#{'${os.name}'.toUpperCase()} = WINDOWS 11
#{otherBean.count * 2} = 42
#{@otherBean.count} = 21
#{T(java.lang.Math).max(...)} = 100
port #{8080 + 1} = port 8081
BeanExpressionResolver = StandardBeanExpressionResolver
The fourth line shows the order from ②. ${os.name} became Windows 11 first, giving #{'Windows 11'.toUpperCase()}, and then SpEL computed it. otherBean.count * 2 and T(java.lang.Math).max(…) work exactly as the Standard column of section 4's table says, and port 8081 on the last line is the template's CompositeStringExpression. The 100 for typeRef came out as an Integer and went through Part 14's convertIfNecessary into the int field.
Easy to get wrong
- There is no code for
#{}inside${}. The order in the code above is fixed,resolveEmbeddedValue→evaluateBeanDefinitionString:${…}is resolved first and SpEL receives the result. Nothing on this path handles the reverse order. (I did not run the reverse case.) - For scoped beans, the scope's objects are reachable by name too. If the bean factory does not have it,
BeanExpressionContext.getObjectasksscope.resolveContextualObject(key)(lines 56–66). This is the route by which names likerequestandsessionarrive in web scopes; not run in this post. - The container sets its own maximum expression length. The property is
spring.context.expression.maxLength; without it, section 1's 10,000 applies (line 69, lines 198–214). - There are two
MapAccessors.context.expression.MapAccessoris@Deprecatedsince 7.0 and merely extendsspel.support.MapAccessor(lines 28–31). That is why theevaluatecode above spells out the package name to use the new one.
If you want to go deeper
A lot was left out to keep the main thread visible. Here are names and locations only. How selection (?[], ^[], $[]) and projection (![]) open a scope per element (Selection lines 140–160). The indexer […], which accepts arrays, lists, maps, strings and objects, and IndexAccessor, added in 6.2 (Indexer lines 55–64). registerFunction for #function() (StandardEvaluationContext lines 493–507). autoGrowNullReferences, which creates null properties on the fly, and auto-growing collections (SpelParserConfiguration line 56, lines 160–164). OperatorOverloader, which opens up operators between user types, and TypeComparator, which handles comparison (EvaluationContext lines 128–134). Safety limits such as the cap on the size of a power result (line 79). CodeFlow, the helper for bytecode generation (line 47), and CompilablePropertyAccessor (lines 31–51). And MethodBasedEvaluationContext (line 45), which lets @Cacheable(key = "#id") and @EventListener(condition = "…") refer to method arguments as #p0 or #id, together with CachedExpressionEvaluator (line 37), which caches those expressions; both are covered in Part 27.
Recap
| Concept | In one line |
|---|---|
SpelExpressionParser |
The thread-safe entry point. Creates a new, non-thread-safe InternalSpelExpressionParser per parse |
Tokenizer |
Reads one character at a time into a Token list. $ is an identifier character; #{ is not a token |
| Recursive descent parser | eatExpression → … → eatStartNode. The deeper the method, the tighter it binds = precedence |
| Tree (AST) | The SpelNodeImpl subclasses in spel.ast. A name followed by ( is a MethodReference, otherwise a PropertyOrFieldReference |
getValue |
Creates an ExpressionState (for this evaluation) and calls the root node's getValueInternal recursively |
EvaluationContext |
What nodes ask: accessors, method resolvers, TypeLocator, BeanResolver, TypeConverter, variables |
TypeConverter |
Part 14's ConversionService, wrapped as is (StandardTypeConverter) |
StandardEvaluationContext |
Fills in every reflection-based default. Never for untrusted expressions |
SimpleEvaluationContext |
Opens only what you chose. T() is always TYPE_NOT_FOUND, no @bean or new, and read-only also blocks assignment |
| Compiler mode | OFF (default), IMMEDIATE (from the 2nd run), MIXED (from the 101st). Generates a CompiledExpression subclass with ASM, using types learned while interpreting |
The path of #{…} |
resolveEmbeddedValue (${}) → evaluateBeanDefinitionString → StandardBeanExpressionResolver.evaluate → convertIfNecessary |
| The container's context | Root = BeanExpressionContext, four accessors that turn bean names into properties, BeanFactoryResolver, the bean class loader, the container's ConversionService |
What to look at next
With this post, three of the four units of Phase 3 (foundations) are done: reading types and annotations in Part 13, type conversion in Part 14, and expressions here. The remaining one is the set of tools whose names only flashed past in those three posts. Utilities such as ClassUtils, ReflectionUtils, StringUtils and Assert, which almost every Spring class uses, and AOT, which does at build time what used to be done with reflection at run time. They are the subject of the next post, Part 16: Utilities and AOT — the Toolbox Everyone Uses, and Computing Ahead of Time.
- Part 1: How I Read It with AI, and the Map
- Part 2: The Big Map — Modules and Packages
- Part 5: How @Autowired Picks the Bean to Inject — Dependency Resolution
- Part 7: The Twelve Steps of refresh() — the Order in Which the Container Starts
- Part 12: Events, Environment, Resource — Three Abstractions Around the Container
- Part 13: ResolvableType and MergedAnnotations — How Spring Reads Types and Annotations
- Part 14: Type Conversion and BeanWrapper — From a String to an Object's Property