Introduction
In any web service that receives and transmits data to and from a server, the first and last events will usually be transforming the data from the format used by the web request into the format that the web server will handle, and vice versa; these operations are called deserialization and serialization, respectively. For some web services, the thought put towards this part of the flow of data is focused solely on how to configure the serialization mechanism so that it works properly. However, there are some scenarios for which every CPU cycle counts, and the faster the serialization mechanism can work, the better. This article will explore the development and performance characteristics of four different options for working with the serialization of JSON messages – GSON, Jackson, JSON-B, and Kotlinx Serialization, using both the Kotlin programming language and some of the unique features that Kotlin offers compared to its counterpart language, Java.
Setup
Since its first release in 2017, Kotlin has grown by leaps and bounds within the JVM community, becoming the go-to programming language for Android development as well as a first-class citizen in major JVM tools like Spring, JUnit, Gradle, and more. Among the innovations that it brought to the JVM community compared to Java was the data class, a special type of class that is to be used primarily as a holder of data (in other words, a Data Transfer Object, or DTO) and automatically generates base utility functions for the class like equals()
, hashcode()
, copy()
, and more. This will form the base of the classes that will be used for the performance tests, the first of which being PojoFoo
– “Pojo” stands for “Plain Old Java Object”, signifying using only basic class types of the Java programming language.
data class PojoFoo(var fizz: String, var bizz: Int, var bazz: List<String>)
{
constructor() : this("", 0, emptyList())
}
For those who are not familiar with the Kotlin programming language: the class has three attributes – fizz, bizz,
and bazz -
that contain both getter and setter functions. There are two constructors for the class: one that requires arguments for each of the attributes, and one that requires no arguments and populates the attributes with default values. This second constructor is the “no-arg constructor” that is typically required by JSON serialization mechanisms.
In the example above, the three class attributes are marked with the keyword var
; this signifies that the attributes are mutable and can be modified at any time during the lifetime of an instance of the class. In order to make the attributes immutable, all that is needed is to change the designator to val,
upon which the attributes will become the equivalent of final
attributes in Java, and Kotlin will no longer generate a getter function for the attributes. In addition, this removes the requirement of a no-arg constructor, so that can be eliminated from the code.
data class ImmutableFoo(val fizz: String, val bizz: Int, val bazz: List<String>)
The next example class – DefaultValueFoo
– uses a default value for the attribute fizz
. This means that, if the constructor of DefaultValueFoo is invoked and no argument is provided for fizz, then the argument will be assigned the default value.
data class DefaultValueFoo(var fizz: String = "FUZZ", var bizz: Int, var bazz: List<String>) {
constructor() : this(bizz = 0, bazz = emptyList())
}
Finally, the example class ValueClassFoo
changes the type of attributebizz
from a plain integer to an inline class. Inline classes function as wrappers around a single “payload” value; while the Kotlin code will treat the inline class as a “genuine” class, the compiler will translate the code so that only the payload value is present. This provides for several advantages compared to simply using the payload value directly, such as enforcing a type safety for different variables, for example specifying a username and a password type – two types that would normally both be strings – for a login function. In this case, it allows for the usage of UInt
: a Kotlin-exclusive class that simulates the behavior of an unsigned function, something that is not supported by default by the JVM.
data class ValueClassFoo(var fizz: String, var bizz: UInt, var bazz: List<String>) {
constructor() : this("", 0u, emptyList())
}
(Note: the class is named as such because while inline classes are still called as such in the Kotlin documentation, they have been renamed as value classes in the actual code; the keyword inline
is deprecated.)
The Contestants
GSON
Introduced in 2008 and developed by Google, GSON is one of the main options that Java users employ for conducting serialization between JSON strings and Java objects and is the preferred library to leverage in Android development thanks to the support by Google.
Usage
The basic usage is to construct an instance of Gson
and invoke the functions Gson.toJson()
and Gson.fromJson()
in order to serialize an object and deserialize a JSON string, respectively.
Working With Kotlin
Surprisingly, there are no additional steps necessary in order to work with the four example classes; all of the code snippets provided above were from the GSON testing code.
Jackson
Introduced in 2009, Jackson is the other widely-used JSON serialization library – alongside GSON – and is used by default in major JVM ecosystems like the Spring Framework.
Usage
The basic usage is to construct an instance of ObjectMapper
and invoke the functionsObjectMapper.writeValueAsString()
and ObjectMapper.readValue()
in order to serialize an object and to deserialize a JSON string, respectively.
Working With Kotlin
Unlike GSON, there is quite a bit of work that is necessary in order to support the Kotlin features in the example classes.
- Jackson does not have a native concept of deserializing classes that do not possess a no-arg constructor; if it cannot find a no-arg constructor, it will normally raise an exception. A workaround for this is to mark the parameters in the constructor with
@JsonProperty
so that Jackson knows which argument corresponds to which class attribute.
data class ImmutableFoo(
@param:JsonProperty("fizz") val fizz: String,
@param:JsonProperty("bizz") val bizz: Int,
@param:JsonProperty("bazz") val bazz: List<String>
)
- Inline classes are not processed properly due to a difference in how Jackson computes how to conduct serialization and deserialization on a class. An advantage of these serialization libraries is that they do not normally require the creation of specialized classes to conduct the serialization and deserialization actions on a class. Instead, they compute which fields to pull values from and to set via reflection; whereas GSON executes the reflection actions on the actual attribute fields within the target class, Jackson’s reflection actions are targeted on the attributes’ getter and setter functions. This is an issue with inline classes, as any function that accepts or returns an inline class is name-mangled in order to prevent collisions with functions that might accept the equivalent “normal” type in the JVM. Thus, both serializing and deserializing classes with inline class attributes will prove problematic.
org.opentest4j.AssertionFailedError: expected: <{"fizz":"FUZZ","bizz":5,"bazz":["BUZZ","BOZZ"]}> but was: <{"fizz":"FUZZ","bazz":["BUZZ","BOZZ"],"bizz-pVg5ArA":5}>
Unrecognized field "bizz" (class com.severett.serializationcomparison.jackson.model.ValueClassFoo), not marked as ignorable (3 known properties: "fizz", "bizz-WZ4Q5Ns", "bazz"])
While there is a specialized module for Jackson –jackson-module-kotlin
– which provides support for many parts of Kotlin that are not included in the testing here (e.g. Pair, Triple, IntRange
, etc), it does not provide support for inline classes and does not plan on offering support for the foreseeable future. Instead, it is necessary to create custom serializer and deserializer classes to handle ValueClassFoo
and mark ValueClassFoo
with @JsonSerialize
and @JsonDeserialize
, respectively.
class ValueClassFooSerializer : JsonSerializer<ValueClassFoo>() {
override fun serialize(value: ValueClassFoo, gen: JsonGenerator, serializers: SerializerProvider?) {
gen.writeStartObject()
gen.writeStringField(ValueClassFoo.FIZZ_FIELD, value.fizz)
gen.writeNumberField(ValueClassFoo.BIZZ_FIELD, value.bizz.toInt())
gen.writeArrayFieldStart(ValueClassFoo.BAZZ_FIELD)
value.bazz.forEach(gen::writeString)
gen.writeEndArray()
gen.writeEndObject()
}
}
class ValueClassFooDeserializer : JsonDeserializer<ValueClassFoo>() {
override fun deserialize(jsonParser: JsonParser, ctxt: DeserializationContext?): ValueClassFoo {
val node = jsonParser.codec.readTree<JsonNode>(jsonParser)
return ValueClassFoo(
fizz = node[ValueClassFoo.FIZZ_FIELD].asText(),
bizz = node[ValueClassFoo.BIZZ_FIELD].asInt().toUInt(),
bazz = (node[ValueClassFoo.BAZZ_FIELD] as ArrayNode).map { it.textValue() }
)
}
}
@JsonSerialize(using = ValueClassFooSerializer::class)
@JsonDeserialize(using = ValueClassFooDeserializer::class)
data class ValueClassFoo(var fizz: String, var bizz: UInt, var bazz: List<String>) {
constructor() : this("", 0u, emptyList())
companion object {
const val FIZZ_FIELD = "fizz"
const val BIZZ_FIELD = "bizz"
const val BAZZ_FIELD = "bazz"
}
}
JSON-B
A relative newcomer to the Java world – having been first released only in 2017 alongside JEE 8 – JSON-B is an official standard for conducting serialization and deserialization for the JSON data format. The API uses either Eclipse Yasson or Apache Johnzon as the underlying implementation, meaning that either one of these libraries would have to be included as a runtime dependency; the tests for this article used Yasson as the implementation.
Usage
The basic usage is to construct an instance of Jsonb
via JsonbBuilder.create()
and invoke the functions Jsonb.toJson()
and Jsonb.fromJson()
in order to serialize an object and to deserialize a JSON string, respectively.
Working with Kotlin
JSON-B requires the most work of the four libraries evaluated in order to properly work with Kotlin.
- JSON-B serializes a class’s attributes in alphabetical order instead of declaration order. While this is not a deal-breaker – JSON objects do not require ordering for key fields – it is necessary to annotate a class with
@JsonbPropertyOrder
if specific ordering is desired.
@JsonbPropertyOrder("fizz", "bizz", "bazz")
data class PojoFoo(var fizz: String, var bizz: Int, var bazz: List<String>) {
constructor() : this("", 0, emptyList())
}
- Like Jackson, JSON-B requires a no-arg constructor and will fail if it does not encounter one while deserializing a JSON string into a class. Thus, a class without a no-arg constructor will need to mark the constructor that JSON-B needs to use with
@JsonbCreator
and mark each of the constructor’s arguments with@JsonbProperty
so that they correspond to the class’s attributes.
@JsonbPropertyOrder("fizz", "bizz", "bazz")
data class ImmutableFoo @JsonbCreator constructor(
@JsonbProperty("fizz") val fizz: String,
@JsonbProperty("bizz") val bizz: Int,
@JsonbProperty("bazz") val bazz: List<String>
)
- Lastly, JSON-B also shares Jackson’s trait of not being able to handle inline classes properly. Attempting to serialize
ValueClassFoo
will produce incorrect output, and while JSON-B will not fail while trying to deserialize a string toValueClassFoo
, it will fail to populate the inline class attribute correctly.
expected: <{"fizz":"FUZZ","bizz":5,"bazz":["BUZZ","BOZZ"]}> but was: <{"bazz":["BUZZ","BOZZ"],"bizz-pVg5ArA":5,"fizz":"FUZZ"}>
expected: <ValueClassFoo(fizz=FUZZ, bizz=5, bazz=[BUZZ, BOZZ])> but was: <ValueClassFoo(fizz=FUZZ, bizz=0, bazz=[BUZZ, BOZZ])>
Like Jackson, the target class will need special serializer and deserializer classes in order to handle it and be annotated as such.
class ValueClassFooSerializer : JsonbSerializer<ValueClassFoo> {
override fun serialize(valueClassFoo: ValueClassFoo, generator: JsonGenerator, ctx: SerializationContext?) {
generator.writeStartObject()
generator.write(ValueClassFoo.FIZZ_FIELD, valueClassFoo.fizz)
generator.write(ValueClassFoo.BIZZ_FIELD, valueClassFoo.bizz.toInt())
generator.writeStartArray(ValueClassFoo.BAZZ_FIELD)
valueClassFoo.bazz.forEach(generator::write)
generator.writeEnd()
generator.writeEnd()
}
}
class ValueClassFooDeserializer : JsonbDeserializer<ValueClassFoo> {
override fun deserialize(jsonParser: JsonParser, ctx: DeserializationContext?, rtType: Type?): ValueClassFoo {
var fizz: String? = null
var bizz: UInt? = null
var bazz: List<String>? = null
while (jsonParser.hasNext()) {
val event = jsonParser.next()
if (event != JsonParser.Event.KEY_NAME) continue
when (jsonParser.string) {
ValueClassFoo.FIZZ_FIELD -> {
jsonParser.next()
fizz = jsonParser.string
}
ValueClassFoo.BIZZ_FIELD -> {
jsonParser.next()
bizz = jsonParser.int.toUInt()
}
ValueClassFoo.BAZZ_FIELD -> {
jsonParser.next()
bazz = jsonParser.array.getValuesAs(JsonString::class.java).map { it.string }
}
}
}
if (fizz != null && bizz != null && bazz != null) {
return ValueClassFoo(fizz = fizz, bizz = bizz, bazz = bazz)
} else {
throw IllegalStateException("'fizz', 'bizz', and 'bazz' must be not null")
}
}
}
@JsonbTypeDeserializer(ValueClassFooDeserializer::class)
@JsonbTypeSerializer(ValueClassFooSerializer::class)
data class ValueClassFoo(var fizz: String, var bizz: UInt, var bazz: List<String>) {
constructor() : this("", 0u, emptyList())
companion object {
const val FIZZ_FIELD = "fizz"
const val BIZZ_FIELD = "bizz"
const val BAZZ_FIELD = "bazz"
}
}
Kotlinx Serialization
Finally, the authors of Kotlin have published their own serialization library for the Kotlin programming language. First released in 2020, the Kotlinx Serialization library is designed for serialization actions in general, not just JSON; while the library only contains official support for JSON, it has experimental support for other formats like Protobuf and CBOR as well as community support for formats like YAML.
Usage
Unlike the other JSON serialization libraries, there is no instance object that needs to be created for conducting serialization actions. Instead, calls to the extension functions encodeToString()
and decodeFromString()
are made for the serializing object in question, in this case, the Kotlin object Json.
Working With Kotlin
Also unlike the other JSON serialization libraries, Kotlinx Serialization does not work on custom classes by default. This is due to the way that the library works: instead of using reflection like the other libraries, Kotlinx Serialization generates specific serialization and deserialization functions for the target class(es) at compile time. In order to recognize which classes need this serialization code generated for it, any target classes need to be annotated with @Serializable
(a different method is available for third-party classes).
@Serializable
data class PojoFoo(var fizz: String, var bizz: Int, var bazz: List<String>) {
constructor() : this("", 0, emptyList())
}
In addition, Kotlinx Serialization does not work by default on attributes with a default value. This needs to be enabled with the annotation @EncodeDefault.
@Serializable
@OptIn(ExperimentalSerializationApi::class)
data class DefaultValueFoo(@EncodeDefault val fizz: String = "FUZZ", var bizz: Int, var bazz: List<String>) {
constructor() : this(bizz = 0, bazz = emptyList())
}
Testing
Parameters
Each of the four JSON serialization libraries conducts serialization and deserialization of the four example classes, and the Java Microbenchmark Harness (JMH) benchmark tests measure the throughput of how many operations get executed per second on average. For example:
@State(Scope.Benchmark)
open class SerializationComparison {
private val gson = Gson()
@Benchmark
fun serializePojoFoo(): String = gson.toJson(pojoFoo)
@Benchmark
fun serializeImmutableFoo(): String = gson.toJson(immutableFoo)
@Benchmark
fun serializeDefaultValueFoo(): String = gson.toJson(defaultValueFoo)
@Benchmark
fun serializeValueClassFoo(): String = gson.toJson(valueClassFoo)
@Benchmark
fun deserializePojoFoo(): PojoFoo = gson.fromJson(pojoFooStr, PojoFoo::class.java)
@Benchmark
fun deserializeImmutableFoo(): ImmutableFoo = gson.fromJson(immutableFooStr, ImmutableFoo::class.java)
@Benchmark
fun deserializeDefaultValueFoo(): DefaultValueFoo = gson.fromJson(defaultValueFooStr, DefaultValueFoo::class.java)
@Benchmark
fun deserializeValueClassFoo(): ValueClassFoo = gson.fromJson(valueClassFooStr, ValueClassFoo::class.java)
}
These tests utilize JMH’s defaults of:
- 5 warmup rounds of 10 seconds
- 5 rounds of measurements
- 5 forked processes to conduct both of the above
The tests are run on a macOS with an Intel Core i7 2.6 GHz 6-Core and 16GB of RAM; the executing JVM is Temurin 19+36.
Results
Serialization
The clear winner among the four libraries is Kotlinx Serialization, as it averages over 5 million operations per second, much faster than the second-place Jackson library. It’d be impossible to identify the exact reasons for why the performance of Kotlinx Serialization is so much higher compared to the competition without diving too deeply into the source code of each library, but a hint may lie in how the other libraries perform much better during the serialization of ValueClassFoo
compared to the other example classes (the exception is Kotlinx Serialization – which appears to do worse, but given the error ranges for each result, it’s not statistically significant). For example, running the Java Flight Recorder profiler on Jackson provides the following result in the call tree for serializing PojoFoo
:
In contrast, here is the call tree for serializing ValueClassFoo
:
As the two call trees show, creating a special class for the serialization of instances of ValueClassFoo
means that Jackson does not have to use reflection – a very expensive process, computationally-speaking – to determine what attributes need to be serialized. Of course, this comes with the downside of having more code for the developer to maintain, and will break as soon as the class’s attributes are modified.
Deserialization
Again, Kotlinx Serialization clearly performs better for deserializing compared to the remaining three libraries. GSON, Jackson, and Kotlinx Serialization all performed markedly better when deserializing instances of DefaultValueFoo
, and that’s presumably due to the fact that there were fewer data to read in for the deserialization test – for that scenario, the libraries had to deserialize {"bizz":5,"bazz":["BUZZ","BOZZ"]}
, meaning one less field to parse. Interestingly, Jackson did worse in deserializing ValueClassFoo
compared to the other example classes. Again using the Java Flight Recorder profiler, here is a flame graph for Jackson deserializing PojoFoo
:
Likewise, here is a flame graph for Jackson deserializing ValueClassFoo:
It appears that, in contrast to serialization actions, Jackson’s default deserializer is faster than a hand-rolled deserializer. Of course, there wasn’t a choice for doing this in the case of an inline class: it was either creating the custom deserializer or having the code crash.
Final Thoughts
While the tests provide promising results for the Kotlinx Serialization library, there are a few caveats that must be provided:
- The example classes were relatively simple in order to reduce the amount of variables between testing scenarios. Conducting serialization and deserialization actions on large and complex data structures might provide entirely different results in favor of a different serialization library.
- Due to the Kotlinx Serialization code being developed for the Kotlin programming language, code written in Java would have to be rewritten in Kotlin in order to use the library, something that might be a very time-consuming endeavor and a hard sell for a project that has a large code base written in Java. The other three libraries, on the other hand, have no such restriction and can be used with Java and Kotlin alike.
Regardless, the results do suggest that it would behoove Kotlin developers to give the Kotlinx Serialization library a try in their projects, as aside from the high performance, it also provides the opportunity to be a “one-stop shop” for serialization not only for JSON but for other formats like Protobuf, YAML, and more.
Interested in reading more content on Kotlin? Keep an eye on Apiumhub´s blog; new and useful content gets published every week.