写点什么

这些 Java8 官方挖的坑,你踩过几个?

用户头像
码大叔
关注
发布于: 2020 年 06 月 01 日
这些Java8官方挖的坑,你踩过几个?



导读:系统启动异常日志竟然被JDK吞噬无法定位?同样的加密方法,竟然出现部分数据解密失败?往List里面添加数据竟然提示不支持?日期明明间隔1年却输出1天,难不成这是天上人间?1582年神秘消失的10天JDK能否识别?Stream很高大上,List转Map却全失败……这些JDK8官方挖的坑,你踩过几个?



目录

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

二、被吞噬的异常:我不敢说出你的名字

三、日期计算:我想留住时间,让1天像1年那么长

四、List:一如你我初见,不增不减

五、Stream处理:给你,独一无二

六、结尾:纸上得来终觉浅,绝知此事要躬行!

一、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字母表之外的字符的编码。





关于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服务器,大佬们的反应亮了



< END >

码 大 叔

踩坑实战 硬核分享

微服务 | 大数据 | 架构设计 | 技术管理



发布于: 2020 年 06 月 01 日阅读数: 1519
用户头像

码大叔

关注

实战踩坑,硬核分享 2018.05.03 加入

公众号【码大叔】,关注微服务、大数据、架构设计、技术管理。

评论 (6 条评论)

发布
用户头像
说实话 Java8 的时间 API 还是觉得很繁琐,但也许时间本来就是个难题?要不霍金为啥写时间简史
2020 年 07 月 04 日 22:39
回复
用户头像
/**
* Returns a stream consisting of the elements of this stream, sorted
* according to natural order. If the elements of this stream are not
* {@code Comparable}, a {@code java.lang.ClassCastException} may be thrown
* when the terminal operation is executed.
*
* <p>For ordered streams, the sort is stable. For unordered streams, no
* stability guarantees are made.
*
* <p>This is a <a href="package-summary.html#StreamOps">stateful
* intermediate operation</a>.
*
* @return the new stream
*/
Stream<T> sorted();

我不建议用 Stream 来排序
展开
2020 年 06 月 03 日 11:35
回复
用户头像
神秘消失的10天在Java中没消失,还是11.....
2020 年 06 月 03 日 09:35
回复
用户头像
感谢分享避雷经验,InfoQ首页推荐。
2020 年 06 月 02 日 14:05
回复
瞬间写作动力满满
2020 年 06 月 02 日 19:08
回复
用户头像
真屏蔽词坑
2020 年 06 月 02 日 11:51
回复
没有更多了
这些Java8官方挖的坑,你踩过几个?