Introduction to Reflectionless: Discover the New Trend in the Java World
Discover this new movement in Java frameworks that aim to circumvent the disuse of reflection to decrease application startup and decrease memory consumption.
Join the DZone community and get the full member experience.
Join For FreeOver the last twenty-five years, many things have changed alongside new versions of Java, such as architectural decisions and their requirements. Currently, there is the factor of cloud computing that, in general, requires the application to have a better startup in addition to a low heap of initial memory. It is necessary to redesign the way the frameworks are made, getting rid of the bottleneck with reflection. The purpose of this article is to present some of the solutions that help reflectionless, the trade-offs of that choice, in addition to presenting the Java Annotation Processor.
Within the framework, reflection certainly plays a big role in several tools, both for classic ORMs and other points, such as a REST API like JAX-RS. This type of mechanism makes life easier for the Java developer by massively reducing various operations' boilerplate. Some people report that the Java world's biggest difference is precisely the many tools and ecosystems around the language.
For the end-user, and here I mean the user who uses these frameworks, this whole process happens in a magical way. Just put some notations into the class, and all operations will work perfectly. They or the class metadata will be read and used to facilitate some process. Currently, the most popular way to perform this type of reading is from the reflection that performs an introspection that lightly generates an idea of dynamic language within Java.
The use of the reflection API for this type of work within the framework was made easier due to the great material created, with examples and documentation for this type of work. However, there are some problems like the one said at the time of startup and the consumption of memory, for some reasons that we will discuss.
The first reason is that all processing and data structure will be performed at the time of execution. Imagine a dependency injection engine that needs to scan class by class, check scope, dependencies, and more. Thus, the more classes that need to be analyzed, the more processing is required and tends to increase the response time.
A second point is in memory consumption, one of the reasons related to the fact that each class needs to be traversed to search for metadata within the Class, that there is a ReflectionData cache that loads all the information of the class, that is, to search for simple information, such as getSimpleName(), all the metadata information is loaded and referenced through the SoftReference that takes time to get out of memory.
In summary, the reflection approach poses a problem both in the initial memory consumption and in the delay in starting the application. This is because data, analysis, and parser processing are performed as soon as an application starts. Memory and runtime consumption tends to increase as the number of classes increases. This is one reason that made Graeme Rocher give a lecture explaining the problem of reflection and how it inspired the creation of Micronaut.
A solution to the problems is to make the frameworks perform these operations at the time of compilation, instead of at the run time, bringing the following benefits:
Metadata and structures will be ready when the application starts, we can imagine here a kind of cache;
There is no need to call the reflection classes, including ReflectionData, thus reducing memory consumption at startup;
Another point is that we don't have to worry about the effect of Type Erasure.
Another point in avoiding reflection is that we can use AoT much more easily and create native code through GraalVM, an exciting possibility, especially for the serverless concept. The program tends to run once and then return the entire resource to the operating system.
Certainly, there are several myths around Ahead of Time, after all, like any choice, there are certain trade-offs. That's why Steve Millidge writes a brilliant article about it.
Show Me the Code
After explaining the concepts, motivations, and trade-offs of the types of readings, the next step will be the creation of a simple tool that converts a Java class to a Map from some notations that will define that the entity will be mapped, the attributes that will be converted and, the field that will be a unique identifier. Let's do all this as shown in the code below:
ElementType.TYPE) (
RetentionPolicy.RUNTIME) (
public @interface Entity {
String value() default "";
}
ElementType.FIELD) (
RetentionPolicy.RUNTIME) (
public @interface Column {
String value() default "";
}
ElementType.FIELD) (
RetentionPolicy.RUNTIME) (
public @interface Id {
String value() default "";
}
To simplify the comparison with reflection, or possibly other options, an interface will be created that will be responsible for converting to/from Map.
xxxxxxxxxx
import java.util.Map;
public interface Mapper {
<T> T toEntity(Map<String, Object> map, Class<T> type);
<T> Map<String, Object> toMap(T entity);
}
To compare the two solutions, the first implementation will be via reflection. One point is that there are several strategies for working with reflection, for example, using the “java.beans” package with Introspector; however, in this example, we will do it in the simplest way to show the basics of how it works.
xxxxxxxxxx
public class ReflectionMapper implements Mapper {
public <T> T toEntity(Map<String, Object> map, Class<T> type) {
Objects.requireNonNull(map, "Map is required");
Objects.requireNonNull(type, "type is required");
final Constructor<?>[] constructors = type.getConstructors();
try {
final T instance = (T) constructors[0].newInstance();
for (Field field : type.getDeclaredFields()) {
write(map, instance, field);
}
return instance;
} catch (InstantiationException | IllegalAccessException | InvocationTargetException exception) {
throw new RuntimeException("An error to field the entity process", exception);
}
}
public <T> Map<String, Object> toMap(T entity) {
Objects.requireNonNull(entity, "entity is required");
Map<String, Object> map = new HashMap<>();
final Class<?> type = entity.getClass();
final Entity annotation = Optional.ofNullable(
type.getAnnotation(Entity.class))
.orElseThrow(() -> new RuntimeException("The class must have Entity annotation"));
String name = annotation.value().isBlank() ? type.getSimpleName() : annotation.value();
map.put("entity", name);
for (Field field : type.getDeclaredFields()) {
try {
read(entity, map, field);
} catch (IllegalAccessException exception) {
throw new RuntimeException("An error to field the map process", exception);
}
}
return map;
}
private <T> void read(T entity, Map<String, Object> map, Field field) throws IllegalAccessException {
final Id id = field.getAnnotation(Id.class);
final Column column = field.getAnnotation(Column.class);
final String fieldName = field.getName();
if (id != null) {
String idName = id.value().isBlank() ? fieldName : id.value();
field.setAccessible(true);
final Object value = field.get(entity);
map.put(idName, value);
} else if (column != null) {
String columnName = column.value().isBlank() ? fieldName : column.value();
field.setAccessible(true);
final Object value = field.get(entity);
map.put(columnName, value);
}
}
private <T> void write(Map<String, Object> map, T instance, Field field) throws IllegalAccessException {
final Id id = field.getAnnotation(Id.class);
final Column column = field.getAnnotation(Column.class);
final String fieldName = field.getName();
if (id != null) {
String idName = id.value().isBlank() ? fieldName : id.value();
field.setAccessible(true);
final Object value = map.get(idName);
if (value != null) {
field.set(instance, value);
}
} else if (column != null) {
String columnName = column.value().isBlank() ? fieldName : column.value();
field.setAccessible(true);
final Object value = map.get(columnName);
if (value != null) {
field.set(instance, value);
}
}
}
}
With the mapper built, the next step is to make a small example. So let's create an Animal
entity.
xxxxxxxxxx
"animal") (
public class Animal {
private String id;
"native_name") (
private String name;
public Animal() {
}
public Animal(String id, String name) {
this.id = id;
this.name = name;
}
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
public class ReflectionMapperTest {
private Mapper mapper;
public void setUp() {
this.mapper = new ReflectionMapper();
}
public void shouldCreateMap() {
Animal animal = new Animal("id", "lion");
final Map<String, Object> map = mapper.toMap(animal);
Assertions.assertEquals("animal", map.get("entity"));
Assertions.assertEquals("id", map.get("id"));
Assertions.assertEquals("lion", map.get("native_name"));
}
public void shouldCreateEntity() {
Map<String, Object> map = new HashMap<>();
map.put("id", "id");
map.put("native_name", "lion");
final Animal animal = mapper.toEntity(map, Animal.class);
Assertions.assertEquals("id", animal.getId());
Assertions.assertEquals("lion", animal.getName());
}
}
With that, the implementation of the reflection implementation was demonstrated. If the developer wants to use this type of tool in other projects, it is possible to create a small project and add it like any other dependency, and all this operation and reading will be performed at run time.
It is important to note that in the reflection world, there are some options and strategies to work with it, for example, to create an internal cache of these metadata to avoid using ReflectionData constantly or, from this information, compile classes at execution time like Geoffrey De Smet demonstrates in his article, using JavaCompiler.
However, the big point is that this whole process will happen at the moment of execution. To make the processing move to the compilation, we will use the Java Annotation Processor API.
A class to be an entity in the process needs to extend the AbstractProcessor class, use the SupportedAnnotationTypes annotation to define which classes will be read at compile-time, and the process method where the heart of the code will be. This method is where all the analysis will be performed. The last step is to register the class as the SPI, and the code will be ready to run at the time of compilation.
xxxxxxxxxx
"org.soujava.medatadata.api.Entity") (
public class EntityProcessor extends AbstractProcessor {
//…
public boolean process(Set<? extends TypeElement> annotations,
RoundEnvironment roundEnv) {
final List<String> entities = new ArrayList<>();
for (TypeElement annotation : annotations) {
roundEnv.getElementsAnnotatedWith(annotation)
.stream().map(e -> new ClassAnalyzer(e, processingEnv))
.map(ClassAnalyzer::get)
.filter(IS_NOT_BLANK).forEach(entities::add);
}
try {
if (!entities.isEmpty()) {
createClassMapping(entities);
createProcessorMap();
}
} catch (IOException exception) {
error(exception);
}
return false;
}
//…
}
The important point is that the configuration for Java Annotation Processing requires more configuration steps than reflection. However, with the initial steps, the next ones tend to be similar to the reflection API. The dependence on this type of this library can be done from the annotationProcessorPaths tag in the pom.xml file. A great advantage is that these dependencies will be visible only in the compilation scope; that is, it is possible to add dependencies to generate classes, for example, using Mustache, without worrying about these dependencies at run time.
As soon as the dependency is added to a project and executed, classes will be generated inside the target/generated-sources
folder. In the example, all classes were generated thanks to the Mustache project.
xxxxxxxxxx
value= "Soujava ClassMappings Generator", date = "2021-01-21T13:08:48.618494") (
public final class ProcessorClassMappings implements ClassMappings {
private final List<EntityMetadata> entities;
public ProcessorClassMappings() {
this.entities = new ArrayList<>();
this.entities.add(new org.soujava.metadata.example.PersonEntityMetaData());
this.entities.add(new org.soujava.metadata.example.AnimalEntityMetaData());
this.entities.add(new org.soujava.metadata.example.CarEntityMetaData());
}
The functioning for the end-user of this library, in general, does not change much since the user will continue making notes in the entities, however, all the processing logic was brought to the compilation time.
The functioning of the end-user of this library, in general, does not change much since the user will continue making notes in the entities. However, all the processing logic was brought to the compilation time.
xxxxxxxxxx
"animal") (
public class Animal {
private String id;
"native_name") (
private String name;
public Animal() {
}
public Animal(String id, String name) {
this.id = id;
this.name = name;
}
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
public class ProcessorMapperTest {
private Mapper mapper;
public void setUp() {
this.mapper = new ProcessorMapper();
}
public void shouldCreateMap() {
Animal animal = new Animal("id", "lion");
final Map<String, Object> map = mapper.toMap(animal);
Assertions.assertEquals("animal", map.get("entity"));
Assertions.assertEquals("id", map.get("id"));
Assertions.assertEquals("lion", map.get("native_name"));
}
public void shouldCreateEntity() {
Map<String, Object> map = new HashMap<>();
map.put("id", "id");
map.put("native_name", "lion");
final Animal animal = mapper.toEntity(map, Animal.class);
Assertions.assertEquals("id", animal.getId());
Assertions.assertEquals("lion", animal.getName());
}
}
In this article, we talk a little about the effects, advantages, and disadvantages within the reflection world. We introduced an example with Java Annotation Processor and showed the advantages of AOT in Java and even converted it to be native, making it easier in several situations like Serverless.
As always, every choice results in a disadvantage. When removing the application, all JIT optimization is lost, and there are already some reports that over time, the JVM will be much more efficient than the native code itself. The definition of performance is very complex and does not take into account just the application startup time. A good analogy would be to cross the ocean with a motorcycle instead of an airplane since the analysis only took into account the initialization of the two means of transport.
Opinions expressed by DZone contributors are their own.
Comments