DEV Community

Apiumhub
Apiumhub

Posted on • Originally published at apiumhub.com on

JSON in Kotlin: Comparing Options

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())
}

Enter fullscreen mode Exit fullscreen mode

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>)
Enter fullscreen mode Exit fullscreen mode

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())
}

Enter fullscreen mode Exit fullscreen mode

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())
}

Enter fullscreen mode Exit fullscreen mode

(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 ObjectMapperand 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>
)

Enter fullscreen mode Exit fullscreen mode
  • 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"])
Enter fullscreen mode Exit fullscreen mode

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 ValueClassFoowith @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"
   }
}

Enter fullscreen mode Exit fullscreen mode

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())
}

Enter fullscreen mode Exit fullscreen mode
  • 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>
)

Enter fullscreen mode Exit fullscreen mode
  • 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 to ValueClassFoo, 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])>

Enter fullscreen mode Exit fullscreen mode

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"
   }
}

Enter fullscreen mode Exit fullscreen mode

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())
}
Enter fullscreen mode Exit fullscreen mode

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())
}
Enter fullscreen mode Exit fullscreen mode

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)
}

Enter fullscreen mode Exit fullscreen mode

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

R6vihdTKTV5oqr2ztMpGB4qeaUiWVEpmmD8H5R64sOLTOdFopqwUgkN 8JNARdgbb hGMyCIY1ZPyrDsEcbTnJ73p d1idZAjh04TaQGvuBOGk GzapSuny6CgyPcgo2LcIlPJi6YfQupcI2uu6WOuOj9T lllUeBbfRTfoklg0YBJqf9bSEOrpLFQ8laQ

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:

cWhOTs6MRZf3o2Vnp6RBkWW9HGSmMUvVO pDxnxVCNBvqBBQGJifl3q5jwXCIi9PMYfoSwg6pp7AMZUvbrl1Zs GxfavY3FVF5TQSq4CfvSaGrp7l m7sq35Xo KYJUikp1RLSEhFKXkdnmQPnEuzj2Z8wgTzxZHWtVJm4ph

In contrast, here is the call tree for serializing ValueClassFoo:

n35ET8eM7u ZISwbcmBRneFrmlfn0PMCi3pvgilNMYGN4vLVnFUWGfyURTSesas M8N1kwXV5c9qL SMscvd1TrmVSXcPTadlpbo4Eu5rbvDmuZLyIvAPxxyRbkqXz7V3noX1yt9a2uGuB9mGoM rGcDzGSrZ9PUvjFCXPjyN30aINjsHUK1MDg0dZkJwA

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

s45GB5gkTWpDaQeEJGjihZoO5BWG0QfrdqUMUajruHdnt9aeuQ4H6FCrXvreVwZ7DAjWwEzAM9qMDPtWNKmdOj9kDxEpvKapUiCgy5fFznwnISfMmmBVUvOnMn1VMMNp9Oyljk6lbGjtcTLwc5F3R5TSyAaZiO18W2NQGIpvja0DWZCSC 5AKj3oOpIhw

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:

VZWOWjXpXwHm2O1p7i1SYs3J9MK0ky6VNvcJXlx6jJrz66O5IEq8XjN53M z1mXo52P5EziuBjmXGSoOPPQoH4WcbSodQKNPkOOljZ1yHVhLQBJiHXhJEo1It0zlwT53bGnJkhzR6UNkTXNCBfJIqeZeKLdaluuPn4FrR 62fvpdFMPtc3DoSBbyrYK4Gw

Likewise, here is a flame graph for Jackson deserializing ValueClassFoo:

gYwE4Kwjd4yWMRWQDlLP kqZJJ1 18rDAs9KUKgoSp2 7PFmKcDUfvLKFFp1Ev

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.

Latest comments (0)