diff --git a/README.md b/README.md index b4de231..f7e1413 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ article; each module's own README has that article's version table, quickstart, | [`exceptions`](exceptions/) | Java Exception Handling Deep Dive: Checked vs Unchecked, Suppression, and What Exceptions Actually Cost | | [`regex`](regex/) | Java Regex Tutorial: Pattern, Matcher, Groups, Lookarounds and ReDoS | | [`serialization`](serialization/) | Java Serialization in 2026: Why It's Dangerous and What Replaced It | +| [`strings`](strings/) | Java Strings Deep Dive: Interning, StringBuilder, Text Blocks, and the Concatenation Benchmark | ## License diff --git a/pom.xml b/pom.xml index 1ef3162..0ebe821 100644 --- a/pom.xml +++ b/pom.xml @@ -25,6 +25,7 @@ exceptions regex serialization + strings diff --git a/strings/README.md b/strings/README.md new file mode 100644 index 0000000..bfe138f --- /dev/null +++ b/strings/README.md @@ -0,0 +1,41 @@ +# strings + +Companion code for the ankurm.com post *"Java Strings Deep Dive: Interning, StringBuilder, Text +Blocks, and the Concatenation Benchmark."* Module `strings` in `java-core-examples`. + +All explanation lives in the post; this module holds the runnable evidence and the captured output. + +## Versions + +| Component | Version | +|---|---| +| JDK | 25.0.4.1+1 (Temurin, LTS) | +| JMH | 1.37 | +| JUnit Jupiter | 5.11.0 | +| Hardware | 2 vCPU x86-64 VM, shared with other jobs while measuring (timings are indicative, not a leaderboard) | + +## Quickstart + +```bash +export JDK25_HOME=/path/to/jdk-25 +./scripts/run-all.sh # rebuilds and regenerates everything in output/ +``` + +`run-all.sh` ends with the JMH run, which takes a few minutes. JMH refuses to start if another JMH +process holds `/tmp/jmh.lock`; wait for it rather than forcing it, or the numbers are worthless. + +## What is in here + +| File | Shows | Output | +|---|---|---| +| `StringPoolDemo` | `==` vs `equals`, constant folding, runtime concatenation, `intern()` (including the already-pooled `"java"` surprise) | `01` | +| `TextBlockDemo` | closing-delimiter position, indentation stripping, trailing spaces, `\s`, `\`, escape timing, `formatted()` | `02` | +| `ConcatLoopDemo` | `+=` in a loop allocates a new String each time; characters copied grows quadratically | `03` | +| `ConcatShapes` | the same `+` expression compiled two ways; `javap` shows `invokedynamic` vs `StringBuilder` | `04`, `05` | +| `src/broken/StringTemplate.java` | `STR."..."` is a compile error on JDK 25, with and without `--enable-preview` | `06` | +| `run-all.sh` flags step | `StringTableSize`, `CompactStrings`, `OptimizeStringConcat`, `UseStringDeduplication` defaults | `07` | +| `ConcatBenchmark`, `LoopConcatBenchmark` | JMH: `+` vs `StringBuilder` vs `String.format`; Latin-1 vs UTF-16; `+=` loop vs builder at 100/1000/10000 | `08` | +| `StringBehaviourTest` | 8 assertions behind the claims above | `09` | + +`ConcatBenchmark` uses 2 forks; `LoopConcatBenchmark` uses 1 fork; both 3 warmup and 5 measurement +iterations of 1 s. Re-running moves the numbers by tens of percent but not the ordering. diff --git a/strings/output/01-string-pool.txt b/strings/output/01-string-pool.txt new file mode 100644 index 0000000..dda8318 --- /dev/null +++ b/strings/output/01-string-pool.txt @@ -0,0 +1,9 @@ +literal == literal : true +literal == new String : false +literal.equals(new String) : true +literal == c.intern() : true +constant + literal == "hello" : true +runtime concat == "hello" : false +runtime concat.intern() == "hello": true +built "java".intern() == the built instance : false +built unseen string .intern() == the built instance: true diff --git a/strings/output/02-text-blocks.txt b/strings/output/02-text-blocks.txt new file mode 100644 index 0000000..002118e --- /dev/null +++ b/strings/output/02-text-blocks.txt @@ -0,0 +1,30 @@ +--- closing delimiter on its own line, aligned with the text --- +{\n +.."id":.1\n +}\n + +--- closing delimiter right after the last line (no final newline) --- +{\n +.."id":.1\n +} +--- closing delimiter 2 columns left of the text: 2 spaces of indentation survive --- +..left\n + +--- trailing spaces are stripped, but \s and \040 survive --- +x....\n +y.\n + +--- backslash at end of line joins the lines --- +one.line\n + +--- escapes are processed after indentation is stripped --- +tab: |\n + +--- double quotes need no escape; only a run of three does --- +say."hi".and."""\n + +two identical text blocks are the same pooled instance: true +equals the hand-written literal: true +--- formatted() is the built-in way to interpolate --- +Hello,.Ann!.You.are.30.\n + diff --git a/strings/output/03-concat-loop.txt b/strings/output/03-concat-loop.txt new file mode 100644 index 0000000..813149c --- /dev/null +++ b/strings/output/03-concat-loop.txt @@ -0,0 +1,7 @@ +iteration 0: length=1 value=0 +iteration 1: length=2 value=01 +iteration 2: length=3 value=012 +iteration 3: length=4 value=0123 +iteration 4: length=5 value=01234 +characters copied in total across the loop results: 15 +n=20000: final length=20000, characters in all intermediate strings=200010000 diff --git a/strings/output/04-javap-indy-vs-inline.txt b/strings/output/04-javap-indy-vs-inline.txt new file mode 100644 index 0000000..b94c707 --- /dev/null +++ b/strings/output/04-javap-indy-vs-inline.txt @@ -0,0 +1,25 @@ +$ javac ConcatShapes.java && javap -c -p ConcatShapes (method one) + public static java.lang.String one(java.lang.String, int); + Code: + 0: aload_0 + 1: iload_1 + 2: invokedynamic #7, 0 // InvokeDynamic #0:makeConcatWithConstants:(Ljava/lang/String;I)Ljava/lang/String; + 7: areturn + +$ javac -XDstringConcat=inline ConcatShapes.java && javap -c -p ConcatShapes (method one) + public static java.lang.String one(java.lang.String, int); + Code: + 0: new #7 // class java/lang/StringBuilder + 3: dup + 4: invokespecial #9 // Method java/lang/StringBuilder."":()V + 7: ldc #10 // String user= + 9: invokevirtual #12 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder; + 12: aload_0 + 13: invokevirtual #12 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder; + 16: ldc #16 // String n= + 18: invokevirtual #12 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder; + 21: iload_1 + 22: invokevirtual #18 // Method java/lang/StringBuilder.append:(I)Ljava/lang/StringBuilder; + 25: invokevirtual #21 // Method java/lang/StringBuilder.toString:()Ljava/lang/String; + 28: areturn + diff --git a/strings/output/05-bootstrap-method.txt b/strings/output/05-bootstrap-method.txt new file mode 100644 index 0000000..6d5f711 --- /dev/null +++ b/strings/output/05-bootstrap-method.txt @@ -0,0 +1,6 @@ +$ javap -c -v ConcatShapes | grep -A3 BootstrapMethods +BootstrapMethods: + 0: #39 REF_invokeStatic java/lang/invoke/StringConcatFactory.makeConcatWithConstants:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/String;[Ljava/lang/Object;)Ljava/lang/invoke/CallSite; + Method arguments: + #37 user=\u0001 n=\u0001 +InnerClasses: diff --git a/strings/output/06-string-template-compile-error.txt b/strings/output/06-string-template-compile-error.txt new file mode 100644 index 0000000..f401e00 --- /dev/null +++ b/strings/output/06-string-template-compile-error.txt @@ -0,0 +1,11 @@ +$ javac StringTemplate.java +src/broken/StringTemplate.java:4: error: illegal escape character + System.out.println(STR."Hello, \{name}"); + ^ +1 error + +$ javac --enable-preview --release 25 StringTemplate.java +src/broken/StringTemplate.java:4: error: illegal escape character + System.out.println(STR."Hello, \{name}"); + ^ +1 error diff --git a/strings/output/07-jvm-flags.txt b/strings/output/07-jvm-flags.txt new file mode 100644 index 0000000..266d97d --- /dev/null +++ b/strings/output/07-jvm-flags.txt @@ -0,0 +1,4 @@ + bool CompactStrings = true {pd product} {default} + bool OptimizeStringConcat = true {C2 product} {default} + uintx StringTableSize = 65536 {product} {default} + bool UseStringDeduplication = false {product} {default} diff --git a/strings/output/08-jmh-raw.txt b/strings/output/08-jmh-raw.txt new file mode 100644 index 0000000..6ae47de --- /dev/null +++ b/strings/output/08-jmh-raw.txt @@ -0,0 +1,16 @@ +Benchmark (loop) Mode Cnt Score Error Units +ConcatBenchmark.explicitBuilder N/A avgt 10 20.838 ± 2.629 ns/op +ConcatBenchmark.formattedMethod N/A avgt 10 164.278 ± 12.164 ns/op +ConcatBenchmark.plusExpression N/A avgt 10 20.448 ± 7.264 ns/op +ConcatBenchmark.plusLatin1 N/A avgt 10 15.791 ± 4.953 ns/op +ConcatBenchmark.plusUtf16 N/A avgt 10 23.185 ± 4.275 ns/op +ConcatBenchmark.stringFormat N/A avgt 10 285.885 ± 133.980 ns/op +LoopConcatBenchmark.loopBuilder 100 avgt 5 270.251 ± 128.322 ns/op +LoopConcatBenchmark.loopBuilder 1000 avgt 5 2268.140 ± 888.134 ns/op +LoopConcatBenchmark.loopBuilder 10000 avgt 5 24468.200 ± 8712.080 ns/op +LoopConcatBenchmark.loopBuilderPresized 100 avgt 5 194.101 ± 168.069 ns/op +LoopConcatBenchmark.loopBuilderPresized 1000 avgt 5 1682.224 ± 135.261 ns/op +LoopConcatBenchmark.loopBuilderPresized 10000 avgt 5 20622.063 ± 16702.078 ns/op +LoopConcatBenchmark.loopPlusEquals 100 avgt 5 2021.521 ± 648.577 ns/op +LoopConcatBenchmark.loopPlusEquals 1000 avgt 5 73764.547 ± 11968.016 ns/op +LoopConcatBenchmark.loopPlusEquals 10000 avgt 5 8939940.357 ± 2388632.875 ns/op diff --git a/strings/output/09-tests.txt b/strings/output/09-tests.txt new file mode 100644 index 0000000..444f900 --- /dev/null +++ b/strings/output/09-tests.txt @@ -0,0 +1,4 @@ +------------------------------------------------------------------------------- +Test set: com.ankurm.strings.StringBehaviourTest +------------------------------------------------------------------------------- +Tests run: 8, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.252 s -- in com.ankurm.strings.StringBehaviourTest diff --git a/strings/pom.xml b/strings/pom.xml new file mode 100644 index 0000000..c66d046 --- /dev/null +++ b/strings/pom.xml @@ -0,0 +1,88 @@ + + + 4.0.0 + + + com.ankurm + java-core-examples + 1.0 + + + strings + strings + Java Strings: the string pool and intern(), invokedynamic concatenation vs StringBuilder (JMH), text blocks, and why string templates were withdrawn. + + + 1.37 + + + + + org.openjdk.jmh + jmh-core + ${jmh.version} + + + org.openjdk.jmh + jmh-generator-annprocess + ${jmh.version} + + + org.junit.jupiter + junit-jupiter + 5.11.0 + test + + + + + benchmarks + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + 25 + + + + org.openjdk.jmh + jmh-generator-annprocess + ${jmh.version} + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.2.5 + + + org.apache.maven.plugins + maven-shade-plugin + 3.5.1 + + + package + shade + + + + org.openjdk.jmh.Main + + + + + + + + + diff --git a/strings/scripts/run-all.sh b/strings/scripts/run-all.sh new file mode 100755 index 0000000..e842034 --- /dev/null +++ b/strings/scripts/run-all.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# Regenerates every file in ../output/. Requires JDK25_HOME. +set -euo pipefail +[[ -z "${JDK25_HOME:-}" ]] && { echo "JDK25_HOME must be set" >&2; exit 1; } +cd "$(dirname "$0")/.." +OUT=output; mkdir -p "$OUT" +export JAVA_HOME="$JDK25_HOME" +mvn -q -f ../pom.xml -pl strings -am package +J="$JDK25_HOME/bin/java" +run() { echo "==> $1"; "$J" -cp target/classes "com.ankurm.strings.$1" 2>&1 | grep -v "Picked up" > "$OUT/$2"; } +run StringPoolDemo 01-string-pool.txt +run TextBlockDemo 02-text-blocks.txt +run ConcatLoopDemo 03-concat-loop.txt + +echo "==> javap: indy vs inline StringBuilder" +SRC=src/main/java/com/ankurm/strings/ConcatShapes.java +rm -rf /tmp/cs-indy /tmp/cs-inline +"$JDK25_HOME/bin/javac" -d /tmp/cs-indy "$SRC" 2>&1 | grep -v "Picked up" || true +"$JDK25_HOME/bin/javac" -XDstringConcat=inline -d /tmp/cs-inline "$SRC" 2>&1 | grep -v "Picked up" || true +{ echo '$ javac ConcatShapes.java && javap -c -p ConcatShapes (method one)' + "$JDK25_HOME/bin/javap" -c -p -cp /tmp/cs-indy com.ankurm.strings.ConcatShapes 2>&1 | grep -v "Picked up" | sed -n '/String one/,/^$/p' + echo '$ javac -XDstringConcat=inline ConcatShapes.java && javap -c -p ConcatShapes (method one)' + "$JDK25_HOME/bin/javap" -c -p -cp /tmp/cs-inline com.ankurm.strings.ConcatShapes 2>&1 | grep -v "Picked up" | sed -n '/String one/,/^$/p' +} > "$OUT/04-javap-indy-vs-inline.txt" +{ echo '$ javap -c -v ConcatShapes | grep -A3 BootstrapMethods' + "$JDK25_HOME/bin/javap" -v -p -cp /tmp/cs-indy com.ankurm.strings.ConcatShapes 2>&1 | grep -v "Picked up" | grep -A4 '^BootstrapMethods' +} > "$OUT/05-bootstrap-method.txt" + +echo "==> StringTemplate (expected compile failures)" +{ echo '$ javac StringTemplate.java' + "$JDK25_HOME/bin/javac" -d /tmp/lc src/broken/StringTemplate.java 2>&1 | grep -v "Picked up" || true + echo; echo '$ javac --enable-preview --release 25 StringTemplate.java' + "$JDK25_HOME/bin/javac" --enable-preview --release 25 -d /tmp/lc src/broken/StringTemplate.java 2>&1 | grep -v "Picked up" || true +} > "$OUT/06-string-template-compile-error.txt" + +echo "==> JVM string flags" +"$J" -XX:+PrintFlagsFinal -version 2>/dev/null | grep -E " (StringTableSize|UseStringDeduplication|CompactStrings|OptimizeStringConcat) " | sed 's/ */ /g' > "$OUT/07-jvm-flags.txt" + +echo "==> JMH" +"$J" -jar target/benchmarks.jar 'ConcatBenchmark|LoopConcatBenchmark' -rf text -rff "$OUT/08-jmh-raw.txt" 2>&1 | grep -v "Picked up" > /dev/null +cp target/surefire-reports/com.ankurm.strings.StringBehaviourTest.txt "$OUT/09-tests.txt" +echo Done diff --git a/strings/src/broken/StringTemplate.java b/strings/src/broken/StringTemplate.java new file mode 100644 index 0000000..30a4396 --- /dev/null +++ b/strings/src/broken/StringTemplate.java @@ -0,0 +1,6 @@ +public class StringTemplate { + public static void main(String[] args) { + String name = "Ann"; + System.out.println(STR."Hello, \{name}"); + } +} diff --git a/strings/src/main/java/com/ankurm/strings/ConcatBenchmark.java b/strings/src/main/java/com/ankurm/strings/ConcatBenchmark.java new file mode 100644 index 0000000..0fd4f9a --- /dev/null +++ b/strings/src/main/java/com/ankurm/strings/ConcatBenchmark.java @@ -0,0 +1,35 @@ +package com.ankurm.strings; + +import org.openjdk.jmh.annotations.*; +import org.openjdk.jmh.infra.Blackhole; +import java.util.concurrent.TimeUnit; + +/** + * Does an explicit StringBuilder beat a single + expression (compiled to invokedynamic)? + * Also: String.format / formatted() for comparison, and Latin-1 vs UTF-16 operands. + */ +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.NANOSECONDS) +@Warmup(iterations = 3, time = 1) +@Measurement(iterations = 5, time = 1) +@Fork(2) +@State(Scope.Thread) +public class ConcatBenchmark { + + public String name = "ann"; + public int n = 12345; + public String latin = "abc"; + public String wide = "abc\u20ac"; // contains a char outside Latin-1 -> UTF16 coder + + // ---- single expression ------------------------------------------------------------- + @Benchmark public String plusExpression() { return "user=" + name + " n=" + n + " ok"; } + @Benchmark public String explicitBuilder() { + return new StringBuilder().append("user=").append(name).append(" n=").append(n).append(" ok").toString(); + } + @Benchmark public String stringFormat() { return String.format("user=%s n=%d ok", name, n); } + @Benchmark public String formattedMethod() { return "user=%s n=%d ok".formatted(name, n); } + + // ---- Latin-1 vs UTF16 operands ----------------------------------------------------- + @Benchmark public String plusLatin1() { return latin + latin + latin; } + @Benchmark public String plusUtf16() { return wide + wide + wide; } +} diff --git a/strings/src/main/java/com/ankurm/strings/ConcatLoopDemo.java b/strings/src/main/java/com/ankurm/strings/ConcatLoopDemo.java new file mode 100644 index 0000000..48ff94a --- /dev/null +++ b/strings/src/main/java/com/ankurm/strings/ConcatLoopDemo.java @@ -0,0 +1,21 @@ +package com.ankurm.strings; + +/** Counts how many intermediate String objects += in a loop creates (observed via identity of each result). */ +public class ConcatLoopDemo { + public static void main(String[] args) { + int n = 5; + String s = ""; + long chars = 0; + for (int i = 0; i < n; i++) { + s += i; // allocates a brand-new String every iteration + chars += s.length(); + System.out.println("iteration " + i + ": length=" + s.length() + " value=" + s); + } + System.out.println("characters copied in total across the loop results: " + chars); + int big = 20_000; + long total = 0; + String t = ""; + for (int i = 0; i < big; i++) { t += 'x'; total += t.length(); } + System.out.println("n=" + big + ": final length=" + t.length() + ", characters in all intermediate strings=" + total); + } +} diff --git a/strings/src/main/java/com/ankurm/strings/ConcatShapes.java b/strings/src/main/java/com/ankurm/strings/ConcatShapes.java new file mode 100644 index 0000000..66fc1f3 --- /dev/null +++ b/strings/src/main/java/com/ankurm/strings/ConcatShapes.java @@ -0,0 +1,11 @@ +package com.ankurm.strings; + +/** Source whose bytecode is inspected with javap, compiled twice: default (invokedynamic) and -XDstringConcat=inline. */ +public class ConcatShapes { + public static String one(String name, int n) { + return "user=" + name + " n=" + n; + } + public static void main(String[] args) { + System.out.println(one("ann", 3)); + } +} diff --git a/strings/src/main/java/com/ankurm/strings/LoopConcatBenchmark.java b/strings/src/main/java/com/ankurm/strings/LoopConcatBenchmark.java new file mode 100644 index 0000000..36f7d87 --- /dev/null +++ b/strings/src/main/java/com/ankurm/strings/LoopConcatBenchmark.java @@ -0,0 +1,33 @@ +package com.ankurm.strings; + +import org.openjdk.jmh.annotations.*; +import java.util.concurrent.TimeUnit; + +/** What += in a loop costs as the loop grows, compared with one StringBuilder (plain and presized). */ +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.NANOSECONDS) +@Warmup(iterations = 3, time = 1) +@Measurement(iterations = 5, time = 1) +@Fork(1) +@State(Scope.Thread) +public class LoopConcatBenchmark { + + @Param({"100", "1000", "10000"}) + public int loop; + + @Benchmark public String loopPlusEquals() { + String s = ""; + for (int i = 0; i < loop; i++) s += "x"; + return s; + } + @Benchmark public String loopBuilder() { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < loop; i++) sb.append("x"); + return sb.toString(); + } + @Benchmark public String loopBuilderPresized() { + StringBuilder sb = new StringBuilder(loop); + for (int i = 0; i < loop; i++) sb.append("x"); + return sb.toString(); + } +} diff --git a/strings/src/main/java/com/ankurm/strings/StringPoolDemo.java b/strings/src/main/java/com/ankurm/strings/StringPoolDemo.java new file mode 100644 index 0000000..0452486 --- /dev/null +++ b/strings/src/main/java/com/ankurm/strings/StringPoolDemo.java @@ -0,0 +1,30 @@ +package com.ankurm.strings; + +/** == versus equals() on strings: the literal pool, compile-time constants, runtime concatenation, intern(). */ +public class StringPoolDemo { + static final String CONST = "hel"; // compile-time constant variable + static final String NOT_CONST = new String("hel"); // not a constant + + public static void main(String[] args) { + String a = "hello"; + String b = "hello"; + String c = new String("hello"); + System.out.println("literal == literal : " + (a == b)); + System.out.println("literal == new String : " + (a == c)); + System.out.println("literal.equals(new String) : " + a.equals(c)); + System.out.println("literal == c.intern() : " + (a == c.intern())); + + String folded = CONST + "lo"; // folded by javac into the constant "hello" + String runtime = NOT_CONST + "lo"; // built at run time + System.out.println("constant + literal == \"hello\" : " + (folded == a)); + System.out.println("runtime concat == \"hello\" : " + (runtime == a)); + System.out.println("runtime concat.intern() == \"hello\": " + (runtime.intern() == a)); + + // "java" is already in the pool (the JDK itself uses the literal), so intern() returns the pooled one, not ours. + String java = new StringBuilder("ja").append("va").toString(); + System.out.println("built \"java\".intern() == the built instance : " + (java.intern() == java)); + // A string nobody has seen before: intern() adds THIS instance to the pool and returns it. + String unseen = new StringBuilder("never-seen-").append(System.nanoTime()).toString(); + System.out.println("built unseen string .intern() == the built instance: " + (unseen.intern() == unseen)); + } +} diff --git a/strings/src/main/java/com/ankurm/strings/TextBlockDemo.java b/strings/src/main/java/com/ankurm/strings/TextBlockDemo.java new file mode 100644 index 0000000..5a80cbe --- /dev/null +++ b/strings/src/main/java/com/ankurm/strings/TextBlockDemo.java @@ -0,0 +1,65 @@ +package com.ankurm.strings; + +/** Text block rules: incidental indentation, closing-delimiter position, trailing spaces, \s, line continuation, escape timing. */ +public class TextBlockDemo { + // Prints the string with newlines shown as \n and spaces shown as dots, so whitespace is visible. + static void show(String label, String s) { + System.out.println("--- " + label + " ---"); + System.out.println(s.replace("\n", "\\n\n").replace(" ", ".")); + } + + public static void main(String[] args) { + String a = """ + { + "id": 1 + } + """; + show("closing delimiter on its own line, aligned with the text", a); + + String b = """ + { + "id": 1 + }"""; + show("closing delimiter right after the last line (no final newline)", b); + + String c = """ + left + """; + show("closing delimiter 2 columns left of the text: 2 spaces of indentation survive", c); + + String d = """ + x \s + y\040 + """; + show("trailing spaces are stripped, but \\s and \\040 survive", d); + + String e = """ + one \ + line + """; + show("backslash at end of line joins the lines", e); + + String f = """ + tab:\t| + """; + show("escapes are processed after indentation is stripped", f); + + String g = """ + say "hi" and ""\" + """; + show("double quotes need no escape; only a run of three does", g); + + String same = """ + { + "id": 1 + } + """; + System.out.println("two identical text blocks are the same pooled instance: " + (a == same)); + System.out.println("equals the hand-written literal: " + a.equals("{\n \"id\": 1\n}\n")); + + String h = """ + Hello, %s! You are %d. + """.formatted("Ann", 30); + show("formatted() is the built-in way to interpolate", h); + } +} diff --git a/strings/src/test/java/com/ankurm/strings/StringBehaviourTest.java b/strings/src/test/java/com/ankurm/strings/StringBehaviourTest.java new file mode 100644 index 0000000..4611936 --- /dev/null +++ b/strings/src/test/java/com/ankurm/strings/StringBehaviourTest.java @@ -0,0 +1,77 @@ +package com.ankurm.strings; + +import static org.junit.jupiter.api.Assertions.*; +import org.junit.jupiter.api.Test; + +class StringBehaviourTest { + static final String CONST = "hel"; + static final String NOT_CONST = new String("hel"); + + @Test void literalsArePooledNewStringIsNot() { + String a = "hello"; + assertSame(a, "hello"); + assertNotSame(a, new String("hello")); + assertSame(a, new String("hello").intern()); + } + + @Test void compileTimeConstantsAreFoldedRuntimeConcatIsNot() { + assertSame("hello", CONST + "lo"); + assertNotSame("hello", NOT_CONST + "lo"); + assertSame("hello", (NOT_CONST + "lo").intern()); + } + + @Test void internReturnsTheAlreadyPooledInstanceOrAdoptsOurs() { + String pooled = new StringBuilder("ja").append("va").toString(); + assertNotSame(pooled, pooled.intern()); // "java" was in the pool before this test ran + String unseen = new StringBuilder("never-seen-").append(System.nanoTime()).toString(); + assertSame(unseen, unseen.intern()); // first intern() adopts this very instance + } + + @Test void textBlockIsAnOrdinaryPooledString() { + String t = """ + hi + """; + assertSame("hi\n", t); + } + + @Test void closingDelimiterPositionDecidesTrailingNewlineAndMargin() { + String onOwnLine = """ + a + """; + String attached = """ + a"""; + String leftOfText = """ + a + """; + assertEquals("a\n", onOwnLine); + assertEquals("a", attached); + assertEquals(" a\n", leftOfText); + } + + @Test void trailingSpacesStrippedUnlessEscaped() { + String t = """ + x \s + y\040 + """; + assertEquals("x \ny \n", t); // "x" + 3 literal spaces + \s, then "y" + \040 + } + + @Test void lineContinuationAndEscapeTiming() { + assertEquals("one line\n", """ + one \ + line + """); + assertEquals("tab:\t|\n", """ + tab:\t| + """); + } + + @Test void plusInLoopAllocatesNewStringEachTime() { + String s = ""; + String prev; + for (int i = 0; i < 3; i++) { prev = s; s += "x"; assertNotSame(prev, s); } + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < 3; i++) sb.append("x"); + assertEquals(s, sb.toString()); + } +}