资讯动态

Java基础二:从面向对象、集合框架到泛型的深度进阶指南

发布时间:2026/9/24 23:06:00 来源:尧图企业网站定制
1. 学完基础一之后为什么基础二才是真正的分水岭先问一个比较现实的问题你现在能独立写一个包含类、对象、循环、判断的小项目吗如果能那恭喜你你已经跨过了“Java基础”的第一道门槛。但我也见过太多人卡在这里——代码能跑面试题却一答一个懵。问面向对象三大特性课本原话背得滚瓜烂熟问ArrayList和LinkedList区别也能说出“一个数组一个链表”可一旦落到实际场景比如“线上有个接口响应特别慢怀疑是遍历列表的方式不对”就不知道从哪下手了。这里想先说明白一个认知Java基础二阶段的学习目标不是继续堆知识点而是把基础一里学过的语法“用出深度”来。换句话说基础一解决的是“Java能干什么”基础二解决的是“Java为什么这么设计、什么时候该用哪种方案”。很多所谓的Java面试八股文其实就是在考察这一层——不是考你记没记住结论而是考你有没有真正理解结论背后的原理。我自己的体会是基础二阶段特别容易走两个极端。一个极端是刷题式学习——今天看一道“String和StringBuilder区别”记住了明天看一道“HashMap底层原理”也记住了但合上电脑全忘了因为脑子里只有结论没有推导过程。另一个极端是工具书式学习——非要把《Java编程思想》从头啃到尾啃到集合那一章就放弃了因为太厚太抽象和实际工作完全联系不起来。正确的姿势应该是“主题式深入”每个核心知识点都问自己三个问题——它解决什么问题、它底层怎么实现的、如果我不用它会有什么后果。带着这三个问题去学哪怕每天只看一个主题三个月后的积累都会非常可观。这篇博客就是按照这个思路写的我会把基础二阶段最关键的几个主题拆开讲包括面向对象、集合框架、泛型、异常处理最后再聊两个为后续进阶打基础的模式和机制。顺便提一句基础二阶段最好把环境再理一遍。很多人在基础一阶段JDK装好了环境变量配好了IDEA能跑Hello World就完事了。但等到学集合源码、看框架代码、做单元测试的时候才发现自己用的是JDK 8跟着教程敲的var语法编译报错或者maven的settings.xml根本没配对镜像源拉不动依赖。建议统一到JDK 8或JDK 11这样的长期支持版本IDE用IntelliJ IDEA社区版就够Maven的配置花半小时规范化一下后面会省很多事。2. 面向对象从“三大特性”的背诵到“设计思维”的落地2.1 封装不是私有字段加getter/setter而是隐藏变化很多人写Java类习惯性把字段设成private然后一键生成getter/setter就觉得“我封装好了”。但面试官问你“封装的意义是什么”你要是回答“把数据藏起来不让人直接访问”只能说答对了一半另一半才是关键封装的核心目的是隐藏变化。举个例子。假设你写了一个User类年龄字段age一开始业务上允许负数后来产品经理说年龄不能为负那你只需要在setAge()里加一个校验逻辑public void setAge(int age) { if (age 0 || age 150) { throw new IllegalArgumentException(年龄不合法: age); } this.age age; }所有调用setAge的地方自动生效外部代码一行都不用改。这就是封装带来的“变化的隔离性”。如果你图省事字段直接public或者只在某些地方做了校验某些地方没做那等出问题的时候你会需要全项目搜age赋值的地方一处处补。实战里还有一个比较隐蔽的封装场景把“怎么算”藏在方法里。比如订单金额需要打折、加运费、去零头如果调用方每次自己去算公式一变所有调用点都得跟着改。正确做法是把计算逻辑拆到独立方法比如getPayableAmount()外部只关心“我付多少钱”不关心“钱是怎么算出来的”。2.2 继承说“组合优先于继承”之前先搞清楚继承的适用边界继承是面向对象里被误解最多、也最容易被滥用的一块。教科书告诉你继承能让子类复用父类代码结果很多新人的思维就变成“只要两个类有共同字段我就搞个父类把它们都继承成子类”。随之而来的就是三层四层的继承树改一处父类方法全部子类行为都变直接怀疑人生。我的建议是基础二阶段把继承当作一种“is-a”关系的建模工具而不是代码复用工具来用。什么叫is-a关系Student is a PersonDog is a Animal这种语义上确实成立的关系才适合用继承。如果只是想复用几个方法优先考虑组合——把要复用的对象作为字段引用进来然后通过方法去调它的能力。之所以反复强调“组合优先于继承”是因为继承有一个非常硬性的坑破坏封装。子类依赖父类的实现细节父类一改子类很容易在不知情的情况下行为变异。经典的Rectangle和Square问题就是例子——正方形继承了长方形重写了setWidth和setHeight结果里氏替换原则直接瓦解调用方按长方形逻辑去用正方形算面积直接出错。基础二阶段你应该掌握的判断标准很简单拿不准就用组合别硬搞继承。继承的层次控制在两层以内如果发现要想三层四层先停下来想想是不是设计出了问题。2.3 多态接口编程是让你面向“能力”而不是面向“具体类”多态这个词听着很玄乎实质就是你写的代码依赖的是一个抽象类型接口或父类但运行时真正干活的是某个具体的实现类。好处是以后换实现不用改调用代码。看个最典型的例子。项目里要做文件上传一开始上了阿里云OSS写了一个OssUploader类全项目都new OssUploader()去用。后来云厂商合同到期换了腾讯云COS你得把所有用了OssUploader的地方全改成CosUploader。如果在设计时定义了一个Uploader接口里面就一个方法upload(File file)业务代码只依赖Uploader那切换云厂商时只改工厂方法里的一行调用方完全不用动。这个思想就是面试题里经常说的“面向接口编程”。但注意不是所有类都需要抽接口——如果一个类在设计层面被继承、被替换的可能性趋近于零那抽出接口纯属给自己增加文件数量。我见过有人一个User类也要先抽一个UserService接口搞得项目里满是每个ServiceImpl对应一个接口其实业务根本没有第二个实现属于为了模式而模式。在基础二阶段重点是把多态的原理搞清楚什么场景该抽接口等你有了真实业务再慢慢体会不迟。3. 集合框架Java容器不是背API而是理解数据结构3.1 为什么说“ArrayList查询快、增删慢”这句话害了不少人集合框架几乎是Java面试八股文里出镜率最高的模块。ArrayList和LinkedList的区别、HashMap底层结构、HashSet如何保证不重复这些都是问烂了的问题。但如果只是背结论遇到真实场景照样用错——比如背着“LinkedList增删快”就在一个需要频繁遍历、偶尔在头部插入的场景用了LinkedList结果性能反而更差原因就在于ArrayList底层数组有CPU缓存友好的特性顺序遍历极快而LinkedList每个节点都是独立对象遍历时频繁跳指针缓存命中率极低。关于增删的真相要看具体位置。ArrayList在尾部添加元素均摊时间复杂度是O(1)只有在扩容那一次才需要复制整个数组在中间或头部插入确实需要移动后面的所有元素是O(n)。LinkedList在头部和尾部插入确实是O(1)但如果你要在中间某个位置插入你得先遍历找到那个位置这一步就是O(n)。所以“ArrayList增删慢”是个过于笼统的结论只有在“中间位置插入/删除”这个特定场景下才成立。选型建议很直白90%以上的场景直接ArrayList。你需要一个不断按索引访问元素的列表需要快速遍历需要元素数量可预估都是ArrayList更合适。LinkedList的实际出场率其实非常低它更适合的场景是“需要频繁在列表两端插入删除且不太关心随机访问”比如实现一个简单的双端队列。大多数业务系统里你根本用不到它的独有优势。3.2 HashMap的底层设计数组加链表加红黑树每一步都是为了对抗哈希冲突HashMap是面试里的大热门说白了就是考察你对哈希表这个数据结构的理解深度。它为什么快因为理想情况下每个key算完哈希后都落在数组的不同槽位查一个元素只需一次数组下标访问O(1)到手。问题是哈希函数做不到完美多个key落到同一个槽位就产生了哈希冲突。解决办法在JDK 8里已经有成熟的方案每个槽位先是一条链表冲突的key挂上去遍历链表找key这是O(n)但如果链表长度超过阈值8并且整个数组长度达到64链表就会转成红黑树查找复杂度降到O(log n)。面试如果追问“为什么是8”你可以说明这是时间和空间的折中并且结合泊松分布在负载因子0.75下链表长度达到8的概率已经低于千万分之一成了基本上可以忽略的场景。还有一个高频考点是扩容。HashMap在元素个数超过容量 x 负载因子时触发扩容例如默认容量16、负载因子0.75元素数达到12就翻倍到32。JDK 8的扩容把整个数组重新分配然后把旧元素重新哈希到新数组。这里有个常见的记忆误区——你以为扩容时每个key要重新计算hash实际上不需要因为hash值没变只需要用新的数组长度重新算槽位下标。JDK 8还做了一个优化扩容后元素要么在原位置要么在原位置加旧容量这个结论可以直接用来回答“JDK 8的扩容机制”。3.3 线程安全容器不要无脑Hashtable也不要无脑ConcurrentHashMapHashMap非线程安全多线程并发写会出问题这大家都知道。但解决方案怎么选老代码里经常见到Hashtable这个类从JDK 1.0就有了特点是所有方法都加了synchronized线程安全但并发性极差——不管读写都是全表锁多线程读也会互斥性能瓶颈非常明显。它的替代者是ConcurrentHashMap引入了分段锁JDK 7和CAS加 synchronizedJDK 8的设计把锁粒度大幅降低读操作基本无锁化并发场景下性能远超Hashtable。但注意这里有个很微妙的坑。ConcurrentHashMap虽然保证了单个操作的线程安全但它不保证复合操作的原子性。比如经典的“若不存在则插入”if (!map.containsKey(key)) { map.put(key, value); }即使map是ConcurrentHashMap这两行之间也可能插入其他线程的修改。如果你需要这种原子语义应该直接用putIfAbsent(key, value)这个方法它才是真正原子的。这个细节在面试里特别容易中招实际开发中也经常有人因为没用对方法而埋下并发Bug。另外还要说一句业务开发里很多“并发场景”其实根本不需要并发容器。比如数据是启动时一次性加载的配置初始化之后只读不写那HashMap就完全够用加锁属于多余开销。4. 泛型与类型系统让Bug在编译期就现形4.1 泛型到底解决了什么问题泛型是Java里比较“劝退”的一个知识点因为它涉及的概念抽象而且由于历史兼容问题规则有些拧巴。但从实际作用上讲泛型就一句话把类型也变成可配置的参数。没有泛型之前你要写一个能存任何类型对象的列表存放时是Object取出来的时候必须强转转错了运行时才报ClassCastException排查难度相当大。有了泛型List的泛型参数一传编译器就会在放错类型的那行代码上直接报错把运行时隐患提前到了编译期。这就带出泛型的核心价值编译期类型安全。你在写代码的时候就把类型约束固定死了比如ListString只能放String放了Integer直接编译失败省掉的是一整类“运行时才发现类型不对”的尴尬。这在维护大型项目时尤其重要——团队里每个人都在调你写的工具类如果用了泛型对方调用时类型对不对一目了然根本不需要去看你的方法实现。4.2 通配符和PECS原则什么时候用extends什么时候用super泛型进阶里通配符是让很多人头疼的部分尤其是List? extends T和List? super T到底什么区别、什么场景用哪个绕起来是真绕。这里可以引入一个助记口诀PECS即Producer extendsConsumer super。意思很简单如果这个泛型容器是“生产者”主要是往外读数据用extends如果它是“消费者”主要是往里写数据用super。举两个例子。第一个你要写一个方法参数可以接收List 、List 、ListList? extends Object也就是List?这样传什么进去都行。第二个你要写一个方法向集合里添加若干元素而且要求这个集合的类型能够容纳Integer——那List? super Integer就比ListNumber更通用传ListNumber也可以传ListObject但不能传ListInteger。前者取值取出来全是Object后者添加只允许Integer两种边界非常清楚。在实际开发中泛型通配符用得最频繁的地方是写通用工具方法。我的经验是不要一上来就想着用通配符把方法做得“万能”大多数业务代码根本不需要。遇到确实需要的地方先把PECS口诀套上去再读一遍方法体是“读多”还是“写多”验证一次基本就不会错。4.3 类型擦除为什么泛型信息运行时就被删掉了类型擦除是泛型面试里绕不开的硬核点。Java的泛型是编译期语法编译完成后泛型类型信息会被擦除运行时你拿不到List 还是List 的区别它们都是同一个List.class。这就是为什么你不能写if (obj instanceof ListString)因为运行时List 和ListInteger}的Class对象是同一个类型参数根本不存在了。也不能new一个T()或者T[]因为T在运行时已经被擦除成它的边界类型一般是Object你无法确认它是哪个类。类型擦除带来的一个经典面试场景是方法重载的坑。比如一个类定义了void handle(ListString list)和void handle(ListInteger list)两个方法的参数经过擦除后都是ListJVM认为这俩是一个方法签名编译直接报错“名称冲突”。这个现象很多人在IDE里遇到过一次就记住了但未必理解底层原因——因为泛型编译后被擦除签名无法区分。理解类型擦除还有个实际用处写通用API时如果你需要在运行时知道泛型的真实类型得绕道传Class参数或使用ParameterizedType去解析父类泛型。这些技巧在写框架、写通用工具时会出现基础二阶段先把擦除原理理解了后面接触框架源码时就不容易被“看着像运行时能拿到泛型”的代码绕晕。5. 异常处理不要在catch里写一行printStackTrace就完事5.1 受检异常和运行时异常这个分类不是摆设是设计约定Java的异常体系分成两大派受检异常Checked Exception和运行时异常RuntimeException。受检异常编译期就强制你处理不处理根本编译不过典型代表是IOException、SQLException。运行时异常则不需要显式声明或捕获典型代表是NullPointerException、IllegalArgumentException、IndexOutOfBoundsException。这个分类经常被初学者当成“编译器好烦”但实际上它是一层设计约定受检异常代表“外部环境出问题了调用方必须想清楚怎么应对”比如文件不存在、数据库连不上这种你不可能在方法内部默默消化掉必须让调用方知道并做出选择。运行时异常则代表“程序自身逻辑有bug”比如传了空参数、数组越界这种属于代码写错了修复手段是改代码而不是在每个调用点try-catch。实际开发中异常设计做得好的项目会严格区分这两类业务预期内的异常比如库存不足、余额不够用业务异常类继承RuntimeException因为这种异常属于调用方业务分支不应该被编译器强制要求捕获技术层面的IO异常、网络异常保留受检异常让上层感知到“外部依赖出问题了可能需要降级、重试或告警”。这个区分如果一开始没想清楚后面代码会越写越乱到处都是羞耻的catch。5.2 抛出什么、哪里捕获、怎么恢复异常处理的三个灵魂拷问很多人的异常处理习惯是哪里报错就哪里try-catchcatch里打一行日志然后程序继续跑。这种做法在演示代码里没问题但线上系统这么搞往往会把真正的问题悄悄吞掉日志里一堆ERROR却不知道影响哪些用户甚至出现“看起来一切正常实际上数据全错了”的惨剧。我的经验是处理异常前先问三个问题第一这个异常我应该自己处理还是抛给上层处理如果是DAO层、工具类通常应该是抛出去让业务层决定如何应对如果是Controller层捕获后要转成用户能看懂的提示信息。第二捕获后能不能做有意义的恢复比如调用远程接口超时如果配置了重试那可以在catch里做一次重试再抛如果不能恢复就往外抛并附带关键上下文。第三日志里要记什么光记一个exception.getMessage()很多时候不够要把业务ID、入参、当前用户这些关联信息一起记上否则排查问题时根本定位不到是哪个请求出了问题。再提一个常见反模式catch了Exception然后什么都不做或者只printStackTrace。printStackTrace在本地调试有用在线上会把堆栈打到标准错误流日志系统不好收集不说还容易丢失。正确姿势是使用日志框架Slf4j加Logback的error方法记录堆栈至少做到log.error(业务描述, 参数{}, param, e)这种程度。这个习惯越早养成越好能帮你省下大量排查线上问题的时间。5.3 自定义异常别偷懒写一个业务异常类真的不亏正常项目跑久了你会发现JDK自带的异常类型根本不够用。业务上常见的“订单已关闭”“库存不足”“用户不存在”如果用IllegalArgumentException去表达调用方的catch很难区分是参数问题还是业务规则问题。所以建议基础二阶段就养成习惯为自己的模块定义一个基础业务异常类。最简单的做法是定义一个BizException extends RuntimeException里面加一个可选的错误码字段public class BizException extends RuntimeException { private final Integer code; public BizException(Integer code, String message) { super(message); this.code code; } public Integer getCode() { return code; } }业务代码里直接throw new BizException(10001, 订单已关闭)Controller层统一捕获再根据code映射成不同的HTTP状态码或提示文案。这么做最直接的好处是异常和业务解耦错误码能支撑前端做多语言提示后端日志里看到错误码也能快速定位是哪类问题。别觉得这是过度设计——只要你写过一次稍微大点的项目就会明白这个类是刚需。6. 从基础二迈向进阶策略模式和动态代理初体验6.1 设计模式不是背出来的是基础知识的组合运用到了基础二阶段的尾声你会开始听到“设计模式”这个词。我见过不少新人一上来就去背《设计模式》的类图背得头昏脑涨拿到实际需求仍然不会用。其实设计模式没那么玄本质就是“经过验证的高频代码组织方式”。它不是独立于Java基础之外的新知识而是面向对象、集合框架、泛型这些基础知识的组合运用。所以基础二阶段的目标不是把23种设计模式全背下来而是理解三到四个高频模式的“为什么”。其中我强烈推荐先看策略模式和动态代理。这两个模式在Java生态里出现频率极高策略模式的思想渗透在接口设计的方方面面动态代理则是Spring AOP、MyBatis Mapper等框架的基础支撑理解了它们你之后看框架源码会轻松一大截。6.2 策略模式把一长串if-else换成一个Map和一堆实现类业务开发中最常见的一段代码就是各种if-else。比如促销活动周五打折、周六满减、周日返券每个if里面是一套完全不同的计算逻辑。这种代码第一天写很简单但随着规则不断叠加方法会越来越长最后变成几百行的大泥球谁改谁害怕。策略模式的思路是把每一套计算逻辑封装到独立的类里然后用一个Map把条件和策略类关联起来。拿促销举例先定义一个策略接口public interface DiscountStrategy { BigDecimal calculate(BigDecimal amount); String getType(); }然后每个规则写一个实现类比如FridayDiscount、SaturdayFullReduction、SundayCashBack。再用一个工厂把所有策略装进Mapkey是getType()返回的促销类型value是策略实例。业务代码只需要根据当前日期从Map里取出对应策略然后调用calculate方法循环判断瞬间就没了。这么做的好处不只是让代码变短更关键的是“开闭原则”——将来要加一种新促销只需新增一个实现类并在工厂里注册原来的代码一行都不用动。新增行为的风险点从“改一个大方法”缩小到“写一个独立类”这对多人协作的项目来说价值巨大。6.3 动态代理Java反射机制最典型的应用现场动态代理的原理一句话概括在运行时动态生成一个代理类代理类持有目标对象外部调用代理方法时代理可以做一些额外处理日志、事务、权限校验再决定要不要调用真实目标。因为代理类是运行时生成的不是编译期写死的所以叫“动态”。Java提供了两个原生支持的动态代理方式。一个是基于接口的java.lang.reflect.Proxy要求被代理对象至少实现一个接口它会在运行时生成一个实现该接口的代理类。另一个是基于继承的CGLIB通常以第三方库形式存在通过继承目标类生成子类作为代理不要求实现接口。Spring的AOP底层会根据目标对象是否实现接口自动选择这两种方式。基础二阶段不需要手写一个完整的动态代理框架但强烈建议动手写一个简单示例感受一下InvocationHandler的回调机制。一个最小可运行的例子是给某个业务类的每个方法自动打印耗时。核心代码就这么一段public class TimerInvocationHandler implements InvocationHandler { private final Object target; public TimerInvocationHandler(Object target) { this.target target; } Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { long start System.currentTimeMillis(); Object result method.invoke(target, args); long cost System.currentTimeMillis() - start; System.out.println(method.getName() 耗时: cost ms); return result; } }这个例子虽然只有十几行但它包含了反射、动态代理、方法调用等一堆重要知识。你把它跑通之后再去理解Spring AOP里的Around通知会觉得熟悉得不得了——无非就是在invoke方法前后加了一些增强逻辑而已。整个Java进阶路上的很多框架黑盒拆开之后核心其实都是这些基础知识的组合。从基础一到基础二最大的变化是“知其然”到“知其所以然”。如果这篇文章里的某些部分你已经能闭着眼睛解释清楚恭喜你可以放心往下走如果还有含糊的地方建议别再囤新教程了把旧知识反反复复看到通透为止。基础这东西快就是慢慢就是快。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价