一張圖帶你看透BigDecimal(上)

對於Java開發人員來說,只要日常工作中涉及到算術運算,那必然會跟BigDecimal這個類打交道。也許我們可以記住一些使用的注意事項,如使用String的構造函數而不是double的構造函數來避免精度問題。但是對於一個5000行的龐然大物,僅僅瞭解兩個構造函數還不足以支撐我們大規模應用的信念,好在源代碼對我們是完全開放的,那不妨來一次源代碼的親密接觸。

 

按照Java的慣例在每個重要類前面都有一篇論文式的註釋,一般情況下把這段理解了應付個面試是沒啥問題的。BigDecimal也不例外的在類註釋上花了近200行,我們做個簡單的摘要:

²  首先給出BigDecimal的定義爲任意精度的有符號十進制數。BigDecimal可以表示爲一個任意精度的無刻度值和一個32位整型的刻度。

²  BigDecimal提供了一系列的方法,如算術操作、標度控制、舍入、比較等等方法,總之很強大。

²  BigDecimal通過precisionscalerounding modeMathContext類來控制標度和進行舍入操作。

²  BigDecimalequals方法並不是數學意義上的相等,所以在用於Sorted MapSorted Set這些和比較有關係的數據結構時需要特別小心。

 

在論文註釋的指引下,我們可以整理出BigDecimal類的脈絡:

image.png

 

接下來我們就順着脈絡一點點的解剖這個龐然大物了。


基本屬性

從圖中可以看出BigDecimal類主要需要關注5個主要屬性

Ø  intValscale

分別表示BigDecimal的無標度值和標度,結合我們在註釋裏看到的說法“BigDecimal可以表示爲一個任意精度的無刻度值和一個32位整型的刻度”,這兩個屬性可以認爲是BigDecimal類的骨架。

Ø  precision

BigDecimal中數字的個數,在確定了precision後就會要求結合Rounding Mode做一些舍入方面的操作。

Ø  stringCache

BigDecimal的字符表示,在toString方法的時候用到。

Ø  intCompact

無標度值的Long表示,方便後續計算。如果intValcompact的過程發現超過Long.MAX_VALUE則將intCompact記爲Long.MIN_VALUE

我們以三個例子來說明BigDecimal對於以上屬性的定義

BigDecimal b1 = new BigDecimal(“3.1415926”);

image.png

Debug的結果看,intVal爲空,因爲無標度值可以被壓縮存儲到intCompact中,precision表示有8個數字位,scale表示標度爲7

BigDecimal b2 = new BigDecimal(“31415926314159263141592631415926”);

image.png

intVal記錄的是無標度值,這時候由於無標度值超過了Long.MAX_VALUEintCompact存儲了Long.MIN_VALUEprecision表示當前數字位爲32個,scale0表示沒有小數位。

MathContext mc3 = new MathContext(30,RoundingMode.HALF_UP);
BigDecimal b2 = new BigDecimal(“31415926314159263141592631415926”);

image.png 

在這裏我們手動設置了precision30,所以最後兩位被丟棄並執行了舍入操作,同時scale記錄爲-2表示無標度值表示到小數點左邊兩位。

         

通過上面三個例子我們對BigDecimal5個基本屬性總結如下。

BigDecimal是通過unscaled valuescale來構造,同時使用Long.MAX_VALUE作爲我們是否壓縮的閾值。當unscaled value超過閾值時採用intVal字段存儲unscaled valueintCompact字段存儲Long.MIN_VALUE,否則對unscaled value進行壓縮存儲到long型的intCompact字段用於後續計算,intVal爲空。

scale字段存儲標度,可以理解爲unscaled value最後一位到實際值小數點的距離。如例1中對於3.1415926來說unscaled value31415926,最後一位6到實際值的小數點距離爲7scale記爲7;對於例3中手動設置precision的情況,unscaled value31415926xxx159的最後一位9到實際值31415926xxx15900的小數點距離爲2,由於在小數點左邊scale則記爲-2

precision字段記錄的是unscaled value的數字個數,當手動指定MathContext並且指定的precision小於實際precision的時候,會要求進行rounding操作。

 

創建函數

提到如何創建一個BigDecimal,首先想到的肯定是使用String參數的構造函數進行構建。

BigDecimal b = new BigDecimal(“3.14”);

實際上對於對象創建來說,BigDecimal提供了至少三種方式:

1, 構造函數

BigDecimal提供了16public的構造函數,支持通過char數組,StringdoubleBigIntegerlongint類型的參數構造。

2, 工廠方法

BigDecimal主要通過valueOf方法提供對象的靜態工廠,支持通過doubleBigIntegerlong類型的參數構造。具體用法:

BigDecimal f = BigDecimal.valueOf(1000L);

3, 對象緩存

對於常用的BigDecimal對象,內部通過數組進行緩存,並開放了ZEROONETEN三個對象供使用端複用。具體用法:

BigDecimal c = BigDecimal.ZERO;

 

接下來具體看看三種創建方式的實現方式。

構造函數

首先看看BigDecimal類提供的私有構造函數。

/**
     * Trusted package private constructor.
     * Trusted simply means if val is INFLATED, intVal could not be null and
     * if intVal is null, val could not be INFLATED.
     */
    BigDecimal(BigInteger intVal, long val, int scale, int prec) {
        this.scale = scale;
        this.precision = prec;
        this.intCompact = val;
        this.intVal = intVal;
    }

從這個私有構造函數可以看出BigDecimal對象主要關注的屬性字段,如果可以準確的給這些屬性字段賦值則可以成功構造一個BigDecimal對象。

這裏我們可以大膽猜測其他公共的構造函數和工廠方法內部的邏輯都是計算這些屬性字段。

 

從我們的脈絡圖上看,構造函數分爲字符構造和數值構造。

字符構造函數

對於字符構造我們只需要關注兩個構造函數即可:

1, public BigDecimal(char[] in, int offset, int len, MathContext mc)

從規模上看這個構造函數是所有字符構造函數中方法體最大的,同時結合其他字符構造函數的邏輯可以發現這個構造函數正是字符構造函數的核心邏輯實現。

2, public BigDecimal(String val)

之所以關注這個構造函數,一方面是實際應用的比較多,再者這個構造函數的100行註釋也表明了官方對於這個構造函數的推薦程度。

 

接下來我們集中攻克字符構造函數的核心實現,我們結合源代碼以程序流的方式進行說明。

 

第一步:處理符號位,如果是符號位則設置isneg字段並將offset往後移動一位

            // handle the sign
            boolean isneg = false;          // assume positive
            if (in[offset] == '-') {
                isneg = true;               // leading minus means negative
                offset++;
                len--;
            } else if (in[offset] == '+') { // leading + allowed
                offset++;
                len--;
            }

 

第二步,針對可壓縮的情況,遍歷字符進行分別處理。

²  如果是字符0判斷了兩種情況來處理preccompact value的賦值,主要解決”00”這種多個0的無意義輸入。

1) 第一位數字爲0,則直接將prec設置爲1

2) 非第一位數字爲0,則判斷之前的數值是否爲0,如果爲0則表明前面的數字是0,當前數字不予處理;如果不爲0則將數值乘以10prec1

                    if ((c == '0')) { // have zero
                        if (prec == 0)
                            prec = 1;
                        else if (rs != 0) {
                            rs *= 10;
                            ++prec;
                        } // else digit is a redundant leading zero
                        if (dot)
                            ++scl;
                    }

²  如果是字符1-9的情況,同樣處理了preccompact value的賦值,主要考慮解決”01”這種以0開頭的數字的prec問題。

                   else if ((c >= '1' && c <= '9')) { // have digit
                        int digit = c - '0';
                        if (prec != 1 || rs != 0)
                            ++prec; // prec unchanged if preceded by 0s
                        rs = rs * 10 + digit;
                        if (dot)
                            ++scl;
                    }

²  如果是字符”.”的情況,主要解決出現了多個小數點的情況。

²  如果是Unicode或者其他格式的字符表示,通過Character.isDigit方法進行判斷,判斷完並完成轉換後將上面01-9的邏輯再走一遍,有點重複代碼的嫌疑。

²  如果是字符”e””E”,解析出e後面的數字用於後面計算scale

 

第三步,結合之前字符解析得到的precMathContext設置的prec進行rounding操作。主要邏輯是通過相差的prec算出一個drop,然後使用compact valuedrop去做除法,比如需要drop 3位,那麼就拿compact value1000去做除法,並結合Rounding Mode判斷結果是否需要加1

由於rounding之後可能存在進位問題,這裏使用while循環來進行檢查。

                int mcp = mc.precision;
                int drop = prec - mcp;
                if (mcp > 0 && drop > 0) {  // do rounding
                    while (drop > 0) {
                        scl = checkScaleNonZero((long) scl - drop);
                        rs = divideAndRound(rs, LONG_TEN_POWERS_TABLE[drop], mc.roundingMode.oldMode);
                        prec = longDigitLength(rs);
                        drop = prec - mcp;
                    }
                }

第四步,針對不可壓縮的情況,引入一個char數組容器用於構建BigInteger類型的intValue。其他對於字符的處理以及如何設置precscale以及如何處理rounding和數值可壓縮的情況基本一致。

 

至此我們對於字符構造函數的分析已經結束,我們可以發現對於String類型的構造函數,我們其實是首先將String轉換成數組類型char[],然後調用字符數組構造函數。所以出於性能考慮,如果我們的應用場景裏面獲取的是char[],可以直接調用字符數組構造函數,沒有必要先轉成String再去調用String構造函數,以至於白白損耗了兩次轉換的性能。

數值構造函數

在數值構造函數中,我們重點關注double類型的構造函數,因爲這是在日常使用中最容易出問題的地方。

其他構造函數的主要邏輯重點在於rounding和對於四個核心屬性的賦值,這點可以在字符構造函數和後續的重點方法介紹中找到相應的實現解析。

 

下面就讓我們集中火力攻克double構造函數吧,同樣也是源代碼結合程序流的方式。

 

第一步,將double轉換成IEEE 754定義的浮點數bit表示方式,並通過位運算獲取到三個部分的值。

image.png

其中轉換成bit表示方式的方法是調用的虛擬機的native方法。

 

獲取sign的值比較好理解,右移63位後判斷值是否爲0來確定數值的正負。

int sign = ((valBits >> 63) == 0 ? 1 : -1);

 

對於exponentsignificand的邏輯就比較複雜了,首先明確目標是將這個double表示爲以下格式val == sign * significand * 2^exponent,再來看代碼:

int exponent = (int) ((valBits >> 52) & 0x7ffL);
long significand = (exponent == 0
                ? (valBits & ((1L << 52) - 1)) << 1
                : (valBits & ((1L << 52) - 1)) | (1L << 52));
exponent -= 1075;

要看懂這段代碼我們首先需要了解IEE754在浮點數轉換的幾點約定:

²  小數點左邊隱含一位,通常是1

²  單精度偏移量127,雙精度偏移量是1023

這時候回頭來看這段代碼,在計算significand的時候分成了兩種情況,當exponent0的時候直接進行左移右邊補0否則在左邊補1,都是爲了補齊52個有效位和一個隱含位。

exponent需要偏移1075 = 1023 + 52,來源於自身的1023偏移量加上52位的有效位偏移。

 

第二步,將significand進行格式化,去除低位的0

        while ((significand & 1) == 0) { // i.e., significand is even
            significand >>= 1;
            exponent++;
        }

 

第三步,計算intValscale

        BigInteger intVal;
        long compactVal = sign * significand;
        if (exponent == 0) {
            intVal = (compactVal == INFLATED) ? INFLATED_BIGINT : null;
        } else {
            if (exponent < 0) {
                intVal = BigInteger.valueOf(5).pow(-exponent).multiply(compactVal);
                scale = -exponent;
            } else { //  (exponent > 0)
                intVal = BigInteger.valueOf(2).pow(exponent).multiply(compactVal);
            }
            compactVal = compactValFor(intVal);
        }

計算的時候按照exponent分成三種情況,

exponent==0,直接計算intVal

exponent<0,表明存在小數位,由於二進制數0.1對應的十進制爲0.5,所以小數位的轉換是5作爲底

exponent>0,表明需要要在右邊補充0,二進制數1.0對應的十進制爲2,所以整數位的轉換是2作爲底。

 

第四步,根據MathContext進行rounding操作,獲取precisionintValuecompact value。這一步是通用操作,就不做過多表述。

 

至此對於數值構造函數的分析已經結束。我們主要分析了double類型的構造函數,從代碼和程序流程可以看出double類型的構造函數首先將double轉換成IEEE標準的二進制表示形式並分離出符號位、指數位和有效位,然後計算出precisionscaleintValcompactVal來表示一個BigDecimal。由於小數轉二進制存在誤差導致了這個構造函數構造出的BigDecimal對象和實際值之間存在誤差,這也是爲什麼double類型的構造函數不推薦使用的原因。

 

工廠函數

BigDecimal的工廠函數是通過靜態的valueOf方法提供的,主要針對longBigIntegerdouble類型的參數。

由於longBigInteger的數據類型和BigDecimal中的intValueintCompact匹配,所以對於這兩種類型的工廠方法實現相對簡單,主要就是四個屬性的賦值。

而在double類型的工廠方法中,使用了和構造函數完全不同的構造邏輯:

    public static BigDecimal valueOf(double val) {
        // Reminder: a zero double returns '0.0', so we cannot fastpath
        // to use the constant ZERO.  This might be important enough to
        // justify a factory approach, a cache, or a few private
        // constants, later.
        return new BigDecimal(Double.toString(val));
    }

這裏通過調用DoubletoString方法首先將double轉換成字符串然後再調用字符構造函數,從而避免了精度丟失的問題,所以在註釋中也提示了使用者:如果一定要用double來構造BigDecimal對象優先使用工廠方法。


由於篇幅限制,本次解讀分爲上下兩篇,上半部分主要解讀基礎屬性和構造函數,下半部分主要聚焦算術運算和關鍵方法。

一張圖帶你看透BigDecimal(下)


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章