一个 Bug JDK 居然改了十年?

摘要:再次执行代码,结果就会抛出 ArrayStoreException 异常,这个异常表明这里并不能把一个 Integer 类型的对象存放到这个数组里面。如下图所示:

今天偶然看到了一个 JDK 的 Bug,给大家分享一下。

假设现在有如下的代码:

List list = new ArrayList;list.add("1");Object array = list.toArray;array[0] = 1;System.out.println(Arrays.toString(array));

上面的代码是可以正常支执行的,如下图所示:

修改代码为如下代码:

List list = Arrays.asList("1");Object array = list.toArray;array[0] = 1;System.out.println(Arrays.toString(array));

再次执行代码,结果就会抛出 ArrayStoreException 异常,这个异常表明这里并不能把一个 Integer 类型的对象存放到这个数组里面。如下图所示:

查看 Arrays 的静态内部类 ArrayList 的 toArray 方法的返回值就是 Object 类型的,如下图所示:

这里就会引发一个疑问: 为啥使用 Java.lang.util.ArrayList 代码就可以正常运行?但是使用 Arrays 的静态内部类 ArrayList 就会报错了?

首先看下 java.lang.util.ArrayList 类的 toArray 方法的实现逻辑:

从上面可以看出 toArray 方法是拷贝了一个 ArrayList 内部的数组对象,然后返回的。而 elementData 这个数组在实际初始化的时候,就是 new 了 Object 类型的数组。如下图所示:

那么经过拷贝之后返回的还是一个实际类型为Object 类型的数组。既然这里是一个 Object 类型的数组,那么往里面放一个 Integer 类型的数据是合法的,因为 Object 是 Integer 类型的父类。

然后再看下 Arrays 的静态内部类 ArrayList 的 toArray 方法的实现逻辑。这里返回的是 a 这个数组的一个克隆。如下图所示:

而这个 a 数组声明的类型是 E,根据泛型擦除后的原则,这里实际上声明的类型也变成了 Object。 如下图所示:

那接下来再看看 a 实际的类型是什么? 由于 Arrays 的静态内部类 ArrayList 的构造函数是包级访问的,因此只能通过 Arrays.asList 静态方法来构造一个这个对象。如下图所示:

而 Arrays.asList 方法的签名是变长参数类型,这个是 java 的一个语法糖,实际对应的是一个数组,泛型擦除后就变成了 Object 类型。如下图所示:

而在代码实际调用处,实际上会 new 一个 String 类型的数组,也就是说 「a 的实际类型是一个 String 类型的数组」。 那么 a 调用了 clone 方法之后返回的类型也是一个 String 类型的数组,克隆嘛,类型一样才叫克隆。如下图所示:

经过上面的分析,答案就呼之欲出了。a 的实际类型是一个 String 类型的数组,那么往这个数组里面放一个 Integer 类型的对象那肯定是要报错的。等效代码如下图所示:

查看 Collection 接口的方法签名,方法声明明确是要返回的是一个 Object 类型的数组,因为方法明确声明了返回的是一个 Object 类型的数组,但是实际上在获取到了这个返回值后把它当作一个 Object 类型的数组使用某些情况下是不满足语义的。

同时这里要注意一下,返回的这个数组要是一个 「安全」的数组,安全的意思就是「集合本身不能持有对返回的数组的引用」,即使集合的内部是用数组实现的,也不能直接把这个内部的数组直接返回。这就是为什么上面两个 toArray 方法的实现要么是把原有的数组复制了一份,要么是克隆了一份,本质上都是新建了一个数组。如下图所示:

在 OpenJDK 的 BugList 官网上很早就有人提出这个问题了,从时间上看至少在 2005 年就已经发现这个 Bug 了,这个 Bug 真正被解决是在 2015 年的时候,整整隔了 10 年时间。花了 10 年时间修这个 Bug,真是十年磨一剑啊!

JDK 9 中的实现修改为了新建一个 Object 类型的数组,然后把原有数组中的元素拷贝到这个数组里面,然后返回这个 Object 类型的数组,这样的话就和 java.util.ArrayList 类中的实现方法一样了。

在 java.util.ArrayList 类的入参为 Collection\ 类型的构造函数中就涉及到可能调用 Arrays 的静态内部类 ArrayList 的 toArray 方法,JDK 在实现的时候针对这个 Bug 还做了特殊的处理,不同厂商发行的 JDK 处理方式还有细微的不同。

Oracel JDK 8 版本的实现方式

Eclipse Temurin Open JDK 8 版本的实现方式

之所以在 java.util.ArrayList 对这个 Bug 做特殊的处理是因为 Sun 公司在当时选择不修复改这个Bug,因为怕修复了之后已有的代码就不能运行了。如下图所示:

比如在修复前有如下的代码,这个代码在 JDK 8 版本是可以正常运行的,如下图所示:

String strings = (String) Arrays.asList("foo", "bar").toArray; for (String string : strings) { System.out.println(string); }

但是如果升级到 JDK 9 版本,就会报 ClassCastException 异常了,如下图所示:

因为修复了这个 Bug 之后,编译器并不能告诉你原来的代码存在问题,甚至连新的警告都没有。假设你从 JDK 8 升级到 JDK 9 了,代码也没有改,但是突然功能就用不了,这个时候你想不想骂人,哈哈哈哈。这也许就是 Sun 公司当年不愿意修复这个 Bug 的原因之一了。当然,如果你要问我为什么要升级的话,我会说:你发任你发,我用 Java 8 !

题外话

阿里巴巴的 Java开发手册对 toArray(T array) 方法的调用有如下的建议:

这里以 java.util.ArrayList 类的源码作为参考,源码实现如下:

// ArrayList 的 toArray 方法实现:public T toArray(T a) { if (a.length size) a[size] = null; return a; }// Arrays 的 coypyOf 方法实现:public static T copyOf(U original, int newLength, Class newType) { @SuppressWarnings("unchecked") T copy = ((Object)newType == (Object)Object.class) ? (T) new Object[newLength] : (T) Array.newInstance(newType.getComponentType, newLength); System.arraycopy(original, 0, copy, 0, Math.min(original.length, newLength)); return copy; }

当调用 toArray 方法时传入的数组长度为 0 时,方法内部会根据传入的数组类型动态创建一个和当前集合 size 相同的数组,然后把集合的元素复制到这个数组里面,然后返回。

当调用 toArray 方法时传入的数组长度大于 0,小于 ArrayList 的 size 时,走的逻辑和上面是一样的,也会进入到 Arays 的 copyOf 方法的调用中,但是调用方法传入的新建的数组相当于新建之后没有被使用,白白浪费了,需要等待 GC 回收。

当调用 toArray 方法时传入的数组长度大于等于 ArrayList 的 size 时,则会直接把集合的元素拷贝到这个数组中。如果是大于的情况,还会把数组中下标为 size 的元素设置为 null,但是 size 下标后面的元素保持不变。如下所示:

List list = new ArrayList; list.add("1"); String array = new String[3]; array[1] = "2"; array[2] = "3"; String toArray = list.toArray(array); System.out.println(array == toArray); System.out.println(Arrays.toString(toArray));

手册中提到的在高并发的情况下,传入的数组长度等于 ArrayList 的 size 时,如果 ArrayList 的 size 在数组创建完成后变大了,还是会走到重新新建数组的逻辑里面,仍然会导致调用方法传入的新建的数组没有被使用,而且这里因为调用方法时新建的数组和 ArrayList 之前的 size 相同,会造成比传入长度为 0 的数组浪费多得多的空间。但是我个人觉得,因为 ArrayList 不是线程安全的,如果存在数据竞争的情况就不应该使用。

来源:老男孩的成长之路一点号

相关推荐