Igoranze
Igoranze

Reputation: 1486

Why is iteration through List<String> slower than split string and iterate over StringBuilder?

I was wondering why a List<String> for each loop is slower than a split for each on a StringBuilder

This is my code:

package nl.testing.startingpoint;

import java.text.DecimalFormat;
import java.text.NumberFormat;
import java.util.ArrayList;
import java.util.List;

public class Main {

    public static void main(String args[]) {
        NumberFormat formatter = new DecimalFormat("#0.00000");

        List<String> a = new ArrayList<String>();
        StringBuffer b = new StringBuffer();        

        for (int i = 0;i <= 10000; i++)
        {
            a.add("String:" + i);
            b.append("String:" + i + " ");
        }

        long startTime = System.currentTimeMillis();
        for (String aInA : a) 
        {
            System.out.println(aInA);
        }
        long endTime   = System.currentTimeMillis();

        long startTimeB = System.currentTimeMillis();
        for (String part : b.toString().split(" ")) {

            System.out.println(part);
        }
        long endTimeB   = System.currentTimeMillis();

        System.out.println("Execution time from StringBuilder is " + formatter.format((endTimeB - startTimeB) / 1000d) + " seconds");
        System.out.println("Execution time List is " + formatter.format((endTime - startTime) / 1000d) + " seconds");

    }
}

The result is:

I would expect the StringBuilder to be slower because of the b.toString().split(" ")).

Can anyone explain this to me?

Upvotes: 2

Views: 590

Answers (3)

T.J. Crowder
T.J. Crowder

Reputation: 1074666

(This is a completely revised answer. See 1 for why. With thanks to Buhb for making me take a second look! Note that he/she has also posted an answer.)


Beware your results, micro-benchmarks in Java are very tricky and your benchmarking code is doing I/O, amongst other things; see this question and its answers for more: How do I write a correct micro-benchmark in Java?

And indeed, as far as I can tell, your results were misleading you (and me, initially). Although an enhanced for loop on a String array is much faster than it is on an ArrayList<String> (more on that below), the .toString().split(" ") overhead would appear to still dominate and make that version slower than the ArrayList version. Markedly slower.

Let's determine which is faster using a throughly-designed and tested tool for microbenchmarking: JMH.

I'm using Linux, so here's how I set it up (the $ is just to indicate a command prompt; what you type is after that):

1. First, I installed Maven since I don't normally have it installed:

$ sudo apt-get install maven

2. Then I used Maven to create a sample benchmark project:

$ mvn archetype:generate \
          -DinteractiveMode=false \
          -DarchetypeGroupId=org.openjdk.jmh \
          -DarchetypeArtifactId=jmh-java-benchmark-archetype \
          -DgroupId=org.sample \
          -DartifactId=test \
          -Dversion=1.0

That creates the benchmark project in a test subdirectory, so:

$ cd test

3. In the resulting project, I deleted the default src/main/java/org/sample/MyBenchmark.java and created three files in that folder for benchmarking:

Common.java: Really boring:

package org.sample;

public class Common {
    public static final int LENGTH = 10001;
}

Originally I expected to need more there...

TestList.java:

package org.sample;

import java.util.List;
import java.util.ArrayList;
import java.text.NumberFormat;
import java.text.DecimalFormat;

import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.State;
import org.openjdk.jmh.annotations.Scope;

public class TestList {

    // This state class lets us set up our list once and reuse it for tests in this test thread
    @State(Scope.Thread)
    public static class TestState {
        public final List<String> list;

        public TestState() {
            // Your code for creating the list
            NumberFormat formatter = new DecimalFormat("#0.00000");
            List<String> a = new ArrayList<String>();
            for (int i = 0; i < Common.LENGTH; ++i)
            {
                a.add("String:" + i);
            }
            this.list = a;
        }
    }

    // This is the test method JHM will run for us
    @Benchmark
    public void test(TestState state) {
        // Grab the list
        final List<String> strings = state.list;

        // Loop through it -- note that I'm doing work within the loop, but not I/O since
        // we don't want to measure I/O, we want to measure loop performance
        int l = 0;
        for (String s : strings) {
            l += s == null ? 0 : 1;
        }

        // I always do things like this to ensure that the test is doing what I expected
        // it to do, and so that I actually use the result of the work from the loop
        if (l != Common.LENGTH) {
            throw new RuntimeException("Test error");
        }
    }
}

TestStringSplit.java:

package org.sample;

import java.text.NumberFormat;
import java.text.DecimalFormat;

import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.State;
import org.openjdk.jmh.annotations.Scope;

@State(Scope.Thread)
public class TestStringSplit {

    // This state class lets us set up our list once and reuse it for tests in this test thread
    @State(Scope.Thread)
    public static class TestState {
        public final StringBuffer sb;

        public TestState() {
            NumberFormat formatter = new DecimalFormat("#0.00000");

            StringBuffer b = new StringBuffer();        

            for (int i = 0; i < Common.LENGTH; ++i)
            {
                b.append("String:" + i + " ");
            }

            this.sb = b;
        }
    }

    // This is the test method JHM will run for us
    @Benchmark
    public void test(TestState state) {
        // Grab the StringBuffer, convert to string, split it into an array
        final String[] strings = state.sb.toString().split(" ");

        // Loop through it -- note that I'm doing work within the loop, but not I/O since
        // we don't want to measure I/O, we want to measure loop performance
        int l = 0;
        for (String s : strings) {
            l += s == null ? 0 : 1;
        }

        // I always do things like this to ensure that the test is doing what I expected
        // it to do, and so that I actually use the result of the work from the loop
        if (l != Common.LENGTH) {
            throw new RuntimeException("Test error");
        }
    }
}

4. Now we have our tests, we build the project:

$ mvn clean install

5. And we're ready to test! Close any programs you don't need running, then fire off this command. This takes a while, and you want to leave your machine alone during the process. Go grab a cup o'Java.

$ java -jar target/benchmarks.jar -f 4 -wi 10 -i 10

(Note: The -f 4 means "only do four forks, not ten"; -wi 10 means "only do 10 warmup-iterations, not 20;" and -i 10 means "only do 10 test iterations, not 20". If you want to be really rigorous, leave them off and go to lunch rather than just taking a coffee break.)

Here's the result I get with JDK 1.8.0_74 on my 64-bit Intel machine:

Benchmark              Mode  Cnt      Score      Error  Units
TestList.test         thrpt   40  65641.040 ± 3811.665  ops/s
TestStringSplit.test  thrpt   40   4909.565 ±   33.822  ops/s

The loop-through-list version did more than 65k operations/second compared to fewer than 5000 ops/sec for the split-and-loop-through-array version.

So your initial expectation that the List version would be faster because of the cost of doing the .toString().split(" ") was correct. Doing that and looping the result is markedly slower than using the List.


About the enhanced for on String[] vs. List<String>: It is markedly faster to loop through String[] than through List<String>, so that .toString().split(" ") must have cost us a lot. To test just the looping portion, I used JMH with the TestList class earlier, and this TestArray class:

package org.sample;

import java.util.List;
import java.util.ArrayList;
import java.text.NumberFormat;
import java.text.DecimalFormat;

import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.State;
import org.openjdk.jmh.annotations.Scope;

public class TestArray {

    // This state class lets us set up our list once and reuse it for tests in this test thread
    @State(Scope.Thread)
    public static class TestState {
        public final String[] array;

        public TestState() {
            // Create an array with strings like the ones in the list
            NumberFormat formatter = new DecimalFormat("#0.00000");
            String[] a = new String[Common.LENGTH];
            for (int i = 0; i < Common.LENGTH; ++i)
            {
                a[i] = "String:" + i;
            }
            this.array = a;
        }
    }

    // This is the test method JHM will run for us
    @Benchmark
    public void test(TestState state) {
        // Grab the list
        final String[] strings = state.array;

        // Loop through it -- note that I'm doing work within the loop, but not I/O since
        // we don't want to measure I/O, we want to measure loop performance
        int l = 0;
        for (String s : strings) {
            l += s == null ? 0 : 1;
        }

        // I always do things like this to ensure that the test is doing what I expected
        // it to do, and so that I actually use the result of the work from the loop
        if (l != Common.LENGTH) {
            throw new RuntimeException("Test error");
        }
    }
}

I ran it just like the test earlier (four forks, 10 warmups and 10 iterations); here are the results:

Benchmark        Mode  Cnt       Score      Error  Units
TestArray.test  thrpt   40  568328.087 ±  580.946  ops/s
TestList.test   thrpt   40   62069.305 ± 3793.680  ops/s

Nearly an order of magnitude more operations/second to loop through the array than the list.

That doesn't surprise me, since the enhanced for loop can work directly on the array, but has to use an Iterator returned by the List in the List case and make method calls to it: Two calls per loop (Iterator#hasNext and Iterator#next) for 10,001 loops = 20,002 calls. Method calls are cheap, but they're not free, and even if the JIT inlines them, the code of those calls still has to run. ArrayList's ListIterator has to do some work before it can return the next array entry, whereas when the enhanced for loop knows it's dealing with an array, it can work directly on it.

The test classes above have testing cruft in them, but to see why the array version is faster, let's look at this simpler program:

import java.util.List;
import java.util.ArrayList;

public class Example {
    public static final void main(String[] args) throws Exception {
        String[] array = new String[10];
        List<String> list = new ArrayList<String>(array.length);
        for (int n = 0; n < array.length; ++n) {
            array[n] = "foo" + System.currentTimeMillis();
            list.add(array[n]);
        }

        useArray(array);
        useList(list);

        System.out.println("Done");
    }

    public static void useArray(String[] array) {
        System.out.println("Using array:");
        for (String s : array) {
            System.out.println(s);
        }
    }

    public static void useList(List<String> list) {
        System.out.println("Using list:");
        for (String s : list) {
            System.out.println(s);
        }
    }
}

Using javap -c Example after compiling it, we can look at the bytecode of the two useXYZ functions; I've boldfaced the loop portions of each and set them off slightly from the rest of each function:

useArray:

  public static void useArray(java.lang.String[]);
    Code:
       0: getstatic     #15                 // Field java/lang/System.out:Ljava/io/PrintStream;
       3: ldc           #18                 // String Using array:
       5: invokevirtual #17                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
       8: aload_0
       9: astore_1
      10: aload_1
      11: arraylength
      12: istore_2
      13: iconst_0
      14: istore_3

      15: iload_3
      16: iload_2
      17: if_icmpge     39
      20: aload_1
      21: iload_3
      22: aaload
      23: astore        4
      25: getstatic     #15                 // Field java/lang/System.out:Ljava/io/PrintStream;
      28: aload         4
      30: invokevirtual #17                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      33: iinc          3, 1
      36: goto          15

      39: return

useList:

  public static void useList(java.util.List);
    Code:
       0: getstatic     #15                 // Field java/lang/System.out:Ljava/io/PrintStream;
       3: ldc           #19                 // String Using list:
       5: invokevirtual #17                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
       8: aload_0
       9: invokeinterface #20,  1           // InterfaceMethod java/util/List.iterator:()Ljava/util/Iterator;
      14: astore_1

      15: aload_1
      16: invokeinterface #21,  1           // InterfaceMethod java/util/Iterator.hasNext:()Z
      21: ifeq          44
      24: aload_1
      25: invokeinterface #22,  1           // InterfaceMethod java/util/Iterator.next:()Ljava/lang/Object;
      30: checkcast     #2                  // class java/lang/String
      33: astore_2
      34: getstatic     #15                 // Field java/lang/System.out:Ljava/io/PrintStream;
      37: aload_2
      38: invokevirtual #17                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      41: goto          15

      44: return

So we can see useArray operating directly on the array, and we can see useList's two calls to Iterator methods.

Of course, most of the time it doesn't matter. Don't bother worrying about these things until and unless you have identified the code you're optimizing to be a bottleneck.


1 This answer has been thoroughly revised from its original version, because I assumed in the original version that the assertion that the split-then-loop-array version being faster was true. I completely failed to check that assertion, and just jumped into the analysis of how the enhanced for loop is faster on arrays than lists. My bad. Many thanks again to Buhb for making me take a closer look.

Upvotes: 5

Buhb
Buhb

Reputation: 7149

Benchmarking java is hard, because of all kinds of optimizations and JIT compilations.

I'm sorry to say you can draw no conclusions from your test. The least you must do is to create two different programs, one for each scenario, and run them separately. I extended on your piece of code, and wrote this:

NumberFormat formatter = new DecimalFormat("#0.00000");

List<String> a = new ArrayList<String>();
StringBuffer b = new StringBuffer();

for (int i = 0;i <= 10000; i++)
{
    a.add("String:" + i);
    b.append("String:" + i + " ");
}

long startTime = System.currentTimeMillis();
for (String aInA : a)
{
    System.out.println(aInA);
}
long endTime   = System.currentTimeMillis();

long startTimeB = System.currentTimeMillis();
for (String part : b.toString().split(" ")) {

    System.out.println(part);
}
long endTimeB   = System.currentTimeMillis();

long startTimeC = System.currentTimeMillis();
for (String aInA : a)
{
    System.out.println(aInA);
}
long endTimeC   = System.currentTimeMillis();

System.out.println("Execution time List is " + formatter.format((endTime - startTime) / 1000d) + " seconds");
System.out.println("Execution time from StringBuilder is " + formatter.format((endTimeB - startTimeB) / 1000d) + " seconds");
System.out.println("Execution time List second time is " + formatter.format((endTimeC - startTimeC) / 1000d) + " seconds");

It gave me the following result:

Execution time List is 0.04300 seconds
Execution time from StringBuilder is 0.03200 seconds
Execution time List second time is 0.01900 seconds

Also, if I remove the System.out.println statements in the loop, and instead just append the strings to a StringBuilder, I get execution times in the milliseconds, instead of tens of milliseconds, which tells me that the splitting vs list looping cant be responsible for one method taking twice the time of the other.

In general, IO is comparatively slow, so your code spends most of its time executing println-statements.

Edit: Ok, so I've done my homework now. Got inspired by the link supplied by @StephenC and created a benchmark using JMH. The methods being benchmarked follows:

public void loop() {
            for (String part : b.toString().split(" ")) {
                bh.consume(part);
            }
        }



    public void loop() {
        for (String aInA : a)
        {
            bh.consume(aInA);
        }

And the result:

Benchmark                          Mode  Cnt    Score   Error  Units
BenchmarkLoop.listLoopBenchmark    avgt  200   55,992 ± 0,436  us/op
BenchmarkLoop.stringLoopBenchmark  avgt  200  290,515 ± 0,975  us/op

So to me, it looks like the list version is quicker, which is consistent with your initial intuition.

Upvotes: 2

Krzysztof Krasoń
Krzysztof Krasoń

Reputation: 27476

In case of split you are operating on an array directly, so it is quite fast. ArrayList uses array inside, but adds some code around it so it has to be slower than iterating over a pure array.

But saying that I woulnd't use such microbenchmark at all - after JIT has run the results might be different.

And more importantly, do what is more readable, worry about performance when you have issues with it, not before - cleaner code is better in the beginning.

Upvotes: 2

Related Questions