<< Home

Chapter 9: Strings and Text Handling

Text is at the heart of most modern software, and Java provides rich, reliable tools for working with it. From simple messages printed to the console to complex document processing and data exchange, strings play a central role in every kind of Java application. Because text is such a common form of data, the Java platform offers a robust, efficient, and secure set of features for creating, manipulating, and comparing strings.

Unlike languages that treat strings as primitive data types, Java defines them as full-fledged objects of the String class. This design provides immutability (meaning once a string is created it cannot be changed), which guarantees consistency, simplifies reasoning about code, and improves performance through shared internal storage. When you modify a string in Java, you are really creating a new one, a fact that shapes many common programming patterns.

Alongside the String class, Java also provides mutable alternatives like StringBuilder and StringBuffer, which allow efficient construction or modification of text in memory. These classes make it easy to assemble dynamic messages, build file paths, or generate large data blocks without the overhead of creating new string objects each time.

This chapter explores how to create and use strings, how to perform concatenation and comparison, and how to work with text using methods from the standard library. You will also learn about common encoding considerations, such as Unicode and character sets, which ensure that Java programs can handle text in any human language with consistency and reliability.

💡 Every string in Java is a sequence of Unicode characters, not just ASCII bytes. This means your programs can safely represent and manipulate text from virtually any written language without additional libraries or conversions.

By mastering Java’s string handling, you will be prepared to work confidently with input and output, file data, formatted text, and any feature that relies on precise control of characters and words. Whether you are processing user input, generating reports, or parsing structured data, understanding strings is essential to writing expressive, correct, and international-ready Java code.

⚠️ Because strings are immutable, repeated concatenation using + inside loops can lead to inefficient code. For such cases, prefer StringBuilder, which avoids unnecessary object creation and improves performance significantly.

String literals and immutability

In Java, strings are represented by the String class, and the most common way to create one is with a string literal. A string literal is a sequence of characters enclosed in double quotes, for example "Java" or "Hello, world!". When the compiler encounters a string literal, it automatically creates a String object for it in a special area of memory called the string pool.

String greeting = "Hello, world!";
String language = "Java";

Strings created with the same literal value are automatically shared in the string pool. This means that two identical literals refer to the same object, conserving memory and improving performance. The following example demonstrates this behavior:

String a = "Java";
String b = "Java";

System.out.println(a == b);       // true (same pooled object)
System.out.println(a.equals(b));  // true (same content)

Although both a and b contain the same value, the important distinction is that they point to the same object instance in memory. Java ensures that identical literals are interned and reused rather than recreated.

💡 The String.intern() method can be used to place a string explicitly into the string pool. This is rarely necessary in everyday code, but can be useful when managing large numbers of repeated string values.

One of the defining features of Java strings is immutability. Once a String object has been created, its contents can never be changed. Any operation that appears to modify a string actually produces a new String object instead.

String name = "Java";
name = name + " Language";

System.out.println(name);  // "Java Language"

Here, concatenating " Language" does not alter the original "Java" string. Instead, a new String object containing "Java Language" is created, and the variable name now refers to it. The original string remains unchanged in memory until it is garbage collected.

This design makes strings thread-safe and predictable, since no code can accidentally alter their contents once created. It also allows the Java runtime to reuse string objects safely without fear of unintended modification.

⚠️ Because strings are immutable, frequent concatenation using the + operator in a loop can generate many intermediate objects. To build text dynamically, use StringBuilder or StringBuffer, which are designed for efficient mutable string manipulation.

StringBuilder and StringBuffer

When you need to construct or modify text repeatedly, creating new String objects each time can be inefficient. Because strings are immutable, every concatenation or insertion creates a new object and copies existing data, which wastes both time and memory in large or iterative operations. To solve this, Java provides two mutable alternatives: StringBuilder and StringBuffer.

Both classes let you build and modify strings efficiently by maintaining a resizable buffer of characters. You can append, insert, delete, and replace text directly within the same object, avoiding the overhead of constantly creating new strings. The main difference between the two lies in thread safety.

// Using StringBuilder
StringBuilder sb = new StringBuilder();
sb.append("Java");
sb.append(" ");
sb.append("Language");
String result = sb.toString();
System.out.println(result); // Java Language

The same task could be done with StringBuffer in exactly the same way:

// Using StringBuffer
StringBuffer sbuf = new StringBuffer();
sbuf.append("Hello");
sbuf.append(", ");
sbuf.append("world!");
System.out.println(sbuf.toString()); // Hello, world!

In most modern programs, StringBuilder is preferred because its performance is better in single-threaded environments, which covers the majority of cases. The StringBuffer class exists mainly for backward compatibility with older Java code that required synchronization.

💡 You can chain method calls on StringBuilder and StringBuffer because their methods return the same object. This allows concise and readable construction of complex strings.
String query = new StringBuilder()
  .append("SELECT * FROM users WHERE name = '")
  .append("Alice")
  .append("' AND active = 1;")
  .toString();

System.out.println(query);

When you call toString(), the buffer’s current contents are converted into an immutable String. You can continue modifying the builder afterward without affecting the string already produced.

⚠️ Neither StringBuilder nor StringBuffer should be shared between threads unless properly synchronized. If multiple threads need to build text simultaneously, give each its own instance or use synchronized access to prevent data corruption.

Formatting text with printf and format

Java provides built-in tools for producing formatted text using the printf() and format() methods. These methods work similarly to C-style formatting functions, allowing you to insert variable values into strings using format specifiers. They are especially useful when generating structured output such as reports, tables, or logs.

The System.out.printf() method prints formatted text directly to standard output, while String.format() creates and returns a formatted string that you can store or reuse later.

int count = 5;
double price = 9.99;
String item = "Widget";

System.out.printf("You bought %d %s(s) for a total of $%.2f%n", count, item, count * price);

Here, each format specifier (beginning with %) represents a placeholder for a value:

SpecifierMeaningExample
%dInteger (decimal)%d → 42
%fFloating-point number%.2f → 3.14 (2 decimal places)
%sString%s → Hello
%nPlatform-independent newlineEquivalent to \n but safer across systems
%cSingle character%c → A
%bBoolean value%b → true

Each specifier can include optional flags and width controls to align and format output neatly:

System.out.printf("|%10s|%5d|%8.2f|%n", "Item", 12, 4.5);
System.out.printf("|%-10s|%5d|%8.2f|%n", "Item", 12, 4.5);

The first example right-aligns text within a 10-character field, while the second left-aligns it using -. These formatting options make it easy to produce tabular text layouts directly from code.

💡 Use String.format() when you need to create formatted text without printing it immediately. This is ideal for logs, messages, or dynamically generated strings.
String message = String.format("User %s has %d new notifications.", "Alice", 3);
System.out.println(message);

Formatting specifiers also support advanced options for locale-aware output, letting you automatically adapt number and date formats to regional conventions. This is done by providing a Locale argument:

import java.util.Locale;

double value = 1234.56;
System.out.println(String.format(Locale.FRANCE, "Value: %,.2f", value));
System.out.println(String.format(Locale.US, "Value: %,.2f", value));

In this example, the comma and period are swapped depending on locale conventions (1 234,56 in France versus 1,234.56 in the US). The , flag adds grouping separators to large numbers automatically.

⚠️ Always match your format specifiers to the types of the provided arguments. Using the wrong specifier (for example, %d for a string) will cause a java.util.IllegalFormatConversionException at runtime.

Regular expressions

Regular expressions, often called regex, are a concise and powerful way to describe and manipulate patterns in text. They allow you to search, match, and replace sequences of characters according to rules you define. Java’s java.util.regex package provides a complete implementation of regular expressions through two main classes: Pattern and Matcher.

With regex, you can validate input (such as email addresses or postcodes), extract structured data from text, or perform advanced find-and-replace operations. Because Java’s regex engine is integrated directly into the standard library, you can use it anywhere without external dependencies.

💡 A regular expression is like a mini language for matching text. Even simple patterns such as \d+ (for digits) or [A-Z][a-z]+ (for capitalized words) can make powerful text-processing tools.

The pattern and matcher classes

The Pattern class represents a compiled regular expression, while the Matcher class applies that pattern to a given input string. To use them, you first compile a pattern, then create a matcher to search within text.

import java.util.regex.*;

String text = "The year is 2025.";
Pattern pattern = Pattern.compile("\\d+");
Matcher matcher = pattern.matcher(text);

if (matcher.find()) {
  System.out.println("Found number: " + matcher.group());
}

This code finds and prints the first sequence of digits in the text. The double backslashes are required because Java string literals also use backslashes for escape sequences, so "\\d+" represents the regex \d+ (one or more digits).

⚠️ Always remember that regex backslashes must be escaped in Java string literals. For example, use "\\s+" for whitespace instead of "\s+", which would cause a syntax error.

Common regex patterns

Regular expressions use a combination of literal characters and special symbols (called metacharacters) to define patterns. Here are some of the most useful constructs:

PatternMeaningExample
.Any single character (except newline)a.b → matches “a_b”
\dDigit (0–9)\d+ → “42”
\wWord character (letter, digit, or underscore)\w+ → “hello_123”
\sWhitespace\s+ → spaces or tabs
[abc]Any one of a, b, or cb[aeiou]t → “bat”, “bet”
[^abc]Any character except a, b, or c[^0-9] → any non-digit
*Zero or more occurrencesgo* → “g”, “go”, “goo”, etc.
+One or more occurrencesgo+ → “go”, “goo”, etc.
?Zero or one occurrence (optional)colou?r → “color” or “colour”
{n,m}Between n and m occurrences\d{2,4} → 2 to 4 digits
^Start of line^Hello → matches at beginning
$End of lineworld!$ → matches at end

Combining these constructs lets you describe complex text structures concisely. For example:

// Match a simple email address
Pattern email = Pattern.compile("[\\w.-]+@[\\w.-]+\\.\\w+");
Matcher m = email.matcher("contact@example.com");

if (m.matches()) {
  System.out.println("Valid email");
}

Searching and replacing

The Matcher class provides methods such as find(), matches(), group(), and replaceAll() for practical text operations. For instance, you can extract or transform multiple matches within a string:

String text = "cat, dog, bird";
Pattern pattern = Pattern.compile("\\w+");
Matcher matcher = pattern.matcher(text);

while (matcher.find()) {
  System.out.println("Animal: " + matcher.group());
}

You can also perform replacements directly using regular expressions:

String input = "2025-10-26";
String result = input.replaceAll("-", "/");
System.out.println(result); // 2025/10/26

More complex replacements can use groups, where parts of the matched text are captured and reinserted using $1, $2, and so on:

String name = "Nixon, Robin";
String formatted = name.replaceAll("(\\w+), (\\w+)", "$2 $1");
System.out.println(formatted); // Robin Nixon
💡 Use parentheses in regex to create capturing groups. Each group can be referenced later during replacement or accessed using matcher.group(n).

Flags and options

Regular expressions in Java can be compiled with flags that alter their behavior. You can combine flags using the bitwise OR (|) operator.

FlagDescription
Pattern.CASE_INSENSITIVEIgnores case when matching
Pattern.MULTILINEAllows ^ and $ to match line boundaries
Pattern.DOTALLMakes . match newline characters too
Pattern.UNICODE_CASEEnables Unicode-aware case matching
Pattern p = Pattern.compile("java", Pattern.CASE_INSENSITIVE);
Matcher m = p.matcher("I love Java!");
System.out.println(m.find()); // true
⚠️ Regular expressions can become difficult to read as they grow in complexity. Break them into smaller parts or use comments with the Pattern.COMMENTS flag to keep them understandable.

Encoding, Unicode, and character sets

Modern software must handle text from many languages, scripts, and symbol systems. To do this reliably, Java uses the Unicode standard for all character data, ensuring that programs can represent and process text from any part of the world. Every char in Java and every character in a String is a Unicode value, not a raw byte. This design makes Java inherently portable and internationalized.

Unicode assigns a unique code point to every character, covering alphabets, ideograms, punctuation, emoji, and even control symbols. Code points are usually written in hexadecimal, prefixed with U+. For example, U+0041 represents the letter A, and U+1F600 represents the grinning face emoji 😀.

char letter = 'A';              // U+0041
char symbol = '\u03A9';         // Greek capital omega (Ω)
String emoji = "\uD83D\uDE00";  // 😀 (represented as a surrogate pair)

Java’s internal string representation uses UTF-16 encoding, meaning that most common characters occupy a single 16-bit char, while characters outside the Basic Multilingual Plane (BMP) use two 16-bit units known as a surrogate pair. This allows Java to represent the full range of Unicode characters transparently.

💡 You can safely include Unicode escape sequences (\uXXXX) in any Java source file. They are processed by the compiler before code is interpreted, making them useful for including special symbols or non-ASCII characters directly in code.

Character encoding and byte conversion

Although Java represents text internally as Unicode, data stored in files or transmitted over networks must eventually be converted into bytes. This conversion depends on a character encoding, such as UTF-8 or ISO-8859-1. The encoding determines how Unicode characters are mapped to byte sequences.

The String and java.nio.charset APIs provide methods to control this process explicitly. For example:

import java.nio.charset.StandardCharsets;

String text = "Café";
byte[] bytes = text.getBytes(StandardCharsets.UTF_8);
String decoded = new String(bytes, StandardCharsets.UTF_8);

System.out.println(decoded); // Café

This code converts a string to a UTF-8 byte array and back again safely. Using a fixed, explicit charset avoids platform-dependent behavior and ensures consistent results across systems.

⚠️ Never rely on the platform default charset when reading or writing text files. Always specify an encoding explicitly (for example, UTF-8) to prevent data corruption or unreadable characters when running your program on different systems.

Working with character sets

The Charset class in java.nio.charset provides access to all available encodings supported by the Java runtime. You can query and list them or use them when reading and writing files.

import java.nio.charset.Charset;

for (String name : Charset.availableCharsets().keySet()) {
  System.out.println(name);
}

Some of the most commonly used encodings are:

💡 For all modern Java applications, UTF-8 is the safest and most portable choice for external file and network communication. It is fully compatible with ASCII and supports every Unicode character.

Unicode awareness in strings and characters

Because of UTF-16’s use of surrogate pairs, a single visual character may consist of more than one char. This distinction matters when iterating over strings or measuring their lengths.

String smile = "😀";
System.out.println(smile.length());  // 2 (two UTF-16 units)
System.out.println(smile.codePointCount(0, smile.length()));
// 1 (one Unicode code point)

To work safely with Unicode text, use the codePointAt() and codePoints() methods instead of direct character indexing. These handle surrogate pairs correctly and prevent cutting characters in half during iteration or substring operations.

smile.codePoints().forEach(cp -> 
  System.out.println(Integer.toHexString(cp))
);
⚠️ Not all string operations in Java are Unicode-aware by default. When dealing with emoji, Asian scripts, or combining characters, prefer code-point-based methods to avoid unexpected results when slicing or counting text.