Java Strings
A String in Java represents a sequence of characters, and it is one of the most heavily used types in the entire language — every program that reads input, prints output, or manipulates text relies on it. Unlike primitive types such as int or char, String is a full class from the java.lang package, which means it comes with dozens of built-in methods for searching, transforming, and comparing text. Understanding how strings work internally — especially their immutability and the string pool — is essential, because it explains both why strings are safe to share across your program and why naive string concatenation can silently hurt performance.
Overview / How Strings Work
A Java String is an object that wraps an internal array of characters (historically a char[]; since Java 9 it is usually a compact byte[] encoded as Latin-1 or UTF-16 depending on content). The single most important fact about String is that it is immutable: once a String object is created, its contents can never change. Every method that appears to "modify" a string — toUpperCase(), replace(), substring(), trim() — actually returns a brand new String object, leaving the original untouched.
This immutability has several consequences. First, strings are inherently thread-safe: since no one can change a string after it is built, multiple threads can share the same String object without any risk of one thread corrupting it for another. Second, it enables the string constant pool, a special region of the JVM’s heap memory reserved for string literals. When you write String s = "hello";, the JVM checks the pool for an existing string with that exact content. If one exists, s is set to reference it; if not, a new entry is added to the pool. This means two identical string literals anywhere in your program will refer to the exact same object in memory, saving space and allowing fast reference comparisons in certain contexts.
Strings created with the new String(...) constructor behave differently: they always create a new object on the regular heap, outside the pool, even if an identical literal already exists. This is the root cause of one of the most common Java bugs — comparing strings with == instead of .equals() — which we’ll cover in detail below.
Internally, String also caches its hash code the first time hashCode() is called, which is why strings make excellent, fast HashMap keys: the hash never needs to be recomputed after the first calculation, since the content can never change.
Syntax
Strings can be created in two main ways, and manipulated through instance methods called on a string reference:
String name = "literal text"; // stored/reused in the string pool
String other = new String("literal"); // always a new object on the heap
String result = someString.methodName(arguments);
| Part | Meaning |
|---|---|
"literal text" |
A string literal; interned automatically in the string constant pool |
new String(...) |
Explicitly allocates a new String object outside the pool |
.methodName(...) |
Calls a String instance method; always returns a new value (or a primitive/boolean), never mutates the original |
Some of the most frequently used String methods:
| Method | Purpose |
|---|---|
length() |
Returns the number of characters |
charAt(int index) |
Returns the character at a given index (0-based) |
substring(int begin, int end) |
Returns a new string from begin (inclusive) to end (exclusive) |
indexOf(String s) |
Returns the first index where s occurs, or -1 |
contains(CharSequence s) |
Returns true if s appears anywhere in the string |
toUpperCase() / toLowerCase() |
Returns a case-converted copy |
trim() / strip() |
Removes leading/trailing whitespace |
replace(old, new) |
Returns a copy with all occurrences replaced |
split(String regex) |
Splits into a String[] using a regular expression delimiter |
equals(Object o) |
Compares string contents for equality |
equalsIgnoreCase(String s) |
Content comparison ignoring case |
Examples
Example 1: Core string methods
public class Main {
public static void main(String[] args) {
String greeting = "Hello, World!";
System.out.println("Length: " + greeting.length());
System.out.println("Upper: " + greeting.toUpperCase());
System.out.println("Substring: " + greeting.substring(7));
System.out.println("Contains 'World': " + greeting.contains("World"));
System.out.println("Replace: " + greeting.replace("World", "Java"));
System.out.println("Char at 0: " + greeting.charAt(0));
System.out.println("Index of comma: " + greeting.indexOf(","));
}
}
Output:
Length: 13
Upper: HELLO, WORLD!
Substring: World!
Contains 'World': true
Replace: Hello, Java!
Char at 0: H
Index of comma: 5
Notice that greeting itself is never altered — every call like toUpperCase() or replace() hands back a fresh string, and we only see the change because we print the return value directly. substring(7) starts at index 7 (the "W") and runs to the end of the string, since only one argument was given.
Example 2: Immutability and the string pool
public class Main {
public static void main(String[] args) {
String a = "java";
String b = "java";
String c = new String("java");
String d = c.intern();
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.equals(c): " + a.equals(c));
String original = "Hello";
String upper = original.toUpperCase();
System.out.println("original: " + original);
System.out.println("upper: " + upper);
}
}
Output:
a == b: true
a == c: false
a == d: true
a.equals(c): true
original: Hello
upper: HELLO
a and b are both literals with the same content, so they point to the same pooled object and == returns true. c was built with new String(...), so it lives on the regular heap as a distinct object — a == c is false even though the content is identical. Calling c.intern() looks up (or adds) the pooled version of "java", so d ends up referencing the exact same object as a. Finally, notice that original is completely unchanged after calling toUpperCase() — immutability in action.
Example 3: Building and parsing text (a realistic scenario)
public class Main {
public static void main(String[] args) {
String[] names = {"Alice", "Bob", "Charlie"};
StringBuilder sb = new StringBuilder();
sb.append("Team Roster:\n");
for (int i = 0; i < names.length; i++) {
sb.append(i + 1).append(". ").append(names[i]);
if (i < names.length - 1) {
sb.append("\n");
}
}
String roster = sb.toString();
System.out.println(roster);
String csvLine = " Alice, 29 , Engineer ";
String[] parts = csvLine.split(",");
for (String part : parts) {
System.out.println("[" + part.trim() + "]");
}
}
}
Output:
Team Roster:
1. Alice
2. Bob
3. Charlie
[Alice]
[29]
[Engineer]
This example combines two very common real-world tasks: building formatted text incrementally with StringBuilder (a mutable companion class to String, ideal when you construct text piece by piece), and parsing delimited data with split() followed by trim() to clean up stray whitespace around each field — exactly the kind of processing you'd do when reading a CSV file or user-entered data.
Under the Hood
When the JVM encounters a string literal in your source code, it happens at class-loading time: the compiler places the literal into the class file's constant pool, and when the class is loaded, the JVM interns it into the runtime string pool (part of the heap since Java 7). Every subsequent occurrence of that exact literal, anywhere in the program, resolves to the same object reference — the JVM never allocates a duplicate.
When you call a method like concat(), substring(), or use the + operator, the JVM allocates a brand-new character array, copies the necessary characters into it, and wraps it in a new String object. The old string's internal array is left completely untouched, which is exactly why the original reference still shows the old value afterward.
The + operator on strings is actually syntactic sugar: the compiler rewrites a + b (for non-constant strings) into calls that, in modern Java, typically use StringBuilder.append() internally, or the invokedynamic-based StringConcatFactory since Java 9. This is efficient for a single expression like "Hi " + name + "!", because the compiler builds one StringBuilder for the whole expression. The performance trap appears when concatenation happens repeatedly inside a loop, because each iteration of result = result + x; creates a brand-new StringBuilder, copies everything built so far into it, and discards the old string — turning what looks like simple code into O(n²) work.
Common Mistakes
Mistake 1: Comparing strings with == instead of .equals()
Because of the string pool, == sometimes appears to work correctly on literals, which lulls people into using it everywhere — until a string built from user input or new String(...) breaks the comparison.
Wrong:
public class Main {
public static void main(String[] args) {
String input = new String("exit");
if (input == "exit") {
System.out.println("Matched");
} else {
System.out.println("No match");
}
}
}
Output:
No match
Even though input contains the text "exit", == compares object references, and new String(...) deliberately created a different object than the pooled literal. This is a classic source of subtle bugs, especially with strings that come from Scanner, file I/O, or network input — none of which come from the string pool.
Corrected:
public class Main {
public static void main(String[] args) {
String input = new String("exit");
if (input.equals("exit")) {
System.out.println("Matched");
} else {
System.out.println("No match");
}
}
}
Output:
Matched
.equals() always compares the actual characters, regardless of where the objects live in memory, so it is the correct choice whenever you're checking string content — which is almost always.
Mistake 2: Concatenating strings in a loop with +
Inefficient (works, but wasteful):
public class Main {
public static void main(String[] args) {
String result = "";
for (int i = 1; i <= 5; i++) {
result = result + i + ",";
}
System.out.println(result);
}
}
Output:
1,2,3,4,5,
The output is correct, but for larger loops this pattern is a real performance problem: each iteration discards the previous string and rebuilds a longer one from scratch, copying every character that came before. With a loop of thousands of iterations this quickly becomes noticeably slow.
Better — use StringBuilder:
public class Main {
public static void main(String[] args) {
StringBuilder result = new StringBuilder();
for (int i = 1; i <= 5; i++) {
result.append(i).append(",");
}
System.out.println(result);
}
}
Output:
1,2,3,4,5,
StringBuilder maintains one growable internal character array and appends to it in place, so building a long string in a loop takes roughly linear time instead of quadratic time.
Best Practices
- Always compare string content with
.equals()or.equalsIgnoreCase(); reserve==for checking whether two references point to the exact same object (rarely what you actually want with strings). - Use
StringBuilderwhen building a string incrementally, especially inside loops; use plain+concatenation only for a small, fixed number of values in a single expression. - Prefer string literals over
new String(...)— the constructor form almost never has a legitimate use case in modern code and only creates an unnecessary duplicate object. - Remember that every "mutating" method (
trim(),replace(),toUpperCase(), etc.) returns a new string — always capture the return value, e.g.s = s.trim();, or the call has no effect. - Use
String.format(...)or text blocks ("""..."""in Java 15+) for readable, multi-value formatted output instead of long chains of+. - When splitting user-provided or file-sourced text, always account for extra whitespace with
.trim()or.strip()on each resulting piece. - Use
isEmpty()andisBlank()(Java 11+) to check for empty or whitespace-only strings instead of manually comparing to"".
Practice Exercises
- Exercise 1: Write a program that reads a full name from the user with
Scanner(e.g. "ada lovelace") and prints it with each word capitalized ("Ada Lovelace"), usingsubstring(),toUpperCase(), andsplit(). - Exercise 2: Write a method that takes a string and returns
trueif it is a palindrome (reads the same forwards and backwards), ignoring case and spaces. Test it with "A man a plan a canal Panama". - Exercise 3: Given a sentence, use
StringBuilderto build a new string that reverses the order of the words (but not the letters within each word). For example, "Java is fun" should become "fun is Java".
Summary
Stringobjects are immutable — every transformation method returns a new string instead of modifying the original.- String literals are stored in the string constant pool, so identical literals share the same object;
new String(...)always creates a separate object outside the pool. - Use
.equals()(never==) to compare string content. StringBuilderis the mutable, efficient alternative for building strings incrementally, especially in loops.- Common methods like
substring(),split(),trim(),replace(), andindexOf()cover the vast majority of everyday text-processing needs. - Immutability makes strings thread-safe and allows their hash codes to be cached, making them efficient as
HashMapkeys.
