Java知识地图:从环境配置到集合算法,系统掌握编程基础
很多初学者找我推荐 Java 资料我一般会先反问一句你脑子里有没有一张“知识地图”所谓基础不是背几个概念而是要知道每个知识点放在哪里、解决什么问题、和实际代码之间是什么关系。这篇文章就是按地图的思路整理的从环境配置、语法地基、面向对象到集合、算法、异常、并发每一块都尽量讲透“为什么”而不只是列结论。篇幅偏长建议先收藏再按章节慢慢过。1. 环境配置到编译链路这一关不过后面全是坑1.1 JDK、JRE、环境变量先搞清楚它们之间的关系很多教程上来就让你装 JDK、配环境变量但环境变量到底是什么解释得非常含糊。其实你配置 PATH 的动机只有一个让操作系统在任意目录下都能找到java和javac命令。Windows 的 PATH 是一个目录列表系统执行命令时按顺序搜索找不到就报“不是内部或外部命令”。JAVA_HOME 的作用则是给 Tomcat、Maven、IDEA 这类工具定位 JDK 用的。很多框架的启动脚本都会引用%JAVA_HOME%\bin你不配 JAVA_HOME后面所有基于 Java 的工具都会闹脾气。所以正确的配置顺序是先安装 JDK再设置 JAVA_HOME最后把%JAVA_HOME%\bin追加到 PATH。macOS / Linux 同理把export JAVA_HOME...和export PATH$JAVA_HOME/bin:$PATH写进.bashrc或.zshrc。我见过不少新人配完环境变量后在命令行里敲java -version发现版本不对。这时候第一反应应该是执行where javaWindows或which -a javaLinux看它到底找到的是哪个路径。很多机器上装了多个 JDKPATH 顺序决定了哪个生效这个排查思路比反复重装系统快得多。1.2 “源发行版 17 需要目标发行版 17”到底在说什么这个报错基本属于编译期最高频的怪问题了。它的本质是编译器的 source 参数和 target 参数不一致。javac 可以指定源码的语言版本source也可以指定编译产物的目标版本target两个一打架编译器就直接撂挑子。最常见的现场是IDEA 里 Project Structure 设置的是 Java 17但 Maven 的 pom.xml 里maven.compiler.source还写着 1.8或者反过来。两个版本不一致javac 就报出“源发行版 17 需要目标发行版 17”。排查思路其实很明确先看构建文件pom.xml 或 build.gradle里的编译器配置再看 IDEA 的 Project Structure - Project SDK 和 Language Level保证这三处一致。命令行直接编译的话就用javac -source 17 -target 17显式指定。这个报错不是你的代码写错了而是工程配置错位所以不要拿着代码翻半天。1.3 Lombok 警告注解处理器与编译器版本兼容热词里有一条you arent using a compiler supported by lombok, so lombok will not work我第一次见到是在一次 JDK 升级之后项目看起来还能跑但 getter/setter 全部失效IDE 里到处飘红。原因不复杂Lombok 在编译期通过注解处理器修改抽象语法树AST这对 JDK 编译器内部 API 有依赖。JDK 大版本更新时这些内部 API 可能调整Lombok 版本太旧就跟不上。处理方式按顺序来先升级 Lombok 依赖到适配当前 JDK 的版本再确认 Maven/Gradle 中 dependency 版本是真的生效了有时候是 IDE 缓存了旧的依赖最后检查编译器的-processor路径确认注解处理器确实被加载。这里有个小坑Lombok 报这个警告时项目依然能编译但运行时会冒出诡异的NoSuchMethodError因为 getter/setter 根本没生成。所以看到这个警告别忽略优先处理再继续开发。2. 语言地基标识符规范、运算符优先级与数组边界2.1 标识符命名规则合法性与规范是两码事Java 标识符有硬性规则由字母、数字、下划线、美元符号组成数字不能开头不能是关键字大小写敏感。看起来简单但实操里最常见的问题是有人为了省事用了中文变量名虽然能编译但团队协作时编码风格、文件编码都会出问题不建议。合法之外还有规范。类名用 PascalCase方法和变量用 camelCase常量用 UPPER_SNAKE_CASE。命名是最容易被忽视的基础但直接影响代码沟通成本。你写的时候觉得temp、data、aaa没问题三个月后回来看全都不认识。一个合格 Java 开发命名规范和语法规则是同一层级的东西。2.2 运算符和表达式优先级不用背用规律记Java 运算符优先级从高到低有个大致顺序可以用口诀记忆单目 算术乘除取余优先于加减 关系 逻辑 赋值。赋值号从右到左结合所以a b c能链式赋值条件运算符?:也是从右向左。优先级运算符类型示例结合性高后缀arr[x]、obj.method()从左到右单目x、!flag从右到左乘除取余*、/、%从左到右加减、-从左到右关系、、、从左到右相等、!从左到右逻辑与从左到右逻辑或||从左到右三元? :从右到左低赋值、从右到左真正容易出问题的不是优先级而是隐式类型转换。比如int / int的结果是 int 而不是浮点数5 / 2得到25.0 / 2才是2.5。很多新手写除法时忘了转 double结果算出来的数据全是错的调半天才发现是类型问题。另一个经典陷阱是i和i的区别一个先取后加一个先加后取。放进System.out.println(i i)这种表达式里几乎没人能一眼说准。实际开发中我的建议是不要在一条语句里对一个变量多次自增可读性和可预测性远比“一行秀操作”重要。2.3 数组声明方式、内存布局和越界异常数组在 Java 里是对象。int[] arr new int[10]会在堆上分配连续空间索引从 0 开始。越界抛出的ArrayIndexOutOfBoundsException是RuntimeException的子类编译期不报错运行期才炸。常见场景是循环边界条件写错比如for (int i 0; i arr.length; i)这个“等于号”错误。排查越界异常有一个很稳的经验看报错行号逆向检查每一处访问数组索引的代码。初学者总以为是数组不够大其实多半是边界条件错了。如果是循环里越界检查i arr.length而不是i 如果是二维数组先确认行和列的维度有没有写反。数组相关的另一个问题是内存溢出。当你在循环里不断 new 数组或把大量对象塞进集合而忘了释放引用堆空间耗尽时会抛OutOfMemoryError: insufficient memory。它和ArrayIndexOutOfBoundsException性质不同OOM 是 JVM 级别的资源耗尽越界异常是访问了不存在的索引。理解这个区别排查问题时才能少走弯路。3. 面向对象三件事封装、继承、多态怎么落到真实代码3.1 封装不是加个 private 就完事了很多人的理解是“封装等于私有字段加 getter/setter”但封装的核心不是 private 关键字而是隐藏实现细节、暴露稳定接口。拿一个BankAccount类举例余额字段用 private取款走方法方法内部校验金额、记录日志。如果把字段直接 public外部代码可以把余额改成负数业务规则防线就没了。封装的价值就是让不变量只被受控的方法修改。在代码评审里判断封装好不好的方式很朴素看外部使用这个类时是“通过方法操作”还是“直接摸字段”。如果到处都写.field 说明封装形同虚设。至于 getter/setter如果一个字段确实需要外部读写且没有额外逻辑简单的 getter/setter 没问题但不要给所有字段自动生成一对那只是“表面封装”。等以后这个字段要加校验逻辑时你会发现所有外部调用点都在裸奔。3.2 继承的边界什么时候该用继承什么时候该用组合继承是 is-a 关系组合是 has-a 关系。但工程实践上我更倾向于“组合优于继承”。为什么因为继承会把父类实现细节暴露给子类父类一升级子类可能莫名其妙挂掉这就是“脆弱的基类问题”。举个例子你有一个BaseService封装数据库连接UserService和OrderService都继承它。某天BaseService改了一个方法签名或者给某个 protected 字段加了初始化逻辑所有子类的行为全变了。如果用组合UserService内部持有一个DatabaseHelper的引用显式调用就不会有这种隐式耦合。当然继承也不是完全不能碰模板方法模式、抽象类设计等场景下它是合理的但“为了复用代码而继承”是我最不建议的做法。3.3 多态父类引用指向子类对象到底意味着什么“父类引用指向子类对象”这句话教材里都有但它的价值不在于背而在于让代码依赖抽象而不是依赖具体类。举个例子你有一个Shape抽象类Circle和Rectangle都实现area()方法。在画图系统里你可以维护一个ListShape遍历时直接调用shape.area()运行时自动调对应子类的版本。新增一个Triangle类上层代码一行不用改这就是开闭原则的体现。如果没有多态你只能在循环里写一堆 if-else 判断类型代码膨胀得没法看。这里还要分清楚重写Override和重载Overload。重写是运行期动态绑定是实现多态的机制重载是编译期静态决定属于语法便利。面试爱问这个但对实际开发而言理解重写的目的更重要它让子类能替换父类行为而不改变调用方代码。3.4 equals 与 hashCode为什么必须一起重写为什么 HashMap 的 key 要同时重写 equals 和 hashCode根本原因是哈希表的查找流程先根据 hashCode 定位桶再在桶里用 equals 判断是否相等。如果你只重写 equals 不重写 hashCode两个内容相同的对象会产生不同的哈希值被分到不同桶里HashMap 会认为它们不相等put 和 get 就都会出错。反过来如果 hashCode 相同但 equals 不同那么同一个桶里会堆出长长的链表性能骤降。实用的经验是自定义类型作为 HashMap 的 key 或 HashSet 的元素时一定先想清楚 equals 和 hashCode 基于哪些业务字段。IDEA 和 Eclipse 都有一键生成方法生成的代码会包含你选中的字段。注意生成后不要把字段改来改去如果一个对象的 hashCode 在放入集合后发生变化你在集合里就再也找不到它了。4. 集合框架精拆ArrayList、LinkedList、HashMap 和 Comparator4.1 ArrayList vs LinkedList选型不是看“听说”是看复杂度这两个是 Java 集合里最常被问到的对比。核心区别是底层数据结构ArrayList 是动态数组LinkedList 是双向链表。很多人把应用场景记反了。ArrayList 的优势是按下标随机访问 O(1)尾部增删 O(1)但中间插入或删除需要移动元素 O(n)。LinkedList 的优势是头部或中间插入删除 O(1)前提是你已经持有对应节点的引用但按下标访问是 O(n)需要从头遍历。所以结论很直接频繁按下标取值用 ArrayList。频繁在头部插入删除用 LinkedList。大多数普通业务场景直接用 ArrayList 就好。LinkedList 的节点对象占内存多而且缓存不友好在实际性能上未必比 ArrayList 强。提醒一句LinkedList 实现了Deque接口当你需要队列或双端队列时它是一个合理的选择。这可能是它在真实代码里最合适的用途。4.2 HashMap 的底层流程从 hash 到红黑树从 Java 8 开始HashMap 底层是“数组 链表 红黑树”。插入键值对时先计算 key 的 hashCode再用(n - 1) hash定位桶下标。同一个桶内发生哈希冲突时用链表存当链表长度超过 8 且数组容量达到 64链表会转成红黑树把查询复杂度从 O(n) 降到 O(log n)节点数降到 6 以下时再退回链表。这里有两个关键参数初始容量默认 16负载因子默认 0.75。扩容阈值 容量 x 负载因子。为什么负载因子是 0.75这是一个空间和时间的折中太小了浪费空间太大了冲突增多、查询变慢。实际开发中如果你预估会有大量数据最好在构造时给出合理初始容量避免反复扩容带来的 rehash 开销。遍历删除的坑也很经典用 for-each 遍历 HashMap 时直接调用map.remove(key)会抛ConcurrentModificationException。正确做法是用Iterator.remove()或者直接用 Java 8 的map.entrySet().removeIf(...)一行搞定。4.3 用 Comparator.comparing 把指定元素排到第一位热词里有一条很实际的需求排序时希望某个指定值排到最前其他元素保持原来的顺序。用Comparator.comparing可以这样实现ListString list Arrays.asList(banana, apple, cherry, date); String priority apple; list.sort(Comparator.comparing((String s) - s.equals(priority) ? 0 : 1) .thenComparing(Comparator.naturalOrder()));核心思路是Comparator.comparing里的 keyExtractor 提取一个可比较的 key我们把 key 定义为“是否是目标值”的映射0 表示是目标放前面1 表示不是。这样排序比较器返回负数时目标元素就排到最前其余元素继续按自然顺序排列。利用同一个机制不只可以排到第一也可以排到最后——把目标映射为 1、其他映射为 0 即可。4.4 集合遍历时删除元素一个高频翻车点for-each 遍历 ArrayList 时删除元素同样会抛ConcurrentModificationException。原因是 ArrayList 内部的 modCount 和迭代器内部的 expectedModCount 不一致了。最安全的删除方式是Iterator.remove()或者直接用list.removeIf(...)。Java 8 以后我强烈建议用 removeIf一行代码可读性也高。5. 基础算法手撕冒泡排序与快速排序5.1 冒泡排序写出正确、能优化的版本冒泡排序的思路每一趟把相邻元素比较逆序则交换最大元素像气泡一样冒到末尾。经过 n-1 趟后数组有序。最容易写错的地方是内层循环边界j arr.length - 1 - i每趟结束末尾 i 个元素已经就位不用再比。优化有两个方向面试里都是加分项如果某一趟没有发生交换说明数组已经有序可以提前 break。记录最后一次交换的位置下一趟只需要排到这个位置之前。参考实现public static void bubbleSort(int[] arr) { for (int i 0; i arr.length - 1; i) { boolean swapped false; for (int j 0; j arr.length - 1 - i; j) { if (arr[j] arr[j 1]) { int tmp arr[j]; arr[j] arr[j 1]; arr[j 1] tmp; swapped true; } } if (!swapped) { break; } } }时间复杂度最好 O(n)最坏 O(n²)平均 O(n²)。排序算法里它很难排上用场但非常适合练手和面试考察基础因为代码短、边界条件清晰。5.2 快速排序分治思想的 Java 实现快排的核心是分治 递归选一个基准值把数组分成“小于基准”和“大于基准”两部分再各自递归排序。框架如下public static void quickSort(int[] arr, int left, int right) { if (left right) { return; } int pivot partition(arr, left, right); quickSort(arr, left, pivot - 1); quickSort(arr, pivot 1, right); }关键是 partition 的写法常见有“挖坑法”和“指针交换法”两种都要会写至少一种。这里必须注意基准值的选择如果每次都选最左作为基准而数组已经接近有序递归会严重退化到 O(n²)。更稳妥的做法是“三数取中”取 left、mid、right 三个位置的中位数作为基准可以大幅降低退化概率。快排平均时间复杂度 O(n log n)最坏 O(n²)但因为它的交换操作是局部性的缓存命中率高实际运行速度通常比归并排序快。5.3 从排序看 Java 标准库的设计哲学Arrays.sort()对基本类型数组使用双轴快速排序对引用类型数组使用 TimSort一种优化过的归并排序。为什么要分两种基本类型排序只需要比较值引用类型还要保证稳定性——相等的元素排序后保持原顺序。这个细节说明排序算法的选择不只考虑“快”还要看稳定性和内存占用。学算法最忌讳直接背最优答案。我的建议是从最直观的版本写起跑通后再逐步加优化每一步优化都亲手验证效果这样面试时才能解释清楚为什么这样改、改完有什么收益。6. 异常、泛型、Lambda写出有“职业感”的 Java 代码6.1 异常体系和处理原则别拿 try-catch 当安全网Java 异常分两大类Exception和Error。Error代表 JVM 层面的不可恢复问题比如OutOfMemoryError、StackOverflowError不建议捕获捕获了也处理不了。Exception下面又分受检异常和运行时异常受检异常如IOException编译期强制处理运行时异常如NullPointerException、ArrayIndexOutOfBoundsException不强制捕获。处理原则我总结几条只捕获你确实要处理的异常。catch 之后什么都不做是最糟糕的代码。不要用空 catch 吞异常。哪怕只是log.error()也比不记强。能用明确的异常类就不要用catch (Exception e)一把梭。业务异常如余额不足建议自定义RuntimeException子类由统一异常处理器返回错误信息。实战中“throws Exception”满天飞很常见看起来省事实际上把所有处理责任推给了调用方最后调用方只能 catch 一个大 Exception根本不知道具体出了什么问题。好的做法是服务内部用精确的异常在边界处统一转换成外部接口能理解的错误码。6.2 泛型类型擦除、通配符和常见疑问泛型在编译期做类型检查运行期会被“擦除”成原始类型。比如ListString和ListInteger在运行时的 class 是同一个。类型擦除带来的连带限制有不能通过泛型直接new T()不能创建泛型数组不能直接获取泛型参数的具体类型。这些限制不是设计缺陷而是为了兼容 Java 1.5 之前的旧代码。通配符? extends T和? super T是很多人的盲区。记住一个原则extends 限定的集合只能读不能写super 限定的集合只能写读出来也是 Object。这就是 PECS 法则——生产者用 extends消费者用 super。实际开发中泛型用得最多的场景是定义通用工具类和接口。写的时候要克制不要为了炫技把简单逻辑写成几层泛型嵌套维护成本极高。6.3 Lambda 表达式什么时候能用什么时候不该用Lambda 是 Java 8 引入的函数式编程支持它简化了匿名内部类的写法。合适的使用场景是传给函数式接口比如Comparator、Runnable、Consumer等。一个函数式接口就是只有一个抽象方法的接口可以用FunctionalInterface注解显式标识。写法示例list.forEach(item - System.out.println(item)); list.sort((a, b) - a.compareTo(b));需要注意Lambda 捕获外部局部变量时该变量必须是 final 或 effectively final初始化后不再赋值。否则直接编译报错。原因在于 Lambda 本质上是一个对象它如果要捕获局部变量就必须在对象创建时复制一份值如果变量后面还会变复制出来的值和外部就不一致了。但不建议在所有场景都用 Lambda 写成“一行流”。一长串链式操作看着很酷Debug 时却很
上一篇/下一篇内容由系统自动关联
返回资讯列表 →