StringBuilderは、Javaで文字列を効率よく組み立てるためのクラスです。変更できないStringと違い、同じインスタンスのまま文字を追加・挿入できるため、ループでの連結やログ・CSVの生成で使われます。この記事では、append・insert・改行・先頭への追加・repeatといった基本操作、StringやStringBufferとの違い、そして「いつ明示的に使うべきか」を、公式のJavadocと実際に動くコードで整理します。速度の差は、JDK 25とJMHで計測した数値で示します。記載の仕様は2026年11月時点のJava 25(LTS)のAPIドキュメントで確認したものです。
まとめ:StringBuilderの要点と、明示して使うべき場面の見分け方
- 役割:可変(mutable)な文字列バッファ。Stringのように変更のたびに新しいオブジェクトを作らない。
java.langパッケージのクラスなのでimportは不要。 - 基本操作:末尾追加は
append、途中や先頭への挿入はinsert、改行はSystem.lineSeparator()か"\n"、同じ文字の繰り返しはJava 21以降のrepeat。appendは戻り値が自身なのでメソッドチェーンできる。 - 速度:ループで1万回連結した計測では、
+=が約59ミリ秒、StringBuilderが約0.21ミリ秒で、約280倍の差が出た。 - StringBufferとの違い:StringBufferは必要な操作を同期してスレッド安全性を確保し、StringBuilderは同期を保証しない。単一スレッドの計測では両者の差は誤差の範囲だったので、選ぶ基準は速度より「複数スレッドで共有するかどうか」。
- 使うべき場面:
+を1つの式で書くだけの連結は、Java 9以降のコンパイラが専用の連結処理に変換し、計測では手書きのStringBuilderより約2倍速かった。ループや条件分岐で文字列を積み上げるときに明示する。 - 落とし穴:
equalsは中身でなく参照を比べる。先頭へのinsert(0, …)を繰り返すと件数の2乗で遅くなる。
以下、仕組み・操作・計測結果・判断基準の順に見ていきます。
StringBuilderの仕組みと、不変のStringで連結を重ねると遅くなる理由
JavaのStringは不変(immutable)です。str += "x"のように見える変更も、内部では毎回新しいStringインスタンスが生成され、それまでの全文字がコピーされます。短い処理なら問題になりませんが、何千回も連結すると生成とコピーが積み重なります。捨てられた中間オブジェクトはヒープに溜まり、Javaのガベージコレクション(GC)の仕組みと種類で解説している回収処理の負担として跳ね返ってきます。
StringBuilderは内部に文字の配列(バッファ)を1つ持ち、それを書き換えながら文字列を伸ばします。最後にtoString()を呼んだ時点で確定したStringを取り出す流れです。StringBuilderのJavadoc(Java 25)には「Since: 1.5」とあり、J2SE 5.0で追加されたクラスです。同じページには、単一スレッドで使うバッファではStringBufferより優先して使うよう推奨する記述もあります。
完全修飾名はjava.lang.StringBuilderです。java.langパッケージは自動でインポートされるため、import文を書かずにそのまま使えます。java.util.StringBuilderというクラスは存在しないので、IDEの補完で別パッケージの同名クラスを取り込んでいないかだけ注意してください。
StringBuilder sb = new StringBuilder(); // import 不要
sb.append("Hello, ").append("World!");
String result = sb.toString(); // "Hello, World!"
内部の文字配列は、Java 9で入ったJEP 254(Compact Strings)以降、中身がLatin-1の範囲に収まる間は1文字1バイトで保持され、日本語などが入るとUTF-16へ切り替わります。利用者がこの違いを意識する必要はありませんが、英数字中心の組み立てではメモリ消費が小さく済みます。
String・StringBuilder・StringBufferの可変性と同期の比較
この3つは可変性とスレッド安全性で役割が分かれます。速度の列は、後述のJMH計測(JDK 25・単一スレッドで1万回連結)の結果です。
| クラス | 可変性 | 同期 | 1万回連結の計測 | 主な用途 |
|---|---|---|---|---|
String(+=) |
不変 | 不要(不変) | 約59ミリ秒 | 変更しない文字列 |
| StringBuilder | 可変 | なし | 約0.21ミリ秒 | 1つのスレッドでの組み立て |
| StringBuffer | 可変 | あり | 約0.24ミリ秒 | 複数スレッドで共有するバッファ |
StringBuilderとStringBufferはAPIがほぼ同じで、どちらも内部実装(package-privateのAbstractStringBuilder)を共有しています。違いは、StringBufferのJavadocにあるとおり、必要な操作が同期され、個々の操作についてスレッド安全性が保証される点です。「StringBufferは同期のぶん遅い」とよく説明されますが、単一スレッドで計測したJDK 25では、StringBuilderとの差は誤差幅に収まりました。したがって選ぶ基準は速度ではなく、1つのインスタンスを複数スレッドから同時に書き換えるかどうかです。スレッドごとにバッファを分けられるなら、StringBuilderで足ります。
仮想スレッドを使う構成では、Java 21〜23でsynchronizedの中でブロックすると仮想スレッドがキャリアスレッドに固定(pinning)される制約がありました。これはJEP 491によりJava 24で解消されています。仮想スレッドと同期処理の関係はJava Virtual Thread(仮想スレッド)の作り方と落とし穴で確認できます。
StringBuilderの使い方をコードで確認する:生成から取り出しまでの基本操作
初期容量16文字の既定値と、容量を指定して再確保を減らす書き方
引数なしのコンストラクタは、初期容量16文字のバッファを確保します。保持する文字列が容量を超えると、内部配列の再確保が必要です。JavadocのensureCapacityの説明では、新しい容量は「要求された最小容量」と「旧容量の2倍+2」の大きいほうと定められています。文字列を渡して初期化した場合の初期容量は「文字列の長さ+16」です。
StringBuilder sb1 = new StringBuilder(); // 初期容量16文字
StringBuilder sb2 = new StringBuilder(1024); // 大きめに確保する
StringBuilder sb3 = new StringBuilder("接頭辞"); // 容量は 3 + 16 = 19
System.out.println(sb1.capacity()); // 16
System.out.println(sb3.capacity()); // 19
sb1.append("12345678901234567"); // 17文字を追加
System.out.println(sb1.capacity()); // 34(16 × 2 + 2)
容量の指定は、効果が大きいとは限りません。1万件の連結で計測したところ、容量を最初から確保した版は約0.20ミリ秒、指定なしは約0.21ミリ秒で、差は誤差幅と重なりました。容量が倍々で増えるため、1万件(38,890文字)を連結しても再確保は12回で済むからです。最終的な長さが数MB単位になる処理や、同じ処理を大量に繰り返す箇所で検討すれば十分です。
appendでの末尾追加とメソッドチェーン、nullとcharの挙動
appendは引数を末尾に追加します。文字列だけでなくint・char・boolean・任意のオブジェクトも受け取り、オブジェクトの場合はString.valueOfと同じ変換結果が連結されます。戻り値は自分自身なので、.append(...)を続けてつなげられます。
StringBuilder sb = new StringBuilder();
sb.append("数量:").append(100).append(true); // "数量:100true"
String s = null;
sb.append(s); // 例外ではなく "null" の4文字が追加される
StringBuilder c = new StringBuilder();
c.append('a' + 1); // "98"(char + int は int になる)
c.append((char) ('a' + 1)); // "b"
注意点は2つあります。1つ目はnullです。append((String) null)はNullPointerExceptionにならず、文字列"null"を連結します。CSVやログに意図せず"null"が混ざる原因になるので、nullになり得る値はObjects.toString(s, "")で既定値を決めてから追加します。2つ目はcharの演算です。'a' + 1はint型の98になり、append(int)が選ばれて数字が追加されます。文字として足したいときは(char)でキャストします。
改行の追加は「\n」とSystem.lineSeparator()を出力先で使い分け
改行はappend("\n")でLF(ラインフィード)を入れられます。実行環境の改行コードに合わせたいならSystem.lineSeparator()を使います。ファイル形式で改行コードが決まっている場合(RFC 4180のCSVはCRLF、多くの設定ファイルはLF)は、実行環境に依存させず固定の文字を書くほうが安全です。C#のAppendLineに当たるメソッドはJava版には無いので、appendで改行文字を足します。
StringBuilder sb = new StringBuilder();
sb.append("1行目").append(System.lineSeparator()); // 実行環境の改行
sb.append("2行目").append('\n'); // 常に LF
sb.append("3行目").append("\r\n"); // 常に CRLF
insertで途中や先頭へ挿入する方法と、範囲外で起きる例外
insertは第1引数のインデックス位置に第2引数を割り込ませます。先頭に追加したいときはinsert(0, …)です。指定位置が0未満か現在の長さを超えるとStringIndexOutOfBoundsExceptionが発生するため、可変長のデータを扱うときは0 <= index && index <= sb.length()を確かめてから呼びます。
StringBuilder sb = new StringBuilder("Hello World");
sb.insert(5, " Java"); // "Hello Java World"
sb.insert(0, '['); // 先頭に追加 → "[Hello Java World"
sb.insert(100, "x"); // 長さを超える位置 → StringIndexOutOfBoundsException
先頭への挿入は1回なら問題ありませんが、ループで繰り返すと遅くなります。理由と代わりの書き方は次の章で計測値とともに説明します。
delete・replace・reverse・setLengthと、末尾の区切り文字の削除
範囲削除のdelete、1文字削除のdeleteCharAt、範囲置換のreplace、反転のreverse、長さを切り詰めるsetLengthは、同じインスタンスを破壊的に変更します。1文字の読み書きはcharAtとsetCharAt、検索はindexOfです。reverseはサロゲートペアの内部順序を保ちます。ただし、結合文字や複数のコードポイントからなる絵文字を、見た目の1文字として保持するわけではありません。各メソッドの正確な仕様はJavadocで確認でき、Javadocの読み方と書き方はJavadocの書き方(コメント・タグの記述例と生成方法)で解説しています。
StringBuilder sb = new StringBuilder("abcdef");
sb.delete(0, 2); // "cdef"(0以上2未満を削除)
sb.replace(0, 1, "C"); // "Cdef"
sb.reverse(); // "fedC"
sb.setLength(0); // 中身を空にする(容量は22のまま保持)
// ループで区切り文字を付け、最後の1文字だけ消す定番の書き方
StringBuilder csv = new StringBuilder();
for (int i = 1; i <= 3; i++) {
csv.append(i).append(',');
}
csv.setLength(csv.length() - 1); // "1,2,3"(deleteCharAt(csv.length() - 1) でも同じ)
末尾の区切り文字を消す書き方は、要素が0件のときlength() - 1が負になり例外が出ます。件数が0になり得るならcsv.length() > 0を確認するか、後述のString.joinやStringJoinerに任せます。
Java 21以降のrepeatによる文字・文字列の繰り返し追加
Java 21ではrepeat(int codePoint, int count)とrepeat(CharSequence cs, int count)が追加されました(Javadocの表記は「Since: 21」)。区切り線やインデント、固定長のパディングをループなしで書けます。countが負の値ならIllegalArgumentExceptionになります。Java 17以前を対象にするコードでは使えないため、ビルドの--release指定とあわせて判断してください。
StringBuilder sb = new StringBuilder();
sb.repeat('-', 10); // "----------"
sb.append('\n').repeat("ab", 3); // 改行のあとに "ababab"
+=・concat・StringBuilder・StringBufferの速度をJMHで計測した結果
「StringBuilderは速い」と言われる差がどの程度なのかを、OpenJDKのベンチマークツールJMH(Java Microbenchmark Harness)で計測しました。環境はTemurin 25.0.4.1(LTS)、Intel Core i9-9880H(macOS)、JMH 1.37です。ループで0からn-1までの整数を連結し、1回あたりの平均時間を比べています(フォーク1回、1秒×3回のウォームアップ後に1秒×5回計測)。
| 書き方 | 100回 | 1,000回 | 10,000回 |
|---|---|---|---|
s += i |
4.8μs | 504μs | 59,281μs |
s = s.concat(…) |
6.8μs | 467μs | 61,382μs |
| StringBuilder | 0.98μs | 16.4μs | 210μs |
| StringBuilder(容量指定) | 0.98μs | 14.8μs | 195μs |
| StringBuffer | 1.04μs | 16.0μs | 238μs |
insert(0, i)で先頭に追加 |
5.3μs | 132μs | 8,766μs |
差が大きいのは件数に対する伸び方です。+=とconcatは件数が10倍になると時間が約70〜130倍に増え、1万回ではStringBuilderの約280倍かかりました。毎回それまでの全文字をコピーするため、処理量が件数の2乗で増えるからです。StringBuilderは件数にほぼ比例して増え、StringBufferや容量指定との差は計測の誤差幅(StringBuilderの1万回で±83μs)に収まりました。insert(0, …)での先頭追加は、追加のたびに既存の文字を後ろへずらすためappendの約42倍かかりました。
先頭へ積み上げる処理が多いなら、要素をいったんArrayListに集めて逆順にappendするか、組み立ての順序そのものを見直すと、appendと同じ線形の処理量に戻せます。
計測コードの中心部分は次のとおりです。自分の環境で比べるときは、System.nanoTime()の差分ではなくJMHを使ってください。JITのウォームアップや不要コードの除去が入るため、単純なループ計測では差が出たり消えたりします。
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@State(Scope.Benchmark)
@Fork(1)
@Warmup(iterations = 3, time = 1)
@Measurement(iterations = 5, time = 1)
public class ConcatBench {
@Param({"100", "1000", "10000"})
int n;
@Benchmark
public String plusEquals() {
String s = "";
for (int i = 0; i < n; i++) s += i;
return s;
}
@Benchmark
public String builder() {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < n; i++) sb.append(i);
return sb.toString();
}
}
+連結とStringBuilderの使い分け:Java 9以降のコンパイラの挙動から判断
「連結は必ずStringBuilderを使う」という説明をよく見かけますが、Java 9以降ではそのまま当てはまりません。Javaマイグレーションの進め方(JDK 8から21/25への手順)で扱う版の更新の中で、連結処理の仕組みが変わっているためです。
単一式の+連結:JEP 280による変換と今回の速度比較
Java 9で導入されたJEP 280(Indify String Concatenation)により、a + b + cのような+連結を、javacはinvokedynamic経由でStringConcatFactoryを呼ぶコードへコンパイルします。Java 8までのように裏でStringBuilderの呼び出し列へ展開するのではなく、実行時のJDKが連結の方法を選ぶ仕組みです。
この差は計測にも表れました。"id=" + id + ", name=" + name + ", active=" + activeという1つの式は1回あたり約66ナノ秒、同じ内容をStringBuilderのappendで手書きした版は約136ナノ秒で、この式と計測条件では手書きのほうが遅くなりました。1つの式で完結する連結は通常+で書き、性能が問題になったときに対象の式で比べます。
ただし、この変換はjavacの出力先が9以上のときだけ働きます。--release 8でコンパイルしたクラスは、新しいJDKで動かしても旧来のStringBuilder展開のままです。Java 8向けの成果物を流用し続けている現場では、再コンパイルまでが移行作業に含まれます。
javapでバイトコードを見て、+連結がどう変換されたかを確かめる手順
変換結果は、JDK付属のjavapで逆アセンブルすると確かめられます。次のクラスをJDK 25でコンパイルしてjavap -cを実行すると、invokedynamicの行にmakeConcatWithConstantsが現れます。--release 8を付けると、new java/lang/StringBuilderとappend・toStringの呼び出しが並びます。
// Concat.java
public class Concat {
static String join(String a, int b) { return a + ":" + b; }
}
// 端末で実行
javac Concat.java && javap -c Concat
// → invokedynamic #7, 0 // InvokeDynamic #0:makeConcatWithConstants:(Ljava/lang/String;I)Ljava/lang/String;
javac --release 8 Concat.java && javap -c Concat
// → new java/lang/StringBuilder、StringBuilder.append を3回、toString
ループ内で文字列を積み上げる処理こそStringBuilderを明示する場面
コンパイラが最適化できるのは「1つの式」の範囲です。ループのように反復ごとに連結が分かれると、各回が独立した連結になり、毎回それまでの全文字をコピーした新しいStringが作られます。前章の計測で1万回に約59ミリ秒かかったのがこのケースで、StringBuilderを明示すべき本命です。条件分岐で文字列を継ぎ足していく処理も同じです。
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 10000; i++) {
sb.append(i).append(',');
}
if (sb.length() > 0) {
sb.setLength(sb.length() - 1); // 末尾のカンマを除く
}
String csv = sb.toString();
区切り連結でのString.joinとCollectors.joiningの選択
区切り文字でつなぐだけなら、StringBuilderを書かずに済みます。固定要素や配列の連結はString.join、コレクションやストリームの連結はCollectors.joiningが読みやすく、Javaラムダ式とStream APIの使いどころで扱う書き方とも相性の良い選択です。接頭辞と接尾辞を付けながら少しずつ足したいときはStringJoinerも使えます。どれも末尾の区切り文字を消す処理が要りません。
String csv = String.join(",", "A", "B", "C"); // "A,B,C"
List<String> list = List.of("x", "y");
String joined = list.stream().collect(Collectors.joining(", ", "[", "]")); // "[x, y]"
// ※ java.util.List と java.util.stream.Collectors の import が必要
1ファイルで実行できるCSV生成とバッファ再利用のサンプル
ここまでの操作を1つのファイルにまとめました。CSV生成部分は、値にカンマ・二重引用符・改行を含まないサンプル専用です。一般のデータでは、これらを含むフィールドを二重引用符で囲み、内部の二重引用符を重ねてエスケープする必要があります。Java 11以降はJEP 330により、事前にjavacを実行せず、java StringBuilderDemo.javaで起動できます。このモードでは起動時にソースがメモリ上へコンパイルされます。repeatを含むためJava 21以降で動かしてください。
import java.util.List;
public class StringBuilderDemo {
public static void main(String[] args) {
List<String[]> rows = List.of(
new String[] {"id", "name", "qty"},
new String[] {"1", "りんご", "3"},
new String[] {"2", "みかん", "12"});
// 1. CSVを組み立てる(RFC 4180に合わせて改行はCRLF)
StringBuilder sb = new StringBuilder(64);
for (String[] row : rows) {
sb.append(String.join(",", row)).append("\r\n");
}
System.out.print(sb);
// 2. setLength(0)で空にし、同じバッファを再利用する
sb.setLength(0);
sb.append("total=").append(15);
sb.insert(0, '[').append(']');
System.out.println(sb); // [total=15]
// 3. Java 21以降のrepeat
sb.setLength(0);
System.out.println(sb.repeat('-', 10)); // ----------
// 4. equalsは参照比較、中身の比較はcompareToかtoString
StringBuilder a = new StringBuilder("java");
StringBuilder b = new StringBuilder("java");
System.out.println(a.equals(b)); // false
System.out.println(a.compareTo(b) == 0); // true
System.out.println(a.toString().equals(b.toString())); // true
}
}
実行すると、3行のCSV、[total=15]、10個のハイフン、false・true・trueの順に出力されます。System.out.print(sb)のようにStringBuilderをそのまま渡せるのは、print(Object)が内部でString.valueOfを呼ぶためです。
StringBuilderで詰まりやすい落とし穴:equals・スレッド共有・文字コード
equalsの参照比較とcompareTo・toStringによる中身の比較
StringBuilderはequalsとhashCodeをオーバーライドしていません。同じ文字列を持つ2つのインスタンスでもequalsはfalseを返し、HashSetの要素にしても中身で重複を判定できません。中身を比べるなら、Java 11で追加されたcompareToが0かどうかを見るか、toString()で確定したString同士を比べます。Javadocにも「自然順序付けがequalsと一致しない」と注意書きがあります。文字列比較の基本はJavaのコード例で学ぶ書き方の基本(文字列比較・命名規則)で整理しています。
複数スレッドで1つのStringBuilderを共有したときに起きる文字列の破損
Javadocは、StringBuilderのインスタンスは複数スレッドでの使用に対して安全ではないと明記しています。並列ストリームや複数の非同期タスクから同じStringBuilderへappendすると、文字の欠落や混在が起き、ArrayIndexOutOfBoundsExceptionが出ることもあります。対処は、スレッドごとにStringBuilderを持って最後に結合する、ストリームならCollectors.joiningに任せる、どうしても共有するならStringBufferに替える、の順で検討します。
バイト列への変換とStandardCharsets.UTF_8による文字コード指定
組み立てた文字列をファイル出力や通信のためにバイト列へ変換するときは、文字コードを明示します。getBytes()を引数なしで呼ぶと既定の文字コードが使われ、その既定値はJavaの版で変わっています。JEP 400によりJava 18以降は既定がUTF-8になりましたが、Java 17以前の日本語Windowsでは既定がMS932(Shift_JIS系)でした。StandardCharsets.UTF_8を明示すれば、版と環境でバイト列が変わる原因を1つ減らせます。
StringBuilder sb = new StringBuilder("こんにちは");
byte[] utf8 = sb.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8); // 15バイト
StringBuilderのC#・.NET版とJava版の違いと検索時の見分け方
「stringbuilder」で検索すると、Javaのjava.lang.StringBuilderと、C#/.NETのSystem.Text.StringBuilderが混在して表示されます。名前と目的(可変な文字列バッファ)は似ていますが、別言語の別クラスで、メソッド名や挙動は一致しません。たとえば.NET版には改行付きで追加するAppendLineや書式付きのAppendFormatがあり、文字列を取り出すのもToString()と大文字始まりです。Java版のappend・insertは小文字始まりで、AppendLineに当たるメソッドはありません。この記事はJavaを対象にしています。C#で同じことをしたい場合は、Microsoft LearnのSystem.Text.StringBuilderクラスを参照してください。
StringBuilderを明示する場面・使わない場面の判断基準
ここまでの内容と計測値を判断の形にまとめます。StringBuilderを明示するのは、(1)ループや再帰で文字列を積み上げる、(2)条件分岐で部分ごとに継ぎ足す、(3)insertやreplaceで途中を書き換える、(4)setLength(0)で同じバッファを使い回したい、のいずれかに当てはまるときです。
逆に、1つの式で終わる連結にStringBuilderは使いません。今回の式では+のほうが約2倍速く、手書きに変更する利点は確認できませんでした。区切り文字で並べるだけならString.join系、複数スレッドから同時に書き込むならスレッドごとのバッファかStringBufferを選びます。性能が問題になったときは、まずJMHで本番と同じ件数・同じ文字種のデータを使って測り、+=のループやinsert(0, …)の繰り返しが残っていないかを探すのが近道です。
既存のJavaシステムで、帳票やCSVの生成が遅い・大量データでメモリが溢れるといった症状が出ているなら、文字列処理だけでなく設計全体を見直す時期かもしれません。一創ではフルスクラッチ開発として、Javaを含む業務システムの新規開発と作り直しを支援しています。メモリ不足の切り分けはJavaヒープのメモリ構造とOutOfMemoryErrorの対処も参考になります。
よくある質問
StringBuilderとStringBufferの違いは何ですか?
APIはほぼ同じで、違いはスレッド安全性です。StringBufferは必要な操作を同期し、個々の操作についてスレッド安全性を保証します。ただし、複数の呼び出しをまとめた処理の原子性や、引数に渡す可変な文字列の保護は、呼び出し側で確保する必要があります。StringBuilderは同期しません。単一スレッドの計測(JDK 25・1万回連結)では両者の速度差は誤差の範囲だったので、1つのスレッドからしか操作しないならStringBuilder、共有するならStringBufferと、共有の有無で選びます。JavadocもStringBuilderを優先して使うよう推奨しています。
StringBuilderで改行するにはどうしますか?
append("\n")でLFを追加できます。実行環境の改行コードに合わせたいならSystem.lineSeparator()をappendします。CSV(RFC 4180ではCRLF)のようにファイル形式で改行コードが決まっている場合は、"\r\n"などを固定で書きます。C#のAppendLineに当たるメソッドはJavaにはありません。
StringBuilderで先頭に文字列を追加するにはどうしますか?
sb.insert(0, "追加する文字列")で先頭に追加できます。1回なら問題ありませんが、ループで繰り返すと追加のたびに既存の文字をずらすため、1万回の計測ではappendの約42倍の時間がかかりました。繰り返し先頭へ足すなら、要素をArrayListに集めて逆順にappendするほうが速くなります。
StringBuilderの初期容量(デフォルト)はいくつですか?
引数なしで生成した場合は16文字、文字列を渡して生成した場合は「その文字列の長さ+16」です。容量を超えると内部配列が「旧容量の2倍+2」以上に再確保されます。new StringBuilder(1024)のように容量を指定すると再確保を減らせますが、1万件の連結の計測では差は誤差幅程度でした。
appendにnullを渡すとどうなりますか?
String型やObject型のnullは文字列"null"(4文字)として追加されます。ただし、append((char[]) null)はNullPointerExceptionになり、型を指定しないappend(null)はオーバーロードが曖昧なためコンパイルできません。意図せず"null"が混入しやすいので、値がnullになり得る箇所ではObjects.toString(値, "")で既定値に置き換えてから追加してください。