一、背景:大促備戰中的異常數據
大促備戰期間,接到客戶反饋我司上傳到客戶服務器上的文件存在科學計數法表示的情況(下圖的4.55058496E7),與約定不符。

??
查看轉換前的數據是:455058496,轉換后(除以10:進行毫米到厘米的轉換)就變成了科學計數法形式了。

??
問題代碼:
說明:
這個是個EL表達式,含義是使用expr的值作為計算邏輯,計算結果賦值給var指向的變量temp.b,類型是java.lang.String。
?_item代表當前上下文里的一個對象。
?boxLength是_item對象所具備的屬性。
?該表達式先對boxLength執行除以 10 的運算,再把運算結果轉換為字符串(由clazz定義的)。
業務上,boxLength是個長度的概念,單位是毫米,除以10是轉換成厘米的含義。為了保證精度,系統(基于JAVA)會先將boxLength先轉成java.lang.Double類型,再除以10,最后調用Double.toString()方法轉成字符串。
二、問題定位:字符串轉換的科學計數法陷阱
2.1 問題復現
代碼:
Double depthInDouble = 455058496d/10;
log.info("depthInDouble={}", depthInDouble);
結果:

??
2.2 原因分析
問題就出在了最后一行,日志輸出的時候Double會被轉成String,調用Double.toString()方法,而對于Double對象的值在一定的范圍內,會使用科學計數法表示。
log.info的調用鏈(為什么會調用到Double.toStirng()):
log.info("depthInDouble={}", depthInDouble);
↓
Log4jLogger.info(String format, Object arg)
↓
AbstractLogger.logIfEnabled(...)
↓
AbstractLogger.logMessage(...)
↓
ParameterizedMessageFactory.newMessage(...)
↓
ParameterizedMessage 構造函數(參數被暫存為 Object[])
↓
// 此時尚未調用 Double.toString()
↓
// 當 Appender 執行輸出時...
Appender.append(LogEvent)
↓
LogEvent.getMessage().getFormattedMessage() // 觸發消息格式化
↓
ParameterizedMessage.getFormattedMessage()
↓
ParameterizedMessage.formatMessage(...)
↓
ParameterizedMessage.argToString(Object)
↓
Double.toString() // 終于在這里被調用!
查看Double.toString()的源碼,可以看到相關解釋:

??
也就是說對于極小(小于10^-3)或者極大(大于10^7)值的浮點數,轉成String的時候會使用科學計數法表示,驗證如下。
代碼:
public static void main(String args[]) {
String depth = "455058496"; // 單位:毫米
Double depthInDouble = Double.parseDouble(depth)/10;
String doubleInString = String.valueOf(depthInDouble);
log.info("depthInDouble={}", depthInDouble);
log.info("doubleInString={}", doubleInString);
depthInDouble = 1e-3;
log.info("10^-3 = {}", depthInDouble);
depthInDouble = 1e7;
log.info("10^7 = {}", depthInDouble);
Double aVerySmallNumber = 1e-9;
depthInDouble = 1e-3 - aVerySmallNumber;
log.info("10^-3 - delta = {}", depthInDouble);
depthInDouble = 1e7 - aVerySmallNumber;
log.info("10^7 - delta = {}", depthInDouble);
}
運行結果:

??
說明,10^-3不會使用科學記計數法,但是小于它就會使用科學計數法,10^7就會使用科學計數法,小于它就會不會,大于它會。
2.3 為什么要使用科學計數法
2.3.1 小數在計算機內是如何表示的
先不急于討論為什么使用科學計數法,我們先看看小數在計算機內是如何表示的。
從存儲角度來看,計算機的存儲是有限資源,能存儲的數據是有范圍的,不是無限大,也就是說有限的硬件資源限制了計算機可以表示的數值的大小。對于一個浮點數,我們可以用10個bit存儲,也可以用100個,為了實現跨設備、跨平臺的數據統一表示和交換,IEEE 754 規范定義了標準格式,規定了Double類型使用64比特。

當64個比特確定了,那么它可以表示的數字的范圍就確定了,接下來考慮怎么表示小數,可以表示什么范圍內的小數,進而再討論威懾么定義超過10^7或者小于10^-3使用科學計數法,而不用普通的方式(定點數表示法)。
類似整數可以利用除以2取余獲得其二級制的表示形式,例如:123(10進制)= 1111011(二進制)

??
小數則進行乘2取整,如0.123(10進制)= 0. 0001111101(二進制,位數會一直循環無法精確表示,只能近似,這里取了10位)

??
?
因此最簡單的一種設計(不考慮正負)就是將64位中的一部分劃分為整數位,一部分劃分為小數位,比如32位整數,32位小數(定點數表示法)。
那么這樣設計的Double最大數可以表示2^32-1,
如果要以米為單位表示銀河系直徑,約1光年≈299792458米/秒*1年 = 299792458米/秒*365天*86400秒/天 ≈ 9.45 * 10^15 ,而2^32-1≈4.29 * 10^9 (遠小于1光年),因此無法使用Double表示銀河系直徑,無法支撐天文學科的計算了。

??
這樣設計的Double最小可以表示2^-32=2.38*10^-10 ,一個質子的大小是0.84飛米=8.4*10^-16,因此也無法支持物理學的計算。
所以,矛盾在于增加整數部分的位數,就會壓縮小數部分的位數,不同的領域中,既有要求數字很大可表示的(在乎量級,如天文學、金融學),也有要求數值很小能表示的(在乎精度,如物理學、生物學)。
可以看到,上面的很多數字表達,我們也使用了科學計數法的表示形式來簡化表達,對于上面這個數字(9.454,254,955,488,000)寫起來麻煩還很占地方,而且我們也不需要那么精確,只是看個量級,因此會寫成9.45 * 10^15 ,不影響理解。
即表示一個極大或者極小的數可以使用:【數值*底數^指數】的形式,對于大數來講指數就是正的,小數就是負的,計算機使用二進制,因此底數就是2,所以小數可以表示成:【數值*2^指數】的形式,這個數值,其實就是尾數。
計算機專家們經過多種研究,最終經過IEEE確定了IEEE 754標準,即不確定整數和小數的位數(固定小數點,即定點數),而使用變化的位數,也就是小數點可以浮動,即浮點數表示法。浮點數表示法定義了小數由符號位+指數位+尾數位三部分組成。
符號位是1bit,0代表整數,1代表負數,指數位決定數值的量級,尾數位決定數值精度。
64位的說明如下:

??
?
其中11和52的設計是在平衡了很多需求后得到的最佳實踐。
Double (64位) = 符號位(1位) + 指數位(11位) + 尾數位(52位) 示例:455058496.0 的IEEE 754表示 原始值:455058496.0 二進制科學計數法:1.0101100001110000000000000000000 × 2^28 符號位:0 (正數) 指數位:28 + 1023(偏移量) = 1051 = 10000011011? 尾數位:0101100001110000000000000000000... (52位) 完整64位表示: 0 10000011011 0101100001110000000000000000000000000000000000000000
2.3.2 數值超過10^7或者小于10^-3會發生什么
其實什么也不會發生,只是基于如下原因綜合權衡的結果。
1、認知科學依據
?人類短期記憶的數字處理能力約為7±2位
?超過7位的整數部分難以快速理解
?科學計數法提供更好的可讀性
2、精度保持考慮
?10^7 = 10,000,000 (8位數字)
?超過此值,普通格式會顯得冗長
?10^-3 = 0.001,更小的數用科學計數法更清晰
3、歷史兼容性
?這個標準在多種編程語言中被采用
?保持了與C語言printf的兼容性
?符合IEEE 754標準的建議
這也就是為什么這個這個范圍內的數要表示成科學計數法了。
2.3.3 源碼探究
1、調用鏈路
根據源碼,可以看到Double.toString()方法的調用鏈是:

??
分流是否使用科學計數法的核心代碼toChars的代碼如下:
/*
* Formats the decimal f 10^e.
*/
private int toChars(byte[] str, int index, long f, int e, FormattedFPDecimal fd) {
/*
* For details not discussed here see section 10 of [1].
*
* Determine len such that
* 10^(len-1) <= f < 10^len
*/
int len = flog10pow2(Long.SIZE - numberOfLeadingZeros(f));
if (f >= pow10(len)) {
len += 1;
}
if (fd != null) {
fd.set(f, e, len);
return index;
}
/*
* Let fp and ep be the original f and e, respectively.
* Transform f and e to ensure
* 10^(H-1) <= f < 10^H
* fp 10^ep = f 10^(e-H) = 0.f 10^e
*/
f *= pow10(H - len);
e += len;
/*
* The toChars?() methods perform left-to-right digits extraction
* using ints, provided that the arguments are limited to 8 digits.
* Therefore, split the H = 17 digits of f into:
* h = the most significant digit of f
* m = the next 8 most significant digits of f
* l = the last 8, least significant digits of f
*
* For n = 17, m = 8 the table in section 10 of [1] shows
* floor(f / 10^8) = floor(193_428_131_138_340_668 f / 2^84) =
* floor(floor(193_428_131_138_340_668 f / 2^64) / 2^20)
* and for n = 9, m = 8
* floor(hm / 10^8) = floor(1_441_151_881 hm / 2^57)
*/
long hm = multiplyHigh(f, 193_428_131_138_340_668L) >>> 20;
int l = (int) (f - 100_000_000L * hm);
int h = (int) (hm * 1_441_151_881L >>> 57);
int m = (int) (hm - 100_000_000 * h);
if (0 < e && e <= 7) {
return toChars1(str, index, h, m, l, e);
}
if (-3 < e && e <= 0) {
return toChars2(str, index, h, m, l, e);
}
return toChars3(str, index, h, m, l, e);
}
代碼地址: https://github.com/openjdk/jdk/blob/master/src/java.base/share/classes/jdk/internal/math/DoubleToDecimal.java
可以看到使用科學計數法處理的核心代碼是toChars3,代碼如下:
private int toChars3(byte[] str, int index, int h, int m, int l, int e) {
/* -3 >= e | e > 7: computerized scientific notation */
index = putDigit(str, index, h);
index = putChar(str, index, '.');
index = put8Digits(str, index, m);
index = lowDigits(str, index, l);
return exponent(str, index, e - 1);
}
2、toChars3()的參數含義
?byte[] str: 輸出字符串的字節數組
?int index: 當前寫入位置的索引
?int h: 最高位數字 (0-9)
?int m: 中間8位數字 (00000000-99999999)
?int l: 低位數字 (用于精度控制)
?int e: 調整后的十進制指數值
3、 toChars3()的數據流處理步驟
1.putDigit(str, index, h) → 寫入最高位數字
2.putChar(str, index, '.') → 寫入小數點
3.put8Digits(str, index, m) → 寫入中間8位數字
4.lowDigits(str, index, l) → 寫入低位數字(去除尾隨零)
5.exponent(str, index, e-1) → 寫入指數部分
為什么使用 e-1?
原因:已經放置了一位數字在小數點前 目的:調整指數以保持數值不變 示例:4.55058496E7 表示 4.55058496 × 10^7
4、exponent()分析
標準科學計數法:a.bcd × 10^n 約束條件:1 ≤ a < 10(小數點前只有一位非零數字)
private int exponent(byte[] str, int index, int exp) {
str[index++] = (byte) 'E'; // 寫入字符 'E'
if (exp < 0) {
str[index++] = (byte) '-'; // 負指數寫入 '-'
exp = -exp; // 轉為正數處理
}
if (exp >= 100) {
str[index++] = (byte) ('0' + exp / 100); // 百位
exp %= 100;
}
if (exp >= 10) {
str[index++] = (byte) ('0' + exp / 10); // 十位
exp %= 10;
}
str[index++] = (byte) ('0' + exp); // 個位
return index;
}
?輸入參數: byte[] str(輸出緩沖區)、int index(寫入位置)、int exp(指數值)
?核心功能: 將指數值格式化為字符串并寫入字節數組
?處理邏輯: 優化處理1位、2位、3位數的指數
1. 寫入 'E' 2. 處理負號(如果 exp < 0) 3. 處理百位(如果 exp >= 100) 4. 處理十位(如果 exp >= 10) 5. 處理個位(必須)
?返回值: 更新后的索引位置
例子:
1. 原始數值: 45505849.6 2. 精確指數: 7.658067227112319 3. 調整后指數: 7.658 - 1 = 6.658 4. 四舍五入: 7 5. exponent方法輸入: exp = 7 6. 執行步驟: - 寫入 'E' → index = 1 - exp = 7 < 10,跳過百位和十位 - 寫入個位 '7' → index = 2 7. 輸出: "E7" 8. 完整結果: "4.55058496E7"
根據源代碼的邏輯簡化了一版如下:
https://coding.jd.com/newJavaEngineerOrientation/Double2String.git
三、解決方案
3.1 BigDecimal 精準控制
new BigDecimal(doubleValue).setScale(2, RoundingMode.HALF_UP).toPlainString()
3.2 DecimalFormat 格式化
new DecimalFormat("#0.00").format(doubleValue) // 強制保留兩位小數
四、總結
Double 數值的字符串格式化規則(如 Double.toString())遵循:
?普通格式(Plain):當數值的指數范圍在 [-3, 7) 時(即絕對值在 [10^-3, 10^7) 之間),直接顯示小數形式(如 0.001 或 123456.0)。
?科學計數法(Scientific):當指數范圍超出 [-3, 7)(如 0.000999 或 10000000.0),顯示為科學計數法(如 9.99e-4 或 1.0e7)。
審核編輯 黃宇
-
Doubler
+關注
關注
0文章
8瀏覽量
7355 -
string
+關注
關注
0文章
41瀏覽量
5076
發布評論請先 登錄
C陷阱與缺陷
科學計數如何轉化為數字。
long double to string函數找不到本地支持
IAR debug查看浮點類型變量怎么不用科學計數法顯示呢?
采用歸零法的N進制計數器原理
java中string不可變的原因
基于隱蔽信息存儲分布的隱蔽信道構造方法
如何使用C語言實現動態擴容的string
UTF8String是如何編碼的?
大促備戰中的隱蔽陷阱:Double轉String會使用科學計數法展示?
評論