A Deep Dive

Java.

A working developer's notebook — language fundamentals, gotchas that show up in production, and the questions interviewers actually ask.

5Topics
17.5 / 20Score so far
ActiveUpdated regularly

The Index

All topics
  1. Primitive Types The 8 raw building blocks. Two's complement, IEEE 754, and why 0.1 + 0.2 ≠ 0.3. byte · char · double
  2. Wrapper Classes & Autoboxing int vs Integer. The hidden costs of autoboxing — NPEs and performance traps. Integer · NPE
  3. The Integer Cache Why == behaves differently for 100 vs 200. The classic Java interview gotcha. cache · == · equals
  4. String Immutability, the Pool & intern() Why Strings can't be changed. The string pool. When and why to call intern(). pool · memory
  5. String vs StringBuilder Why + in a loop is O(n²). Mutable builders, capacity, and the StringBuffer footnote. mutable · O(n²) · perf
Topic 01

Primitive Types

Java has 8 primitive types. They are not objects — they are raw values stored directly in memory (usually on the stack, for local variables). This is different from almost everything else in Java, which is an object reference.

The 8 primitives

TypeSizeRangeDefaultNotes
byte8 bits−128 to 1270Signed. Useful for raw binary data, file I/O.
short16 bits−32,768 to 32,7670Rarely used.
int32 bits−2³¹ to 2³¹−10Default integer type.
long64 bits−2⁶³ to 2⁶³−10LSuffix L required for literals.
float32 bits~ ±3.4 × 10³⁸0.0fSuffix f required.
double64 bits~ ±1.7 × 10³⁰⁸0.0Default for decimals.
char16 bits0 to 65,535'\u0000'Unsigned. Holds a UTF-16 code unit.
booleanJVM-definedtrue / falsefalseSize not specified by JVM spec.

Key things that trip people up

class Foo {
    int x;        // defaults to 0
    boolean b;    // defaults to false
}

void method() {
    int y;
    System.out.println(y);  // COMPILE ERROR — not initialized
}

My Doubts

Doubt 01

Didn't fully get why byte b = (byte) 200 gives −56. Explain the signed/wrap-around mechanic.

Doubt 02

Didn't fully get why 0.1 + 0.2 != 0.3. Explain why this happens.

Detailed Explanations

Doubt 01 — Why byte (byte) 200 is −56

Signed vs unsigned. A byte is 8 bits. With 8 bits you can represent exactly 256 different values (2⁸). The question is which 256 values?

Java picked signed. Always. No unsigned primitives except char.

Two's complement. Java stores negative numbers using two's complement. The leftmost bit is the sign bit — 0 means non-negative, 1 means negative.

00000000 →    0
00000001 →    1
00000010 →    2
...
01111111 →  127   ← largest positive
10000000 → -128   ← most negative (sign bit flips)
10000001 → -127
...
11111110 →   -2
11111111 →   -1

The values wrap around. After 127 (01111111), adding 1 gives 10000000, which is −128, not 128.

Now: byte b = (byte) 200

200 in binary is 11001000. The cast says "truncate to 8 bits." So the byte stores 11001000. Java reads it as signed two's complement — leftmost bit is 1, so negative. To find the value:

  1. Flip all bits: 1100100000110111
  2. Add 1: 00110111 + 1 = 00111000 = 56
  3. Apply negative sign: −56
Shortcut: any value from 128 to 255 wraps to v − 256.
So 200 − 256 = −56. ✓

Doubt 02 — Why 0.1 + 0.2 ≠ 0.3

Computers store numbers in binary. Integers are easy — 5 in binary is 101, exact. Fractions are ugly. In decimal, we write 0.1 exactly. In binary, 0.1 is not exactly representable — it's a repeating fraction, like 1/3 in decimal.

0.1 in binary = 0.0001100110011001100110011...  (repeating forever)

So when you write double x = 0.1;, the computer stores the closest possible binary approximation in 64 bits — about 0.1000000000000000055511151231257827021181583404541015625.

When you add:

So 0.1 + 0.2 == 0.3 is false. The values differ in the 17th decimal place. == is exact.

This is IEEE 754, not a Java bug. Every modern language uses the same hardware standard — Python, JavaScript, C, C++, Go, Rust. Same result everywhere.

Why this matters for money:

double balance = 0.0;
for (int i = 0; i < 1000; i++) {
    balance += 0.01;  // add 1 paisa, 1000 times
}
System.out.println(balance);  // 9.999999999999831, not 10.00

Multiply that across millions of transactions and audits fail.

The fix: BigDecimal. Stores numbers as exact decimal digits + a scale, not as binary approximations.

import java.math.BigDecimal;

BigDecimal a = new BigDecimal("0.1");   // pass as STRING!
BigDecimal b = new BigDecimal("0.2");
BigDecimal sum = a.add(b);
System.out.println(sum);  // 0.3 exactly
System.out.println(sum.equals(new BigDecimal("0.3")));  // true

Critical gotcha: always construct BigDecimal from a String, not a double. new BigDecimal(0.1) feeds it the already-corrupted binary approximation. Use new BigDecimal("0.1") or BigDecimal.valueOf(0.1) (which internally calls Double.toString first).

Q&A — Check Yourself

Q · 01
What does this print?
byte b = (byte) 130;
System.out.println(b);
My answer

−32

Wrong — arithmetic slip

Correct answer: −126

130 in binary is 10000010. Flip → 01111101, +1 → 01111110 = 126, negate → −126. Shortcut: 130 − 256 = −126.

Q · 02
What does this print?
byte b = (byte) 128;
System.out.println(b);
My answer

−128

Correct ✓

128 is exactly the most-negative value in two's complement — 10000000. Shortcut: 128 − 256 = −128. The range [−128, 127] is asymmetric by one because of this.

Q · 03
True or false: in Java, byte, short, int, long, and char are all signed.
My answer

False — char is unsigned

Correct ✓

char is the only unsigned primitive. 16 bits, range 0 to 65,535. The integer types are all signed.

Q · 04
What does this print, and why?
double a = 0.1 + 0.2;
double b = 0.3;
System.out.println(a == b);
My answer

false (decimal precision stored differently)

Correct ✓

Binary approximations of 0.1 and 0.2 sum to 0.30000000000000004, while 0.3 has its own approximation. == is exact, so false.

Q · 05
A colleague writes: new BigDecimal(0.1).add(new BigDecimal(0.2)) and says "I used BigDecimal, so it's exact, right?" Spot the bug.
My answer

Parameter is a double which has corrupted long-decimal storage, so output might still be 0.30000...xxx

Correct ✓

new BigDecimal(0.1) accepts a double — already the corrupted binary approximation. BigDecimal faithfully stores it. Fix: new BigDecimal("0.1") or BigDecimal.valueOf(0.1).

Score
4.5 / 5
Only stumble was Q1's arithmetic — concept was right. Lock in the v − 256 shortcut.
↑ Back to index
Topic 02

Wrapper Classes & Autoboxing

Every primitive type has a corresponding wrapper class — an object that "wraps" the primitive value. Wrappers exist because Java's generics and collections only work with objects, not primitives.

The 8 wrappers

PrimitiveWrapper
byteByte
shortShort
intInteger
longLong
floatFloat
doubleDouble
charCharacter
booleanBoolean

Naming gotcha: mostly capitalized, but int → Integer and char → Character (not Int and Char).

List<int> list = new ArrayList<>();        // COMPILE ERROR
List<Integer> list = new ArrayList<>();    // OK

int vs Integer

AspectintInteger
TypePrimitiveObject (reference)
Default0null
MemoryStack, 4 bytesHeap, ~16 bytes
Can be null?NoYes
Use in generics?NoYes
== comparisonValue comparisonReference comparison ⚠

That last row is where bugs live — we'll see why in Topic 03.

Wrapper objects are immutable

Once you create an Integer(5), that object always represents 5. If you do i = i + 1, you're creating a new Integer object and pointing i to it. The old one is unchanged. This matters for thread safety and for the cache trick coming up.

Autoboxing & Unboxing

Java automatically converts between primitives and wrappers so you don't have to write conversion code.

Integer i = 5;            // autoboxing: int 5 → Integer.valueOf(5)
int x = i;                // unboxing: i.intValue() → int

List<Integer> list = new ArrayList<>();
list.add(10);             // autoboxing
int first = list.get(0);  // unboxing

Under the hood, the compiler rewrites Integer i = 5; as Integer i = Integer.valueOf(5); — not new Integer(5). That distinction is the doorway into the Integer cache.

Hidden costs of autoboxing

1. NullPointerException on unboxing

Integer i = null;
int x = i;        // NullPointerException at runtime!

Compiler generates i.intValue(), and calling a method on null blows up. Very common bug with Map.get():

Map<String, Integer> counts = new HashMap<>();
int n = counts.get("missing");   // NPE

2. Performance

Long sum = 0L;                    // Long, not long!
for (long i = 0; i < 1_000_000; i++) {
    sum += i;                     // autoboxes/unboxes every iteration
}
// Allocates ~1 million Long objects. Slow.

Fix: use long sum = 0L; (primitive).

My Doubts

Doubt 01

None on this topic — moved straight to the cache.

Q&A — Check Yourself

Pending

Q&A folded into Topic 03 since the Integer cache builds directly on this material.

↑ Back to index
Topic 03

The Integer Cache

Remember that autoboxing uses Integer.valueOf(x), not new Integer(x). Here's what Integer.valueOf() actually does inside the JDK:

public static Integer valueOf(int i) {
    if (i >= IntegerCache.low && i <= IntegerCache.high) {
        return IntegerCache.cache[i + (-IntegerCache.low)];
    }
    return new Integer(i);
}

That IntegerCache is a static array of pre-made Integer objects for the values −128 to 127. The JDK creates them once when the class loads and reuses them forever.

Why it matters: == comparison

== on objects compares references, not values.

Integer a = 100;
Integer b = 100;
System.out.println(a == b);    // true  — same cached object

Integer c = 200;
Integer d = 200;
System.out.println(c == d);    // false — different heap objects
Same code structure, different result based purely on the value.
This is the Java interview gotcha.

The fix: always use .equals()

Integer c = 200;
Integer d = 200;
System.out.println(c.equals(d));   // true — compares values
System.out.println(c == d);        // false — compares references

Rule of thumb: never use == on wrapper objects. Always .equals(), or Objects.equals() for null safety.

Why −128 to 127?

  1. Common values. Most integers in real code are small — loop counters, indices, ages, status codes.
  2. It matches byte range. Anything that fits in a byte is cached.

You can expand the upper bound via -XX:AutoBoxCacheMax=N. Production code shouldn't rely on this.

Other wrapper caches

WrapperCached range
Byte−128 to 127 (the whole range)
Short−128 to 127
Integer−128 to 127 (configurable upper)
Long−128 to 127
Character0 to 127 (ASCII)
BooleanTRUE and FALSE (only 2)
FloatNo cache
DoubleNo cache

The deepest gotcha: new Integer(x) skips the cache

Integer a = 100;                 // cached
Integer b = new Integer(100);    // brand new object
System.out.println(a == b);      // false! Both are 100.

new Integer() always allocates fresh — which is why it was deprecated in Java 9 and removed in later versions.

Q&A — Check Yourself

Q · 01
What does this print?
Integer a = 100;
Integer b = 100;
System.out.println(a == b);
My answer

false (different object references)

Wrong

Correct answer: true

100 is within the −128 to 127 cache range. Both a and b get the same cached object. Same reference → == is true. This is the whole point of the cache.

Q · 02
What does this print?
Integer a = 200;
Integer b = 200;
System.out.println(a == b);
My answer

false (different object references)

Correct ✓

200 is outside the cache range. Integer.valueOf(200) allocates a new object each time. Different heap objects → == is false.

Q · 03
What does this print, and why?
Integer a = 127;
Integer b = new Integer(127);
System.out.println(a == b);
System.out.println(a.equals(b));
My answer

false, true — first compares references (one cached, one heap); second compares values

Correct ✓

new Integer() bypasses the cache even within the cache range. Different references → == is false. Same value → .equals() is true.

Q · 04
What does this code do at runtime?
Map<String, Integer> counts = new HashMap<>();
counts.put("apple", 5);
int n = counts.get("banana");
System.out.println(n);
My answer

NullPointerException

Correct ✓

get() returns null. Unboxing null to int calls null.intValue() → NPE. Fix: use getOrDefault or guard with a null check.

Q · 05
Spot the bug. Tests pass, production fails.
public boolean sameId(Integer x, Integer y) {
    return x == y;
}
My answer

References are compared. If out of cache range it returns false. Should use equals.

Correct ✓

Tests use small IDs (in cache, == works). Production has IDs in the thousands → cache miss → == fails. Fix: Objects.equals(x, y) (null-safe).

Integer a = X; Integer b = X; a == b
→ true if X ∈ [−128, 127]
→ false otherwise
Score
4 / 5
Missed Q1 — the most important concept. The cache makes == return true for small values, which is what makes the bug insidious in production.
↑ Back to index
Topic 04

String Immutability, the Pool & intern()

This topic has the same "shared object" theme as the Integer cache — pooled instances for common values, fresh allocations otherwise. Once you see the pattern, both topics click together.

Part 1 — Strings are immutable

Immutable means: once a String object is created, its contents cannot be changed. Every "modification" actually creates a new String.

String s = "hello";
s.toUpperCase();           // returns "HELLO" — but doesn't change s
System.out.println(s);     // still "hello"

s = s.toUpperCase();       // now s points to the NEW "HELLO" object
System.out.println(s);     // "HELLO"

Methods like toUpperCase(), replace(), substring(), trim(), concat() — none of them modify the original. They all return new String objects.

Why immutable? Four real reasons

1. Security

Strings are everywhere — file paths, URLs, connection strings, class names. If they were mutable, a malicious thread could change a path after a security check but before the file open. Immutability eliminates this entire bug class.

2. Thread safety

Immutable objects are inherently thread-safe. No locks needed. You can share a String across 1000 threads without any synchronization.

3. Hashcode caching

String caches its hashcode after first computation. Makes it incredibly fast as a HashMap key. If strings were mutable, the cached hashcode could go stale.

// Inside String.java:
private int hash; // cached hashcode

public int hashCode() {
    int h = hash;
    if (h == 0 && value.length > 0) {
        // compute and cache
        hash = h = ...;
    }
    return h;
}

4. The String pool

Which leads us to part 2.

Part 2 — The String Pool

Just like the Integer cache, Java keeps a special memory region called the String pool (a.k.a. "string intern pool"). It holds unique string literals.

When you write a string literal, Java checks the pool:

String a = "hello";
String b = "hello";
System.out.println(a == b);    // true — same pool object

But new String(...) always allocates

String a = "hello";              // pool
String b = new String("hello");  // heap — brand new object
System.out.println(a == b);      // false — different references
System.out.println(a.equals(b)); // true — same content

Same pattern as new Integer(100) vs autoboxed 100. There's almost never a reason to call new String(literal) in real code.

Where does the pool live?

The pool is garbage-collected — entries can be removed if nothing references them.

Part 3 — The intern() method

intern() lets you manually add a string to the pool, or get the pool reference for an equal string already there.

String a = new String("hello");        // heap object
String b = a.intern();                  // returns pool reference
String c = "hello";                     // pool literal

System.out.println(a == b);  // false — a is heap, b is pool
System.out.println(b == c);  // true  — both pool references

Contract: if a string equal to this one is already in the pool, return that reference; otherwise add this one and return it. After intern(), s.intern() == "literal" is true for any equal string.

The classic gotcha — concatenation

String a = "hello";
String b = "hel" + "lo";        // COMPILE-TIME fold
System.out.println(a == b);     // true — both pool

String x = "hel";
String y = x + "lo";            // RUNTIME — uses StringBuilder
String z = "hello";
System.out.println(z == y);     // false — y is on heap
System.out.println(z == y.intern()); // true — intern brings it to pool

The rule:

A subtle extension: marking the variable final makes it a compile-time constant, which changes the behavior:

final String x = "hel";
String y = x + "lo";
System.out.println("hello" == y);  // true! final inlines the value

Why care? Memory.

If you're parsing millions of strings (logs, JSON, indexes) where many are duplicates, intern() can save massive memory by ensuring only one copy exists.

// Naive — 1M Strings even if most are duplicates
List<String> logLevels = new ArrayList<>();
for (LogEntry e : entries) {
    logLevels.add(e.getLevel());  // 1M copies of "INFO"
}

// Memory-efficient
for (LogEntry e : entries) {
    logLevels.add(e.getLevel().intern());  // one "INFO" in memory
}

But intern() isn't free — it does a pool lookup. Use it when you know you have lots of duplicates.

"literal" → pool · new String(...) → heap
"a" + "b" → pool (compile fold) · x + "b" → heap (runtime)
s.intern() → pool reference for s

My Doubts

Doubt 01

None — the Integer cache mental model carried over directly.

Q&A — Check Yourself

Q · 01
What does this print?
String a = "java";
String b = "java";
System.out.println(a == b);
System.out.println(a.equals(b));
My answer

true, true

Correct ✓

Both literals point to the same pool entry. == (same reference) → true. .equals() (same content) → true.

Q · 02
What does this print?
String a = "java";
String b = new String("java");
System.out.println(a == b);
System.out.println(a == b.intern());
My answer

false, true

Correct ✓

new String(...) forces a heap allocation, so a == b is false. b.intern() returns the pool reference that a already points to.

Q · 03
What does this print? (Mind compile-time vs runtime.)
String a = "hello";
String b = "hel" + "lo";
String c = "hel";
String d = c + "lo";
System.out.println(a == b);
System.out.println(a == d);
My answer

true, false

Correct ✓

"hel" + "lo" folds at compile time into the literal "hello" → pool. But c + "lo" uses c as a variable → runtime StringBuilder → heap. Mark c as final and the second comparison flips to true.

Q · 04
Why doesn't this work, and how to fix?
String name = "ravi";
name.toUpperCase();
System.out.println(name);   // prints "ravi", not "RAVI"
My answer

toUpperCase doesn't modify the string. Fix: name = name.toUpperCase()

Correct ✓

Strings are immutable. Every "modifier" method returns a new string. The return value was thrown away. Reassign it — same pattern for replace(), trim(), substring(), etc.

Q · 05
Parsing 10M log entries with level field ("INFO" / "WARN" / "ERROR" / "DEBUG") into a List<String>. A junior says "Strings are immutable so they're already shared." Right or wrong?
My answer

No — we need to use intern() (pool lookup) before adding to the list.

Correct ✓

The pool only auto-shares literals. Strings parsed at runtime are fresh heap objects — 10M of them, even though only 4 distinct values exist. intern() collapses them to 4 pool entries. In production, a manual canonicalizing map or an enum is often a better choice than polluting the global pool.

Score
5 / 5
Clean sweep. The Integer cache mental model transferred perfectly — same pattern, different type.
↑ Back to index
Topic 05

String vs StringBuilder

This builds directly on String immutability. Every "modification" to a String creates a new object — elegant in isolation, catastrophic in a loop. StringBuilder is the escape hatch: mutable, single-object, fast.

The problem with String concatenation

Watch what actually happens here:

String result = "";
  for (int i = 0; i < 5; i++) {
      result = result + i;
  }
  // result = "01234"

Looks innocent. Trace what happens to the heap:

Iterationresult isNew objects
Start""
i=0"0"new String "0"
i=1"01"new String "01" (old "0" → garbage)
i=2"012"new String "012" (old "01" → garbage)
i=3"0123"new String "0123" (old "012" → garbage)
i=4"01234"new String "01234" (old "0123" → garbage)

5 String objects allocated, 4 immediately garbage. And it gets quadratically worse. For N iterations, the total characters copied is 1 + 2 + 3 + ... + N = N(N+1)/2 = O(N²), because each new String has to copy all the previous characters.

For N = 1,000,000 that's ~500 billion character copies. The GC will scream.

Enter StringBuilder

StringBuilder is a mutable sequence of characters. You can append, modify, and insert — all without creating new objects (most of the time). Internally it holds a char[] (or byte[] in Java 9+) and an int length.

StringBuilder sb = new StringBuilder();
  for (int i = 0; i < 5; i++) {
      sb.append(i);
  }
  String result = sb.toString();   // "01234"

One StringBuilder object. One toString() at the end. No garbage in between.

How StringBuilder works internally

A StringBuilder holds:

When you append():

Most appends are O(1). The occasional resize is O(n), but amortized across many appends it's still O(1) per character. Way better than String's O(n²).

StringBuilder sb = new StringBuilder();  // capacity 16
  sb.append("hello");                      // length=5, capacity=16
  sb.append(" world");                     // length=11, capacity=16
  sb.append(" of streams and threads");   // length=33 → resize to 34

Performance tip: if you know the final size roughly, pre-size:

StringBuilder sb = new StringBuilder(1024);  // pre-allocate 1KB

Avoids all the intermediate resize copies. Big win for hot paths.

The useful API

StringBuilder sb = new StringBuilder();

  sb.append("hello");          // "hello"
  sb.append(' ').append(42);   // "hello 42" — chainable, any type
  sb.insert(0, ">> ");         // ">> hello 42"
  sb.delete(0, 3);             // "hello 42"
  sb.reverse();                // "24 olleh"
  sb.replace(0, 2, "XX");      // "XX olleh"
  sb.setLength(2);             // "XX" — truncate
  sb.charAt(0);                // 'X'
  sb.length();                 // 2
  sb.capacity();               // backing array size (may be > length)

  String result = sb.toString();   // convert to immutable String

All of these mutate sb in place (except toString()). append() returns sb itself for chaining, not a new object.

What about StringBuffer?

StringBuffer is the older sibling. Same API, same behavior — except every method is synchronized (thread-safe).

AspectStringBuilderStringBuffer
Mutable?YesYes
Thread-safe?NoYes (synchronized)
SpeedFasterSlower (lock overhead)
When to use99% of casesAlmost never

Rule of thumb: use StringBuilder. StringBuffer exists for backward compatibility (since Java 1.0). StringBuilder was added in Java 5 because synchronizing every operation is wasteful for the common single-threaded case. In multi-threaded code, the standard pattern is "each thread has its own builder, merge at the end" — which avoids lock contention entirely.

The compiler is smarter than you think

String result = "hello" + name + " you are " + age;

This doesn't actually do naive String concatenation. The compiler rewrites it to use a StringBuilder (or StringConcatFactory in Java 9+) behind the scenes. A single concatenation expression is fine.

The problem only appears when you concatenate across separate statements, especially in loops:

// BAD — compiler can't optimize across iterations
  String result = "";
  for (int i = 0; i < 1000; i++) {
      result = result + "x";   // O(n²)
  }

  // GOOD — explicit StringBuilder
  StringBuilder sb = new StringBuilder();
  for (int i = 0; i < 1000; i++) {
      sb.append("x");          // O(n)
  }
  String result = sb.toString();

The compiler can't lift the StringBuilder out of the loop — because in principle, someone could observe the intermediate value of result. So you have to do it yourself.

Modern alternatives

1. String.join() — for joining with a separator

String csv = String.join(",", "a", "b", "c");   // "a,b,c"
  String csv2 = String.join(",", List.of("a", "b", "c"));

2. String.format() — for templates

String msg = String.format("User %s is %d years old", name, age);

Slow internally (regex-based parsing) — fine for one-offs, bad in hot loops.

3. Text blocks (Java 15+)

String json = """
      {
        "name": "%s",
        "age": %d
      }
      """.formatted(name, age);

Decision table — when to use what

SituationUse
Single concat expression (a + b + c)Plain + — compiler handles it
Loop building a StringStringBuilder
Joining a collection with a separatorString.join
Template with placeholdersString.format (one-off)
Thread-safe mutation (rare)StringBuffer or external sync
String → immutable, every "edit" allocates
StringBuilder → mutable, single object, NOT thread-safe (default choice)
StringBuffer → mutable, single object, synchronized (legacy)

My Doubts

Doubt 01

None — the immutability mental model from Topic 4 carried over.

Q&A — Check Yourself

Q · 01
What's the time complexity of this code, and why?
String result = "";
  for (int i = 0; i < n; i++) {
      result = result + "x";
  }
My answer

O(n)

Wrong

Correct answer: O(n²)

Each iteration creates a brand new String containing all the old characters plus one new one. Total characters copied = 1 + 2 + 3 + ... + n = n(n+1)/2 = O(n²). For n=1M, that's ~500 billion character copies. This is the entire reason StringBuilder exists.

Q · 02
What does this print?
StringBuilder sb = new StringBuilder("hello");
  StringBuilder sb2 = sb.append(" world");
  System.out.println(sb == sb2);
  System.out.println(sb);
My answer

true, "hello world"

Correct ✓

append() returns this, not a new object. So sb == sb2 is true. Both references point to the same mutated buffer. This is also why sb.append(a).append(b).append(c) works — every call returns the same builder.

Q · 03
Spot the bug — CSV row builder.
StringBuilder sb = new StringBuilder();
  sb.append("a");
  sb.append(",");
  sb.append("b");
  sb.append(",");
  sb.append("c");
  String csv = sb;
  System.out.println(csv);
My answer

sb.toString() is missing

Correct ✓

Won't compile — StringBuilder isn't assignable to String. Fix: String csv = sb.toString(); Subtle related gotcha: System.out.println(sb) works (implicitly calls toString), but storing as a String needs the explicit conversion.

Q · 04
Colleague writes this and claims "the compiler optimizes concatenation." Is the compiler going to save them at 10,000 entries?
public String buildReport(List<String> lines) {
      String result = "";
      for (String line : lines) {
          result = result + line + "\n";
      }
      return result;
  }
My answer

Compiler can't resolve concat in loop, so it happens at runtime — creates 10,000 intermediate substrings.

Correct ✓

Right instinct. The compiler optimizes within a single expression — each iteration internally uses a temp StringBuilder for result + line + "\n". But that result has to be re-copied into a fresh builder on the next iteration. Compiler can't lift it out of the loop because result must remain a String at each step.

For 10,000 entries averaging 50 chars: total character copies ≈ 2.5 billion, ~2.5 GB of garbage allocations, GC thrash. Fix:

StringBuilder sb = new StringBuilder(lines.size() * 64);
  for (String line : lines) {
      sb.append(line).append('\n');
  }
  return sb.toString();

Now it's O(n) total — roughly 100x–1000x faster.

Q · 05
Two parts: (a) When use StringBuffer over StringBuilder? (b) Singleton logger with shared StringBuilder — what can go wrong?
public class Logger {
      private StringBuilder buffer = new StringBuilder();

      public void log(String msg) {
          buffer.append(msg).append("\n");
      }

      public String flush() {
          String result = buffer.toString();
          buffer.setLength(0);
          return result;
      }
  }
My answer

(a) StringBuilder when no thread safety needed, StringBuffer when synchronized access needed. (b) If multiple threads use this singleton, logs might get corrupted.

Correct ✓

(a) Right call. In practice though, StringBuffer is almost never the modern answer — the standard pattern is "each thread its own builder, merge later" to avoid lock contention. But you correctly named the technical distinction.

(b) Right instinct on corruption. Concretely:

  • buffer.append(msg).append("\n") is two method calls. Threads can interleave — log lines get scrambled.
  • append itself isn't atomic — internal resize during another thread's write can cause ArrayIndexOutOfBoundsException.
  • flush() has a race: writes between toString() and setLength(0) get lost or duplicated.

Standard fix — explicit synchronization on the critical sections:

public synchronized void log(String msg) {
      buffer.append(msg).append("\n");
  }

  public synchronized String flush() {
      String result = buffer.toString();
      buffer.setLength(0);
      return result;
  }

Alternatively swap in StringBuffer, but synchronized methods give you control over the whole critical section. In real code you'd use a logging framework (Logback, Log4j2) instead of rolling your own.

Score
4 / 5
Q1 miss — the foundational concept of this topic. Lock in: String concat in a loop is O(n²). The rest you got cleanly, and the Q4 reasoning showed you actually understand the mechanic.
↑ Back to index