Java过渡期核心突破:面向对象、集合泛型与并发实战指南
1. 从“能跑就行”到“跑得明白”Java 过渡阶段的核心分水岭写这个系列写到第四篇后台收到最多的一类留言是“语法我都懂跟着视频敲也能跑但一合上教程就不知道从哪下手。”这个问题我太熟了因为我自己当年就是从那个阶段熬过来的。Java 的过渡期本质上不是“再学几个新语法”而是从面向过程的模仿式编程切换到面向对象的建模式编程。这个切换点才是真正拉开差距的地方。这篇内容面向的是已经能写出for循环、能定义class、能调用方法但面对一个完整需求时脑子里没有“结构感”的人。我会把过渡期最容易卡住的几个硬骨头拆开讲面向对象到底怎么落地、集合与泛型怎么选、异常和并发怎么不踩坑、JVM 和工具链怎么建立直觉。每一块我都会给出“为什么这么设计”的解释而不是只丢一段能跑的代码。因为过渡期最缺的不是代码量是判断力——知道什么场景该用什么工具知道一个写法背后在牺牲什么、换取什么。我个人的经验是过渡期大概会持续三到六个月取决于你是否有意识地去“复盘自己的代码”。如果你只是不停地写新功能写三年也还是那个水平如果你每写完一个模块都回头问自己“这个类为什么这么拆”“这个集合为什么选它”三个月就能有明显质变。下面这些内容就是我这几年带人、自己踩坑、反复验证后沉淀下来的东西。2. 面向对象不是背概念把“类”当成职责的容器2.1 为什么很多人写了三年还是面向过程我见过太多这样的代码一个UserService类里塞了两千行注册、登录、改密码、发通知、算积分全在里面方法之间靠一堆成员变量传递状态。这种代码能跑但它不是面向对象它只是把面向过程的代码装进了一个 class 的壳子里。判断标准很简单如果你的类去掉class关键字、把方法都改成静态的逻辑几乎不用改就能跑那它就不是面向对象。面向对象的核心不是“有类和对象”而是职责的分配。一个类应该只对一件事负责它的方法应该是它自己状态的自然延伸。举个我常用来解释的例子Order订单这个类它的职责是维护订单自身的状态——商品明细、金额、状态流转。至于“下单后发短信”那是NotificationService的事“下单后扣库存”那是InventoryService的事。Order不应该知道短信怎么发、库存怎么扣它只负责在自己状态变化时通过事件或返回值告诉外界“我变了”。这个思路落到实操上就是先画职责边界再写类。我现在的习惯是拿到一个需求先在纸上列出所有名词候选类和动词候选方法然后问自己这个动词是哪个名词的“本能行为”比如“计算订单总价”总价是订单的属性计算是订单的本能所以放Order里“发送订单确认邮件”邮件不是订单的属性发送也不是订单的本能所以拆出去。这个“本能判断法”帮我省掉了大量后期重构的时间。2.2 封装、继承、多态在真实项目里长什么样教科书上讲封装就是“private getter/setter”这其实是个误导。真正的封装是隐藏实现细节暴露稳定契约。我举个实际场景一个PaymentGateway接口定义了pay(amount)和refund(txId)两个方法。至于底层是走哪个渠道、怎么签名、怎么重试调用方完全不需要知道。这样当渠道方改了接口协议我只需要改实现类所有调用方一行都不用动。这才是封装的价值——把变化关在盒子里。继承这个东西我现在用得越来越谨慎。因为继承是最强的耦合子类和父类绑死父类改一个方法签名所有子类都得跟着改。我现在的原则是优先用组合只有在“is-a”关系非常明确、且父类设计时就考虑了扩展比如模板方法模式时才用继承。举个反例很多人喜欢写一个BaseService让所有 Service 都继承它结果BaseService里塞了一堆通用方法子类被迫继承了一堆用不上的东西。这就是典型的继承滥用。更好的做法是把通用逻辑抽成一个Helper或Util谁需要谁注入。多态是过渡期最容易被低估的东西。它的本质是用统一接口处理不同实现。我举个真实例子一个报表导出功能要支持 Excel、PDF、CSV 三种格式。如果不用多态代码里会有一堆if (type EXCEL) {...} else if (type PDF) {...}每加一种格式就要改这个 if。用多态的话定义一个Exporter接口三个实现类各自实现export()调用方只依赖接口新增格式只需要加一个实现类调用方零改动。这就是开闭原则的落地——对扩展开放对修改关闭。2.3 一个可复现的职责拆分实操我拿一个“列车调度”的小场景来演示这个词在热搜里出现过我猜很多人做课程设计会碰到。需求是有一组列车每列有车次、始发站、终点站、发车时间系统要能按发车时间排序、能查询某站的所有列车、能检测时间冲突。新手常见的写法是写一个TrainManager里面塞一个ListTrain然后所有方法都写在这个 Manager 里。这个写法能跑但职责全糊在一起了。我的拆法是Train只负责自身数据提供getDepartureTime()等访问方法实现Comparable用于排序。TrainRepository负责列车的存储和检索对外提供findByStation()、findAllSortedByTime()。ConflictDetector只负责冲突检测逻辑输入一组列车输出冲突对。TrainScheduleService编排层组合上面三者完成业务用例。这样拆的好处是排序逻辑改了只动Train存储换成数据库只动TrainRepository冲突规则改了只动ConflictDetector。每个类都能单独写单元测试不用启动整个系统。我实测下来这种拆法在后期加需求时改动量能比“大 Manager”写法少一半以上。注意职责拆分不是越细越好。我见过有人把每个方法都拆成一个类结果类爆炸读代码要跳十几个文件。判断标准是“这个类是否有独立的变更理由”如果一个类里的两个方法总是同时因为同一个需求而修改那它们就该待在一起。3. 集合与泛型选错容器性能差十倍3.1 ArrayList、LinkedList、HashMap 的真实取舍面试八股文里背“ArrayList 查快增删慢LinkedList 增删快查慢”但真实项目里怎么选我的经验是90% 的场景直接用 ArrayList。原因很简单LinkedList 的“增删快”只在“已经持有目标节点引用”的前提下成立而实际业务里你几乎总是先查找再删除查找本身就是 O(n)LinkedList 的优势根本发挥不出来。而且 LinkedList 每个节点额外维护前后指针内存开销比 ArrayList 大得多CPU 缓存命中率也差。HashMap 是过渡期必须吃透的。它的核心是用空间换时间通过哈希函数把 key 映射到桶数组的某个位置理想情况下查找是 O(1)。但有两个坑必须知道第一key 必须正确实现 hashCode 和 equals否则会出现“明明放进去了却查不到”的诡异问题。我踩过一次用自定义对象做 key只重写了 equals 没重写 hashCode结果两个逻辑相等的对象被当成不同的 keyMap 里出现了重复项。第二HashMap 不是线程安全的多线程并发 put 在扩容时可能形成环形链表导致 CPU 飙满这个在 JDK 8 之后虽然改进了但并发场景还是应该用ConcurrentHashMap。我整理了一张选型对照表这是我实际项目里总结的场景推荐容器理由普通列表读多写少ArrayList内存紧凑随机访问 O(1)频繁在头部插入删除ArrayDeque比 LinkedList 更快内存更省需要去重且关心顺序LinkedHashSet保留插入顺序去重 O(1)需要排序的键值对TreeMap红黑树实现天然有序高并发读写ConcurrentHashMap分段锁/CAS吞吐量高一对多映射Map 嵌套 List比自定义结构更灵活3.2 泛型擦除带来的那些“坑”泛型是过渡期另一个容易翻车的地方。核心要记住一句话Java 泛型是编译期的运行期会被擦除。这意味着ListString和ListInteger在运行时都是List你没法在运行时判断一个 List 的泛型参数是什么。这个特性带来几个实际影响。第一不能new T()因为运行期 T 被擦除了编译器不知道要创建什么类型。解决办法是传入ClassT对象用反射创建。第二不能创建泛型数组new T[10]编译不过因为数组是协变的、运行期需要知道元素类型。第三静态方法不能用类的泛型参数因为静态方法属于类而非实例而泛型参数是实例级别的。我举个实际踩坑的例子写一个通用的ResultT包装类序列化后反序列化时T的类型信息丢了反序列化出来是LinkedHashMap而不是原来的对象。解决办法是用TypeReference或者传入ClassT。这个坑我在对接外部 API 时踩过排查了半天才发现是泛型擦除导致的。3.3 用泛型写出可复用的工具方法泛型最大的价值是类型安全 代码复用。我举个自己常用的例子一个分页工具方法。public static T ListT paginate(ListT source, int page, int size) { if (source null || source.isEmpty()) { return Collections.emptyList(); } int from Math.min((page - 1) * size, source.size()); int to Math.min(from size, source.size()); return source.subList(from, to); }这个方法对任何类型的 List 都适用而且编译期就能保证返回类型和输入类型一致。如果不用泛型要么用Object然后到处强转运行期可能 ClassCastException要么为每种类型写一个重载代码爆炸。泛型在这里的价值就是把类型检查从运行期提前到编译期让错误在写代码时就暴露而不是上线后炸。实操心得泛型方法声明里的T要放在返回类型之前这是语法规定。另外如果方法参数里出现了 T编译器通常能自动推断类型调用时不用显式指定。我见过有人每次都写Util.Stringpaginate(...)其实完全没必要。4. 异常与并发过渡期最容易埋雷的两块4.1 异常不是用来控制流程的新手最常见的异常误用是用异常做流程控制。比如查用户查不到就抛异常然后上层 catch 住当“没找到”处理。这个写法的问题在于异常的创建和抛出是有成本的要填充栈轨迹在高频调用路径上会严重拖慢性能。更重要的是它让代码的意图变得模糊——异常应该表示“意外情况”而不是“预期内的分支”。我的原则是预期内的分支用返回值预期外的错误用异常。查用户查不到这是预期内的返回OptionalUser或null数据库连不上这是预期外的抛异常。这个界限划清楚代码的可读性和性能都会好很多。另一个坑是吞异常。我见过太多这样的代码try { doSomething(); } catch (Exception e) { // 什么都不做 }这是最危险的写法因为出了问题你完全不知道。正确的做法至少是记录日志最好是要么处理要么往上抛要么包装成业务异常再抛。我现在的习惯是catch 块里如果什么都不做那必须写注释说明为什么可以忽略否则一律记日志。4.2 线程安全从“知道”到“会用”并发这块过渡期的人通常知道synchronized但不知道怎么选。我整理了一个决策路径如果只是保护一个简单的计数器用AtomicInteger如果是保护一段复合逻辑用synchronized或ReentrantLock如果是读多写少的共享数据用ReadWriteLock或volatile注意 volatile 只保证可见性不保证原子性。ConcurrentHashMap是我在项目里用得最多的并发容器。它的computeIfAbsent方法特别适合做本地缓存MapString, User cache new ConcurrentHashMap(); User user cache.computeIfAbsent(userId, id - loadFromDb(id));这个写法是原子的多个线程同时查同一个 key只有一个会真正执行loadFromDb其他线程会等待结果。这比“先 get 再 put”的写法安全得多后者在并发下可能重复加载。关于 AQSAbstractQueuedSynchronizer很多人背八股文背得很熟但不知道它到底解决什么问题。简单说AQS 是一个同步器的骨架它把“排队、阻塞、唤醒”这套逻辑抽象出来让ReentrantLock、Semaphore、CountDownLatch这些工具只需要实现“什么时候能获取锁、什么时候释放锁”的判断逻辑。理解 AQS 的价值在于你能看懂这些并发工具的源码知道它们的性能特征和适用边界而不是只会调 API。4.3 数据一致性过渡期就该建立的意识“Java 怎么保证数据一致性”这个热搜词说明很多人已经意识到这个问题了。我的经验是一致性分几个层次单机单线程靠事务单机多线程靠锁和原子类分布式靠分布式事务或最终一致性方案。过渡期最该掌握的是单机多线程下的一致性。举个典型场景一个库存扣减多个线程同时扣如果写成if (stock 0) stock--;在并发下会超卖。因为“判断”和“扣减”不是原子的两个线程可能都通过了判断。解决办法是用synchronized包住或者用AtomicInteger的decrementAndGet配合 CAS 循环。我实测过用synchronized在低并发下性能足够高并发下AtomicInteger的 CAS 更优但要注意 CAS 在竞争激烈时会自旋消耗 CPU。注意不要过早优化并发。我见过有人在单线程场景下用ConcurrentHashMap理由是“以后可能并发”。这是过度设计增加了复杂度却没带来收益。先保证正确再在压测中发现瓶颈后优化。5. 工具链与 JVM建立“代码之外”的直觉5.1 环境变量配置与版本管理“java 环境变量配置”是热搜常客说明这是很多人的第一道坎。我简单说清楚原理JAVA_HOME指向 JDK 安装目录PATH里加上%JAVA_HOME%\binWindows或$JAVA_HOME/binLinux/Mac这样命令行才能找到java和javac。CLASSPATH现在基本不用手动配了默认就是当前目录。但我要提醒一个坑多版本共存时的切换。我机器上同时装了 JDK 8、11、17如果JAVA_HOME指向 8 但PATH里有个 17 的路径在前面就会出现“java -version显示 17但编译报错说源发行版 17 需要目标发行版 17”这种诡异问题。解决办法是确保PATH里只有一个 JDK 的 bin 目录切换版本时改JAVA_HOME并重新打开终端。这个坑我在热搜里看到有人问其实就是版本没对齐。5.2 从“能跑”到“跑得好”JVM 基础直觉过渡期不需要深入 JVM 调优但要有几个基本直觉。第一堆内存分新生代和老年代新创建的对象在新生代经过多次 GC 还活着的才晋升到老年代。第二Minor GC 频繁但快Full GC 慢且会停顿如果发现 Full GC 频繁通常是内存泄漏或大对象过多。第三栈是线程私有的堆是共享的所以局部变量天然线程安全成员变量不一定。我举个实际排查的例子有次线上服务每隔几分钟卡顿一下日志显示 Full GC。用jstat -gc一看老年代几乎满了但新生代很空。后来发现是一个静态 Map 一直在 put 从不清理导致对象不断晋升到老年代。解决办法是换成带过期策略的缓存。这个排查过程用到的工具就是 JDK 自带的jstat和jmap不需要额外装东西。5.3 构建工具与依赖管理Maven 和 Gradle 是过渡期必须会的。Maven 的核心是约定优于配置标准目录结构、生命周期、依赖坐标。我建议新手先把 Maven 的pom.xml吃透理解groupId、artifactId、version三坐标理解scopecompile、test、provided、runtime的区别。provided这个 scope 特别容易搞错比如 Servlet API 在 Tomcat 里已经有了打包时就不该带进去否则会冲突。依赖冲突是另一个高频坑。Maven 的依赖调解规则是“最短路径优先路径相同先声明优先”。我遇到过 A 依赖 B 的 1.0C 依赖 B 的 2.0结果实际用的是 1.0导致 C 的功能异常。排查工具是mvn dependency:tree能看到完整的依赖树找出冲突来源后用exclusions排除掉不需要的版本。6. 常见问题与排查技巧实录6.1 过渡期高频问题速查表问题现象可能原因排查方向编译报“源发行版 X 需要目标发行版 X”JDK 版本与编译配置不一致检查 JAVA_HOME 和 pom 里的 source/target数组越界异常循环边界或索引计算错误打印索引和数组长度检查和空指针异常对象未初始化或方法返回 null用 Optional 或提前判空看栈轨迹定位行号HashMap 查不到刚放的值自定义 key 没重写 hashCode/equals检查两个方法是否都重写且逻辑一致并发下数据错乱共享变量未同步检查成员变量用锁或原子类保护内存持续增长静态集合未清理或监听器未注销用 jmap 看堆快照找大对象6.2 我踩过的三个典型坑第一个坑是字符串拼接。早期我写循环拼接用数据量一大就慢得离谱。后来才知道每次都会创建新的 String 对象循环一万次就创建一万个对象。正确做法是用StringBuilder它在内部维护一个可变字符数组拼接是 O(1) 的。但要注意StringBuilder不是线程安全的多线程下用StringBuffer。第二个坑是对象深度拷贝。有次我改了一个对象的属性结果发现另一个“不相关”的对象也变了排查半天发现两个引用指向同一个对象。浅拷贝只复制引用深拷贝才复制对象本身。实现深拷贝可以用序列化反序列化或者手动递归复制。我现在的习惯是如果一个对象要跨层传递且可能被修改就在边界处做一次深拷贝避免意外的共享状态。第三个坑是动态代理的 self-invocation。用 Spring 的Transactional时如果在一个方法内部直接调用同一个类的另一个Transactional方法事务不会生效。因为 Spring 的事务是基于代理的内部调用不走代理。解决办法是注入自己或者用AopContext.currentProxy()。这个坑我在做支付相关功能时踩过排查了很久才定位到。6.3 独家避坑建议我的第一条建议是写代码前先想清楚数据流。输入是什么、经过哪些处理、输出是什么、中间状态存在哪里。这个想清楚了代码结构自然就出来了。我见过太多人上来就写写到一半发现数据结构不对推倒重来。第二条建议是每个类都问自己“如果需求变了我要改几个地方”。如果答案是“很多地方”说明职责没拆好。好的设计是一个需求变更只影响一个类或一个方法。第三条建议是不要怕重构。过渡期最大的进步往往来自“把之前写的烂代码重写一遍”。我自己的习惯是每完成一个模块隔一天再回来看如果觉得“这写的什么玩意”那就说明有进步然后动手重构。这个过程比写新代码学到的更多。最后分享一个我常用的调试技巧当遇到诡异问题时先缩小范围。把可疑代码单独抽出来写一个最小复现往往在抽的过程中就发现问题了。这个方法帮我解决过无数次“明明逻辑对但结果不对”的问题。