← 返回题目列表

为什么数组是协变的而泛型是不变的?什么是 ArrayStoreException?

困难 第 32 / 32 题 更新于 2026/07/27
协变泛型数组型变

简化版

协变」指「子类型的容器能当父类型的容器用」。数组是协变的——String[] 可以赋值给 Object[]Object[] arr = new String[3] 合法);但泛型是不变的——List<String> 不能赋值给 List<Object>List<Object> list = new ArrayList<String>() 编译错误)。为什么不同?因为数组的协变是「不安全」的——它把「类型检查」推迟到了运行时Object[] arr = new String[3]; arr[0] = 1; 编译能过,但运行时抛 ArrayStoreException(往 String 数组里存了 Integer)。泛型设计者吸取教训,让泛型不变、把类型检查放在编译期(更安全,编译就拦住),代价是灵活性差,所以又用通配符(? extends/? super 来按需实现协变/逆变。

详细版

数组协变 vs 泛型不变

// 数组是协变的(子类型数组能当父类型数组用)——但不安全
Object[] objArr = new String[3];   // ✓ 编译通过!String[] 是 Object[] 的子类型
objArr[0] = "hello";               // ✓ 存 String,没问题
objArr[1] = 123;                   // ✓ 编译通过!但运行时抛 ArrayStoreException!
                                   //   因为实际是 String[],存不了 Integer

// 泛型是不变的(子类型泛型不能当父类型泛型用)——但安全
List<Object> list = new ArrayList<String>();  // ✗ 编译错误!List<String> 不是 List<Object>
// 泛型在编译期就拦住了这种赋值,从根上杜绝了上面数组的运行时错误

为什么这样设计

维度数组(协变)泛型(不变)
型变协变(子类型数组可赋值给父类型数组)不变(不允许)
类型检查时机运行时(存元素时检查)编译期
安全性不安全(运行时才报 ArrayStoreException)安全(编译期拦截)
代价错误延迟到运行时才发现灵活性差(用通配符补)

通配符补充灵活性(PECS)

// ? extends:上界通配,实现"协变"(能读不能写)——生产者用
List<? extends Number> producer = new ArrayList<Integer>();  // ✓
Number n = producer.get(0);   // 能读
// producer.add(1);           // ✗ 不能写(保证安全)

// ? super:下界通配,实现"逆变"(能写不能安全读)——消费者用
List<? super Integer> consumer = new ArrayList<Number>();    // ✓
consumer.add(1);              // 能写

⚠️ 数组协变的「不安全」是它把类型检查推迟到运行时的直接后果——Object[] arr = new String[3]; arr[0] = 123; 编译期不报错(因为编译器只知道 arr 是 Object[],往里存 Integer 语法合法),要等运行时存入元素时才发现「实际是 String[]、存不了 Integer」而抛 ArrayStoreException。泛型的「不变」正是为了避免这种「编译能过、运行才炸」——把错误提前到编译期。这就是「泛型比数组更类型安全」的体现。

完整版教学

一、什么是型变:协变、逆变、不变

「型变(Variance)」描述「子类型关系如何在容器/泛型上传递」。假设 StringObject 的子类型,那 List<String>List<Object> 之间是什么关系?型变就是回答这个问题:

已知:String 是 Object 的子类型(String <: Object)

协变(Covariant):容器保持子类型关系
  Container<String> 是 Container<Object> 的子类型
  → String[] 是 Object[] 的子类型(数组是协变的)

逆变(Contravariant):容器反转子类型关系
  Container<Object> 是 Container<String> 的子类型(反过来)

不变(Invariant):容器之间没有子类型关系
  List<String> 和 List<Object> 互不为子类型(泛型是不变的)

理解这三个词:协变 = 顺着来(子类型容器是父类型容器的子类型)、逆变 = 反着来不变 = 没关系。Java 里数组是协变的String[]Object[] 的子类型)、泛型是不变的List<String>List<Object> 无关)。这是本题的核心对比。理解「型变描述子类型关系在容器上如何传递」,就有了讨论「数组和泛型为什么不同」的框架。

二、数组的协变:方便但危险

Java 从一开始就让数组协变——String[] 可以当 Object[] 用:

Object[] arr = new String[3];   // ✓ 合法,数组协变

这个设计的初衷是「灵活/方便」——比如写一个 void printAll(Object[] arr) 方法,希望它能接收任何类型的数组(String[]、Integer[] 都行),数组协变让这成为可能。在泛型出现之前(Java 5 之前),协变数组是「写通用方法」的重要手段。

但这个方便是有代价的——它不安全

Object[] arr = new String[3];   // arr 实际指向 String[],但类型是 Object[]
arr[0] = "ok";                  // 存 String,没问题
arr[1] = 123;                   // 编译器:arr 是 Object[],存 Integer 合法 → 编译通过
                                // 运行时:实际是 String[],存不了 Integer → ArrayStoreException!

问题的根源:编译器只看到 arr 的类型是 Object[],认为「往 Object[] 存任何 Object 都合法」,所以编译通过;但实际对象是 String[],只能存 String。这个矛盾在编译期发现不了(编译器不知道 arr 实际是什么数组),只能等运行时存元素时暴露。理解「数组协变方便但不安全、编译期发现不了类型错误」,就理解了它为什么会抛 ArrayStoreException——协变让「类型不匹配的存储」通过了编译。

三、ArrayStoreException:运行时的类型检查

ArrayStoreException 就是数组为了「弥补协变的不安全」而加的运行时类型检查

既然数组协变会让"往错误类型的数组存元素"编译通过,
那就必须在运行时补一道检查,否则真的存进去会破坏类型安全:

数组每次存元素(arr[i] = x)时,JVM 都会检查:
  x 的实际类型 是不是 数组实际元素类型 的子类型?
    是 → 允许存
    否 → 抛 ArrayStoreException

所以 Object[] arr = new String[3]; arr[1] = 123:
  存 123(Integer)时,JVM 检查:Integer 是 String 的子类型吗?否 → ArrayStoreException

关键认知:数组把「类型检查」放在了运行时(每次存元素都检查)——这是协变的必然代价。因为编译期无法保证类型安全(协变让错误代码通过编译),只能靠运行时检查兜底。这也带来轻微的性能开销(每次数组存元素都要做类型检查)。理解「ArrayStoreException 是数组在运行时补的类型检查、弥补协变的不安全」,就理解了它的本质——它是「协变换来的运行时代价」。而这正是泛型设计者想避免的。

四、泛型的不变:把检查提前到编译期

泛型(Java 5)的设计者吸取了数组的教训——让泛型「不变」,把类型检查放在编译期

List<Object> list = new ArrayList<String>();  // ✗ 编译错误!
// 泛型不变:List<String> 不是 List<Object> 的子类型
// → 这行代码在编译期就被拒绝,根本不给你机会写出"运行时才炸"的代码

对比数组:

数组(协变):
  Object[] arr = new String[3];  // 编译通过
  arr[0] = 123;                  // 编译通过,运行时 ArrayStoreException(晚发现)

泛型(不变):
  List<Object> list = new ArrayList<String>();  // 编译错误(早发现)
  → 从源头杜绝了"往 String 容器存 Object"的可能

泛型不变的价值是「类型安全 + 早发现错误」——它在编译期就拦住了「类型不匹配的容器赋值」,让你根本写不出「编译能过、运行才炸」的代码。这符合「错误越早发现越好」的原则(编译错误 >> 运行时错误)。代价是灵活性差——你不能像数组那样把 List<String>List<Object> 用(有时确实需要这种灵活)。所以泛型又引入了通配符来「按需、安全地」实现协变/逆变。理解「泛型不变是为了编译期类型安全、吸取了数组协变不安全的教训」,就理解了这个设计选择——用一点灵活性换来了类型安全。

五、通配符:安全地实现协变逆变

泛型不变太死板,所以 Java 用通配符(? 来「按需实现协变/逆变」,且保证安全:

// ? extends T:上界通配 → 实现"协变"(安全版)
List<? extends Number> nums = new ArrayList<Integer>();  // ✓ 能接收 Number 的子类型 List
Number n = nums.get(0);   // ✓ 能读(取出来至少是 Number)
// nums.add(1);           // ✗ 不能写!(编译器不知道具体是 Integer 还是 Double 的 List)
// → 协变,但只能读不能写,从而保证安全(不会像数组那样存错类型)

// ? super T:下界通配 → 实现"逆变"(安全版)
List<? super Integer> sink = new ArrayList<Number>();    // ✓ 能接收 Integer 的父类型 List
sink.add(1);              // ✓ 能写(存 Integer 一定安全)
// Integer i = sink.get(0); // ✗ 读出来只能当 Object

通配符的巧妙在于「用『能读不能写』或『能写不能读』来保证安全」:

? extends(协变):能读不能写
  因为编译器只知道"是 Number 的某个子类型的 List",不知道具体是哪个
  → 读出来当 Number 安全,但不能写(写什么都可能类型不符)

? super(逆变):能写不能安全读
  因为编译器知道"是 Integer 的某个父类型的 List"
  → 写 Integer 一定安全(Integer 是任何父类型的子类),但读出来只能当 Object

PECS:Producer Extends(生产者/读用 extends)、Consumer Super(消费者/写用 super)

对比数组:数组用「运行时检查」保证安全(晚、有开销),泛型通配符用「编译期限制读写」保证安全(早、无运行时开销)。理解「通配符用读写限制安全地实现协变逆变、PECS 原则」,就理解了泛型如何在「不变」的基础上补回灵活性——而且比数组的协变更安全(编译期就限制了不安全操作)。

六、总结对比:为什么泛型比数组安全

把数组和泛型的型变对比总结,能看清「泛型更安全」的本质:

维度数组泛型
型变协变(内置,不安全)不变(用通配符按需协变/逆变)
类型检查时机运行时(存元素时)编译期
类型错误何时发现运行时(ArrayStoreException,晚)编译期(早)
安全保证方式运行时检查 + 抛异常编译期限制 + 通配符读写限制
性能每次存元素有运行时检查开销编译期搞定,运行时无型变检查

核心结论:数组的协变是「编译期宽松、运行时检查」(方便但错误晚发现),泛型的不变+通配符是「编译期严格」(安全、错误早发现)。这体现了 Java 类型系统的演进——从数组的「方便优先」到泛型的「安全优先」。实践启示:优先用泛型集合(List)而非数组——泛型集合类型更安全(编译期拦错)、功能更丰富,除非有明确的性能/底层需求才用数组。另外,「数组和泛型不要混用」(List<String>[] 这种是被禁止或有警告的,因为两者的型变规则冲突)。理解「泛型比数组安全在『编译期 vs 运行时』发现错误」,就掌握了这道题的精髓——它讲的是 Java 类型系统「把错误提前到编译期」的设计哲学。

记忆钩子:「数组协变(String[] 是 Object[] 子类型)但不安全——Object[] arr=new String[3]; arr[0]=123 编译过、运行抛 ArrayStoreException(类型检查在运行时);泛型不变(List<String> 不是 List<Object>)但安全——编译期就拦住(吸取数组教训、把错误提前到编译期);泛型用通配符补灵活:? extends 协变能读不能写、? super 逆变能写不能读(PECS);优先用泛型集合而非数组」

七、常见误区与追问

  • 误区:泛型和数组的型变规则一样。 完全不同——数组是协变的(String[] 是 Object[] 子类型)、泛型是不变的(List<String> 不是 List<Object>);数组运行时检查、泛型编译期检查。
  • 误区:Object[] arr = new String[3]; arr[0]=123 会编译错误。 编译通过(arr 类型是 Object[],存 Integer 语法合法);运行时存入时才抛 ArrayStoreException(实际是 String[])。
  • 误区:泛型不变是设计缺陷、不够灵活。 是有意的安全设计——吸取数组协变不安全的教训,把类型错误提前到编译期;灵活性用通配符(? extends/? super)安全地补回来。
  • 误区:List<? extends Number> 能往里 add。 不能——上界通配是协变、只能读不能写(编译器不知道具体子类型,写什么都可能不符);? super 才能写。
  • 追问:为什么数组会有 ArrayStoreException? 数组协变让「往错误类型数组存元素」通过编译,为保证类型安全,JVM 在每次存元素时运行时检查「元素类型是否匹配数组实际类型」,不匹配就抛 ArrayStoreException。
  • 追问:为什么泛型设计成不变而数组是协变? 数组协变方便但不安全(错误延迟到运行时);泛型吸取教训,用不变把类型检查放编译期(早发现错误、更安全),再用通配符按需安全地实现协变/逆变。
  • 追问:? extends? super 分别实现什么型变,怎么记? ? extends 实现协变(能读不能写,生产者用)、? super 实现逆变(能写不能安全读,消费者用);PECS——Producer Extends、Consumer Super。

八、加强记忆

型变」描述子类型关系如何在容器上传递:协变(子类型容器是父类型容器的子类型)、逆变(反过来)、不变(无关)。Java 里 数组是协变的String[]Object[] 的子类型)但不安全——Object[] arr = new String[3]; arr[0] = 123; 编译通过(编译器只看 arr 是 Object[]),运行时抛 ArrayStoreException(实际是 String[]、存不了 Integer),因为数组把类型检查放在运行时(每次存元素检查)。泛型是不变的List<String> 不是 List<Object>但安全——它吸取了数组协变不安全的教训,把类型检查放在编译期List<Object> list = new ArrayList<String>() 直接编译错误),从源头杜绝「编译过、运行炸」。代价是不灵活,所以用通配符安全地补回型变:? extends 实现协变(能读不能写,生产者)、? super 实现逆变(能写不能安全读,消费者),靠「读写限制」在编译期保证安全(PECS:Producer Extends、Consumer Super)。核心哲学是「把错误提前到编译期」——所以泛型比数组安全,实践上优先用泛型集合而非数组。一句话「数组协变不安全(运行时 ArrayStoreException)、泛型不变安全(编译期拦),泛型用通配符 extends 协变/super 逆变补灵活,错误提前到编译期」。