String Basics
A String is an immutable sequence of characters. It is the type Java uses for all text: names, messages, file contents, JSON, SQL, URLs. String is an ordinary class in java.lang, but the language gives it special support: double-quoted literals, the + operator for joining, and a shared pool of literal values. This lesson looks closely at what a string is, how strings are created and stored, and the rules that follow from their design. The lessons after it cover length, methods, comparison, searching, and formatting in detail.
What a String Contains
Internally, a string is a sequence of UTF-16 code units, each represented in Java by a char. For most text (English, Tamil, Hindi, Chinese, and every other script in Unicode's Basic Multilingual Plane), one character is one char:
String word = "Java";
char first = word.charAt(0); // 'J'
char[] chars = word.toCharArray(); // ['J', 'a', 'v', 'a']
Characters outside that plane, including most emoji, take two char values (a surrogate pair):
String wave = "Hi 👋";
System.out.println(wave.length()); // 5: H, i, space, and two chars for the emoji
System.out.println(wave.codePointCount(0, wave.length())); // 4: actual Unicode characters
Most code never needs to think about this, but it matters when counting user-visible characters, truncating text, or reversing strings that may contain emoji. The String Length lesson covers it in detail.
char versus String
char | String | |
|---|---|---|
| Kind | Primitive | Object (reference type) |
| Literal | Single quotes: 'A' | Double quotes: "A" |
| Holds | Exactly one UTF-16 code unit | Zero or more characters |
Can be null | No | Yes |
'A' and "A" are different types and are not interchangeable: String s = 'A'; does not compile.
Creating Strings
String literal = "Chennai"; // string literal: the usual way
String empty = ""; // empty string, length 0
String fromChars = new String(new char[]{'J', 'D', 'K'}); // "JDK"
String part = String.valueOf(new char[]{'a', 'b', 'c', 'd'}, 1, 2); // "bc"
String fromNumber = String.valueOf(42); // "42"
String fromBytes = new String(bytes, StandardCharsets.UTF_8); // decode bytes as text
String repeated = "-".repeat(10); // "----------"
String joined = String.join(", ", "a", "b", "c"); // "a, b, c"
new String("Chennai") also works but is almost never useful: it creates an extra copy of a string you already have. When converting bytes to text, always specify the character set; relying on the platform default causes garbled text when the program runs on a differently configured machine.
Immutability
A String object never changes after it is created. Every method that seems to modify a string returns a new string and leaves the original untouched:
String city = "chennai";
city.toUpperCase(); // result discarded
System.out.println(city); // chennai
city = city.toUpperCase(); // reassign the variable to the new string
System.out.println(city); // CHENNAI
before: city ──► "chennai"
after: city ──► "CHENNAI" "chennai" (unchanged, collected if unused)
The variable now points to a different object; the original object was never modified.
Why strings are immutable
- Safety. A string passed to a method, stored in a collection, or used as a
HashMapkey cannot be changed by other code. A file path checked for permission cannot be altered after the check. - Thread safety. Immutable objects can be shared between threads with no synchronization.
- Hash caching. The hash code is computed once and cached, making strings fast map keys.
- Sharing. Equal literals can safely share one object, which is the basis of the string pool.
The cost is that repeatedly "modifying" a string creates many intermediate objects. For building text in loops, use StringBuilder, covered in its own lesson.
The String Pool
The JVM keeps a pool of string literals. Identical literals in the program refer to one shared object:
String s1 = "hello";
String s2 = "hello";
String s3 = new String("hello");
String s4 = s3.intern();
System.out.println(s1 == s2); // true: same pooled object
System.out.println(s1 == s3); // false: new String creates a separate object
System.out.println(s1 == s4); // true: intern() returns the pooled instance
System.out.println(s1.equals(s3)); // true: same characters
Compile-time constant expressions are also pooled, but strings built at runtime are not:
String c = "Ab";
String d = "A" + "b"; // computed by the compiler: pooled
String x = "A";
String y = x + "b"; // computed at runtime: a new object
System.out.println(c == d); // true
System.out.println(c == y); // false
This is why == sometimes appears to "work" for strings and sometimes does not. Always compare string content with equals (or equalsIgnoreCase). The String Compare lesson covers this in detail.
null, Empty, and Blank
Three different situations are easy to confuse:
| Value | Meaning | length() | isEmpty() | isBlank() |
|---|---|---|---|---|
null | No string object at all | NullPointerException | NullPointerException | NullPointerException |
"" | A string with no characters | 0 | true | true |
" " | Only whitespace | 3 | false | true |
String input = " ";
if (input == null || input.isBlank()) {
System.out.println("Please enter a value");
}
Check for null first, because calling any method on null throws. isBlank() (Java 11) treats strings containing only whitespace as empty, which is usually what form validation needs.
Concatenating null does not throw; it produces the text "null":
String name = null;
System.out.println("Hello, " + name); // Hello, null
This is a frequent source of odd output in logs and user interfaces; check values before building messages from them.
Converting To and From Strings
// To String
String a = String.valueOf(3.5); // "3.5"
String b = Integer.toString(255, 16); // "ff": base 16
String c = Boolean.toString(true); // "true"
String d = String.valueOf(someObject); // calls toString(), or "null" if the object is null
// From String
int n = Integer.parseInt("42");
double x = Double.parseDouble("3.14");
boolean flag = Boolean.parseBoolean("TRUE"); // true (case-insensitive)
LocalDate date = LocalDate.parse("2026-11-11");
String.valueOf(obj) is safer than obj.toString() because it handles null. Parsing methods throw exceptions (such as NumberFormatException or DateTimeParseException) for malformed input, so input from users or files should be validated.
Strings as CharSequence
String implements the CharSequence interface, as do StringBuilder and StringBuffer. Methods that accept CharSequence work with all of them:
static boolean hasDigit(CharSequence text) {
for (int i = 0; i < text.length(); i++) {
if (Character.isDigit(text.charAt(i))) return true;
}
return false;
}
hasDigit("abc123"); // true
hasDigit(new StringBuilder("no digits")); // false
String.join, String.contains, and the regular-expression APIs accept CharSequence for this reason.
Practical Example: Username Registration
A sign-up form must normalize usernames, reject invalid ones, and prevent duplicates.
import java.util.*;
public class Usernames {
static String normalize(String raw) {
if (raw == null || raw.isBlank()) {
throw new IllegalArgumentException("username is required");
}
String u = raw.strip().toLowerCase();
if (u.length() < 3 || u.length() > 15) {
throw new IllegalArgumentException("must be 3-15 characters: \"" + u + "\"");
}
for (char c : u.toCharArray()) {
if (!(Character.isLetterOrDigit(c) && c < 128) && c != '_') {
throw new IllegalArgumentException("invalid character '" + c + "' in \"" + u + "\"");
}
}
return u;
}
public static void main(String[] args) {
Set<String> taken = new HashSet<>();
String[] requests = {" Ada_Lovelace ", "ada_lovelace", "Linus", "x", "grace hopper", null, "Ravi99"};
for (String r : requests) {
try {
String name = normalize(r);
if (!taken.add(name)) {
System.out.println("TAKEN " + name);
} else {
System.out.println("OK " + name);
}
} catch (IllegalArgumentException e) {
System.out.println("REJECTED " + e.getMessage());
}
}
System.out.println("Registered: " + new TreeSet<>(taken));
}
}
Output:
OK ada_lovelace
TAKEN ada_lovelace
OK linus
REJECTED must be 3-15 characters: "x"
REJECTED invalid character ' ' in "grace hopper"
REJECTED username is required
OK ravi99
Registered: [ada_lovelace, linus, ravi99]
normalize checks for null and blank input before calling any other method on the string. strip() and toLowerCase() each return a new string, which is stored in u; the original raw is never changed. Because normalization happens before the duplicate check, " Ada_Lovelace " and "ada_lovelace" are recognized as the same name. The character loop allows only ASCII letters, digits, and underscores; the c < 128 check is needed because Character.isLetterOrDigit also accepts letters from other scripts. A HashSet<String> detects duplicates through equals and hashCode, which compare the characters, not object identity, so the normalized strings match even though they are different objects.