Iteration over a String via String.toCharArray() improves performance in one case, but worsen it in another similar one

Viewed 124

this question is slightly related to one I've asked on SO before.

I've modified the code from java.util.regex.Matcher.quoteReplacement(String) by dropping char-by-char iteration via String.charAt() in favour of String.toCharArray():

//before
public static String quoteReplacement(String s) {
  if ((s.indexOf('\\') == -1) && (s.indexOf('$') == -1))
    return s;
  StringBuilder sb = new StringBuilder();
  for (int i=0; i<s.length(); i++) {
    char c = s.charAt(i);
    if (c == '\\' || c == '$') {
      sb.append('\\');
    }
    sb.append(c);
  }
  return sb.toString();
}

//after
public static String quoteReplacement(String s) {
  if ((s.indexOf('\\') == -1) && (s.indexOf('$') == -1)) {
    return s;
  }
  StringBuilder sb = new StringBuilder();
  for (char c : s.toCharArray()) {
    if (c == '\\' || c == '$') {
      sb.append('\\');
    }
    sb.append(c);
  }
  return sb.toString();
}

This change brought significant improvement as per average execution time along with higher memory consumption, measured with this benchmark (full output can be found here):

                                        latin   length              before                after     Units

quoteReplacement                         true        8     37.708 ±  0.335      32.737 ±  0.040     ns/op
quoteReplacement                         true       64    212.155 ±  1.359     146.926 ±  0.239     ns/op
quoteReplacement                         true      128    382.048 ±  6.600     266.333 ±  0.872     ns/op
quoteReplacement                         true      256    759.663 ± 14.673     508.559 ±  0.987     ns/op

quoteReplacement                        false        8     61.607 ±  0.278      57.685 ±  0.210     ns/op
quoteReplacement                        false       64    309.292 ±  1.089     236.214 ±  1.822     ns/op
quoteReplacement                        false      128    591.459 ±  5.455     437.748 ±  3.134     ns/op
quoteReplacement                        false      256   1135.112 ±  5.119     837.601 ± 10.067     ns/op

quoteReplacement:·gc.alloc.rate.norm     true        8     88.008 ±  0.001     120.010 ±  0.001      B/op
quoteReplacement:·gc.alloc.rate.norm     true       64    288.036 ±  0.001     432.038 ±  0.001      B/op
quoteReplacement:·gc.alloc.rate.norm     true      128    512.065 ±  0.001     784.068 ±  0.001      B/op
quoteReplacement:·gc.alloc.rate.norm     true      256    944.121 ±  0.001    1472.128 ±  0.001      B/op

quoteReplacement:·gc.alloc.rate.norm    false        8    176.015 ±  0.001     208.018 ±  0.001      B/op
quoteReplacement:·gc.alloc.rate.norm    false       64    592.062 ±  0.001     736.064 ±  0.001      B/op
quoteReplacement:·gc.alloc.rate.norm    false      128   1088.115 ±  0.001    1360.118 ±  0.001      B/op
quoteReplacement:·gc.alloc.rate.norm    false      256   2064.217 ±  0.002    2592.225 ±  0.001      B/op

Later on I've applied the same approach to another JDK class - java.io.DataOutputStream:

//before
public final void writeBytes(String s) throws IOException {
  int len = s.length();
  for (int i = 0 ; i < len ; i++) {
    out.write((byte)s.charAt(i));
  }
  incCount(len);
}

//after
public final void writeBytes(String s) throws IOException {
  int len = s.length();
  for (char c : s.toCharArray()) {
    out.write((byte)c);
  }
  incCount(len);
}

To measure the impact I've used this benchmark:

@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Fork(jvmArgsAppend = {"-Xms2g", "-Xmx2g"})
public class DataOutputStreamBenchmark {

  @Benchmark
  public Object writeBytes(Data data) throws IOException {
    var string = data.string;
    var baos = new ByteArrayOutputStream();
    var dataOutputStream = new DataOutputStream(baos);
    dataOutputStream.writeBytes(string);
    return baos;
  }

  @State(Scope.Thread)
  public static class Data {
    private String string;

    @Param({"8", "64", "256"})
    private int length;

    @Setup
    public void setup() throws IOException {
      String alphabet = "abcdefghijklmnopqrstuvwxyz";

      RandomStringGenerator generator = new RandomStringGenerator();

      string = generator.randomString(alphabet, length);
    }
  }
}

And the results are quite confusing:

                                    length              before               after     Units
writeBytes                               8     150.922 ± 0.135      96.896 ± 0.229     ns/op
writeBytes                              64    1158.181 ± 1.179    1124.765 ± 3.810     ns/op
writeBytes                             256    4597.537 ± 9.825    4835.081 ± 4.610     ns/op

writeBytes:·gc.alloc.rate.norm           8     112.018 ± 0.001     144.018 ± 0.001      B/op
writeBytes:·gc.alloc.rate.norm          64     192.036 ± 0.005     376.065 ± 0.004      B/op
writeBytes:·gc.alloc.rate.norm         256     608.115 ± 0.020    1176.203 ± 0.011      B/op

While for the short string the change was indeed fruitful, for the longer ones it brought degradation. The fact is confusing to me, because the both benchmarks do mostly the same: the put a byte (in case of latin alphabet) into a byte[] in either StringBuilder or ByteArrayOutputStream and check bounds of that array.

Could one explain this behaviour or give an advice where I've got to look into to get some better understanding of what's going on under the hood? I suspect there's a kind of magic related to StrinBuilder.append(char) done by C2 in case of Matcher.quoteReplacement().

0 Answers
Related