Supercharge Your Code: Apply Log4j2’s Internal Optimisation Techniques
Apply valuable lessons from Log4j2’s internal optimisations to enhance your own codebase.
Anybody who works on Java would have seen multiple codebases using Log4j2 for logging. But have you ever wondered why Log4j2 stands out among its competitors? The buzz around the town suggests that Log4j2’s remarkable speed and memory efficiency are key factors.
In this article, we will delve into the inner workings of Log4j2, uncovering its powerful techniques that deliver blazing-fast performance while minimizing memory consumption. By understanding and applying these techniques in your own codebase, you can enhance the performance of your application too.
So, without any delay, let’s embark on this insightful journey.
Reusing Internal Objects
Within standard Java-based web applications, a multitude of internal objects are generated during request processing. As the server handles an increasing number of requests, the volume of objects created rises accordingly, resulting in wasteful time spent on space allocation and garbage collection. The situation worsens when object creation proves to be both expensive and resource-intensive. These challenges significantly impact the application’s performance and overall efficiency.
Fortunately, Log4j2 recognized this challenge and effectively addressed it by implementing a smart approach of reusing internal objects whenever possible. In the following sections, we will explore specific classes within Log4j2 that exemplify these innovative techniques, shedding light on how Log4j2 tackles the problem head-on and sets a benchmark for efficient object handling.
Let’s take a closer look at a key class in Log4j2’s codebase called ReusableMessageFactory
. Simplified for clarity, the class structure appears as follows:
public final class ReusableMessageFactory implements MessageFactory2, Serializable {
private static ThreadLocal<ReusableSimpleMessage> threadLocalSimpleMessage = new ThreadLocal();
public ReusableMessageFactory() {
}
public Message newMessage(final CharSequence charSequence) {
ReusableSimpleMessage result = getSimple();
result.set(charSequence);
return result;
}
private static ReusableSimpleMessage getSimple() {
ReusableSimpleMessage result = (ReusableSimpleMessage)threadLocalSimpleMessage.get();
if (result == null) {
result = new ReusableSimpleMessage();
threadLocalSimpleMessage.set(result);
}
return result;
}
public static void release(final Message message) {
if (message instanceof Clearable) {
((Clearable)message).clear();
}
}
}
The provided code snippet showcases ReusableMessageFactory
, a key component responsible for creating objects of different types of reusable classes. While these classes come in multiple variations, we will focus on the simplest implementation, which is called ReusableSimpleMessage
. Below, you’ll find a simplified version of this class:
public class ReusableSimpleMessage implements ReusableMessage, CharSequence, ParameterVisitable, Clearable {
private CharSequence charSequence;
public ReusableSimpleMessage() {
}
public void set(final CharSequence charSequence) {
this.charSequence = charSequence;
}
public void clear() {
this.charSequence = null;
}
}
Note that this class implements an interface called Clearable
which is common between all the reusable classes. This will come in handy in the later part.
Now let’s focus on the three significant aspects of the code snippets I have shared.
The first notable element is the usage of ThreadLocal
to cache the ReusableSimpleMessage
type object for each thread. This approach optimizes performance by eliminating redundant object creation and reusing existing instances.
private static ThreadLocal<ReusableSimpleMessage> threadLocalSimpleMessage = new ThreadLocal();
Next is the newMessage(final CharSequence charSequence)
method. If an instance of the ReusableSimpleMessage
type doesn’t already exist in the ThreadLocal
cache, a new object is created using the CharSequence
provided by the client and stored. However, if a cached object is available, it is retrieved and returned
public Message newMessage(final CharSequence charSequence) {
ReusableSimpleMessage result = getSimple();
result.set(charSequence);
return result;
}
private static ReusableSimpleMessage getSimple() {
ReusableSimpleMessage result = (ReusableSimpleMessage)threadLocalSimpleMessage.get();
if (result == null) {
result = new ReusableSimpleMessage();
threadLocalSimpleMessage.set(result);
}
return result;
}
The third and most important aspect is the release(final Message message)
method. When the outer classes finish using the ReusableSimpleMessage
type object for their internal operations, this method is invoked. Its implementation ensures that any resources or data stored within the object are properly cleared or reset. You would have noticed the usage of the Clearable
interface here. If any other subclass of the Message
type is passed which doesn’t implement the Clearable
interface, then it will be ignored.
By performing this clearance, the object becomes ready for reuse in subsequent requests.
public static void release(final Message message) {
if (message instanceof Clearable) {
((Clearable)message).clear();
}
}
As mentioned earlier, there are various reusable classes used within Log4j2, and each class type, including ReusableSimpleMessage
, incorporates its own implementation of the clear()
method. Following this technique reduces a lot of pressure on the garbage collector and improves the throughput of your application.
Optimising Varargs Methods
In Java, varargs (variable arguments) is a feature that allows a method to accept a variable number of arguments of the same type.
// Example
Message newMessage(String message, Object... params);
It provides flexibility when you don’t know in advance how many arguments will be passed to a method. Using this feature makes your life easier as a developer but it comes with a downside.
Digging deeper into how this works would show you that varargs functionality is just syntactical sugar over the use of arrays of variable size. Each time a varargs method is called, an array must be created to hold the variable arguments. This array allocation operation involves memory allocation and initialization, which takes time and resources. Additionally, all the variable arguments are needed to be copied to the newly created array, incurring further overhead. The problem becomes more pronounced when the method is frequently called or when a large number of arguments are used.
To mitigate these performance concerns, Log4j2 takes a different approach. It offers overloaded methods that have a predefined number of parameters up to a certain limit. By providing multiple methods with fixed parameter counts, Log4j2 avoids the need for varargs in certain cases. Let’s explore some code examples from the Log4j2 library to understand this better.
Let’s look at an interface called MessageFactory2
. The code for the same is given below:
public interface MessageFactory2 extends MessageFactory {
Message newMessage(CharSequence charSequence);
Message newMessage(String message, Object p0);
Message newMessage(String message, Object p0, Object p1);
Message newMessage(String message, Object p0, Object p1, Object p2);
Message newMessage(String message, Object p0, Object p1, Object p2, Object p3);
Message newMessage(String message, Object p0, Object p1, Object p2, Object p3, Object p4);
Message newMessage(String message, Object p0, Object p1, Object p2, Object p3, Object p4, Object p5);
Message newMessage(String message, Object p0, Object p1, Object p2, Object p3, Object p4, Object p5, Object p6);
Message newMessage(String message, Object p0, Object p1, Object p2, Object p3, Object p4, Object p5, Object p6, Object p7);
Message newMessage(String message, Object p0, Object p1, Object p2, Object p3, Object p4, Object p5, Object p6, Object p7, Object p8);
Message newMessage(String message, Object p0, Object p1, Object p2, Object p3, Object p4, Object p5, Object p6, Object p7, Object p8, Object p9);
}
While we are looking at it, let’s also see the interface MessageFactory
which is extended by MessageFactory2
public interface MessageFactory {
Message newMessage(Object message);
Message newMessage(String message);
Message newMessage(String message, Object... params);
}
As you might have observed, MessageFactory
contains a method called newMessage(String message, Object... params)
This method takes a String
and a list of optional params of type Object
.
The interesting part is that this method is further overloaded in the sub-interface called MessageFactory2
The benefit of this approach over a typical varargs method becomes apparent when a logging call is made by the client with a specific number of arguments. In such cases, the compiler can directly determine the appropriate overloaded method to invoke, treating it like any other method call. This eliminates the overhead associated with varargs and improves performance.
Furthermore, if more arguments are passed than what is supported by the overloaded methods, it is seamlessly handled by the varargs method defined in the parent interface.
By offering these overloaded methods, Log4j2 provides a more efficient alternative to traditional varargs usage. It optimizes the method resolution process and avoids the need for array creation and copying, resulting in improved execution speed and reduced memory consumption.
Using Lock-Free Inter-Thread Communication
Log4j2 provides an implementation of async loggers to be used. Using this improves the throughput of the application because the clients no longer need to wait for the logging call to complete before proceeding.
While the intricate workings of this mechanism are beyond the scope of our discussion today, it involves inter-thread communication to transfer data from the main producer thread to various appender threads responsible for writing the data to their respective destinations (files, consoles, etc).
Different approaches can be employed to achieve inter-thread communication with one commonly used method being the utilization of a blocking array queue. However, this approach has inherent limitations. It introduces thread contention, which negatively impacts logging throughput even in the async mode. The reason behind this is that multiple threads contend to read or write data from the queue, requiring the use of locks. To overcome this challenge, Log4j2 adopts a different strategy by leveraging a non-blocking data structure for inter-thread communication. This is where the disruptor library comes into play.
This library was built to implement a way to have lock-free inter-thread communication. At the core of it, there is a ring buffer data structure which is nothing but an array of fixed size. Producers claim slots in the ring buffer and publish their events by advancing the sequence number. Consumers, on the other hand, track their progress by keeping track of the sequence number they have processed. This allows for parallel processing without the need for locks or synchronized blocks.
Again, getting into more depth about how it works internally would be out of the scope of today’s conversation, the key concept is that this library can be utilized not only for async logging in Log4j2 but also in other scenarios that require efficient inter-thread communication and better throughput.
Read more about Log4j2’s async logging here
The lessons learned from Log4j2’s internal optimizations offer valuable insights for any Java application. By applying techniques such as object reuse, varargs optimization, and the use of the LMAX Disruptor library, developers can enhance the performance and efficiency of their own applications.
Thank you for reading. Don’t forget to clap and share if you like this article. Happy coding!