1. Introduction
If you’re writing a study project and it processes 10 lines, then memory questions sound like nitpicking of the “don’t breathe near the CPU—it’ll overheat” kind. But as soon as there’s more data (logs, files, records in collections, reports), it suddenly turns out that the most expensive problems are not “complex algorithms” but a pile of small allocations and constant cleanup by the garbage collector. On the JVM this shows up as unpredictable pauses, spikes in memory usage, and the feeling that the program either flies or stares thoughtfully out the window.
To talk to the JVM in the same language, we need a working model in three words: stack, heap, GC. This model doesn’t explain everything (today we intentionally won’t dive into bytecode/JIT/fine GC tuning), but it explains 80% of “why it got slower” and “where the memory went”.
2. Minimal JVM memory model: stack and heap
When you call a function in Kotlin, you don’t just “jump to another line”. The JVM has to store information somewhere about what this call is, what local variables it has, and where to return. That place is the stack. And when you create objects (classes, strings, lists, exceptions), they typically live in the heap. And when you stop referencing them, GC (the garbage collector) shows up from time to time and says: “Alright, who’s unnecessary here?”
An important idea right away: most performance problems in “typical application Kotlin” are not about the stack—they’re about the heap. The stack usually fails dramatically (for example, StackOverflowError), while the heap fails slowly and sadly: “somehow it eats more and more memory and the pauses are kind of weird”.
Stack: memory for function calls and why it’s “not unlimited”
Imagine a stack of trays in a cafeteria. You put a new tray on top when you call a function, and you remove it when the function finishes. While the function runs, its “tray” holds local variables (roughly speaking). That’s the stack: a LIFO structure (last in—first out).
Practically, this means: if you accidentally do infinite recursion or very deep recursion, the stack grows, grows, grows… and then runs out. On the JVM this typically leads to StackOverflowError (in Kotlin it’s also a serious Error-level problem, not a “regular error you should catch”). Kotlin’s exception documentation explicitly emphasizes that such things are problems the application often cannot “recover from normally”.
We don’t need to optimize the stack right now. What matters is understanding: the stack is about call depth and per-call local data, not about “all your lists and strings”.
Heap: memory for objects—everything interesting and everything expensive
The heap is a “shared warehouse of objects”. Almost everything that looks like an object in Kotlin ends up there: class instances, String, collections, Pair, lambdas (in some cases), exceptions, and so on.
The key point: the heap “lives longer than a single function call”. You created a MutableList, returned it from a function—it continues to exist. As long as there are references, it’s considered needed. Once there are no references, the object becomes a candidate for garbage collection.
And here comes a subtle but very practical idea: performance is strongly affected not only by “how many objects are alive”, but also by “how often you create new ones”. If you create a million tiny objects every second, the GC will be very busy.
References: why val doesn’t “put an object into a variable”
Many beginners subconsciously think that val x = ... “puts an object into a variable”. On the JVM it’s often more useful to think like this: the variable stores a reference to an object in the heap (an address/pointer in everyday terms), not the object itself.
That’s why the phrase “a collection in val is immutable” is incorrect (if it’s a MutableList). val protects the reference (you can’t reassign a different collection), but it doesn’t forbid modifying the object itself. Kotlin documentation explicitly notes that you can keep even a mutable collection in a val so you don’t lose control over the reference and don’t accidentally replace it with another collection.
A mini example to “feel” the idea of references:
fun main() {
val xs = mutableListOf(1, 2)
val ys = xs
ys.add(3)
println(xs) // [1, 2, 3]
println(ys) // [1, 2, 3]
}
ys didn’t “copy the list”—it got a second reference to the same heap object.
3. GC: who takes out the trash and why it sometimes gets in the way
The JVM garbage collector is a mechanism that frees memory in the heap when objects become unreachable (no one references them anymore). It does what you would have to do manually in languages without GC (and yes, manually this ends either in leaks, or “oops, we freed memory twice”, or all of the above).
But the convenience comes at a price: garbage collection is work that consumes resources and sometimes leads to pauses. In the best case you don’t notice GC at all. In the worst case the program starts to “stutter”, and you open monitoring and see: the CPU isn’t doing useful work, but the application keeps pausing.
A helpful everyday model: the less garbage you produce, the happier GC is. And this leads us smoothly to allocations.
Let’s draw a simplified diagram of an object’s “life path”:
flowchart TD
A[Created an object in the heap] --> B[It has references]
B --> C{Did references disappear?}
C -- no --> B
C -- yes --> D[Object becomes a candidate for GC]
D --> E[GC frees memory]
That’s enough to understand why “extra temporary objects” are not a harmless detail.
4. Allocations: where extra objects are born in Kotlin
An allocation is the moment when the JVM reserves space in the heap for a new object. One allocation by itself is usually not a disaster. The disaster is a thousand allocations inside a loop that runs a million times. So our goal is to learn to spot typical places where Kotlin code “accidentally” creates many temporary objects.
The most honest way to think: everything that looks like “created a new value” potentially creates a new object. Sometimes the compiler optimizes it away, sometimes it doesn’t. So we keep simple patterns in mind that almost always cost a lot.
Strings and concatenation in loops: a classic garbage factory
Strings on the JVM are objects. Moreover, they are immutable: if you do "a" + "b", the JVM doesn’t “append to the old string”—it creates a new one.
In Kotlin, the + operator for strings is convenient, but if you start doing result += ... in a loop, you often create many temporary strings. A typical anti-example:
fun buildReportBad(lines: List<String>): String {
var s = ""
for (line in lines) {
s += line + "\n"
}
return s
}
Yes, it’s readable. Yes, it works. Yes, GC cries afterward.
The right everyday tool is StringBuilder:
fun buildReportGood(lines: List<String>): String {
val sb = StringBuilder()
for (line in lines) {
sb.append(line).append('\n')
}
return sb.toString()
}
StringBuilder keeps an internal buffer and grows it as needed, so you don’t create a new string at every step.
Collections and map/filter chains: intermediate lists are allocations too
Many Kotlin collection operations return a new collection: map, filter, sorted, etc. That’s great for readability and functional style, but it’s important to remember: each such operation can create an intermediate list.
If you have a chain of five transformations on a large collection, you potentially create several large temporary lists. Sometimes that’s fine (readability is more important). Sometimes it’s expensive. This is where we remember Sequence: it lets you make transformations lazy and reduce the number of intermediate collections (but it’s not free either).
By the way, Kotlin documentation emphasizes the difference between collections and arrays: an array has a fixed size, while collections are more convenient for adding/removing elements and in general for typical tasks. This isn’t directly about memory, but it matters indirectly: attempts to “optimize everything with arrays” often turn into complex and brittle code.
Pair, Triple, and small temporary objects: seems minor, but adds up fast
Pair and Triple are convenient, but they are objects too. If you use them as temporary containers in a hot spot (for example, returning a Pair from a function inside a loop), you create many short-lived objects.
Sometimes that’s okay. Sometimes it’s better to return separate values differently (for example, via a specialized model, or via updating an accumulator, or via fold). But it’s important not to turn the code into “optimization for optimization’s sake”: first you need to understand what exactly is your hot spot.
5. Boxing: why List<Int> can be heavier than it looks
Boxing is when a “simple number” (Int) turns into a wrapper object because it needs to be placed where objects are expected (for example, into a generic collection).
In Kotlin/JVM, generics mostly work with reference types, and so List<Int> at the JVM level often stores not “bare ints” but wrapper objects. This doesn’t mean List<Int> is always evil. It means that for huge arrays of numbers it can sometimes be better to use specialized structures.
Kotlin’s documentation on arrays explicitly says that if you use Array with primitives, they will be boxed, and it suggests primitive arrays (IntArray, DoubleArray, etc.) instead to avoid the overhead of boxing.
Array<Int> vs IntArray: a small “on-the-fingers” example
fun main() {
val a: Array<Int> = arrayOf(1, 2, 3)
val b: IntArray = intArrayOf(1, 2, 3)
println(a.joinToString()) // 1, 2, 3
println(b.joinToString()) // 1, 2, 3
}
From the outside it’s almost the same. But the idea is: IntArray stores primitives more densely and without boxing.
When this matters in our “practical CLI project”
In our training app (a hypothetical CLI tracker/analyzer where we store records and build reports), most data is usually objects: expense/event/string records. Boxing isn’t the main enemy there.
But if you add a subsystem that stores large numeric series (for example, daily sums by day, metrics, time series), then switching from List<Int> to IntArray can sometimes drastically reduce memory pressure. And that affects GC: fewer objects—less garbage—fewer pauses.
6. Overheads: exceptions and reflection
In earlier days of the course we already discussed exceptions as an error-handling mechanism. Here we look at them from the memory and performance angle—and we put reflection next to them because it has a similar profile: “powerful, but not free”.
Exceptions and stack traces: why try/catch for parsing is expensive
An exception is an object. And it carries context: a message, a cause (cause), and the most “expensive” part—the stack trace, i.e., the list of stack frames that shows how execution arrived at the point of failure. Kotlin documentation separately explains that a stack trace is a sequence of function calls and gives an example of JVM output.
That’s useful for debugging, but it means: creating an exception is not the same as “returned null”. An exception is usually more expensive because it needs to gather diagnostic information.
Moreover, the documentation stresses that exceptions are stateful objects, and you shouldn’t make them object singletons, because the state (including the stack trace) should reflect the place where they occurred. This is another hint: an exception is not a “cheap flag”, but a “thick folder of documents”.
If the error is expected (for example, the user entered a non-number), it’s often better to use toIntOrNull()/toDoubleOrNull() and validation. In our course this already came up: “read → prepare → parse”, and if there’s a problem—return null and ask the user to repeat the input.
And it’s convenient to recall the fail-fast style here: require and check are a quick way to throw an exception when you can’t continue, and Kotlin officially describes these preconditions as a way to automatically throw exceptions when conditions are violated.
Reflection and memory: why dynamic access is heavy
We just studied reflection, so now we can honestly say: yes, it’s powerful, but nothing comes for free.
Reflection often leads to:
- additional metadata (sometimes also a separate dependency),
- member lookup by scanning lists,
- creating helper objects for invocation,
- more complicated tracing and debugging.
From a memory perspective, the main practical advice is: don’t do reflection in a hot loop. If you need to invoke a method/read a property by name, it’s usually worth finding the “descriptor” once (a conditional KFunction/KProperty) and reusing it, rather than scanning memberFunctions from scratch every time.
Even if you don’t measure this with benchmarks (and today we’re not diving into measurement discipline), common sense works great here: repeating “list scan” + “wrappers” + “invoke via call” 100000 times is almost always a bad idea.
7. Mini refactoring: reducing allocations without losing readability
Now we’ll do the most life-useful thing: look at a piece of code from the “practical CLI project” and improve it so it creates less garbage while staying understandable. Important: our goal is not “squeeze out the maximum”, but “don’t shoot yourself in the foot by default”.
Let’s imagine we have a domain model (simplified):
data class Expense(
val title: String,
val amountCents: Int
)
And we build a text report for a list of expenses.
Bad report building via +=
fun renderExpensesBad(items: List<Expense>): String {
var s = "Expenses:\n"
for (e in items) {
s += "${e.title}: ${e.amountCents} cents\n"
}
return s
}
Does it work? Yes. Does it create lots of temporary strings? Also yes.
Good report building via StringBuilder
fun renderExpensesGood(items: List<Expense>): String {
val sb = StringBuilder()
sb.append("Expenses:\n")
for (e in items) {
sb.append(e.title)
.append(": ")
.append(e.amountCents)
.append(" cents\n")
}
return sb.toString()
}
This is still readable, but it creates fewer temporary objects. Plus, we removed unnecessary string interpolation inside the loop.
Arrays vs collections in reports: choose the structure consciously
Sometimes students try to “speed up everything” by switching to arrays. But arrays are fixed-size, and add/remove operations lead to creating a new array and copying elements (Kotlin documentation shows this directly as an inefficient path when changes are frequent).
So in our project the baseline container for records is a collection (MutableList, List). And that’s correct. But if a specialized numeric buffer appears (for example, an IntArray for sums by category), then you can use a primitive array in a targeted way to avoid boxing overhead.
8. Common mistakes
Mistake #1: building large strings via += in a loop and being surprised why it gets sad “on large data”.
The problem is not that the + operator is bad. The problem is that a string is immutable, and with each += you often create a new string in the heap, leaving the old one for the GC. On small data it’s unnoticeable; on large data it turns into a garbage factory.
Mistake #2: using exceptions as a normal branching mechanism (“if it didn’t work—catch it”).
An exception is a stateful object, including a stack trace needed for diagnostics and debugging. The documentation shows that a stack trace reflects the call chain, and that information is not free. If the error is expected (for example, input parsing), it’s better to return null/Result/a sealed result and handle it calmly, leaving exceptions for truly exceptional situations.
Mistake #3: not understanding where boxing happens, and storing huge numeric arrays in Array<Int> / List<Int> when you need a dense buffer.
Kotlin explicitly warns: using Array with primitives leads to boxing, and for such tasks there are primitive arrays (IntArray, DoubleArray, etc.). This doesn’t mean you should rewrite the whole project using arrays; it means that for “a million numbers” it’s worth at least thinking about it.
Mistake #4: doing reflection in a hot spot and repeating member lookup on every loop iteration.
Reflection is a tool that reduces compiler guarantees and adds overhead. If you must use it for dynamic behavior, try to limit the scope and cache lookup results so you don’t scan metadata again and again.
Mistake #5: trying to optimize memory blindly, breaking readability where it isn’t needed.
The most frustrating situation is when the code becomes three times more complex but speeds up by 0.5% in a place that wasn’t a bottleneck anyway. It’s much more useful to eliminate obvious garbage factories first (string concatenation in loops, unnecessary intermediate collections, exceptions in a mass scenario), and only then think about subtler things.
GO TO FULL VERSION