Java学习路线图:从环境变量到动态代理的实战笔记
整理旧硬盘时翻出几年前的Java学习笔记A6软皮本扉页写着环境变量中间夹着好几版手绘的HashMap结构图后面还贴着几段用荧光笔标记的lambda示例。说实话大部分内容是当时死记硬背下来的但工作几年后再回头看有个现象很有意思当年笔记里画了重点的知识点和现在面试新人时高频出现的问题重合度非常高。所以我把那份笔记重新梳理了一遍按入门到基础再到进阶与踩坑的顺序整理成这份偏实战的Java学习笔记。它不是教科书更像一张过滤掉冗余信息之后的地图环境变量该不该配CLASSPATH、集合框架哪些细节值得抠、排序算法为什么面试爱考、Java 8之后的新语法怎么用顺手、动态代理到底在解决什么问题以及几个我实际遇到过的诡异报错。既适合刚装好JDK准备系统学习Java的同学也适合工作了两三年想回来补基础的人。1. 环境变量配置不是玄学JAVA_HOME、PATH与CLASSPATH各管什么很多人的Java之旅止步于环境变量。要么配完之后java -version依然提示不是内部或外部命令要么百度一搜出来几十篇教程让人照着抄也不知道自己在抄什么。其实环境变量没那么多玄机核心就三个角色搞清楚它们各自管什么配置就是顺理成章的事。1.1 三个变量各自的职责与记忆方法先说结论JAVA_HOME是家PATH是门牌号CLASSPATH是导航路线。JAVA_HOME指向JDK安装的根目录。它不直接参与命令执行而是给IDE、Maven、Gradle、Tomcat这些程序看的——它们启动时需要找到JDK就会去读JAVA_HOME。把它单独抽成一个变量而不是把JDK路径写死在各个工具里最大的好处是切换版本方便。比如你装着JDK 8和JDK 17两套环境需要切换时只改JAVA_HOME这一个变量就行不用去翻其他工具的配置。PATH需要在后面追加%JAVA_HOME%\binWindows或$JAVA_HOME/binmacOS/Linux。原因很简单你在命令行敲java、javac这些命令时操作系统并不知道这个命令在哪里。它只能按照PATH里记录的目录一个个去找可执行文件找到就执行。bin目录里放着的正是java、javac、jar这些可执行命令所以在PATH里加上这个路径就等于告诉操作系统在这几个地方帮我找命令。CLASSPATH在JDK 1.5之前是必须配的它告诉JVM去哪里找class文件。但现在的JDK版本已经完全不需要手动配置CLASSPATH了编译器会基于当前目录和依赖库自动计算类路径。如果一个教程还让你配CLASSPATH可以直接关掉。面试中偶尔会问到这个概念但实际开发中没人再动它知道历史背景就行。提示JAVA_HOME和PATH值得认真配CLASSPATH不用管。这是现代Java开发的基本认知。1.2 配置过程中最容易翻车的几个细节我在指导新手时发现环境变量配置失败绝大多数不是因为步骤复杂而是栽在细节上。其中一个高发问题是修改完环境变量后没有重新打开命令行窗口。环境变量是在进程启动时读取的不管你是用set命令还是系统设置面板改完之后原来已经打开的终端窗口依然保留旧值只有新开的窗口才会生效。很多人改完直接在当前窗口执行java -version发现还是报错就以为是配置错了实际上只要关掉终端重开一次就好。另一个坑是PATH变量被覆盖。Windows的环境变量编辑器里编辑PATH时如果不小心把原有内容删了会导致很多系统命令失效比如ipconfig、ping都不认了。操作前最好先备份PATH里的内容编辑时尽量使用新建而非编辑在原有路径后面追加%JAVA_HOME%\bin。还有一类问题出在JDK和JRE的混淆上。安装JDK时弹窗里往往还会问要不要安装公共JRE。有些教程会让你勾选有些让你取消。对开发来说JDK自带JRE公共JRE装不装都行装了也不影响什么但如果你在配置时把JAVA_HOME指到了C:\Program Files\Java\jre...而不是jdk目录那java -version能运行但javac会提示找不到命令——很多人卡在这一步就是没分清这两个目录的区别。1.3 配置好之后的验证命令与多版本切换配置完成后需要验证三个命令java -version确认JVM能运行javac -version确认编译器可用echo %JAVA_HOME%macOS/Linux用echo $JAVA_HOME确认环境变量生效且指向正确的JDK目录。如果前两条命令输出版本号但第三条为空说明JAVA_HOME没有配好需要回去检查系统变量。多版本切换是实际开发中经常碰到的事。老项目可能锁定JDK 8新项目用JDK 17。我的做法是电脑上装好多个JDK目录然后单独维护一个开关脚本需要哪个版本就切换JAVA_HOME的指向。Windows下可以准备一个简单的bat脚本echo off setx JAVA_HOME C:\Program Files\Java\jdk-17 /M echo JAVA_HOME switched to JDK 17切换之后记得重开终端。macOS下可以用/usr/libexec/java_home -V列出所有已安装的JDK版本然后通过export JAVA_HOME$(/usr/libexec/java_home -v 17)动态切换比改全局变量干净得多。2. 语法与集合框架学Java最忌讳会用但讲不清语法阶段容易产生一个错觉跟着教程敲一遍程序能跑起来就以为自己会了。真到面试或者自己排查问题时才发现问深一层就答不上来。基础语法里真正值得放慢速度反复消化的其实是面向对象和集合框架这两块因为后续看框架源码、理解设计模式靠的全是它们打底。2.1 面向对象三大特性在内存中长什么样封装、继承、多态理论谁都能背两句但理解要落到内存层面才算数。以多态为例它的底层实现是虚方法表。我们写Animal a new Cat(); a.speak();时编译阶段a的静态类型是Animal编译器按Animal类检查speak()方法是否存在运行阶段JVM根据a实际指向的对象类型Cat去查它的方法表找到Cat重写过的speak()并执行。这就是常说的编译看左边运行看右边。理解了这张表就能明白为什么父类引用调用不到子类独有的方法——编译期检查时就过不了。继承还有一个容易让人懵的点是构造方法的调用顺序。创建子类对象时会先调用父类构造方法再调用子类构造方法一层层往上。所以父类里如果只写了带参构造没写无参构造子类编译会直接报错因为子类构造方法默认会调用父类无参构造找不到就过不去。解决办法是父类显式提供无参构造或者在子类构造方法第一行用super(参数)手动调用父类的带参构造。封装这个特性很多人以为只是加个private就完事。其实封装的目的是控制可变性和暴露边界能不给外部修改机会的字段就不给该用final修饰的就用final修饰。这也是为什么现在写类的第一反应要思考字段是不是该不可变——不可变对象在多线程环境下天然安全不需要加锁。Java里的String就是不可变类的代表面试问String为什么设计成final时答案不止是安全还涉及到字符串常量池的复用机制。2.2 HashMap高频追问从存储结构到扩容细节集合框架里HashMap是被问得最狠的一个。它的底层结构是数组加链表加红黑树数组的每个位置是一个桶bucket元素通过hash值定位到桶当多个元素的hash冲突落在同一个桶里时就用链表串起来当链表长度超过8且数组长度达到64时链表会转成红黑树把查询复杂度从O(n)降到O(log n)。这里有个细节经常被忽略——转红黑树有两个条件链表长度达到8同时数组长度不低于64。如果数组长度还没到64就算链表长度到了8也会先触发扩容而不是直接树化。为什么HashMap的容量总是2的幂次因为计算元素落点用的是(n - 1) hash这个位运算当n是2的幂次时n-1的二进制低位全是1与运算的结果等价于取模但性能比取模高得多。默认容量是16加载因子是0.75意味着元素个数超过16 * 0.75 12时就会触发扩容扩容后数组翻倍到32元素重新分配位置。另外为什么JDK 8之后HashMap的hash要做一次h ^ (h 16)的扰动计算因为很多时候key的hash值低16位变化大、高16位几乎不变如果不做处理参与落点计算的只有低16位冲突概率会明显升高。右移16位再异或目的是让高16位也参与低位运算打散分布。这个细节在看我后来读源码时特别有启发很多看似莫名其妙的一行代码背后都是为了把数据分布得更均匀。2.3 异常与泛型两个背完就忘的知识点异常体系其实不复杂但很多人分不清受检异常和非受检异常。Exception分为两类RuntimeException及其子类属于非受检异常比如NullPointerException、ArrayIndexOutOfBoundsException除此之外的Exception子类属于受检异常比如IOException。受检异常必须显式处理try-catch或throws否则编译不通过非受检异常编译期不强制处理。设计思路上受检异常用于调用方应该知道并处理的可恢复情况非受检异常用于程序本身有bug的不可恢复情况。泛型是另一个看着简单、细想容易出问题的地方。Java的泛型是编译期的语法糖运行时会被擦除。也就是说ListString和ListInteger在字节码层面是同一个类型都是List。所以你不能用list instanceof ListString这样的判断因为运行时String这个类型参数已经被擦掉了而且new T()这种写法也无法编译。理解擦除机制后再看ClassT传参、泛型方法的写法思路会清晰很多。还有个实用技巧:异常处理中不要把整个方法体都包进try-catch。应该只包可能抛异常的那几行并且catch时要尽量捕获具体的异常类型而不是笼统地catch Exception。把异常信息打印到日志时记得要把异常对象传进去而不是只打印e.getMessage()——否则堆栈信息就丢了线上排查问题时会非常痛苦。3. 冒泡排序到手写代码为什么算法题是面试绕不开的坎学Java过程中很难绕开一道经典题手写冒泡排序。很多人不理解现在业务开发又不真让你手写排序面试官为什么要问这种题后来我自己也当了面试官才明白手写排序题考查的根本不是排序本事而是在看你的代码习惯变量命名是否清晰、循环边界会不会写错、有没有优化意识。一个五分钟能写出干净冒泡排序的人写业务代码的基本功大概率也不会差。3.1 为什么练手要从排序开始排序算法是最适合练手代码的题材。它不依赖任何外部框架逻辑足够复杂到能锻炼循环、判断、数组操作但又不会复杂到让人放弃。冒泡排序又是排序里最简单直观的思路一句话就能讲清楚从前往后依次比较相邻元素如果前面的比后面的大就交换一轮下来最大的元素就冒到了末尾重复n-1轮所有元素就都有序了。我之前带过一个零基础的朋友学Java第一周让他把冒泡排序、选择排序、插入排序各写三遍不用抄写完一遍隔几小时再写一遍。他一开始觉得无聊但两周后他开始能自己写一些简单的双层循环逻辑了。原因很简单排序算法同时训练了循环不变式的思维和边界敏感的直觉这两个能力对写业务代码至关重要。3.2 冒泡排序的两种写法和一次优化最基础的实现是这样public static void bubbleSort(int[] arr) { int n arr.length; for (int i 0; i n - 1; i) { for (int j 0; j n - 1 - i; j) { if (arr[j] arr[j 1]) { int temp arr[j]; arr[j] arr[j 1]; arr[j 1] temp; } } } }内层循环的n - 1 - i是关键的边界条件。每一轮结束后最后i个元素已经有序不需要再参与比较所以内层循环的上界要减去i。如果把n - 1 - i写成n - 1排序结果依然是正确的但会多做很多无意义的比较——这就是最简单的优化也最能看出一个人对循环边界的理解是否到位。再进一步可以在每轮循环开始时加一个布尔标记记录本轮是否发生过交换。如果某一轮比较下来一个交换都没发生说明数组已经有序直接跳出循环public static void bubbleSortOptimized(int[] arr) { int n arr.length; for (int i 0; i n - 1; i) { boolean swapped false; for (int j 0; j n - 1 - i; j) { if (arr[j] arr[j 1]) { int temp arr[j]; arr[j] arr[j 1]; arr[j 1] temp; swapped true; } } if (!swapped) { break; } } }加上这个优化后最理想的情况数组已经有序时间复杂度从O(n²)降到了O(n)。这个优化在面试中是非常加分的细节因为它说明你不仅会背代码还考虑到了输入的特殊情况。3.3 手写排序时面试官真正想看的东西除了正确的排序结果面试官会在意几个点。第一是稳定性如果两个相等的元素排序前后相对顺序不变这个排序就是稳定的。冒泡排序只在arr[j] arr[j1]时交换相等时不交换所以它是稳定排序。第二是空间复杂度冒泡排序是原地排序没有使用额外数组空间复杂度O(1)。第三是上面说的优化会不会在数组有序时提前退出。还有一道几乎必问的变体冒泡排序的时间复杂度。答案分三档——平均和最坏情况是O(n²)最好情况优化后且数组有序是O(n)。这个回答如果能在30秒内干净利落地说完就是加分。如果支支吾吾地想半天说明对算法本身还是不够熟。4. Lambda与StreamJava 8留下的分水岭Java 8发布的时候很多人是用新语法的心态去看Lambda的。但真正上手以后会发现Lambda和Stream带来的不只是语法变化更是一种从外部迭代到内部迭代的思维转换。这个转换没完成之前看别人的代码总觉得像天书一旦跨过去很多集合处理的代码写起来就干净得多了。4.1 Lambda的语法本质不是简写是行为参数化Lambda的本质可以理解成一段可以传递的代码块。以前我们要传一个行为给某个方法只能通过匿名内部类先new一个接口实现再在里面写方法。比如启动一个线程// 匿名内部类的写法 Runnable task new Runnable() { Override public void run() { System.out.println(Hello); } }; // Lambda的写法 Runnable taskLambda () - System.out.println(Hello);Lambda的语法极其简洁左边是参数列表中间是箭头右边是方法体。参数类型通常可以省略因为编译器能从上下文推断出来单个参数可以省略圆括号方法体如果只有一行表达式可以省略大括号和return关键字。这些省略规则不需要死记多写几次自然就内化了。但Lambda不是万能的它有一个前提目标类型必须是函数式接口——也就是有且仅有一个抽象方法的接口。Runnable是Comparator是Callable也是。为了保险可以在接口上加FunctionalInterface注解编译器会帮你检查这个接口确实只有一个抽象方法如果写了两个就编译报错。4.2 Stream流式操作的使用体会小心惰性求值Lambda最常配合的是Stream API它把集合处理变成了一条流水线。用一个实际例子感受一下从一个用户列表中筛选出年龄大于18岁的用户取出他们的名字放到一个List里去重并排序。ListUser users getAllUsers(); ListString names users.stream() .filter(user - user.getAge() 18) .map(User::getName) .distinct() .sorted() .collect(Collectors.toList());如果不用Stream这段逻辑至少得写一个for循环加一个中间List还需要处理去重和排序的麻烦。Stream的写法描述的是做什么而不是怎么做代码可读性明显上一个台阶。刚用Stream时最容易踩的坑是惰性求值。Stream的中间操作比如filter、map、sorted并不会立即执行它们只是记录了一个操作链只有当遇到终止操作比如collect、forEach、count时整个链条才会真正执行。这意味着如果你写了一个Stream但最后忘了加终止操作代码不会执行也不会报错。这种坑在排查问题时特别隐蔽——日志打不出来断点也进不去别人一看才发现是缺了终止操作。4.3 方法引用与lambda的互转方法引用是Lambda的一种简化写法用::符号表示把某个方法作为函数传过去。上面的User::getName等价于user - user.getName()。它们的对应关系有四种常见情况静态方法引用ClassName::staticMethod比如Math::max实例方法引用instance::method比如list::add特定类型的方法引用ClassName::instanceMethod比如String::length构造引用ClassName::new比如ArrayList::new初学阶段不用急着把所有代码都改写成方法引用完全可以用Lambda先表达出来。等看多了项目代码自然会发现方法引用的写法更紧凑。我个人经验是方法重名多或者引用逻辑复杂时用Lambda反而更清晰逻辑简单时方法引用读起来更舒服。具体怎么选没有对错关键是代码要让人一眼看懂。5. 动态代理从反射到Spring AOP的必经之路动态代理在Java里是个很特殊的知识点面试问得深工作中用得多但很多人学的时候一头雾水——动态代理到底动态在哪里为什么要这么绕其实把它拆成三层看就清晰了最底层是反射中间层是JDK动态代理和CGLIB最上层才是Spring AOP。5.1 反射是理解动态代理的前提反射是程序在运行时检查和操作类的能力。正常情况下我们是先有类再new出对象反射则是反过来拿着一个Class对象回溯出这个类的构造方法、字段、方法并调用它们。Class? clazz Class.forName(com.example.UserService); Method method clazz.getMethod(sayHello, String.class); Object obj clazz.getDeclaredConstructor().newInstance(); method.invoke(obj, world);这一段代码在编译时不依赖UserService类的定义完全靠运行时动态加载和调用。这种运行时才确定的灵活性正是动态代理的基石——代理要拦截某个方法必须先能在运行时拿到这个方法、调用它、并在前后插入额外逻辑。5.2 JDK动态代理与CGLIB的原理对比JDK动态代理的核心类是Proxy和InvocationHandler。用法是实现一个InvocationHandler接口在invoke方法里写增强逻辑然后通过Proxy.newProxyInstance生成代理对象。public class LogHandler implements InvocationHandler { private final Object target; public LogHandler(Object target) { this.target target; } Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { System.out.println(before method: method.getName()); Object result method.invoke(target, args); System.out.println(after method: method.getName()); return result; } } UserService proxyService (UserService) Proxy.newProxyInstance( UserService.class.getClassLoader(), new Class[]{UserService.class}, new LogHandler(new UserServiceImpl()) );JDK动态代理有一个硬性要求目标类必须实现至少一个接口代理对象是动态生成的接口实现类。如果类没有实现任何接口JDK动态代理就无能为力这时需要CGLIB。CGLIB的原理是生成目标类的子类在子类里重写目标方法所以不能用final修饰目标类和目标方法——final类不能被继承final方法不能被重写。Spring AOP的代理策略在Spring Boot 2.x之后默认使用CGLIB因为很多新代码根本不写接口只写一个Service类。这也解答了一个常见的面试题为什么Spring Boot 2.x默认用CGLIB而不是JDK动态代理了。理解了两种代理的底层原理这个答案完全不用背自己就能推导出来。5.3 动态代理到底用在哪些地方很多人学完动态代理还是觉得它没什么用其实是没把动态代理和框架联系起来。最典型的应用是Spring AOP事务管理、日志记录、权限校验都是通过代理在方法执行前后插入横切逻辑而不是侵入式地写在每个业务方法里。再往远了看MyBatis的Mapper接口为什么只写一个接口方法就能执行SQL是因为MyBatis在启动时对Mapper接口生成代理对象调用方法时动态绑定SQL并执行。Retrofit也是同样的套路——定义一个接口方法加上注解框架通过动态代理生成实现把方法调用转换为HTTP请求。明白了原理之后再看到接口注解的框架设计思路都会心一笑底层就是这个代理机制。6. 三个真实踩坑记录Redis自增、Lombok报错与NoClassDefFoundError学习阶段踩的坑大多是语法问题好解决。真正让人头疼的是那些编译能过、一跑就炸的运行时问题以及换个JDK版本就报错的构建期问题。我把三个印象最深的记录写在这里每个都是真实排查过程而不是直接给结论希望帮你省下回头查资料的时间。6.1 RedisTemplate.increment()报not an integer or out of range的完整排查链路现象用redisTemplate.opsForValue().increment(key)给一个计数键加一结果抛异常ERR value is not an integer or out of range。第一反应是肯定有人在redis里放了一个非数字的值但用Redis客户端查看时键对应的值看起来完全正常。排查链条是这样走的。第一步查看键的类型type {key}返回string类型没问题。第二步查看值内容get {key}返回1看起来也没问题。第三步手动执行incr {key}居然成功。这就奇怪了——同样的key和值客户端命令能成功Java代码却报错。问题出在序列化器上。Spring Data Redis的RedisTemplate默认使用JdkSerializationRedisSerializer存入redis时会先把value序列化成二进制字节再用十六进制字符串存储。你看到redis里的值可能是类似于\xAC\xED\x00\x05t\x0041这样的二进制。但increment()方法并不是先读出来、在Java层面加一、再写回去它是直接把incr命令发给Redis去执行Redis处理incr时要求value必须是纯数字字符串遇到二进制序列化结果自然就报not an integer or out of range。解决方式也很明确要么用StringRedisTemplate它对key和value都使用StringRedisSerializer存进去的就是纯字符串要么在自定义RedisTemplate时把value的序列化器统一设置为GenericJackson2JsonRedisSerializer或StringRedisSerializer。这里最需要注意的细节是StringRedisTemplate是RedisTemplate的子类但它重新指定了所有序列化器。如果同一份数据一会儿用StringRedisTemplate写、一会儿用默认RedisTemplate读就会出现数据格式不一致的问题所以一个项目里对同一类key的读写要固定使用同一套序列化方案。6.2 Lombok提示You arent using a compiler supported by lombok这个报错一般发生在你把JDK升到一个较新版本或者打开一个老项目时。完整报错是java: You arent using a compiler supported by lombok, so lombok will not work with your project.。Lombok的工作原理是在编译器处理注解时修改抽象语法树在编译期帮你生成getter、setter、builder等方法。这个操作依赖Lombok对被编译的javac版本有明确识别与支持一旦编译器版本比Lombok支持的版本更超前Lombok就没有办法识别了就会抛出这个报错。排查思路先看pom.xml或build.gradle中lombok的版本如果是1.18.20之前的版本大概率不支持JDK 17以上的编译器。最简单的解决办法是升级Lombok到较新版本新版本通常会在几周内适配新发布的JDK。另一个容易忽略的点是IDE内置的Lombok插件版本有时Maven依赖已经升级了但IDE自身的注解处理插件还是旧版也会出现类似报错。升级Lombok版本后记得在IDE里做一次Rebuild Project清掉旧的编译缓存。我在实盘改造项目中遇到过一种更刁钻的情况Maven编译通过但IDE里代码一直标红。后来发现是Maven用的JAVA_HOME和IDE配置的JDK不是一个版本——Maven用JDK 17去编译最新Lombok支持IDE用JDK 21而Lombok版本尚未支持JDK 21。两边各自看不到完整上下文排查了很久才定位到是两套编译路径的JDK版本不一致。这也是我一直强调环境变量统一的原因。6.3 从NoClassDefFoundError看JDK版本变更的连锁反应热搜里有个问题java.lang.NoClassDefFoundError: java/applet/Applet。这个报错的诡异之处在于编译期完全正常运行时却找不到类。java.applet.Applet是Java早期用于浏览器插件的Applet APIJDK 9开始这个模块被标记为废弃并最终移除。如果一个老项目的依赖树里藏着一个间接引用Applet的第三方库在JDK 8上运行没问题一迁移到JDK 9就报NoClassDefFoundError。排查这类问题有一个标准流程先用mvn dependency:treeGradle项目用gradle dependencies查看整个依赖树找出哪个依赖传递引用了已移除的API然后通过exclusions排除掉这个传递依赖或者升级到不再依赖该API的新版本库。有时还需要用jdeps工具分析jar包对已移除模块的引用——JDK自带的分析工具用法是jdeps --check-deps your.jar能列出jar包引用了哪些JDK模块对排查这种兼容性问题很有帮助。这类问题背后有一个更深层的习惯值得养成升级JDK版本时不要光看自己写的代码能不能编译通过还要跑一遍完整的测试并且关注第三方依赖与JDK新版本的兼容性说明。很多诡异报错本质都是旧依赖碰上新运行时导致的。7. 我对Java学习路线的复盘如果把这份笔记浓缩成一张路线图我自己的学习经历可以分成四个阶段。每条路线的核心内容和检验方式都不一样放在这里供你参考也方便你对照自己正处于哪个阶段。阶段核心内容建议时长检验标准入门环境、语法、面向对象、数组、流程控制2~3周能独立配置环境写完百行以内的控制台小程序基础集合框架、异常、IO、多线程、JVM内存模型1~2个月能讲清HashMap底层结构能排查并发报错进阶泛型、注解、反射、动态代理、Lambda、Stream、设计模式1个月能手写简单动态代理能读懂常见框架哪些环节用到了代理生态与实战MySQL、Redis、Spring Boot、中间件、项目实战持续能独立完成一个带数据库和缓存的Web接口并处理线上报错7.1 一条能落地的路线与你可能走的弯路第一阶段最容易踩的坑是教程收集癖。收藏了几十篇环境配置教程、买了一堆书但实际代码一行没写。我的建议很朴素环境配完就写代码哪怕是打印九九乘法表、写一个猜数字游戏都比看完教程再做练习强。这个阶段的核心目标是让手指记住语法而不是让眼睛觉得我懂了。基础阶段最容易犯的错误是只背知识点不读源码。很多人面试时把ArrayList和LinkedList的区别背得滚瓜烂熟但被问到ArrayList扩容逻辑就卡住了。其实这个问题不用背打开IDE点进ArrayList源码看到grow()方法里的int newCapacity oldCapacity (oldCapacity 1);就知道是扩容1.5倍。源码是学习集合框架最好的教科书没有之一。进阶阶段对动态代理的学习则不要停留在概念——照着写一个InvocationHandler打印方法名和耗时观察代理对象在方法调用前后做了什么比背十篇原理帖子都管用。7.2 不同阶段对应的自检方法每个阶段学完建议做一次你能给别人讲清楚吗的自检。方法很简单找一个完全不懂Java的朋友尝试给他讲明白你最近学的知识点。讲的过程你会发现很多以为懂了但说不清楚的地方这些卡壳的点就是你的薄弱环节。基础阶段的自检方式是阅读源码。能不能说清楚HashMap一次put操作的完整链路计算hash、定位桶、判断链表、是否需要扩容、要不要树化进阶阶段的自检方式是做小项目。给自己定一个目标写一个带MyBatis和Redis的Spring Boot接口查询一次用户信息并写入缓存。做完这个动态代理、序列化、连接池这些概念才算真正长在你身上了。最后再分享几个我在实际操作中的体会第一环境变量配置和IDE配置不是一劳永逸的事。每换一台电脑、每换一个JDK版本都值得花十分钟重新确认一遍省得问题出现时怀疑人生。第二读源码和写笔记是我见过回报率最高的学习方式但做笔记不是抄代码而是记录为什么比如为什么HashMap用2的幂次为什么JDK动态代理要基于接口。我当年的A6小本子能翻到现在靠的也是这些带着思考的批注。第三如果遇到报错先静下心把报错信息完整读三遍再看堆栈的前十行做好准备定位方向比直接复制粘贴去搜索更有效率。Java这条路没有捷径但如果你愿意把基础的地基打扎实后面的框架、中间件、架构相关内容学起来会顺畅得多。