Skip to content
SparkTime, home
Java From Zero to Job-Ready
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
8 · Databases and JDBC
9 · Web and Spring
10 · Persistence with JPA
11 · Secure, Tested, Production-Ready
12 · Interviews and Readiness
Lesson 8 of 41

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.

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 StringBuilder exists, and how it relates to StringBuffer.
  • How Unicode complicates "length".
  • Text blocks and formatting.
  • The java.time API, 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.

Java
// 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"));    }}
Code
[  Asha Rao  ][Asha Rao][ASHA RAO]6 n 2 anatrue true bonono2026-10-05ababab true true4true -1

The 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 HashMap key (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.

Java
// 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));    }}
Code
truefalsetruetruefalsetrue

Literals 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:

Java
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.

Java
// 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")));    }}
Code
1,2,3,4,55,4,3,2,1Hello, Java![a, b, c]x | y | z

When 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.

String

StringBuilder

StringBuffer

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.

Java
// 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();    }}
Code
43true41 1f600 42

The 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.

Java
// 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));    }}
Code
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

LocalDate

A date without time or zone

A birthday, an application date

LocalTime

A time of day without a date or zone

Opening hours

LocalDateTime

A date and time without a zone

A meeting, as written on a calendar

Instant

A point on the timeline (UTC)

A timestamp for when something happened

ZonedDateTime

A date, time and time zone

A flight departure in a specific city

Duration

An amount of time in seconds and nanoseconds

Time between two instants

Period

An amount of time in years, months, days

The gap between two dates

Java
// 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");    }}
Code
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 London

Notice 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

#
  1. 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 a LocalDate, because it has no time zone.
  2. Never subtract dates yourself with arithmetic. Use ChronoUnit, Period or Duration.
  3. 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.
  4. Do not use LocalDateTime for 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.

Java
// 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)));    }}
Code
truefalsefalse

In 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, toUpperCase and other methods.
  • Concatenating in a loop instead of using StringBuilder.
  • Assuming length() equals the number of visible characters.
  • Using split with a special character such as . or | without escaping.
  • Formatting numbers without a locale when the output must be machine-readable.
  • Using Date, Calendar or SimpleDateFormat in new code.
  • Calling now() everywhere, which makes code untestable.
  • Using LocalDateTime for timestamps instead of Instant.

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
Java
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
Java
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
Java
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-03

The 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:

Java
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

#

Sources & further reading

  1. docs.oracle.com
  2. openjdk.org
  3. docs.oracle.com
  • 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