一、参赛选手:六种主流方案
在 JDK 原生能力范围内,保留两位小数的方案主要有以下六种。
选手一:String.format。 这是最常用也最直观的方案,通过格式化字符串指定小数位数,内部基于 Formatter 实现。优点是语法简洁,一行搞定;缺点是每次调用都会创建新的 Formatter 对象,开销不小。
选手二:DecimalFormat。 这是 JDK 提供的专门用于数字格式化的类,支持自定义模式。它是线程不安全的,但可以通过 ThreadLocal 复用实例来提升性能。在大量调用场景下,复用后的表现非常出色。
选手三:NumberFormat。 这是 DecimalFormat 的父类,通过工厂方法获取实例。它是线程安全的,但正因为线程安全,内部加了同步锁,在高并发场景下反而不如非线程安全的 DecimalFormat 配合 ThreadLocal 快。
选手四:BigDecimal。 这是金融计算的首选方案,通过 setScale 方法精确控制小数位数,支持多种舍入模式。它的优势是精度无误差,但代价是对象创建和运算的开销都比较大。
选手五:Math.round 手动计算。 这是最原始的方案,通过乘以一百、四舍五入、再除以一百的方式实现。不创建任何额外对象,纯数学运算,理论上应该是最快的。
选手六:String.valueOf 配合截断。 先通过数学运算得到两位小数的值,再转成字符串。这个方案比 Math.round 多了一次类型转换,但避免了格式化的开销。
二、测试环境与方法
为了保证测试结果的客观性,本次 benchmark 在以下环境中执行:JDK 17,单核 CPU,堆内存配置充足,每个方案独立运行一千万次,取平均值。测试数据覆盖三种典型数值:正小数、负小数、整数。
测试指标主要关注两项:单次执行的平均耗时(纳秒级),以及内存分配量。内存分配量虽然不是本文的核心指标,但在高并发场景下,频繁的对象创建会给垃圾回收带来压力,间接影响系统吞吐。
每个方案都在冷启动和热启动两种状态下分别测试。冷启动反映的是首次调用的真实开销,热启动反映的是 JIT 优化后的稳定表现。很多方案在冷启动和热启动之间的差距非常大,这一点在后续的分析中会重点体现。
三、测试结果:数据不会说谎
冷启动阶段
在冷启动状态下,各方案的表现差异非常显著。
Math.round 手动计算方案毫无悬念地排在第一位,平均耗时仅为十几纳秒。它不涉及任何对象创建,纯粹是 CPU 的算术运算,是所有方案中开销最小的。
String.valueOf 配合截断的方案紧随其后,比 Math.round 慢了一倍左右,多出来的时间主要花在了 double 到 String 的类型转换上。
DecimalFormat 在复用实例的情况下排在第三位。注意,这里说的是复用实例——如果每次都新建 DecimalFormat 对象,它的耗时会飙升到几百纳秒,比 String.format 还要慢。这说明 DecimalFormat 的构造开销非常大,但格式化本身的效率很高。
String.format 排在第四位,平均耗时在几百纳秒的量级。它的开销主要来自每次都要新建 Formatter 对象和解析格式化字符串。
NumberFormat 排在第五位。由于内部的同步机制,它在单线程下的表现甚至不如非线程安全的 DecimalFormat。
BigDecimal 毫无意外地垫底,冷启动平均耗时达到了微秒级别,比最快的方案慢了两个数量级。这是因为 BigDecimal 内部使用了 int 数组来存储数值,每次运算都涉及数组的创建和复制。
热启动阶段
经过 JIT 优化后,各方案的排名发生了有趣的变化。
Math.round 依然稳居第一,但优势有所缩小。这是因为 JIT 对后续方案也做了大量优化,尤其是对 String.format 和 DecimalFormat 的内联优化效果明显。
String.format 在热启动后的表现提升非常明显,从几百纳秒降到了接近 DecimalFormat 的水平。JIT 对 Formatter 的创建做了逃逸分析,在某些场景下会栈上分配对象,从而大幅降低了 GC 压力。
DecimalFormat 复用实例的方案在热启动后依然保持在前列,表现非常稳定。这说明它的格式化逻辑本身就很高效,JIT 的优化空间虽然有,但提升幅度不如 String.format 那么大。
NumberFormat 在热启动后略有提升,但依然排在倒数的位置。同步锁的开销是硬伤,JIT 也很难完全消除。
BigDecimal 在热启动后有一定改善,但依然是最慢的。这不是 JIT 的问题,而是 BigDecimal 的设计定位就不是为了高性能格式化,而是为了精确计算。用它来做格式化,属于杀鸡用牛刀。
四、内存分配分析:看不见的成本
除了耗时,内存分配量是另一个关键指标。
Math.round 方案在整个测试过程中几乎不分配内存,只在栈上做计算。这对 GC 极其友好,在高并发场景下优势巨大。
String.format 每次调用都会分配一个 Formatter 对象和一个 String 对象,内存分配量是所有方案中最高的之一。一千万次调用下来,会产生大量短命对象,给年轻代 GC 带来明显压力。
DecimalFormat 复用实例后,每次调用只分配一个 String 对象,内存分配量大幅下降。但如果不复用,每次还会额外分配一个 DecimalFormat 对象,内存开销就会翻倍。
BigDecimal 每次调用都会创建新的 BigDecimal 对象,而且内部还有数组分配。它的内存分配量是 Math.round 的几十倍,这也是它慢的根本原因之一。
五、不同场景下的选型建议
根据测试结果,结合实际业务场景,以下是选型建议。
场景一:高频调用、对性能敏感。 比如订单金额计算、实时数据统计等每秒需要处理数万次的场景。首选 Math.round 手动计算方案。它最快、内存开销最小,而且不依赖任何对象,不会给 GC 添负担。如果需要返回字符串,再配合 String.valueOf 即可。
场景二:中等频率、需要灵活格式。 比如日志输出、接口返回等每秒几千次的场景。推荐使用 DecimalFormat 配合 ThreadLocal 复用实例。它的性能在热启动后非常优秀,而且支持丰富的格式化模式,比如千分位分隔、百分比等。
场景三:低频率、格式要求复杂。 比如报表导出、邮件模板等偶尔调用的场景。String.format 是最省心的选择,语法简洁,可读性好,性能上的差异在低频调用中完全可以接受。
场景四:金融计算、精度要求极高。 比如账户余额、利率计算等不允许任何精度误差的场景。必须使用 BigDecimal,而且要指定明确的舍入模式。虽然它慢,但这是精度和性能之间的必要取舍。在这种场景下,性能不是第一优先级,正确性才是。
场景五:多线程环境、不想自己管理实例。 NumberFormat 是最简单的选择,因为它天然线程安全。但你要接受它在性能上的妥协。如果项目中格式化调用非常频繁,建议还是自己用 ThreadLocal 包装 DecimalFormat,收益会明显更多。
六、容易被忽视的细节
在实际使用中,有几个细节会直接影响性能表现。
第一,DecimalFormat 的模式字符串不要在循环内拼接。每次拼接都会产生新的字符串对象,应该把模式定义为常量,在类加载时就确定下来。
第二,String.format 的格式化字符串也应该提取为常量。虽然 JIT 会做一些优化,但减少不必要的字符串对象仍然是好习惯。
第三,BigDecimal 一定要复用。如果你在循环中反复创建 BigDecimal 对象,性能会差到让你怀疑人生。能用 double 计算的地方,尽量不要用 BigDecimal。
第四,注意舍入模式的选择。不同的舍入模式在底层实现上有细微差异,但对性能的影响可以忽略不计。真正需要关注的是业务逻辑是否允许某些舍入方式。
七、总结:没有最好的方案,只有最合适的方案
通过这次完整的性能对比,可以得出一个清晰的结论:在保留两位小数这件事上,没有万能的方案。
追求极致性能,选 Math.round;追求平衡,选 DecimalFormat 加 ThreadLocal;追求简洁,选 String.format;追求精度,选 BigDecimal。每种方案都有自己的适用场景,关键是你要清楚自己的业务属于哪一类。
性能优化从来不是盲目追求最快,而是在理解每种方案的开销之后,做出最合理的取舍。希望这次 benchmark 的数据和分析,能帮你在下次写格式化逻辑时,多一分笃定,少一分犹豫。