Lessons
1 · Java Foundations
2 · Object-Oriented Java
3 · Core Java
4 · Collections and Generics
5 · Functional Java
6 · The JVM and Concurrency
7 · Tools and Craft
- 21File I/O and Serialization
- 22Debugging: Errors, Stack Traces, the Debugger and Logging
- 23Maven: What It Does and How to Use It
- 24Git for Java Projects: Commits, Branches, Merging and Pull Requests
- 25Testing with JUnit and Mockito: Unit Tests, Mocks and Their Limits
- 26Project 1: Build an Expense Tracker CLI in Plain Java
8 · Databases and JDBC
9 · Web and Spring
10 · Persistence with JPA
11 · Secure, Tested, Production-Ready
- 36Security Fundamentals and Spring Security: Passwords, JWT, Access Control, CORS and Secrets
- 37Testing Spring Boot Applications: Slice Tests, MockMvc, Real Databases and the Test Pyramid
- 38Production Practices: Configuration, Logging, Docker and Health Checks for Spring Boot
- 39Project 3: The Job Application Tracker API, Assembled and Reviewed
Lesson 8 · Core Java
Strings, StringBuilder, Text Blocks and Time
Why Java strings are immutable and what the string pool really is, when to use StringBuilder, how Unicode makes length surprising, how text blocks and formatting work, and how to handle dates and times with java.time instead of the old Date and Calendar. Includes testable time with Clock.
For
Learners who need to process text and dates correctly, and who want to answer interview questions about String immutability and the string pool.
Level
Before you start
equals, hashCode, toString and the Object Contract (article 7): immutability and equality.
On this page
Key takeaways
- A String is immutable, so every operation that seems to change it returns a new String. That makes strings safe to share and cache, and is why string literals can live in a shared pool.
- Use equals to compare strings, never ==. Build strings in loops with StringBuilder, not repeated concatenation. StringBuffer is the older, synchronised class that you rarely need.
- A char is a UTF-16 code unit, so length() counts code units, not what a person sees as characters. Use code point methods when you work with emoji or non-Latin text.
- Text blocks make multi-line text readable, and formatted or String.format insert values. Be explicit about the locale when formatting numbers.
- Use java.time for dates and times: its classes are immutable, and LocalDate, Instant, Duration and Period model different ideas. Inject a Clock so time-dependent code can be tested.
Almost every program handles text and time: names, messages, log lines, JSON, order dates, deadlines. Two things go wrong again and again. First, text handling that is slow or subtly incorrect (comparing with ==, building huge strings in a loop, assuming that length means characters). Second, dates handled with old, error-prone classes. This article fixes both.
What you will learn
#- What immutability means for
String, and what the string pool is. - How to build and compare strings efficiently and correctly.
- Why
StringBuilderexists, and how it relates toStringBuffer. - How Unicode complicates "length".
- Text blocks and formatting.
- The
java.timeAPI, and how to make time-dependent code testable.
Strings are immutable
#A String object can never change. The Java documentation says it directly: strings are constant, their values cannot be changed after creation, and because they are immutable they can be shared. Methods that look like modifications return a new string and leave the original untouched.
// File: Main.javapublic class Main { public static void main(String[] args) { String name = " Asha Rao "; String trimmed = name.strip(); String upper = trimmed.toUpperCase(); name.replace("Asha", "Meena"); // result thrown away! System.out.println("[" + name + "]"); System.out.println("[" + trimmed + "]"); System.out.println("[" + upper + "]"); String s = "banana"; System.out.println(s.length() + " " + s.charAt(2) + " " + s.indexOf("nan") + " " + s.substring(1, 4)); System.out.println(s.contains("ana") + " " + s.startsWith("ban") + " " + s.replace('a', 'o')); System.out.println(String.join("-", "2026", "10", "05")); System.out.println("ab".repeat(3) + " " + " ".isBlank() + " " + "".isEmpty()); System.out.println("a,b,,c".split(",").length); System.out.println("Java".equalsIgnoreCase("jAVA") + " " + "apple".compareTo("banana")); }}[ Asha Rao ][Asha Rao][ASHA RAO]6 n 2 anatrue true bonono2026-10-05ababab true true4true -1The line name.replace("Asha", "Meena"); is the classic beginner bug: the new string is returned and then ignored, so name is unchanged. You must assign the result: name = name.replace(...). The rest of the output shows the common methods. Note that split(",") on "a,b,,c" gives four parts including an empty one. Also remember compareTo returns a negative number, zero or a positive number, and the sign is what matters, not the exact value.
Why immutable?
#The designers made String immutable for good reasons:
- Safe sharing. Nobody can change a string that another part of the program is using.
- Safe keys. A string's hash code never changes, so it is safe as a
HashMapkey (as article 7 required), and it can be cached. - Security. Strings hold file names, URLs and credentials, and checking a string and then using it must not allow it to change in between.
- Thread safety. Immutable objects can be shared between threads freely.
- The string pool. Because strings cannot change, identical literals can be shared.
The string pool
#When the compiler sees the same string literal in several places, the JVM can keep one object and let all of them refer to it. This shared area is called the string pool (or intern pool). The String documentation describes intern() as returning a canonical representation, so equal strings in the pool are the same object.
// File: Main.javapublic class Main { public static void main(String[] args) { String a = "hello"; String b = "hello"; // same literal: shared object String c = new String("hello"); // forces a separate object String d = c.intern(); // asks for the pooled one System.out.println(a == b); System.out.println(a == c); System.out.println(a.equals(c)); System.out.println(a == d); String part = "hel"; String built = part + "lo"; // computed at runtime System.out.println(a == built); System.out.println(a.equals(built)); }}truefalsetruetruefalsetrueLiterals with the same text are the same object, while new String(...) and strings computed at runtime are separate objects, which is why == gives answers that look random to beginners. The takeaway for real code is: always compare strings with equals (or equalsIgnoreCase). The pool is an implementation optimisation, and in interviews the correct answer to "what is the string pool?" is that it is an area where the JVM keeps one copy of each distinct literal (and interned string), which is possible because strings are immutable. You rarely need intern() yourself.
Building strings: concatenation, StringBuilder and friends
#Because strings are immutable, "appending" creates a new string every time. A single expression like "Hello, " + name + "!" is fine: the compiler turns it into efficient code. (The documentation notes that the way concatenation is implemented is up to the compiler and may differ between JDK versions.) The problem is repeated concatenation in a loop:
String result = "";for (int i = 0; i < 10_000; i++) { result += i + ","; // each pass copies the whole string so far}Each pass copies everything built so far into a new string, so the total work grows with the square of the length. The standard solution is StringBuilder, a mutable sequence of characters you can append to cheaply.
// File: Main.javaimport java.util.List;import java.util.StringJoiner;public class Main { public static void main(String[] args) { StringBuilder sb = new StringBuilder(); for (int i = 1; i <= 5; i++) { sb.append(i).append(','); } sb.setLength(sb.length() - 1); // remove the trailing comma System.out.println(sb); System.out.println(sb.reverse()); StringBuilder editor = new StringBuilder("Hello World"); editor.insert(5, ",").replace(7, 12, "Java").append('!'); System.out.println(editor); StringJoiner joiner = new StringJoiner(", ", "[", "]"); joiner.add("a").add("b").add("c"); System.out.println(joiner); System.out.println(String.join(" | ", List.of("x", "y", "z"))); }}1,2,3,4,55,4,3,2,1Hello, Java![a, b, c]x | y | zWhen joining a list of items with a separator, prefer String.join, StringJoiner or the stream collector Collectors.joining (article 16), because they remove the "trailing comma" problem.
StringBuilder or StringBuffer? The two classes have the same methods. StringBuffer is the older class from Java 1.0, and all its methods are synchronized, which makes it safe to share between threads but slower. StringBuilder, added in Java 5, is the same without synchronisation. Because you almost never share a half-built string between threads, use StringBuilder. You will see StringBuffer in older code and in interviews. That is its main legacy value.
|
|
| |
|---|---|---|---|
Mutable | No | Yes | Yes |
Thread-safe | Yes (immutable) | No | Yes (synchronised) |
Use for | Values, keys, text you pass around | Building text in one thread | Older code only |
Unicode: length is not what you see
#Java strings use UTF-16. The documentation explains that supplementary characters are represented by surrogate pairs, and index values refer to char code units, so a supplementary character uses two positions. For ordinary English text, one char is one character. For emoji and many other scripts, it is not.
// File: Main.javapublic class Main { public static void main(String[] args) { String text = "A" + new StringBuilder().appendCodePoint(0x1F600) + "B"; // A, a smiling face, B System.out.println(text.length()); // UTF-16 code units System.out.println(text.codePointCount(0, text.length())); // user-visible code points System.out.println(Character.isSurrogate(text.charAt(1))); text.codePoints().forEach(cp -> System.out.print(Integer.toHexString(cp) + " ")); System.out.println(); }}43true41 1f600 42The string has three characters to a reader but a length() of 4, because the emoji takes two UTF-16 units. If you reverse such a string with charAt you split the pair and produce garbage. Code that handles names, messages or any user-entered text should use the code point methods (codePointAt, codePoints) when it counts or slices. Even code points are not always what users see as one "letter" (some visible characters combine several), which is why serious text handling uses libraries, but the first step is simply to know that length() counts code units.
Text blocks and formatting
#Embedding multi-line text (SQL, JSON, HTML) in a Java string used to mean a clutter of \n and +. Text blocks, final in Java 15, fix that. JEP 378 describes them as multi-line literals, delimited by triple quotes, that avoid most escape sequences and are formatted in a predictable way, with incidental whitespace (the indentation that only exists to line up your source code) stripped.
// File: Main.javapublic class Main { public static void main(String[] args) { String sql = """ SELECT id, company FROM job_application WHERE status = 'APPLIED' ORDER BY applied_on DESC """; System.out.print(sql); String template = """ Dear %s, your application for %s was received."""; System.out.println(template.formatted("Asha", "Backend Developer")); System.out.println(String.format("%-10s|%5d|%8.2f|", "item", 42, 3.14159)); }}SELECT id, companyFROM job_applicationWHERE status = 'APPLIED'ORDER BY applied_on DESCDear Asha,your application for Backend Developer was received.item | 42| 3.14|The compiler removes the indentation common to all lines (here, the position of the closing delimiter and the left-most text decide how much), so the string starts at the left margin. A closing """ on its own line adds a final newline, and placing it at the end of the last line (as in template) does not. formatted(...) (Java 15) and String.format(...) fill placeholders: %s for text, %d for whole numbers, %f for decimals, %n for a line break, with width and precision options. One caution: number formatting depends on the locale. In some locales String.format("%.2f", 3.5) prints 3,50, and for machine-readable output you should pass Locale.ROOT or Locale.US explicitly: String.format(Locale.ROOT, "%.2f", value).
A pitfall with split: its argument is a regular expression, so "1.2.3".split(".") returns an empty array (a dot matches any character), and you must write split("\\.").
Time: the java.time API
#Dates and times look simple and are famously hard: time zones, daylight saving time, leap years, different calendars. For years Java's java.util.Date and Calendar were the tools, and they had design flaws: they were mutable, Calendar months started at zero, SimpleDateFormat was not thread-safe, and Date mixed a point in time with a calendar view. Java 8 introduced the java.time package (based on the ISO-8601 calendar), and its classes are all immutable and thread-safe, as the package documentation states. Use java.time for all new code, and treat Date and Calendar as legacy that you must be able to read.
The key is to choose the class that matches the idea:
Class | Meaning | Example use |
|---|---|---|
| A date without time or zone | A birthday, an application date |
| A time of day without a date or zone | Opening hours |
| A date and time without a zone | A meeting, as written on a calendar |
| A point on the timeline (UTC) | A timestamp for when something happened |
| A date, time and time zone | A flight departure in a specific city |
| An amount of time in seconds and nanoseconds | Time between two instants |
| An amount of time in years, months, days | The gap between two dates |
// File: Main.javaimport java.time.DayOfWeek;import java.time.Duration;import java.time.Instant;import java.time.LocalDate;import java.time.Period;import java.time.ZoneId;import java.time.ZonedDateTime;import java.time.format.DateTimeFormatter;import java.time.temporal.ChronoUnit;public class Main { public static void main(String[] args) { LocalDate applied = LocalDate.of(2026, 10, 5); LocalDate followUp = applied.plusDays(14); System.out.println(followUp + " is a " + followUp.getDayOfWeek()); System.out.println(applied.isBefore(followUp) + " " + applied.getDayOfWeek().equals(DayOfWeek.MONDAY)); System.out.println(ChronoUnit.DAYS.between(applied, LocalDate.of(2026, 12, 25)) + " days to Christmas"); Period age = Period.between(LocalDate.of(1998, 3, 15), applied); System.out.println(age.getYears() + " years, " + age.getMonths() + " months"); LocalDate parsed = LocalDate.parse("2026-02-28").plusDays(1); // ISO format parses directly System.out.println(parsed + " " + LocalDate.of(2024, 2, 28).plusDays(1)); DateTimeFormatter indian = DateTimeFormatter.ofPattern("dd/MM/yyyy"); System.out.println(applied.format(indian) + " " + LocalDate.parse("25/12/2026", indian)); Instant start = Instant.parse("2026-10-05T08:00:00Z"); Instant end = Instant.parse("2026-10-05T09:45:30Z"); Duration elapsed = Duration.between(start, end); System.out.println(elapsed + " = " + elapsed.toMinutes() + " minutes"); ZonedDateTime inMumbai = start.atZone(ZoneId.of("Asia/Kolkata")); ZonedDateTime inLondon = start.atZone(ZoneId.of("Europe/London")); System.out.println(inMumbai.toLocalTime() + " in Mumbai, " + inLondon.toLocalTime() + " in London"); }}2026-10-19 is a MONDAYtrue true81 days to Christmas28 years, 6 months2026-03-01 2024-02-2905/10/2026 2026-12-25PT1H45M30S = 105 minutes13:30 in Mumbai, 09:00 in LondonNotice several design points. Every "change" (plusDays) returns a new object. Dates parse and print in the standard ISO format (2026-10-05), and you provide a DateTimeFormatter for other formats. LocalDate handles leap years (the 2024 result differs from 2026). A Period measures human calendar differences, and a Duration measures exact elapsed time. And one Instant becomes different local times in different zones.
Rules that prevent time bugs
#- Store and exchange timestamps as
Instant(UTC), and convert to a zone only when displaying to people. Store a plain date (a birthday, a due date) as aLocalDate, because it has no time zone. - Never subtract dates yourself with arithmetic. Use
ChronoUnit,PeriodorDuration. - Be explicit about the zone.
ZoneId.systemDefault()varies between machines, and code that depends on it behaves differently on a server and on your laptop. - Do not use
LocalDateTimefor a moment in time, because it cannot tell you which instant it is without a zone.
Making time testable: inject a Clock
#Code that calls LocalDate.now() directly gives a different answer every day, which makes it impossible to test deterministically. The java.time.Clock class provides the current time, and you can pass one in. This is the composition idea from article 6 applied to time.
// File: Main.javaimport java.time.Clock;import java.time.Instant;import java.time.LocalDate;import java.time.ZoneOffset;public class Main { static class ReminderService { private final Clock clock; ReminderService(Clock clock) { this.clock = clock; } boolean isOverdue(LocalDate dueDate) { return LocalDate.now(clock).isAfter(dueDate); } } public static void main(String[] args) { Clock fixed = Clock.fixed(Instant.parse("2026-10-05T10:00:00Z"), ZoneOffset.UTC); ReminderService service = new ReminderService(fixed); System.out.println(service.isOverdue(LocalDate.of(2026, 10, 4))); System.out.println(service.isOverdue(LocalDate.of(2026, 10, 5))); System.out.println(service.isOverdue(LocalDate.of(2026, 10, 6))); }}truefalsefalseIn production you pass Clock.systemUTC() (or a system clock for a specific zone). In a test you pass a fixed clock, and the test gives the same answer every day. We use exactly this technique in the Spring Boot project (article 33).
Common mistakes
#- Comparing strings with
==. - Ignoring the return value of
replace,trim,toUpperCaseand other methods. - Concatenating in a loop instead of using
StringBuilder. - Assuming
length()equals the number of visible characters. - Using
splitwith a special character such as.or|without escaping. - Formatting numbers without a locale when the output must be machine-readable.
- Using
Date,CalendarorSimpleDateFormatin new code. - Calling
now()everywhere, which makes code untestable. - Using
LocalDateTimefor timestamps instead ofInstant.
Exercises
#Exercise 1
Predict
#What are the values of a == b and a.equals(b) for String a = "hi"; String b = "h" + "i";? (Think about whether "h" + "i" is a compile-time constant.) Then run it to check, and explain the difference from the earlier built example.
Exercise 2: Fix the performance
#This method joins a list of 100,000 words with spaces using += in a loop. Rewrite it with StringBuilder and explain why the original is slow.
Exercise 3: Count characters properly
#Write a method that returns the number of Unicode code points in a string, and explain how it differs from length() for a string containing an emoji.
Exercise 4: Date arithmetic
#Using java.time, compute how many days remain between 5 October 2026 and 31 March 2027, and the date 90 days after 5 October 2026.
Exercise 5: Make it testable
#A class has boolean isWeekend() { return LocalDate.now().getDayOfWeek().getValue() >= 6; }. Redesign it so a test can check both a Saturday and a Monday.
Show solutionHide solution
"h" + "i" is a compile-time constant expression, so the compiler folds it into the literal "hi", and a == b is true (and equals is true). In the earlier example, part + "lo" involved a variable (part was not final), so the concatenation happened at runtime and produced a new object, making == false. This is exactly why you should not rely on == for strings: whether it is true depends on how the string was produced.
Exercise 2
Show solutionHide solution
static String joinWords(List<String> words) { StringBuilder sb = new StringBuilder(); for (String word : words) { if (sb.length() > 0) { sb.append(' '); } sb.append(word); } return sb.toString();}The += version copies the entire string built so far on every iteration, so the total copying grows with the square of the final length. StringBuilder appends into a growing buffer and copies only occasionally. (String.join(" ", words) does the same job in one line and is the best choice here.)
Exercise 3
Show solutionHide solution
static int codePointCount(String text) { return text.codePointCount(0, text.length());}For "A😀B", length() is 4 (the emoji is a surrogate pair of two char values), while codePointCount returns 3. Counting code points is closer to what a person means by "characters", although some visible characters are built from several code points, which is a deeper topic for specialised libraries.
Exercise 4
Show solutionHide solution
LocalDate start = LocalDate.of(2026, 10, 5);long days = ChronoUnit.DAYS.between(start, LocalDate.of(2027, 3, 31)); // 177LocalDate later = start.plusDays(90); // 2027-01-03The days between the two dates are 177, and 90 days after 5 October 2026 is 3 January 2027. Let the library do the calendar arithmetic, because months differ in length, and writing it by hand is the source of many bugs. You can verify the numbers by running the code.
Exercise 5
Show solutionHide solution
Inject a Clock:
class WeekendChecker { private final Clock clock; WeekendChecker(Clock clock) { this.clock = clock; } boolean isWeekend() { return LocalDate.now(clock).getDayOfWeek().getValue() >= 6; }}In production: new WeekendChecker(Clock.systemDefaultZone()). In a test: new WeekendChecker(Clock.fixed(Instant.parse("2026-10-03T10:00:00Z"), ZoneOffset.UTC)) for a Saturday, and a fixed clock on 5 October 2026 for a Monday. The test result no longer depends on the day it runs.
Where to go next
#- Next: Enums, Records and Sealed Types shows how to model fixed sets of values and data precisely.
- For text from and to files: File I/O and Serialization.
- For immutable values in maps: Maps and How HashMap Works.
Sources & further reading
- Technology
- Software Engineering
- #interview preparation
- #Java
- #Java backend
- #Java basics
- #Java interview
- #JPA
- #learn Java
- #modern Java
- #Spring Boot
- #SQL
Connected lessons
References
Linked from 5 guides
Other pages in the library that point here.
Keep reading
More in Technology →Guide · Technology
Java Essentials for DSA
Guide · Technology
Heaps and Priority Queues
Course · Technology
DSA From Zero to Job-Ready: A Practical Guide with Java

