Opening

Here is a question I want you to sit with for a moment. How many String objects does your app create when it parses a single API response? Ten? Fifty? Try hundreds. Maybe thousands. Every JSON key, every JSON value, every intermediate concatenation, every toString() call in your logging — each one creates a new String object on the heap. And nobody measures it. We obsess over network latency, we profile our database queries, we optimize our layouts — but we almost never look at the sheer volume of String objects our code produces. Today we are going to pull back the curtain on strings, buffers, and copies on the JVM. You are going to understand why String operations are more expensive than they look, what the compiler actually does with your string templates, and how to avoid creating a blizzard of garbage objects every time you process data.

Strings Are Immutable on the JVM

Let us start with the fundamental fact that explains everything else. On the JVM, Strings are immutable. We covered this in the mutability video, but now let us explore the consequences. When you have a String "Hello" and you call plus(" World"), the JVM cannot just append bytes to the existing object. It must create a brand new String object: allocate memory, copy the bytes from "Hello", copy the bytes from " World", and give you a reference to this new object. The old "Hello" String is untouched, sitting in memory, waiting to be garbage collected. One concatenation. One new object. One old object that becomes garbage. This seems minor. And for one concatenation, it is. But think about what happens in a loop.

Concatenation in Loops: The Silent Killer

Imagine you are building up a log message in a loop. You start with an empty String and concatenate a new piece on each iteration. Ten items in the loop means ten concatenations. Each concatenation creates a new String. But worse than that — each new String is larger than the last, because it includes all the previous content plus the new piece. You are copying an ever-growing string over and over. For ten items, you create ten String objects and copy a total amount of data that grows quadratically. For a hundred items, you are allocating and copying a staggering amount. This is O of n-squared behavior hiding behind a simple-looking plus-equals operator. The fix is StringBuilder. StringBuilder is a mutable buffer — an array of characters that can grow. You append to it, and it writes into the buffer without creating new objects. When you are done, you call toString() once to produce the final String. One allocation at the end instead of one per iteration. The Kotlin compiler actually knows about this. If you concatenate strings in a single expression, it generates StringBuilder code automatically. But in a loop — where each iteration is a separate statement — you need to use StringBuilder explicitly.

Kotlin String Templates: What They Compile To

Kotlin's string templates are one of the language's nicest features. You write "User: $name, age: $age" and it feels natural and clean. But what does the compiler generate? It creates a StringBuilder, appends "User: ", appends the result of name.toString(), appends ", age: ", appends the result of age.toString(), and calls toString() on the StringBuilder. This is efficient for a single template expression. One StringBuilder, a few appends, one final String. But be aware of the hidden costs. That name.toString() call — if name is already a String, there is no cost. But if it is a data class? Kotlin's auto-generated toString() creates another String with another StringBuilder, concatenating all the fields. If you have nested data classes, each one generates its own toString(). In a logging statement like Log.d(TAG, "Response: $response"), if response is a complex data class, you might create dozens of intermediate String objects just for that one log line. In production, with logging happening on every API call, this adds up.

String.intern() and the String Pool

We mentioned the String pool before — the JVM's optimization that reuses identical string literals. But there is also String.intern(), which lets you manually add strings to the pool. When you call "someString".intern(), the JVM checks if an equal string already exists in the pool. If yes, it returns the pooled reference. If no, it adds this string to the pool and returns it. This can save significant memory when you have many duplicate strings. Think about JSON parsing. If your API returns a list of a hundred users, each with a "name" key, a "email" key, a "role" key — that is the same key strings repeated a hundred times. Without interning, you have a hundred separate "name" String objects. With interning, you have one. But interning has costs too. The pool lookup is not free. The pool itself takes memory and is never garbage collected in older JVM versions. And if you intern strings that are unique — like user IDs or timestamps — you are wasting memory adding them to a pool where they will never be matched. Use interning selectively, for strings you know are highly duplicated.

JSON Parsing: The String Factory

Let us quantify the String problem with a real example. Say your app fetches a JSON array of fifty user objects. Each user has six string fields — name, email, avatar URL, role, city, bio. The JSON parser reads each field and creates a String object for each key and each value. That is twelve Strings per user — six keys plus six values. Times fifty users, that is six hundred String objects just from the field parsing. But there is more. The parser might create intermediate Strings for number parsing, for unescaping unicode characters, for handling quoted strings. Then your code maps these into data class instances, potentially copying some values. Then you might log the response, generating toString() strings. A single API response can easily create over a thousand String objects. Each one is a heap allocation. Each one eventually needs garbage collection. This is why high-performance JSON libraries like Moshi and kotlinx.serialization use tricks to minimize allocations — streaming parsers that reuse buffers, code generation that avoids reflection, and careful management of intermediate strings.

Char Arrays vs Byte Arrays

Here is a low-level detail that matters more than you might think. On the JVM, Strings internally store their data as either a byte array or a char array, depending on the content and the JVM version. In older JVMs, it was always a char array — two bytes per character, even for ASCII text. Since Java 9, the JVM uses compact strings: ASCII-only strings use one byte per character, while strings with non-ASCII characters use two bytes per character. This means that if you are processing raw data — reading from a network stream, parsing binary protocols, processing file bytes — you should avoid converting to String until you actually need a String. Work with byte arrays as long as possible. Every byte-array-to-String conversion allocates a new String object and copies all the bytes. If you are searching through data, comparing values, or extracting substrings, and the data is in bytes, operating on the byte array directly can avoid significant allocation overhead.

Measuring the Cost

Let me show you how to actually measure String allocations. In Android Studio, open the Memory Profiler and record an allocation trace while your app processes an API response. Filter by java.lang.String. You will see every String allocation — where it happened, how large it was, and who triggered it. The numbers will surprise you. Another approach: in a unit test, you can track allocations before and after an operation using Runtime.getRuntime(). Call freeMemory() before and after to see the delta. It is approximate, but it gives you a ballpark. A third approach: count the number of String objects in a heap dump. Take a heap dump after processing a response, search for java.lang.String instances, and look at the count and total retained size. I have seen apps where Strings account for 30 to 40 percent of all heap allocations. That is not unusual — it is just invisible until you measure it.

toString() in Data Classes: Hidden Logging Cost

This one is a trap that catches almost every team. You have a data class with a dozen fields. You log it: Log.d(TAG, "State: $state"). Kotlin's generated toString() dutifully creates a string with every field. If one of those fields is a List of fifty items, toString() iterates through all fifty, calling toString() on each. If those items are themselves data classes, each one generates its own toString(). One log statement, potentially hundreds of String objects. Even worse: in production builds, if your logging framework checks the log level and decides not to actually output the message, the String was still created. The toString() was still called. The allocations still happened. The fix? Use lazy logging: Log.d(TAG) { "State: $state" } — the lambda is only invoked if the log level is enabled. Or override toString() on data classes that appear in logs to return only the relevant fields. Or simply do not log complex objects on hot paths.

StringBuilder and Buffer Reuse Patterns

When you need to build strings repeatedly — say, formatting items in a RecyclerView adapter — reusing a StringBuilder can make a measurable difference. Instead of creating a new StringBuilder for each item, create one and clear it between uses. Call setLength(0) to reset it without deallocating the internal buffer. The buffer stays allocated at its previous size, ready to be filled again. This same principle applies to any buffer. ByteArrayOutputStream for building byte arrays. BufferedOutputStream for writing to streams. CharArrayWriter for accumulating characters. If you allocate these once and reuse them, you avoid repeated allocation and garbage collection of the internal buffers. The pattern is straightforward: create the buffer once, use it, clear it, reuse it. In Android's RecyclerView, the onBindViewHolder method is called potentially hundreds of times during a scroll. If each call creates and discards a StringBuilder, that is hundreds of allocations. If you reuse one, it is zero.

Practical Recommendations

Let me give you a practical checklist for managing strings in Android apps. First: avoid string concatenation in loops — use StringBuilder. Second: be aware that string templates are efficient for simple expressions but can trigger cascading toString() calls on complex objects. Third: use lazy logging to avoid creating log strings that will never be output. Fourth: if you process high-volume JSON, consider a streaming parser or code-generated serialization. Fifth: work with byte arrays instead of Strings when processing raw data — defer the String conversion until display time. Sixth: consider String.intern() for highly duplicated strings like JSON keys, but measure the benefit first. Seventh: reuse buffers in hot paths — RecyclerView binding, animation frames, real-time data processing. And finally: measure. Open the memory profiler, record during a typical flow, and look at String allocations. You will find opportunities to reduce them, often dramatically.

Recap and What is Next

Here is what we covered. Strings are immutable on the JVM, so every modification creates a new object. Concatenation in loops is O of n-squared — use StringBuilder. Kotlin string templates compile to StringBuilder but trigger toString() on embedded objects. JSON parsing creates hundreds or thousands of String objects per response. String.intern() can reduce duplicates but has its own costs. Char arrays and byte arrays avoid premature String creation. toString() on data classes is a hidden allocation machine, especially in logging. And buffer reuse patterns eliminate repeated allocations in hot paths. In the next video, we are going to explore the GC's perspective — WeakReferences, SoftReferences, and how the garbage collector sees different types of references. See you there.