Skip to content
academia.sh

Lesson 09 / 10

Strings

The same + operator gives two separate results on strings: between two constants it leaves no trace at all in the class file, between variables it turns into a dynamic call; in a loop, the 24-instruction form leaves 20 string objects while the 27-instruction builder produces a single object.

Contents

The previous lesson measured that writing to a String[] array is checked at run time; the value written was, every time, an already-ready string object. This lesson looks at that object itself.

That a string is immutable was established in the Programming Fundamentals course and is not repeated here. The question here is: how much does immutability hold up? What step does the + operator written in the source turn into in the class file, how many objects does writing the same constant twice produce, and how many objects does text grown inside a loop leave along the way?

The Concatenation Operator Does Not Stay in the Class File

  • BS28 — The measured source text is written inside the lesson; every extra found in the class file is what the compiler added.
  • BS29 — The Gauge core comes from the shared definition. This lesson writes only the three records it uses: call, dynamic call, and object production. The measurements’ behavior does not change.
  • BS30 — Four of the six measured methods are four separate forms of concatenation: between two constants, with one variable, with three operators, and across a loop. The remaining two are for comparison.
// Gauge.java — shared measurement core: reads what the compiler puts in the class file
import java.io.PrintWriter;
import java.io.Writer;
import java.lang.classfile.*;
import java.lang.classfile.instruction.*;
import java.nio.file.*;
import java.util.*;
import java.util.spi.ToolProvider;

class Gauge {
    record Method(String name, int instructions, List<String> added) {}
    record ClassInfo(List<Method> methods, List<String> fields) {}

    static ClassInfo read(String source, String className) throws Exception {
        Path d = Files.createTempDirectory("gauge");
        Path k = d.resolve(className + ".java");
        Files.writeString(k, source);
        PrintWriter sink = new PrintWriter(Writer.nullWriter());
        if (ToolProvider.findFirst("javac").orElseThrow()
                .run(sink, sink, "-d", d.toString(), k.toString()) != 0)
            throw new IllegalStateException("did not compile: " + className);
        ClassModel cm = ClassFile.of().parse(d.resolve(className + ".class"));
        List<Method> methods = new ArrayList<>();
        for (MethodModel m : cm.methods()) {
            if (m.code().isEmpty()) continue;
            List<String> added = new ArrayList<>();
            int instructions = 0;
            for (CodeElement e : m.code().get()) {
                if (e instanceof Instruction) instructions++;
                if (e instanceof InvokeInstruction iv)
                    added.add(shortName(iv.owner().asInternalName()) + "." + iv.name().stringValue());
                else if (e instanceof InvokeDynamicInstruction id)
                    added.add("dynamic:" + id.name().stringValue());
                else if (e instanceof NewObjectInstruction n)
                    added.add("new " + shortName(n.className().asInternalName()));
            }
            methods.add(new Method(m.methodName().stringValue(), instructions, added));
        }
        List<String> fields = new ArrayList<>();
        for (FieldModel f : cm.fields()) fields.add(f.fieldName().stringValue());
        return new ClassInfo(methods, fields);
    }

    static String shortName(String internal) { return internal.substring(internal.lastIndexOf('/') + 1); }
}
// Concat.java — what string concatenation turns into in the class file
public class Concat {
    static final String K = """
        class StringSample {
            static String concat(String name, int count) { return name + count; }
            static String constantConcat() { return "si" + "mple"; }
            static int length(String s) { return s.length(); }
            static String line(String name, int count, long weight) {
                return name + " x" + count + " (" + weight + " g)";
            }
            static String concatInLoop(String[] d) {
                String s = "";
                for (String x : d) s += x;
                return s;
            }
            static String withBuilder(String[] d) {
                StringBuilder b = new StringBuilder();
                for (String x : d) b.append(x);
                return b.toString();
            }
        }
        """;

    public static void main(String[] args) throws Exception {
        System.out.printf("%-18s %6s  %s%n", "method", "instr", "step not written in source");
        for (Gauge.Method y : Gauge.read(K, "StringSample").methods())
            if (!y.name().equals("<init>"))
                System.out.printf("%-18s %6d  %s%n", y.name(), y.instructions(),
                        y.added().isEmpty() ? "-" : String.join(", ", y.added()));

        String name = "north";
        int count = 12;
        long weight = 1800;
        System.out.println("\ntext line's form produces: "
                + name + " x" + count + " (" + weight + " g)");
    }
}
method              instr  step not written in source
concat                  4  dynamic:makeConcatWithConstants
constantConcat          2  -
length                  3  String.length
line                    5  dynamic:makeConcatWithConstants
concatInLoop           24  dynamic:makeConcatWithConstants
withBuilder            27  new StringBuilder, StringBuilder.<init>, StringBuilder.append, StringBuilder.toString

text line's form produces: north x12 (1800 g)

The first row is the lesson’s central measurement. In the source, name + count is written and an operator sits there; in the class file there is a dynamic call. String concatenation is not found in the class file as an operator — because no concatenation instruction exists as its counterpart. The compiler turns the operator into a call; how the call is realized is left to run time, and only the operation’s name along with the constant parts written in the source stand in the class file. Someone looking at the source cannot see that a number is being converted to text either: count is an integer, and that conversion is inside the same call.

The second row drops the same operator to zero. The method writing "si" + "mple" is 2 instructions: load the constant, return. There is no concatenation there at all. Two string constants have been folded into a single constant during compilation — this is the string counterpart of the constant folding measured in the first lesson. The same + operator, a call on one line, nothing on the other.

The third row places the difference from the previous lesson. On an array, d.length was an instruction; on a string, s.length() is a call, and the gauge records it as an added step. The syntax says this too: there are no parentheses on the array, there are on the string. Length is read on the array from the object’s header, on the string it is asked for with a method call; because both are written with the same word they get confused, in the class file they resemble each other not at all.

The fourth row measures the operator’s most-used form. In the line method that builds a record line, there are three + operators, but in the class file there stands a single dynamic call, and the whole method is 5 instructions. The compiler does not compile consecutive concatenations one by one; it gathers all the pieces into a single call. Saying “every + produces an intermediate text” by looking at the source would be wrong for this reason — the number of concatenations inside an expression is not the number of intermediate objects produced. The line below shows the text this form produces: the record’s name, count, and weight in grams combine in a single call.

The bottom two rows set up the second pair. The method that writes s += x inside a loop is 24 instructions, the one written with a builder is 27 instructions. Someone looking at instruction count would pick the first. The added-step list, though, shows the opposite direction: the first has a single dynamic call running on every turn of the loop, the second has one object production and three calls. The numbers give no reason for a choice here; instruction count does not say how many times the program does what.

Objects Left in the Loop

  • BS31 — The measurement does not measure duration; it counts string objects produced. On every turn, whether the result is the same as the previous object is asked with ==, and what comes out separate is counted. No identity number is printed.
  • BS32 — Part count is tried with three separate values; what is measured is not a single number but how that number changes with part count.
// Production.java — how many string objects are left in the loop
public class Production {
    static String[] parts(int n) {
        String[] d = new String[n];
        for (int i = 0; i < n; i++) d[i] = (i % 2 == 0) ? "north" : "-";
        return d;
    }

    static int withOperator(String[] d) {
        String s = "";
        int produced = 0;
        for (String x : d) {
            String previous = s;
            s = s + x;
            if (s != previous) produced++;
        }
        return produced;
    }

    static boolean builderSame(String[] d) {
        StringBuilder b = new StringBuilder();
        boolean same = true;
        for (String x : d) same = same && (b.append(x) == b);
        return same;
    }

    public static void main(String[] args) {
        System.out.printf("%6s %22s %28s%n", "parts", "strings by operator",
                "append returned same object");
        for (int n : new int[] {5, 10, 20})
            System.out.printf("%6d %22d %28s%n", n, withOperator(parts(n)),
                    builderSame(parts(n)));

        StringBuilder b = new StringBuilder();
        for (String x : parts(5)) b.append(x);
        System.out.println("\ntwo toString calls same object: " + (b.toString() == b.toString()));
        String withOperators = "";
        for (String x : parts(5)) withOperators += x;
        System.out.println("do both paths give the same text: " + withOperators.equals(b.toString()));
        System.out.println("result: " + b);
    }
}
 parts    strings by operator  append returned same object
     5                      5                         true
    10                     10                         true
    20                     20                         true

two toString calls same object: false
do both paths give the same text: true
result: north-north-north

The number moves exactly with part count: 5 parts, 5 objects; 20 parts, 20 objects. Only the last of these is ever used; the other nineteen are built and discarded. This is not an implementation detail, it is immutability’s direct consequence. The form s += x cannot make an addition to the s object, because writing onto a string is not an operation that exists; the only thing it can do is build a new string and bind the name to it. Like the array’s fixed size in the previous lesson, this is not a prohibition either, it is the absence of an operation with a counterpart.

The += form here is a shorthand too, and the shorthand’s expansion changes by type. On a number, += adds a silent narrowing conversion and changes the value in place; on a string, it adds a dynamic call, builds a new object, and rebinds the name to it. The two forms look the same, the two added steps are separate, and neither is written in the source.

The third column says why the builder separates: the append call returns the object it was called on, in all twenty of the twenty turns. A builder holds a mutable buffer; appending writes into it and no new object is built. A string is only produced when toString is called, and that call is made once. The line below confirms this call really is a production: two consecutive toString calls give separate objects.

This completes the two paths’ table. The operator path is 24 instructions and n objects, the builder path is 27 instructions and 1 object; both give the same text. The course’s second thesis takes its full shape here: instruction count and work done do not move in the same direction. Instruction count in the class file measures how much a method is written, not how many times it runs. A single instruction inside a loop does more work than three instructions outside one.

A direct rule follows from this, and it can be applied just by looking at the form of writing: if concatenation count is known at compile time, the operator is enough, because it compiles to a single call; if the count is decided at run time, the builder is used.

A Constant Is Not Produced, It Is Taken from the Pool

  • BS33 — In the identity measurement, only == and equals results are printed; no identity value or address is written.
  • BS34 — All five compared strings carry the same letters; the only thing measured is whether they are the same object.
// Pool.java — a string constant is not produced, it is taken from the pool
public class Pool {
    static final String K = """
        class PoolSample {
            static String first() { return "north"; }
            static String second() { return "north"; }
            static String constructed() { return new String("north"); }
        }
        """;

    static void report(String name, String a, String b) {
        System.out.printf("%-32s %7s %8s%n", name, a == b, a.equals(b));
    }

    public static void main(String[] args) throws Exception {
        System.out.printf("%-14s %6s  %s%n", "method", "instr", "step not written in source");
        for (Gauge.Method y : Gauge.read(K, "PoolSample").methods())
            if (!y.name().equals("<init>"))
                System.out.printf("%-14s %6d  %s%n", y.name(), y.instructions(),
                        y.added().isEmpty() ? "-" : String.join(", ", y.added()));

        String constant = "north";
        String second = "north";
        String constantSum = "nor" + "th";
        String computed = "nor" + constant.substring(3);
        String constructed = new String("north");

        System.out.printf("%n%-32s %7s %8s%n", "compared with \"north\"", "==", "equals");
        report("constant written a second time", second, constant);
        report("\"nor\" + \"th\"", constantSum, constant);
        report("concatenated at run time", computed, constant);
        report("new String(\"north\")", constructed, constant);
        report("computed.intern()", computed.intern(), constant);
    }
}
method          instr  step not written in source
first               2  -
second              2  -
constructed         5  new String, String.<init>

compared with "north"                 ==   equals
constant written a second time      true     true
"nor" + "th"                        true     true
concatenated at run time           false     true
new String("north")                false     true
computed.intern()                   true     true

The top table is the lesson’s bounding measurement. A method returning a string constant is 2 instructions and the added-step column is empty: no production, no call, no conversion. The constant sits ready-made in the class file and is not produced at run time, it is merely loaded. Writing the same constant a second time does not build a second object either. new String("north"), by contrast, is 5 instructions and adds two steps: an object production and a constructor call. The only difference written in the source is a single keyword; the difference in the class file is three instructions and one object.

The bottom table gives its result on identity. A constant written twice is the same object; concatenating two constants is the same object too, because that concatenation was done at compile time and the result became a constant again. When the same letters are concatenated at run time, though, the result is a separate object, and new String gives a separate object on every call. equals is correct in all five of the five rows: the content is always the same, the identity is not.

The place that gathers all of this is the string pool: constants are held in a single pool, and the same character sequence is stored once. The last line shows the way to enter the pool afterward — an intern call gives the pool’s counterpart of a string built at run time, and the result comes out the same object as the constant. The only practical consequence of this is: strings are not compared with ==. The operator sometimes gives the correct answer, and precisely for that reason it cannot be trusted; what gives the correct answer is not the content, it is where the constant came from.

The intern call may look like a way to fix this table, but what it fixes is not the measurement itself: every entry into the pool asks for a search and a store, and this lesson did not count those steps. Using equals for comparison requires both fewer assumptions and fewer steps; intern is an option worth measuring only when the same text is held many times over and gathering it into a single copy is wanted.

Summary

  • String concatenation is not an operator in the class file: + between variables turns into a dynamic call, the conversion from number to text is inside that same call, and three operators in one expression still give a single call.
  • On an array, d.length is an instruction; on a string, s.length() is a call. The same word compiles to separate steps on the two types.
  • Concatenating two string constants adds no step at all: the method is 2 instructions, the constant has been folded at compile time.
  • Text grown with += in a loop leaves one string per part — 20 parts, 20 objects — because writing onto a string is not an operation that exists; with a builder, append returns the object it was called on and the string is produced only once, with toString.
  • The operator path is 24, the builder path is 27 instructions: fewer instructions does not mean less work, because instruction count does not say how many times something runs.
  • Of five strings carrying the same letters, three are the same object as the constant, two are separate; equals is correct in all five, which is why strings are not compared with ==.

Next Step

This lesson measured that a string can come out the same by == and equal by equals; two names sometimes held a single object. The same question can be asked of a method call too: when a record object is given to a method, what does the called method access, and why do changing that object’s field and rebinding the parameter give separate results? The next lesson reads Java’s single passing model from the class file and closes the course.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close