DEV Community

Mayur Bodhare
Mayur Bodhare

Posted on

Java Strings Explained: Pool, Builder and Buffer

Strings look like the easiest thing in Java. You write some text in quotes, and you're done. Then one day you compare two strings with == and get true. The next day you compare two that look identical and get false. And somewhere along the way someone tells you, "never concatenate strings in a loop, use StringBuilder," and nobody explains why.

I was confused by all of this for a long time. It only made sense once I saw what Java does with strings in memory. In this post, we'll go through String, the String Constant Pool, StringBuilder and StringBuffer, and I'll explain the reasoning behind each one so you can choose with confidence.

Unless I show a full class, the snippets below go inside a main method.

What is a String?

A String is a sequence of characters, like "Hello". In Java it's not a basic type like int. It's a class, so every string is an object.

There are two common ways to create one:

String a = "Java";              // string literal
String b = new String("Java");  // new keyword
Enter fullscreen mode Exit fullscreen mode

They look almost the same, but they behave differently in memory. That difference is the whole story of this post, so let's look at it.

A few handy methods first

String s = "  Hello, Java  ";

System.out.println(s.trim());
System.out.println(s.trim().length());
System.out.println(s.trim().charAt(0));
System.out.println(s.trim().indexOf("Java"));
System.out.println(s.trim().substring(7));
Enter fullscreen mode Exit fullscreen mode

Output:

Hello, Java
11
H
7
Java
Enter fullscreen mode Exit fullscreen mode

Notice something: I never changed s. I kept calling methods on it and getting new results. That's a clue about the next big idea.

Strings are immutable

Immutable means "cannot be changed after it's created." Once a String object exists, its text can never be modified.

String s = "hello";
s.toUpperCase();
System.out.println(s);

s = s.toUpperCase();
System.out.println(s);
Enter fullscreen mode Exit fullscreen mode

Output:

hello
HELLO
Enter fullscreen mode Exit fullscreen mode

The first toUpperCase() didn't change s. It created a new string "HELLO" and returned it, and we threw it away. In the second case, we caught the new string by assigning it back to s.

Here is what really happens when you "change" a string:

String s = "Java";
s = s + "!";
Enter fullscreen mode Exit fullscreen mode
  Before:   s ---> [ "Java" ]

  After:    s ---> [ "Java!" ]      (a brand new object)
            [ "Java" ] is still there, but nobody points to it
            (the garbage collector can clean it up later)
Enter fullscreen mode Exit fullscreen mode

You never edited "Java". You built a new object and moved the reference.

Why did Java make strings immutable?

It seems annoying at first, but there are good reasons:

  • Sharing is safe. As you'll see in a moment, Java reuses the same string object in many places. That's only safe if nobody can change it.
  • Thread safety. Many threads can read the same string with no risk, because it never changes.
  • Security. Strings hold things like file paths, usernames and class names. If they could change after being checked, that would be a hole.
  • Speed for hashing. A string's hash code is calculated once and stored. This makes strings fast to use as keys in a HashMap, which only works if the text can't change.

The String Constant Pool

Now the interesting part. Strings are used everywhere, and many of them repeat. If a program uses "Java" a thousand times, creating a thousand separate objects would waste memory.

So Java keeps a special area inside the heap called the String Constant Pool (also called the string pool). It's a collection of unique string objects. When you write a string literal, Java first checks the pool:

  • If that text is already there, Java reuses the existing object.
  • If not, Java creates it in the pool.
String s1 = "Java";
String s2 = "Java";
String s3 = new String("Java");

System.out.println(s1 == s2);
System.out.println(s1 == s3);
System.out.println(s1.equals(s3));
Enter fullscreen mode Exit fullscreen mode

Output:

true
false
true
Enter fullscreen mode Exit fullscreen mode

Here's the picture:

  STACK                  HEAP
 +--------+      +-------------------------------+
 | s1   --|--+   |  STRING CONSTANT POOL         |
 | s2   --|--+-->|     "Java"                    |
 +--------+      |                               |
 | s3   --|----->|  REGULAR HEAP AREA            |
 +--------+      |     String object "Java"      |
                 +-------------------------------+
Enter fullscreen mode Exit fullscreen mode
  • s1 and s2 point to the same pooled object, so s1 == s2 is true.
  • s3 was made with new, which always creates a fresh object outside the pool, so s1 == s3 is false.
  • But all three contain the same text, so equals says true.

== vs .equals()

This is where the pool explains the confusing behavior:

  • == asks, "Are these the same object?"
  • .equals() asks, "Do these have the same text?"

Almost always, you want .equals(). The reason == sometimes gives true is just the pool reusing an object. It's a side effect, and not something to rely on.

How many objects does new String("Java") create?

Up to two. If "Java" isn't in the pool yet, the literal inside the brackets creates one pooled object. Then new creates a second, separate object on the heap. This is why writing new String("...") is almost never a good idea. It wastes memory and gives you nothing.

intern(): pulling a string into the pool

If you ever need the pooled version of a string, intern() does that:

String s1 = "Java";
String s3 = new String("Java");

System.out.println(s1 == s3);
System.out.println(s1 == s3.intern());
Enter fullscreen mode Exit fullscreen mode

Output:

false
true
Enter fullscreen mode Exit fullscreen mode

intern() returns the pool's object for that text, adding it first if needed. You'll rarely need this day to day, but it helps to understand how the pool works.

Strings built at run time are not pooled

String a = "Ja";
String b = a + "va";

System.out.println(b == "Java");
System.out.println(b.equals("Java"));
System.out.println("Ja" + "va" == "Java");
Enter fullscreen mode Exit fullscreen mode

Output:

false
true
true
Enter fullscreen mode Exit fullscreen mode

Why the difference? In the last line, both parts are literals, so the compiler joins them while compiling and stores "Java" in the pool. In a + "va", the value of a is only known when the program runs, so the result is a new object. Another reason never to trust == on strings.

The problem with changing strings a lot

Since strings are immutable, every "change" creates a new object. For one or two changes, that's fine. In a loop, it adds up:

String result = "";
for (int i = 1; i <= 5; i++) {
    result += i;
}
System.out.println(result);
Enter fullscreen mode Exit fullscreen mode

Output:

12345
Enter fullscreen mode Exit fullscreen mode

The output is right, but look at what happened behind the scenes. Each round built a brand new string and left the old one behind:

"" -> "1" -> "12" -> "123" -> "1234" -> "12345"
Enter fullscreen mode Exit fullscreen mode

That's 6 string objects for one result. Each new string also copies all the old characters. With 5 items it doesn't matter, but with 100,000 items it can become painfully slow and put a lot of pressure on memory.

We need something we can change in place. That's where the next two classes come in.

StringBuilder: the mutable string

StringBuilder holds text that you can change without creating new objects each time. Inside, it keeps a resizable block of characters and edits it directly.

StringBuilder sb = new StringBuilder("Java");
sb.append(" is");
sb.append(" fun");
sb.insert(0, "Learn ");

System.out.println(sb);
Enter fullscreen mode Exit fullscreen mode

Output:

Learn Java is fun
Enter fullscreen mode Exit fullscreen mode

All of those changes happened to one object. A few more useful methods:

sb.reverse();
System.out.println(sb);

sb.reverse();
sb.deleteCharAt(0);
System.out.println(sb);

sb.replace(0, 5, "Study");
System.out.println(sb);
Enter fullscreen mode Exit fullscreen mode

Output:

nuf si avaJ nraeL
earn Java is fun
Study Java is fun
Enter fullscreen mode Exit fullscreen mode

You can also chain calls, because append returns the same builder:

String text = new StringBuilder()
        .append("Hello")
        .append(", ")
        .append("world")
        .toString();
Enter fullscreen mode Exit fullscreen mode

When you're done editing, call toString() to get a normal String.

Now the loop from before, done properly:

StringBuilder sb = new StringBuilder();
for (int i = 1; i <= 5; i++) {
    sb.append(i);
}
System.out.println(sb);
Enter fullscreen mode Exit fullscreen mode

Output:

12345
Enter fullscreen mode Exit fullscreen mode

Same result, but only one builder object is doing all the work.

Behind the scenes: capacity

A StringBuilder keeps a character array that's usually bigger than the text inside it. That extra room is called capacity. When the text outgrows it, the builder makes a bigger array (roughly double) and copies the old content over.

StringBuilder sb = new StringBuilder();
System.out.println(sb.length());
System.out.println(sb.capacity());

sb.append("12345678901234567");   // 17 characters
System.out.println(sb.capacity());
Enter fullscreen mode Exit fullscreen mode

Output on a typical JDK:

0
16
34
Enter fullscreen mode Exit fullscreen mode

It starts with room for 16 characters. When we added 17, it grew to 34. Growing in big jumps means most append calls are just writing into free space, which is why it's fast. If you know roughly how big the final text will be, you can pass that number, like new StringBuilder(1000), to avoid regrowing.

StringBuffer: the thread-safe twin

StringBuffer looks almost identical to StringBuilder. It has the same methods (append, insert, reverse and so on):

StringBuffer buf = new StringBuffer("Hi");
buf.append(" there");
System.out.println(buf);
Enter fullscreen mode Exit fullscreen mode

Output:

Hi there
Enter fullscreen mode Exit fullscreen mode

The one difference is that its methods are synchronized. That means only one thread can run a method on the object at a time, so two threads can't mess up each other's changes.

That safety isn't free. Locking adds a bit of overhead, so StringBuffer is slower than StringBuilder.

Here's an important point: most of the time, you don't need that safety. A StringBuilder created inside a method is a local variable, and no other thread can see it. So the lock would protect you from nothing. StringBuffer is the older class (it has been there since Java's first version). StringBuilder was added later, in Java 5, exactly because people wanted the same thing without the locking cost.

Which one should I use?

Here's how I think about it:

  • Text that doesn't change, or changes rarely: use String. It's simple, safe to share and works well as a map key.
  • Building or editing text in a loop, or in many steps: use StringBuilder. This is the default choice for building text.
  • Building text that several threads change at the same time: use StringBuffer. This is rare, and in modern code people often reach for other tools instead.
  • Joining a list of items with a separator: use String.join(", ", list). It's short and clear.
  • A single line like "Hello " + name: plain + is fine. The compiler handles simple one-line joins well, so you don't need a builder for them.

A quick summary of the three classes:

  • String: immutable, thread-safe (because it can't change), pooled when written as a literal.
  • StringBuilder: mutable, not thread-safe, fastest for building text.
  • StringBuffer: mutable, thread-safe through locking, a bit slower.

Common mistakes

  1. Comparing strings with ==. It checks if two variables point to the same object, not if the text matches. Use .equals(). A nice habit is writing "Java".equals(name), because it won't crash with a NullPointerException if name is null.
  2. Ignoring the return value. s.toUpperCase() or s.trim() on its own does nothing to s. You must assign the result: s = s.trim();.
  3. Joining strings with += inside big loops. It creates a new object every round. Use StringBuilder instead.
  4. Using StringBuffer by default. It's safe, but you pay for locking you probably don't need. Start with StringBuilder.
  5. Comparing two builders with equals. StringBuilder and StringBuffer don't override equals, so it checks for the same object, not the same text. Compare with sb1.toString().equals(sb2.toString()).
  6. Writing new String("text"). It makes an extra object for no benefit. Just write "text".

Quick recap

  • String is a class, and its objects are immutable. Every "change" makes a new object.
  • Literals live in the String Constant Pool inside the heap, and identical literals share one object.
  • new String("...") always creates a separate object outside the pool.
  • == compares objects, .equals() compares text. Use .equals() for strings.
  • StringBuilder is a mutable string that grows in place, and it's the best choice for building text.
  • StringBuffer is the same idea with synchronized methods, so it's thread-safe but slower.

Try it yourself

Here's a small practice idea: write a method that reverses a string in two ways. First, use a loop with +=. Second, use StringBuilder. Then try it on a string with 100,000 characters and measure the time with System.currentTimeMillis(). Seeing the difference with your own eyes makes the lesson stick.

For a bonus experiment, create String s1 = "Java", String s2 = new String("Java"), and String s3 = s2.intern(). Predict the result of every == between them before you run the code.

If this helped, or if you spotted something I should fix, tell me in the comments. I'm still learning too.

Top comments (0)