Java String Methods

A String in Java isn’t just a sequence of characters — it’s a full object with dozens of built-in methods for searching, transforming, comparing, and rebuilding text. Because String objects are immutable, every method that looks like it “changes” a string actually returns a brand-new String and leaves the original untouched. Nearly every Java program that touches user input, files, or text processing leans on these methods constantly, so knowing them well — and knowing their gotchas — pays off immediately. This lesson covers the core String API from the ground up, how it behaves internally, and the mistakes that trip up even experienced developers.

Overview: How String Methods Work

In Java, String is a final class (you cannot extend it) backed internally by a character array — technically a byte[] since Java 9’s “compact strings” optimization, which stores Latin-1 text using one byte per character instead of two, falling back to UTF-16 only when needed. That backing array is never exposed and never modified after construction: String is immutable by design. This has several consequences you’ll feel constantly:

  • Every method that appears to modify a string (toUpperCase(), trim(), replace(), substring(), etc.) actually allocates and returns a new String object. The original is untouched.
  • String literals (like "Java") are stored in a special region of the heap called the string pool. When the compiler sees the same literal used twice, both references point to the exact same object — this is called interning. Strings built at runtime with new String(...) or by concatenating variables are not automatically pooled, which is why comparing them with == is unreliable (more on this in Common Mistakes).
  • Immutability makes strings inherently thread-safe (no locking needed to read them) and allows the JVM to safely cache a string’s hash code the first time hashCode() is called, since it can never change.
  • When you write str1 + str2 in source code, the compiler doesn’t call any String method directly — it desugars the concatenation into calls to a mutable buffer (historically StringBuilder.append(), and since Java 9 often an invokedynamic call to StringConcatFactory) which builds the combined characters once and produces a single new String at the end.

Because every transformation allocates a new object, chaining many String method calls in a loop (e.g. repeatedly using += to build a large string) creates a lot of short-lived garbage. That’s exactly the problem the mutable StringBuilder class solves, which you’ll see used later in this lesson.

Syntax

String methods are called using dot notation on a string reference: stringReference.methodName(arguments). Since methods return new String objects (not void), calls are commonly chained: text.trim().toLowerCase().replace("a", "b"). The table below lists the methods you’ll use most often.

Method Returns Description
length() int Number of characters in the string
charAt(int index) char Character at the given zero-based index
substring(int begin) String Everything from begin to the end
substring(int begin, int end) String Characters from begin (inclusive) to end (exclusive)
indexOf(String s) / lastIndexOf(String s) int Position of first/last occurrence, or -1 if not found
contains(CharSequence s) boolean Whether the substring appears anywhere
toUpperCase() / toLowerCase() String New string with case changed
trim() / strip() String New string with leading/trailing whitespace removed (strip() is Unicode-aware, added in Java 11)
replace(CharSequence a, CharSequence b) String Replaces every literal occurrence of a with b
replaceAll(String regex, String repl) String Replaces every regex match
split(String regex) String[] Splits the string by a regex delimiter
equals(Object o) / equalsIgnoreCase(String s) boolean Compares string content, not object identity
compareTo(String s) int Lexicographic comparison (negative, zero, or positive)
startsWith(String s) / endsWith(String s) boolean Prefix/suffix check
isEmpty() / isBlank() boolean True if length is 0 / true if empty or only whitespace (Java 11+)
String.join(CharSequence delim, CharSequence... elements) String Static method that joins pieces with a delimiter

Examples

Example 1: Inspecting and transforming a string

public class Main {
    public static void main(String[] args) {
        String greeting = "  Hello, Java World!  ";

        System.out.println("Length: " + greeting.length());
        System.out.println("Trimmed: [" + greeting.trim() + "]");
        System.out.println("Upper: " + greeting.toUpperCase());
        System.out.println("Lower: " + greeting.toLowerCase());

        String trimmed = greeting.trim();
        System.out.println("Char at 0: " + trimmed.charAt(0));
        System.out.println("Index of 'Java': " + trimmed.indexOf("Java"));
        System.out.println("Substring(7): " + trimmed.substring(7));
        System.out.println("Substring(7, 11): " + trimmed.substring(7, 11));
        System.out.println("Contains 'World': " + trimmed.contains("World"));
        System.out.println("Replace: " + trimmed.replace("Java", "Kotlin"));
    }
}

Output:

Length: 22
Trimmed: [Hello, Java World!]
Upper:   HELLO, JAVA WORLD!  
Lower:   hello, java world!  
Char at 0: H
Index of 'Java': 7
Substring(7): Java World!
Substring(7, 11): Java
Contains 'World': true
Replace: Hello, Kotlin World!

Notice that greeting itself is never modified — trim(), toUpperCase(), and toLowerCase() each return a new string, so you must capture the result (as trimmed does) to use it later. Also note that substring(7, 11) includes index 7 but stops before index 11 — a common source of off-by-one bugs.

Example 2: Cleaning and formatting real user input

public class Main {
    public static void main(String[] args) {
        String rawName = "  jOHN   smiTH  ";
        String cleaned = rawName.trim().replaceAll("\\s+", " ");
        System.out.println("Cleaned: [" + cleaned + "]");

        String[] parts = cleaned.split(" ");
        StringBuilder formatted = new StringBuilder();
        for (String part : parts) {
            String lower = part.toLowerCase();
            String capitalized = lower.substring(0, 1).toUpperCase() + lower.substring(1);
            formatted.append(capitalized).append(" ");
        }
        String result = formatted.toString().trim();
        System.out.println("Formatted name: " + result);

        String email = "JOHN.SMITH@example.com";
        System.out.println("Email lowercase: " + email.toLowerCase());
        System.out.println("Starts with 'john': " + email.toLowerCase().startsWith("john"));
        System.out.println("Ends with '.com': " + email.endsWith(".com"));

        System.out.println("Is blank check: " + "   ".isBlank());
    }
}

Output:

Cleaned: [jOHN smiTH]
Formatted name: John Smith
Email lowercase: john.smith@example.com
Starts with 'john': true
Ends with '.com': true
Is blank check: true

This example mirrors real form-input cleanup: replaceAll("\\s+", " ") collapses any run of whitespace (tabs, multiple spaces) into a single space, split(" ") breaks the name into words, and each word is re-capitalized manually since Java has no built-in “title case” method. Because none of these methods mutate their receiver, the code reassigns the result at every step.

Example 3: Comparison, StringBuilder, and splitting on delimiters

public class Main {
    public static void main(String[] args) {
        String a = "Java";
        String b = "Java";
        String c = new String("Java");

        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.equalsIgnoreCase(\"JAVA\"): " + a.equalsIgnoreCase("JAVA"));

        StringBuilder sb = new StringBuilder();
        for (int i = 1; i <= 5; i++) {
            sb.append(i);
            if (i < 5) {
                sb.append(", ");
            }
        }
        System.out.println("Built string: " + sb.toString());
        System.out.println("Reversed: " + sb.reverse().toString());

        String csv = "apple,banana,,cherry";
        String[] fruits = csv.split(",");
        System.out.println("Number of parts: " + fruits.length);
        System.out.println("Joined with ' | ': " + String.join(" | ", fruits));
    }
}

Output:

a == b: true
a == c: false
a.equals(c): true
a.equalsIgnoreCase("JAVA"): true
Built string: 1, 2, 3, 4, 5
Reversed: 5 ,4 ,3 ,2 ,1
Number of parts: 4
Joined with ' | ': apple | banana |  | cherry

a and b are the same pooled literal object, so == reports true. c was created with new String(...), forcing a distinct heap object, so == reports false even though the content is identical — equals() is what correctly reports true. The StringBuilder loop shows why you use a mutable buffer instead of repeated String concatenation inside loops. Finally, notice that split(",") on "apple,banana,,cherry" keeps the empty string between the two commas (giving 4 parts, not 3) — split only drops trailing empty strings by default.

Under the Hood: What Happens When You Call a String Method

  • Literals and the pool: When the class loads, the JVM sees the literal "Java" and either adds it to the string pool or reuses an existing entry. Any other occurrence of the identical literal in your compiled code reuses that same reference.
  • Calling a transforming method: A call like trimmed.substring(7) does not touch trimmed's backing array. Instead, the JVM allocates a new byte/char array sized for the result, copies the relevant range of characters into it, and wraps that array in a brand-new String object, which is what gets returned.
  • Concatenation with +: The compiler rewrites chains of + into either StringBuilder calls or an invokedynamic call to the JDK's string-concatenation bootstrap method, which computes the final length once and writes all pieces into a single buffer — far more efficient than N intermediate String allocations.
  • StringBuilder internals: Unlike String, StringBuilder wraps a mutable, resizable character array. append() writes directly into that buffer; if the buffer runs out of room, the JVM allocates a larger array (typically doubling capacity) and copies the existing data over — the same growth strategy used by ArrayList.
  • Garbage collection: Every intermediate String produced by chained method calls becomes garbage as soon as nothing references it, and the JVM's garbage collector reclaims that memory later. This is harmless for occasional use but adds real overhead inside hot loops.

Common Mistakes

Mistake 1: Comparing strings with == instead of equals()

== compares object references, not string content. It happens to "work" for two literals because both point at the same pooled object, which lulls beginners into a false sense of safety — it silently breaks the moment a string comes from user input, concatenation, or new String(...).

public class Main {
    public static void main(String[] args) {
        String input = new String("yes");

        if (input == "yes") {
            System.out.println("Matched");
        } else {
            System.out.println("No match (unexpected!)");
        }

        if (input.equals("yes")) {
            System.out.println("Matched with equals()");
        }
    }
}

Output:

No match (unexpected!)
Matched with equals()

Fix: Always use .equals() (or .equalsIgnoreCase()) for content comparison. Reserve == for checking whether two references point to the exact same object, which is rarely what you actually want with strings.

Mistake 2: Forgetting that split() takes a regular expression

split(String) treats its argument as a regex, not a literal string. Characters like ., *, |, and + have special regex meaning, so splitting on "." doesn't split on the dot character — it splits on every character, because . matches any single character in regex.

public class Main {
    public static void main(String[] args) {
        String path = "file.name.txt";

        String[] wrongParts = path.split(".");
        System.out.println("Wrong split length: " + wrongParts.length);

        String[] correctParts = path.split("\\.");
        System.out.println("Correct split length: " + correctParts.length);
        for (String part : correctParts) {
            System.out.println(" - " + part);
        }
    }
}

Output:

Wrong split length: 0
Correct split length: 3
 - file
 - name
 - txt

Fix: Escape regex metacharacters with a backslash (written as "\\." in source code) or use Pattern.quote(".") when the delimiter comes from a variable and might contain special characters.

Best Practices

  • Always compare string content with equals() or equalsIgnoreCase(), never ==.
  • Use StringBuilder instead of repeated += concatenation inside loops to avoid creating excessive short-lived objects.
  • Prefer isEmpty() or isBlank() over comparing length() == 0 or comparing against "" — they're clearer and handle whitespace-only strings correctly.
  • Remember that split(), replaceAll(), and matches() take regular expressions; use replace() (not replaceAll()) when you just need a literal substring swap.
  • Check indexOf() results against -1 before using them in substring() to avoid a StringIndexOutOfBoundsException.
  • Be cautious with locale-sensitive methods like toUpperCase() in internationalized code — use the toUpperCase(Locale) overload when the default locale could produce surprising results (e.g. Turkish 'i').
  • Never rely on string interning for correctness; treat it strictly as a JVM memory optimization you don't control.

Practice Exercises

  • Exercise 1: Write a program that takes the string "programming in java is fun" and prints it with every word capitalized ("Programming In Java Is Fun"), using only split(), substring(), and toUpperCase()/toLowerCase().
  • Exercise 2: Given the string "racecar", write a program that checks whether it's a palindrome (reads the same forwards and backwards) using charAt() in a loop — do not use StringBuilder.reverse().
  • Exercise 3: Given a comma-separated string of numbers like "4,8,15,16,23,42", split it, convert each piece to an int with Integer.parseInt(), and print their sum. Expected output: 108.

Summary

  • String objects are immutable — every transforming method returns a new String rather than modifying the original.
  • String literals are pooled and interned by the JVM, which is why == can appear to "work" on literals but fails on strings built at runtime; always use equals() for content comparison.
  • Core methods to know cold: length(), charAt(), substring(), indexOf(), contains(), toUpperCase()/toLowerCase(), trim()/strip(), replace()/replaceAll(), split(), and equals()/equalsIgnoreCase().
  • Use StringBuilder for building strings incrementally, especially inside loops, since it uses a mutable, resizable buffer instead of allocating a new object per step.
  • split() and replaceAll() take regular expressions — escape metacharacters like . when you mean them literally.