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
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));
Output:
Hello, Java
11
H
7
Java
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);
Output:
hello
HELLO
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 + "!";
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)
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));
Output:
true
false
true
Here's the picture:
STACK HEAP
+--------+ +-------------------------------+
| s1 --|--+ | STRING CONSTANT POOL |
| s2 --|--+-->| "Java" |
+--------+ | |
| s3 --|----->| REGULAR HEAP AREA |
+--------+ | String object "Java" |
+-------------------------------+
-
s1ands2point to the same pooled object, sos1 == s2istrue. -
s3was made withnew, which always creates a fresh object outside the pool, sos1 == s3isfalse. - But all three contain the same text, so
equalssaystrue.
== 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());
Output:
false
true
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");
Output:
false
true
true
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);
Output:
12345
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"
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);
Output:
Learn Java is fun
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);
Output:
nuf si avaJ nraeL
earn Java is fun
Study Java is fun
You can also chain calls, because append returns the same builder:
String text = new StringBuilder()
.append("Hello")
.append(", ")
.append("world")
.toString();
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);
Output:
12345
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());
Output on a typical JDK:
0
16
34
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);
Output:
Hi there
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
-
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 aNullPointerExceptionifnameisnull. -
Ignoring the return value.
s.toUpperCase()ors.trim()on its own does nothing tos. You must assign the result:s = s.trim();. -
Joining strings with
+=inside big loops. It creates a new object every round. UseStringBuilderinstead. -
Using
StringBufferby default. It's safe, but you pay for locking you probably don't need. Start withStringBuilder. -
Comparing two builders with
equals.StringBuilderandStringBufferdon't overrideequals, so it checks for the same object, not the same text. Compare withsb1.toString().equals(sb2.toString()). -
Writing
new String("text"). It makes an extra object for no benefit. Just write"text".
Quick recap
-
Stringis 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. -
StringBuilderis a mutable string that grows in place, and it's the best choice for building text. -
StringBufferis 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)