這些Java8官方挖過的坑,你踩過幾個?

碼大叔發表於2020-06-01

在這裡插入圖片描述

導讀:系統啟動異常日誌竟然被JDK吞噬無法定位?同樣的加密方法,竟然出現部分資料解密失敗?往List裡面新增資料竟然提示不支援?日期明明間隔1年卻輸出1天,難不成這是天上人間?1582年神祕消失的10天JDK能否識別?Stream很高大上,List轉Map卻全失敗……這些JDK8官方挖的坑,你踩過幾個? 關注公眾號【碼大叔】,實戰踩坑硬核分享,一起交流!

@

一、Base64:你是我解不開的迷

出於使用者隱私資訊保護的目的,系統上需將姓名、身份證、手機號等敏感資訊進行加密儲存,很自然選擇了AES演算法,外面又套了一層Base64,之前用的是sun.misc.BASE64Decoder/BASE64Encoder,網上的資料基本也都是這種寫法,執行得很完美。但這種寫法在idea或者maven編譯時就會有一些黃色告警提示。到了Java 8後,Base64編碼已經成為Java類庫的標準,內建了 Base64 編碼的編碼器和解碼器。於是乎,我手賤地修改了程式碼,改用了jdk8自帶的Base64方法

import java.util.Base64;

public class Base64Utils {

    public static final Base64.Decoder DECODER = Base64.getDecoder();
    public static final Base64.Encoder ENCODER = Base64.getDecoder();

    public static String encodeToString(byte[] textByte) {
        return ENCODER.encodeToString(textByte);
    }

    public static byte[] decode(String str) {
        return DECODER.decode(str);
    }

}

程式設計師的職業操守我們還是有的,構造新老資料、自測、通過,提交測試版本。信心滿滿,我要繼續延續我 0 Bug的神話!然後……然後版本就被打回了。

Caused by: java.lang.IllegalArgumentException: Illegal base64 character 3f
    at java.util.Base64$Decoder.decode0(Base64.java:714)
    at java.util.Base64$Decoder.decode(Base64.java:526)
    at java.util.Base64$Decoder.decode(Base64.java:549)

關鍵是這個錯還很詭異,部分資料是可以解密的,部分解不開

Base64依賴於簡單的編碼和解碼演算法,使用65個字元的US-ASCII子集,其中前64個字元中的每一個都對映到等效的6位二進位制序列,第65個字元(=)用於將Base64編碼的文字填充到整數大小。後來產生了3個變種:

  • RFC 4648:Basic
    此變體使用RFC 4648和RFC 2045的Base64字母表進行編碼和解碼。編碼器將編碼的輸出流視為一行; 沒有輸出行分隔符。解碼器拒絕包含Base64字母表之外的字元的編碼。​
  • RFC 2045:MIME
    此變體使用RFC 2045提供的Base64字母表進行編碼和解碼。編碼的輸出流被組織成不超過76個字元的行; 每行(最後一行除外)通過行分隔符與下一行分隔。解碼期間將忽略Base64字母表中未找到的所有行分隔符或其他字元。
  • RFC 4648:Url
    此變體使用RFC 4648中提供的Base64字母表進行編碼和解碼。字母表與前面顯示的字母相同,只是-替換+和_替換/。不輸出行分隔符。解碼器拒絕包含Base64字母表之外的字元的編碼。
S.N. 方法名稱 & 描述
1 static Base64.Decoder getDecoder()
返回Base64.Decoder解碼使用基本型base64編碼方案。
2 static Base64.Encoder getEncoder()
返回Base64.Encoder編碼使用的基本型base64編碼方案。
3 static Base64.Decoder getMimeDecoder()
返回Base64.Decoder解碼使用MIME型別的base64解碼方案。
4 static Base64.Encoder getMimeEncoder()
返回Base64.Encoder編碼使用MIME型別base64編碼方案。
5 static Base64.Encoder getMimeEncoder(int lineLength, byte[] lineSeparator)
返回Base64.Encoder編碼使用指定的行長度和線分隔的MIME型別base64編碼方案。
6 static Base64.Decoder getUrlDecoder()
返回Base64.Decoder解碼使用URL和檔名安全型base64編碼方案。
7 static Base64.Encoder getUrlEncoder()
返回Base64.Decoder解碼使用URL和檔名安全型base64編碼方案。

關於base64用法的詳細說明,可參考:https://juejin.im/post/5c99b2976fb9a070e76376cc

對於上面的錯誤,網上有的說法是,建議使用Base64.getMimeDecoder()Base64.getMimeEncoder(),對此我只能建議:老的系統如果已經有資料了,就不要使用jdk自帶的Base64了。JDK官方的Base64和sun的base64是不相容的!不要替換!不要替換!不要替換!

二、被吞噬的異常:我不敢說出你的名字

這個問題理解起來還是蠻費腦子的,所以我把這個系統異常發生的過程提煉成了一個美好的故事,放鬆一下,吟詩一首!

最怕相思濃
一切皆是你
唯獨
不敢說出你的名字
-- 碼大叔

這個問題是在使用springboot的註解時遇到的問題,發現JDK在解析註解時,若註解依賴的類定義在JVM載入時不存在,也就是NoClassDefFoundError時,實際拿到的異常將會是ArrayStoreException,而不是NoClassDefFoundError,涉及到的JDK裡的類是AnnotationParser.java, 具體程式碼如下:

private static Object parseClassArray(int paramInt, ByteBuffer paramByteBuffer, ConstantPool paramConstantPool, Class<?> paramClass) {
    Class[] arrayOfClass = new Class[paramInt];
    int i = 0;
    int j = 0;
    for (int k = 0; k < paramInt; k++){
        j = paramByteBuffer.get();
        if (j == 99) {
            // 注意這個方法
        	arrayOfClass[k] = parseClassValue(paramByteBuffer, paramConstantPool, paramClass);
        } else {
        	skipMemberValue(j, paramByteBuffer);
        	i = 1;
        }
    }
    return i != 0 ? exceptionProxy(j) : arrayOfClass;
}
private static Object parseClassValue(ByteBuffer paramByteBuffer, ConstantPool paramConstantPool, Class<?> paramClass) {
    int i = paramByteBuffer.getShort() & 0xFFFF;
    try
    {
        String str = paramConstantPool.getUTF8At(i);
        return parseSig(str, paramClass);
    } catch (IllegalArgumentException localIllegalArgumentException) {
        return paramConstantPool.getClassAt(i);
    } catch (NoClassDefFoundError localNoClassDefFoundError) {
         // 注意這裡,異常發生了轉化
        return new TypeNotPresentExceptionProxy("[unknown]", localNoClassDefFoundError);
    } catch (TypeNotPresentException localTypeNotPresentException) {
        return new TypeNotPresentExceptionProxy(localTypeNotPresentException.typeName(), localTypeNotPresentException.getCause());
    }
}

parseClassArray這個方法中,預期parseClassValue返回Class物件,但看實際parseClassValue的邏輯,在遇到NoClassDefFoundError時,返回的是TypeNotPresentExceptionProxy,由於型別強轉失敗,最終丟擲的是java.lang.ArrayStoreException: sun.reflect.annotation.TypeNotPresentExceptionProxy,此時只能通過debug到這行程式碼,找到具體是缺少哪個類定義,才能解決這個問題。

筆者重現一下發現這個坑的場景,有三個module,module3依賴module2但未宣告依賴module1,module2依賴module1,但宣告的是optional型別,依賴關係圖如下:
在這裡插入圖片描述

上面每個module中有一個Class,我們命名為ClassInModuleX。ClassInModule3啟動時在註解中使用了ClassInModule2的類,而ClassInModule2這個類的繼承了ClassInModule1,這幾個類的依賴關係圖如下:
在這裡插入圖片描述

如此,其實很容易知道在module執行ClassInModule3時,會出現ClassInModule1的NoClassDefFoundError的,但實際執行時,你能看到的異常將不是NoClassDefFoundError,而是java.lang.ArrayStoreException: sun.reflect.annotation.TypeNotPresentExceptionProxy,此時,若想要知道具體是何許異常,需通過debug在AnnotationParser中定位具體問題,以下展示兩個截圖,分別對應系統控制檯實際丟擲的異常和通過debug發現的異常資訊。

控制檯異常資訊:
在這裡插入圖片描述
注意異常實際在紅色圈圈這裡,自動收縮了,需要展開才可以看到通過debug發現的異常資訊:
在這裡插入圖片描述
如果你想體驗這個示例,可關注公眾號碼大叔和筆者交流。如果你下次遇到莫名的java.lang.ArrayStoreException: sun.reflect.annotation.TypeNotPresentExceptionProxy,請記得用這個方法定位具體問題。

三、日期計算:我想留住時間,讓1天像1年那麼長

Java8之前日期時間操作相當地麻煩,無論是Calendar還是SimpleDateFormat都讓你覺得這個設計怎麼如此地反人類,甚至還會出現多執行緒安全的問題,阿里巴巴開發手冊中就曾禁用static修飾SimpleDateFormat。好在千呼萬喚之後,使出來了,Java8帶來了全新的日期和時間API,還帶來了Period和Duration用於時間日期計算的兩個API。

Duraction和Period,都表示一段時間的間隔,Duraction正常用來表示時、分、秒甚至納秒之間的時間間隔,Period正常用於年、月、日之間的時間間隔。

網上的大部分文章也是這麼描述的,於是計算兩個日期間隔可以寫成下面這樣的程式碼:

// parseToDate方法作用是將String轉為LocalDate,略。
LocalDate date1 = parseToDate("2020-05-12");
LocalDate date2 = parseToDate("2021-05-13");
// 計算日期間隔
int period = Period.between(date1,date2).getDays();

一個是2020年,一個是2021年,你認為間隔是多少?1年?
恭喜你,和我一起跳進坑裡了(畫外音:裡面的都擠一擠,動一動,又來新人了)。
正確答案應該是:1天。

這個單詞的含義以及這個方法看起來確實是蠻誤導人的,一不注意就會掉進坑裡。Period其實只能計算同月的天數、同年的月數,不能計算跨月的天數以及跨年的月數。

正確寫法1

 long period = date2.toEpochDay()-date1.toEpochDay();

toEpochDay():將日期轉換成Epoch 天,也就是相對於1970-01-01(ISO)開始的天數,和時間戳是一個道理,時間戳是秒數。顯然,該方法是有一定的侷限性的

正確寫法2

long period = date1.until(date2,ChronoUnit.DAYS);

使用這個寫法,一定要注意一下date1和date2前後順序:date1 until date2。

正確做法3(推薦)

 long period = ChronoUnit.DAYS.between(date1, date2);

ChronoUnit:一組標準的日期時間單位。這組單元提供基於單元的訪問來操縱日期,時間或日期時間。 這些單元適用於多個日曆系統。這是一個最終的、不可變的和執行緒安全的列舉。

看到”適用於多個日曆系統“這句話,我一下子想起來歷史上1582年神祕消失的10天,在JDK8上是什麼效果呢?1582-10-15和1582-10-04你覺得會相隔幾天呢?11天還是1天?有興趣的小夥伴自己去寫個程式碼試試吧。
在這裡插入圖片描述
開啟你的手機,跳轉到1582年10月,你就能看到這消失的10天了。

四、List:一如你我初見,不增不減

這個問題其實在JDK裡存在很多年了,JDK8中依然存在,也是很多人最容易跳的一個坑!直接上程式碼:

public List<String> allUser() {
    // 省略
    List<String> currentUserList = getUser();
    currentUserList.add("碼大叔");
    // 省略
}

就是上面這樣一段程式碼,往一個list裡新增一條資料,你覺得結果是什麼呢?“碼大叔”成功地新增到了List裡?天真,不報個錯你怎麼能意識到JDK存在呢。

Exception in thread "main" java.lang.UnsupportedOperationException
    at java.util.AbstractList.add(AbstractList.java:148)

原因
因為在getUser方法裡,返回的List使用的是Arrays.asList生成的,示例:

    private List<String> getUser(){
        return Arrays.asList("劍聖","小九九");
    }

我們來看看Arrays.asList的原始碼

    @SafeVarargs
    @SuppressWarnings("varargs")
    public static <T> List<T> asList(T... a) {
        return new ArrayList<>(a);
    }
 private static class ArrayList<E> extends AbstractList<E>
        implements RandomAccess, java.io.Serializable
    {
   		private final E[] a;
        // 部分程式碼略
        ArrayList(E[] array) {
            // 返回的是一個定長的陣列
            a = Objects.requireNonNull(array);
        }
        // 部分程式碼略
   }

很明顯,返回的實際是一個定長的陣列,所以只能“一如你我初見”,初始化什麼樣子就什麼樣子,不能新增,不能減少。如果你理解了,那我們就再來一個栗子

   int[] intArr  = {1,2,3,4,5};
   Integer[] integerArr  = {1,2,3,4,5};
   String[] strArr = {"1", "2", "3", "4", "5"};
   List list1 = Arrays.asList(intArr);
   List list2 = Arrays.asList(integerArr);
   List list3 = Arrays.asList(strArr);
   System.out.println("list1中的數量是:" + list1.size());
   System.out.println("list2中的數量是:" + list2.size());
   System.out.println("list3中的數量是:" + list3.size());

你覺得答案是什麼?預想3秒鐘,揭曉答案,看跟你預想的是否一致呢?

list1中的數量是:1
list2中的數量是:5
list3中的數量是:5

是不是和你預想又不一樣了?還是回到Arrays.asList方法,該方法的輸入只能是一個泛型變長引數。基本型別是不能泛型化的,也就是說8個基本型別不能作為泛型引數,要想作為泛型引數就必須使用其所對應的包裝型別,那前面的例子傳遞了一個int型別的陣列,為何程式沒有報編譯錯誤呢?在Java中,陣列是一個物件,它是可以泛型化的,也就是說我們的例子是把一個int型別的陣列作為了T的型別,所以在轉換後在List中就只有1個型別為int陣列的元素了。除了int,其它7個基本型別的陣列也存在相似的問題。

JDK裡還為我們提供了一個便捷的集合操作工具類Collections,比如多個List合併時,可以使用Collections.addAll(list1,list2), 在使用時也同樣要時刻提醒自己:“請勿踩坑”!

五、Stream處理:給你,獨一無二

Java8中新增了Stream流 ,通過流我們能夠對集合中的每個元素進行一系列並行或序列的流水線操作。當使用一個流的時候,通常包括三個基本步驟:獲取一個資料來源(source)→ 資料轉換→執行操作獲取想要的結 果,每次轉換原有 Stream 物件不改變,返回一個新的 Stream 物件(可以有多次轉換),這就允許對其操作可以 像鏈條一樣排列,變成一個管道。
在這裡插入圖片描述
專案上千萬不要使用Stream,因為一旦用起來你會覺得真遮蔽詞爽,根本停不下來。當然不可避免的,還是有一些小坑的。

假設我們分析使用者的訪問日誌,放到list裡。

list.add(new User("碼大叔", "登入公眾號"));
list.add(new User("碼大叔", "編寫文章"));

因為一些原因,我們要講list轉為map,Steam走起來,

private static void convert2MapByStream(List<User> list) {
    Map<String, String> map = list.stream().collect(Collectors.toMap(User::getName, User::getValue));
    System.out.println(map);
}

咣噹,掉坑裡了,程式將丟擲異常:

Exception in thread "main" java.lang.IllegalStateException: Duplicate key 碼大叔

使用Collectors.toMap() 方法中時,預設key值是不允許重複的。當然,該方法還提供了第三個引數:也就是出現 duplicate key的時候的處理方案

如果在開發的時候就考慮到了key可能重複,你需要在這樣定義convert2MapByStream方法,宣告在遇到重複key時是使用新值還是原有值:

    private static void convert2MapByStream(List<User> list) {
        Map<String, String> map = list.stream().collect(Collectors.toMap(User::getName, User::getValue, (oldVal, newVal) -> newVal));
        System.out.println(map);
    }

關於Stream的坑其實還是蠻多的,比如尋找list中的某個物件,可以使用findAny().get(),你以為是找到就返回找不到就就返回null?依然天真,找不到會丟擲異常的,需要使用額外的orElse方法。

六、結尾:紙上得來終覺淺,絕知此事要躬行!

所謂JDK官方的坑,基本上都是因為我們對技術點了解的不夠深入,望文生義,以為是怎樣怎樣的,而實際上我們的自以為是讓我們掉進了一個又一個坑裡。面對著這些坑,我流下了學藝不精的眼淚!但也有些坑,確實發生的莫名其妙,比如吞噬異常,沒有理解JDK為什麼這麼設計。還有些坑,誤導性確實太強了,比如日期計算、list操作等。最後只能說一句:

紙上得來終覺淺,絕知此事要躬行!
編碼不易,且行且珍惜!

推薦閱讀

Try-Catch包裹的程式碼異常後,竟然導致了產線事務回滾!
Redis 6.0 新特性-多執行緒連環13問!
報告老闆,微服務高可用神器已祭出,您花巨資營銷的高流量來了沒?
我成功攻擊了Tomcat伺服器,大佬們的反應亮了

公眾號:碼大叔
資深程式設計師、架構師技術社群
微服務 | 大資料 | 架構設計 | 技術管理
個人微信:itmadashu

相關文章