Strings
String is Java's immutable text type — every operation that looks like it modifies a String actually produces a brand-new String object, leaving the original untouched.
String is one of the most heavily used types in Java, and also one of the most misunderstood. It is a full reference type (backed by java.lang.String), it is immutable — once created, a String object's character content can never change — and comparing strings correctly requires understanding the sharp distinction between == (reference identity) and .equals() (content equality). Getting this right early avoids a huge share of beginner bugs.
What String Is
A String represents a sequence of characters. Despite looking almost like a primitive because of literal syntax and operator support (+ for concatenation), String is a genuine class, and string variables hold references to String objects, just like any other reference type.
String greeting = "Hello"; // string literal
String explicit = new String("Hello"); // explicit object creation — rarely necessary
System.out.println(greeting.length()); // 5 — length() is a method, unlike array's .length fieldString Literals and the String Pool
String literals are interned by the JVM into a shared pool: two identical literals typically refer to the very same String object, letting the compiler and runtime avoid duplicating identical text in memory. This pooling behavior is a JVM implementation detail described by the language specification at a conceptual level — treat it as "typical," not as a guarantee you should write logic around.
String a = "hello";
String b = "hello";
System.out.println(a == b); // true (typically) — both literals refer to the same pooled object
String c = new String("hello");
System.out.println(a == c); // false — new String(...) always creates a distinct object,
// bypassing the pool, even though its content matchesImmutability
A String object's characters can never be changed after construction. Every method that appears to modify a string — toUpperCase(), substring(), replace(), concat(), +— actually returns a new String object, leaving the original unaffected.
This is why you must always capture the result of a String method: original.toUpperCase(); alone (without assigning the result) is effectively a no-op — you've discarded the new string it produced and the original is still lowercase.
String original = "hello";
String upper = original.toUpperCase();
System.out.println(original); // "hello" — completely unchanged
System.out.println(upper); // "HELLO" — a brand-new String objectConcatenation
The + operator is overloaded for strings, and mixing a String with any other type via + converts that other value to its string form automatically. Repeated concatenation in a loop is a well-known performance trap, since each + typically allocates a new String — StringBuilder is the right tool for building strings incrementally (covered in its own topic).
String name = "World";
String message = "Hello, " + name + "! Count: " + 42;
System.out.println(message); // Hello, World! Count: 42
// Anti-pattern for large loops — each += allocates a new String
String result = "";
for (int i = 0; i < 5; i++) {
result += i; // fine for tiny loops; avoid for large ones — use StringBuilder instead
}
System.out.println(result); // 01234String Equality — == vs. equals()
This is the single most important rule about strings in Java: == compares references (are these two variables pointing at the exact same object?), while .equals() compares content (do these two strings hold the same sequence of characters?). Because of pooling, == sometimes appears to "work" for literals — but it is not a reliable content-comparison tool, especially once strings are built at runtime (via concatenation of variables, new String(...), substring, user input, etc.).
The rule of thumb: NEVER use == to compare String content. Always use .equals() (or .equalsIgnoreCase() for case-insensitive comparison).
String s1 = "java";
String s2 = "java";
String s3 = new String("java");
String s4 = "ja" + "va"; // compile-time constant expression — often pooled too
String prefix = "ja";
String s5 = prefix + "va"; // built at RUNTIME — NOT guaranteed to be pooled
System.out.println(s1 == s2); // true (typically, same pooled literal)
System.out.println(s1 == s3); // false — new String() is a distinct object
System.out.println(s1.equals(s3)); // true — same content
System.out.println(s1 == s5); // false (typically) — s5 built at runtime, new object
System.out.println(s1.equals(s5)); // true — content is identical either wayequalsIgnoreCase and Other Comparison Methods
equalsIgnoreCase compares two strings for equality while treating uppercase and lowercase letters as the same. compareTo gives a lexicographic ordering (useful for sorting), and compareToIgnoreCase does the same case-insensitively.
System.out.println("Java".equals("java")); // false — case matters
System.out.println("Java".equalsIgnoreCase("java")); // true
System.out.println("apple".compareTo("banana")); // negative — "apple" sorts before "banana"Common String Methods
The String class offers a large, well-tested API for inspection and transformation. A working set of the most-used methods:
substring(beginIndex, endIndex) is a half-open range: it includes beginIndex but excludes endIndex, so substring(0, 5) gives exactly 5 characters.
String s = " Hello, Java World! ";
System.out.println(s.length()); // 22 (includes surrounding spaces)
System.out.println(s.trim()); // "Hello, Java World!" (leading/trailing whitespace removed)
System.out.println(s.strip()); // like trim(), Unicode-aware
System.out.println(s.toLowerCase()); // " hello, java world! "
System.out.println(s.contains("Java")); // true
System.out.println(s.indexOf("Java")); // index of first occurrence
System.out.println(s.replace("Java", "Kotlin")); // substitutes all occurrences
System.out.println(s.trim().split(",\\s*").length); // splitting on a regex-based delimiter
System.out.println(s.trim().substring(0, 5)); // "Hello" — chars from index 0 up to (not including) 5
System.out.println(String.join("-", "a", "b", "c")); // "a-b-c"
System.out.println("5".repeat(3)); // "555"
System.out.println("abc".charAt(1)); // 'b'
System.out.println("Hello".isEmpty()); // false
System.out.println(" ".isBlank()); // true — whitespace-onlyNull Handling
A String reference, like any reference type, can be null. Calling any instance method on a null String reference throws NullPointerException. String.valueOf(obj) and Objects.toString(obj, default) are safe ways to convert a possibly-null reference to text without risking that.
Note the difference: + concatenation with a null reference safely produces the text "null", but calling a method ON a null reference always throws.
String maybeNull = null;
try {
maybeNull.length(); // NullPointerException — cannot call a method on null
} catch (NullPointerException e) {
System.out.println("caught");
}
System.out.println(String.valueOf(maybeNull)); // "null" — safe, no exception
System.out.println("literal" + maybeNull); // "literalnull" — + safely stringifies null tooUnicode Considerations
Internally, a String is a sequence of UTF-16 code units, and length() returns the number of char code units, not necessarily the number of user-perceived characters. Characters outside the Basic Multilingual Plane (like many emoji) are represented as a pair of char values (a surrogate pair), which length() counts as 2. Full handling of code points and surrogate pairs is covered in the Character & Text Handling topic.
String emoji = "😀"; // a single visual character, but two UTF-16 code units
System.out.println(emoji.length()); // 2, not 1
System.out.println(emoji.codePointCount(0, emoji.length())); // 1 — correctly counts one code pointPractical Usage
Strings appear everywhere: user input, file paths, formatting output, keys in maps, parsing data, building messages. Immutability makes String naturally thread-safe and safe to share freely (no defensive copying needed) and safe to use as a HashMap key (since its content, and therefore its hashCode, can never change after construction).
import java.util.Map;
import java.util.HashMap;
Map<String, Integer> ages = new HashMap<>();
ages.put("Alice", 30); // safe because String's hashCode is stable for the object's whole life
System.out.println(ages.get("Alice"));Edge Cases
Empty string ("") is not the same as null — "".isEmpty() is true, but null.isEmpty() throws NullPointerException. substring(i, i) returns an empty string, and substring throws StringIndexOutOfBoundsException for indices outside the valid range. String comparison via compareTo is case-sensitive and based on Unicode code point values, which can surprise you when sorting mixed-case text.
System.out.println("Apple".compareTo("apple") < 0); // true — 'A' (65) < 'a' (97) in UnicodeBest Practices
Always compare String content with .equals()/.equalsIgnoreCase(), never ==. Avoid new String("literal") — it's almost always unnecessary and defeats pooling. Use StringBuilder for building strings in loops. Use isBlank()/isEmpty() explicitly instead of comparing to "". Prefer String.format or text blocks over deeply nested concatenation for readability.
Complete Example
public class StringDemo {
public static void main(String[] args) {
String input = " The Quick Brown Fox ";
String cleaned = input.strip();
String lower = cleaned.toLowerCase();
System.out.println("Original: [" + input + "]");
System.out.println("Cleaned: [" + cleaned + "]");
System.out.println("Words: " + cleaned.split("\\s+").length); // 4
String a = "fox";
String b = lower.substring(lower.length() - 3);
System.out.println(a.equals(b)); // true — content compare
System.out.println(a == b); // false — b is a fresh substring object
}
}Key Takeaways
String is immutable — every seemingly mutating operation returns a new String, leaving the original untouched.
Always use .equals() (or .equalsIgnoreCase()) for content comparison; == compares references and is unreliable for strings, especially those built at runtime.
String literals are typically pooled/interned by the JVM, but this is an implementation detail, not something to rely on for correctness.
length() counts UTF-16 code units, not necessarily user-perceived characters — characters outside the Basic Multilingual Plane use surrogate pairs and count as 2.
Immutability makes String inherently thread-safe and safe as a hash-based collection key, since its hash code can never change after construction.
if (userInput == "yes") {... } // comparing content with == if ("yes".equals(userInput)) {... } // content compare, and null-safe on the literal side
== compares references, not content — it can silently give the wrong answer for strings not guaranteed to be pooled (like anything read from user input, files, or built at runtime). Writing the literal first also avoids a NullPointerException if userInput happens to be null.
s.toUpperCase(); // result discarded — s is still lowercase System.out.println(s);
s = s.toUpperCase(); // capture the new String System.out.println(s);
String is immutable — toUpperCase() (like every other transforming method) returns a NEW String rather than modifying s in place; forgetting to assign the result is effectively a no-op.
String result = ""; for (int i = 0; i < 100000; i++) { result += i; } // O(n^2)-ish, huge allocation churn
StringBuilder sb = new StringBuilder(); for (int i = 0; i < 100000; i++) { sb.append(i); } String result = sb.toString();
Each += on a String typically allocates a brand-new String object; in a large loop this creates enormous allocation and copying overhead. StringBuilder mutates an internal buffer in place instead.
Why is s.toUpperCase(); alone (without assignment) essentially a no-op on the variable s?
Because String is immutable — toUpperCase() cannot modify s's existing character content; it can only compute and return a brand-new String object holding the uppercase version. If you don't capture that return value (e.g., s = s.toUpperCase();), the new string is simply discarded and s is unchanged.
What is the difference between == and .equals() when comparing two String variables, and which should you use for content comparison?
== compares whether two references point to the exact same object in memory (reference/identity equality); .equals() compares the actual character content of the two strings (value equality). You should always use .equals() (or .equalsIgnoreCase() for case-insensitive comparison) for content comparison — == is unreliable for strings that aren't guaranteed to be pooled.
Why might a == b be true for two literal-assigned String variables with the same text, but false for a String built by runtime concatenation of variables?
The JVM typically interns string literals into a shared pool, so two identical literals often refer to the exact same pooled object, making == true. But a string built at runtime (e.g., from concatenating non-constant variables, or via new String(...)) is typically a freshly allocated object not automatically placed in the pool, so == is false even though the content is identical.
What does length() return for the string "😀" (a single visual emoji character), and why might that number surprise you?
It returns 2, not 1 — because String stores text as UTF-16 code units, and characters outside the Basic Multilingual Plane (like many emoji) are represented as a surrogate pair of two char values. length() counts code units, not user-perceived characters, so it can be larger than the number of characters a person would count.
Is it safe to use a String as a key in a HashMap? Why does String's immutability matter for this?
Yes, it is safe and idiomatic. Because String is immutable, its content — and therefore its hashCode() — can never change after construction, which is essential for hash-based collections: if a key's hash could change after insertion, the map could no longer find the entry in the correct bucket, effectively corrupting the map.
What is the difference between an empty string "" and a null String reference, and what happens if you call a method on each?
"" is a real String object with zero characters — calling methods on it (like "".isEmpty()) works fine and returns true. null is the absence of any object reference; calling any instance method on a null String reference (like nullRef.isEmpty()) throws NullPointerException, because there is no object to invoke the method on.
Why is repeated += string concatenation inside a large loop considered inefficient, and what class is typically used instead?
Because String is immutable, each += typically allocates a brand-new String object combining the old content with the addition, discarding the previous object — in a loop with many iterations, this creates a lot of redundant allocation and copying. StringBuilder is designed for this case: it maintains a mutable internal character buffer that can grow and be appended to in place, avoiding that repeated allocation.
Write a method isPalindrome that returns true if a given String reads the same forwards and backwards, ignoring case, using only String methods (no StringBuilder).
public class PalindromeDemo { static boolean isPalindrome(String input) { String cleaned = input.toLowerCase(); int left = 0; int right = cleaned.length() - 1; while (left < right) { if (cleaned.charAt(left) != cleaned.charAt(right)) { return false; } left++; right--; } return true; } public static void main(String[] args) { System.out.println(isPalindrome("Racecar")); // true System.out.println(isPalindrome("Hello")); // false System.out.println(isPalindrome("")); // true — trivially } } isPalindrome lowercases the input for case-insensitive comparison, then walks two pointers inward from both ends comparing characters with charAt; any mismatch returns false immediately, and if the pointers cross without a mismatch, the string is a palindrome.
What does each println print, and why does calling intern() on c change the result of the third comparison?
It prints true, false, true. a and b are both literals and typically resolve to the same pooled object, so a == b is true. c = new String("test") explicitly creates a distinct object outside the pool, so a == c is false. c.intern() looks up (or adds) an equal string in the pool and returns the pooled reference — since "test" is already pooled (from the literal a), d ends up referencing that same pooled object as a, making a == d true.
What does each line do, and why does one succeed while the other fails?
The first line prints "value: null" successfully, because the + operator safely converts a null operand to the text "null" when concatenating with a String. The second line throws NullPointerException, because s.length() attempts to invoke an instance method ON the null reference itself — there is no object there to call length() on, which is fundamentally different from safely stringifying null inside a concatenation.
String a = "test";
String b = "test";
String c = new String("test");
String d = c.intern();
System.out.println(a == b);
System.out.println(a == c);
System.out.println(a == d);String s = null;
System.out.println("value: " + s);
System.out.println(s.length());