
这是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与hashCodeHashMap是集合框架里真正值得好好琢磨的一个类。它内部根据键的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操作。比如下面的代码会抛出ConcurrentModificationExceptionListString 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方法IteratorString 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-catchcatch里又只是打印一行栈信息然后继续执行。这样的代码不仅没有真正处理问题还会掩盖一些严重的错误信号后期排查时非常痛苦。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)); // truea和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到127Character缓存的是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。泛型引入后你在创建集合时就声明了元素类型编译器会帮你在放元素的时候就做检查取元素时也不需要强转了ListString 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 BoxT { private T item; }编译之后这个类在JVM眼里其实差不多就是class Box { private Object item; }T被擦除成了Object或者擦除到上边界。由于这个机制的存在泛型有一些写不了的代码最典型的就是new T()和new T[]。因为在运行阶段根本没有T的类信息JVM无法创建一个T类型的对象。如果你真的需要在泛型工厂方法中创建实例常见的做法是额外传入一个Class 类型的参数再用反射来创建对象public static T T createInstance(ClassT 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 Animalpublic static void addDog(List? super Animal list) { list.add(new Dog()); }因为list存储的类型是Animal的某个父类型那么Dog作为Animal的子类自然也一定能放进去。这样写的好处是你既可以使用List 来调用addDog也可以使用List这套规则在Java官方文档和社区里有一个广为人知的记忆口诀PECS即Producer ExtendsConsumer Super。生产者提供元素用extends消费者接收元素用super。如果只是想用ArrayList作为容器不涉及这类通配符设计那倒不用每处都套这个口诀但一旦你开始写公共工具类、库代码或者面对一类多态的批量处理时通配符的思路就会派上用场。我自己带新人的时候总发现大家一看到泛型的尖括号就开始发怵。其实泛型的核心就两个问题编译器怎么利用它帮你检查类型运行时它被擦除成了什么。把编译期和运行期这两件事分开理解再靠平时写代码时多看看API源码里泛型方法的使用方式这个知识点很快就会熟起来。基础二的内容到这里就梳理得差不多了。最后分享一个我在实践中反复验证的做法学完这一阶段之后不要急着冲框架找一个稍微完整一点的小型练手题目比如写一个命令行记账本要求用到自定义类、集合存数据、异常处理兜住文件读写错误再顺手把字符串和泛型都揉进去。当你在自己的代码里真正遇到过空指针、遇到过集合元素丢失、遇到过异常被吞掉导致很难排查你就会明白这些基础点到底为什么被设计成现在这个样子。这一遍练完再去看任何Java框架的源码你都会觉得顺畅很多。
拿不准这条消息跟你有没有关系?
工种不同、批次不同,要求可能差很多。打电话把你的情况说清楚,我们按信阳、平顶山本地的口径给你捋一遍。