Yesterday, I got an interesting exchange about Logging in Java ecosystem and, even if I did my choice years ago, I think it can be worth a post to explain how I decided a solution on this topic.

What is logging when we speak about Java?

It can sound obvious but what is the goal of a logging library/framework for a Java developer?

The overall goal is to write a message and get it output somewhere else configured elsewhere so we can say that:

Logging goal is to decouple the message producing side from the message consuming side.

Concretely a Java developer wants to be able to do:

final var logger = getLoggerSomehow("....");
logger.message("text or other info");

And with some "magic" from the logging library/framework you will get text or other info in a file, stdout, a Kafka topic, …​

This is the big picture to keep in mind: Java code only (almost ;)) cares about producing side!

What information in a log message?

The information contained in a log message/record are generally:

  • The message itself - what the developer wants to share,

  • The level: is this message important or not. The most common levels being DEBUG/FINEST, INFO,WARNING, ERROR (from the lowest to the most important),

  • The source of the log message (which class, which method, which line - you don’t always have all the details),

  • The date the message was created,

  • Optionally the source thread,

  • Optionally an exception/error associated to the record,

  • Optionally a map associated to the record (MDC, NDC).

indeed you can add as much data as you want but these ones are the common ones across most frameworks.

What is logging when we speak about environments?

Each environment will get its own constraints. It is the same for logging. Concretely there are some big families of logging "configuration" but what is important is that we move in this part from the producing side to the consumer side.

Concretely the dev environment will mainly issue to stdout/stderr in a human readable format (ex: <date> [<level>] <message><exception>), the prod environment will send the log record to a Kafka topic in some environment or more likely log in JSON to be captured by Kubernetes/Docker common logging driver or, on premises, you will log on a file with rotation (one file per day for example).

Here we start to identify two needs which makes the logging framework configured for an environment:

  • The physical output (file, topic, HTTP, directly to a collector or Elasticsearch, …​),

  • The format (human, JSON, could be binary like protobuf etc…​) of the messages.

Logging libraries in Java as of today

Logging libraries are quire common from Java Commons Logging to Log4j2 but some are dead so we can consider the mainstream ones are:

  • Platform Logging API and Service (System.Logger): it is one provided by the JVM itself, all applications have it (but read the rest of the post before using it),

  • Java Util Logging (JUL): it is the one provided by the JVM itself, all applications have it,

  • Log4j2: it is the natural replacement for the historically first solution Log4j. It is a very powerful implementations with integration with most outputs of the nowdays ecosystem,

  • Logback: a pure implementation of SLF4J API which is not as powerful as Log4j2 but quite close in practise.

API vs implementations

An API defines the classes/methods an integrator/developer can use to interact with a library.

An Implementation, sometimes called provider defines the code behind an API.

In logging the most known example is SLF4J. SLF4J is mainly an API (slf4j-api module) and Logback is one of its implementation.

Concretely it means that when you do org.slf4j.LoggerFactory.getLogger(Class) there is a mecanism to lookup a LoggerFactory implementation (Logback one for example) which will then give you a Logger implementation (letsay LogbackLogger to illustrate).

To enable an implementation to be provided, an API always provides an extension point which enables the implementation to be picked and used behind the API.

Here the status of the previous libraries regarding API/implementation:

  • Platform Logging API and Service (System.Logger): it is an API + a JUL and console implementation. You can plug an implementation with java.lang.System$LoggerFinder SPI (you extend this class and register it with module-info - provides java.lang.System.LoggerFinder with com.app.MyLogFinder; or META-INF/services/java.lang.System$LoggerFinder resource). It uses JUL as implementation if java.logging java module is enabled else it uses a plain console logger. The API entry point is System.getLogger,

  • Java Util Logging (JUL): it is an implementation and an API. The entry point is Logger.getLogger and the extension point to plug any custom logic is the java.util.logging.LogManager. You can extend this class to implement whatever logic you need and set it as java.util.logging.manager system property to take it into account,

  • Log4j2: it provides, since version 2, an API and an implementation so it can be the primary API or not. Note that it integrates with JUL providing a log manager and SLF4J providing a binding.

  • Logback: it is just an implementation of SLF4J API, you shouldn’t use its classes on producer side and you interact with the producer side thanks its configuration files or classes.

Selecting a logging library

Each time you need to choose a library there is rarely a "good" answer, all libraries of this post are good and the choice can depends from your case but I want to highlight a few criteria to help you think about the topic.

Is the configuration global or local?

This difference is mainly between built-in JVM solutions versus pure external libraries.

For Platform Logging API and Service and Java Util Logging, you need to be able to put the implementation you use at JVM level (either a system property or a SPI in the JVM classpath, not a subclassloader).

For log4j2 and slf4j you need to put the implementation with the classloader loading the API…​ok let me explain this one. If you have a flat classpath you are back to the previous case, nothing crazy. If you are in OSGi, one bundle will provide the API and, until you use a specific mechanism like a whiteboard, this bundle will load the implementation so this bundle must be able to find your implementation. In a JavaEE/JakartaEE container you have the JVM classpath then often a container classloader and finally your application classloader(s) so depending which classloader loads the API you use, you can put the implementation in the same loader or not.

Let’s dig in this last case which is quite representative of this criteria: if the container provides SLF4J-API and does not let the application override it - it is not only about children fist since SLF4J also uses resource directly and not only class loading, then you will use the implementation - binding for SLF4J - of you container even if you put another one in your war. Now the opposite is true as well: if you want to put in your container SLF4J-API + logback to configure everything at container level, then you can’t if your container enables the application to override it and not configure this behavior.

This represents this first criteria: do you want to control your logs from:

  • The JVM,

  • The container,

  • The application (ex: you deploy in a managed Apache Tomcat you don’t control and want to control your logs).

these days we mainly deploy docker containers so in this case we control the JVM so I tend to think this criteria will slowly disappear and make all library equivalent even if the mecanism is different.

Do you jlink/native-graalify your library?

If you want to use JLink, you must have a compatible library, ie with a (working) module-info. All the mentioned libraries have it but slf4j integration with JPMS (module platform of Java - JLink to make it short) is not yet mainstream and well integrated. Log4j2 has module-info but part of it can not work - dependencies mainly - so no guarantee until you test or just use log4j2 itself.

Indeed the JVM solutions work with JLink.

For GraalVM native-image - the binary enabling to convert Java bytecode to a native binary to get a lighter and faster application, it is the same kind of story. Integrating with native-image is quite some work and the design of Log4j2 kills it - a lot of it is static or instructions are not GraalVM friendly so if you want to use it you must do a lot of custom codes - substitutions. Logback has some working setup but it is based on workarounds as well. JUL is not perfect too since by default it is not reconfigurable but some custom log managers are - like Yupiik Logging.

So if you intend to do a custom packaging or native binary (for docker for ex ;)) of your application, JUL clearly wins the game.

Platform Logging API and Service vs JUL

There are two solutions in the JVM? Seriously?

Yes and actually in Log4j/logback you can find 2 as well if you look well.

Long story short, the issue is: how to log anything while you are setting up the logging?

This is what the platform API solves.

Indeed, some applications can use it - I’m thinking to javaagent which shouldn’t trigger JUL initialization too early for example - and technically speaking any application can but it is not designed as a full logging library so you will miss concepts and fluentness in the API itself.

The conclusion is - in the JVM context: if you write a super light bootstrap library for Java then use the Platform Logging API and Service, else use JUL.

Lazy evaluation to keep correct performances

A common need in the logging area is to keep a low overhead. Typically if I do:

logger.debug("id=" + id + ", message=" + message);

I will evaluate the concatenation and create an overhead I don’t need if the debug level is not kept by the logger chain (output/consumers).

A common workaround is to wrap it in a isLoggeable kind of call:

if (logger.isDebugEnabled()) {
    logger.debug("id=" + id + ", message=" + message);
}

But it makes the code more verbose.

To solve that, frameworks created some alternatives. Historical framework use interpolations (SLF4J, Log4j2):

logger.debug("id={}, message={}", id, message);

This will pass the pattern + values and evaluate it lazily if the message is logged.

JUL provided, thanks to Java 8 new API, a very interesting alternative using Supplier:

logger.debug(() -> "id=" + id + ", message=" + message);

Using a lazy provider of the message this makes the code:

  • Easier to write (no need to recall you have to use {} or {0} or anything else),

  • More efficient: you code exactly the direct interpolation without any parsing.

So here JUL wins IMHO.

Available outputs

With all its appenders, Log4j2 is likely the most complete framework in terms of integration. You can compare to Logback ones if you want.

JUL has mainly only stderr/stdout, file and socket outputs by defaults but it can be complemented by some custom or libraries like Yupiik Logging to get more power.

having a lot of integration is great but not bringing a lot of dependencies to get them is great too because it will reduce your security work (CVE monitoring) and integration work in terms of conflicts.

Just looking like that you will make Log4j2 winning but I’d like to emphasise the fact you should only care about what you need so if you don’t care about Kafka appender/handler, then don’t consider it as a criteria. Personally I only care about container case so (rotating) file + stdout/stderr outputs are the only ones I want so all are equivalent - using Yupiik Logging for JUL case.

Docker/Kubernetes friendly

In a docker environment we mainly want to be able to log as JSON on stdout/stderr - small bonus there being to be able to log errors on stderr and other levels on stdout.

All libraries support that case but the stack is not always the same.

Log4j2 has some native JSON encoding (plain string manipulation) but it does not make the output very JSON friendly you so end up using the jackson integration quite quickly.

Logback relies on a contrib using jackson as well (it is an external integration).

JUL does not have anything built-in but Yupiik Logging brings a JSON-B (javax and jakarta) integration to get it.

in all cases, writing a custom formatter/encoder is quite trivial so it is always possible to go through a custom implementation.

Regarding the stderr/stdout point, each output/appender/handler is generally linked to a single stream but Yupiik Logging enables to redirect the log records depending the level on stdout or stderr.

So overall I think this criteria is met by all libraries.

MDC

MDC (or NDC to be more advanced) are a way to pass some context to the formatter/encoder to let some additional data be used.

Concretely it behaves - and is often implemented as - a ThreadLocal passed to the formatter to let you use a pattern extracting some data from this local "map".

JUL is the only one without this concept but it is trivial to use a custom formatter - potentially wrapping an existing formatter to reuse what exists - to add this capability.

So for this criteria Log4j2 and Logback are a bit better but I would not weight a lot this criteria but more use it as a decision one if everything else is equal.

Size

The size of any library s important at some point - keep in mind docker images are downloaded on pods or dev machines for example.

Log4j2 is about 2M, logback is about 500k and JUL is 0 since in the JVM (another cached layer of you docker image and there anyway even if not used by your application).

if you use any integration like Kafka or JSON you must add the related dependencies, can become some dozen of megas quite quickly.

So here JUL wins even if 2M is ok-ish.

Additional library or not

Is the solution you pick a built-in solution or not is important, and indeed only JUL meets the fact it is built in.

For your application it is not a big criteria but do you write a dependency free application? Likely not.

As far as you have some library doing some logging, you must ensure all your application logging is unified and go to the same location.

Concretely if you pick logback you must ensure that JUL logs go to SLF4j, that log4j2 logs go to SLF4j, that JBoss Logging (another facade) goes to SLF4j, etc…​

There are several cases there to decide.

Application

If you see your application as a grape of libraries, you have different strategies:

  • What is the most used API in the libraries I use?

  • What is the most easily "bridge-able" output?

  • (worse case) I have to use library X because my company enforces libraries instead of specifications/formats → no real choice ;).

Library

If you are writing a library now the choice is different what is the least impacting choice with the minimum requirements (minimum required features)? Here the answer is often JUL because it is built-in and it will never impose anything on user code/stack which is very very important for a smooth integration. Any other choice - including SLF4j - will impact the user and forces him to learn your library + something else (like to import a SLF4j binding for example) which is a drawback for a library.

Container

Finally, there is the container case, ie an application containing applications (Apache Tomcat, Apache TomEE, Apache Karaf, Wildfly, Payara, …​). Here the challenge is to:

  • Enable the container to log properly,

  • Enable applications to log as expected (if an application picked SLF4j to use its configuration - or not as we saw before depending if the logging is considered as a service provided by the application or container),

  • Enable to identify the log of each part of what is in the JVM (container, application, library, provided service etc…​) and configure each of them properly - often thanks a logger but you can want to configure different a particular logger per application.

All these challenges are always solved in containers and not in libraries since it depends the "model" of the container but here are some examples:

  • Apache Tomcat created Tomcat JULi which is a thin wrapper on top of JUL which enables to

    • Plug another backend if needed behind JUL API (like Log4j2) if needed,

    • Configure JUL per application (classloader) differently even for the same logger name (thanks a prefix mecanism).

  • Apache Karaf uses Pax Logging to configure all known logging libraries in an unified way making all the logs going to the same actual backend - even OSGi log services.

You have the same kind of solutions in libraries, for example Log4j2 or Logback have contextual/classloader based configuration which means each classloader can get its own logging configuration but it has some drawback:

  • It uses a single strategy (parent first or child first),

  • It is per classloader so if you have an Enterprise ARchive (ear) then it is not per application

This is one of the reason it is often done in containers.

Now the model of the container is very important there, if the container leaks it classloader (like Apache Tomcat leaks its common.loader to the deployed webapps for example), then it must not be any library which can conflict with the applications (so it must be JUL by default or use an isolation mechanism which is not Tomcat native but doable). If the container is OSGi related it is a bit different since you are dynamically wired but if you take Pax Logging, it enforces the API versions to be able to handle the configuration properly which has some drawbacks and prevents you to use some new features for example, in particular when libraries are breaking some features.

So overall, the best choice for a container is to use JUL and provide a custom LogManager (logger factory) which will read the configuration as needed (in the application and the container to merge both are needed).

If your container can not be pure and only use JUL - like Apache TomEE which relies on some dependencies which are SLF4j oriented as library - you must also patch all your classloaders to handle SLF4j to let the application/container decide if it uses the container or application SLF4j binding which is not always trivial to do properly and often can require additional configuration.

So for a container JUL is the best choice until it is fully isolated - like OSGi - where the best is likely to deploy native bundles but will require a lot of configuration work to unify the outputs/configuration - this is why Pax Logging is often used/recommended instead even if it has some limitations.

Performance

Logging overhead must stay acceptable. Once we said that we said nothing :). The overall point is that you shouldn’t pay as much as your business logic for logging, we often reference ⇐ 5% in terms of ratio even if it is not a strict limit.

When logging to stdout/stderr or their virtual equivalent (in docker it is not the tty of your machine so it is a bit faster) the performances are close and mainly depends the formatters/encoders you are using. But when logging to a file, Log4j2 and Logback are generally faster but for JUL you can implement you own handler or use your container one (Apache TomEE provides one which has rotation support and is quite fast, Apache Tomcat also provides one which is not bad with rotation support). Worse case for JUL you can use a library like Yupiik Logging which has a good file handler/appender which can replace the one of the JVM.

Sometimes we get the reference of asynchonous loggers or appenders. Here Log4j2 is likely the best but you must also ensure to understand what it means:

  • It will process the log message partially not synchronously compared to the code execution

    • It means you can loose message whatever you think in all cases - you said disruptor?,

    • It means you push the load associated to the logging in another chunk of threads instead of dispatching it on all applications threads.

This last point is very very tempting but it also means you will prevent some application code execution from application unrelated code so the application behavior can become less stable/predictable in some cases…​.and don’t even think to reduce the priority of these threads until you don’t care of the logs which means you should just not log if so ;).

So personally I think that asynchronous loggers are not a killer feature but more a workaround for wrongly designed handlers. Concretely there are cases it is interesting:

  • Files: fill a buffer before writing on disk - like databases do,

  • Remote/Socket/HTTP: if you send payloads to a log aggregator through the network it is interesting to handle a buffer with retries and potentially disk dump to not loose all data.

But this is an implementation of the appender/handler, not a feature moved to loggers.

I’ll not mention a winner there because, in my opinion, all are fine in synchronous mode while the configuration is done properly - ie don’t use default JUL file handler for example. Log4j2 and logback have, indeed, interesting asynchronous wrapper but it is quite trivial to do for JUL too in < 100 LoC and they generally tend to show some issue elsewhere but there are rare case it can be a criteria (clearly not for web application or batches but more for some daemon).

A word on bridges

I don’t want to enter into details, but it is important to note that almost all frameworks - and more particularly the ones I spoke in this post - are bridgeable which means if you use SLF4j API you can log on JUL backend, the opposite as well and with Log4j2 too. It is generally just a library to add in the right classloader and some configuration to do in some case (not always).

Conclusion

This post is already long, and I didn’t share much code snippets - a real logging report/analyzis can be a 50 pages report! - but I hope it will make you think differently about logging from now on.

As any choice it is a compromise of criteria which means you pick what is important for you and evaluate against it the libraries.

Personally I can summarize my vision of logging frameworks in Java as:

  • All can be used as an API - so not a criteria,

  • All can be used to log on files and stdout/stderr - so not a criteria,

  • All are configurable (pattern) - so not a criteria,

  • All are usable in any application - so not a criteria,

    • Note there that I don’t care of the managed container case anymore but if you do, use it as a criteria.

  • JUL is the lighter and likely the most common one/only all can agree on since it is in the JVM,

  • Performances are generally good for all cases (yes, even JUL can handle > 100kmessages/s in synchronous mode!),

  • All can have a MDC like feature even if not always a direct MDC API - and do you need it?

  • JUL is the only one which is dependency free but until you use a huge integration core are generally light even if Log4j2 is the biggest.

    • Here we can note that whatever you do, you have to handle the security issues (CVE) for JUL since it is in the JVM but if you use another one you must handle others too like the Log4j2 "0-day" one.

So since ~8 years my choice is for JUL even if it has some drawbacks but the recent releases of Java solved most of them. The only remaining one is to not be configurable from the JVM itself but as we saw with the System.getLogger API there are some reason for that and its advantages are really huge in practise so it is not a bad choice even if we often read wrong information about it over blog posts. Docker erea also dropped most of these drawbacks since you own the JVM in docker so it is likely time to invest a bit more in JUL and maybe abandon historical logging frameworks which will enforce you to do more work without any real gain from a business or performance point of view. It is even more true for libraries or container writers but it stays true at application level.

From the same author:

In the same category: