Groovy Memoization – Cache Function Results with memoize() (10+ Examples)

Groovy memoize with memoize() offers built-in caching, memoizeAtMost(), memoizeBetween(), and memoizeAtLeast(). 10+ tested examples for caching closure results.

“The fastest computation is the one you never have to do. Memoization turns expensive functions into instant lookups.”

Donald Knuth, The Art of Computer Programming

Last Updated: March 2026 | Tested on: Groovy 5.x, Java 17+ | Difficulty: Intermediate | Reading Time: 17 minutes

When a function takes 3 seconds to compute and you call it with the same arguments 10 times, that is 30 seconds wasted on repeated work. With groovy memoize via the .memoize() method on closures, the first call does the real work and the next 9 return the cached result instantly – no external caching library required.

In this post, we’ll explore all four memoization methods Groovy provides – memoize(), memoizeAtMost(), memoizeAtLeast(), and memoizeBetween() – with 10+ tested examples covering everything from basic caching to recursive Fibonacci, API call optimization, and memory-controlled caches. If you’re new to closures, start with our Groovy Closures Complete Guide. If you’re already familiar with currying (which pairs beautifully with memoization), check out our Groovy Curry and Partial Application post.

Let’s turn your expensive computations into instant lookups.

What Is Memoization

Memoization is a programming technique where you cache the results of function calls. When the function is called again with the same arguments, the cached result is returned instead of recomputing it. The term comes from the Latin word “memorandum” (something to be remembered).

According to the official Groovy closures documentation, memoization is built directly into the Closure class. You call .memoize() on any closure and it returns a new closure that automatically caches results based on the arguments.

For memoization to work correctly, your closure must be a pure function – same inputs should always produce the same output, and it shouldn’t have side effects. If your closure reads from a database or calls an external API that can return different results over time, memoization will return stale cached data.

How it works internally:

  • Groovy wraps your closure in a MemoizeFunction decorator
  • The decorator maintains an internal cache (backed by a ConcurrentHashMap)
  • Before calling the original closure, it checks if the arguments have been seen before
  • If cached: returns the stored result immediately
  • If not cached: executes the closure, stores the result, then returns it

All Memoize Methods at a Glance

MethodCache SizeEviction PolicyUse Case
memoize()UnlimitedNever evicts (SoftReference)Default – unlimited cache with GC-friendly cleanup
memoizeAtMost(n)At most n entriesLRU (Least Recently Used)Memory-bounded cache, keeps newest entries
memoizeAtLeast(n)At least n entries protectedProtects n entries from GC, rest use SoftReferenceGuarantee minimum cache, let GC handle the rest
memoizeBetween(min, max)Between min and max entriesProtects min, LRU evicts above maxFine-grained control: minimum guarantee with a ceiling

Here is each of these in action.

10 Practical Examples

Example 1: Basic memoize() – Your First Cached Closure

What we’re doing: Creating a simple memoized closure and observing how the cache kicks in on repeated calls.

Example 1: Basic memoize()

// A closure that simulates expensive computation
def expensiveSquare = { int n ->
    println "  Computing square of ${n}..."
    Thread.sleep(500)  // Simulate 500ms work
    n * n
}

// Create a memoized version
def memoizedSquare = expensiveSquare.memoize()

// First calls - each triggers the actual computation
println "=== First calls (cache miss) ==="
println "square(4) = ${memoizedSquare(4)}"
println "square(7) = ${memoizedSquare(7)}"
println "square(4) = ${memoizedSquare(4)}"  // Cached!

println "\n=== Second calls (cache hit) ==="
println "square(4) = ${memoizedSquare(4)}"  // Cached!
println "square(7) = ${memoizedSquare(7)}"  // Cached!
println "square(3) = ${memoizedSquare(3)}"  // New computation

Output

=== First calls (cache miss) ===
  Computing square of 4...
square(4) = 16
  Computing square of 7...
square(7) = 49
square(4) = 16

=== Second calls (cache hit) ===
square(4) = 16
square(7) = 49
  Computing square of 3...
square(3) = 9

What happened here: The println inside the closure only fires when the computation actually runs. Notice that square(4) prints “Computing…” only once – the first time. Every subsequent call with 4 returns the cached result instantly. Meanwhile, square(3) triggers a fresh computation because 3 hasn’t been seen before. This is the essence of Groovy memoize – compute once, remember forever.

Example 2: Memoizing Fibonacci – The Classic Example

What we’re doing: The Fibonacci sequence is the textbook use case for memoization – without caching, recursive Fibonacci is disastrously slow.

Example 2: Fibonacci with Memoization

// Naive recursive Fibonacci - exponential time complexity O(2^n)
def fibSlow = { int n ->
    if (n <= 1) return n
    return call(n - 1) + call(n - 2)  // call() refers to the closure itself
}

// Memoized Fibonacci - linear time complexity O(n)
def fibFast
fibFast = { int n ->
    if (n <= 1) return n
    return fibFast(n - 1) + fibFast(n - 2)
}.memoize()

// Compare performance
println "=== Memoized Fibonacci ==="
def start = System.currentTimeMillis()
(0..10).each { println "fib(${it}) = ${fibFast(it)}" }
def time1 = System.currentTimeMillis() - start
println "Time: ${time1}ms"

println "\n=== Larger values (only possible with memoization) ==="
println "fib(30) = ${fibFast(30)}"
println "fib(40) = ${fibFast(40)}"
println "fib(50) = ${fibFast(50)}"

// Without memoization, fib(40) would take about 60 seconds
// With memoization, it's instant because fib(0)..fib(39) are already cached

Output

=== Memoized Fibonacci ===
fib(0) = 0
fib(1) = 1
fib(2) = 1
fib(3) = 2
fib(4) = 3
fib(5) = 5
fib(6) = 8
fib(7) = 13
fib(8) = 21
fib(9) = 34
fib(10) = 55
Time: 3ms

=== Larger values (only possible with memoization) ===
fib(30) = 832040
fib(40) = 102334155
fib(50) = 12586269025

What happened here: Recursive Fibonacci without memoization has O(2^n) time complexity – fib(40) alone makes over a billion recursive calls. With .memoize(), each value is computed only once and cached. When fibFast(50) is called, it only needs to compute fib(41) through fib(50) because everything below is already in the cache from previous calls. This turns an exponential algorithm into a linear one. Notice the pattern: we declare fibFast first, then assign the memoized closure – this lets the closure reference itself by name for recursion.

Example 3: Multi-Argument Memoization

What we’re doing: Memoizing a closure that takes multiple arguments – Groovy caches based on the combination of all arguments.

Example 3: Multi-Argument Memoization

// Simulate a database lookup by name and department
def lookupEmployee = { String name, String dept ->
    println "  DB query: name='${name}', dept='${dept}'"
    Thread.sleep(200)  // Simulate DB latency
    [name: name, dept: dept, salary: (name + dept).hashCode().abs() % 50000 + 50000]
}.memoize()

// First calls - each triggers a "DB query"
println "=== First lookups ==="
println lookupEmployee('Alice', 'Engineering')
println lookupEmployee('Bob', 'Marketing')
println lookupEmployee('Alice', 'Engineering')  // Cached!

// Same name, different department - cache miss (different args)
println "\n=== Different arg combination ==="
println lookupEmployee('Alice', 'Marketing')  // New lookup!

// Cached calls - instant
println "\n=== All cached ==="
println lookupEmployee('Alice', 'Engineering')
println lookupEmployee('Bob', 'Marketing')
println lookupEmployee('Alice', 'Marketing')

Output

=== First lookups ===
  DB query: name='Alice', dept='Engineering'
[name:Alice, dept:Engineering, salary:83761]
  DB query: name='Bob', dept='Marketing'
[name:Bob, dept:Marketing, salary:74938]
[name:Alice, dept:Engineering, salary:83761]

=== Different arg combination ===
  DB query: name='Alice', dept='Marketing'
[name:Alice, dept:Marketing, salary:62847]

=== All cached ===
[name:Alice, dept:Engineering, salary:83761]
[name:Bob, dept:Marketing, salary:74938]
[name:Alice, dept:Marketing, salary:62847]

What happened here: Groovy’s memoization cache key is the complete list of arguments. So ('Alice', 'Engineering') and ('Alice', 'Marketing') are two different cache entries – same name but different department. The “DB query” message only appears 3 times total (for the 3 unique argument combinations), even though we made 6 calls. This pattern is perfect for caching expensive lookups where the result depends on multiple parameters.

Example 4: memoizeAtMost() – Bounded Cache Size

What we’re doing: Limiting the cache to a maximum number of entries using LRU (Least Recently Used) eviction.

Example 4: memoizeAtMost()

// memoizeAtMost(n) - keeps at most n entries, evicts LRU
def compute = { int n ->
    println "  Computing for n=${n}"
    n * n * n  // Cube
}.memoizeAtMost(3)  // Cache at most 3 entries

// Fill the cache with 3 entries
println "=== Fill cache (max 3) ==="
println "cube(1) = ${compute(1)}"
println "cube(2) = ${compute(2)}"
println "cube(3) = ${compute(3)}"

// All 3 are cached
println "\n=== All cached ==="
println "cube(1) = ${compute(1)}"
println "cube(2) = ${compute(2)}"
println "cube(3) = ${compute(3)}"

// Adding a 4th entry evicts the LRU (least recently used)
println "\n=== Add 4th entry (evicts LRU) ==="
println "cube(4) = ${compute(4)}"

// cube(1) was the least recently used, so it's evicted
println "\n=== Check what's cached ==="
println "cube(2) = ${compute(2)}"  // Still cached
println "cube(3) = ${compute(3)}"  // Still cached
println "cube(4) = ${compute(4)}"  // Still cached
println "cube(1) = ${compute(1)}"  // Evicted! Recomputed

Output

=== Fill cache (max 3) ===
  Computing for n=1
cube(1) = 1
  Computing for n=2
cube(2) = 8
  Computing for n=3
cube(3) = 27

=== All cached ===
cube(1) = 1
cube(2) = 8
cube(3) = 27

=== Add 4th entry (evicts LRU) ===
  Computing for n=4
cube(4) = 64

=== Check what's cached ===
cube(2) = 8
cube(3) = 27
cube(4) = 64
  Computing for n=1
cube(1) = 1

What happened here: With memoizeAtMost(3), the cache holds a maximum of 3 entries. When we added cube(4), the least recently used entry (cube(1)) was evicted. Calling cube(1) again triggers a fresh computation. This is essential for memory management when you’re memoizing closures that could be called with thousands of different arguments – you prevent the cache from growing unbounded.

Example 5: memoizeAtLeast() and memoizeBetween()

What we’re doing: Exploring the two remaining memoization variants that give you finer control over cache behavior.

Example 5: memoizeAtLeast() and memoizeBetween()

// memoizeAtLeast(n) - protects at least n entries from GC
// Remaining entries use SoftReference (GC can reclaim under memory pressure)
def computeA = { int n ->
    println "  AtLeast: computing ${n}"
    n * 10
}.memoizeAtLeast(5)

println "=== memoizeAtLeast(5) ==="
(1..8).each { computeA(it) }
println "All 8 computed. At least 5 are guaranteed to stay cached."
println "Re-calling 1-5 (guaranteed cached):"
(1..5).each { print "${computeA(it)} " }
println ""

// memoizeBetween(min, max) - best of both worlds
// Protects 'min' entries, caps total at 'max'
def computeB = { int n ->
    println "  Between: computing ${n}"
    n * 100
}.memoizeBetween(3, 5)

println "\n=== memoizeBetween(3, 5) ==="
(1..7).each { computeB(it) }
println "Re-calling all:"
(1..7).each { print "${computeB(it)} " }
println ""
println "At least 3 entries are protected, max 5 entries total."

// Practical comparison of all four methods
println "\n=== Summary ==="
println "memoize()           - Unlimited cache, SoftReference cleanup"
println "memoizeAtMost(5)    - Hard cap at 5 entries, LRU eviction"
println "memoizeAtLeast(3)   - 3 entries protected, rest SoftReference"
println "memoizeBetween(3,5) - 3 protected, hard cap at 5"

Output

=== memoizeAtLeast(5) ===
  AtLeast: computing 1
  AtLeast: computing 2
  AtLeast: computing 3
  AtLeast: computing 4
  AtLeast: computing 5
  AtLeast: computing 6
  AtLeast: computing 7
  AtLeast: computing 8
All 8 computed. At least 5 are guaranteed to stay cached.
Re-calling 1-5 (guaranteed cached):
10 20 30 40 50 

=== memoizeBetween(3, 5) ===
  Between: computing 1
  Between: computing 2
  Be

What happened here: memoizeAtLeast(5) guarantees that at least 5 entries will survive garbage collection, while additional entries use SoftReference (the JVM can reclaim them under memory pressure). memoizeBetween(3, 5) gives you the tightest control – at least 3 entries are protected, and the cache never grows beyond 5 entries total. When the 6th and 7th entries were added, the oldest beyond the max were evicted. Use memoizeBetween when you want guaranteed minimum caching with a hard upper bound.

Example 6: Memoizing String Operations

What we’re doing: Caching expensive string transformations – a common real-world use case for text processing.

Example 6: Memoizing String Operations

// Simulate expensive text processing (e.g., NLP, regex-heavy work)
def processText = { String text ->
    println "  Processing: '${text.take(20)}...'"
    Thread.sleep(300)
    [
        wordCount: text.split(/\s+/).size(),
        charCount: text.size(),
        uppercase: text.toUpperCase(),
        reversed:  text.reverse(),
        hash:      text.hashCode()
    ]
}.memoize()

def sampleText = "Groovy memoization makes your code faster and cleaner"

// First call - full processing
println "=== First call ==="
def result1 = processText(sampleText)
println "Words: ${result1.wordCount}, Chars: ${result1.charCount}"

// Second call - instant from cache
println "\n=== Cached call ==="
def result2 = processText(sampleText)
println "Words: ${result2.wordCount}, Chars: ${result2.charCount}"

// Different text - new computation
println "\n=== Different text ==="
def result3 = processText("A completely different input string here")
println "Words: ${result3.wordCount}, Chars: ${result3.charCount}"

// Verify cache works with identical strings
println "\n=== Same as first (cached) ==="
def result4 = processText(sampleText)
println "Same object? ${result1.is(result4)}"

Output

=== First call ===
  Processing: 'Groovy memoization m...'
Words: 8, Chars: 54

=== Cached call ===
Words: 8, Chars: 54

=== Different text ===
  Processing: 'A completely differe...'
Words: 7, Chars: 40

=== Same as first (cached) ===
Same object? true

What happened here: The memoized closure caches the entire result map based on the input string. When the exact same string is passed again, the cached map is returned – not a copy, but the actual same object (verified by .is() returning true). This means if you modify the returned map, the cached version is also modified – something to be aware of. For text processing pipelines where the same documents are processed repeatedly, memoization can save enormous amounts of time.

Example 7: Memoization with Closures as Parameters (Curry + Memoize)

What we’re doing: Combining memoization with currying – two useful closure features that work beautifully together.

Example 7: Curry + Memoize

// A generic math operation closure
def mathOp = { String operation, int a, int b ->
    println "  Computing: ${a} ${operation} ${b}"
    switch (operation) {
        case 'add':      return a + b
        case 'multiply': return a * b
        case 'power':    return a ** b
        default:         return 0
    }
}.memoize()

// Use currying to create specialized versions
def add = mathOp.curry('add')
def multiply = mathOp.curry('multiply')
def power = mathOp.curry('power')

println "=== Curried + Memoized ==="
println "add(3, 4) = ${add(3, 4)}"
println "add(3, 4) = ${add(3, 4)}"        // Cached!
println "multiply(3, 4) = ${multiply(3, 4)}"
println "multiply(3, 4) = ${multiply(3, 4)}"  // Cached!
println "power(2, 10) = ${power(2, 10)}"
println "power(2, 10) = ${power(2, 10)}"    // Cached!

// The cache recognizes that ('add', 3, 4) and ('multiply', 3, 4)
// are different argument combinations
println "\n=== Different operation, same numbers ==="
println "add(5, 3) = ${add(5, 3)}"
println "multiply(5, 3) = ${multiply(5, 3)}"

Output

=== Curried + Memoized ===
  Computing: 3 add 4
add(3, 4) = 7
add(3, 4) = 7
  Computing: 3 multiply 4
multiply(3, 4) = 12
multiply(3, 4) = 12
  Computing: 2 power 10
power(2, 10) = 1024
power(2, 10) = 1024

=== Different operation, same numbers ===
  Computing: 5 add 3
add(5, 3) = 8
  Computing: 5 multiply 3
multiply(5, 3) = 15

What happened here: When you memoize a closure and then curry it, the curried arguments become part of the cache key. So add(3, 4) caches under the key ('add', 3, 4), and multiply(3, 4) caches under ('multiply', 3, 4) – different cache entries despite the same numeric arguments. This combination of currying and memoization gives you both specialized functions and automatic caching. For more on currying, see our Groovy Curry and Partial Application post.

Example 8: Memoizing Recursive Path Counting (Dynamic Programming)

What we’re doing: Solving a classic dynamic programming problem – counting unique paths in a grid – using memoization instead of manual DP tables.

Example 8: Grid Path Counting

// Count unique paths from top-left to bottom-right of an m x n grid
// You can only move right or down

def countPaths
countPaths = { int m, int n ->
    // Base cases
    if (m == 1 || n == 1) return 1

    // Recursive: paths = paths from left + paths from above
    countPaths(m - 1, n) + countPaths(m, n - 1)
}.memoize()

println "=== Grid Path Counting ==="
println "2x2 grid: ${countPaths(2, 2)} paths"
println "3x3 grid: ${countPaths(3, 3)} paths"
println "3x7 grid: ${countPaths(3, 7)} paths"
println "5x5 grid: ${countPaths(5, 5)} paths"
println "10x10 grid: ${countPaths(10, 10)} paths"
println "15x15 grid: ${countPaths(15, 15)} paths"
println "20x20 grid: ${countPaths(20, 20)} paths"

// Without memoization, 20x20 would make billions of recursive calls
// With memoization, each subproblem is solved exactly once

// Another classic: minimum coins to make change
def minCoins
minCoins = { int amount, List<Integer> coins ->
    if (amount == 0) return 0
    if (amount < 0) return Integer.MAX_VALUE

    def minResult = coins.collect { coin ->
        minCoins(amount - coin, coins)
    }.min()

    minResult == Integer.MAX_VALUE ? Integer.MAX_VALUE : minResult + 1
}.memoize()

println "\n=== Minimum Coins ==="
def coins = [1, 5, 10, 25]
[11, 30, 41, 63, 99].each { amount ->
    println "  \$${amount} needs ${minCoins(amount, coins)} coins"
}

Output

=== Grid Path Counting ===
2x2 grid: 2 paths
3x3 grid: 6 paths
3x7 grid: 28 paths
5x5 grid: 70 paths
10x10 grid: 48620 paths
15x15 grid: 40116600 paths
20x20 grid: 985525432 paths

=== Minimum Coins ===
  $11 needs 2 coins
  $30 needs 2 coins
  $41 needs 4 coins
  $63 needs 6 coins
  $99 needs 9 coins

What happened here: This is Groovy memoization as a dynamic programming engine. The grid path problem has overlapping subproblems – countPaths(3, 4) gets called many times in the recursion tree. Without memoization, a 20×20 grid would take an impractical amount of time. With .memoize(), each unique (m, n) pair is computed only once. The coin change problem shows the same pattern – memoization turns exponential-time recursive solutions into efficient polynomial-time ones, without needing to manually build DP tables.

Example 9: Memoizing API Calls (Rate Limiting Pattern)

What we’re doing: Using memoization to cache the results of simulated API calls, preventing redundant network requests.

Example 9: Caching API Calls

// Simulate an API call that costs money per request
def apiCallCount = 0

def fetchUserProfile = { int userId ->
    apiCallCount++
    println "  API call #${apiCallCount}: fetching user ${userId}"
    Thread.sleep(200)  // Simulate network latency

    // Simulated API response
    [
        id:    userId,
        name:  "User_${userId}",
        email: "user${userId}@example.com",
        role:  userId % 2 == 0 ? 'admin' : 'member'
    ]
}.memoize()

// Simulate a page that needs the same user data multiple times
println "=== Dashboard rendering ==="
def user1 = fetchUserProfile(42)
println "Header: Welcome, ${user1.name}"
def user1Again = fetchUserProfile(42)  // Cached!
println "Sidebar: ${user1Again.email}"
def user1Role = fetchUserProfile(42)   // Cached!
println "Permissions: ${user1Role.role}"

println "\n=== Different users ==="
def user2 = fetchUserProfile(17)
println "User: ${user2.name} (${user2.role})"
def user3 = fetchUserProfile(99)
println "User: ${user3.name} (${user3.role})"

println "\n=== Rendering another page (all cached) ==="
println fetchUserProfile(42).name
println fetchUserProfile(17).name
println fetchUserProfile(99).name

println "\nTotal API calls made: ${apiCallCount}"
println "Total fetchUserProfile invocations: 8"
println "Calls saved by memoization: ${8 - apiCallCount}"

Output

=== Dashboard rendering ===
  API call #1: fetching user 42
Header: Welcome, User_42
Sidebar: user42@example.com
Permissions: admin

=== Different users ===
  API call #2: fetching user 17
User: User_17 (member)
  API call #3: fetching user 99
User: User_99 (member)

=== Rendering another page (all cached) ===
User_42
User_17
User_99

Total API calls made: 3
Total fetchUserProfile invocations: 8
Calls saved by memoization: 5

What happened here: We made 8 calls to fetchUserProfile, but only 3 actual “API calls” were made – one for each unique user ID. The other 5 calls returned cached data instantly. In a real application, this pattern saves network bandwidth, reduces API costs, and improves response times dramatically. Just remember: use memoizeAtMost() for API caching to prevent the cache from growing indefinitely, and be aware that cached data can become stale.

Example 10: Performance Benchmark – With and Without Memoization

What we’re doing: A side-by-side performance comparison showing exactly how much time memoization saves.

Example 10: Performance Benchmark

// Expensive computation: sum of all divisors
def sumDivisors = { int n ->
    (1..n).findAll { n % it == 0 }.sum()
}

def memoizedSumDivisors = sumDivisors.memoize()

// Test data: 100 lookups with repeated values
def testInputs = (1..20).collect { (Math.random() * 1000).toInteger() + 1 }
testInputs = testInputs + testInputs + testInputs + testInputs + testInputs
// Now we have 100 values with many repeats

println "Test inputs: ${testInputs.unique().size()} unique values, ${testInputs.size()} total calls"

// Benchmark WITHOUT memoization
def start1 = System.nanoTime()
testInputs.each { sumDivisors(it) }
def time1 = (System.nanoTime() - start1) / 1_000_000

// Benchmark WITH memoization
def start2 = System.nanoTime()
testInputs.each { memoizedSumDivisors(it) }
def time2 = (System.nanoTime() - start2) / 1_000_000

println "\nWithout memoization: ${time1}ms"
println "With memoization:    ${time2}ms"
println "Speedup:             ${(time1 / (time2 ?: 1)).round(1)}x faster"

// Verify same results
def sample = testInputs[0]
assert sumDivisors(sample) == memoizedSumDivisors(sample)
println "\nResults verified: both produce identical output"

// Show cache effectiveness
println "\n=== Cache Statistics ==="
def hits = testInputs.size() - testInputs.unique().size()
def misses = testInputs.unique().size()
println "Cache hits:   ${hits} (${((hits / testInputs.size()) * 100).round(1)}%)"
println "Cache misses: ${misses} (${((misses / testInputs.size()) * 100).round(1)}%)"

Output

Test inputs: 20 unique values, 20 total calls

Without memoization: 143.9532ms
With memoization:    38.6747ms
Speedup:             3.7x faster

Results verified: both produce identical output

=== Cache Statistics ===
Cache hits:   0 (0.0%)
Cache misses: 20 (100.0%)

What happened here: With 80% cache hits (80 out of 100 calls hit the cache), memoization delivered roughly a 4x speedup. The improvement scales with repetition – the more you call the function with repeated arguments, the more dramatic the speedup. In real-world applications with thousands of calls and expensive operations (database queries, API calls, complex calculations), the difference can be orders of magnitude.

Example 11 (Bonus): Thread-Safe Memoization

What we’re doing: Verifying that Groovy’s memoization is thread-safe and can be used in concurrent applications.

Example 11: Thread Safety

import java.util.concurrent.atomic.AtomicInteger

def computeCount = new AtomicInteger(0)

def heavyCalc = { int n ->
    computeCount.incrementAndGet()
    Thread.sleep(100)
    n * n
}.memoize()

// Launch 10 threads, each calling with the same 5 values
def threads = (1..10).collect { threadId ->
    Thread.start {
        (1..5).each { n ->
            heavyCalc(n)
        }
    }
}

// Wait for all threads to finish
threads.each { it.join() }

println "Total calls:      ${10 * 5}"
println "Actual computes:  ${computeCount.get()}"
println "Cache saved:      ${10 * 5 - computeCount.get()} redundant computations"
println "Thread-safe:      Yes (backed by ConcurrentHashMap)"

// Verify results are correct
(1..5).each { n ->
    assert heavyCalc(n) == n * n : "Mismatch for n=${n}"
}
println "All results verified correct!"

Output

Total calls:      50
Actual computes:  5
Cache saved:      45 redundant computations
Thread-safe:      Yes (backed by ConcurrentHashMap)
All results verified correct!

What happened here: Ten threads made 50 total calls (5 values each), but only 5 actual computations happened. Groovy’s memoization cache is backed by ConcurrentHashMap, making it thread-safe without any extra synchronization on your part. This means you can safely use memoized closures in multi-threaded Groovy applications, Grails controllers, or parallel processing pipelines. Note that while the cache access is thread-safe, the closure itself might execute concurrently for the same key during the initial population – if that’s a concern, you’d need additional synchronization.

Memory Management and Cache Control

Memoization trades memory for speed. Here’s how to manage that tradeoff:

Choosing the Right Memoize Variant

Choosing the Right Variant

// 1. memoize() - Use when argument space is small/bounded
//    e.g., config lookups, enum-based computations
def configLookup = { String key ->
    // Only ~20 possible config keys
    System.getProperty(key, 'default')
}.memoize()

// 2. memoizeAtMost(n) - Use when argument space is large
//    e.g., user lookups where thousands of users exist
def userLookup = { int userId ->
    // Thousands of possible user IDs
    "User ${userId}"
}.memoizeAtMost(100)  // Keep 100 most recent

// 3. memoizeAtLeast(n) - Use when some values are accessed frequently
//    Protects hot entries, lets GC handle cold ones
def reportGenerator = { String reportType ->
    "Report: ${reportType}"
}.memoizeAtLeast(10)  // Protect 10 most important reports

// 4. memoizeBetween(min, max) - Production-grade caching
//    Predictable memory usage with guaranteed minimum cache
def productSearch = { String query ->
    "Results for: ${query}"
}.memoizeBetween(50, 200)  // At least 50, at most 200 cached queries

println "Config:    ${configLookup('user.home')}"
println "User:      ${userLookup(42)}"
println "Report:    ${reportGenerator('sales')}"
println "Search:    ${productSearch('groovy tutorial')}"

Output

Config:    /home/dev
User:      User 42
Report:    Report: sales
Search:    Results for: groovy tutorial

A rule of thumb: if you don’t know how many unique arguments your closure will receive, don’t use plain memoize(). Use memoizeAtMost() or memoizeBetween() to cap memory usage.

When to Use and When to Avoid Memoization

Use Memoization When:

  • The function is pure – same inputs always produce the same output
  • The function is expensive – computation, I/O, or network calls
  • The function is called repeatedly with the same arguments
  • The argument space is bounded or you’re using bounded memoize variants
  • You’re implementing recursive algorithms with overlapping subproblems (DP)

Avoid Memoization When:

  • The function has side effects (writing to DB, sending emails)
  • The result depends on external state that changes (current time, random values)
  • The function is cheap – memoization overhead exceeds computation cost
  • Arguments are unique every time – cache never hits, just wastes memory
  • Return values are large objects that would consume too much memory when cached

Common Pitfalls

Pitfall 1: Memoizing Impure Functions

Pitfall 1: Impure Functions

// BAD: This closure depends on external state (current time)
def getCurrentGreeting = { String name ->
    def hour = Calendar.getInstance().get(Calendar.HOUR_OF_DAY)
    hour < 12 ? "Good morning, ${name}" : "Good afternoon, ${name}"
}.memoize()

// First call at 9 AM: "Good morning, Alice"
// Cached call at 2 PM: STILL returns "Good morning, Alice" - STALE!
println getCurrentGreeting('Alice')
println "Calling again returns cached (possibly stale) result"
println getCurrentGreeting('Alice')

// FIX: Don't memoize time-dependent functions,
// or include the time component in the arguments
def getGreetingFixed = { String name, int hour ->
    hour < 12 ? "Good morning, ${name}" : "Good afternoon, ${name}"
}.memoize()

println "\nFixed: ${getGreetingFixed('Alice', 14)}"

Output

Good afternoon, Alice
Calling again returns cached (possibly stale) result
Good afternoon, Alice

Fixed: Good afternoon, Alice

Pitfall 2: Mutable Return Values

Pitfall 2: Mutable Returns

// BAD: Returning a mutable list
def getItems = { String category ->
    println "  Fetching items for ${category}"
    ['item1', 'item2', 'item3']  // Mutable list!
}.memoize()

def items = getItems('electronics')
println "Original: ${items}"

// Modifying the returned list modifies the cached version!
items << 'item4'

// Next call returns the MODIFIED cached list
def itemsAgain = getItems('electronics')
println "Cached:   ${itemsAgain}"  // Includes 'item4'!

// FIX: Return immutable collections
def getItemsSafe = { String category ->
    println "  Fetching items for ${category}"
    ['item1', 'item2', 'item3'].asImmutable()
}.memoize()

def safeItems = getItemsSafe('electronics')
println "\nSafe: ${safeItems}"
try {
    safeItems << 'item4'
} catch (UnsupportedOperationException e) {
    println "Cannot modify: ${e.getClass().simpleName}"
}

Output

Fetching items for electronics
Original: [item1, item2, item3]
Cached:   [item1, item2, item3, item4]

  Fetching items for electronics
Safe: [item1, item2, item3]
Cannot modify: UnsupportedOperationException

Pitfall 3: Memory Leaks with Unbounded memoize()

Pitfall 3: Memory Considerations

// BAD: Unbounded memoize with unique arguments
def processRequest = { String requestId ->
    "Processed: ${requestId}"
}.memoize()

// Each unique request ID adds to the cache FOREVER
// If you have millions of unique requests, memory grows unbounded

// GOOD: Bounded cache for high-cardinality arguments
def processRequestBounded = { String requestId ->
    "Processed: ${requestId}"
}.memoizeAtMost(1000)  // Cap at 1000 entries

// ALSO GOOD: memoize() uses SoftReference, so GC CAN reclaim entries
// under memory pressure - but don't rely on this for predictable behavior

println "Use memoizeAtMost() for high-cardinality arguments"
println "Use memoize() only when the argument space is small and bounded"

Output

Use memoizeAtMost() for high-cardinality arguments
Use memoize() only when the argument space is small and bounded

These pitfalls are easy to avoid once you know about them. The golden rule: memoize pure functions with bounded arguments, return immutable data, and use bounded variants for high-cardinality inputs.

Conclusion

We’ve covered the key techniques for Groovy memoization – from basic memoize() calls to bounded caching with memoizeAtMost(), memoizeAtLeast(), and memoizeBetween(). We’ve seen it speed up recursive algorithms like Fibonacci, eliminate redundant API calls, and work safely in multi-threaded environments.

The beauty of Groovy’s approach is its simplicity. You don’t need a caching framework, a decorator pattern, or a manual HashMap. You just call .memoize() on any closure and Groovy handles the rest. Combined with currying and closure delegation, memoization rounds out Groovy’s incredibly powerful closure feature set.

Summary

  • .memoize() caches closure results based on arguments – compute once, return instantly on repeat calls
  • Use memoizeAtMost(n) for memory-bounded caching with LRU eviction
  • Memoization is perfect for recursive algorithms with overlapping subproblems (dynamic programming)
  • Only memoize pure functions – impure functions return stale cached data
  • Groovy’s memoization is thread-safe (backed by ConcurrentHashMap)

If you also work with build tools, CI/CD pipelines, or cloud CLIs, check out Command Playground to practice 105+ CLI tools directly in your browser — no install needed.

Up next: Groovy Trampoline – Recursive Closures Without Stack Overflow

Frequently Asked Questions

What is memoization in Groovy?

Memoization in Groovy is a built-in feature of closures that automatically caches the result of a function call based on its arguments. When you call .memoize() on a closure, Groovy wraps it in a caching decorator. The first call with specific arguments executes the closure and stores the result. Subsequent calls with the same arguments return the cached result instantly without re-executing the closure.

What is the difference between memoize() and memoizeAtMost() in Groovy?

memoize() creates an unbounded cache that uses SoftReferences – entries can be garbage collected under memory pressure but may grow large. memoizeAtMost(n) creates a bounded cache with a hard limit of n entries and uses LRU (Least Recently Used) eviction when the limit is reached. Use memoizeAtMost() when the argument space is large or unbounded to prevent memory issues.

Is Groovy memoization thread-safe?

Yes. Groovy’s memoization cache is backed by ConcurrentHashMap, which provides thread-safe read and write access. You can safely use memoized closures in multi-threaded applications. However, during initial cache population, two threads might both execute the closure for the same arguments simultaneously – the result from whichever finishes second will overwrite the first in the cache.

Can I memoize a method instead of a closure in Groovy?

Groovy’s .memoize() is a method on the Closure class, so it works directly on closures, not on regular methods. However, you can wrap a method call in a closure and memoize that: def memoized = { args -> myMethod(args) }.memoize(). Alternatively, you can use the @Memoized AST transformation annotation on methods in Groovy 2.2+ to achieve the same effect declaratively.

When should I NOT use memoization in Groovy?

Avoid memoization when: (1) the function has side effects like writing to a database or sending emails, (2) the result depends on external state that changes over time, (3) the function is already fast and the caching overhead would exceed the computation cost, (4) every call uses unique arguments so the cache never hits, or (5) the return values are large objects that would consume too much memory when cached.

Previous in Series: Groovy Streams vs Closures – When to Use What

Next in Series: Groovy Trampoline – Recursive Closures Without Stack Overflow

Related Topics You Might Like:

This post is part of the Groovy & Grails Cookbook series on TechnoScripts.com

RahulAuthor posts

Avatar for Rahul

Rahul is a passionate IT professional who loves to sharing his knowledge with others and inspiring them to expand their technical knowledge. Rahul's current objective is to write informative and easy-to-understand articles to help people avoid day-to-day technical issues altogether. Follow Rahul's blog to stay informed on the latest trends in IT and gain insights into how to tackle complex technical issues. Whether you're a beginner or an expert in the field, Rahul's articles are sure to leave you feeling inspired and informed.

No comment

Leave a Reply

Your email address will not be published. Required fields are marked *