
先说一个我经常在群里看到的画面A同学贴了一段代码问Java里怎么判断一个字符串是不是数字底下立刻有两派回复一派说try-catch包一个Double.parseDouble()另一派说用正则表达式。两派都没错但如果你真的去问什么样的字符串算数字是整数、小数、还是带科学计数法空格和正号算不算大多数人会愣一下。这篇博文就专门聊透Java中用正则表达式验证数字字符串这件事从最朴素的\\d一直写到能上生产环境的工具类把我在实际项目里踩过的那些看着对但其实是错的坑一起交代清楚。1. 验证数字字符串需求场景与常见误区1.1 哪些场景真正需要数字字符串校验先说需求。天天处理用户输入、接口报文、CSV导入的程序员一定绕不开这个场景前端传了一个字符串类型的字段比如age、price、port你需要在后端拦截非法值不能让它一路传到数据库里炸掉。又比如解析某外部系统返回的字段对方文档写着必须是数字字符串可你收到的却是空串、NaN、1,234、全角数字这时候一个可靠的正则校验就能挡掉大部分脏数据。别小看这个需求它本质上是在回答一个问题数字字符串到底是个什么集合如果是纯整数-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.是 true1.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时它会返回 truelookingAt()要求从区域开头开始匹配但不要求到结尾比如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等。你校验表单时用户如果从微信或苹果输入法里打出全角\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, , , }; for (String s : arr) { System.out.printf(%-8s 整数:%b 小数:%b 数字:%b%n, s, isInteger(s), isDecimal(s), isNumber(s)); } }你会看到全角数字在三个方法里都是 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校验导致某个外部系统传进来的全角数字被放行入库后我的同事在SQL里WHERE id 查不出数据排查了一下午才发现是全角问题。第三个坑比较隐蔽正则用了^\\d$但实际调用的是find()导致abc123被误判为合法数字。原因是我把提取那段代码复制过来改布尔判断时忘了改方法名。这三点都不需要多高深的知识全靠细心。我现在写这类校验已经养成两个习惯第一所有正则里表示数字的部分全部用[0-9]而不是\d第二写完先列一张包含正常值、边界值、异常值的测试表自己跑一遍再交出去。正则这种东西写出来是第一步把它放进真实输入里验证才是真正的完成。