A working developer's notebook — language fundamentals, gotchas that show up in production, and the questions interviewers actually ask.
0.1 + 0.2 ≠ 0.3.
== behaves differently for 100 vs 200. The classic Java interview gotcha.
+ in a loop is O(n²). Mutable builders, capacity, and the StringBuffer footnote.
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.
| Type | Size | Range | Default | Notes |
|---|---|---|---|---|
| byte | 8 bits | −128 to 127 | 0 | Signed. Useful for raw binary data, file I/O. |
| short | 16 bits | −32,768 to 32,767 | 0 | Rarely used. |
| int | 32 bits | −2³¹ to 2³¹−1 | 0 | Default integer type. |
| long | 64 bits | −2⁶³ to 2⁶³−1 | 0L | Suffix L required for literals. |
| float | 32 bits | ~ ±3.4 × 10³⁸ | 0.0f | Suffix f required. |
| double | 64 bits | ~ ±1.7 × 10³⁰⁸ | 0.0 | Default for decimals. |
| char | 16 bits | 0 to 65,535 | '\u0000' | Unsigned. Holds a UTF-16 code unit. |
| boolean | JVM-defined | true / false | false | Size not specified by JVM spec. |
char c = 'A'; int x = c; gives 65.byte b = (byte) 200; gives −56, not 200.0.1 + 0.2 != 0.3. Never use float/double for money.class Foo { int x; // defaults to 0 boolean b; // defaults to false } void method() { int y; System.out.println(y); // COMPILE ERROR — not initialized }
Didn't fully get why byte b = (byte) 200 gives −56. Explain the signed/wrap-around mechanic.
Didn't fully get why 0.1 + 0.2 != 0.3. Explain why this happens.
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?
unsigned char): 0 to 255.byte): −128 to 127. Half negative, half non-negative.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:
11001000 → 0011011100110111 + 1 = 00111000 = 56v − 256.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:
0.30000000000000004So 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).
byte b = (byte) 130; System.out.println(b);
−32
Correct answer: −126
130 in binary is 10000010. Flip → 01111101, +1 → 01111110 = 126, negate → −126. Shortcut: 130 − 256 = −126.
byte b = (byte) 128; System.out.println(b);
−128
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.
False — char is unsigned
char is the only unsigned primitive. 16 bits, range 0 to 65,535. The integer types are all signed.
double a = 0.1 + 0.2; double b = 0.3; System.out.println(a == b);
false (decimal precision stored differently)
Binary approximations of 0.1 and 0.2 sum to 0.30000000000000004, while 0.3 has its own approximation. == is exact, so false.
new BigDecimal(0.1).add(new BigDecimal(0.2)) and says "I used BigDecimal, so it's exact, right?" Spot the bug.Parameter is a double which has corrupted long-decimal storage, so output might still be 0.30000...xxx
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).
v − 256 shortcut.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.
| Primitive | Wrapper |
|---|---|
| byte | Byte |
| short | Short |
| int | Integer |
| long | Long |
| float | Float |
| double | Double |
| char | Character |
| boolean | Boolean |
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
| Aspect | int | Integer |
|---|---|---|
| Type | Primitive | Object (reference) |
| Default | 0 | null |
| Memory | Stack, 4 bytes | Heap, ~16 bytes |
| Can be null? | No | Yes |
| Use in generics? | No | Yes |
| == comparison | Value comparison | Reference comparison ⚠ |
That last row is where bugs live — we'll see why in Topic 03.
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.
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.
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
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).
None on this topic — moved straight to the cache.
Q&A folded into Topic 03 since the Integer cache builds directly on this material.
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.
== 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
.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.
byte is cached.You can expand the upper bound via -XX:AutoBoxCacheMax=N. Production code shouldn't rely on this.
| Wrapper | Cached range |
|---|---|
| Byte | −128 to 127 (the whole range) |
| Short | −128 to 127 |
| Integer | −128 to 127 (configurable upper) |
| Long | −128 to 127 |
| Character | 0 to 127 (ASCII) |
| Boolean | TRUE and FALSE (only 2) |
| Float | No cache |
| Double | No cache |
new Integer(x) skips the cacheInteger 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.
Integer a = 100; Integer b = 100; System.out.println(a == b);
false (different object references)
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.
Integer a = 200; Integer b = 200; System.out.println(a == b);
false (different object references)
200 is outside the cache range. Integer.valueOf(200) allocates a new object each time. Different heap objects → == is false.
Integer a = 127;
Integer b = new Integer(127);
System.out.println(a == b);
System.out.println(a.equals(b));
false, true — first compares references (one cached, one heap); second compares values
new Integer() bypasses the cache even within the cache range. Different references → == is false. Same value → .equals() is true.
Map<String, Integer> counts = new HashMap<>(); counts.put("apple", 5); int n = counts.get("banana"); System.out.println(n);
NullPointerException
get() returns null. Unboxing null to int calls null.intValue() → NPE. Fix: use getOrDefault or guard with a null check.
public boolean sameId(Integer x, Integer y) { return x == y; }
References are compared. If out of cache range it returns false. Should use equals.
Tests use small IDs (in cache, == works). Production has IDs in the thousands → cache miss → == fails. Fix: Objects.equals(x, y) (null-safe).
== return true for small values, which is what makes the bug insidious in production.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.
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.
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.
Immutable objects are inherently thread-safe. No locks needed. You can share a String across 1000 threads without any synchronization.
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; }
Which leads us to part 2.
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
new String(...) always allocatesString 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.
String uses byte[] instead of char[] internally (compact strings — saves ~half the memory for ASCII)The pool is garbage-collected — entries can be removed if nothing references them.
intern() methodintern() 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.
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:
StringBuilder.toString()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
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.
None — the Integer cache mental model carried over directly.
String a = "java"; String b = "java"; System.out.println(a == b); System.out.println(a.equals(b));
true, true
Both literals point to the same pool entry. == (same reference) → true. .equals() (same content) → true.
String a = "java"; String b = new String("java"); System.out.println(a == b); System.out.println(a == b.intern());
false, true
new String(...) forces a heap allocation, so a == b is false. b.intern() returns the pool reference that a already points to.
String a = "hello"; String b = "hel" + "lo"; String c = "hel"; String d = c + "lo"; System.out.println(a == b); System.out.println(a == d);
true, false
"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.
String name = "ravi"; name.toUpperCase(); System.out.println(name); // prints "ravi", not "RAVI"
toUpperCase doesn't modify the string. Fix: name = name.toUpperCase()
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.
No — we need to use intern() (pool lookup) before adding to the list.
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.
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.
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:
| Iteration | result is | New 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.
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.
A StringBuilder holds:
When you append():
length, bump lengtholdCapacity * 2 + 2), copy old contents in, then appendMost 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.
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.
StringBuffer is the older sibling. Same API, same behavior — except every method is synchronized (thread-safe).
| Aspect | StringBuilder | StringBuffer |
|---|---|---|
| Mutable? | Yes | Yes |
| Thread-safe? | No | Yes (synchronized) |
| Speed | Faster | Slower (lock overhead) |
| When to use | 99% of cases | Almost 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.
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.
String csv = String.join(",", "a", "b", "c"); // "a,b,c" String csv2 = String.join(",", List.of("a", "b", "c"));
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.
String json = """
{
"name": "%s",
"age": %d
}
""".formatted(name, age);
| Situation | Use |
|---|---|
Single concat expression (a + b + c) | Plain + — compiler handles it |
| Loop building a String | StringBuilder |
| Joining a collection with a separator | String.join |
| Template with placeholders | String.format (one-off) |
| Thread-safe mutation (rare) | StringBuffer or external sync |
None — the immutability mental model from Topic 4 carried over.
String result = ""; for (int i = 0; i < n; i++) { result = result + "x"; }
O(n)
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.
StringBuilder sb = new StringBuilder("hello"); StringBuilder sb2 = sb.append(" world"); System.out.println(sb == sb2); System.out.println(sb);
true, "hello world"
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.
StringBuilder sb = new StringBuilder(); sb.append("a"); sb.append(","); sb.append("b"); sb.append(","); sb.append("c"); String csv = sb; System.out.println(csv);
sb.toString() is missing
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.
public String buildReport(List<String> lines) { String result = ""; for (String line : lines) { result = result + line + "\n"; } return result; }
Compiler can't resolve concat in loop, so it happens at runtime — creates 10,000 intermediate substrings.
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.
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; } }
(a) StringBuilder when no thread safety needed, StringBuffer when synchronized access needed. (b) If multiple threads use this singleton, logs might get corrupted.
(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.