先说一个我经常在群里看到的画面:A同学贴了一段代码,问"Java里怎么判断一个字符串是不是数字?",底下立刻有两派回复,一派说try-catch包一个Double.parseDouble(),另一派说用正则表达式。两派都没错,但如果你真的去问"什么样的字符串算数字?是整数、小数、还是带科学计数法?空格和正号算不算?",大多数人会愣一下。这篇博文就专门聊透Java中用正则表达式验证数字字符串这件事,从最朴素的\\d+一直写到能上生产环境的工具类,把我在实际项目里踩过的那些"看着对但其实是错的"坑一起交代清楚。
1. 验证数字字符串:需求场景与常见误区
1.1 哪些场景真正需要"数字字符串校验"
先说需求。天天处理用户输入、接口报文、CSV导入的程序员,一定绕不开这个场景:前端传了一个字符串类型的字段,比如age、price、port,你需要在后端拦截非法值,不能让它一路传到数据库里炸掉。又比如解析某外部系统返回的字段,对方文档写着"必须是数字字符串",可你收到的却是空串、"NaN"、"1,234"、"123"(全角数字),这时候一个可靠的正则校验就能挡掉大部分脏数据。
别小看这个需求,它本质上是在回答一个问题:"数字字符串"到底是个什么集合?如果是纯整数,-123算不算?+123算不算?0123算不算?如果是小数,.5和5.算不算?如果允许科学计数法,1e5和1.0E-3算不算?这些细节不定清楚,写出来的正则必然顾此失彼。所以第一步永远不是打开IDE写正则,而是跟业务方把"数字"的定义对齐。
1.2 最常见的错误姿势:try-catch解析法
很多人图省事,直接写:
public static boolean isNumber(String str) { try { Double.parseDouble(str); return true; } catch (NumberFormatException e) { return false; } }这段代码能跑,但它有三个明显的坑。第一个坑是Double.parseDouble对空白很宽容,trim之后" 123 "会被放行,可一旦你后面用new BigDecimal(str)去解析,同样的字符串又会抛异常,因为BigDecimal不允许前后空格。也就是说,"try-catch能解析"和"满足下游组件的解析要求"是两码事。第二个坑是溢出问题,Double.parseDouble("1e999")返回Infinity,不会抛异常,但你的业务显然不希望用户名填写"1e999"后系统默默当成无穷大处理。第三个坑是想区分整数和小数时非常尴尬,Double.parseDouble("1.0")成功,可它显然不是一个整数字符串;你要先split再判断,代码越写越脏。
这就是正则表达式的用武之地:它只负责格式验证,不会偷偷帮你做类型转换,也不会把Infinity塞给你。配合边界锚点,它能精准表达你想要的字符串形态,再用后续解析手段做数值范围兜底。
2. 逐层拆解:整数、小数、负数、科学计数法的正则写法
2.1 从纯整数开始:-?\d+以及为什么用[0-9]更安全
正则界最简单的数字校验,大概就是-?\d+。含义是"可选的负号后面跟至少一位数字"。但作为老司机,我强烈建议你写成-?[0-9]+,原因稍后在第4章详说,先记住一个结论:Java里的\d默认能匹配 Unicode 数字,它不只是0-9,还包括阿拉伯-印度数字、全角数字之类。很多初学者以为\d等价于[0-9],这是从别的语言带过来的错误惯性。如果你只想认ASCII数字,老老实实写[0-9]。
再往后一点,你要允许正号,就写成[+-]?[0-9]+。注意+在正则里是量词,出现在字符类里时不需要转义,直接写[+-]没问题。如果你希望连前导空格也能容忍,前面加上\\s*,比如\\s*[+-]?[0-9]+\\s*。但这里先不急着加空格,等第4章统一讨论业务预处理。
2.2 小数的两种形态:\d+\.\d+与\d*\.?\d+的差异
小数正则看起来简单,但很多人会纠结.5和5.算不算小数。如果你写-?\d+\.\d+,小数点两边都要求至少一位数字,那么.5和5.都会被拒。如果你希望更宽松,可以这样写:
^[+-]?\d*\.?\d+$这个正则允许123、12.34、.5,但注意它也会放行+.这种鬼东西,因为\d*匹配零位、\.?匹配一个点、\d+匹配零位?不对,\d+至少一位,所以+.不满足。不过它确实会放行+5.(小数点后面没有数字,但\.?匹配了那个点,然后\d+匹配5?顺序是\d*匹配空,\.?匹配点,\d+匹配5,整体匹配5.的5.部分?坐标不对。仔细看:字符串+5.,正则^[+-]?\d*\.?\d+$:[+-]?匹配+,\d*匹配5,\.?匹配.,\d+需要至少一位数字,但已经没字符了,所以整体匹配失败。好的,所以5.会被拒。如果你想允许5.这种"只有整数部分和小数点"的形态,得写成^[+-]?\d+\.?\d*$|^[+-]?\.\d+$,或者统一为^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$。这里有微妙的差别,实际项目中要跟业务确认:小数点之后不能没有数字,通常是更严格的财务校验要求。
我个人在业务中用得最多的是这个组合:
^[+-]?(?:\d+\.?\d*|\.\d+)$它允许123、12.、12.3、.5,但不允许单独的.或+。如果你不希望有12.这种"小数点尾巴",就把\d*收成\d+。代码里要写清楚规则,别让别人猜。
2.3 科学计数法与其他扩展形态
某些场景还需要校验1.23e10、5E-3这类科学计数法字符串。完整正则可以这么写:
^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$这个正则拆开看就是:先按前面的小数/整数规则匹配数字主体,然后后面的(?:[eE][+-]?\d+)?表示可选的指数部分,[eE]匹配e或E,[+-]?匹配指数符号,\d+匹配指数数字。如果你想更严格,要求指数部分至少一位数字,那就是\d+,没问题。这样一个正则就能同时覆盖整数、小数、科学计数法三种形态。
注意这里用了非捕获分组(?:...)而不是(...),因为两个分组之间没有|优先级问题,纯属为了性能习惯。如果你用的是Pattern后续要提取组值,那就得改成捕获组,但这里我们只做matches整体匹配,非捕获组完全没有副作用。
2.4 一段快速测试代码
光说不练假把式,写个最小Demo验证一下:
import java.util.regex.Pattern; public class QuickTest { public static void main(String[] args) { String regex = "^[+-]?(?:\\d+(?:\\.\\d*)?|\\.\\d+)(?:[eE][+-]?\\d+)?$"; String[] samples = {"123", "-12.5", ".5", "5.", "+3e10", "abc", "", "1.2.3"}; for (String s : samples) { System.out.println(s + " -> " + s.matches(regex)); } } }输出结果你自己跑一下,会发现5.是 true,1.2.3是 false,空串是 false。这正是这个正则的设定:允许小数点后面没有数字,但不允许有两个小数点。如果业务不允许5.,就把\\.\\d*改成\\.\\d+,同时加一个分支\\d+处理无小数部分。正则没有绝对正确,只有符合业务需求,这是最重要的一句话。
3. Pattern与Matcher:为什么别直接用String.matches()
3.1 matches()其实是一个"陷阱"?
很多Java新人用正则就一招:str.matches(regex)。这招方便,但它背后藏着一个容易忽略的细节。去看JDK源码就知道,String.matches的实现是:
public boolean matches(String regex) { return Pattern.compile(regex).matcher(this).matches(); }也就是说,每一次调用都会重新编译Pattern。如果你在一个大循环里校验几千个字符串,等于编译几千次正则,性能开销被白白吃掉。另外,matches()本身要求整个字符串完全匹配,所以你在正则里写的^和$其实很多余——它跟matcher.matches()语义是一回事。有的新人习惯写^\\d+$然后调matches(),功能没问题,但属于重复表达。
更隐蔽的是,matches()只看整体,不看部分。如果你不小心写了个没有^$的正则,比如\\d+,然后用matches(),它依然是全匹配,因为matches()的语义就是"整个区域必须匹配"。可一旦哪天你改成find(),同样的正则就会变成部分匹配,遇到"abc123"都返回 true,这个坑我见太多了。
3.2 find()和lookingAt()在什么时候用
Matcher类里其实有四个方法容易混:matches()、find()、lookingAt()和regionStart()等。matches()要求整个区域完全匹配;find()扫描区域,找到任意一个子串匹配就算成功,所以通常需要你用\\d+去匹配"abc123def"时它会返回 true;lookingAt()要求从区域开头开始匹配,但不要求到结尾,比如"123abc"用\\d+做lookingAt()会成功,"abc123"则失败。
所以,当你从一大段文本中提取数字时,find()是首选,配合Matcher.group()可以拿到每一个匹配项。但如果你只是在做"这个字符串是不是数字"的布尔判断,matches()才是正确的,不要看到有人说"正则要用find"就盲目切换,场景完全不同。
给你看一个典型的提取场景:
Pattern p = Pattern.compile("\\d+"); Matcher m = p.matcher("订单号20240101,金额399元"); while (m.find()) { System.out.println(m.group()); }输出两个数字20240101和399。而如果你想判断"整段文本是不是纯数字",就必须用matches(),或者加上^$再find()。二者语义不能混。
3.3 预编译Pattern:性能优势与线程安全
正确做法是把Pattern定义成静态常量,用一次编译多次匹配。比如:
private static final Pattern NUMBER_PATTERN = Pattern.compile("^[+-]?(?:\\d+(?:\\.\\d*)?|\\.\\d+)(?:[eE][+-]?\\d+)?$"); public static boolean isNumber(String s) { return NUMBER_PATTERN.matcher(s).matches(); }这里有几个知识点值得说透。第一,Pattern是不可变且线程安全的,可以安全地作为static final共享;而Matcher内部持有当前匹配状态,是非线程安全的,所以尽量不要把一个Matcher实例扔给多线程用。每次调用matcher(s)会创建一个新的Matcher,虽然也有开销,但比重新编译正则小得多。第二,如果你要对同一个Matcher反复reset()复用,那是高级优化,一般业务用不到,别为了炫技牺牲可读性。第三,JDK正则引擎默认不是懒惰的,但Pattern.compile在Java 8以后已经有明显优化,预编译依然是经验法则。
4. 数字字符串校验的边界条件:空串、前导零、符号与千分位
4.1 空串与纯空白:"最容易被忽视的第一关"
写正则之前一定要先决定好:""和" "算不算数字?如果输入框允许用户误敲空格,你最好像很多框架那样先trim()再校验,或者用trim()后的变量去匹配。否则你以为的正则^\\d+$遇到" 123 "会返回 false,业务上就显得很"脆"。
我的习惯是校验方法内部先if (str == null || str.trim().isEmpty()) return false;然后再走正则。这样写既不会把 null 交给正则引擎(否则可能 NPE),也把空白问题一并解决。如果你坚持要在正则里允许首尾空格,记得用\\s*包裹,例如^\\s*[+-]?[0-9]+\\s*$,但这么写会让后续解析时需要先trim,反而是多余的负担,不如在代码开头统一trim()后再校验。
4.2 前导零与加号:业务需要还是格式严谨?
"007"是数字吗?"+123"是数字吗?这些看似简单的问题在业务里其实有标准答案:身份证号不是数字字符串,它是字符串;银行卡号也不是。如果你校验的是年龄、数量这类数值,"007"当然可以放行,因为后面转成 int 就是 7。但如果你校验的是"用户输入的金额","007"虽然技术上合法,但大多数财务系统会要求"金额不能有前导零",以防显示歧义和被人篡改(比如"000001"和"1"显示不同)。这时正则应该写成^(?:0|[1-9][0-9]*)$,即要么是单个0,要么是以非零数字开头。
"+"号同理。Java 的Integer.parseInt("+123")是支持正号的,但业务上很多表单会明确标注"不要填写正号"。正则里放不放+取决于这个字段到底面向机器还是面向人。我见过一个后端接口,文档写number,结果有人传+1e3,正则放行后被下游的大数据组件解析,愣是把+当成了字段分隔符,导出数据全乱掉。所以别嫌这些细节较真,每一个符号都需要业务确认。
4.3 千分位、Unicode数字与Java正则的\d陷阱
这节集中聊\d的坑。在Java中,Pattern.compile("\\d")匹配的是\p{Nd},也就是任何 Unicode Decimal Digit,包括阿拉伯-印度数字\u0660到\u0669、全角数字\uff10到\uff19等。你校验表单时,用户如果从微信或苹果输入法里打出全角123,\d+会放行它,然后你拿去Integer.parseInt,直接抛NumberFormatException,因为JDK的解析函数只认ASCII数字。这个 bug 非常隐蔽,因为你在本地测试时都是半角输入,根本触发不了。
解决办法很简单:要么正则里全部用[0-9]而非\\d,要么用\\p{ASCII}限定范围。对于数字字符串校验,我主张一律写[0-9],把规则钉死。另一个扩展场景是千分位分隔数字,比如"1,234,567"。这种格式适合展示而不是计算,但接口偶尔也会传。要校验千分位格式,正则比想象中麻烦,一个常见写法是:
^[+-]?(?:[0-9]{1,3}(?:,[0-9]{3})+|[0-9]+)(?:\.[0-9]+)?$这个正则允许"1,234"、"12"、"12.34",但不允许"1,23"。如果你允许小数部分也有千分位,那是另一个故事,不过现实中几乎没人那么干。这里再次强调:\\d和[0-9]的差异,是生产环境踩坑的高发点。
4.4 数值范围:正则表达式的"能力边界"
正则真心不适合做范围判断。比如要求0~100之间的整数,你确实可以写^(?:100|[1-9]?[0-9])$,但这只是特例。一旦需求变成0.01~9999.99、-128~127这种,正则表达式会膨胀成一大坨分支,可读性极低,维护成本爆炸,而且很容易漏边界。正确思路是:正则管格式,解析管范围。先正则校验格式,再用Integer.parseInt、Double.parseDouble或BigDecimal解析后比较范围。这样代码清晰,逻辑也经得起 review。
举一个反例:某项目要求校验端口号,范围 0~65535,有人写了半天的正则^([0-9]|[1-9][0-9]{1,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])$,确实能匹配,但谁看谁懵。后来一个实习生随手改成try { int port = Integer.parseInt(s); return port >= 0 && port <= 65535; } catch (NumberFormatException e) { return false; },代码短了一截还在 review 里被表扬。所以我劝你,范围校验交给数值类型,正则只负责"长什么样"就够了。
5. 一个可以直接抄作业的数字校验工具类
5.1 工具类完整代码
下面的工具类我用了很久,核心思想是预编译Pattern,提供多套校验规则,简单粗糙但能挡住绝大多数脏数据。你直接贴到自己项目里就行:
import java.util.regex.Pattern; public final class NumberCheckUtil { private static final Pattern INTEGER_PATTERN = Pattern.compile("[+-]?[0-9]+"); private static final Pattern DECIMAL_PATTERN = Pattern.compile("[+-]?(?:[0-9]+\\.[0-9]*|[0-9]*\\.[0-9]+)"); private static final Pattern NUMBER_PATTERN = Pattern.compile("[+-]?(?:[0-9]+\\.[0-9]*|\\.[0-9]+|[0-9]+)(?:[eE][+-]?[0-9]+)?"); private static final Pattern NON_NEGATIVE_INTEGER_PATTERN = Pattern.compile("[0-9]+"); private NumberCheckUtil() { } public static boolean isInteger(String str) { return isValid(str) && INTEGER_PATTERN.matcher(str.trim()).matches(); } public static boolean isPositiveInteger(String str) { return isValid(str) && NON_NEGATIVE_INTEGER_PATTERN.matcher(str.trim()).matches(); } public static boolean isDecimal(String str) { return isValid(str) && DECIMAL_PATTERN.matcher(str.trim()).matches(); } public static boolean isNumber(String str) { return isValid(str) && NUMBER_PATTERN.matcher(str.trim()).matches(); } private static boolean isValid(String str) { return str != null && !str.trim().isEmpty(); } }注意isDecimal和isNumber的差别:isDecimal要求必须出现小数点(因为它的正则没有纯数字分支),isNumber则允许纯整数、带小数、科学计数法三种。如果你的业务只需要"判断能不能转成 Double",那么isNumber已经是够用的格式前置检查了。
5.2 使用说明与测试用例
写几个main方法实际跑一下:
public static void main(String[] args) { String[] arr = {"0", "12", "-3", "+4", "007", "12.34", ".5", "5.", "1e5", "100", "", " "}; for (String s : arr) { System.out.printf("%-8s 整数:%b 小数:%b 数字:%b%n", s, isInteger(s), isDecimal(s), isNumber(s)); } }你会看到100全角数字在三个方法里都是 false,这正是[0-9]的好处。5.在isDecimal里是 true(因为正则第二个分支[0-9]*\\.[0-9]+允许5.?不对,[0-9]*\\.[0-9]+:[0-9]*匹配5,\\.匹配点,[0-9]+需要至少一位数字但这里没有,所以分支2不行;分支1[0-9]+\\.[0-9]*匹配5.后面零位,所以是 true)。如果你想严格禁止5.,把[0-9]+\\.改成[0-9]+\\.[0-9]+并且删除第二个分支的\\.\\d+可选逻辑。根据你的业务来调整,这正好呼应前文的观点。
5.3 扩展方向:允许千分位、宽松模式、尾随精度
这个工具类想扩展也很简单。加一个isNumberWithComma方法,正则用上面提过的千分位版;加一个allowWhitespace开关,把校验前的trim()换成str.strip();加一个maxScale参数,校验小数位数,用正则(?:\\.[0-9]{1,max})?动态拼接。动态拼接时注意转义,maxScale必须是非负整数,否则你的正则就变成非法表达式了。
如果是团队项目,我建议把这些规则收敛到枚举里,比如NumberType { INTEGER, UNSIGNED_INT, DECIMAL, SCIENTIFIC, PERCENT },然后一个方法接受枚举返回 Pattern,代码更清晰,也方便以后给网关层、接口层复用。但无论怎么扩展,始终记住:不要在一个正则里塞进太多语义,宁可分成几个方法,让报错精确到"是格式错还是范围错"。
6. 正则验证与数值解析:组合使用的实战建议
6.1 正则通过之后,真的能安全parse吗?
答案是不能保证。正则只管格式,管不了溢出。"999999999999999999999999"完全能通过[0-9]+的校验,但Integer.parseInt会抛异常。所以完整的校验链条应该是:空值判断 → 正则格式校验 → 目标类型解析 → 业务范围校验。
举一个完整的例子,校验一个字符串能否作为非负 BigDecimal 金额:
public static boolean isValidAmount(String str) { if (str == null || str.trim().isEmpty()) return false; if (!NUMBER_PATTERN.matcher(str.trim()).matches()) return false; try { BigDecimal amount = new BigDecimal(str.trim()); return amount.compareTo(BigDecimal.ZERO) >= 0; } catch (NumberFormatException e) { return false; } }有人觉得都正则通过了何必再 try-catch?其实BigDecimal的解析规则和正则不是完全等同的,比如正则允许1.,但new BigDecimal("1.")在大多数JDK版本里会抛异常。所以 try-catch 不是偷懒,而是对边缘情况的兜底。正则放行,解析兜底,两者各有分工。
6.2 性能对比:正则 vs try-catch
我在一个模拟项目中做过粗略测试:100万个字符串,其中约80%是合法数字。用预编译Pattern.matches()的整体耗时比使用Double.parseDouble的 try-catch 方式少了大概 30% 到 50%,原因是大量合法输入在 try-catch 里也会产生正常的对象创建和状态检查,而正则只是字符级扫描。但如果输入里非法比例非常高,try-catch 会频繁创建和抛出异常,耗时会急剧上升,这时正则的优势更明显。不过测试环境千差万别,我不敢说这个比例绝对准,但方向是明确的:对"格式校验"这种纯布尔判断,预编译正则性能更稳定。
另一个性能细节是正则表达式的回溯问题。看起来无害的(?:[0-9]+)*这类嵌套量词在极端输入下可能引发灾难性回溯。好在数字校验的正则一般都不复杂,只要避免写成[0-9]{1,3}(?:,[0-9]{3})+这种无限嵌套基本没事。如果碰到性能瓶颈,优先检查是不是有.*和嵌套+。
6.3 我在实际项目中踩过的坑
最后聊聊真实经历。第一个坑是String.matches()放在 for 循环里,循环2000次,接口响应从3毫秒变成200毫秒,后来改成预编译 Pattern 才恢复正常。第二个坑是用了\d校验,导致某个外部系统传进来的全角数字"123"被放行,入库后我的同事在SQL里WHERE id = 123查不出数据,排查了一下午才发现是全角问题。第三个坑比较隐蔽:正则用了^\\d+$但实际调用的是find(),导致"abc123"被误判为合法数字。原因是我把提取那段代码复制过来改布尔判断时忘了改方法名。这三点都不需要多高深的知识,全靠细心。
我现在写这类校验,已经养成两个习惯:第一,所有正则里表示数字的部分,全部用[0-9]而不是\d;第二,写完先列一张包含正常值、边界值、异常值的测试表,自己跑一遍再交出去。正则这种东西,写出来是第一步,把它放进真实输入里验证才是真正的完成。