Demo: string bugs: ==, off-by-one, and slow concatenation
On This Lesson 11 topics
Lesson Overview
This is the last lesson of the module, and it is a bug hunt. Three mistakes show up again and again in String code. Two of them stop the program or give a wrong answer; the third gives the right answer, slowly. We will make each one happen on purpose, read what Java tells us, and fix it.
The picture of the module still holds: a String is a printed sheet that cannot be erased. Two sheets can carry the same words yet be two different pieces of paper (bug one). The sheet has a fixed number of positions, and asking for one past the end fails (bug two). And reprinting the whole sheet after every added word is a lot of paper (bug three).
Why this matters: these are the bugs reviewers look for first in a backend pull request. Recognizing them in seconds is a real skill.
This lesson builds on Lessons 4 and 5 and on reading stack traces (BEG-013 and BEG-014).
Learning Objectives
By the end of this lesson you will be able to:
- Show that
==on Strings can befalsefor identical text, and fix it withequals. - Write a null-safe comparison by putting the known text first.
- Explain the end-exclusive rule of
substringand fix an end index that exceedslength(). - Handle a missing
indexOfresult before cutting text. - Measure a slow
+=loop against aStringBuilderand see how the cost grows with size. - Apply the rule of thumb:
equals, check bounds,StringBuilderin loops.
Written Explanation
Bug 1: == compares references, not text
== on two reference variables asks whether they point at the very same object. The Java Language Specification says that String literals (and strings produced by constant expressions) refer to a single shared instance, so two identical literals are ==. A String created at run time is a new, separate object, even when its text is the same. That is why == sometimes works in a quick test with literals and then fails with real input from a keyboard, a file, or a network message. Use equals for the text, or equalsIgnoreCase.
A related hazard is null. Calling answer.equals("yes") when answer is null throws a NullPointerException. Writing "yes".equals(answer) is safe: a String literal is never null, and equals with a null argument simply returns false.
Bug 2: the end of the sheet
substring(begin, end) accepts begin up to end, and end up to length(). Passing length() + 1 as the end throws a StringIndexOutOfBoundsException, the String sibling of the array error. Remember the two rules together:
endis excluded, sosubstring(0, s.length())is the whole text and is allowed.- The largest legal
endislength(), notlength() - 1.
When positions come from indexOf, a result of -1 for "not found" must be tested before it is used as an end index.
Bug 3: slow concatenation
Lesson 5 explained that s += x builds a new String that copies the old text every time. In a loop with a lot of steps, the total copying grows much faster than the number of steps, so the program is correct but sluggish. A program that is "correct but slow" gets past testing with small data, which is exactly what makes this bug expensive: it is found by customers.
A short debugging routine
For all three, the same four steps work:
- Reproduce with the smallest input that fails.
- Read the exception message (index, length) and the first line of the trace that names your own code.
- Name the rule that was broken (reference vs. text, end excluded, copying cost).
- Fix and re-run the same input to confirm.
Examples
Example 1: == versus equals (simple)
public class Ex1 {
public static void main(String[] args) {
String a = "hi";
String b = "hi";
String c = new String("hi");
String lo = "i";
String d = "h" + lo;
System.out.println(a == b);
System.out.println(a == c);
System.out.println(a == d);
System.out.println(a.equals(c));
System.out.println(a.equals(d));
String answer = null;
System.out.println("yes".equals(answer));
System.out.println(answer.equals("yes"));
}
}Output (compiled and run with JDK 21):
true
false
false
true
true
false
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.equals(Object)" because "answer" is null
at Ex1.main(Ex1.java:15)The two literals are the same object, so the first result is true. new String("hi") and the run-time concatenation "h" + lo build separate objects with the same text, so == is false, while equals is true in both cases. The null-safe form returns false quietly, and the unsafe form throws. The exact wording of the NullPointerException message can differ between Java versions.
Fix: always equals. Lesson: a comparison that passes with literals may fail with real data.
Example 2: bounds and indexOf (practical)
public class Ex2 {
static String lastN(String s, int n) {
if (n >= s.length()) {
return s;
}
return s.substring(s.length() - n);
}
static String firstWord(String s) {
int sp = s.indexOf(" ");
if (sp == -1) {
return s;
}
return s.substring(0, sp);
}
public static void main(String[] args) {
String s = "backend";
System.out.println(lastN(s, 3));
System.out.println(lastN(s, 20));
System.out.println(firstWord("hello world"));
System.out.println(firstWord("single"));
System.out.println(s.substring(0, s.length()));
System.out.println(s.substring(0, s.length() + 1));
}
}Output (JDK-internal trace lines left out):
end
backend
hello
single
backend
Exception in thread "main" java.lang.StringIndexOutOfBoundsException: Range [0, 8) out of bounds for length 7
at Ex2.main(Ex2.java:24)lastN guards the case where n is larger than the text, and firstWord checks for -1 before cutting. The whole-string call substring(0, s.length()) is legal. The final call fails: the message reports the requested range [0, 8) and the length 7, and the trace line points at the faulty call. (This message format is from JDK 21; other versions word it differently.)
Fix: keep end <= length(), and test indexOf for -1. Lesson: guards go before the cut, not after the crash.
Example 3: how slow is slow? (realistic)
public class Ex3 {
static String slow(int n) {
String out = "";
for (int i = 0; i < n; i++) {
out += "line " + i + "\n";
}
return out;
}
static String fast(int n) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < n; i++) {
sb.append("line ").append(i).append('\n');
}
return sb.toString();
}
public static void main(String[] args) {
for (int n : new int[]{25000, 50000, 100000}) {
long t0 = System.nanoTime();
String a = slow(n);
long t1 = System.nanoTime();
String b = fast(n);
long t2 = System.nanoTime();
System.out.println(n + " same=" + a.equals(b) + " plus=" + (t1 - t0) / 1_000_000 + "ms builder=" + (t2 - t1) / 1_000_000 + "ms");
}
}
}Output from one run on the author's machine (your timings will differ):
25000 same=true plus=483ms builder=6ms
50000 same=true plus=1457ms builder=3ms
100000 same=true plus=5752ms builder=3msThe texts are identical, so the slow version is not wrong, just expensive. Doubling the number of lines made the += version take several times longer (here about three and then about four times), while the builder stayed in the low milliseconds. This is the signature of repeated copying: the cost climbs faster than the size. The numbers come from a simple timing loop on one machine, so treat them as an illustration, not a benchmark.
Fix: a StringBuilder created before the loop and converted once afterwards. Lesson: "it gave the right answer" is not the same as "it is fine".
Backend Insight
All three bugs have production versions:
- A role check written as
if (role == "admin")passes in tests where the role is a literal and fails (or, worse, succeeds only sometimes) when the role is read from a token or a database. Comparing text is a security-relevant operation, so useequals, and write the constant first so a missing value does not crash. - Cutting text at positions that came from user input (a file extension, a domain name, a prefix) fails on short or malformed input. Bounds checks are part of input validation, the "never trust the user" habit from BEG-011.
- Building a large text response in a loop with
+=shows up as slow requests only under real load, so a quick local run will not reveal it. Learn to recognize the pattern in review rather than waiting for a profiler.
Common Mistakes
Mistake 1: Using == because it worked once
It worked because both sides were the same literal. Real data is created at run time. Use equals.
Mistake 2: Calling equals on a possibly-null variable
answer.equals("yes") crashes on null. Prefer "yes".equals(answer).
Mistake 3: Thinking the end index of substring is a position that gets included
end is excluded, and the largest legal value is length().
Mistake 4: Cutting with an unchecked indexOf
s.substring(0, s.indexOf(" ")) crashes when there is no space, because the end index is -1.
Mistake 5: Leaving slow += in a loop because the test data is small
The program is correct, so no test fails. Decide by the shape of the code (appending in a loop), not by how fast your small test runs.
Exercises
Try each exercise yourself before opening the solutions.
Level A: Foundation
A1. String x = "ok"; String y = new String("ok"); What do x == y and x.equals(y) print?
Show solution A1
A1. x == y prints false (two separate objects) and x.equals(y) prints true (same characters).
A2. What is wrong with s.substring(2, s.length() + 2)? What is the largest valid second argument?
Show solution A2
A2. The end index is greater than length(), so it throws StringIndexOutOfBoundsException. The largest valid end is s.length().
A3. Which of the three bugs of this lesson never changes the output, only the speed?
Show solution A3
A3. The slow concatenation in a loop.
Level B: Application
B1. A command loop does if (cmd == "quit") break; and the user types quit. Why might it not stop, and what is the corrected line, also accepting QUIT with stray spaces?
Show solution B1
B1. == checks whether two variables point at the same object, and a String read at run time is usually a different object from the literal "quit", so the test can fail. The correct line is if (cmd.trim().equalsIgnoreCase("quit")) break;.
B2. Write static String safeSubstring(String s, int from, int to) that never throws: it clamps from to at least 0, clamps to to at most s.length(), and returns "" if the range is empty or reversed.
Show solution B2
B2.
static String safeSubstring(String s, int from, int to) {
int start = Math.max(from, 0);
int end = Math.min(to, s.length());
if (start >= end) {
return "";
}
return s.substring(start, end);
}Math.max and Math.min pull each bound into range. When the range is empty or reversed, "" is returned before substring is called.
B3. Rewrite this so it is fast:
String csv = "";
for (int i = 1; i <= n; i++) {
csv += i + ";";
}Show solution B3
B3.
StringBuilder sb = new StringBuilder();
for (int i = 1; i <= n; i++) {
sb.append(i).append(';');
}
String csv = sb.toString();Level C: Challenge
C1. With String a = "hi"; String lo = "i"; explain why a == ("h" + "i") is true but a == ("h" + lo) is false. (Hint: when is each text assembled?)
Show solution C1
C1. "h" + "i" is a constant expression: the compiler can join it and treats the result as a literal, which shares one instance with "hi", so == is true. "h" + lo uses a variable, so the joining happens when the program runs, creating a new object, so == is false. (Both facts are described in the language specification, section 3.10.5.) The takeaway is not to rely on any of this and to use equals.
C2. A teammate says: "The += loop is fine, it works." You time it with 20,000 steps and then 40,000 steps. What would you expect to see in the ratio of the two times, and what would you tell your teammate?
Show solution C2
C2. The time for 40,000 steps should be clearly more than double the time for 20,000 (in our runs, roughly three to four times for each doubling), because each step copies a longer text. The builder version would take roughly double or less. Tell the teammate: it is correct, but the cost grows faster than the data, so with production-sized input it will be slow; replace it with a StringBuilder.
Mini Challenge
Write a short "String bug checklist" program: a method static boolean sameAnswer(String typed, String expected) that is case-insensitive, trims the input, and is safe when typed is null. Add a method static String domainOf(String email) that returns the part after @, or "" when the text has no @. Add a method static String repeat(String s, int times) that uses a StringBuilder. Test each with a normal input, an edge input (empty text, null, a missing @, times of 0) and print the results.
Check your work: no == on text, every cut is guarded, and the repeat method has no += inside its loop.
Summary & Next Lesson
Key takeaways
==compares references; useequals(orequalsIgnoreCase), and put the known text first to be null-safe.substring's end is excluded and can be at mostlength(); testindexOffor-1before cutting.+=in a loop is correct but slow; build withStringBuilderand convert once.- Debug by reproducing, reading the message and the first trace line from your code, naming the broken rule, and re-running the same input.
- Rule of thumb for this module:
equals, check bounds,StringBuilderin loops.
Next lesson
This was the final lesson of the module. The module lab, "Student-grade manager", is the place to apply everything: arrays of names and grades, statistics, case-insensitive search, an aligned report, and a safe empty-class case.
Self-check
- Why can
a == bbefalsefor two Strings with identical text? - How do you compare a possibly-null String with the text
"yes"without a crash? - What is the largest valid end index for
substringon a String of length 7? - What does
indexOfreturn when the text is missing, and why can that break a latersubstring? - Which bug gives the right output, and how would you notice it?
Show answers
==compares whether they are the same object; a String created at run time is a different object even with the same text."yes".equals(value).- 7.
-1; using it as an index or end index makessubstringthrow.- Slow concatenation; you notice it by timing with larger inputs or by spotting
+=inside a loop in review.
References
- Oracle, Java Language Specification SE 21, section 3.10.5: String literals. It supports: string literals and constant-expression strings share one instance, while strings computed at run time are newly created and distinct.
- Oracle,
Stringclass, Java SE 21 API. It supports:substring(begin, end)excludesendand throws whenendexceeds the length,indexOfreturns-1when there is no match, andequalsis false for anullargument. - Not verified from a primary page: the statement that
new String("hi")always creates a separate object (the run with JDK 21 confirmeda == cisfalse), and the claim that the cost of+=in a loop grows faster than the input size (consistent with the timings above, which come from one machine and are not a benchmark). The exception message wording shown is from JDK 21 and may differ in other versions.
Practice
Apply this lesson with the module's lab, exercises and quiz.
Marking a lesson complete is optional and you can undo it any time. Your progress stays in this browser.