Strings, text blocks and formatting
String is the type you will use more than any other, and it has two properties
that surprise everybody exactly once. One produces code that looks like it works
and does nothing. The other produces a comparison that is true in testing and
false in production.
Both are worth meeting deliberately, here, rather than at 11pm before a demo.
Strings cannot be changed
A String object is immutable. Once created, its characters never change. Every
method that looks like it modifies a string actually returns a new one.
String name = "priya";
name.toUpperCase();
System.out.println(name);
priya
Nothing happened. toUpperCase() built a new string containing PRIYA and threw
it away, because nothing was assigned. The fix is to use the return value:
name = name.toUpperCase();
If a String method call is a statement on its own, it is almost certainly a
bug. replace, trim, strip, substring, concat — all of them return
rather than modify.
Immutability is not an accident. It makes strings safe to share between threads,
safe to use as Map keys, and cacheable. The price is that building a string
piece by piece creates a lot of garbage, which the StringBuilder section below
is about.
The == trap
String a = "Pune";
String b = "Pune";
String c = new String("Pune");
String d = "Pu" + "ne";
String part = "Pu";
String e = part + "ne";
System.out.println("a == b : " + (a == b));
System.out.println("a == c : " + (a == c));
System.out.println("a == d : " + (a == d));
System.out.println("a == e : " + (a == e));
System.out.println("a.equals(e) : " + a.equals(e));
a == b : true
a == c : false
a == d : true
a == e : false
a.equals(e) : true
Four identical-looking strings, three different answers from ==.
The explanation is the string pool. String literals appearing in source are
stored once in a shared pool, so a and b reference the same object. d is
"Pu" + "ne" where both halves are literals, so the compiler folds it into the
literal "Pune" before the program ever runs — also pooled. c was built with
new, which forces a fresh object. And e was assembled at runtime from a
variable, so it is a new object too.
== on reference types asks "is this the same object?". It is not asking
about the characters.
Here is why this is not academic. Every string that arrives from a user, a file,
a database or an HTTP request is built at runtime, exactly like e:
String typed = scanner.nextLine();
if (typed == "veg") { ... } // never true
if (typed.equals("veg")) { ... } // correct
The first version compiles, runs, and silently never matches. It is the single most common bug in beginner Java, and it survives testing whenever the tester uses a literal.
Three habits that make it go away:
- Always
.equals()for strings. Always. - For case-insensitive comparison,
.equalsIgnoreCase(). - When one side might be null, put the literal first:
"veg".equals(typed)cannot throw, whereastyped.equals("veg")can. Or useObjects.equals(a, b), which handles null on both sides.
The methods you will actually use
Worth keeping on one page rather than looking up each time. s is
"Dal Rice Tiffin" throughout.
| Method | Returns | Example result |
|---|---|---|
s.length() |
Number of characters | 15 |
s.isEmpty() |
Length is zero | false |
s.isBlank() |
Empty or only whitespace | false |
s.charAt(0) |
One character | 'D' |
s.indexOf("Rice") |
First position, or -1 |
4 |
s.lastIndexOf("i") |
Last position, or -1 |
13 |
s.contains("Tiffin") |
Substring present | true |
s.startsWith("Dal") |
true |
|
s.endsWith("n") |
true |
|
s.substring(4) |
From index to the end | "Rice Tiffin" |
s.substring(4, 8) |
From, to — end exclusive | "Rice" |
s.replace("Dal", "Rajma") |
A new string | "Rajma Rice Tiffin" |
s.toUpperCase() |
A new string | "DAL RICE TIFFIN" |
s.trim() |
Removes characters up to U+0020 | |
s.strip() |
Removes Unicode whitespace — prefer this | |
s.split(",") |
String[], split on a regex |
|
s.repeat(3) |
Repeated | |
s.equals(t) |
Same characters | |
s.equalsIgnoreCase(t) |
Same ignoring case | |
s.compareTo(t) |
Negative, zero or positive | "apple".compareTo("banana") is -1 |
s.formatted(x, y) |
Like String.format(s, x, y) |
|
s.chars() |
A stream of character codes | |
String.join(", ", parts) |
Glues an array or list together | |
String.valueOf(42) |
Any value as text | "42" |
Integer.parseInt("42") |
Text as an int |
42 |
trim() versus strip() is worth ten seconds. trim() predates Unicode
awareness and removes only characters at or below U+0020. strip(), added in
Java 11, removes anything Unicode calls whitespace:
String messy = "\u2003 Dal Rice \u2003"; // \u2003 is an em space
System.out.println(messy.length());
System.out.println(messy.trim().length());
System.out.println(messy.strip().length());
12
12
8
trim() removed nothing at all. Text pasted from Word, a PDF or a web form
frequently contains exactly this sort of character. Use strip().
split takes a regular expression, not a string
This one costs people an hour.
System.out.println("10.20.30".split(".").length);
System.out.println("10.20.30".split("\\.").length);
0
3
In a regular expression, . means "any character", so splitting on it consumes
everything and returns an empty array. To split on a literal dot, escape it:
"\\." — two backslashes, because the first escapes the second for the Java
compiler, leaving \. for the regex engine. The same applies to |, *, +,
?, (, ), [, ], ^, $ and {, }.
And a second surprise in the same method:
String trailing = "a,b,,,";
System.out.println(trailing.split(",").length);
System.out.println(trailing.split(",", -1).length);
2
5
By default split discards trailing empty strings. When you are parsing a CSV
row where an empty final column is meaningful — a missing pincode, say — that
silently loses data. Pass -1 as the limit when reading real data.
Building text
Four ways, in increasing order of how much you should like them.
Concatenation with +. Fine for two or three pieces.
String line = customer + " owes Rs " + rupees;
String.format or .formatted. Better when there is formatting to do. The
specifier table from module 1 applies.
String line = "%s owes Rs %,d.%02d".formatted(customer, paise / 100, paise % 100);
Text blocks, since Java 15. Three double quotes, a newline, then your content:
String bill = """
Tiffin bill
===========
Customer : %s
Total : Rs %d.%02d
""".formatted(customer, total / 100, total % 100);
Tiffin bill
===========
Customer : Priya
Total : Rs 2141.10
The indentation in your source is not in the string. Java finds the common
leading whitespace across all lines including the closing """ and removes
it. That is why the closing delimiter's position matters: move it further left
and every line keeps some indentation.
Text blocks are exactly what you want for SQL, JSON and anything multi-line:
String sql = """
SELECT name, pincode
FROM customers
WHERE city = 'Pune'
""";
Note that single quotes inside need no escaping, and neither do double quotes
unless you write three in a row. Two extra escapes are worth knowing: a trailing
\ joins a line to the next without a newline, and \s keeps a space that would
otherwise be stripped from the end of a line.
StringBuilder, when you are building in a loop. Here is why it matters:
int n = 40_000;
String slow = "";
for (int i = 0; i < n; i++) {
slow += "x";
}
StringBuilder sb = new StringBuilder();
for (int i = 0; i < n; i++) {
sb.append("x");
}
String fast = sb.toString();
string += : 90 ms
StringBuilder : 0 ms
Your numbers will differ; the ratio will not. Because strings are immutable,
slow += "x" creates a whole new string every iteration and copies everything so
far into it. Forty thousand iterations copy roughly eight hundred million
characters. StringBuilder keeps one growable buffer and appends.
The rule: + outside a loop, StringBuilder inside one. Do not reach for
StringBuilder to join three values — the compiler already uses one behind the
scenes for a single + expression, and the explicit version is just noisier.
switch on a string
Legal since Java 7, and pleasant in the expression form:
String price = switch (plan) {
case "veg" -> "Rs 82.35";
case "jain" -> "Rs 91.00";
default -> "unknown plan";
};
Control flow is the next lesson but one; this is here because switch on strings
uses equals internally, so it does not suffer the == trap. It is
case-sensitive, and it throws NullPointerException if the value is null — which
is a good reason to validate input before switching on it.
What String is not for
- Money. The previous lesson covered this. Store paise.
- Building large text in a loop.
StringBuilder. - A fixed set of options —
"veg","jain","eggs". A typo in a string compiles; a typo in an enum does not. Module 3 gets to enums, and this is the argument for them. - Holding a password for a long time. Strings stay in memory until garbage
collected and cannot be wiped. Security-sensitive code uses
char[]. You will rarely need this, and you should know why the API exists.
Check your work
Why does name.toUpperCase(); on its own line do nothing? Strings are
immutable. The method returns a new string and the result is discarded. Assign
it: name = name.toUpperCase();.
Why is a == b true for two "Pune" literals but a == e false when e was
built from a variable? Literals are interned in the string pool and share one
object. A string assembled at runtime is a new object, and == compares object
identity. Use .equals().
Why does comparing user input with == fail even when the text looks
identical? Input is built at runtime, so it is never the pooled literal. The
comparison asks the wrong question and answers false.
Which is safer, typed.equals("veg") or "veg".equals(typed)? The second.
If typed is null the first throws NullPointerException; the second returns
false. Objects.equals(a, b) handles null on either side.
What does "10.20.30".split(".") return, and why? An empty array. split
takes a regular expression and . matches any character. Escape it as "\\.".
What does "a,b,,,".split(",") give, and how do you keep the empty
columns? Length 2 — trailing empty strings are discarded. Pass a limit of -1
to keep them, giving length 5.
Why is trim() worse than strip()? trim() only removes characters up to
U+0020, so Unicode spaces pasted from other applications survive it. strip() is
Unicode-aware.
Why is += in a loop slow? Each iteration allocates a new string and copies
all previous characters into it, so the total work grows with the square of the
length. StringBuilder appends into one buffer.
Where does the indentation of a text block go? Java removes the common
leading whitespace of all lines, including the line holding the closing """.
Moving that line changes the result.
Practice 2, initials. The naive version splits on " " and breaks on a
double space, because split returns an empty string between the two spaces and
charAt(0) on it throws StringIndexOutOfBoundsException. Splitting on the
regex \s+ — one or more whitespace characters — fixes it and handles tabs too:
static String initials(String fullName) {
StringBuilder sb = new StringBuilder();
for (String word : fullName.strip().split("\\s+")) {
sb.append(Character.toUpperCase(word.charAt(0))).append('.');
}
return sb.toString();
}
"Priya Anil Deshmukh" -> P.A.D.
"Priya Anil Deshmukh" -> P.A.D.
The strip() matters as well: a leading space would otherwise produce an empty
first element even with \s+.
Practice 3, the CSV row. "Priya Deshmukh,Wagholi,411207,".split(",", -1):
0 : [Priya Deshmukh]
1 : [Wagholi]
2 : [411207]
3 : []
Without the -1 you get three fields and the empty one vanishes.
Practice 4, masking.
static String mask(String phone) {
if (phone.length() < 4) {
return "X".repeat(phone.length());
}
return phone.substring(0, 2)
+ "X".repeat(phone.length() - 4)
+ phone.substring(phone.length() - 2);
}
"9876543210" -> 98XXXXXX10
"98765432" -> 98XXXX32
Written against length() rather than the literal positions 2 and 8, a
nine-digit or eight-digit number masks sensibly instead of throwing. The
short-input branch is a decision, not an accident — deciding what a method does
with bad input, rather than letting an exception decide for you, is most of what
module 7 is about.
Practice 6, the timings. On the machine these lessons were written on, +=
took about 60 ms for 50,000 characters and about 197 ms for 100,000 — three to
four times the work for twice the length, not twice. StringBuilder was under a
millisecond for both. Your absolute numbers will differ; the shape will not.
Practice
-
Reproduce the
==trap and then fix it. Read a line withnew Scanner(System.in).nextLine(), compare it to a literal with==, and confirm it never matches even when you type it exactly. Then switch to.equals(). -
Write
initials(String fullName)."Priya Anil Deshmukh"becomes"P.A.D.". Usesplit(" "),charAt(0)and aStringBuilder. Then test it with a name that has two spaces between words and fix what you find. -
Parse a CSV line properly. Given
"Priya Deshmukh,Wagholi,411207,"— note the trailing comma — print each field with its index. Make the empty fourth field appear. That is the-1limit. -
Mask a phone number.
"9876543210"becomes"98XXXXXX10", for any ten-digit input. Usesubstringandrepeat. Then decide what your method should do with a nine-digit input, and make it do that deliberately rather than throwingStringIndexOutOfBoundsException. -
Build a bill with a text block. Customer name, three line items with right-aligned amounts, and a total. All amounts in paise, formatted at the point of printing. Move the closing
"""four spaces to the left and look at what happens to every line. -
Measure it yourself. Concatenate 50,000 characters with
+=and time it withSystem.nanoTime(). Then do the same withStringBuilder. Then try 100,000 with+=and confirm the time grows by three to four times rather than doubling. That is what "grows with the square" means, and seeing it is more convincing than reading it.
Next: operators, and the one where += quietly changes the answer.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship