JavaSE中级进阶:掌握集合框架、多线程与反射的工程实践

发布时间:2026/10/10 6:04:37
JavaSE中级进阶:掌握集合框架、多线程与反射的工程实践
1. 中级阶段到底在学什么1.1 从会写代码到会设计代码的跨越JavaSE的基础语法阶段大多数人的状态是照着书上的例子能写离开例子就卡壳。比如知道怎么定义类、怎么写for循环、怎么用if判断但一碰到多个类之间怎么协作数据多了怎么组织程序卡死了怎么排查就不知道从哪下手。中级JavaSE的学习重点其实不是再多背几个语法点而是完成一次思维方式的转换从写单条语句变成设计代码结构。我见过不少朋友拿着入门书翻到集合框架那一章抄了一遍ArrayList的用法就以为自己会了。但真到写一个学生管理系统要按学号查找、按成绩排序、去重统计才发现自己不知道选哪个集合类更不知道为什么HashMap要重写equals和hashCode。这说明基础语法只是零件中级阶段学的是怎么把这些零件组装成能用的机器。打个比方基础语法是教你认识螺丝、扳手、电钻知道每样工具怎么握。中级JavaSE则是教你怎么把木板钉成一个柜子——什么时候用螺丝、什么时候用榫卯、哪个位置要加固。这一阶段的知识点几乎都是围绕工程实践来的接口是为了让代码可替换集合是为了高效管理数据异常是为了让程序出问题时能体面退出多线程是为了让程序能同时干多件事。1.2 中级JavaSE的知识地图中级阶段的范围其实非常明确我把它拆成六个板块每个板块都对应真实项目中的一类需求面向对象进阶接口、抽象类、多态、内部类这是代码设计的基石。集合框架List、Set、Map以及对应的实现类这是数据管理的工具箱。异常处理受检异常、非受检异常、自定义异常、资源关闭这是程序健壮性的保障。IO与NIO文件读写、字符编码、缓冲流、通道与选择器这是和外部世界打交道的入口。多线程线程创建、同步、可见性、死锁这是并发编程的基础。反射、注解与泛型这是框架技术的底层支撑也是读懂Spring这类框架的前置知识。还有Lambda表达式和Stream流虽然不算传统的JavaSE核心但JDK 8以后基本成了写业务代码的主流方式也应该在中级阶段拿下。每学完一个板块建议拿一个真实场景去验证。比如学完集合框架就试着用Map写一个单词频率统计学完多线程就写一个模拟窗口卖票的小程序。有了场景知识才不会被遗忘。2. 面向对象进阶从语法到设计2.1 接口与抽象类什么时候用哪个很多初学者在接口和抽象类之间犹豫不决本质上是因为没想清楚两者的定位。我的一句话总结抽象类适合代码复用接口适合行为约定。抽象类是模板提取多个子类的公共部分接口是合同规定一个类型必须具备哪些能力但不关心实现细节。举个例子假设要写一套日志器支持输出到控制台、写入文件、发送到网络。三个实现类之间有共同点都要格式化日志信息、都要判断日志级别。那就可以用一个抽象类来承载这些公共逻辑public abstract class AbstractLogger { protected Level level; public void log(String message) { if (checkLevel(level)) { write(format(message)); } } protected abstract void write(String formattedMessage); }控制台日志器和文件日志器分别继承AbstractLogger只需要实现write方法格式化和级别判断都复用父类逻辑。但如果需求是支持多种消息发送渠道且渠道之间可以互相替换那更适合用接口public interface MessageSender { void send(String target, String content); }接口的意义在于解耦。调用方MessageService只依赖MessageSender接口不关心实际是短信发送还是邮件发送。以后新增一个钉钉机器人发送只要实现接口并注册进去就行不用改任何调用方代码。这是依赖倒置原则在实际项目中最直观的体现。需要注意Java的类只能单继承但接口可以多实现。如果一个类既需要复用公共逻辑又想遵守多个行为合同通常做法是继承一个抽象类 实现多个接口。在JDK源码里像AbstractList继承了AbstractCollection同时实现List接口就是这个模式的经典案例。2.2 多态的体现重写与重载要分清多态是面向对象最难理解、也最有价值的概念。我们可以把多态粗略分成两种重写体现运行时多态重载体现编译时多态。重写指的是子类重新实现父类的方法。调用时看的是对象的实际类型而不是声明类型Animal animal new Dog(); animal.speak(); // 实际执行Dog重写的speak方法这是运行时多态的核心机制也是Java实现面向接口编程的底层支撑。很多框架在设计时只暴露接口调用端拿到的都是接口类型真正干活的是某个具体实现类靠的就是这一机制。重载则是同一个类里方法名相同、参数列表不同。它在编译期就已经确定调用哪个方法和运行时的对象类型无关。这两个概念中最容易出问题的是重写的equals方法。我见过很多新手只重写equals不重写hashCode结果对象放进HashSet或作为HashMap的key时出现明明两个对象内容一样却查不到的诡异问题。原因在于HashMap的查找过程是先按hashCode定位桶再在桶里用equals比较。两个内容相同的对象hashCode不同就会落到不同的桶equals根本没机会被调用。所以有一条铁律重写equals必须重写hashCode两者要保持一致。内部类在中级阶段也需要掌握尤其是匿名内部类和局部内部类的使用场景。比如给按钮绑定事件、给排序方法传Comparator都是匿名内部类的高频应用。JDK 8以后Lambda表达式可以替代大部分匿名内部类的写法但理解匿名内部类仍然是理解Lambda的基础。3. 集合框架程序员的第二个大脑3.1 集合家族的整体布局集合框架的根本目的是让你在不同场景下用最合适的数据结构组织数据。它的体系可以一句话概括Collection是单列集合的根接口Map是双列键值对的根接口。Collection下面又分成List和Set两大阵营。List是有序可重复的常用实现类有ArrayList和LinkedList。ArrayList底层是动态数组随机访问快按下标取元素的时间复杂度是O(1)但中间插入和删除需要搬移元素。LinkedList底层是双向链表中间插入删除快但按下标访问需要从头遍历效率低。实际业务中ArrayList用得远多于LinkedList因为场景大多是遍历、追加、按下标取而中间插入删除的场景很少。我建议不要凭感觉选而是先分析数据操作的类型和频率。Set是无序不可重复的。HashSet基于HashMap实现去重依赖hashCode和equals方法查找速度接近O(1)LinkedHashSet在HashSet基础上维护了插入顺序适合需要去重且要保持顺序的场景TreeSet基于红黑树元素按自然顺序或指定的Comparator排序适合需要有序去重的场景。Map中HashMap是最常用的允许key和value为nullTreeMap按键排序LinkedHashMap保持插入顺序同时继承了HashMap的查询效率。一个实用场景是最近最少使用缓存就可以用LinkedHashMap配合重写removeEldestEntry实现这也是很多框架底层缓存的基础思路。3.2 HashMap源码级别的认知HashMap是面试里绕不开的话题更是实际开发中定位问题的关键。它的底层结构是数组加链表加红黑树。当发生hash冲突时冲突的键值对挂在同一个桶的链表上当链表长度超过8且数组长度大于等于64时链表会转成红黑树把最坏查找时间从O(n)降低到O(log n)。理解HashMap要抓住三个关键点。第一键对象必须正确实现hashCode和equals。HashMap的存取过程是先算hashCode确定桶的位置再在桶内用equals比较。这也是前面反复强调重写规则的原因。第二默认数组长度是16负载因子是0.75当元素个数超过容量乘以负载因子时触发扩容为原来的两倍。0.75是一个时间和空间的折中太小则频繁扩容浪费空间太大则冲突概率增加影响效率。第三hash值的扰动处理。JDK 8以后hash函数会把key的hashCode高16位和低16位做异或运算目的是让高位的特征也能参与确定桶下标减少冲突。实际开发中我踩过最大的坑是用可变对象当HashMap的key。比如把某个对象的id作为key通常没问题但如果有朋友把对象本身作为key对象创建后修改了其中的字段hashCode也随之变化HashMap内部却还记录着旧的桶位置。结果就是get的时候找不到数据甚至可能出现内存泄漏一样的残留。这个问题的结论很明确HashMap的key应该用不可变对象例如String或Integer。4. 异常处理与资源管理4.1 Java异常体系与自定义异常异常机制设计的初衷是把程序出错时怎么办的处理逻辑从业务代码中分离出来。Java把运行时产生的问题分成两大类Error和Exception。Error是JVM层面的严重问题比如OutOfMemoryError、StackOverflowError这类问题程序基本无法自行恢复不应该去捕获。Exception是程序层面的问题细分为受检异常和非受检异常。受检异常是编译期强制要求处理的异常比如IOException、SQLException。不处理就编译不过这是编译器在提醒你这个操作有失败的可能你必须想好预案。非受检异常是RuntimeException及其子类比如NullPointerException、ArrayIndexOutOfBoundsException编译器不强制处理因为它们大多源自程序逻辑缺陷更好的做法是修正代码而不是用try-catch掩盖。自定义异常的使用时机要把握好。业务中密码错误余额不足这类错误用RuntimeException的子类定义成业务异常配合全局异常处理器返回友好的错误信息是比较常见的设计。自定义异常的一般写法是继承RuntimeException提供几个常用构造器包括带消息、带原因、消息加原因的组合public class BusinessException extends RuntimeException { public BusinessException(String message) { super(message); } public BusinessException(String message, Throwable cause) { super(message, cause); } }在使用自定义异常时我建议在项目里建立统一的异常码体系异常类持有code和message两个字段方便前端或调用方根据code做分支处理。直接用异常消息做逻辑判断是很糟糕的做法因为消息一改判断条件就断了。4.2 为什么推荐try-with-resources在JDK 7之前处理资源关闭是一件既繁琐又容易出错的事。数据库连接、文件流、网络连接这类资源用完之后必须手动关闭否则会耗尽系统资源。传统写法需要在finally里关资源而且还要处理close本身抛出的异常。代码长可读性差还经常有人忘记关闭。JDK 7引入了try-with-resources语法凡是实现了AutoCloseable接口的资源都可以写在try的括号里代码块结束或异常抛出时JVM会自动调用close方法而且关闭顺序和打开顺序相反try (FileInputStream in new FileInputStream(a.txt); FileOutputStream out new FileOutputStream(b.txt)) { byte[] buffer new byte[1024]; int len; while ((len in.read(buffer)) ! -1) { out.write(buffer, 0, len); } }这个写法好在哪里第一资源一定被关闭不管代码块正常结束还是抛异常。第二多个资源按逆序关闭避免依赖关系出错。第三代码简洁焦点集中在真正的读写逻辑上。我在项目中要求所有涉及打开资源的地方一律使用这种语法除非遇到极其特殊的嵌套关闭需求。关于异常处理有几个常犯的错误值得单独说明。第一不要捕获异常后什么都不做至少要打印日志。第二不要在finally里写return。finally里return会吞掉try块中抛出的所有异常排查问题时会出现明明抛了异常却返回了正常值的诡异现象。第三不要在循环内捕获大范围异常循环中任何一次异常都可能导致后续数据丢失如果必须继续执行外层包循环、内层逐条捕获并记录是相对稳妥的方式。5. 多线程从创建到同步5.1 线程创建的三种方式与停止线程的正确姿势创建线程的基础方式有三种继承Thread类、实现Runnable接口、实现Callable接口。第三种和前两种的区别在于任务执行完毕后能返回结果因为它设计成搭配FutureTask使用call方法的返回值会被FutureTask保存后续通过get方法取回。实际项目中继承Thread的方式用得越来越少因为Java的单继承限制了类的扩展而实现接口的方式更灵活也更贴合任务与线程分离的思想。CallableInteger callable () - { // 模拟耗时计算 return 42; }; FutureTaskInteger task new FutureTask(callable); Thread thread new Thread(task); thread.start(); Integer result task.get();我在这里想特别强调如何正确停止线程。Thread的stop方法早就被标记为废弃因为它会直接强制终止线程导致资源无法释放、数据不一致。正确的做法是协作式中断在线程内部定期检查中断标记外部调用interrupt方法发出中断请求。public void run() { while (!Thread.currentThread().isInterrupted()) { // 执行任务逻辑 } }多个线程天生共享进程内的内存和资源因此会面临两个核心问题数据竞争和可见性。数据竞争是指多个线程同时修改同一个变量最终结果取决于线程调度的顺序可见性是指一个线程修改了变量另一个线程未必能立即看到新值。两者的根源都在于CPU缓存和指令重排。synchronized是最基础、也最容易用对的同步方式。它可以修饰方法也可以包裹代码块。修饰实例方法时锁定的是this对象修饰静态方法时锁定的是Class对象包裹代码块时锁定的是指定的对象。锁定的粒度越小并发度越高。经典案例是模拟三个窗口同时卖票public synchronized void sellTicket() { if (ticketCount 0) { Thread.sleep(100); ticketCount--; System.out.println(卖出第 ticketCount 张票); } }如果不加synchronized两个线程可能同时进入方法判断ticketCount大于0然后都执行减一操作导致卖出同一张票。这就是竞态条件。volatile是另一个高频关键字它的作用是保证变量的可见性和禁止指令重排但不保证原子性。最常见的场景是双重检查锁单例模式。DCL双重检查锁定中instance变量必须用volatile修饰因为new Singleton()这一步包含分配内存、初始化对象、把引用赋值给变量三个动作如果不禁止指令重排另一个线程可能拿到一个尚未完成初始化的半成品对象。但volatile不能解决i这种复合操作的原子性问题要保证原子性还得用synchronized或AtomicInteger。死锁是多线程开发中让人最头疼的问题。形成死锁需要四个条件互斥、持有并等待、不可剥夺、循环等待。实际项目中我见过最多的死锁场景是线程A持有了锁1想获取锁2线程B持有了锁2想获取锁1。两个线程互相等待谁都不释放。避免死锁的方法说起来就两句按固定顺序加锁或者使用tryLock带超时时间。加锁顺序规范是治本的方法tryLock是兜底的保命手段。6. IO与NIO体系6.1 字节流与字符流怎么选Java的IO体系非常庞大但只要抓住一条主线就不会乱按数据单位分字节流和字符流按方向分输入流和输出流。InputStream和OutputStream处理字节适合图片、视频、压缩包等二进制数据Reader和Writer处理字符适合文本文件。字符流内部其实也离不开字节它是在字节流的基础上增加了字符编码和解码。这也是为什么在网络传输和文件读写中经常出现乱码问题——源头文件用的是UTF-8程序却用GBK去读字符流在转换时就会出错。处理字符流时我建议统一显式指定字符集比如new InputStreamReader(inputStream, StandardCharsets.UTF_8)不要依赖平台默认编码。使用流时有一个朴素但实用的原则处理文件的人不应该一行一行手动读。缓冲流BufferedInputStream和BufferedReader内部维护了一个字节数组或字符数组减少了与底层IO的交互次数性能提升非常明显。一个直观的体验是用FileInputStream逐字节复制大文件和用BufferedInputStream复制时间能差出一个数量级。原因在于逐字节读取每次都要触发一次系统调用而缓冲流一次读入一大块数据到内存再逐个交给应用层。6.2 NIO的核心思路NIO也就是JDK 1.4引入的new IO核心思想是通道加缓冲区加选择器。传统的BIO是阻塞式的线程发起读取后在数据到达之前一直阻塞。NIO的Channel是双向的既可以读也可以写数据总是从Channel读到Buffer或者从Buffer写到ChannelBuffer内部维护了position、limit、capacity三个关键索引。选择器Selector才是NIO的真正亮点。它允许一个线程管理多个Channel通道监听各通道的读写事件。这在大规模网络连接场景下意义重大BIO模式下每个连接需要一个线程一万个连接就需要一万个线程NIO模式下一个线程可以轮询处理所有连接的事件。这也是Netty这类高性能网络框架的技术底座。不过我要提醒一点NIO的学习曲线相当陡峭如果你目前只是在做文件读写、配置文件解析老老实实用IO流加缓冲类就够了。NIO的价值主要在大量并发网络连接的场景入门时理解它的模型比死磕API更重要。等真正接触到网络编程或需要写高并发服务端时再系统深入也不迟。7. 反射、注解与泛型7.1 反射框架世界的照妖镜反射是Java语言中一个极其特别的能力程序在运行时可以拿到自己的类结构动态创建对象、调用方法、访问字段。这种能力是Spring依赖注入、MyBatis结果映射、JSON序列化库的底层支撑。如果没有反射这些框架几乎不可能以当前的形态存在。获取Class对象有三种方式类名.class、对象.getClass()、Class.forName(全限定类名)。第三种在框架中大量使用因为很多时候类的名字是配置文件里的字符串必须运行时才能知道。拿到Class对象后可以通过getDeclaredMethods、getDeclaredFields、getConstructor等API获取方法、字段和构造器的描述信息配合setAccessible(true)可以访问私有成员。一个典型的反射应用场景是写一个通用的对象转Map工具遍历一个类声明的所有字段把字段名作为key、字段值作为value组装成Map。这个过程不需要知道具体的类是什么任何对象都能塞进这个方法处理。这体现了反射的核心优势代码的可复用性和通用性。反射也有明显的代价。性能上比直接调用慢因为少了编译期的类型检查和JIT的部分优化安全性上setAccessible可能绕过访问控制代码可读性上反射代码往往比直接调用隐晦得多。所以在业务代码中优先使用直接调用反射留给框架和工具类去用。7.2 注解给代码打标签注解本质上是一种元数据它不直接改变程序行为而是给代码打上标记供编译器或运行时去读取和处理。定义一个注解非常简单Retention(RetentionPolicy.RUNTIME) Target(ElementType.METHOD) public interface RequirePermission { String value(); }其中Retention控制注解的生命周期SOURCE只在源码中保留CLASS保留到字节码RUNTIME才能被反射读取。Target限定注解能放在什么位置比如方法、字段、类上。写框架级代码时最常用的是RUNTIME生命周期的注解因为需要运行期通过反射读取并处理。注解本身没有魔法魔法在于谁来读它。Spring的ComponentScan之所以能扫描到bean是因为容器在启动时会去扫描指定包下的类检查是否有对应注解然后创建对象放进容器。理解了这一点再看任何基于注解的框架都会豁然开朗。7.3 泛型擦除与通配符泛型解决的问题是编译期的类型安全。它让集合在编译阶段就约束元素类型避免运行时才暴露ClassCastException。但泛型在运行时会擦除也就是说List 和List 在JVM里都是同一个List类。这个擦除机制带来一个经典陷阱不能直接判断List 的类型因为运行时信息已经丢了。通配符是泛型进阶的难点。? extends T表示某个T的子类型适合读数据? super T表示某个T的父类型适合写数据。这个规律被总结为PECS也就是Producer ExtendsConsumer Super。如果你的方法要从集合中取数据用extends如果要往集合里放数据用super。递归泛型T extends Comparable 在排序场景中很常见例如让排序工具只接受实现了Comparable接口的类。泛型方法在实际业务中也有实用价值比如写一个通用类型转换方法避免到处强转和重复代码。泛型学得好不好直接决定了读框架源码时的体验。如果看到源码里一个个 、?就头疼建议专门花几天啃透这块。8. Lambda与函数式编程8.1 Lambda表达式的本质Lambda表达式的出现让Java从一切皆对象的纯面向对象语言引入了函数式编程的思维方式。看一个典型场景对一个字符串数组按长度排序传统写法是new一个Comparator匿名内部类重写compare方法。Lambda写法只需一行Arrays.sort(arr, (s1, s2) - s1.length() - s2.length());Lambda表达式更直观它省去了匿名内部类的模板代码把焦点放在真正的逻辑上。它的底层实现是invokedynamic指令加函数式接口但入门阶段不需要深究只需要明白Lambda就是一种简洁的函数式语法它适用于只有一个抽象方法的接口即函数式接口。Lambda捕获外部局部变量时要求该变量不能后续被修改即effectively final。原因是Lambda可能延迟执行如果允许变量改变执行时的值和定义时的预期可能不一致容易引入难以排查的并发问题。这在多线程和异步场景中尤为重要。Stream是Lambda最经典的应用场景。Stream不是集合而是对集合的一种高级抽象视图通过链式的中间操作和终止操作完成数据处理。一个典型例子从学生列表中筛选出成绩大于80分的学生按分数排序取前3名收集成新列表students.stream() .filter(s - s.getScore() 80) .sorted(Comparator.comparing(Student::getScore).reversed()) .limit(3) .collect(Collectors.toList());这一段代码如果用传统for循环写需要遍历两次、维护临时列表、写排序逻辑代码要多好几倍。Stream把做什么和怎么做分开了filter、sorted、limit描述的是对数据的操作意图Stream内部决定执行方式。这让代码的阅读负担大大降低调试时也能清晰地看到每一环节的输入输出。使用需要注意的点也很明确Stream不会修改原始数据源每个中间操作都会生成一个新的StreamStream是一次性的用完之后不能再次遍历否则会报stream has already been operated upon并行流parallelStream虽然能加速处理但线程安全、结果顺序、性能开销都需要仔细评估小数据集用并行流反而更慢。9. 学习路径与常见错误清单9.1 我的学习方法建议中级JavaSE不是学完一本书就结束的关键在于用起来。我的建议是走一条输入-实践-输出的闭环路径输入看系统的知识内容最好是一本经典的Java书配合JDK官方文档查细节。实践每学完一个板块做一个20到50行的小练习不要只抄书上的例子要自己改需求。输出把知识点用自己的话写技术笔记或博客写的过程会暴露你理解上的空洞。以多线程为例光看synchronized的原理记不牢。找一个真实的卖票案例强制自己用三种方式写一版再模拟出超卖和死锁观察现象再去查原理记忆会牢固很多。我特别推荐一个笨办法翻JDK源码。不需要从头到尾看遇到不理解的类就去打开源码看它的类注释和关键方法。比如ArrayList源码里的扩容逻辑、HashMap里的putVal方法看完一遍后你对集合的理解会超过大多数停留在会用API层面的开发者。刚开始觉得源码晦涩是正常的先看结构注释和方法签名再看核心逻辑反复几轮就会越来越熟练。9.2 高频错误与避坑清单我把这些年见过的初学者高频错误整理成一张速查表几乎每个学Java的人都至少踩过其中两三个问题类别典型错误正确做法equals和hashCode只重写equals不重写hashCode同时重写保证一致集合使用普通数组临时拼数据按需选用ArrayList、HashSet、HashMap异常处理捕获Exception后吞掉不处理打印日志或抛出带上下文的异常finally在finally中return不要在finally里返回值多线程用stop()停止线程使用interrupt协作式中断单例不使用volatile的DCL双重检查锁必须加volatileIO不关闭资源优先使用try-with-resources泛型运行时判断泛型类型记住类型擦除不要依赖运行时类型Stream重复使用同一个StreamStream是一次性的每次操作新建最后一类让我觉得特别值得提醒的是代码能跑就行的心态。中级阶段和基础阶段的区别在于基础阶段会写就行中级阶段要求能写对、会优化、可维护。每写完一段代码问自己三个问题这段代码如果并发调用会不会出问题资源的生命周期管理好了吗换一个需求场景这段代码要改多少三个问题从多线程、资源管理、设计三个维度倒逼你审视代码这也是中级开发者逐渐走向高级的分水岭。我在带新人时经常说中级JaSE学的不是孤立的新语法而是一整套写靠谱代码的思路。集合选择是数据结构的权衡异常设计是失败预案的考量多线程是资源协调的艺术反射泛型是框架思维的启蒙。把这些串起来Java才算真正入了门。后面再接触Spring这类重量级框架你会发现很多魔法其实都建立在今天学的这些基础之上那时候回头再看你会庆幸这个阶段没有跳过任何一个看似枯燥的知识点。