一个 Bug JDK 居然改了十年?

科技   2024-12-16 17:03   北京  
将 脚本之家 设为“星标
第一时间收到文章更新
出处:javadaydayup(ID:javadaydayup)

问题现象

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

假设现在有如下的代码:

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

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

修改代码为如下代码:

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

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

查看 Arrays 的静态内部类 ArrayListtoArray() 方法的返回值就是 Object[] 类型的,如下图所示:

image.png

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

原因分析

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

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

image.png

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

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

image.png

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

image.png

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

image.png

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

image.png

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

image.png

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

image.png

为什么是个Bug ?

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

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

image.png

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

如何修正的这个 Bug ?

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

java.util.ArrayList 类的入参为 Collection\<? exends E> 类型的构造函数中就涉及到可能调用 Arrays 的静态内部类 ArrayListtoArray() 方法,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 异常了,如下图所示:

image.png

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

题外话

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

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

// ArrayList 的 toArray() 方法实现:
public <T> T[] toArray(T[] a) {  
    if (a.length < size)  // 如果传入的数组的长度小于 size 
        // Make a new array of a's runtime type, but my contents:  
        return (T[]) Arrays.copyOf(elementData, size, a.getClass());  
    System.arraycopy(elementData, 0, a, 0, size);  
    if (a.length > size)  
        a[size] = null;  
    return a;  
}
// Arrays 的 coypyOf 方法实现:
public static <T,U> T[] copyOf(U[] original, int newLength, Class<? extends T[]> 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 时,走的逻辑和上面是一样的,也会进入到 ArayscopyOf 方法的调用中,但是调用方法传入的新建的数组相当于新建之后没有被使用,白白浪费了,需要等待 GC 回收。

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

List<String> 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));
image.png

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

参考

Arrays.asList(x).toArray().getClass() should be Object[].class
array cast Java 8 vs Java 9
toArray方法的小陷阱,写开发手册的大佬也未能幸免
.toArray(new MyClass[0]) or .toArray(new MyClass[myList.size()])?
Arrays of Wisdom of the Ancients
Java开发手册(黄山版).pdf

  推荐阅读:
  1. 微信的第一行代码,看起来,我也会。。。
  2. 越来越多的人开始讨厌15薪。
  3. 世界上最难的 5 种编程语言!网友看后惊讶道:竟不是C/C++?
  4. 为什么黑客喜欢攻击打印机?🧐

  5. Linux 中还有哆啦A梦中出现过的蒸汽机车呢

脚本之家
脚本之家(jb51.net)每天提供最新IT类资讯、原创内容、编程开发的教程与经验分享,送书福利天天在等你!
 最新文章