String pool: == vs equals()

Your login check works in testing and fails for real users. The cause is usually one character: == instead of equals(). You'll see how Java's String pool shares literals, why == compares addresses and not text, when two Strings are the same object, how to compare safely (even with null), and then practise with 9 problems.

Module 4 · Arrays and Strings⏱ 20 minBeginner9 practice problems
1 The story

“yes” is not “yes”?

Riya's quiz app asks “Ready to start?”. Her code says if (answer == "yes"). When she tests with answer = "yes"; in the code, it works. When a friend actually types yes, the app replies “OK, maybe later.”

String pool "yes" "yes" typed on the keyboard (new object) "yes" in code answer answer == "yes" → false Same text, different objects. answer.equals("yes") → true myphatshala.in
Riya

Sir, both say “yes”. How can they not be equal?

Arjun Sir

== doesn't look at the letters. For objects, it asks: “are these the same object in memory?” Your typed “yes” is a new object; the “yes” in your code lives in a shared area called the String pool.

Riya

Then why did it work when I wrote answer = "yes" in the code?

Arjun Sir

Because both literals came from the pool, so they really were the same object. That's what makes this bug sneaky: it passes your tests and fails for real users. Use equals() to compare text, always.

2 Definition

What are the String pool, == and equals()?

Formal definition (write this in exams)

The String pool (string constant pool) is a JVM-managed table of unique String objects. String literals and compile-time constant expressions are stored there once and shared. For references, == tests reference equality (same object), while equals(), overridden in String, tests content equality (same sequence of characters).

In simple words

== asks “same house?”. equals() asks “same people living inside?”. Two houses can have identical families. For text, you almost always want the second question.

Words you'll meet in this lesson

String poolShared store of unique Strings, part of the heap since Java 7.
Reference equalitya == b: both variables point to the same object.
Content equalitya.equals(b): same characters in the same order.
Compile-time constantA value javac can work out, like "Ja" + "va".
intern()Returns the pool's copy of a String, adding it if needed.
LexicographicDictionary order, character by character: what compareTo uses.

Which comparison should I use?

Same text, exacta.equals(b)
Same text, ignore casea.equalsIgnoreCase(b)
One side may be null"yes".equals(answer) or Objects.equals(a, b)
Order (sorting)a.compareTo(b): negative, 0 or positive; compareToIgnoreCase ignores case
Same object?a == b (rarely what you want for Strings)
Primitives== is correct for int, char, boolean …

Exam tip: String s1 = "Java"; String s2 = new String("Java"); → s1 == s2 is false, s1.equals(s2) is true. Also: new String("Java") can create two objects: the pool literal (if not already there) and the new one on the heap.

3 Types and ways to use it

When are two Strings the same object?

How the String was madeWhere it lives== "Java"
"Java" (literal)Pool, sharedtrue
"Ja" + "va" (constants)Pool: javac joins it at compile timetrue
final String p = "Ja"; p + "va"Pool: p is a constant tootrue
p + q (normal variables)New object at run timefalse
new String("Java")New object, alwaysfalse
scanner.nextLine(), file, network, databaseNew objects at run timefalse
s.intern()Returns the pool copytrue

The pattern: only values known at compile time are pooled automatically. Everything a real user, file or database gives you is a new object, so == fails exactly where it matters.

Proof, with a real compiler

public class Main {
    public static void main(String[] args) {
        String a = "Java";
        String p = "Ja", q = "va";
        final String fp = "Ja";
        System.out.println(("Ja" + "va") == a);
        System.out.println((p + q) == a);
        System.out.println((fp + "va") == a);
        System.out.println((p + q).equals(a));
    }
}
true false true true

compareTo: dictionary order

Negative

"apple".compareTo("banana") → negative: apple comes first.

a < b

Zero

"Java".compareTo("Java") → 0: same text.

a == b (content)

Positive

"b".compareTo("a") → positive: b comes after a.

a > b

Case matters: compareTo compares character codes, and all capital letters come before all small letters. So “Zara” sorts before “aman”. Use compareToIgnoreCase for name lists.

4 Step by step

What the JVM does with Strings

  1. Class loads: literals go to the pool

    String a = "Java";
    String b = "Java";
    The first time the literal "Java" is used, the JVM puts one String object in the pool. Every later use of the same literal, anywhere in the program, gets that same object. So a == b is true.
  2. new always makes a new object

    String c = new String("Java");
    new creates a separate heap object with the same characters. a == c is false. There's almost never a reason to write this.
  3. Run-time values are new objects

    String typed = sc.nextLine();   // user types Java
    Input, file contents, results of + on variables, substring, toUpperCase: all build new String objects at run time, outside the pool.
  4. equals compares characters

    if (typed.equals(a)) { … }
    String.equals first checks if it's the same object (fast yes), then the lengths, then each character. Different objects with equal text → true.
  5. Make it null-safe

    if ("Java".equals(typed)) { … }
    Calling a method on a literal can never throw NullPointerException, and equals(null) just returns false.
5 See it happen

Pool, heap, == and equals(): both views

Left: the code and its result. Right: four variables and where their Strings really are.

Same text, same object? 6 steps
What you see String a = "Java"; String b = "Java"; String c = new String("Java"); a.equals(c) String d = sc.nextLine(); // Java a == c.intern() a → "Java" a == b → true a == c → false a.equals(c) → true a == c → false d == a → false d.equals(a) → true a == c.intern() → true A literal. Same literal: == is true. Same text, but == is false. equals looks at the letters. User input behaves like new String. intern() gives back the pool copy. myphatshala.in
Behind the screen Stack Heap a b c d String pool "Java" "Java" "Java" J=J a=a v=v a=a ✓ intern() The literal "Java" is placed in the pool once. b gets the same pooled object: same address. new creates a second object outside the pool. equals checks length, then each character. Input makes a third "Java" object at run time. intern() looks up the pool and returns that object. myphatshala.in
6 Real-world use

String comparison in real systems

Text comparison is everywhere: logins, coupons, roles, search, sorting. A wrong comparison is a real bug that reaches users.

Watch it with an example: sorting student names

compareTo compares two Strings character by character using their Unicode numbers. Watch why “arjun” lands after “Bela”, and how compareToIgnoreCase fixes it.

"arjun".compareTo("Bela") 5 steps
Animated example 1st a 97 r 114 j 106 u 117 n 110 2nd B 66 e 101 l 108 a 97 97 − 66 = 31 positive → arjun after Bela Arrays.sort(names); 'a' (97) vs 'B' (66) ["Aman", "Bela", "arjun"] names.sort(String::compareToIgnoreCase); ["Aman", "arjun", "Bela"] 'a' vs 'b': 97−98 = −1 negative → arjun first ✓ Capital letters (65–90) come before small ones (97–122). myphatshala.in

1. Login and verification

OTPs, usernames and security answers typed by users are compared with stored values using equals (or a constant-time compare for secrets), after normalising case and spaces where appropriate.

Typed OTP→stored.equals(typed)→Allow / deny
FEST50

2. Coupons and promo codes

Codes are matched case-insensitively against the active list, so “fest50” works. Status values like "ACTIVE" or "EXPIRED" from the database are compared with constants.

"fest50"→equalsIgnoreCase→50% off
AgraDelhiKochiPune

3. Sorting lists

Contact lists, product names and dropdown options are sorted with compareTo or a comparator, usually ignoring case, or using a locale-aware Collator for languages with accents.

Names→compareToIgnoreCase→A–Z list
1 shared copy

4. Memory saving by sharing

The pool lets every class share one copy of each literal. Large systems that load millions of repeated values (city names, status codes) can deduplicate them too; the JVM's G1 collector even has an option for it.

10 lakh "Pune"→deduplicate→1 object

Examples are simplified. Passwords themselves are never compared as plain text; they're hashed and the hashes are compared.

7 Run the code

Four Strings, one word

public class Main {
    public static void main(String[] args) {
        String a = "Java";
        String b = "Java";
        String c = new String("Java");

        System.out.println("a == b      : " + (a == b));
        System.out.println("a == c      : " + (a == c));
        System.out.println("a.equals(c) : " + a.equals(c));
        System.out.println("a == c.intern(): " + (a == c.intern()));
        System.out.println("\"java\".equalsIgnoreCase(a): " + "java".equalsIgnoreCase(a));
    }
}
a == b : true a == c : false a.equals(c) : true a == c.intern(): true "java".equalsIgnoreCase(a): true
  • (a == b)Brackets matter: without them, "a == b : " + a would be joined first and then compared.
  • new String("Java")Forces a separate object, used here only to demonstrate.
  • a.equals(c)The right way to compare text.
  • "java".equalsIgnoreCase(a)Ignores case; calling it on the literal is also null-safe.
8 Line by line

Follow the addresses

Objects are numbered #1, #2 so you can see which variable points where.

Worked examples: real problems, solved and traced in memory

Three production-style problems where comparing Strings the wrong way breaks the business rule. Run each one, then step through it and compare the object numbers (#1, #2 …): same number means same object, which is all == checks.

Example 1SaaS · admin access

Role from the database: == vs equals

Problem statement

A user's role is loaded from the database as text. The admin page must open only for "ADMIN". Compare the role with == and with equals and see which one gives the right answer.

Input
role from DB = "ADMIN" (created at run time)
Expected output
== says: false
equals says: true

Approach

  1. Values read from a database, file or keyboard are new String objects, not the pooled literal.
  2. == compares the two references (object numbers).
  3. equals compares the characters, which is what the business rule means.

Solution: run it live

What happens in memory, step by step

Recorded from the real JVM: press Next to execute one line at a time. Watch method calls push and pop frames on the stack, and objects (numbered #1, #2 …) appear on the heap.

Example 2E-commerce · checkout

Coupon code in any case

Problem statement

A customer types a coupon as " fest50 ". The active coupons are FEST50 and NEW10. A method findCoupon must return the matching code's index, ignoring spaces and case, or -1.

Input
typed = " fest50 ", active = {"FEST50", "NEW10"}
Expected output
Matched: FEST50

Approach

  1. Normalise the input once with trim() (a new String).
  2. Loop over the active codes and compare with equalsIgnoreCase.
  3. Return the index as soon as one matches; return -1 if the loop finishes.

Solution: run it live

What happens in memory, step by step

Recorded from the real JVM: press Next to execute one line at a time. Watch method calls push and pop frames on the stack, and objects (numbered #1, #2 …) appear on the heap.

Example 3Logistics · delivery fee

Free delivery check that survives null

Problem statement

Orders to Pune get free delivery. Some customers never entered a city, so their city is null. Write deliveryFee(city) so it never crashes: 0 for Pune, 40 otherwise.

Input
cities = {"Pune", null, "Delhi"}
Expected output
Pune -> Rs 0
null -> Rs 40
Delhi -> Rs 40

Approach

  1. Calling a method on null throws NullPointerException, so never write city.equals("Pune").
  2. Call equals on the literal instead: "Pune".equals(city) simply returns false for null.
  3. Use a ternary to choose the fee.

Solution: run it live

What happens in memory, step by step

Recorded from the real JVM: press Next to execute one line at a time. Watch method calls push and pop frames on the stack, and objects (numbered #1, #2 …) appear on the heap.

9 Quiz and revision

Check yourself

Quick revision

Lesson 4.4 in one screen

  • Literals and compile-time constants are stored once in the String pool and shared.
  • new String(…), input, files, databases and run-time joins create new objects.
  • == → same object? equals → same characters?
  • Compare text with equals / equalsIgnoreCase, never ==.
  • Null-safe: "literal".equals(x) or Objects.equals(a, b).
  • compareTo: negative / 0 / positive; capitals sort before small letters.
  • intern() returns the pooled copy; rarely needed.

Interview and viva questions

What is the difference between == and equals() for Strings?

== compares references: true only if both variables point to the same object. equals() is overridden in String to compare characters. Two different String objects with the same text are equals but not ==.

How many objects does String s = new String("Hello") create?

Up to two: the literal "Hello" in the pool (if it isn't there already from earlier code) and one new String object on the heap that s refers to.

What is the String pool and why does it exist?

A JVM table of unique String objects for literals and interned Strings. Because Strings are immutable, they can be safely shared, saving memory when the same text appears many times in a program.

What does intern() do?

It returns the canonical pooled String with the same content: if one exists it's returned, otherwise this String is added to the pool. It can save memory for many repeated values, but should not be used as a way to make == comparisons work.

Why can the same == code give different results in testing and production?

In tests, values are often literals in the code, which are pooled, so == happens to be true. In production the values come from users, files or databases as new objects, so == is false. That's why == on Strings is a bug even when tests pass.

Practice problems

Every problem here compares Strings, so every answer uses equals, equalsIgnoreCase or compareTo, never ==. The last three are real business tasks.

P1Easy

Yes or no, any case

A user answers a question. Accept “yes”, “YES”, “Yes” and so on, even with spaces around it.

Input
answers = " Yes", "YES", "yep", "no"
Expected output
[ Yes] -> agreed
[YES] -> agreed
[yep] -> not agreed
[no] -> not agreed
Show solution and explanation
public class Main {
    public static void main(String[] args) {
        String[] answers = {" Yes", "YES", "yep", "no"};
        for (String a : answers) {
            boolean agreed = a.trim().equalsIgnoreCase("yes");
            System.out.println("[" + a + "] -> " + (agreed ? "agreed" : "not agreed"));
        }
    }
}
[ Yes] -> agreed [YES] -> agreed [yep] -> not agreed [no] -> not agreed

How it works: trim() removes outer spaces (returning a new String), then equalsIgnoreCase compares letters without caring about case. == would fail even for an exact match typed by the user, because input creates new String objects.

P2Easy

Count a word in a list

Count how many orders in a list are for “Masala Dosa”.

Input
orders = {"Idli", "Masala Dosa", "Vada", "Masala Dosa", "Upma"}
Expected output
Masala Dosa ordered 2 times
Show solution and explanation
public class Main {
    public static void main(String[] args) {
        String[] orders = {"Idli", "Masala Dosa", "Vada", "Masala Dosa", "Upma"};
        String item = "Masala Dosa";
        int count = 0;
        for (String o : orders) {
            if (item.equals(o)) count++;
        }
        System.out.println(item + " ordered " + count + " times");
    }
}
Masala Dosa ordered 2 times

How it works: equals compares the characters. Writing the known value first, item.equals(o), is also safe if the array contains null.

P3Easy

Null-safe comparison

Some customers have no coupon (null). Check who used “FEST50” without crashing.

Input
coupons = {"FEST50", null, "NEW10", "FEST50"}
Expected output
Customer 1 used FEST50
Customer 4 used FEST50
Show solution and explanation
public class Main {
    public static void main(String[] args) {
        String[] coupons = {"FEST50", null, "NEW10", "FEST50"};
        for (int i = 0; i < coupons.length; i++) {
            if ("FEST50".equals(coupons[i])) {
                System.out.println("Customer " + (i + 1) + " used FEST50");
            }
        }
    }
}
Customer 1 used FEST50 Customer 4 used FEST50

How it works: "FEST50".equals(null) simply returns false. Writing coupons[i].equals("FEST50") would throw NullPointerException for customer 2. Objects.equals(a, b) is another null-safe option.

P4Medium

Alphabetical order of three names

Print three names in dictionary order using compareTo (without sorting methods).

Input
a = "Meera", b = "Arjun", c = "Kabir"
Expected output
Arjun, Kabir, Meera
Show solution and explanation
public class Main {
    public static void main(String[] args) {
        String a = "Meera", b = "Arjun", c = "Kabir", t;
        if (a.compareTo(b) > 0) { t = a; a = b; b = t; }
        if (b.compareTo(c) > 0) { t = b; b = c; c = t; }
        if (a.compareTo(b) > 0) { t = a; a = b; b = t; }
        System.out.println(a + ", " + b + ", " + c);
    }
}
Arjun, Kabir, Meera

How it works: x.compareTo(y) is negative if x comes first, 0 if equal, positive if x comes after. Three compare-and-swap steps sort three values. Note that compareTo is case-sensitive: all capitals come before all small letters.

P5Medium

Find duplicate names

Print every name that appears more than once in a list, once each.

Input
names = {"Riya", "Kabir", "riya", "Meera", "Kabir", "Kabir"}
Expected output
Riya x2
Kabir x3
Show solution and explanation
public class Main {
    public static void main(String[] args) {
        String[] names = {"Riya", "Kabir", "riya", "Meera", "Kabir", "Kabir"};
        for (int i = 0; i < names.length; i++) {
            boolean seenBefore = false;
            for (int k = 0; k < i; k++) {
                if (names[k].equalsIgnoreCase(names[i])) { seenBefore = true; break; }
            }
            if (seenBefore) continue;
            int count = 1;
            for (int j = i + 1; j < names.length; j++) {
                if (names[j].equalsIgnoreCase(names[i])) count++;
            }
            if (count > 1) System.out.println(names[i] + " x" + count);
        }
    }
}
Riya x2 Kabir x3

How it works: for each name we first check whether it appeared earlier (to report it only once), then count later matches. equalsIgnoreCase treats “Riya” and “riya” as the same person. This is O(n²); for large lists a HashMap is far faster (Module 11).

P6Medium

First and last in dictionary order

From a list of city names, find the one that comes first and the one that comes last alphabetically, ignoring case.

Input
cities = {"pune", "Agra", "Mumbai", "delhi", "Kochi"}
Expected output
First: Agra, Last: pune
Show solution and explanation
public class Main {
    public static void main(String[] args) {
        String[] cities = {"pune", "Agra", "Mumbai", "delhi", "Kochi"};
        String first = cities[0], last = cities[0];
        for (String c : cities) {
            if (c.compareToIgnoreCase(first) < 0) first = c;
            if (c.compareToIgnoreCase(last) > 0) last = c;
        }
        System.out.println("First: " + first + ", Last: " + last);
    }
}
First: Agra, Last: pune

How it works: the same max/min pattern as for numbers (Lesson 4.1), but with compareToIgnoreCase instead of < and >. With plain compareTo, “Mumbai” would come before “delhi” because capital letters sort first.

P7Business problem

Coupon code checker

Customers type coupon codes in any case, sometimes with spaces. Check each typed code against the list of active coupons and print the discount.

Input
active = FEST50 (50%), NEW10 (10%), FREESHIP (0%); typed = " fest50 ", "NEW10", "Fest-50"
Expected output
[ fest50 ] -> FEST50, 50% off
[NEW10] -> NEW10, 10% off
[Fest-50] -> invalid code
Show solution and explanation
public class Main {
    public static void main(String[] args) {
        String[] codes = {"FEST50", "NEW10", "FREESHIP"};
        int[] percent = {50, 10, 0};
        String[] typed = {" fest50 ", "NEW10", "Fest-50"};
        for (String t : typed) {
            String clean = t.trim();
            int found = -1;
            for (int i = 0; i < codes.length; i++) {
                if (codes[i].equalsIgnoreCase(clean)) { found = i; break; }
            }
            if (found >= 0) {
                System.out.println("[" + t + "] -> " + codes[found] + ", " + percent[found] + "% off");
            } else {
                System.out.println("[" + t + "] -> invalid code");
            }
        }
    }
}
[ fest50 ] -> FEST50, 50% off [NEW10] -> NEW10, 10% off [Fest-50] -> invalid code

How it works: parallel arrays hold each code and its discount at the same index. Normalising user input (trim, ignore case) before comparing avoids rejecting real customers for harmless typing differences, while “Fest-50” is still correctly rejected.

P8Business problem

Role-based menu

A user's role arrives from the login system as text. Show the right menu for ADMIN, MANAGER and STAFF, and deny anything else, including a missing role.

Input
roles = "ADMIN", "manager", null, "Guest"
Expected output
ADMIN -> All settings, users, reports
manager -> Reports, approvals
null -> Access denied
Guest -> Access denied
Show solution and explanation
public class Main {
    static final String ADMIN = "ADMIN", MANAGER = "MANAGER", STAFF = "STAFF";

    public static void main(String[] args) {
        String[] roles = {"ADMIN", "manager", null, "Guest"};
        for (String role : roles) {
            String menu;
            if (ADMIN.equalsIgnoreCase(role)) menu = "All settings, users, reports";
            else if (MANAGER.equalsIgnoreCase(role)) menu = "Reports, approvals";
            else if (STAFF.equalsIgnoreCase(role)) menu = "My tasks";
            else menu = "Access denied";
            System.out.println(role + " -> " + menu);
        }
    }
}
ADMIN -> All settings, users, reports manager -> Reports, approvals null -> Access denied Guest -> Access denied

How it works: constants are written once and compared with CONSTANT.equalsIgnoreCase(role), which is both case-tolerant and null-safe. The final else denies by default: unknown or missing roles get nothing, the safe choice for access control.

P9Business problem

Remove duplicate sign-ups

A newsletter list has the same email typed in different cases and with spaces. Print a cleaned list with each email once, in lower case.

Input
emails = {"Riya@Mail.com", " kabir@mail.com", "riya@mail.com ", "MEERA@mail.com", "Kabir@Mail.com"}
Expected output
3 unique subscribers:
  riya@mail.com
  kabir@mail.com
  meera@mail.com
Show solution and explanation
public class Main {
    public static void main(String[] args) {
        String[] emails = {"Riya@Mail.com", " kabir@mail.com", "riya@mail.com ", "MEERA@mail.com", "Kabir@Mail.com"};
        String[] unique = new String[emails.length];
        int count = 0;
        for (String e : emails) {
            String clean = e.trim().toLowerCase();
            boolean exists = false;
            for (int i = 0; i < count; i++) {
                if (unique[i].equals(clean)) { exists = true; break; }
            }
            if (!exists) unique[count++] = clean;
        }
        System.out.println(count + " unique subscribers:");
        for (int i = 0; i < count; i++) System.out.println("  " + unique[i]);
    }
}
3 unique subscribers: riya@mail.com kabir@mail.com meera@mail.com

How it works: normalise first (trim + lower case), then compare with equals. count tracks how much of the unique array is filled (Lesson 4.1). Sending duplicate emails annoys customers and can get a company's mail marked as spam.

Real-world scenario questions (asked in company interviews)

Comparison bugs often pass tests and fail in production. Decide your answer first, then open the model answer.

SaaS · access controlFresher

Admins can't see the admin page, but only in production

The check if (user.getRole() == "ADMIN") works in local tests, where the test sets the role with the literal "ADMIN". In production, roles are loaded from the database and no one can open the admin page. Explain.

The interviewer is checking: do you understand pooling and why == hides the bug in tests?

Show a strong answer
  • In the tests both sides are literals, so they're the same pooled object and == is true by luck.
  • From the database, the driver builds a new String at run time: same text, different object, so == is false.
  • Fix: "ADMIN".equals(user.getRole()) (null-safe), or better, store roles as an enum.
  • Make tests realistic: build values with new String(…) or load them like production does; static analysis tools also flag == on Strings.

Avoid: fixing it with intern(). Comparing content is the correct intent.

Logistics · checkoutFresher

Checkout crashes for customers without a city

Free delivery logic has if (customer.getCity().equals("Pune")). Customers who signed up with only a phone number have no city, and their checkout page crashes. What happened and how do you fix it?

The interviewer is checking: do you write null-safe comparisons?

Show a strong answer
  • getCity() returns null, and calling equals on null throws NullPointerException.
  • Flip it: "Pune".equals(customer.getCity()), or use Objects.equals(customer.getCity(), "Pune").
  • Decide the business rule for missing data explicitly (e.g. ask for the city at checkout) rather than letting it crash.

Avoid: wrapping it in a try-catch that hides the problem.

CRM · contacts1–3 years

“Why is Zara above aman?”

The contact list is sorted with a.compareTo(b). Users complain that all names starting with capitals appear before names typed in small letters, and names with accents (like “Élodie”) land at the very end. What would you change?

The interviewer is checking: do you know compareTo is code-point order, not human alphabetical order?

Show a strong answer
  • compareTo compares character codes: 'Z' (90) is less than 'a' (97), and accented letters have even higher codes.
  • Use compareToIgnoreCase or String.CASE_INSENSITIVE_ORDER for case.
  • For proper language-aware ordering, use a java.text.Collator for the user's locale.
  • Optionally store a normalised “sort name” in the database so sorting is done once, consistently.

Avoid: converting names to upper case permanently just to sort them.

Data platform · memory3+ years

Millions of copies of the same city name

A service loads 50 lakh customer records from CSV files into memory. A heap analysis shows that Strings like "Mumbai", "Bengaluru" and "ACTIVE" exist as millions of separate objects, using gigabytes. How could you reduce this?

The interviewer is checking: do you understand pooling beyond literals, and its trade-offs?

Show a strong answer
  • Each value parsed from the file is a new String object, even if the text repeats.
  • Deduplicate low-variety fields: intern() them, or keep your own HashMap<String, String> cache so equal values share one object.
  • Better still, model fixed values as enums (status) or IDs (city codes).
  • The G1 garbage collector also offers automatic String deduplication (-XX:+UseStringDeduplication), which shares the character arrays of equal Strings.
  • Measure before and after with a heap dump; don't intern high-variety values like emails, which adds cost without savings.

Avoid: “Just add more RAM” without understanding where memory goes.