字符串里的空白处理,几乎是每个Java开发者都绕不开的一道坎。看着简单——不就是把空格删掉吗?但真到线上环境,全角空格、不间断空格、制表符、换行符混在一起,再加上各种编码问题,分分钟教做人。我见过太多项目里用一句replace(" ", "")就想打天下的代码,结果用户从Excel里粘贴一段带全角空格的数据进来,校验直接崩掉。这篇内容就把“Java去除文本中的空格或空白符”这件事彻底拆开讲清楚:从最朴素的字符串替换,到正则表达式的精细控制,再到字符流级别的过滤,以及不同场景下该怎么选、参数怎么定、坑在哪里。不管你是刚接触Java字符串操作的新手,还是想把手头清洗逻辑做得更稳的老手,都能从里面找到能直接抄作业的方案。
1. 先搞清楚“空白符”到底指什么
动手写代码之前,得先把概念对齐。很多人嘴上说“去空格”,实际需求可能是三种完全不同的东西:只去掉普通空格、去掉所有空白字符、或者去掉首尾空白但保留中间。这三种需求对应的实现方式差别很大,选错了轻则结果不对,重则性能翻车。
1.1 普通空格与空白字符的区别
普通空格就是键盘上敲出来的那个字符,ASCII码是32,Unicode码点是U+0020。而“空白字符”是一个更大的集合,Java里Character.isWhitespace(char)方法判断的范围包括:空格(U+0020)、制表符(U+0009)、换行符(U+000A)、回车符(U+000D)、换页符(U+000C)、垂直制表符(U+000B),以及文件分隔符、组分隔符、行分隔符、段分隔符这些Unicode类别为SPACE_SEPARATOR、LINE_SEPARATOR、PARAGRAPH_SEPARATOR的字符。
这里有个特别容易踩的坑:全角空格(U+3000)。中文输入法下按空格键打出来的就是它,Character.isWhitespace对它是返回true的,但如果你用str.replace(" ", "")这种只认半角空格的写法,全角空格会被原样保留。我之前处理过一批从网页表单提交上来的地址数据,用户复制粘贴时带进了全角空格,结果按空格切分地址的代码全部失效,排查了大半天才发现问题出在这个看不见的字符上。
还有一个更隐蔽的:不间断空格(U+00A0,NBSP)。它常见于从Word文档、PDF或者某些富文本编辑器复制出来的内容里。这个字符的“坑点”在于,Character.isWhitespace对它返回的是false,因为它被设计成“不可断行”的空白,不属于Java定义的可空白字符。所以如果你只用isWhitespace做过滤,NBSP会漏网。要处理它,得单独判断码点是否等于0x00A0,或者用正则里的\s配合UNICODE_CHARACTER_CLASS标志。
下面这张表把常见空白字符列清楚,方便对照排查:
| 字符名称 | Unicode码点 | isWhitespace结果 | 常见来源 |
|---|---|---|---|
| 半角空格 | U+0020 | true | 键盘直接输入 |
| 制表符 | U+0009 | true | 代码缩进、Excel复制 |
| 换行符 | U+000A | true | 多行文本 |
| 回车符 | U+000D | true | Windows换行 |
| 全角空格 | U+3000 | true | 中文输入法 |
| 不间断空格 | U+00A0 | false | Word/PDF复制 |
| 零宽空格 | U+200B | false | 网页富文本 |
零宽空格(U+200B)也值得单独提一句,它宽度为零、肉眼完全看不见,isWhitespace同样返回false,但在字符串比较、长度计算时会产生诡异的结果。处理用户输入时,如果发现两个看起来一模一样的字符串equals返回false,八成就是这类不可见字符在作祟。
1.2 三种典型需求场景拆解
把需求分清楚,方案自然就出来了。实际项目里最常见的三种场景:
场景一:去掉全部空白,做紧凑化处理。比如把身份证号、手机号、银行卡号里的空格全部清掉再入库。这类场景要求“一个不留”,包括中间的空格。典型实现是正则replaceAll("\\s+", "")或者逐字符过滤。
场景二:只去首尾空白,保留中间结构。比如用户填写的姓名、标题,前后可能带空格,但中间的空格(如英文名“John Smith”)必须保留。这种用String.trim()或者更推荐的String.strip()。
场景三:规范化空白,把连续空白压缩成单个空格。比如处理从网页抓下来的正文,里面混着大量换行和连续空格,需要压成正常段落。这种用replaceAll("\\s+", " ")再配合trim。
注意:
trim()和strip()的区别是新手最容易混淆的点。trim()只去除码点小于等于U+0020的字符,对全角空格无效;strip()是Java 11引入的,基于Character.isWhitespace判断,能正确处理全角空格等Unicode空白。项目如果还在Java 8,就得自己写工具方法补上这个能力。
2. 五种实现方案逐个拆解
概念理清之后,进入正题。Java里去除空白符至少有五种主流写法,每种都有它的适用边界。我按“从简单到复杂、从粗糙到精细”的顺序逐个讲,每个都给出代码、说明适用场景和性能特征。
2.1 字符串替换法:replace与replaceAll
最直觉的写法就是替换。String.replace(CharSequence, CharSequence)做的是字面量替换,不涉及正则,性能好;String.replaceAll(String, String)第一个参数是正则表达式,灵活但每次调用都要编译正则(除非用预编译的Pattern)。
String text = " Java 去 除 空 格 "; // 只替换半角空格,全角空格和制表符都动不了 String r1 = text.replace(" ", ""); // 正则匹配所有空白字符,包括全角空格 String r2 = text.replaceAll("\\s+", ""); // 预编译Pattern,循环处理大量字符串时性能更好 private static final Pattern WHITESPACE = Pattern.compile("\\s+"); String r3 = WHITESPACE.matcher(text).replaceAll("");这里的关键差异在于\s的匹配范围。默认情况下,Java正则的\s等价于[ \t\n\x0B\f\r],也就是只匹配ASCII空白,不包含全角空格U+3000。想要让它识别Unicode空白,必须加上标志:
Pattern.compile("\\s+", Pattern.UNICODE_CHARACTER_CLASS);加上这个标志后,\s会匹配所有Unicode空白字符,包括全角空格。但要注意,即便加了标志,NBSP(U+00A0)依然不在\s的匹配范围内,因为它在Unicode分类里属于“非断行空格”,需要单独用\u00A0或者\p{Zs}处理。\p{Zs}匹配所有空格分隔符,覆盖面更广。
实测下来,处理纯ASCII文本时,replace(" ", "")比replaceAll快不少,因为省掉了正则引擎的开销。但如果文本里可能混入各种Unicode空白,还是老老实实用带标志的正则,或者自己写字符过滤,别为了那点性能埋雷。
2.2 trim与strip:首尾清理的正确姿势
只去首尾空白的话,trim()是最老牌的方案,但它的判断标准很粗暴:只认码点<= ' '(即U+0020)的字符。这意味着它能去掉半角空格、制表符、换行符,但对全角空格(U+3000)无能为力。
String s1 = "\u3000 hello \u3000"; // 前后是全角空格 System.out.println(s1.trim()); // 输出 "\u3000 hello",全角空格还在 System.out.println(s1.strip()); // 输出 "hello",全角空格被去掉strip()是Java 11新增的,内部用Character.isWhitespace判断,能正确处理全角空格。它还有两个变体:stripLeading()只去头部,stripTrailing()只去尾部,处理某些拼接场景时很有用。
如果项目卡在Java 8没法升级,可以自己实现一个等价方法:
public static String stripUnicode(String str) { if (str == null) return null; int start = 0, end = str.length(); while (start < end && Character.isWhitespace(str.charAt(start))) start++; while (end > start && Character.isWhitespace(str.charAt(end - 1))) end--; return str.substring(start, end); }这段代码逻辑不复杂,但要注意两个细节:一是用charAt逐字符判断,对BMP范围内的空白字符够用,但如果遇到增补平面的字符(码点大于0xFFFF),charAt会拆成代理对,判断可能出错。不过空白字符都在BMP内,实际不会有这个问题。二是substring在Java 7之后会复制字符数组,不会造成内存泄漏,可以放心用。
2.3 字符流过滤:逐字符判断的精细控制
当需求变得复杂——比如“去掉所有空白但保留换行符”,或者“只去掉特定几种空白”——正则表达式的可读性就开始下降了。这时候逐字符过滤反而更清晰。
public static String removeWhitespace(String str, boolean keepNewline) { if (str == null) return null; StringBuilder sb = new StringBuilder(str.length()); for (int i = 0; i < str.length(); i++) { char c = str.charAt(i); if (keepNewline && (c == '\n' || c == '\r')) { sb.append(c); } else if (!Character.isWhitespace(c) && c != '\u00A0' && c != '\u200B') { sb.append(c); } } return sb.toString(); }这种写法的优势是规则完全可控。你可以精确指定哪些字符保留、哪些丢弃,还能顺手处理NBSP和零宽空格这些正则容易漏掉的字符。性能上,单次遍历加StringBuilder追加,复杂度O(n),比正则引擎的匹配过程更轻量,处理大文本时优势明显。
有个细节值得说:StringBuilder初始化时传入str.length()作为容量,能避免扩容时的数组复制。虽然对短字符串影响不大,但处理几十KB的文本时,这个预分配能省下不少开销。另外,如果确定结果长度和原串差不多,也可以直接用char[]数组加索引操作,性能还能再压一点,但代码可读性会下降,一般项目里没必要做到这个程度。
2.4 正则高级用法:分组保留与条件替换
有些场景不是简单“删掉”,而是“删掉但保留某些结构”。比如处理CSV行时,引号内的空格要保留,引号外的要压缩。这种就得靠正则的分组和回调。
// 把连续空白压缩成单个空格,但不动引号内的内容 String csv = "a, b , \"c d\", e"; Pattern p = Pattern.compile("\"([^\"]*)\"|(\\s+)"); Matcher m = p.matcher(csv); StringBuffer sb = new StringBuffer(); while (m.find()) { if (m.group(1) != null) { m.appendReplacement(sb, "\"" + m.group(1) + "\""); } else { m.appendReplacement(sb, " "); } } m.appendTail(sb);这段代码的思路是:正则先尝试匹配引号包裹的内容(group 1),匹配到了就原样保留;匹配不到就落到空白分支(group 2),替换成单个空格。appendReplacement和appendTail配合使用,是Java正则做条件替换的标准套路。
提示:
appendReplacement的替换字符串里,$和\有特殊含义,如果替换内容来自用户输入,必须先做转义,否则会抛IllegalArgumentException。稳妥的做法是用Matcher.quoteReplacement()包一层。
这种方案适合格式相对固定的文本,比如日志行、CSV、简单的标记语言。如果文本结构复杂到正则难以描述,就该考虑上解析器了,硬用正则只会写出没人能维护的怪物。
2.5 第三方工具类:别重复造轮子
Apache Commons Lang的StringUtils提供了几个现成方法,省得自己写边界判断:
import org.apache.commons.lang3.StringUtils; StringUtils.deleteWhitespace(str); // 删除所有空白字符 StringUtils.strip(str); // 去首尾空白(null安全) StringUtils.normalizeSpace(str); // 去首尾并把连续空白压成单个空格 StringUtils.trimToNull(str); // 去空白后为空则返回nulldeleteWhitespace内部用的是Character.isWhitespace判断,所以全角空格能处理,但NBSP同样漏网。normalizeSpace在处理网页正文、日志清洗时特别好用,一行顶好几行正则。
用第三方库的代价是引入依赖。如果项目里已经有Commons Lang,直接用没问题;如果只是为了一个去空格功能引入整个jar,就有点杀鸡用牛刀了。我的建议是:小项目自己写工具方法,大项目或者已经依赖了Commons Lang的,直接用现成的,别重复造轮子。
3. 性能对比与选型决策
方案列完了,接下来是选型。选型不能只看“能不能用”,得看数据量、调用频率、文本特征。我做过一组简单的基准测试,用不同长度的字符串跑一百万次,结果能说明不少问题。
3.1 各方案性能实测数据
测试环境是常见的开发机配置,JDK 17,字符串分别取100字符、1KB、100KB三种规模,每种方案跑100万次(大文本降到1万次),取平均耗时。数据只反映趋势,具体数值会随硬件和JVM版本浮动:
| 方案 | 100字符 | 1KB | 100KB | 说明 |
|---|---|---|---|---|
replace(" ","") | 最快 | 快 | 快 | 仅限半角空格 |
预编译Pattern +\s+ | 中等 | 中等 | 中等 | 通用性好 |
replaceAll每次编译 | 慢 | 慢 | 很慢 | 循环里禁用 |
| 字符流过滤 | 快 | 快 | 最快 | 规则可控 |
StringUtils.deleteWhitespace | 中等 | 中等 | 中等 | 内部也是字符遍历 |
结论很清晰:循环里绝对不要直接调replaceAll,每次调用都重新编译正则,开销是预编译的几十倍。字符流过滤在大文本上表现最好,因为它是单次遍历、无回溯、无中间对象。replace在只处理半角空格时最快,但适用范围窄。
3.2 按场景选型的决策清单
把上面的分析整理成一张决策表,实际开发时对着选就行:
| 需求 | 推荐方案 | 理由 |
|---|---|---|
| 只去半角空格 | replace(" ", "") | 最快,无正则开销 |
| 去所有Unicode空白 | 预编译\s++UNICODE_CHARACTER_CLASS | 覆盖全,可读性好 |
| 去首尾空白(Java 11+) | strip() | 标准库,正确处理全角 |
| 去首尾空白(Java 8) | 自写stripUnicode | 补上标准库缺失 |
| 压缩连续空白 | replaceAll("\\s+", " ")或normalizeSpace | 语义清晰 |
| 复杂条件过滤 | 字符流过滤 | 规则完全可控 |
| 大文本批量处理 | 字符流过滤 + 预分配容量 | 性能最优 |
选型时还有一个容易被忽略的维度:null安全。String.replace系列方法在调用者为null时会抛NPE,而StringUtils的方法大多返回null或空串。如果输入来源不可控(比如接口参数、数据库字段),要么在入口处统一判空,要么用null安全的工具方法,别等到线上报NPE才想起来。
4. 实战中的坑与排查技巧
理论讲完,该说点真刀真枪的东西了。下面这些坑,有的是我自己踩的,有的是review代码时发现的,都是文档里不会写的经验。
4.1 全角空格与不可见字符的排查
最典型的问题:明明看着没有空格,equals却返回false,或者按空格split出来的数组长度不对。排查这类问题,第一步是把字符串的每个字符的码点打出来:
public static void dumpChars(String str) { str.codePoints().forEach(cp -> System.out.printf("U+%04X %s%n", cp, Character.getName(cp))); }Character.getName能给出字符的Unicode名称,全角空格会显示为IDEOGRAPHIC SPACE,NBSP显示为NO-BREAK SPACE,零宽空格显示为ZERO WIDTH SPACE,一眼就能定位。这个方法我在排查编码问题时用了无数次,强烈建议收进工具箱。
另一个实用技巧是在日志里输出字符串时,把不可见字符替换成可见标记:
String debug = str.replace("\u3000", "[全角空格]") .replace("\u00A0", "[NBSP]") .replace("\u200B", "[零宽]");这样日志里一眼就能看出问题字符藏在哪,比盯着空白发呆强多了。
4.2 正则回溯导致的性能陷阱
用replaceAll("\\s+", "")处理正常文本没问题,但如果正则写得更复杂,比如"(\\s*,\\s*)+"这种嵌套量词,遇到不匹配的长文本会触发灾难性回溯,CPU直接飙满。我见过一个接口因为清洗逻辑里的正则写得太贪心,单次请求耗时从几毫秒涨到几秒,最后靠线程dump才定位到。
规避方法有三条:一是尽量用简单的字符类,别嵌套量词;二是对超长文本做长度限制,超过阈值走流式处理;三是用Pattern预编译并复用,至少省掉编译开销。如果确实需要复杂匹配,考虑用possessive quantifier(如\\s++)或者原子分组,能有效抑制回溯。
4.3 常见问题速查表
把高频问题整理成表,遇到时直接对照:
| 现象 | 可能原因 | 解决 |
|---|---|---|
| 全角空格没去掉 | 用了replace(" ","")或trim() | 改用strip()或带标志的正则 |
| NBSP残留 | isWhitespace对NBSP返回false | 单独判断\u00A0 |
| 循环里越来越慢 | 每次调replaceAll重新编译 | 预编译Pattern |
| split结果异常 | 分隔符是正则元字符 | 用Pattern.quote转义 |
| 字符串比较不相等 | 混入零宽字符 | dump码点排查 |
| 大文本OOM | 中间字符串对象过多 | 字符流过滤 + StringBuilder预分配 |
注意:
String.split的参数是正则表达式,如果分隔符本身是.、|、*这类元字符,必须转义。我见过用split(".")想按点号切分的代码,结果返回空数组,因为.在正则里匹配任意字符。正确写法是split("\\.")或split(Pattern.quote("."))。
5. 封装一个靠谱的空白处理工具类
讲了这么多方案,最后落到工程实践上:与其在每个业务类里散落各种replaceAll,不如统一封装一个工具类,把边界情况一次性处理干净。下面这个类是我在多个项目里迭代出来的版本,覆盖了前面提到的所有坑点。
5.1 工具类设计与实现
public final class WhitespaceUtils { private static final Pattern UNICODE_WS = Pattern.compile("[\\s\\p{Zs}\\u00A0\\u200B]+", Pattern.UNICODE_CHARACTER_CLASS); private WhitespaceUtils() {} /** 删除所有空白字符,包括全角空格、NBSP、零宽空格 */ public static String removeAll(String str) { if (str == null || str.isEmpty()) return str; return UNICODE_WS.matcher(str).replaceAll(""); } /** 去首尾空白,null安全,兼容Java 8 */ public static String strip(String str) { if (str == null) return null; int start = 0, end = str.length(); while (start < end && isBlankChar(str.charAt(start))) start++; while (end > start && isBlankChar(str.charAt(end - 1))) end--; return str.substring(start, end); } /** 压缩连续空白为单个半角空格,并去首尾 */ public static String normalize(String str) { if (str == null || str.isEmpty()) return str; return UNICODE_WS.matcher(str).replaceAll(" ").trim(); } private static boolean isBlankChar(char c) { return Character.isWhitespace(c) || c == '\u00A0' || c == '\u200B'; } }设计上有几个考量点值得说明。第一,正则里同时用了\s、\p{Zs}、\u00A0、\u200B,看起来冗余,实际是层层兜底:\s覆盖ASCII空白,\p{Zs}覆盖各类空格分隔符(含全角空格),后两个单独列出处理isWhitespace漏掉的特殊字符。第二,strip方法没有用正则,而是逐字符扫描,因为首尾清理通常只涉及少量字符,正则的编译和匹配开销反而不划算。第三,所有方法都做了null和空串的短路判断,避免无谓的对象创建。
5.2 单元测试要点
工具类写完,测试必须跟上。空白处理的测试用例有几个必测项:全角空格、NBSP、零宽空格、混合空白、null输入、空串输入、纯空白串。下面给几个关键断言:
@Test public void testRemoveAll() { assertEquals("abc", WhitespaceUtils.removeAll(" a\u3000b\u00A0c ")); assertEquals("", WhitespaceUtils.removeAll("\u3000\u00A0\u200B")); assertNull(WhitespaceUtils.removeAll(null)); } @Test public void testStrip() { assertEquals("a b", WhitespaceUtils.strip("\u3000 a b \u00A0")); assertEquals("", WhitespaceUtils.strip(" ")); }测试里用\uXXXX转义写特殊字符,比直接粘贴可见字符可靠得多——后者在IDE编码设置不一致时容易变成乱码,导致测试莫名其妙失败。另外,纯空白串的用例一定要有,很多边界bug就出在“全是空白”这种输入上。
提示:如果项目用JUnit 5,可以配合
@ParameterizedTest把各种空白字符做成数据源,一次性覆盖所有码点,比手写一堆assertEquals更清晰,也不容易漏。
6. 不同业务场景的落地建议
工具类有了,最后说说在不同业务里怎么用。同样是去空格,用户输入清洗、日志处理、数据导入的侧重点完全不同,一刀切的做法迟早出问题。
6.1 用户输入清洗的防御性处理
用户输入是最不可控的来源。表单字段、接口参数、文件上传,任何一处都可能混入奇怪字符。我的建议是在入口层统一清洗,而不是在每个业务方法里零散处理。比如在参数校验环节加一道预处理:
public String sanitizeInput(String raw) { if (raw == null) return null; // 先去掉不可见字符,再压缩空白,最后去首尾 return WhitespaceUtils.normalize(WhitespaceUtils.removeAll(raw)); }但要注意,不是所有字段都适合去空格。密码字段绝对不能去空格,因为空格是合法密码字符;富文本内容也不能粗暴压缩,会破坏排版。清洗规则要按字段类型区分,别图省事全局套一个方法。我见过一个系统把密码里的空格也trim掉了,导致用户在某些客户端能登录、在另一些客户端登录失败,排查了很久。
6.2 日志与文本分析的空白规范化
处理日志、抓取正文这类场景,目标不是“删干净”,而是“规范化”。连续的空格和换行压成单个空格,既保留可读性,又方便后续分词和检索。normalize方法就是为这个场景设计的。
如果文本量很大(比如每天几个GB的日志),逐条调正则仍然有开销。这时候可以考虑流式处理:按行读取,每行做规范化后写出,内存占用恒定。用BufferedReader配合BufferedWriter,配合前面预编译的Pattern,吞吐量能稳定在不错的水平。实测下来,单线程处理百万行日志在秒级完成,瓶颈通常在磁盘IO而不是字符串处理本身。
6.3 数据导入时的编码与空白双重校验
从Excel、CSV导入数据是空白问题的重灾区。除了全角空格和NBSP,还可能遇到BOM头(U+FEFF)——它出现在文件开头,肉眼不可见,但会让第一列的字段名匹配失败。处理方式是读取时先判断并剥离BOM:
if (line.startsWith("\uFEFF")) { line = line.substring(1); }导入流程里,我通常会在解析出每行数据后,对每个字段统一做一次strip加不可见字符过滤,再做类型转换和校验。这样即使源文件有脏字符,也能在入库前拦下来,而不是等到查询时才发现数据对不上。校验失败的行要记录原始内容和码点dump,方便回溯问题源头。
7. 一点个人经验
写了这么多,最后说几句掏心窝的话。空白字符处理这件事,技术难度不高,但细节密度极大。我见过因为一个全角空格导致对账失败的,也见过因为零宽字符导致缓存key不命中的。这些问题的共同点是:出问题时现象诡异、排查成本高,但根因往往就是一行想当然的replace。
我的习惯是:凡是处理外部来源的字符串,先假设它“脏”,在入口处用统一的工具方法过一遍,把不可见字符、全角半角、首尾空白一次性处理干净。工具方法要有单元测试兜底,特殊字符用转义写,别依赖肉眼。排查问题时,dump码点永远比盯着字符串猜有效。这套习惯养成之后,因为空白字符引发的线上问题基本绝迹了。
另外,别迷信“性能最优”的写法。字符流过滤确实快,但如果业务量根本到不了那个级别,用预编译正则的可读性更好,维护成本更低。选型的核心是匹配场景,不是追求纸面上的极致。真正该警惕的是循环里反复编译正则这种明显的性能反模式,把这类问题堵住,剩下的差异对大多数项目来说可以忽略不计。