The String class in Java represents a sequence of characters, and it is immutable: once a string exists, its characters can never change. Every method that looks like it edits a string actually builds a new one and hands it back. That design is the reason a string can be passed to a method, shared across threads, and used as a map key without any locking.
What is a String under the hood?
String is a final class in the java.lang package, so every string in your program is an object and none of them can be subclassed. Internally it holds a reference to an array of characters plus a cached hash code, and it exposes that array only through methods that copy it out. You never get a handle on the backing storage, which is exactly what makes the class immutable.
Because every string is an object, the usual object rules apply: equals and hashCode for comparison, and a null value that means "no string at all" rather than "the empty string".
Why does immutability matter so much?
Four practical benefits follow from a string that cannot change after creation, and each one shows up in real code.
- Safe sharing. If a string is passed to ten methods, none of them can alter what the others see.
- Usable as a map key. The hash code of a map key must never change, or the key would become unreachable inside the map.
- Safe across threads. Two threads can read the same string with no synchronisation because there is nothing to write.
- Cheap literal reuse. Identical string literals can share one object, which is what the string pool does.
The price is that "changing" a string costs an allocation. When you build a string in a loop, the intermediate copies pile up and the work grows faster than the number of iterations, because each new string copies the whole text so far.
public class StringImmutability {
public static void main(String[] args) {
String original = "invoice";
String changed = original.toUpperCase();
System.out.println("original : " + original);
System.out.println("changed : " + changed);
System.out.println("same object : " + (original == changed));
String alias = original;
original = original + "-2026";
System.out.println("alias still : " + alias);
System.out.println("original now : " + original);
char[] letters = original.toCharArray();
letters[0] = 'X';
System.out.println("string unaffected by char[] edit : " + original);
System.out.println("char[] start : " + new String(letters));
}
}original : invoice
changed : INVOICE
same object : false
alias still : invoice
original now : invoice-2026
string unaffected by char[] edit : invoice-2026
char[] start : Xnvoice-2026Look at the third line of output: same object : false. toUpperCase did not touch original - it produced a different string entirely. The rest of the output shows the practical consequence. Reassigning original left alias pointing at the old text, because reassignment changes a variable, not an object. And editing a char[] cannot reach into a string, because the string copied the characters out of that array when it was created.
Why does == sometimes work and sometimes not?
This is the single most common String bug. == compares references - "are these two variables pointing at the same object". equals compares contents - "do these two strings hold the same characters". For every other type in Java, == is the right choice, so people reach for it with strings by habit and get surprising results.
public class StringIdentity {
public static void main(String[] args) {
String a = "tutorsbot";
String b = "tutorsbot";
String c = new String("tutorsbot");
System.out.println("a == b : " + (a == b));
System.out.println("a == c : " + (a == c));
System.out.println("a.equals(c) : " + a.equals(c));
System.out.println("a.hashCode() == c.hashCode() : " + (a.hashCode() == c.hashCode()));
System.out.println("class of a : " + a.getClass().getName());
System.out.println("is final : " + java.lang.reflect.Modifier.isFinal(a.getClass().getModifiers()));
}
}a == b : true
a == c : false
a.equals(c) : true
a.hashCode() == c.hashCode() : true
class of a : java.lang.String
is final : truea == b is true because both variables refer to the same pooled literal. a == c is false because new String built a separate object, even though the characters are identical. a.equals(c) is true, and the hash codes match, which is what lets strings work correctly as keys in a HashMap.
The rule to remember is simple: use equals for strings, always. Reach for == only when you genuinely want to ask whether two variables name the same object, which in practice means checking for null.
What is the string pool?
The runtime keeps a table of string literals. When your code contains the literal "shared", the same object is handed out every time the class is loaded, so the literal is created once no matter how many places use it. That is why a == b was true above, and why literals are cheaper than equal-looking strings built at runtime.
public class StringPool {
public static void main(String[] args) {
String first = "shared";
String second = "shared";
String third = "sha" + "red";
String fourth = new String("shared").intern();
System.out.println("first == second : " + (first == second));
System.out.println("first == third : " + (first == third));
System.out.println("first == fourth : " + (first == fourth));
String dynamic = "sha" + "red";
System.out.println("dynamic == first : " + (dynamic == first));
System.out.println("hash codes equal : " + (first.hashCode() == dynamic.hashCode()));
}
}first == second : true
first == third : true
first == fourth : true
dynamic == first : true
hash codes equal : trueThe interesting line is first == third, which is true. The compiler folds "sha" + "red" into a single literal at compile time because both operands are constants, so the result is pooled. But dynamic == first is false: at least one operand was a variable, so the concatenation happened at runtime and produced a new object. intern() on the last line forces a runtime-built string back into the pool, which is why the comparison is true.
What are the safe habits for using strings?
Three habits cover most of it. Check for null before calling any method on a string that came from outside your code, because the compiler will not stop you and the result is a NullPointerException. Use equals for comparison. And when a value is going to be shared widely - a configuration key, a label, a lookup token - keep it in a local variable and pass that around rather than rebuilding it.
public class StringSafeUse {
static String describe(String value) {
if (value == null) {
return "missing";
}
return "length " + value.length();
}
public static void main(String[] args) {
System.out.println(describe(null));
System.out.println(describe("hello"));
String password = new String("hunter2");
System.out.println("password equals text : " + password.equals("hunter2"));
String shared = "config";
String copy = shared;
copy = copy + ".bak";
System.out.println("shared : " + shared);
System.out.println("copy : " + copy);
}
}missing
length 5
password equals text : true
shared : config
copy : config.bakThe describe method shows the null check at the top of the method rather than at every call site, which means callers never have to think about it. The last two lines show the difference between mutating a variable and mutating an object: copy = copy + ".bak" creates a new string and leaves shared exactly as it was.
The facts to keep in mind
Stringis final and immutable; no method can change the characters it holds.- Every "modifying" method returns a new string and leaves the original alone.
- Compare with
equals;==compares references and lies to you about equal text. - Literals are pooled, so identical literals can be the same object.
- Runtime-built strings are normally separate objects even when the text matches.
- Strings are safe to share between threads and usable as map keys precisely because they never change.
Try it yourself: write a program that builds a string one character at a time with += in a loop and then builds the same text with a StringBuilder, and compare the two results with equals. Add prints of hashCode() and of System.identityHashCode for each result, and explain in a comment why the two strings can be equal in content while being different objects.