“When I am working on a problem, I never think about beauty. I think only of how to solve the problem. But when I have finished, if the solution is not beautiful, I know it is wrong.”
R Buckminster Fuller
In the first article on FreshMarker performance, some of the template engine’s speed problems were addressed. The benchmark showed a tripling of the speed for these adjustments. In this article, the use of Reflections will be removed, which is certainly also of interest outside of a template engine.
Reflections is part of the Java API and makes it possible to determine and manipulate information about classes, methods, fields and constructors at runtime, even if they are not known at compile time. This allows developers to create instances of classes, call methods and change fields without knowing the name of the classes or their members at compile time. Reflections is often used in frameworks and libraries that need to provide flexible and generic functionality, such as FreshMarker.
In addition to the many possibilities that Reflections offers, there are also some significant disadvantages. One of the main disadvantages that concerns us here is the loss of performance, as Reflections is generally slower than direct code access, especially for frequent and complex operations.
Reflections is used within FreshMarker for Built-Ins and access to Java Beans and Records.
Built-ins are methods that can be called on instances of the model classes. There are built-ins for the TemplateString
class such as uppercase
and kebab-case
, counter
and item_parity
for the TemplateSequenceLooper
class and many more.
So far, there have been two approaches to defining Build-Ins. The use of Reflections and the use of Lambdas.
The use of Reflections is based on public static
methods whose first parameter is the instance on which the Build-In operates. The return value is obviously the result of the Build-In. Additional parameters can be Built-In parameters of type TemplateObject
or the ProcessContext
.
The following are four simple Build-Ins for the TemplateSequenceLooper
and TemplateHashLooper
classes.
@BuiltInMethod public static TemplateNumber counter(TemplateSequenceLooper value) { return value.getCounter(); } @BuiltInMethod public static TemplateNumber counter(TemplateHashLooper value) { return value.getCounter(); } @BuiltInMethod public static TemplateString itemParity(TemplateSequenceLooper value) { return (TemplateString) value.cycle(ITEM_PARITYTY); } @BuiltInMethod public static TemplateString itemParity(TemplateHashLooper value) { return (TemplateString) value.cycle(ITEM_PARITYTY); }
To ensure that the Built-In methods are recognized as such, they are annotated with @BuildInMethod
. As all four Built-Ins do not require any further parameters, only one parameter of the type TemplateSequenceLooper
or TemplateHashLooper
is passed.
What is expensive about this solution is not only the invoke
method on the Method
instance for the respective method, but also the provision of the parameters.
public interface BuiltInFunction { TemplateObject apply(TemplateObject value, List<TemplateObject> parameters, ProcessContext context); }
FreshMarker calls the apply
method of BuiltInFunction
for a Built-In call. The parameter array for the invoke
method must then first be provided from its parameters. This is an inexpensive operation for the example Built-Ins without parameters, but not insignificant if there are several parameters.
To remove the Reflections approach, all you have to do is convert all Built-In methods into Lambdas.
builtIns.put(HASH.of("counter"), (x, y, e) -> ((TemplateHashLooper) x).getCounter()); builtIns.put(SEQUENCE.of("counter"), (x, y, e) -> ((TemplateSequenceLooper) x).getCounter()); builtIns.put(HASH.of("item_parity"), (x, y, e) -> ((TemplateHashLooper) x).cycle(ITEM_PARITYTY)); builtIns.put(SEQUENCE.of("item_parity"), (x, y, e) -> ((TemplateSequenceLooper) x).cycle(ITEM_PARITYTY));
The Map builtIns
contains instances of the type BuiltInFunction
as values. These can, like these four here, be written as lambda expressions. With a little effort, all Built-In methods could be rewritten as Lambdas and the call to Built-Ins no longer contains any Reflections.
The solution for accessing Java Beans and Records without using Reflections also has something to do with Lambdas, but not as simple as with Built-Ins.
Java Beans and Records are interpreted as a Hash in FreshMarker. A Hash is a data structure that maps names to values. For Java developers, this means a Map with String
instances as keys.
FreshMarker‘s data model does not care whether it is a Java Bean, a Record or a Map. All three are accessed via the Map
interface in the TeamplateBean
class. A BaseReflectionsMap
instance is created for Java Beans and Records, which controls access to the getter methods.
public class TemplateBeanProvider { private final Map<Class<?>, Map<String, Method>> methodBeans = new HashMap<>(); public Map<String, Object> provide(Object bean, BaseEnvironment environment) { final Map<String, Method> methods = methodBeans.computeIfAbsent(bean.getClass(), b -> collectMethods(bean)); return new BaseReflectionsMap(methods, environment, bean); } private Map<String, Method> collectMethods(Object bean) { try { BeanInfo beanInfo = Introspector.getBeanInfo(bean.getClass(), Object.class); return Stream.of(beanInfo.getPropertyDescriptors()).collect(toMap(FeatureDescriptor::getName, PropertyDescriptor::getReadMethod)); } catch (IntrospectionException e) { throw new ProcessException(e.getMessage(), e); } } }
The TemplateBeanProvider
creates the Map for Java Beans by using the Introspector
to read the getter methods of the Java Bean. A corresponding class also exists for the Records.
An alternative to the TemplateBeanProvider
is shown here. Access to a getter method is realized via the Getter
interface.
public interface Getter { Object get(Object instance); }
For a Java Bean Example
with the attribute value
, there is access via the ExampleValueGetter
.
public class ExampleValueGetter implements Getter { @Override public Object get(Object instance) { return ((Example)instance).getValue(); } } Getter getter = new ExampleValueGetter();
Or as a shorter lambda expression.
Getter getter = x -> ((Example)x).getValue()
The value can then be read out with getter.get(new Example(42));
.
However, no Lambdas can be created for Java Beans and Records to read the attributes of the classes in FreshMarker because the classes are not necessarily known at compile time. Fortunately, this is not correct. Since Java 8, lambdas can also be created at runtime.
The LambdaMetafactory
is a special class that is used to create lambda expressions and method references at runtime. It makes it possible to create dynamically implemented functionalities that would otherwise have to be defined at compile time. This class uses the Method Handles API to enable more efficient and secure dynamic method calls.
Another provider that uses the LambdaMetafactory
is shown below.
public class TemplateMapGetterProvider { private final Function<Class<?>, Map<String, Method>> methodSupplier; public TemplateMapGetterProvider(Function<Class<?>, Map<String, Method>> methodSupplier) { this.methodSupplier = methodSupplier; } public interface Getter { Object get(Object instance); } private final Map<Class<?>, Map<String, Getter>> methodBeans = new HashMap<>(); public Map<String, Object> provide(Object bean, BaseEnvironment environment) { final Map<String, Getter> methods = methodBeans.computeIfAbsent(bean.getClass(), b -> collectGetters(bean)); return new BaseGetterMap(methods, environment, bean); } private Map<String, Getter> collectGetters(Object bean) { final MethodHandles.Lookup lookup = MethodHandles.lookup(); Map<String, Getter> result = new HashMap<>(); for (Entry<String, Method> entry : methodSupplier.apply(bean.getClass()).entrySet()) { result.put(entry.getKey(), wrapGetter(lookup, entry.getValue())); } return result; } private static Getter wrapGetter(Lookup lookup, Method method) { try { MethodHandle handle = lookup.unreflect(method); return (Getter) LambdaMetafactory.metafactory(lookup, "get", MethodType.methodType(Getter.class), MethodType.methodType(Object.class, Object.class), handle, handle.type()).getTarget().invoke(); } catch (Throwable e) { throw new IllegalArgumentException("Could not generate the function to access the getter " + method.getName(), e); } } }
In the collectGetters
method, all getter methods are first read out with the Introspector
and then a Getter
instance is created for each getter method in the wrapGetter
method. The Getter
instances are then used in the BaseGetterMap
class to access the attributes of the Java Bean.
This means that access to the attributes of a Java Bean via Reflections has been replaced by faster access via Lambdas. The benchmark also confirms this, as more than 15000 pages per second are now generated.
Benchmark Mode Cnt Score Error Units FreshMarker.benchmark thrpt 50 15398,787 ± 61,881 ops/s
The new version of the library can be found on Maven Central. Have fun trying it out.