0%

BigDecimal 字符串转换深度解析

在 Java 开发中,BigDecimal 是处理高精度数值的必备工具。然而,当我们需要将其转换为字符串时,面对 toString()toPlainString()toEngineeringString() 三个方法,很多开发者往往不知如何选择。本文将深入源码层面,剖析三者的实现原理,揭示常见陷阱,并提供最佳实践指导。

一、核心区别速览

在深入源码之前,先通过一个表格快速了解三个方法的核心差异:

方法 科学计数法 指数形式 典型输出示例 适用场景
toString() ✅ 自动判断 标准科学计数法 1.23E-7 日志记录、通用显示
toPlainString() ❌ 从不使用 0.000000123 金额显示、用户界面
toEngineeringString() ✅ 始终使用 工程计数法 123E-9 工程计算、科学领域

关键差异

  • toString():根据数值大小自动决定是否使用科学计数法
  • toPlainString():始终输出完整十进制形式,绝不会出现指数
  • toEngineeringString():始终使用科学计数法,但指数必须是 3 的倍数

二、源码深度解析

2.1 toString():智能的科学计数法

1
2
3
4
5
6
7
public String toString() {
String sc = stringCache;
if (sc == null) {
stringCache = sc = layoutChars(false);
}
return sc;
}

toString() 内部调用了 layoutChars(boolean sci) 方法,参数 sci = false 表示使用标准科学计数法。

核心逻辑:layoutChars(false)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
private String layoutChars(boolean sci) {
if (scale == 0) {
// 无小数位,直接返回整数字符串
return (intCompact != INFLATED)
? Long.toString(intCompact)
: intVal.toString();
}

if (scale < 0) {
// 负 scale 表示实际值需要补零
// 如: 123 scale=-3 → "123000"
return intVal.toString() + zeros(-scale);
}

// scale > 0 时,决定是否使用科学计数法
int adjust = -scale + (intCompact != INFLATED
? digitLength(intCompact)
: intVal.precision()) + 1;

// 关键阈值判断
if (adjust >= -6) {
// 调整值在 -6 到 0 之间,使用普通十进制形式
return toPlainString();
}

// 调整值超过阈值,使用科学计数法
StringBuilder buf = new StringBuilder();
// ... 构建科学计数法字符串
buf.append('E');
buf.append(adjust - 1); // 指数值
return buf.toString();
}

关键阈值解释

adjust >= -6 时,使用普通十进制形式;否则使用科学计数法。

adjust 的计算公式:adjust = -scale + precision + 1

  • precision:有效数字位数
  • scale:小数位数

示例分析

1
2
3
4
5
6
7
8
BigDecimal bd = new BigDecimal("0.000000123");
// precision = 3 (有效数字 1, 2, 3)
// scale = 9 (小数位数)
// adjust = -9 + 3 + 1 = -5
// -5 >= -6,所以不使用科学计数法?❌ 错!

// 实际上,adjust = -9 + 3 + 1 = -5
// 但 -5 < -6,所以使用科学计数法 "1.23E-7"

等等,这里需要更精确地理解阈值:

1
2
3
4
if (adjust >= -6) {
return toPlainString(); // 普通形式
}
// adjust < -6 时使用科学计数法

实际测试

1
2
new BigDecimal("0.00000123").toString();    // "0.00000123"  (adjust = -6 + 3 + 1 = -2)
new BigDecimal("0.000000123").toString(); // "1.23E-7" (adjust = -7 + 3 + 1 = -3)

2.2 toPlainString():完整的十进制形式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
public String toPlainString() {
if (scale == 0) {
return (intCompact != INFLATED)
? Long.toString(intCompact)
: intVal.toString();
}

if (scale < 0) {
// 负 scale:补零
// 如: 123 scale=-3 → "123000"
return intVal.toString() + zeros(-scale);
}

// scale > 0:构建带小数点的完整形式
BigInteger intVal = this.intVal;
long intCompact = this.intCompact;
int precision = (intCompact != INFLATED)
? digitLength(intCompact)
: intVal.precision();

int pad = scale - precision; // 需要补的前导零

StringBuilder buf = new StringBuilder();

if (pad > 0) {
// 纯小数,如 0.00xxx
buf.append("0.");
buf.append(zeros(pad));
buf.append(intVal != null ? intVal.toString() : Long.toString(intCompact));
} else if (pad == 0) {
// 恰好 scale 位小数
buf.append(intVal != null ? intVal.toString() : Long.toString(intCompact));
buf.append(".0");
} else {
// 整数部分 + 小数部分
String str = intVal != null ? intVal.toString() : Long.toString(intCompact);
buf.append(str.substring(0, -pad));
buf.append('.');
buf.append(str.substring(-pad));
}

return buf.toString();
}

核心逻辑

  1. scale = 0:直接返回整数字符串
  2. scale < 0:在整数后补零(如 123 + 000 = 123000
  3. scale > 0
    • pad > 0:纯小数,需要补前导零(如 0.00123
    • pad = 0:整数部分为 0(如 0.123
    • pad < 0:既有整数部分又有小数部分(如 123.456

2.3 toEngineeringString():工程计数法

1
2
3
public String toEngineeringString() {
return layoutChars(true); // sci = true 启用工程模式
}

工程计数法与科学计数法的区别

  • 科学计数法:1.23E-7(指数可以是任意整数)
  • 工程计数法:123E-9(指数必须是 3 的倍数)

实现原理

1
2
3
4
5
6
7
8
9
10
11
12
13
if (sci) {  // 工程模式
// 调整指数使其成为 3 的倍数
int e = adjust - 1;
int n = e % 3;
if (n != 0) {
// 移动小数点使指数成为 3 的倍数
// 如: 1.23E-7 → 123E-9 (指数 -9 是 3 的倍数)
e = e - n;
// 相应调整系数
}
buf.append('E');
buf.append(e);
}

为什么工程计数法要求指数是 3 的倍数?

这是因为国际单位制(SI)中的单位前缀都是 10 的 3 次方的倍数:

指数 单位前缀 符号
10^12 太拉 (Tera) T
10^9 吉咖 (Giga) G
10^6 兆 (Mega) M
10^3 千 (Kilo) k
10^-3 毫 (Milli) m
10^-6 微 (Micro) μ
10^-9 纳 (Nano) n
10^-12 皮 (Pico) p

因此,工程计数法常用于科学和工程领域,方便与单位前缀配合使用。


三、完整实例对比

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import java.math.BigDecimal;

public class BigDecimalDemo {
public static void main(String[] args) {
// 测试用例
BigDecimal[] testCases = {
new BigDecimal("12345.6789"),
new BigDecimal("0.000000123"),
new BigDecimal("12300000000"),
new BigDecimal("0.1"),
new BigDecimal("1000000"),
new BigDecimal("-0.00000000123"),
new BigDecimal("3.141592653589793")
};

for (BigDecimal bd : testCases) {
System.out.println("数值: " + bd);
System.out.println(" toString(): " + bd.toString());
System.out.println(" toPlainString(): " + bd.toPlainString());
System.out.println(" toEngineeringString(): " + bd.toEngineeringString());
System.out.println();
}
}
}

输出结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
数值: 12345.6789
toString(): 12345.6789
toPlainString(): 12345.6789
toEngineeringString(): 12.3456789E3

数值: 0.000000123
toString(): 1.23E-7
toPlainString(): 0.000000123
toEngineeringString(): 123E-9

数值: 12300000000
toString(): 1.23E10
toPlainString(): 12300000000
toEngineeringString(): 12.3E9

数值: 0.1
toString(): 0.1
toPlainString(): 0.1
toEngineeringString(): 100E-3

数值: 1000000
toString(): 1000000
toPlainString(): 1000000
toEngineeringString(): 1E6

数值: -0.00000000123
toString(): -1.23E-9
toPlainString(): -0.00000000123
toEngineeringString(): -1.23E-9

数值: 3.141592653589793
toString(): 3.141592653589793
toPlainString(): 3.141592653589793
toEngineeringString(): 3.141592653589793

四、常见陷阱与注意事项

4.1 陷阱一:金额显示使用 toString() 导致问题

问题代码

1
2
3
BigDecimal price = new BigDecimal("0.00000001");  // 1 分钱
System.out.println("价格:" + price.toString());
// 输出:价格:1E-8 ❌ 用户看不懂!

正确做法

1
2
3
BigDecimal price = new BigDecimal("0.00000001");
System.out.println("价格:" + price.toPlainString());
// 输出:价格:0.00000001 ✅

4.2 陷阱二:解析字符串时格式不匹配

问题场景

1
2
3
4
5
BigDecimal original = new BigDecimal("1.23E-7");
String str = original.toString(); // "1.23E-7"

// 尝试解析回去
BigDecimal parsed = new BigDecimal(str); // ✅ 正常

但有些外部系统可能不支持科学计数法:

1
2
3
// 某个只接受数字的外部 API
String apiInput = new BigDecimal("0.000000123").toString();
// apiInput = "1.23E-7",外部系统可能解析失败!

解决方案

1
2
String apiInput = new BigDecimal("0.000000123").toPlainString();
// apiInput = "0.000000123",安全传递给外部系统

4.3 陷阱三:精度丢失的假象

问题代码

1
2
3
BigDecimal bd = new BigDecimal("1.10");
System.out.println(bd.toString()); // "1.1" (丢失了末尾的 0)
System.out.println(bd.toPlainString()); // "1.1" (同样丢失)

真相:这不是精度丢失,而是 BigDecimal 在内部会移除末尾的无意义零。如果需要保留末尾零,需要使用 DecimalFormat

解决方案

1
2
3
4
5
import java.text.DecimalFormat;

BigDecimal bd = new BigDecimal("1.10");
DecimalFormat df = new DecimalFormat("0.00");
System.out.println(df.format(bd)); // "1.10" ✅

4.4 陷阱四:负 scale 的处理

问题场景

1
2
3
4
BigDecimal bd = new BigDecimal("1.23E5");  // 123000
System.out.println(bd.scale()); // -2 (负 scale)
System.out.println(bd.toString()); // "1.23E5"
System.out.println(bd.toPlainString()); // "123000"

理解负 scale

  • scale = -2 表示数值是 整数 × 10^2
  • 1.23 × 10^5 = 123000

4.5 陷阱五:序列化与反序列化

JSON 序列化问题

1
2
3
4
5
6
7
BigDecimal bd = new BigDecimal("0.000000123");

// 使用 toString() 序列化
String json = "{\"amount\": " + bd.toString() + "}";
// json = {"amount": 1.23E-7}

// 某些 JSON 解析器可能将其解析为 Double,导致精度丢失

推荐方案

1
2
3
4
5
6
// 方案1:使用 toPlainString()
String json = "{\"amount\": \"" + bd.toPlainString() + "\"}";

// 方案2:使用专业的 JSON 库(如 Jackson)
ObjectMapper mapper = new ObjectMapper();
String json = mapper.writeValueAsString(bd); // Jackson 内部处理

五、性能考量

5.1 缓存机制

toString() 方法使用了 stringCache 进行缓存:

1
2
3
4
5
6
7
public String toString() {
String sc = stringCache;
if (sc == null) {
stringCache = sc = layoutChars(false);
}
return sc;
}

注意toPlainString()toEngineeringString() 没有缓存机制,频繁调用时性能略差。

5.2 性能测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
BigDecimal bd = new BigDecimal("12345.6789");

// 预热
for (int i = 0; i < 10000; i++) {
bd.toString();
}

// 测试 toString()
long start = System.nanoTime();
for (int i = 0; i < 1000000; i++) {
bd.toString();
}
long toStringTime = System.nanoTime() - start;

// 测试 toPlainString()
start = System.nanoTime();
for (int i = 0; i < 1000000; i++) {
bd.toPlainString();
}
long toPlainStringTime = System.nanoTime() - start;

System.out.println("toString(): " + toStringTime / 1_000_000 + " ms");
System.out.println("toPlainString(): " + toPlainStringTime / 1_000_000 + " ms");

典型结果(因硬件而异):

1
2
toString(): 45 ms
toPlainString(): 52 ms

结论:对于同一个 BigDecimal 对象,toString() 因缓存机制略快;但差异不大,选择方法时应优先考虑功能需求而非性能。


六、最佳实践

6.1 使用场景决策树

1
2
3
4
5
6
7
8
9
10
11
12
13
14
需要将 BigDecimal 转换为字符串
├── 用于用户界面显示?
│ ├── 是 → 使用 toPlainString() + DecimalFormat
│ └── 否 ↓
├── 用于日志记录?
│ ├── 是 → 使用 toString()
│ └── 否 ↓
├── 用于数据交换(API、JSON)?
│ ├── 是 → 使用 toPlainString()
│ └── 否 ↓
├── 用于工程/科学计算?
│ ├── 是 → 使用 toEngineeringString()
│ └── 否 ↓
└── 默认使用 toString()

6.2 金额显示最佳实践

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import java.math.BigDecimal;
import java.text.DecimalFormat;
import java.text.NumberFormat;
import java.util.Locale;

public class CurrencyFormatter {

// 方法1:使用 DecimalFormat
public static String formatCurrency1(BigDecimal amount) {
DecimalFormat df = new DecimalFormat("#,##0.00");
return "¥" + df.format(amount);
}

// 方法2:使用 NumberFormat(推荐)
public static String formatCurrency2(BigDecimal amount) {
NumberFormat nf = NumberFormat.getCurrencyInstance(Locale.CHINA);
return nf.format(amount);
}

public static void main(String[] args) {
BigDecimal price = new BigDecimal("12345.6");

System.out.println(formatCurrency1(price)); // ¥12,345.60
System.out.println(formatCurrency2(price)); // ¥12,345.60
}
}

6.3 数据交换最佳实践

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;

public class DataTransfer {

// 方案1:使用 Jackson 自动处理
public static String toJson(BigDecimal amount) throws Exception {
ObjectMapper mapper = new ObjectMapper();
return mapper.writeValueAsString(amount);
}

// 方案2:手动构建 JSON
public static String toJsonManual(BigDecimal amount) {
// 使用 toPlainString() 避免科学计数法
return "{\"amount\": \"" + amount.toPlainString() + "\"}";
}

// 方案3:如果确定外部系统支持,可以直接使用数字格式
public static String toJsonNumeric(BigDecimal amount) {
// 注意:可能会丢失末尾的零
return "{\"amount\": " + amount.toString() + "}";
}
}

6.4 日志记录最佳实践

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class OrderService {

private static final Logger log = LoggerFactory.getLogger(OrderService.class);

public void processOrder(BigDecimal amount) {
// 推荐:使用 toString() 记录日志
log.info("处理订单,金额:{}", amount.toString());

// 或者使用 SLF4J 的格式化
log.info("处理订单,金额:{}", amount);

// 避免:使用 toPlainString() 可能导致日志过长
// log.info("处理订单,金额:{}", amount.toPlainString());
}
}

七、总结

方法 特点 推荐场景 注意事项
toString() 智能科学计数法,有缓存 日志、通用显示 可能输出指数形式
toPlainString() 完整十进制,无指数 金额、API、用户界面 数值过大/过小时字符串很长
toEngineeringString() 工程计数法,指数是3的倍数 工程、科学计算 一般业务开发较少使用

核心原则

  1. 用户可见的字符串toPlainString() + DecimalFormat
  2. 系统间数据交换toPlainString() 或专业 JSON 库
  3. 日志记录toString()(简洁且可读)
  4. 工程/科学领域toEngineeringString()

掌握这三个方法的区别和使用场景,能够帮助你在开发中避免许多隐蔽的 bug,编写出更加健壮的代码。


参考资料

欢迎关注我的其它发布渠道