news 2026/10/12 5:20:44

Java基础进阶:面向对象、集合框架、异常处理与泛型全梳理

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Java基础进阶:面向对象、集合框架、异常处理与泛型全梳理

这是Java总结进阶之路系列的第二篇。写这篇的起因很简单:很多朋友学完基础语法之后会卡在一个不上不下的位置——变量、数组、循环、方法都会写,但一旦看到的代码开始出现类继承、集合框架、异常捕获这些内容,整个人就开始发懵。基础一解决的通常是“怎么写命令”的问题,比如循环怎么写、方法怎么调用;基础二要解决的则是“怎么组织代码”的问题,也就是当你面对的代码量从几十行涨到几百行、上千行时,Java到底提供了哪些工具和规则,让你能把代码整理得不乱、好改、好扩展。

这篇总结不会去逐条复述教科书上的定义,而是把我在实际开发和带新人过程中反复看到的“基础模糊点”挑出来,用一种梳理加纠偏的思路重新串一遍。那些你学过但没真正想明白的地方,那些面试题背后真正想考察的东西,都在这篇文章里。适合正在学第二遍Java基础的人,也适合已经工作但想回头把地基补牢的开发者。

1. 从基础一连到基础二:这次复习要覆盖哪些知识块

1.1 基础一毕业后的能力画像

学完基础一,正常来说你应该已经掌握了这几个东西:基本数据类型和运算符、控制流程(if、switch、for、while)、数组、方法定义和调用、还有简单的类与对象的创建。这一阶段写出来的代码有个典型特征:结构是“平铺”的,所有逻辑都挤在一个类里,或者至多拆了两三个方法,数据靠变量传来传去。

但真实项目里的代码,哪怕是几千行的小工具,也会大量用到面向对象的继承与多态、集合框架来管理一组对象、异常机制来兜住非预期情况。这些不是某个框架的专属设计,而是Java语言本身提供的基础能力。换句话说,基础二学的依然是地基,只是这层地基比语法更接近“工程”这个概念。

我对基础二的能力画像有这样一个判断标准:拿到一个业务描述,能不能初步把它拆成几个类,类之间是继承还是组合关系;需要存一组对象时,知道用ArrayList还是HashMap,并且清楚它们各自的坑;代码里出现空指针或者文件读取失败时,知道问题应该在哪一层被处理。这些听起来不复杂,但恰恰是很多初级开发者在实际工作中摸爬滚打才慢慢悟出来的东西。

1.2 基础二要解决的三个层次:组织代码、选择结构、处理边界

我把基础二的知识块分成三个层次来看。

第一层是组织代码,也就是面向对象三件套:封装、继承、多态。它回答的问题是:我的程序里有这些数据和操作,我该把它们放进哪些类,类与类之间怎么发生关系,怎么让变化的部分尽量只影响一小块地方。

第二层是选择结构,对应的是集合框架和泛型。当程序中出现了不止一个对象,而是要处理一批对象时,用数组还是用List?用Set还是Map?如何让一个工具类既支持处理字符串又支持处理数字,而不需要写好几份重复代码?这就是集合与泛型的工作。

第三层是处理边界,对应异常处理、包装类、字符串这些基础类。程序运行起来后,总会有磁盘读不到、网络连不上、用户输入了非法值这类情况发生。怎么让程序在出错时不至于整个崩溃,怎么把异常信息留给排查程序的人,这就是异常处理机制要做的事情。而包装类和字符串的很多规范,也是你实际编码中天天要用的。

这三个层次不是孤立的。一个业务系统里,你总是先用类把业务对象建模,用集合来管理它,用泛型写出更通用的处理方法,再用异常兜住运行时的各种意外。基础二的整个学习过程,其实就是在练这三层之间配合的熟练度。

1.3 学习方法上的一个建议:代码要“再写一遍”

学基础二有一个特别容易踩的误区:看懂了。看懂了不等于会写,更不等于写出来的代码是对的。面向对象这种东西,看别人画的类图觉得特别合理,自己一拍脑袋动手做设计时,往往会纠结这个方法是放A类还是B类,这个父类到底该不该抽出来。集合的差别同样如此,刷了一遍API文档觉得都好懂,等到实际编写增删改查的代码时,才发现为什么这边越删越慢、那边存进去取不出来。

所以我的建议是:这一阶段学任何一个小节,都要干一件事——把示例代码关掉,自己重新写一遍,并且故意改一些条件。比如你学了HashMap的基本用法,那就自己写一个统计字符串里每个字符出现次数的程序;你学了异常处理,就故意让代码去读一个不存在的文件,然后把各种写法都试一遍。只有当你亲自动手踩过这些细节,基础二才真正内化成了你的能力。

2. 面向对象三件套:封装、继承、多态的尺度和取舍

2.1 封装:私有不是目的,防止滥用才是

很多初学者对封装的第一个反应是:把字段设成private,然后加一堆getter和setter,这不就是脱裤子放屁吗?明明直接就可以访问的东西,绕一圈之后还是一样能访问。这种吐槽其实点到了封装的关键——如果加getter和setter只是为了应付考试,那确实是形式主义。封装的真实目的,是把数据的使用方式约束在类的设计者允许的范围内,防止外部代码破坏对象内部的一致性。

举个很常见的例子。一个订单类里有一个状态字段,它只能从“已创建”变到“已支付”再变到“已发货”,不能从“已发货”回到“已创建”。如果你直接暴露一个public的状态字段,外部代码想怎么改就怎么改,订单状态乱成什么样都无法控制。但如果你把它设成private,外面只能通过你提供的pay()、ship()这些方法来修改状态,在方法里加上状态流转的检查,非法状态一出现就直接抛异常。到这里你就能理解,private的真正价值不是锁住数据,而是让“修改数据这件事”经过你的代码逻辑,而不是被外部随意跳过。

实际编码里,我一般建议:字段默认private,确实需要被外部读取时才提供getter,确实需要被外部修改时才提供setter,而不是无脑全套生成。一个只读属性就不该有setter,一个内部计算出来的衍生值连保存都不需要。

2.2 继承:尽量符合is-a关系,别为复用硬凑

继承在Java基础里是必考点,但在实际开发里,它反而是我提醒新人用得最谨慎的东西。继承只有在类之间满足明显的“is-a”关系时才值得用,比如猫是动物、正方形是矩形、圆是形状。除开这一层含义,其他情况往往有更好的替代方案。

我见过很多初学者为了复用几个相同的方法,就让两个毫不相干的类去继承同一个父类。这会导致一个很尴尬的局面:子类平白无故拥有了一些它根本不需要的字段和方法,而且父类改了之后,子类可能莫名其妙地受影响。面向对象里有个广泛使用的设计原则说得好:优先使用组合而不是继承。组合的意思是,一个类需要某种能力时,持有一个具有该能力的对象,而不是去继承那个对象所属的类。打个比方,汽车需要引擎的能力,但汽车不是引擎,所以汽车类里应该有一个引擎类型的字段,而不是让汽车去继承引擎类。

如果你已经确定要写继承,有几个细节是必须注意的。首先,父类的构造方法不会被子类继承,子类构造时如果没有显式调用super(),那么Java会默认调用父类的无参构造方法;如果父类没有无参构造方法,子类就必须显式调用super(参数),否则编译不过。其次,重写父类方法时要遵循一个基本要求:子类方法不能比父类方法有更严格的访问权限。父类方法是public,子类重写时改成private是会直接编译报错的,因为那样会破坏多态调用。再有,父类中被final修饰的方法不能重写,被final修饰的类不能继承,这与我们后面要讲的String类的情况是一致的。

2.3 多态:接口编程与抽象类的选择题

多态是面向对象里最容易被小看的一块。字面上看,多态不过是“父类引用指向子类对象”、方法重写、动态绑定这些术语,但这些术语落到代码里,带来的是一种完全不同的代码组织方式。

我习惯用接口来讲解多态的价值。现在假设有一个支付场景,将来可能会有微信支付、支付宝支付、银行卡支付等多种支付方式。如果你不使用接口,很可能会写出这样的代码:

public void pay(String type, double amount) { if ("wechat".equals(type)) { // 微信支付逻辑 } else if ("alipay".equals(type)) { // 支付宝支付逻辑 } else if ("bankcard".equals(type)) { // 银行卡支付逻辑 } }

这段代码最大的问题是:每一次接入一种新的支付方式,都要改动这个方法的内部逻辑,if分支越来越多,代码块越来越臃肿,而且很容易改到别人正在用的那一段。如果换成接口加多态的思路,写起来是这样:

public interface Payment { void pay(double amount); } public class WechatPayment implements Payment { @Override public void pay(double amount) { // 微信支付逻辑 } } public class AlipayPayment implements Payment { @Override public void pay(double amount) { // 支付宝支付逻辑 } }

调用方不再需要关心具体是哪种支付方式,只需要面向Payment接口编程就够了。以后新增支付方式,只需要新写一个实现Payment接口的类,原有调用代码不用改。这就是面向接口编程的典型价值:把“变”的部分和“不变”的部分分开,新增功能时尽量不动旧代码。

那么在接口和抽象类之间怎么选?我的经验是:优先考虑接口。接口表达的是某个类型能做什么,一个类可以同时实现多个接口,支持多个能力;抽象类的本质还是一个类,它的用途更多是抽取出一批子类的公共状态和公共逻辑,并且还能提供一些已经实现好的方法供子类直接使用。比如从“动物”抽象出一个抽象类,里面有所有动物共同的字段和吃饭的通用逻辑,而具体的“猫”“狗”再继承它并重写各自的叫声方法。简单说,能定义“能力”的用接口,能定义“共同族谱”的用抽象类,不需要强制二选一,把接口和抽象类组合起来使用也是很常见的做法。

3. 集合框架选型:ArrayList、LinkedList与HashMap的实战视角

3.1 ArrayList与LinkedList:别被名字误导

集合框架的基础知识里,ArrayList和LinkedList的对比几乎必问。初学者听到LinkedList是链表,就直觉地认为它增删元素快、性能好,于是写代码时常常优先选择LinkedList。但这里有两个误区。

第一,LinkedList所谓的“增删快”只在特定条件下成立。ArrayList尾插很快,因为数组尾部有预留空间;在中间位置插入元素时,ArrayList需要把插入点后面的元素全部复制移动一位,LinkedList则只需要调整相邻节点的引用,这看起来LinkedList占优。但ArrayList在中间插入前需要找到插入位置,LinkedList也需要通过遍历找到对应节点。对于随机访问来说,ArrayList按索引取值是O(1),LinkedList则要O(n)地从头遍历。实际开发里,随机访问的频率往往比你想象的高得多,ArrayList整体上在大多数普通场景下反而更省心。

第二,LinkedList每个节点需要额外存储前后指针,内存开销比ArrayList更大。而且现代CPU对数组这类连续内存的遍历非常友好,LinkedList节点分散在堆里的各个角落,对缓存命中并不利。所以我个人的经验是:默认使用ArrayList,除非你能明确说出自己需要LinkedList的哪些特殊能力,比如经常需要在头部插入删除元素,或者实现一个队列时需要频繁地从两端操作,那些情况下LinkedList才有存在的必要。

还有一个很多初学者没注意的细节:ArrayList扩容。ArrayList底层的默认初始容量是10,当元素数量达到容量上限时,它会自动扩容,新容量大约是旧容量的1.5倍(旧容量 + 旧容量右移一位)。如果能在创建时就预知大概要装多少条数据,最好直接new ArrayList<>(预估大小)来指定初始容量,这样可以省掉不少扩容时复制底层数组的开销。

3.2 HashMap:哈希、扩容、equals与hashCode

HashMap是集合框架里真正值得好好琢磨的一个类。它内部根据键的hashCode值来决定存放位置,查找时先用hash定位到某个桶,再比较key是否相等。理解这个机制,你对HashMap的各种行为就会有清晰的认识。

首先,作为key的对象必须同时正确覆写hashCode和equals。如果一个类只用equals而不用hashCode,或者两个方法的行为不一致,那么HashMap就会出现一个现象:equals比较相等的两个对象,hashCode却不一致,导致它们被放在不同桶里,你按照其中一个key去get的时候会得到一个空值。反过来,如果hashCode相同但equals不等,那么它们会被放进同一个桶里,这时候桶内的查找效率会下降,最极端的情况下所有元素堆在同一个桶里,HashMap退化成一条链表,查询复杂度从O(1)掉到O(n)。

其次,对于String、Integer这些Java自带的类,不用你操心,它们的hashCode和equals都已经正确覆写了,所以日常把String当作key是完全没有问题的。但如果你自己写了一个类要当作key,就要认真考虑两个方法,并且有一个重要的纪律:用作key的对象应该尽量是不可变的。如果某个对象放进map之后,它的hashCode依赖的字段被外部改了,那么它的哈希值就变了,以后你再想用同样的引用去get时,很可能已经到了另一个桶里,造成数据明明存在却取不出来的诡异问题。

还有一个值得注意的参数:负载因子,默认是0.75。当HashMap里的元素数量达到容量乘以负载因子时,就会触发扩容,把元素重新分布到更大的数组里。0.75这个值是时间和空间上的折中:负载因子的值太小,比如0.5,空间浪费比较严重;值太大,比如1,桶里发生冲突的概率就会上升,影响查找性能。大多数场景直接用默认值就好,不建议乱调。

3.3 遍历删除与Key设计的常见坑

集合这块有两个坑,几乎每个新手都会踩,踩的次数多了就会长记性。

第一个坑是用for-each循环遍历List的时候直接执行remove操作。比如下面的代码会抛出ConcurrentModificationException:

List<String> list = new ArrayList<>(); list.add("a"); list.add("b"); list.add("c"); for (String s : list) { if ("b".equals(s)) { list.remove(s); } }

原因在于for-each底层是用迭代器来遍历的,迭代器在创建时会记录一个modCount值,也就是集合被修改的次数。每次next()时迭代器都会检查当前的modCount是否和自己记录的一致,不一致就抛异常。而你通过list.remove()修改了集合,modCount变了,迭代器自然就发现不对劲了。正确的写法有几种:用迭代器自己的remove方法:

Iterator<String> it = list.iterator(); while (it.hasNext()) { String s = it.next(); if ("b".equals(s)) { it.remove(); } }

JDK 8之后还有更简洁的removeIf写法:list.removeIf(s -> "b".equals(s)); 一行搞定,内部也是迭代器安全的删除。

第二个坑是用可变对象作为HashMap的key。比如把一个自定义的类对象放进map,然后修改了这个对象里的某个字段,这个字段恰好参与了hashCode的计算,那么接下来再根据同一个对象的引用去map里get时,结果大概率就是null。排查这类问题特别消耗时间,因为代码表面上看起来完全正常,同一个对象,放得进去,取不出来。我在实际工作中遇到过好几次,最后查来查去都是改变了key对象的状态。所以设计key时,要么用String、Integer这类不可变类型,要么确保自己的类在作为key之后不会被修改。

4. 异常处理习惯:受检异常、非受检异常与资源关闭的一体化思路

4.1 受检异常与非受检异常的分界线

Java的异常体系分为两大类:受检异常(checked exception)和运行时异常(unchecked exception,也叫非受检异常)。这两者最直观的区别是:受检异常必须在编译期被处理,也就是说要么方法上声明throws,要么在方法内部try-catch,否则代码编译不过;运行时异常则不需要强制处理,编译能通过,运行到那一步才会抛出来。

受检异常的典型代表是IOException、SQLException这类,它们描述的是“外部环境出了状况”的场景,比如要读的文件不存在、网络连接中断、数据库连不上。这些情况在正常代码流程里是可能发生的,所以编译器强制你要么把问题向上抛出去,让上层统一处理;要么就地处理掉。

运行时异常的典型代表是NullPointerException、ArrayIndexOutOfBoundsException、ClassCastException,它们通常说明代码本身有逻辑问题,空指针往往是某个外部传入的值为null你没有校验,数组越界往往是遍历边界搞错了。这类问题正确的做法不是在每个地方都try-catch,而是要保证代码逻辑的正确性,比如在访问之前校验null、在遍历时用合法的下标。

这里有一个非常重要的实践原则:受检异常适合在方法签名里声明throws,让调用方决定怎么处理;运行时异常则适合在程序的顶层统一捕获,记录日志后给用户一个友好提示。我见过不少代码把异常处理当成了任务本身,到处try-catch,catch里又只是打印一行栈信息,然后继续执行。这样的代码不仅没有真正处理问题,还会掩盖一些严重的错误信号,后期排查时非常痛苦。

4.2 try-with-resources:关闭资源的新习惯

异常处理和资源关闭经常是绑在一起出现的。因为之前学习IO时,打开了一个文件流,无论读取成功还是读取失败,最终都要把它关闭,否则可能造成文件句柄泄漏。传统写法是finally块:

FileInputStream fis = null; try { fis = new FileInputStream("config.txt"); // 读取操作 } catch (IOException e) { e.printStackTrace(); } finally { if (fis != null) { try { fis.close(); } catch (IOException e) { e.printStackTrace(); } } }

这段代码的问题是:关闭流的代码比真正读取数据的代码还要多一截,而且finally里close()方法本身也会抛出IOException,你还得再包一层try-catch。这种写法虽然没错,但明显不优雅。

Java 7之后就简单多了,只要资源类实现了AutoCloseable接口,就可以直接写在try右边的括号里,try代码块执行完,无论是正常结束还是抛异常,资源都会自动关闭:

try (FileInputStream fis = new FileInputStream("config.txt")) { // 读取操作 } catch (IOException e) { e.printStackTrace(); }

这里要注意,写在try括号里的变量是隐式final的,也就是说你不能在后面的代码里重新给fis赋值。还有一点,如果需要同时打开多个资源,可以依次写在括号里,用分号分隔,比如同时读一个文件写另一个文件。用这个语法之后,关闭资源这件事就从“手动管理”变成了“机制管理”,代码干净了一大截,也少了很多漏关资源的隐患。

4.3 异常被吞掉也只是“暂时安全”

我见过一个特别典型的反例。有些初学者在catch块里只写一句:

} catch (Exception e) { // 忽略异常 }

这样做最直接的后果是:程序假装一切正常继续运行,可后续的代码可能建立在失败的结果之上,接着跑出更莫名其妙的错误。等到项目上线后界面显示的数据与实际文件内容完全对不上,你再去一层层反推,才发现原始异常早就被吞得一干二净,连个日志都没留下。

正确的做法是至少把异常记录下来。如果用的还是传统的printStackTrace,也可以,但最好是调用日志框架,比如log.warn("读取配置文件失败,filePath={}", path, e);连多线程、生产环境这些因素也不用考虑,先把异常的栈信息留在日志里,后面出了问题才有线索。还有一个原则:如果你确实判断某个异常不影响主流程,想在catch里吞掉它,那么务必在注释里写清楚“为什么可以忽略”,否则三个月之后的你或者接手代码的同事,根本不知道这里曾经发生过什么。

如果自己定义了业务异常,一般我会让自定义异常继承RuntimeException。理由很简单:如果你让异常继承受检异常,那么每个调用你方法的地方都得写throws或try-catch,这会非常啰嗦;而继承RuntimeException之后,调用方可以选择在合适的位置统一捕获,也可以让它通过方法签名传递到顶层。业务代码里的用户输入错误、业务状态不对,本质上都属于预期内的“特殊分支”,用运行时异常表达最灵活。

5. 字符串与包装类:基础中最容易翻车的几个细节

5.1 String的不可变性与常量池

String是Java里使用频率最高的类,没有之一,但它恰恰也是基础知识里最容易让人翻车的点。首先明确一个基本事实:String对象是不可变的。你写的任何看起来在修改字符串的操作,比如concat、substring、replace,实际都不会修改原有的String对象,而是创建了一个新的String对象。

String的这个设计没有想象中那么简单,它带来了线程安全、字符串常量池缓存、hashCode稳定等一堆好处。因为字符串内容不会变,多个变量才能放心地引用同一个字符串对象,从而有了字符串常量池这样的东西。你在代码里写成字面量的字符串,比如"hello",会被放到常量池里;当另一个地方也写着相同的字面量"hello"时,JVM会直接复用同一个对象,不会在堆里重新创建。

于是就有了经典问题:==和equals的区别。==比较的是两个引用是否指向同一个对象,equals比较的是两个字符串的内容是否相同。所以判断字符串相等时,千万不要用==,除非你非常确定两个引用都指向同一个常量池中的字符串。下面这行代码就是常见翻车现场:

String a = "hello"; String b = new String("hello"); System.out.println(a == b); // false System.out.println(a.equals(b)); // true

a和b的内容完全相同,但它们一个是常量池里的对象,一个是new出来的新对象,引用当然不一样。也有一种办法可以把任意字符串对象“拉”回常量池:调用intern()方法。如果你在常量池中能找到内容相同的字符串,它就返回池中的那个引用;找不到就把当前字符串加入常量池并返回引用。不过在日常代码中,我建议你别过度依赖intern,直接用equals比较内容才是稳定可靠的方法。

5.2 StringBuilder与StringBuffer:性能与线程安全

字符串不可变的代价就是拼接操作的性能问题。如果直接在循环里用加号拼接字符串,比如循环一万次执行str += item,每执行一次就会创建一个新的String对象,旧的字符串变成垃圾等待回收。这种写法在小数据量时看不出来,数据量一大,性能和内存占用都会明显变差。

推荐的方案是用StringBuilder来拼接:

StringBuilder sb = new StringBuilder(); for (int i = 0; i < 10000; i++) { sb.append(i); } String result = sb.toString();

StringBuilder内部维护了一个可变的字符数组,append操作是直接往数组里写内容,只有在容量不够的时候才扩容。由于它大多数情况下只是在单线程环境中局部使用,因此不需要考虑并发安全,性能是最优的。StringBuffer则是在StringBuilder的基础上为方法加了synchronized关键字,换来了线程安全,但代价是性能比StringBuilder差一些。在绝大多数业务代码里,你根本不存在多个线程同时修改同一个字符串缓冲区的情况,所以优先使用StringBuilder即可。如果一个字符串确实会被多个线程共同编辑,那么更靠谱的思路是重新审视设计,而不是依赖StringBuffer的同步。

5.3 包装类缓存与equals陷阱

基本类型int、long、boolean这些都不是对象,但Java集合框架里只能放对象,于是就有了对应的包装类Integer、Long、Boolean。自动装箱和自动拆箱让你能写出看似顺畅的代码:

Integer x = 128; Integer y = 128; System.out.println(x == y); // false

但这个坑就在这里:-128到127之间的整数,Integer会直接复用缓存对象,所以在这个范围内用==比较结果是true;超出这个范围,就是两个不同的对象,==比较结果是false。这里隐藏了一个巨大的风险:你的代码在测试时,可能恰好都用了128以内的小数字,一切正常;上线之后数据稍微大一点,比较结果就莫名其妙变了。所以比较包装类对象的内容,一律使用equals方法,这是我从第一天就养成的习惯。

同样的道理还适用于Long和Short,它们也有缓存,范围同样是-128到127;Character缓存的是0到127。Boolean比较就简单一些,它只有true和false两个实例,但保险起见还是推荐用equals。另外要注意,自动拆箱发生在运算场景中,比如比较Integer和一个int基本类型时,会自动拆箱;如果那个Integer是null,拆箱的过程就会抛NullPointerException,这也是空指针来源之一。平时写完代码,应该多多留意那些包装类变量有没有可能为null,不能想当然地认为它像基本类型一样永远不会是空值。

6. 泛型入门:类型擦除、通配符和一套实用写法

6.1 泛型到底解决了什么问题

泛型几乎是Java基础里最后一个门槛。它解决的核心问题就一句话:让代码在编译期就能确认类型,避免运行时才暴露类型不匹配的问题。

如果没有泛型,集合里可以放任何类型的对象,取出时必须自己做强转。下面这种代码是Java 5之前的老写法:

List list = new ArrayList(); list.add("hello"); list.add(123); String s = (String) list.get(0);

问题很明显:list里的元素类型无法约束,你想存String结果存进去一个Integer,编译期根本没有任何提示,只有运行时强转那一步才抛ClassCastException。泛型引入后,你在创建集合时就声明了元素类型,编译器会帮你在放元素的时候就做检查,取元素时也不需要强转了:

List<String> list = new ArrayList<>(); list.add("hello"); // list.add(123); // 编译直接报错 String s = list.get(0);

这等于把一部分错误检查提前到了编码阶段。编译能过,运行时的很多类型问题就已经被排除掉了。泛型不只是集合能用,你还可以写泛型类、泛型接口、泛型方法。开发中常见的工具类方法就非常实用:

public static <T> T getOrDefault(T value, T defaultValue) { return value != null ? value : defaultValue; }

这个方法的返回值类型由参数类型决定,传入String返回String,传入Integer返回Integer,一套代码适配多种类型,同时又保留了类型信息,不用强转。用泛型方法去替代一些“重复的但只是类型不同”的代码,是让代码变简洁又保持安全的好办法。

6.2 类型擦除与限制:为什么不能new T

泛型有一个让初学者无比困惑的机制:类型擦除。意思是,泛型类型信息在编译期是存在的,编译器利用它做类型检查,但一旦编译完成,泛型信息就会被擦除掉,运行时JVM根本不知道你当初声明的是什么类型。

以泛型类为例:

class Box<T> { private T item; }

编译之后,这个类在JVM眼里其实差不多就是:

class Box { private Object item; }

T被擦除成了Object,或者擦除到上边界。由于这个机制的存在,泛型有一些写不了的代码,最典型的就是new T()和new T[]。因为在运行阶段根本没有T的类信息,JVM无法创建一个T类型的对象。如果你真的需要在泛型工厂方法中创建实例,常见的做法是额外传入一个Class 类型的参数,再用反射来创建对象:

public static <T> T createInstance(Class<T> clazz) throws Exception { return clazz.getDeclaredConstructor().newInstance(); }

另外要注意的是,Java的泛型不支持基本类型,也就是说你不能写List ,只能写List 。这背后的原因也和类型擦除有关,因为擦除后的Object不能容纳基本类型的值,只好用包装类顶上。

6.3 通配符与PECS的实用解读

通配符是泛型里最容易看懵的一块,但它其实很符合直觉。有时候你并不需要关心泛型类的具体类型是什么,只关心它能被安全地读取或写入。

?代表未知类型,然后有两种常用的上界下界写法:? extends T表示某个继承自T的类型,? super T表示某个T的父类型。用一个例子来说明它们的应用场景。比如有一个方法,它要把一个list里的元素全部打印出来:

public static void printAll(List<? extends Animal> list) { for (Animal animal : list) { // 读取元素,可以按Animal来处理 } }

因为list中的元素至少是Animal的子类,所以你从里面取出来的每一个元素,都一定可以当作Animal来使用。这就是所谓“生产者”场景:列表负责提供元素,所以用extends上界。

反过来,如果要往一个list里写入Animal对象,这个list的类型应当用? super Animal:

public static void addDog(List<? super Animal> list) { list.add(new Dog()); }

因为list存储的类型是Animal的某个父类型,那么Dog作为Animal的子类,自然也一定能放进去。这样写的好处是,你既可以使用List 来调用addDog,也可以使用List

这套规则在Java官方文档和社区里有一个广为人知的记忆口诀:PECS,即Producer Extends,Consumer Super。生产者提供元素,用extends;消费者接收元素,用super。如果只是想用ArrayList作为容器,不涉及这类通配符设计,那倒不用每处都套这个口诀;但一旦你开始写公共工具类、库代码,或者面对一类多态的批量处理时,通配符的思路就会派上用场。

我自己带新人的时候,总发现大家一看到泛型的尖括号就开始发怵。其实泛型的核心就两个问题:编译器怎么利用它帮你检查类型;运行时它被擦除成了什么。把编译期和运行期这两件事分开理解,再靠平时写代码时多看看API源码里泛型方法的使用方式,这个知识点很快就会熟起来。

基础二的内容到这里就梳理得差不多了。最后分享一个我在实践中反复验证的做法:学完这一阶段之后,不要急着冲框架,找一个稍微完整一点的小型练手题目,比如写一个命令行记账本,要求用到自定义类、集合存数据、异常处理兜住文件读写错误,再顺手把字符串和泛型都揉进去。当你在自己的代码里真正遇到过空指针、遇到过集合元素丢失、遇到过异常被吞掉导致很难排查,你就会明白这些基础点到底为什么被设计成现在这个样子。这一遍练完,再去看任何Java框架的源码,你都会觉得顺畅很多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/12 5:20:28

STC单片机USB驱动与ISP烧写全攻略:从装驱动到成功烧录

简介&#xff1a;面向STC系列单片机初学者与嵌入式开发入门者&#xff0c;该工具包整合了从环境搭建到程序烧录的完整开发链路&#xff0c;针对USB驱动识别失败、Keil工程配置繁琐、ISP下载不顺畅等常见问题&#xff0c;提供可直接使用的配套素材。压缩包共813个文件&#xff0…

作者头像 李华
网站建设 2026/10/12 5:16:03

SVS转TIFF实战:绕开内存黑洞与色彩偏移的生产级方案

简介&#xff1a;本资源是一款专为数字病理图像处理工程师与医学AI研究者设计的SVS格式转TIFF格式工具&#xff0c;解决江丰生物KFB切片经官方软件转换后TIFF仅显示左上角区域的工程痛点。针对ASAP标注平台仅支持TIFF/SVS格式、而KFB原生不可标注的现实约束&#xff0c;该工具提…

作者头像 李华
网站建设 2026/10/12 5:15:56

PLC中断机制详解:突破扫描周期限制的实时响应方案

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/12 5:15:09

XGBoost回归与二分类实战:从数据准备到超参数调优的完整实例

简介&#xff1a;这份资源面向机器学习入门与进阶学习者&#xff0c;围绕XGBoost这一高效梯度提升框架&#xff0c;提供从理论到落地的完整实践素材&#xff0c;帮助读者理解并行化、正则化、早停与近似梯度计算等核心优化机制&#xff0c;并掌握分类任务的建模流程。压缩包共4…

作者头像 李华
网站建设 2026/10/12 5:12:26

不买开发板也能学STM32?纯软件仿真入门全攻略

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华