String 类型应该算是 Java 中最常用的类型之一了, 不过其中有一些函数用不好就容易中招.

我们就以分割字符串的函数举例, 一般我们都会这么写:

String[] arr = "a,b,c".split(",");

非常简洁, 并且没什么问题. 不过一旦上下文变得复杂起来事情恐怕就没这么简单了.

比如说, 现在我们的任务是要对一本 (英文) 书进行分词, 分割符是标点和空格. 一般来说我们可以这么写:

for (String line : lines) {
    line.split("[,.!+@#$%^&*()\\- ]");
}

这种写法虽说看起来 OK, 但其实并非如此. 实际上, 这样写的话一旦遇到调用频率高或是需要分割大文本的情况就会出现内存占用大及运行耗时长的问题. 至于为什么会这样, 我们可以来看一下该函数是如何实现的:

public String[] split(String regex, int limit) {
    /* fastpath if the regex is a
     (1)one-char String and this character is not one of the
        RegEx's meta characters ".$|()[{^?*+\\", or
     (2)two-char String and the first char is the backslash and
        the second is not the ascii digit or ascii letter.
     */
    char ch = 0;
    if (((regex.value.length == 1 &&
         ".$|()[{^?*+\\".indexOf(ch = regex.charAt(0)) == -1) ||
         (regex.length() == 2 &&
          regex.charAt(0) == '\\' &&
          (((ch = regex.charAt(1))-'0')|('9'-ch)) < 0 &&
          ((ch-'a')|('z'-ch)) < 0 &&
          ((ch-'A')|('Z'-ch)) < 0)) &&
        (ch < Character.MIN_HIGH_SURROGATE ||
         ch > Character.MAX_LOW_SURROGATE))
    {
        int off = 0;
        int next = 0;
        boolean limited = limit > 0;
        ArrayList<String> list = new ArrayList<>();
        while ((next = indexOf(ch, off)) != -1) {
            if (!limited || list.size() < limit - 1) {
                list.add(substring(off, next));
                off = next + 1;
            } else {    // last one
                //assert (list.size() == limit - 1);
                list.add(substring(off, value.length));
                off = value.length;
                break;
            }
        }
        // If no match was found, return this
        if (off == 0)
            return new String[]{this};

        // Add remaining segment
        if (!limited || list.size() < limit)
            list.add(substring(off, value.length));

        // Construct result
        int resultSize = list.size();
        if (limit == 0) {
            while (resultSize > 0 && list.get(resultSize - 1).length() == 0) {
                resultSize--;
            }
        }
        String[] result = new String[resultSize];
        return list.subList(0, resultSize).toArray(result);
    }
    return Pattern.compile(regex).split(this, limit);
}

也就是说在大部分情况下, split(String regex) 函数实际上是新建了一个 Pattern 对象, 再去调用它的 split(CharSequence input, int limit) 函数, 仅有两种情况例外:

传入的 regex 参数仅有一个字符, 且非正则表达式中的 ".$|()[{^?*+\" 字符 传入的 regex 参数仅有两个字符, 且第一个字符为反斜杠, 第二个字符不能是数字或字母 这样事情就很明朗了, 我们在分词的时候调用了多少次 split 函数就等于新建了多少 Pattern 对象, 自然会慢. 因此只要对原来的实现稍加改动就能解决这个问题:

Pattern pattern = Pattern.compile("[,.!+@#$%^&*()\\- ]");
for (String line : lines) {
    pattern.split(line);
}

下面附上实际测试的一些数据, 对 “The_Thousand_and_One_Nights_.txt” (1.42 MB) 进行分词, 两种调用方式各跑 100 遍, 取平均值后结果如下:

String#split Pattern#split
24.32 ms 3.03 ms

从这个结果可以看出比较明显的差异, Pattern 要比 String 的切分快 8 倍左右.

需要注意的是 String 中除了 split 还有一些函数也会在内部生成 Pattern 对象, 包括:

  • matches(String regex)
  • replaceFirst(String regex, String replacement)
  • replaceAll(String regex, String replacement)
  • split(String regex, int limit)

所以使用这些函数的时候就要小心了, 如果是被反复调用的情况, 最好是声明成一个 Pattern 常量, 再去调用对应的函数.

最后附上测试代码:

import com.google.common.base.Stopwatch;
import com.google.common.io.Resources;
import org.junit.BeforeClass;
import org.junit.Test;

import java.io.IOException;
import java.net.URL;
import java.nio.charset.Charset;
import java.util.LinkedList;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;
import java.util.regex.Pattern;
import java.util.stream.IntStream;

/**
 * Created by gota on 2017/2/27.
 */
public class StringTest {

  private static final String REGEX = "[,.!+@#$%^&*()\\- ]";

  private static List<String> lines = new LinkedList<>();

  @BeforeClass
  public static void before() throws IOException {
    URL resource = Resources.getResource(StringTest.class, "The_Thousand_and_One_Nights_.txt");
    lines.addAll(Resources.readLines(resource, Charset.defaultCharset()));
  }

  /**
   * 使用 String 的 split 函数分割字符串
   */
  @Test
  public void test() {
    split(lines, line -> line.split(REGEX));
  }

  /**
   * 使用 Pattern 的 split 函数分割字符串
   */
  @Test
  public void test2() {
    Pattern pattern = Pattern.compile(REGEX);
    split(lines, line -> pattern.split(REGEX));
  }

  private void split(List<String> lines,
                     Function<String, String[]> function) {
    IntStream
        .range(0, 100)
        .mapToLong($ -> {
          Stopwatch stopwatch = Stopwatch.createStarted();
          lines.forEach(function::apply);
          return stopwatch.stop().elapsed(TimeUnit.MILLISECONDS);
        })
        .average()
        .ifPresent(ms -> System.out.printf("Use: %s ms\n", ms));
  }
}