Java 字符串 split 踩坑记

1.1 split 的坑

前几天在公司对通过 FTP 方式上传的数据文件按照事先规定的格式进行解析后入库,代码的大概实现思路是这样的:先使用流进行文件读取,对文件的每一行数据解析封装成一个个对象,然后进行入库操作。本以为很简单的一个操作,然后写完代码后自己测试发现对文件的每一行进行字符串分割的时候存在问题,在这里做个简单的记录总结。在 Java 中使用 split 方法对字符串进行分割是经常使用的方法,经常在一些文本处理、字符串分割的逻辑中,需要按照一定的分隔符进行分割拆解。这样的功能,大多数情况下我们都会使用 String 中的 split 方法。关于这个方法,稍不注意很容易踩坑。

(1)split 的参数是正则表达式
首先一个常见的问题,就是忘记了 String 的 split 方法的参数不是普通的字符串,而是正则表达式,例如下面的这两种使用方式都达不到我们的预期:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* @author mghio
* @date: 2019-10-13
* @version: 1.0
* @description: Java 字符串 split 踩坑记
* @since JDK 1.8
*/
public class JavaStringSplitTests {

@Test
public void testStringSplitRegexArg() {
System.out.println(Arrays.toString("m.g.h.i.o".split(".")));
System.out.println(Arrays.toString("m|g|h|i|o".split("|")));
}

}

以上代码的结果输出为:

1
2
[]
[m, |, g, |, h, |, i, |, o]

上面出错的原因是因为 .| 都是正则表达式,应该用转义字符进行处理:

1
2
"m.g.h.i.o".split("\\.")
"m|g|h|i|o".split("\\|")

在 String 类中还有其它的和这个相似的方法,例如:replaceAll。

(2)split 会忽略分割后的空字符串
大多数情况下我们都只会使用带一个参数的 split 方法,但是只带一个参数的 split 方法有个坑:就是此方法只会匹配到最后一个有值的地方,后面的会忽略掉,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* @author mghio
* @date: 2019-10-13
* @version: 1.0
* @description: Java 字符串 split 踩坑记
* @since JDK 1.8
*/
public class JavaStringSplitTests {

@Test
public void testStringSplitSingleArg() {
System.out.println(Arrays.toString("m_g_h_i_o".split("_")));
System.out.println(Arrays.toString("m_g_h_i_o__".split("_")));
System.out.println(Arrays.toString("m__g_h_i_o_".split("_")));
}

}

以上代码输出结果为:

1
2
3
[m, g, h, i, o]
[m, g, h, i, o]
[m, , g, h, i, o]

像第二、三个输出结果其实和我们的预期是不符的,因为像一些文件上传其实有的字段通常是可以为空的,如果使用单个参数的 split 方法进行处理就会有问题。通过查看 API 文档 后,发现其实 String 中的 split 方法还有一个带两个参数的方法。第二个参数是一个整型类型变量,代表最多匹配上多少个,0 表示只匹配到最后一个有值的地方,单个参数的 split 方法的第二个参数其实就是 0,要想强制匹配可以选择使用负数(通常传入 -1 ),换成以下的写法,输出结果就和我们的预期一致了。

1
2
3
"m_g_h_i_o".split("_", -1)      // [m, g, h, i, o]
"m_g_h_i_o__".split("_", -1) // [m, g, h, i, o, , ]
"m__g_h_i_o_".split("_", -1) // [m, , g, h, i, o, ]

(3)JDK 中字符串切割的其它 API
在 JDK 中还有一个叫做 StringTokenizer 的类也可以对字符串进行切割,用法如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* @author mghio
* @date: 2019-10-13
* @version: 1.0
* @description: Java 字符串 split 踩坑记
* @since JDK 1.8
*/
public class JavaStringSplitTests {

@Test
public void testStringTokenizer() {
StringTokenizer st = new StringTokenizer("This|is|a|mghio's|blog", "|");
while (st.hasMoreElements()) {
System.out.println(st.nextElement());
}
}

}

不过,我们从源码的 javadoc 上得知,这是从 JDK 1.0 开始就已经存在了,属于历史遗留的类,并且推荐使用 String 的 split 方法。

1.2 JDK 源码探究

通过查看 JDK 中 String 类的源码,我们得知在 String 类中单个参数的 split 方法(split(String regex))里面调用了两个参数的 split 方法(split(String regex, int limit)),两个参数的 split 方法,先根据传入第一个参数 regex 正则表达式分割字符串,第二个参数 limit 限定了分割后的字符串个数,超过数量限制的情况下前limit-1个子字符串正常分割,最后一个子字符串包含剩下所有字符。单个参数的重载方法将 limit 设置为 0。源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
public String[] split(String regex, int limit) {
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);
}

接下来让我们一起看看 String 的 split 方法是如何实现的。

(1)特殊情况判断

1
2
3
4
5
6
7
8
9
(((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))
  • 第一个参数 regex 为单个字符时,将其赋值给 ch,并判断是否在元字符:「.$|()[{^?*+\」中
  • 第一个参数 regex 为两个字符时,第一个字符为 \\(要表示一个\需要用两个\转义得到),第二个字符不在数字、大小写字母和 Unicode 编码 Character.MIN_HIGH_SURROGATE(’\uD800’)和 Character.MAX_LOW_SURROGATE(’\uDBFF’)之间。

(2)字符串分割
第一次分割时,使用 off 和 next,off 指向每次分割的起始位置,next 指向分隔符的下标,完成一次分割后更新 off 的值,当 list 的大小等于 limit - 1 时,直接添加剩下的子字符串。

  • 如果字符串不含有分隔符,则直接返回原字符串
  • 如果字符串进行完第一次分割后,数量没有达到 limit - 1 的话,则剩余的字符串在第二次添加
  • 如果传入的第二个参数 limit 等于 0 ,则从最后的字符串往前移动,将所有的空字符串(”“)全部清除

(3)正则匹配
String 的 split 方法在不是上面的特殊情况下,会使用两个类 PatternMatcher 进行分割匹配处理,而且 Strig 中涉及正则的操作都是调用这两个类进行处理的。

  • Pattern 类我们可以将其理解为模式类,它主要是用来创建一个匹配模式,它的构造方法是私有的,不能直接创建该对象,可以通过 Pattern.complie(String regex) 简单的工厂方法创建一个正则表达式。
  • Matcher 类我们可以将其理解为匹配器类,它是用来解释 Pattern 类对字符串执行匹配操作的引擎,它的构造方法也是私有的,不能直接创建该对象,可以通过 Pattern.matcher(CharSequence input) 方法得到该类的实例。String 类的双参数 split 方法最后使用 Pattern 类的 compile 和 split 方法,如下:
    1
    return Pattern.compile(regex).split(this, limit);

首先调用 Pattern 类的静态方法 compile 获取 Pattern 模式类对象

1
2
3
public static Pattern compile(String regex) {
return new Pattern(regex, 0);
}

接着调用 Pattern 的 split(CharSequence input, int limit) 方法,在这个方法中调 matcher(CharSequence input) 方法返回一个 Matcher 匹配器类的实例 m,与 String 类中 split 方法的特殊情况有些类似。

  • 使用 m.find()、m.start()、m.end() 方法
  • 每找到一个分割符,则更新 start 和 end 的位置
  • 然后处理没找到分隔符、子字符串数量小于 limit 以及 limit = 0 的情况

1.3 其它的字符串分割方式

  • 方式一:使用 org.apache.commons.lang3.StringUtils#split,此方法使用完整的字符串作为参数,而不是正则表达式。底层调用 splitWorker 方法(注意:此方法会忽略分割后的空字符串)
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    /**
    * @author mghio
    * @date: 2019-10-13
    * @version: 1.0
    * @description: Java 字符串 split 踩坑记
    * @since JDK 1.8
    */
    public class JavaStringSplitTests {

    @Test
    public void testApacheCommonsLangStringUtils() {
    System.out.println(Arrays.toString(StringUtils.split("m.g.h.i.o", ".")));
    System.out.println(Arrays.toString(StringUtils.split("m__g_h_i_o_", "_")));
    }

    }

输出结果:

1
2
[m, g, h, i, o]
[m, g, h, i, o]
  • 方式二:使用 com.google.common.base.Splitter,使用 Google Guava 包中提供的分割器 splitter,它提供了更加丰富的分割结果处理的方法,比如对结果前后去除空格,去除空字符串等
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    /**
    * @author mghio
    * @date: 2019-10-13
    * @version: 1.0
    * @description: Java 字符串 split 踩坑记
    * @since JDK 1.8
    */
    public class JavaStringSplitTests {

    @Test
    public void testApacheCommonsLangStringUtils() {
    Iterable<String> result = Splitter.on("_").split("m__g_h_i_o_");
    List<String> resultList = Lists.newArrayList();
    result.forEach(resultList::add);
    System.out.println("stringList's size: " + resultList.size());
    result.forEach(System.out::println);
    }

    }

输出结果:

1
2
3
4
5
6
7
stringList's size: 7
m

g
h
i
o

1.4 总结

String 类中除了 split 方法外,有正则表达式接口的方法都是调用 Pattern(模式类)和 Matcher(匹配器类)进行实现的。JDK 源码的每一个如 finalprivate 的关键字都设计的十分严谨,多读类和方法中的javadoc,多注意这些细节对于阅读代码和自己写代码都有很大的帮助。

-------------本文结束感谢您的阅读-------------
mghio wechat
微信公众号「mghio」
请我吃🍗