Java包装类与Math类:核心机制、常见坑位与实战
搞Java开发这些年我见过不少人把“常用类”这一章草草翻过去——String类天天在用看得仔细包装类和Math类觉得没什么可看的就是几个静态方法嘛。真到写代码、排查线上问题时才发现坑全埋在“简单”下面。包装类涉及自动装箱、缓存、空指针、相等比较这些日常高频问题Math类则是随手调用的数学工具箱用对了省事用反了出事故。这篇文章我就以第13章这两个主题为线索把包装类和数学类的核心机制、高频用法、常见坑位一次说清楚适合正在学JavaSE基础的同学也适合准备面试或者写工具类时翻出来对照。1. 包装类先理解“int为什么要有对象”这件事1.1 为什么需要包装类它们和基本类型的对应关系Java有8种基本类型但它们不是对象不能放进泛型容器也不能调用方法。与此同时集合框架List、Map、Set这些要求元素必须是Object的子类泛型擦除后也不支持基本类型。这两套体系是割裂的为了打通它们JDK为每种基本类型都配了一个对应的类这些类统称为包装类。对应关系很简单基本类型包装类默认值基本类型默认值包装类intInteger0nulllongLong0LnullshortShort0nullbyteByte0nullcharCharacter\u0000nullbooleanBooleanfalsenullfloatFloat0.0fnulldoubleDouble0.0dnull注意两个特别的名字int对应Integerchar对应Character其他六个都是基本类型名首字母大写。这个表值得背下来因为包装类默认值是null这一点决定了它和基本类型最本质的区别基本类型永远有值包装类能表达“没有值”或“尚未设置”的状态。实际开发中这个差异非常关键。比如数据库某个字段允许为空映射到Java实体里如果定义成int查询出来会自动填0用户明明没设置过页面却显示0容易产生误导。定义成Integer则能拿到null前端就知道这个字段没有值该隐藏隐藏、该提示提示。JSON返回给前端的时候Integer是null会输出nullint则会输出0很多联调问题就是从这里开始的。1.2 自动装箱与拆箱编译器的“隐形代码”JDK 5之后Java支持自动装箱和自动拆箱写起来很爽但很多人没搞懂底层发生了什么。比如这一行Integer n 42;编译器会自动把它翻译成Integer n Integer.valueOf(42);这就是自动装箱基本类型变成了包装类对象。反过来的情况int sum n 1;编译时会变成int sum n.intValue() 1;这就是自动拆箱包装类对象变回基本类型。问题在于拆箱过程是编译器悄悄帮你加了一句xxxValue()调用。如果这个包装类是null调方法时就会抛NullPointerException。看这个经典例子Integer total null; int sum total 1; // 运行到这里直接NPE一眼看上去是简单的加法但实际上这里发生了total.intValue()调用total是null直接崩了。我当年做电商优惠券计算时就踩过这种坑数据库里优惠金额字段是NULL取值出来放进Integer一参与四则运算线上接口就报错日志里看堆栈全是NullPointerException定位起来还挺费劲。所以记住一句话自动拆箱的前提是包装类不为null参与运算或赋值给基本类型之前务必判空。2. 包装类核心用法与高频方法拆解2.1 字符串转换parseXxx和valueOf怎么选项目里大量需求是把字符串数字转换成真正的数字类型比如从表单、请求参数、配置文件里读到的都是String用之前必须转类型。包装类提供了静态转换方法最常用的两组是parseXxx和valueOfint a Integer.parseInt(123); // 返回基本类型 int Integer b Integer.valueOf(123); // 返回包装类 Integer double d Double.parseDouble(3.14); boolean flag Boolean.parseBoolean(true);parseInt返回的是基本类型int后面直接用于数值计算没压力valueOf返回的是包装类Integer更适合放进集合或者作为对象传递。Java 9以后还有个Integer.valueOf(String)的兄弟Integer.parseInt(String)底层实际调用parseInt再装箱这个细节知道就行。这里真正的坑是格式敏感。字符串只要混有非数字字符转换就会抛NumberFormatException。我遇到最多的情况有两种第一种是字符串带空格比如 123直接parse会炸必须先trim()。第二种是带了千分位分隔符比如1,200parse也会炸需要先去掉逗号再转换。实际项目中我通常写一个小工具方法统一处理这些脏数据public static int toInt(String value, int defaultValue) { if (value null) { return defaultValue; } try { return Integer.parseInt(value.trim().replace(,, )); } catch (NumberFormatException e) { return defaultValue; } }这样调用方就不需要每个地方都try-catch还统一了缺省值逻辑。2.2 Integer缓存机制小数字的“户口本”这是面试高频也是线上bug高发区。Integer内部维护了一个缓存池IntegerCache默认缓存范围是-128到127也就是说在这个区间内的整数valueOf返回的是同一个对象。Integer a 100; Integer b 100; System.out.println(a b); // true两个引用指向同一个缓存对象 Integer c 200; Integer d 200; System.out.println(c d); // false超出缓存区间创建了两个新对象很多人第一次看到这个结果会觉得“Java神经病啊”但这是经过权衡的设计。小整数对象在业务代码里出现频率极高缓存起来可以减少对象创建提升性能。问题在于不少人把当成了数值比较来用结果运行环境一变结果就变了很难复现。Long也有类似的缓存范围-128到127Character缓存0到127Byte和Short因为范围本身小整个范围都缓存了。结论很明确比较包装类的数值永远用equals()或者先拆箱再比较千万别用。哪怕你确认现在在缓存区间内也别依赖这个行为JVM参数-XX:AutoBoxCacheMax是可以调整缓存上限的代码一旦依赖这种实现细节就是给自己埋雷。2.3 equals方法包装类比较只能有一种正确姿势比较引用equals比较数值这是包装类最基础也最容易混淆的点。更隐蔽的是equals还要求类型一致Integer.valueOf(1).equals(Long.valueOf(1)); // false数值相等但类型不同equals返回false。在写通用工具类的时候这种比较很容易踩坑比如从Map里拿值一会儿Integer一会儿Long直接equals对不上先打印日志一看数值明明一样找了半天才发现是类型问题。我自己的习惯是凡涉及Integer和int比较直接让基本类型参与运算凡涉及两个包装类比较统一用equals特别是两个都是null的情况equals返回true行为是合理的Integer x null; Integer y null; System.out.println(x null ? y null : x.equals(y)); // true另外推荐一种防NPE写法把常量放前面调用equals。if (Integer.valueOf(1).equals(status)) { // 即使status是null这里也不会抛空指针equals返回false }反过来status.equals(Integer.valueOf(1))在status为null时直接NPE。这个细节我在code review时经常提成本极低收益不小。至于Character和Boolean这两个相对“轻量”的包装类实际开发中常用方法不多但有几个很实用Character.isDigit(5); // 判断是否是数字 Character.isLetter(a); // 判断是否是字母 Character.isWhitespace( ); // 判断是否是空白 Boolean.parseBoolean(true); // 字符串转布尔处理验证码、输入合法性校验时Character这几个静态方法很有用不用自己写正则。3. 数学类Math一个全是静态方法的工具箱3.1 那些每天都在用的Math方法边界情况你想过吗Math类的构造方法是private的官方设计就是不让你new所有方法都是静态的直接Math.xxx()调用。它最常用的几个方法如下方法作用注意事项abs(a)绝对值对Integer.MIN_VALUE取绝对值会溢出结果是负数max(a, b) / min(a, b)最大/最小值没有坑但注意参数类型一致pow(a, b)a的b次方返回double结果可能有浮点精度误差sqrt(a)平方根负数返回NaN不是抛异常ceil(a)向上取整返回double如ceil(2.1) 3.0floor(a)向下取整返回double如floor(2.9) 2.0round(a)四舍五入返回long/int注意负数行为random()返回[0.0, 1.0)随机数底层用静态Random实例高并发下有竞争几个边界情况值得专门提出来第一Math.abs(Integer.MIN_VALUE)返回的是-2147483648依然是负数。因为int的范围是-2147483648到2147483647绝对值2147483648超出了int能表示的范围溢出后还是负数。如果你要取绝对值的是int最小值要么转成long再取要么用Math.abs((long) val)。第二Math.round对负数的处理很容易误解。它内部实现是Math.floor(x 0.5)所以Math.round(-1.5); // 结果是-1不是-2 Math.round(-1.6); // 结果是-2负数时“四舍五入”其实变成了“先四舍五入再向负无穷取整”这里非常容易算错。第三Math.pow返回double大量涉及整数的幂运算时要注意精度比如Math.pow(2, 10)算出来是1024.0如果你要的是int直接强转没问题但如果算Math.pow(5, 3)这类结果再和别的浮点数硬比较就可能因为浮点误差翻车。需要精确结果的场景用整数循环或BigDecimal别依赖pow。3.2 随机数Math.random和Random、ThreadLocalRandom怎么选Math.random()是最早接触的随机数写法返回[0.0, 1.0)范围内的double。但它内部使用了一个静态的Random实例多线程高并发调用时会有竞争和性能问题而且拿到的double再缩放成整数区间写法也很别扭。JDK 7之后官方推荐用ThreadLocalRandom每个线程维护独立的随机种子没有锁竞争性能和线程安全性都比直接new Random好// 生成 [1, 100] 的随机整数 int num ThreadLocalRandom.current().nextInt(1, 101); // 生成 [0, 33) 的随机整数 int idx ThreadLocalRandom.current().nextInt(33);要是只是生成一个随机数写在业务方法里就直接用ThreadLocalRandom别new Random()。但有一种场景需要保留Random就是用固定种子复现随机序列比如测试、模拟实验new Random(42L)可以保证每次运行生成的序列完全一致方便排查问题。安全性上多说一句Random和Math.random()都不适合做安全相关的随机数生成它们不是加密安全随机数抽奖、Token、验证码这类场景如果要求防预测要用SecureRandom。普通负载均衡随机、随机选取数据这类不涉及安全的场景ThreadLocalRandom足够。4. 实操用包装类与Math类实现一个随机号码生成器4.1 需求分解生成双色球号码并格式化输出只看理论容易飘不如写个小工具把前面这些知识点串起来。假设产品提了一个需求生成一组随机双色球号码红球6个范围1到33不能重复蓝球1个范围1到16输出时每个号码补零到两位红球按升序排列。先拆解一下需要什么生成随机数用ThreadLocalRandom。保证红球不重复最直观的做法是每次随机一个数然后去重判断但要循环重试效率不高。更好的思路是用洗牌法把1到33放进一个列表随机打乱顺序取前6个再排序。这样天然不重复也不用反复重试。格式化输出补零用String.format(%02d, num)。4.2 代码实现与逐段解读import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.concurrent.ThreadLocalRandom; public class RandomLottery { private static final int RED_MAX 33; private static final int RED_COUNT 6; private static final int BLUE_MAX 16; public static void main(String[] args) { ListInteger redBalls generateRedBalls(); int blueBall ThreadLocalRandom.current().nextInt(1, BLUE_MAX 1); System.out.println(红球: formatNumbers(redBalls)); System.out.println(蓝球: formatNumbers(List.of(blueBall))); } private static ListInteger generateRedBalls() { ListInteger pool new ArrayList(); for (int i 1; i RED_MAX; i) { pool.add(i); } Collections.shuffle(pool); ListInteger selected new ArrayList(pool.subList(0, RED_COUNT)); Collections.sort(selected); return selected; } private static String formatNumbers(ListInteger numbers) { StringBuilder sb new StringBuilder(); for (int num : numbers) { if (sb.length() 0) { sb.append( ); } sb.append(String.format(%02d, num)); } return sb.toString(); } }几个关键点逐一说Collections.shuffle(pool)用的是默认随机源内部已经处理好了随机性比自己写随机去重清晰得多。洗牌后pool.subList(0, RED_COUNT)取前6个但要注意subList返回的是原列表的一个视图不是独立副本。在这里后续不会修改pool所以直接使用问题不大但更稳妥的做法是像代码里那样包一层new ArrayList()切断和原列表的关联避免以后有人改了pool影响到取出来的结果。ThreadLocalRandom.current().nextInt(1, BLUE_MAX 1)的区间是左闭右开所以生成1到16的随机数要写成nextInt(1, 17)很多人第一次用会写错成nextInt(1, 16)结果永远拿不到16。String.format(%02d, num)是格式化补零的好帮手两位整数不足两位前面补0输出效果就是“01 05 12 23 28 33”。循环里for (int num : numbers)有个自动拆箱的过程这里安全因为numbers列表里不可能放null集合元素是Integer但赋给基本类型int变量时编译器自动拆箱不会触发异常。4.3 扩展如果要判断中奖包装类比较的坑就来了假设进一步要做一个中奖判断输入一组用户号码和开奖号码比对。最朴素的写法ListInteger userBalls parseUserInput(01 05 12 23 28 33); int redHit 0; for (Integer userBall : userBalls) { if (redBalls.contains(userBall)) { redHit; } }这里contains底层走的是equals因为Integer重写了equals比较的是数值所以功能上没问题。但如果你图省事用for循环里直接写userBall redBalls.get(i)就踩中了前面说的引用比较问题号码在缓存区间内没事一旦超出范围结果就飘了。另一个容易翻车的地方是parseUserInput用户输入“01 05 12 23 28 33”这种字符串如果直接Integer.parseInt(01)是可以的结果是1没问题但如果用户输入带其他空白或者某个号码漏了空格就会抛异常需要在解析时做好容错。之前遇到过生产环境的一个类似问题用户输入字符串用逗号分隔代码用split(,)但用户在中文输入法状态下输成了全角逗号split直接不识别解析结果数组只有一项后面的判断全错。所以解析外部输入一定要先做标准化把全角标点转半角、去掉所有空格再走解析逻辑。这个小工具写下来包装类的自动拆箱、equals比较、ThreadLocalRandom随机数、格式化补零全用上了。日常很多工具类本质上就是把这类知识点组合起来多写几次自然就熟了。5. 常见问题与排查技巧实录5.1 包装类空指针线上最常见的“隐形杀手”包装类空指针之所以“隐形”是因为代码看起来没有任何问题。Integer count map.get(count);明明是个赋值语句谁能想到下一行count就炸了呢常见场景有三种第一种从数据库查出的字段映射到Integer属性数据库里字段为NULL代码直接做算术运算。第二种从JSON解析的实体对象前端没传某个Integer字段后端拿到一个null的包装类到处参与比较。第三种方法返回类型是Integer内部逻辑某条路径返回null调用方直接把这个结果赋给int变量拆箱NPE。排查思路有两条一是看异常堆栈。如果堆栈里出现Integer.intValue()或者Long.longValue()这类方法调用基本上就是自动拆箱触发的空指针。IDE里断点也很方便直接看变量值是不是null。二是写代码时规范起来。实体字段能定义成基本类型就定义成基本类型确有必要用包装类的地方比如数据库可空字段、集合泛型元素用之前先判空。5.2 性能损耗包装类是对象创建和缓存要留意包装类本质是对象每次new一个都会产生堆内存分配。虽然JVM对短生命周期对象有优化但高频循环里还是能感觉到差异。比如for (int i 0; i 1000000; i) { Integer value i; // 每次循环都会装箱超出缓存区间还得new对象 }这种代码应尽量避免能直接用int就用int。集合里必须用包装类时没办法但操作时尽量先拆箱再计算ListInteger prices getPrices(); int total 0; for (int price : prices) { // 循环内部只拆箱一次没有额外的装箱操作 total price; }这段代码里for (int price : prices)自动把Integer拆箱成inttotal是基本类型整体没有额外装箱性能损耗很小。如果反过来用Integer total 0然后在循环里total price每次都会装箱拆箱一万次循环还好百万次循环差距就出来了。5.3 数学类高频易错点速查表场景代码实际结果原因取最小值绝对值Math.abs(Integer.MIN_VALUE)-2147483648int溢出负数四舍五入Math.round(-1.5)-1底层是floor(x 0.5)平方根负参数Math.sqrt(-1)NaN浮点数约定随机整数区间nextInt(1, 16)1到15左闭右开想要1到16要写nextInt(1, 17)两个包装类比较Integer(200) Integer(200)false超过缓存区间引用不同不同类型包装类比较Integer(1).equals(Long(1))falseequals要求类型一致这张表基本覆盖了我这些年遇到过的“回头翻文档”时刻。每一项都有人踩过踩的时候都很怀疑人生因为结果和直观预期完全不一致。最后再分享一个我自己的习惯实体字段能不用包装类就不用尤其ID和数字状态这种字段int比Integer少很多空指针问题但凡是数据库可空字段、或者要放进集合里的数字才用包装类。所有包装类数值比较统一走equals两份代码都判了null再比基本类型也省心。随机数生成优先ThreadLocalRandom别在循环里new Random。第13章这两个类看着基础真把这些边界抠清楚后面学集合、泛型、IO时会少踩很多莫名其妙的坑。把坑提前踩完比等线上出事故再回头翻文档要划算得多。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →