A String is an immutable UTF-16 text value. Methods that appear to modify a string return a result; they don’t change the original object.
== tests reference identity, while length() counts UTF-16 code units. Default charsets, default locales, and regex-based splitting can also make behavior vary with data or environment.
Compare content with equals, name the required text unit, and pass an explicit UTF_8 or suitable Locale for encoding and case operations. Use StringBuilder to construct text in a loop.
What it is and why it exists
String is Java’s standard text class. A string contains a fixed sequence of UTF-16 code units; its length and contents don’t change after creation. It isn’t a primitive type, but literal syntax, + concatenation, and compile-time constant folding make it feel like a built-in value.
Immutability makes a string reference safe to share. A method can pass a string to other code as a map key, class name, path segment, or protocol field without letting the receiver rewrite that object. The object may also cache its hashCode() because its content can’t change afterward.
You encounter strings in source literals, command-line arguments, HTTP fields, files, database values, and logs. Reliable string code needs more than searching and concatenation: it must define content equality, null, Unicode boundaries, character encoding, and locale behavior.
Immutable objects and reassigned variables
The String object is immutable; a variable that holds its reference isn’t. In label = label.strip(), the variable label is reassigned to the returned result, while the original string remains unchanged. Ignore the return value from strip(), replace(), or toUpperCase(), and you lose the operation’s result.
String is a final class, and its public API offers no way to mutate characters in place. When a constructor creates a string from mutable character or byte input, it also does not expose caller-modifiable storage as the string’s contents.
Immutability does not mean every equal value is the same object. Two strings with equal contents may be distinct objects, while a library call that needs no change may return its receiver. Application code should depend on content and documented contracts, not object reuse.
Three kinds of text boundary
A Java char is one UTF-16 code unit . String.length() therefore returns a code-unit count, and the indexes accepted by charAt() and substring() are code-unit offsets. A character outside the Basic Multilingual Plane uses a surrogate pair and occupies two index positions.
A Unicode code point occupies one or two UTF-16 code units. codePointAt(), codePointCount(), offsetByCodePoints(), and codePoints() let code work at this level, but they do not normalize text automatically.
One user-visible character may still be a grapheme cluster containing several code points, such as a letter plus combining marks or an emoji sequence. If a product requirement concerns cursor movement or display-character truncation, code-point counting is still insufficient; use a text-boundary tool that implements the required Unicode segmentation rules.
Choosing a text API
Use + for a few fixed pieces, and use String.join() or a stream’s Collectors.joining() when the elements already exist. When a loop appends pieces incrementally, StringBuilder makes the mutable construction process explicit before toString() produces an immutable result.
StringBuffer also provides a synchronized mutable buffer, but synchronizing individual methods does not make a compound business operation atomic. A local builder does not need cross-thread sharing, so it normally uses StringBuilder; if mutable text really is shared, reconsider ownership and the scope of locking first.
Conversion between external bytes and text always goes through a charset. If a protocol specifies UTF-8, use StandardCharsets.UTF_8 instead of the process default. Case conversion also needs a contract: machine-readable identifiers commonly use Locale.ROOT, while natural-language text uses the appropriate language locale.
How it works
Content equality and reference identity
equals() tests whether two strings contain the same sequence of code units, while compareTo() orders those sequences lexicographically. Objects.equals(left, right) provides null-safe content equality when either reference may be null.
== tests reference identity : whether two expressions point to the same object. The string pool makes some equal expressions happen to share a reference, so a bug written with == may pass tests that contain only literals and then fail for strings produced by files, networks, or constructors.
equalsIgnoreCase() performs a locale-independent, character-by-character comparison. It is not a complete natural-language search or Unicode normalization policy. For a login name, tag, or search key, define the domain’s casing, normalization, and allowed-character rules before choosing an API.
Literals, constant expressions, and the string pool
Java maintains canonical instances for string literals and text blocks. Equal literals are required to reference the same instance, and strings computed by constant expressions are interned as well. This mechanism is commonly called the string pool .
A string produced at runtime does not automatically share identity with a pooled instance merely because its content matches. new String("ready") explicitly constructs a distinct object, while reuse by parsing, slicing, or case conversion is not a contract callers may rely on.
intern() returns the canonical pooled instance with equal contents and establishes one when none exists. It can fit a measured, highly repetitive data set with a clear lifetime; it is not a replacement for ordinary content comparison. Interning unbounded user input ties memory use to a global sharing policy.
Operations return results
substring(), replace(), strip(), repeat(), and case conversion all return string results. A result may be a new object, or it may reuse the receiver when nothing changes; the API guarantees content, not an allocation count.
That design makes a chain such as raw.strip().toUpperCase(Locale.ROOT) natural. Each step still has separate semantics: strip() follows Unicode whitespace rules, toUpperCase() can change length, and indexes must be recalculated against the current result.
StringBuilder uses the opposite model. append(), insert(), delete(), and setCharAt() mutate builder state, and most return the same builder for chaining. toString() creates an independent String snapshot, so later builder changes do not rewrite a string already returned.
Compile-time and runtime concatenation
A string concatenation containing only constants can be folded and interned at compile time. A + expression with runtime values evaluates operands left to right, converts them to strings, and produces a new string result. Whether that uses invokedynamic, internal helper classes, or allocation elimination is a compiler and runtime detail.
For one short expression, + is usually clearest. The problem is cumulative assignment in a loop: each iteration must retain the previous contents and produce the next result, potentially copying an ever-growing prefix repeatedly. An explicit StringBuilder concentrates those appends in one mutable buffer.
String.valueOf(object) returns the text "null" for null, while calling object.toString() directly throws NullPointerException. Either behavior may violate the domain requirement, so decide whether a missing value is rejected, skipped, left empty, or shown as a placeholder before building a message.
Search, splitting, and replacement
indexOf() and contains() search for literal content, while matches(), split(), replaceFirst(), and replaceAll() interpret regular expressions. Their names look related, but their input languages differ; passing user-provided literal text to a regex API can turn ., [, or * into syntax.
Use replace(CharSequence, CharSequence) for literal replacement. When a regex is required, protect literal pattern fragments with Pattern.quote() and literal replacement text with Matcher.quoteReplacement(), because pattern syntax and replacement syntax have different metacharacters.
split(regex) behaves like a split with a zero limit and removes trailing empty strings. CSV, fixed records, and protocol fields may treat an empty final value as a real column, in which case use split(regex, -1). Full CSV also has quoting, line-break, and escaping rules and should not be implemented with one regex split.
Bytes, code units, and code points
A string has no “current encoding” property; the Java API presents it as a sequence of UTF-16 code units. getBytes(charset) encodes text to bytes, and new String(bytes, charset) decodes bytes with a charset. Mismatched charsets corrupt content even when no exception is thrown.
substring(begin, end) uses a half-open code-unit range. An out-of-range endpoint throws StringIndexOutOfBoundsException, but an in-range endpoint in the middle of a surrogate pair is not rejected automatically; it produces a string containing an unpaired surrogate.
To take a code-point prefix, first cap the count with codePointCount(), then turn that count into a code-unit endpoint with offsetByCodePoints(). A grapheme-cluster prefix instead needs a text segmenter and cannot reuse a code-point count as if it meant the same thing.
Examples
Immutability, content equality, and identity
The first example puts three rules together: transformation methods return values, equals() compares contents, and == compares object identity. Constant folding reuses the pooled literal, while the explicitly constructed copy does not share its identity.
import java.util.Locale;
public class StringValues {
public static void main(String[] args) {
String status = "draft";
status.toUpperCase(Locale.ROOT); // The return value is ignored.
String literal = "ready";
String folded = "re" + "ady";
String copied = new String(literal);
System.out.println("status=" + status);
System.out.println("upper=" + status.toUpperCase(Locale.ROOT));
System.out.println("sameContent=" + literal.equals(copied));
System.out.println("literalIdentity=" + (literal == folded));
System.out.println("copyIdentity=" + (literal == copied));
}
}status=draft
upper=DRAFT
sameContent=true
literalIdentity=true
copyIdentity=falsestatus is still "draft" because the first case conversion result was not saved. folded comes from a constant expression, so it shares the canonical instance with the literal; copied has equal content but a different reference.
Do not read that output as a reason to use identity comparison. Change folded to runtime input and identity no longer follows from content; only equals() expresses the value semantics required here.
Normalizing external text
External input often involves whitespace, casing, delimiters, and encoding at once. This program explicitly chooses Unicode whitespace handling, locale-neutral casing, splitting that retains a trailing empty field, and UTF-8 encoding and decoding.
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Locale;
public class NormalizeInput {
public static void main(String[] args) {
String rawStatus = "\u2003 pending \u2003";
String status = rawStatus.strip().toUpperCase(Locale.ROOT);
String record = "A17,READY,";
String[] fields = record.split(",", -1); // Keep the trailing empty field.
String city = "München";
byte[] encoded = city.getBytes(StandardCharsets.UTF_8);
String decoded = new String(encoded, StandardCharsets.UTF_8);
System.out.println("status=" + status);
System.out.println("fields=" + Arrays.toString(fields));
System.out.println("bytes=" + encoded.length);
System.out.println("roundTrip=" + city.equals(decoded));
}
}status=PENDING
fields=[A17, READY, ]
bytes=8
roundTrip=truestrip() removes the Unicode whitespace in the example, while the older trim() only handles characters with code points no greater than U+0020. split(",", -1) uses a negative limit to preserve the trailing empty field; omitting the limit discards it.
The UTF-8 round trip succeeds because encoding and decoding explicitly use the same charset. A real system must also decide whether malformed bytes are replaced or rejected; use a CharsetDecoder when that policy must be controlled instead of relying only on the convenience constructor.
Taking a code-point prefix
length() and code-point count often match for BMP-only text, hiding an index-unit error. This example includes a surrogate pair and a combining mark, then converts a code-point limit into a safe code-unit endpoint.
public class CodePointPrefix {
static String prefixByCodePoints(String text, int limit) {
if (limit < 0) {
throw new IllegalArgumentException("limit must be non-negative");
}
int available = text.codePointCount(0, text.length());
int end = text.offsetByCodePoints(0, Math.min(limit, available));
return text.substring(0, end);
}
public static void main(String[] args) {
String label = "A🚗e\u0301";
String broken = label.substring(0, 2);
System.out.println("units=" + label.length());
System.out.println("codePoints="
+ label.codePointCount(0, label.length()));
System.out.println("brokenHighSurrogate="
+ Character.isHighSurrogate(broken.charAt(1)));
System.out.println("prefix=" + prefixByCodePoints(label, 2));
}
}units=5
codePoints=4
brokenHighSurrogate=true
prefix=A🚗substring(0, 2) stays within bounds but splits the car emoji’s surrogate pair. The endpoint returned by offsetByCodePoints() falls after a complete code point, so the prefix keeps the pair intact.
The final e plus combining mark still consists of two code points but is commonly displayed as one grapheme cluster. The function’s name deliberately promises code-point behavior; a UI limit on user-visible characters requires a grapheme-segmentation contract instead.
Combining a result with a builder
When the number of pieces is known only at runtime, StringBuilder can keep delimiter policy next to the append operation. This example inserts delimiters only between elements, avoiding the empty-input edge case caused by appending and then deleting a final delimiter.
import java.util.List;
public class OrderSummary {
static String summarize(List<String> itemCodes) {
StringBuilder output = new StringBuilder("items=");
for (int index = 0; index < itemCodes.size(); index++) {
if (index > 0) {
output.append(" | ");
}
output.append(itemCodes.get(index));
}
output.append("; count=").append(itemCodes.size());
return output.toString();
}
public static void main(String[] args) {
System.out.println(summarize(List.of("A17", "B04", "C22")));
System.out.println(summarize(List.of()));
}
}items=A17 | B04 | C22; count=3
items=; count=0The builder belongs to the method call, so no mutable state is shared between calls. toString() returns the final immutable value, and later changes to the builder would not alter a string already returned.
If the task only joins existing strings, String.join(" | ", itemCodes) is shorter. A builder is usually clearer when the result also includes labels, counts, conditional pieces, or values of several types.
Pitfalls
String-pool and implementation boundaries
The string pool is a language-visible canonicalization mechanism, not an application cache API. Literals, text blocks, and constant string expressions have specified interning behavior; an ordinary runtime result obtains a required canonical reference only after an explicit intern() call.
This boundary explains why "ab" == "a" + "b" can be true while an equal value formed by concatenating two variables at runtime should not be predicted by identity. Compile-time folding and runtime concatenation occur at different stages; content equality is the business property that remains stable across them.
Constant variables affect folding
A constant expression may include primitive or String constant variables that satisfy the specification’s rules. A final String participates in compile-time string folding only if its declaration has a constant-expression initializer and it meets the definition of a constant variable.
Method parameters, runtime method results, and fields assigned inside a static initializer are not string constant expressions of that kind. Do not infer identity merely because final appears in the source; let the compiler optimize the expression and keep application comparisons on equals().
The number of objects in the pool also cannot be derived reliably from one source line. Class loading, earlier execution, compiler-generated constants, and runtime implementation all affect which objects already exist. “Does new String create one object or two?” is neither a stable API contract nor a useful business decision.
Compact strings are an implementation detail
OpenJDK optimizes many string representations with Compact Strings . JEP 254 changed OpenJDK’s pre-9 char[] representation to a byte[] plus an encoding flag, allowing strings containing only Latin-1 characters to use one-byte internal elements while other strings use a UTF-16 representation.
This does not change the public UTF-16 semantics of String. The contracts of length(), charAt(), substring(), and the code-point APIs did not change with the internal array type, and callers cannot choose the internal coder through the standard API.
Saying a Latin-1 string uses half the payload space does not prove that every complete object is exactly half the size. Object headers, alignment, compressed references, garbage collection, and JIT optimization all affect a measurement. Do not state whole-object sizes or throughput multiples without a benchmark and memory analysis on the target JVM.
Reflectively reading private fields to inspect coder depends on module-opening options and an OpenJDK implementation. Production logic should depend on public text semantics; diagnostics that inspect representation must record the vendor, exact build, and JVM options.
Hash codes and map keys
String.equals() and String.hashCode() both derive from content, which makes strings suitable keys for HashMap and HashSet. Immutability ensures that a key cannot move to the wrong hash bucket because the string itself changed after insertion.
Equal hashes do not prove equal content. A hash table still calls equals() between candidate keys, and an application must not treat hashCode() as a unique identifier, checksum, or security digest. Persisting a hash across processes is not a substitute for storing the original key either.
String immutability also does not freeze related business objects. If a map value, a record containing string fields, or a cache entry built around a string is mutable, it still needs a separate ownership, equality, and concurrency policy.
Unicode boundaries and normalization
A Java string may contain unpaired surrogate code units. Constructing a String does not automatically prove a well-formed UTF-16 sequence, and substring() can split a surrogate pair. Encoding malformed input with some charsets may replace it; a strict protocol needs a CharsetEncoder configured with the intended error action.
Code units, code points, and grapheme clusters solve different problems. One count cannot stand in for another.
| Unit | Java operation | Suitable contract |
|---|---|---|
| UTF-16 code unit | length(), charAt(), substring() | Java indexes and UTF-16 API interoperation |
| Unicode code point | codePointCount(), codePoints() | Scalar-level classification and supplementary-character iteration |
| Grapheme cluster | Text segmenter | Cursor movement and user-visible character limits |
Unicode normalization is a separate axis. Visually or semantically equivalent text may use a precomposed character or a base character plus combining marks, and equals() treats different code-unit sequences as unequal. Normalize to a chosen form such as NFC or NFD at a controlled boundary only when the domain actually requires normalized equality, and preserve that rule as part of the contract.
Case mapping is not normalization either. Some mappings change length, and different locales can produce different results. A persisted key that uses Locale.ROOT must still state whether Unicode normalization applies; natural-language display text should not automatically inherit a machine-key policy.
Text blocks still process content
A text block is a multiline string literal, not a raw string. The compiler processes incidental indentation, line terminators, and escape sequences, so source formatting can change the result or be removed by common-indent rules.
Text blocks have the same String type and interning rules as ordinary string literals. They are useful for embedded JSON, SQL, or test text, but they neither escape the target language nor make interpolated data safe from injection. Dynamic SQL, HTML, and JSON still need the corresponding parameterized API, escaper, or serializer.
When exact text matters, assert line terminators and trailing whitespace instead of judging console output by eye. A cross-platform protocol that requires fixed line endings should construct or normalize them explicitly rather than treating the editor’s save behavior as a contract.
Builder capacity and concurrency semantics
StringBuilder maintains a growable character sequence and a capacity. It expands internal storage when needed, but its exact growth formula is not an application contract. If the final code-unit count can be estimated reliably, an initial capacity may reduce resizing; when it cannot, the default constructor is usually clearer.
Capacity preallocation does not fix an algorithm error. Passing an untrusted declared length straight to a constructor can reserve too much memory, and multiplying an inaccurate “average character count” may overflow. Range-check a capacity hint and add one only when analysis or measurement shows that allocation matters.
StringBuilder provides no cross-thread synchronization guarantee. StringBuffer synchronizes public methods, but a read-then-write sequence such as if (buffer.length() > 0) buffer.append(...) still needs higher-level synchronization to be atomic as a whole.
Most text construction naturally belongs to one request or method call. Keeping the builder local and returning only the final string usually provides both clear ownership and sufficient performance. When several threads produce separate messages, give each its own builder instead of contending on a shared buffer.
A compiler may optimize one + expression, so do not mechanically rewrite all concatenation as builders. Choose a builder when the source describes an incremental, repeated construction process; verify performance claims with the target JDK, realistic data shapes, and an appropriate benchmark tool.
Further reading
4 questions · 1 predict-the-output · 1 spot-the-bug