In this article, we’ll learn how to improve the performance of the applications that tend to use most of the space due to extensive usage of Strings. There’s no need to store more than one instance of immutable objects in our heap, and String is a good example.

Object Allocation

Let’s briefly refresh the information about how Strings are allocated on the heap. There are two ways to create a String, using a literal or a new keyword:

String stringA = "Hello!";

String stringB = "Hello!";
String stringC = new String("Hello!");

The Strings identity is often a question in interviews and Java quizzes. The difference is the process used to create these Strings. Creating a String without a new keyword provides implicit optimization by caching and storing it in a string pool. This way, the code can reuse the string from the string pool without allocating new memory. 

However, creating a String with the new keyword explicitly allocates a new object in the heap, which adds to the memory consumption. We can show this by simple identity checks:

System.out.println("stringA == stringB: " + (stringA == stringB));

System.out.println("stringA == stringC: " + (stringA == stringC));
System.out.println("stringB == stringC: " + (stringB == stringC));

As a result, we’ll get the following output:

stringA == stringB: true

stringA == stringC: false
stringB == stringC: false

String.intern()

While creating a String with a new keyword, we can still optimize it with the String.intern() method. This method would place the string into a string pool if it’s not there or return the reference to the same string in the pool. 

This way, we can reuse already allocated Strings. The fact that Strings are immutable allows us to reuse them throughout the application safely.

System.out.println("stringB == interned stringC: " + (stringA == stringC.intern()));

In the previous code snippet, we interned the stringC which substituted it with a reference to the string in the string pool:

stringB == interned stringC: true

String Deduplication

However, it’s not always possible to optimize the String allocations in the code. Luckily, Java provides us with a mechanism to do it from the outside. 

1. Main parameter

-XX:+UseStringDeduplication allows us to do something similar to String.intern() but implicitly during garbage collection. However, it’s not enough just to add this parameter while running out applications. There are a couple of additional things we need to understand about this process.

2. Deduplication Age

First of all, as was mentioned previously, the deduplication process happens during garbage collection. However, because the operation is costly, it works only on mature objects. This way, we avoid spending time on the optimization for short-lived strings. 

-XX:StringDeduplicationAgeThreshold sets the “maturity” of the Strings that would undergo the optimization step. For example, setting it to five would require a String to survive five garbage collection cycles. By default, the parameter is set to three, meaning the strings would be deduplicated only after surviving three garbage collection cycles. 

3. Deduplication Info

It’s possible to get more information about the deduplication process using special VM options. -XX:+PrintStringDeduplicationStatistics can be used before Java 9 and Xlog:stringdedup*=debug from Java 9 and beyond. 

Getting the information about deduplication is an excellent way to control and evaluate it and gain additional insight into the process. 

4. Performance Comparison

Let’s review how String deduplication affects our application. First, let’s check the most extreme version:

public static void main(String[] args) {

String helloWorld = "Hello World!";
final LinkedList<String> strings = new LinkedList<String>();
for (int i = 1; i < 1_000_000_000; i++) {
strings.add(new String(helloWorld));
}
}

The heap dump from this example clearly shows that we have a problem with memory allocation, and the reason for this is the Strings. As we can see, the Strings take half of our heap (the –Xmx for this application is set to 512MB to make it easier to analyze):

Heap Histogram generated by HeapHero
Fig: Heap Histogram generated by HeapHero

Repeated allocation of the same String might be considered a memory leak and can cause OutOfMemoryError if the objects live long enough and the creation rate is relatively high. Let’s run the same code but allow String deduplication and check the heap dump:

Heap Histogram generated by HeapHero
Fig: Heap Histogram generated by HeapHero

The code still results in the OutOfMemoryError but for different reasons. The size of the LinkedList grew so dramatically that it consumed the entire heap. However, it’s not the main focus of our benchmark.

Let’s concentrate on the space allocated only to Strings for now. As we can see, the Strings take up only around 500KB. 

However, the fact that we can do this doesn’t mean we should do it for all the applications. Let’s review the negative impact of String deduplication when we don’t gain many benefits. We have duplicated Strings, but they are cleaned up regularly:

@BenchmarkMode(Mode.Throughput)

@Measurement(time = 5, iterations = 1, timeUnit = TimeUnit.MINUTES)
@Warmup(iterations = 1, time = 10, timeUnit = TimeUnit.SECONDS)
@Fork(1)
public class StringDeduplicationPerformanceBenchmark {

@State(Scope.Benchmark)
public static class SoftReferenceSetHolder {
final SoftReferenceSet<List<String>> set = new SoftReferenceSet<>();
}
@Benchmark
@Fork(jvmArgs = {"-Xmx6g", "-Xms6g", "-XX:+UseG1GC",
"-XX:+PrintStringDeduplicationStatistics",
"-Xloggc:./gc-without-string-deduplication.txt"})
public void stringCreationWithoutDeduplication(SoftReferenceSetHolder softReferenceSetHolder) {
final List<String> list = new LinkedList<>();
for (int i = 1; i < 1_000_000; i++) {
list.add(getNewString());
}
softReferenceSetHolder.set.add(list);
}
@Benchmark
@Fork(jvmArgs = {"-Xmx6g", "-Xms6g", "-XX:+UseG1GC",
"-XX:+UseStringDeduplication", "-XX:+PrintStringDeduplicationStatistics",
"-Xloggc:./gc-with-string-deduplication.txt"})
public void stringCreationWithDeduplication(SoftReferenceSetHolder softReferenceSetHolder) {
final List<String> list = new LinkedList<>();
for (int i = 1; i < 1_000_000; i++) {
list.add(getNewString());
}
softReferenceSetHolder.set.add(list);
}

private static String getNewString() {
return new String( "Hello World!");
}
}

This simple benchmark does the same job, putting Strings into our home-grown LRU cache. One has String deduplication enabled, and the other doesn’t. The heap fills up, but the garbage collector consistently cleans it up:

Heap Usage without XX:+UseStringDeduplication flag
Fig: Heap Usage without XX:+UseStringDeduplication flag – generated by GCeasy

Let’s run the same application but with String deduplication enabled:

Heap Usage with XX:+UseStringDeduplication flag
Fig: Heap Usage with XX:+UseStringDeduplication flag – generated by GCeasy

The graphs look similar. The first one has more frequent garbage collection than the second, which reflects better throughput. The one without string deduplication has the following KPIs:

with and without XX:+UseStringDeduplication
Fig: Comparison of with and without XX:+UseStringDeduplication

The main goal of the KPI is to show the main metrics in a succinct way. They mostly concentrate on the application throughput: how much time the application spends on the useful work. Also, it shows the latency of our application and the average and max pauses, which are crucial for applications that require low latency. 

Additionally, we can check string deduplication statistics, which can help us understand where we spent additional time:

Table of deduplication statistics
Fig: Table of deduplication statistics

Garbage collector have longer pauses while the String deduplication is enabled. More significant CPU utilization is the main downside of using deduplication, as all the checks are happening during the garbage collection cycle. 

The decision to use string deduplication should be weighted and tailored to each specific application. However, it doesn’t mean that string deduplication is terrible. In this article, we used a crude example to show the cost of string deduplication in the application. It can help to prevent OutOfMemoryErrors and allow applications to run using a smaller heap, but as with any optimization, we have the trade-off between time and space. 

Conclusion

Many IDEs highlight and recommend using static constants for the values used in the code more than once. String deduplication is a similar step on the JVM level. However, all the optimizations come with a cost. Premature optimization or incorrect deduplication age threshold can create unnecessary overhead. 

We should base the decision for optimization on concrete metrics and code analysis. yCrash can help identify improvement opportunities and check the constant performance of running applications.