ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java常用类核心要点:包装类、BigDecimal精度与随机数实战

Java常用类核心要点:包装类、BigDecimal精度与随机数实战 1. 包装类到底解决什么问题——先聊设计思路1.1 基本类型不是对象集合又只收对象Java有两套类型体系一套是基本类型int、double、boolean这些另一套是引用类型String、数组、各种类对象。这两套体系在JVM里的存储方式完全不一样基本类型直接存值堆上都留不下痕迹引用类型存的是地址对象本体活在堆内存里。问题就出在集合上。你用List、Map、Set的时候泛型参数不能写int、double只能写Integer、Double。为什么因为集合底层要用Object数组或者链表节点来存元素基本类型没法塞进Object。这就很尴尬早期学Java的人肯定遇到过这种报错Listint list new ArrayList();编译直接不给过。想存一组数字只能老老实实写ListInteger。包装类就是为这种场景准备的它把基本类型“包”成一个对象让基本类型也能进集合、也能参与泛型。再往深了说包装类的存在还解决了一个NPE问题——不对准确说是引入了一个NPE问题。后续我会专门讲这个坑但设计初衷确实是给基本类型穿上对象的外衣让它能融入面向对象的世界。JDK 1.0时代就设计了8个包装类分别对应byte、short、int、long、float、double、char、boolean一直到今天都没变化可见这玩意儿有多稳定、多基础。1.2 装箱与拆箱自动帮你做的类型转换包装类和基本类型之间可以互相转换分两种手动Integer i Integer.valueOf(100); int n i.intValue();自动Integer i 100; int n i;自动装箱和自动拆箱是JDK 1.5引入的语法糖编译器会自动把Integer i 100翻译成Integer i Integer.valueOf(100)把int n i翻译成int n i.intValue()。看起来很方便但方便背后有代价。最大的代价就是性能。自动装箱会创建对象如果在循环里频繁装箱拆箱会产生大量临时对象给GC增加压力。举个典型反面教材Integer sum 0; for (int i 0; i 1000000; i) { sum i; // 每次循环拆箱加法装箱三万个对象就没了 }这段代码里sum是Integeri是int。sum i的实际执行过程是sum.intValue() i得到int结果后又调用Integer.valueOf重新装箱。如果i超过127new出来的对象数量就很吓人。实测跑一百万次循环这种写法比直接用int慢好几倍。正确做法是循环里用基本类型最后要存进集合时再装箱。还有一个更隐蔽的坑是equals和混用。两个Integer用比较比较的是引用地址不是数值。这在下面第4节我会专门展开。1.3 包装类里常用的核心API清单每个包装类都有一组固定的“家族式”方法搞懂一个其他基本全会。核心方法分四类第一类是字符串到数字的解析方法最典型的是Integer.parseInt(String)、Long.parseLong(String)、Double.parseDouble(String)。这类方法返回基本类型输入不对会抛NumberFormatException。特别注意Integer.parseInt(3.14)必挂parseInt只认整数格式。第二类是valueOf系列不只是Integer.valueOf(int)还有Integer.valueOf(String)。它返回包装类对象内部有缓存机制后面细聊。如果你只需要基本类型用parseInt就够了需要对象就valueOf。第三类是intValue、doubleValue这类拆箱方法以及toString、equals、hashCode这些Object继承来的方法。特别提醒包装类的hashCode不是简单返回底层数值Integer的hashCode就是value本身但Double的hashCode是value对应long的位模式再做一次异或右移运算具体实现翻源码能看到。第四类是进制转换的静态方法toBinaryString、toHexString、toOctalString以及Integer类特有的parseInt(String, radix)方法可以指定进制解析比如二进制字符串转十进制就是Integer.parseInt(1010, 2)结果是10。每个包装类还有几个常量值得记一下Integer.MAX_VALUE是2147483647Integer.MIN_VALUE是-2147483648Integer.SIZE是32位数Integer.BYTES是4字节数。2. 数学类不止是static方法——Math、Random与严格计算2.1 从Math类开始那些高频的静态方法Math类从名字就能看出来干的全是数学运算。它全篇都是static方法不需要实例化直接用Math.xxx()调用。日常开发中最高频的几类我按使用频率排个序绝对值类Math.abs(int/double/...)。注意一个极端情况Math.abs(Integer.MIN_VALUE)的结果还是负数因为int范围不对称正数最大只到2147483647取不到2147483648溢出后绕回负数。这在做金额绝对值计算时非常危险。最值类Math.min、Math.max支持基本类型其实内部就是三元表达式没啥性能损耗。日常写int max a b ? a : b和Math.max(a, b)完全等价看个人喜好。幂和根Math.pow(a, b)、Math.sqrt(x)、Math.cbrt(x)。注意pow的两个参数都是double返回值也是double。想算整数幂反而要小心Math.pow(2, 10)返回的是1024.0你要int的话还得自己强转或者加个epsilon再取整。向上向下取整和四舍五入Math.ceil(x)是向上取整找最小的整数天花板Math.floor(x)是向下取整找最大的整数地板Math.round(x)是四舍五入。这三个方向搞反的人很多我直接给个对照表方法Math.ceil(2.1)Math.floor(2.9)Math.round(2.5)Math.round(-2.5)结果3.02.03-2注意负数的roundMath.round(-2.5)结果是-2不是-3。因为round底层是floor(x 0.5)-2.5 0.5 -2.0floor(-2.0) -2。要四舍五入到3这种数学上的对称行为做不到round就是原地0.5下取整和正负数无关理解这个公式就不会记混了。另外一个我特别想提醒的Math类还有一个random()方法但它不是数学运算是随机数生成器底层调用Random类的nextDouble()。这个单独在2.2节展开讲。2.2 随机数别只会用Math.random()很多人写随机数就只会Math.random()这个方法返回[0.0, 1.0)的double用起来确实简单但在工程里并不总是最优选择。先看Math.random()的参数缺陷。它只返回0到1之间的小数你想要个[1, 100]的整数就得自己算int randomNum (int)(Math.random() * 100) 1; // 得到1~100这个写法没问题但可读性差。更关键的是每一次Math.random()调用都要创建一次Random对象源码里的方法是RandomNumberGeneratorHolder.randomNumberGenerator.nextDouble()用的是ThreadLocalRandom的一个静态实例高并发场景下性能不如直接用ThreadLocalRandom.current().nextInt(...)。实战中生成随机整数的三个选择(int)(Math.random() * n) min需要手动处理边界适合偶尔用一次new Random().nextInt(bound)传入一个上界返回[0, bound)的整数需要加min调整区间ThreadLocalRandom.current().nextInt(min, max 1)多线程友好写法最清晰我最推荐这种方式看一段实际用法// 抽奖活动从1到1000之间抽一个奖品编号 ThreadLocalRandom random ThreadLocalRandom.current(); int luckyNumber random.nextInt(1, 1001); // 包含1不包含1001正好是1~1000nextInt(origin, bound)方法的规则是包含origin不包含bound。这个规则容易记反我当初也踩过几次坑写nextInt(1, 100)的时候以为能抽到100结果每次最高只有99。后来总结了一个口诀左闭右开。还有一个冷知识Random类如果两个实例用相同种子seed生成的随机序列完全一样。这在测试环境很坑比如你两个JVM进程同时new Random()而系统时间恰好相同两边的随机数就能对得上。生产环境一般没事但做分布式任务时要注意种子一致性带来的“伪随机重复”问题。2.3 BigDecimal金额计算必须用它有一句话我建议每个Java开发都刻在脑子里double和float永远不要用于金额计算。别跟我说误差只有一点点金额这种东西差一分钱都是事故。为什么double会有误差因为进制转换问题。十进制的0.1转成二进制是一个无限循环小数0.000110011001100...double精度只有52位尾数存不下这么多位只能截断。所以0.1 0.2在double里算出来是0.30000000000000004不是0.3。这不是bug是浮点数的宿命。BigDecimal解决这个问题的思路是不直接用二进制浮点数存储而是用十进制的大整数加上一个scale小数位数来表示。比如123.45内部存储就是 unscaledVal 12345, scale 2表示12345 × 10⁻²。使用BigDecimal有四个核心注意点第一构造方法要用String。new BigDecimal(0.1)你以为是0.1实际存储的是0.1000000000000000055511151231257827021181583404541015625。因为传入的double参数本身就已经失真了。正确写法是new BigDecimal(0.1)。这是最经典的坑没有之一。第二四则运算方法是add、subtract、multiply、divide不是加减乘除运算符。第三除法必须指定精度和舍入模式。divide(BigDecimal divisor)这句代码在不整除的情况下会抛ArithmeticException: Non-terminating decimal expansion。所以要写divide(divisor, scale, RoundingMode.HALF_UP)。第四BigDecimal是不可变对象加减乘除都会返回新的BigDecimal原对象不变。看一个安全的分摊场景BigDecimal total new BigDecimal(100.00); BigDecimal each total.divide(new BigDecimal(3), 2, RoundingMode.HALF_UP); // 结果是33.33而不是报错或者33.3333...这里scale取2是“分”的精度RoundingMode.HALF_UP是四舍五入符合绝大多数财务场景。2.4 对数、指数与严格数学运算除了上面这些Math类还提供了全套的超越函数Math.log(x)是自然对数以e为底Math.log10(x)是常用对数以10为底Math.exp(x)是e的x次幂。这类函数在算法类业务里更常见比如计算信息熵、归一化指数softmax常需要log和exp配合。顺带提一下StrictMath类。StrictMath和Math几乎一模一样区别是StrictMath所有算法严格遵循IEEE 754标准保证在不同平台上计算结果完全一致Math为了性能允许使用平台相关的硬件指令结果可能在不同平台有微小差异。绝大多数业务不需要这种跨平台的一致性但如果做科学计算、加密算法、跨端同步计算统一用StrictMath更稳妥。还有个细节Math类里的三角函数sin、cos、tan接收的单位是弧度radian不是角度。想算30度的正弦得先转弧度Math.sin(Math.toRadians(30))结果是0.5。Math.toRadians和Math.toDegrees这两个转换方法也很常用但很容易被忽略。3. 综合案例用包装类与数学类解决一个实际问题3.1 场景设计简化版订单统计工具纯讲API很枯燥我拿一个真实业务场景串一遍一个订单统计功能。需求很简单给你一个订单金额列表需要计算出总金额、平均金额、最大金额、最小金额并且按“元”为单位输出成两位小数的字符串。很多人一上来就用double数组存金额然后for循环累加。这也能跑但只要金额一多、小数位一多误差就会累积放大。正确做法是List加BigDecimal。这个功能虽然简单但它把本节所有知识点都能串起来集合要用包装类、金额要用BigDecimal、比较大小要用compareTo而不是equals、格式化输出要setScale。3.2 关键实现逐步拆解先定义数据结构。因为金额进入集合必须用ListBigDecimal这里BigDecimal既是数学类也是包装类的“近亲”——它就是为精确计算设计的数值类。import java.math.BigDecimal; import java.math.RoundingMode; import java.util.ArrayList; import java.util.List; public class OrderStatistic { public static void main(String[] args) { ListBigDecimal amounts new ArrayList(); amounts.add(new BigDecimal(19.90)); amounts.add(new BigDecimal(299.00)); amounts.add(new BigDecimal(0.80)); amounts.add(new BigDecimal(99.00)); amounts.add(new BigDecimal(1500.50)); // 1. 求和累加时注意不可变性 BigDecimal sum BigDecimal.ZERO; for (BigDecimal amount : amounts) { sum sum.add(amount); // add返回新对象必须重新赋值 } // 2. 最大最小值先拿第一个做基准再逐一比较 BigDecimal max amounts.get(0); BigDecimal min amounts.get(0); for (int i 1; i amounts.size(); i) { BigDecimal current amounts.get(i); if (current.compareTo(max) 0) { max current; } if (current.compareTo(min) 0) { min current; } } // 3. 平均值除法必须指定scale和舍入模式 BigDecimal avg sum.divide(new BigDecimal(amounts.size()), 2, RoundingMode.HALF_UP); // 4. 格式化输出保留两位小数四舍五入 System.out.println(订单数 amounts.size()); System.out.println(总金额 sum.setScale(2, RoundingMode.HALF_UP)); System.out.println(平均金额 avg); System.out.println(最高金额 max); System.out.println(最低金额 min); } }这段代码有几个细节值得展开讲讲第一个求和用的是sum sum.add(amount)而不是sum.add(amount)。BigDecimal是不可变对象add方法是返回一个新的BigDecimal不修改原来的sum。这个坑新手几乎必踩写完才发现sum一直是0。第二个比较大小用的是compareTo方法不是equals。BigDecimal的equals有点“矫情”它认为2.0和2.00不是同一个对象因为scale不同而compareTo认为它们在数值上相等。业务上判断金额相等应该用compareTo 0别用equals。第三个平均数是divide除法必须给scale和RoundingMode。这里有5个订单合计1919.20除以5等于383.84刚好整除所以没报错。但如果换成4个订单1919.20 / 4是479.8还是不报错。真正危险的是除不尽的场景比如3个订单分100块钱不指定scale和模式就直接抛异常了。3.3 答案是整数还是小数拆箱与装箱要注意的边界问题在统计功能里amounts.size()返回的是int类型new BigDecimal(amounts.size())这里就是把int自动装箱成Integer再通过Integer和BigDecimal的构造函数转换。这个过程中int先被装箱成Integer对象再被拆箱成int值传入构造器虽然最终结果一样但多了一次无意义的装箱拆箱。更优雅的写法是BigDecimal.valueOf(amounts.size())valueOf内部会直接处理int和long类型不产生中间对象。另一个边界问题订单数用int够不够如果系统日订单量超过21亿int就溢出了得用long。我见过很多系统上线几年后订单量到了int上限统计模块直接算错。设计阶段就把订单数声明为long或者用Integer.MAX_VALUE做上限判断能省掉后面一堆麻烦。3.4 随机数的工程应用红包金额生成器再完整写一个抽奖/红包场景。假设要做个活动生成一个1到200元之间的随机红包金额要求保留两位小数。这个场景要用到随机数和BigDecimal配合也是很多初学者的盲区随机数直接生成double然后new BigDecimal会有精度问题。import java.math.BigDecimal; import java.math.RoundingMode; import java.util.concurrent.ThreadLocalRandom; public class LuckyMoney { public static void main(String[] args) { for (int i 0; i 5; i) { BigDecimal amount randomAmount(1, 200); System.out.println(红包金额 amount); } } private static BigDecimal randomAmount(int min, int max) { ThreadLocalRandom random ThreadLocalRandom.current(); // 生成100到20000之间的整数代表“分” int cents random.nextInt(min * 100, max * 100 1); // 用整数构造BigDecimal再除以100得到“元” return BigDecimal.valueOf(cents, 2); } }这个实现的关键思路是先把金额换算成“分”整数在整数范围内做随机最后再用BigDecimal精确构造。整数运算是精确的随机也精确最后一步valueOf(cents, 2)直接把整数值除以100没有任何浮点误差。这是处理随机金额的正确姿势比先生成double再四舍五入稳得多。4. 我这几年踩过的坑——常见问题与排查技巧4.1 Integer比较等不等于Cache缓存机制深度扒皮这个坑出现频率极高我直接给测试题Integer a 100; Integer b 100; System.out.println(a b); // true Integer c 200; Integer d 200; System.out.println(c d); // false为什么第一个是true第二个是false因为Integer的valueOf(int)方法内部有缓存默认缓存了-128到127之间的Integer对象实例。当你装箱的int值落在这个区间内valueOf直接返回缓存池里的同一个对象超出区间就new一个新对象。所以a和b拿的是同一个对象引用为truec和d是两个不同对象比较引用地址自然false。这个设计是为了性能优化-128到127的整数使用频率最高复用对象能减少内存分配。类似机制也存在于Character0~127、Short-128~127、Long-128~127中。Double和Float没有缓存因为浮点数“值”太离散缓存意义不大。排查这个问题的标准方案是所有包装类对象比较一律用equals或者compareTo不要用。在[-128, 127]区间碰巧成立给了很多人“也能用”的错觉一旦数值跨过127就翻车。4.2 BigDecimal的equals和compareTo之争我刚才提到new BigDecimal(2.0).equals(new BigDecimal(2.00))结果是false但compareTo是0。这个差异在Set和Map里尤其致命如果你用BigDecimal做HashMap的keyHashMap用equals和hashCode来判断key是否重复那么2.0和2.00会当成两个不同的key看似相同的金额却存了两条记录。而用HashSet存储时也会出现重复元素。解决方案如果业务上认为2.0和2.00是等价的就别用BigDecimal直接做Map的key或者在处理数据前先把所有BigDecimal统一scale到同一精度比如都setScale(2)这样equals和hashCode也一致了。4.3 除法不指定舍入模式直接crash我在3.2节里特意强调过说少了都是泪。有一次我在生产环境跑一个佣金结算的任务逻辑是佣金 总额 / 单数。运营那边配了一组3单的数据总额是100元100/333.333...无限循环。BigDecimal的divide方法默认有精度限制除不尽就抛异常整个任务直接挂掉。日志里就是ArithmeticException排查了半天才反应过来是divide的问题。修复方案就是必写两参数divide(divisor, scale, RoundingMode.HALF_UP)。scale根据自己的业务定金额类一般到2分汇率类可能需要4-6位。RoundingMode.HALF_UP是最常用的四舍五入但有些场景比如库存、张数可能有自己特定的舍入逻辑得按需选。4.4 包装类和基本类型的NPE问题自动拆箱最大的隐患是NPE空指针异常。看一个经典场景Integer value null; int result value 1; // 运行期抛 NullPointerExceptionvalue是Integer对象和int相加时要拆箱调用value.intValue()而value是null调用方法直接NPE。这种错误在从数据库/接口取值拿到null后直接参与运算时尤其常见。排查技巧凡是包装类参与运算、比较、拼接前务必判空。if (value ! null)是最保险的。另外Java 8的Optional在null语义管理上能帮上忙但这属于另一个话题了。4.5 Random种子重复导致线上随机结果一致这是一个比较少见的坑但遇到就非常诡异。当年做一个A/B测试分流服务用new Random()生成实验组ID结果上线后一连几次实验组比例都异常。查到最后发现服务由多个节点组成每次重启时系统时间恰好相同比如都是整点启动new Random()的种子来自当前纳秒时间如果两个节点启动时间差距太小种子可能撞上两个节点生成相同的随机序列导致分流不均。解决方案是用ThreadLocalRandom.current()它内部维护的是线程级的种子更新每个线程独立不容易出现全局重复序列。或者显式指定一个业务相关的种子比如基于用户ID做seed这样同一个用户每次进来分到的随机结果反而是一致的有状态随机。4.6 包装类作为Map key时的哈希性能最后说一个偏性能的坑。如果Map的key是IntegerHashMap计算哈希时直接取value本身速度很快。但如果是Long或者String哈希算法会多几步位运算。这在数据量千万级以上才看得出差异平时不用过度担心。真正要小心的是同一个对象作为key时它的hashCode计算依赖内部状态如果对象可变哈希值也会变Map就找不到之前放的键了。包装类都是不可变的所以不存在这个问题这也是为什么推荐用包装类做key而不是自定义可变对象。5. 为什么学完常用类写代码的思路会不一样相比框架和中间件包装类和数学类听起来确实不够“炫”很多干了半年的开发也没觉得它有多重要。但我个人理解“常用类”这三个字恰恰说明了它的分量它不是一段能用或不用的代码而是Java整个生态里无处不在的基础设施。你可以在框架里不直接写ThreadLocalRandom但框架内部的雪花算法、分布式ID生成器底层全是数学运算和位运算你可以用double应付所有金额计算但报表对不上账的时候回头补BigDecimal的课就是必须的。这些基础类就像建筑里的钢筋平时看不见但每一层楼都靠它在撑着。我身边的团队在招人面试时最喜欢问的就是包装类比较、自动装箱、Math.round这些“小问题”。不是面试官刁难而是这些点能看出一个人是否真正理解Java的类型系统、对象模型和精度边界。把这些细节琢磨透写业务代码时就会少很多莫名其妙的问题排查bug的效率也会高一大截。最后送各位一个我在实际项目中养成的习惯凡是涉及金额的字段代码层面强制用BigDecimal数据库层面用DECIMAL两者之间传输用字符串。从源头杜绝浮点数问题比事后修数据强一万倍。这个习惯帮我挡掉的线上事故一只手数不过来。
返回列表