很多人一搜“Java基础知识”,搜到的多半是那种“超详细总结”“全覆盖思维导图”,动辄上万字,看到最后反而不知道重点在哪。我在这个行业写了近十年代码,也带过不少新人,越来越觉得所谓“基础”,真正值钱的不是那些背下来的名词,而是你在写每一行业务代码时,能不能下意识地做出正确的选择——变量用对了吗?这个对象能不能复用?HashMap 和 TreeMap 换着用会不会出事?异常到底是吞掉还是抛出去?线程该加什么锁?这些问题没有标准答案,但一定有一套经得起追问的底层判断逻辑。
这篇文章不打算按教科书顺序给你罗列语法,而是从我实际的开发经历出发,把 Java 基础里最容易踩坑、面试最容易追问、日常代码最高频出现的几个大块拆开讲透。适合两类人看:一类是刚开始学 Java、被术语和框架绕晕的新手,另一类是写过一阵子业务代码、想回头把基础补扎实的开发者。我会尽量说人话,也会把一些只有实际写代码才会碰到的细节讲出来。
1. 从 JDK 到第一个能跑的程序:环境里藏着第一道坎
很多人觉得 Java 基础就是从变量、循环学起,环境搭建这种东西“一会儿就搞定”,不值得花时间。实际上我带过的实习生里,至少有三分之一卡在环境问题上——不是 JDK 装不上,而是装了之后完全不知道自己装的是什么,出了错也看不懂日志。环境这块不花五分钟搞清楚,后面学什么都像在沙子上盖楼。
1.1 JDK、JRE、JVM 到底是什么关系
先把这个最基础也最容易被问懵的关系说清楚。
JDK(Java Development Kit)是开发工具包,给写代码的人用的,里面包含编译器javac、调试工具、文档生成工具,以及一个完整的 JRE。JRE(Java Runtime Environment)是运行环境,给跑 Java 程序的人用的,里面装着类库和 JVM。JVM(Java Virtual Machine)是 Java 虚拟机,真正执行字节码的那个“引擎”。
你可以这样理解:JDK 是“厨房全套设备带菜刀砧板”,JRE 是“能加热半成品饭菜的微波炉”,JVM 是“微波炉里的加热管”。你写代码需要 JDK,用户跑你的程序只需要 JRE,而 JVM 是整个机制的核心——Java 之所以能“一次编写,到处运行”,靠的就是 JVM 把字节码翻译成当前机器的指令。
面试里常问的“JDK 和 JRE 的区别”其实就一句话:JDK 包含 JRE,JRE 包含 JVM,JDK 是给开发者用的,JRE 是给普通用户用的。
这里有个实操层面的建议:如果你只是在学习,装 JDK 就够了,不用单独装 JRE,因为 JDK 自带 JRE;但如果你想在服务器上跑一个打包好的 jar 包,只装 JRE 会更轻量,攻击面也小。
1.2 环境变量配置的细节:Path 和 JAVA_HOME 不是一回事
Windows 上装完 JDK,教程都会让你配两个环境变量:JAVA_HOME和Path。但很少有人解释为什么两个都要配。
JAVA_HOME是告诉系统“JDK 装在哪个目录”,很多开发工具(比如 Maven、Tomcat、IDEA)启动时会去读取这个变量找 JDK 位置。Path则是告诉系统“可执行文件在哪里”,这样你在命令行里直接敲java或javac就能找到对应的命令,不用每次写全路径。
有的教程还会让你配一个CLASSPATH,这个在 JDK 1.5 之后其实已经不需要手动配了。早期的 Java 版本需要靠它指定类搜索路径,现在编译器会从当前目录和依赖库中自动加载。如果你在网上看到老教程还在教配CLASSPATH,可以直接跳过,那属于历史遗留写法。
再说一个常见坑:装完 JDK 之后在命令行里输java -version提示“不是内部或外部命令”,绝大多数是因为Path配的是 JDK 的根目录,而不是bin目录。正确写法是%JAVA_HOME%\bin。也别把bin之前的一级配错,比如配成C:\Program Files\Java\jdk-17\bin才能生效,如果你只配到C:\Program Files\Java\jdk-17,命令同样找不到。
1.3 命令行编译运行:不用 IDE 你也得会这一套
现在很多人从 IDEA 或其他集成开发环境入门,点一下运行按钮就能出结果,这是好事,但也导致一个隐性短板:对编译运行过程缺乏直觉。IDE 帮你掩盖了所有中间步骤,一旦出现类路径异常、打包失败,你连排查方向都没有。
至少一次,请打开命令行,手动走一遍完整的 Java 编译运行流程。新建一个Hello.java,内容就写最简单的main方法。然后执行:
javac Hello.java java Hello第一行javac会把.java源码编译成.class字节码文件,第二行java会启动 JVM 加载这个字节码并运行。
这里经常有人犯的错是运行java Hello.class,这不对。java后面跟的是类名,不是文件名。如果你写的类带了包名,比如package com.demo;,那运行命令就得写java com.demo.Hello,而且要保证从包结构的最外层目录往下执行。这个细节我见过不少工作两三年的同事偶尔也会犯迷糊。
还有一点值得知道:命令行里的中文编码问题。如果你的源码里有中文,javac默认按平台编码读取,Windows 上通常是 GBK,而你的文件是用 UTF-8 存的,就会报“编码 GBK 的不可映射字符”。解决办法是编译时显式指定编码:
javac -encoding UTF-8 Hello.java这个参数在你以后用脚本批量编译或者写 CI 流程时非常实用,别等到中文乱码才想起来。
2. 数据类型与控制结构:变量的“边界感”比概念重要
数据类型这一节,几乎所有教材都会先让你背八大基本类型:byte、short、int、long、float、double、char、boolean。背这个没错,但实际开发中真正需要用到的,是对“边界”和“转换”的敏感。
2.1 基本类型和引用类型的分水岭在哪
基本类型直接存值,引用类型存的是“指向对象的地址”。这句话如果只是背诵,那价值不大,它的价值体现在两个具体场景里。
第一个场景是==比较。整型用==比较没问题,因为比较的是值;但如果你用==比较两个Integer对象,行为就有点绕。看这段代码:
Integer a = 127; Integer b = 127; System.out.println(a == b); // true Integer c = 128; Integer d = 128; System.out.println(c == d); // false同样是用==,结果却不一样。原因在于Integer内部有一个缓存池,默认缓存了-128到127之间的对象。a和b都在缓存范围内,直接指向同一个对象,所以==为 true;c和d超出缓存范围,各自 new 了对象,==比较的是地址,自然为 false。这段逻辑面试考烂了,但实际写代码时,千万不要依赖这个缓存行为,Integer之间比较统一用equals(),或者拆成基本类型intValue()再比较,就不会踩这种玄学坑。
第二个场景是参数传递。Java 只有值传递,没有引用传递——这个说法我在无数面试里问过,能答对的不到一半。具体来说,传基本类型时传递的是值的副本,你在方法里改参数不会影响外部变量;传引用类型时,传递的是“地址”的副本,你用这个地址去修改对象内容,外部能看到变化,但你重新给参数赋值新的对象,外部不会受影响。换句话说,你拿到的是一把钥匙的复制品,你用钥匙开门里面的东西能变,但你换一把钥匙,原来的锁一点都不会变。
2.2 String、StringBuilder、StringBuffer 的选择逻辑
String 是引用类型里最特殊的一个,它在 Java 里的出场率极高,相关的“为什么 String 是不可变的”也是面试高频题。我的理解是:String 不可变首先是为了安全,比如你作为 key 放进 HashMap,如果 String 可变,hashCode 就得跟着变,整个 Map 就乱了;其次是为了字面量池的复用,比如String s1 = "abc"; String s2 = "abc";这两个变量指向同一个常量池对象,省内存;最后是不可变对象天然线程安全,不需要加锁。
但不可变带来一个性能问题:大量拼接时会生成很多中间对象。比如在循环里不断用+拼接字符串,每次拼接都会新建一个 String,效率很低。这个时候应该用StringBuilder。StringBuffer则是线程安全的版本,方法加了synchronized,但代价是性能差一些。
我个人的选择标准很简单:单线程环境一律用StringBuilder,多线程环境下如果真需要共享可变字符串,才考虑StringBuffer,但实际情况里多线程共享可变字符串的频率极低,大部分场景用线程封闭或者干脆用不可变的String就够了。如果用了 Java 8 以上的版本,也可以用StringJoiner或 Stream 的joining,代码可读性更好,我自己很常用这个。
2.3 循环和分支里最容易影响性能的写法
控制结构看起来人人都会写,但细节处很见功底。举个例子,switch和if-else的选择:分支数量多的时候,switch在编译期会生成跳转表,比一串if-else更高效,代码也更整洁。但要注意switch的穿透问题——每个case后面不写break就会一直往下执行,这既是历史包袱也是一把双刃剑,有时候你故意利用穿透合并逻辑(比如多个 case 处理同一段代码),但大多数时候它是 bug 源头。
另一个细节是循环里的条件判断。假设你要遍历一个List,最忌讳的写法是:
for (int i = 0; i < list.size(); i++) { // ... }每次循环都会调用一次size()方法。虽然现代 JIT 可能会优化,但对于很大的集合,明确地把size存到局部变量里是更好的习惯。更推荐的写法是使用增强 for 循环或者Iterator,也顺便解决了并发修改时抛出ConcurrentModificationException的一些困扰。切记不要在增强 for 循环里直接list.remove(),这个坑后面讲到容器时再细说。
3. 面向对象的核心:不是用继承强行套关系,而是用接口划定契约
面向对象(OOP)是 Java 的根,但很多教材把它讲成了“抽象、封装、继承、多态”四个名词解释。我这些年写下来的感悟是:面向对象的关键不是你会不会背这四个词,而是你在设计类结构时,有没有把“变化”和“稳定”分开,把“做什么”和“怎么做”分开。
3.1 封装:私有化只是手段,隐藏变化才是目的
private字段加 getter/setter 是封装,但封装的真正意义是“隐藏实现细节,把变化关在门内”。举个例子,你写了一个订单金额计算的类,里面金额字段用BigDecimal,将来如果改成long存储分,外部调用方不应该感知到任何变化——这是封装的收益。
但我也见过很多“过度封装”的代码:类里所有字段都提供 getter 和 setter,连一个内部计数器都要暴露出去改。这样写让类的内部状态完全裸露,跟直接把字段设成public没什么区别,还多了几行模板代码。正确的做法是:尽量少暴露可修改的入口,能只读就只读,能不可变就不可变。这一点上record(Java 16 正式引入)帮了大忙,定义数据载体类型的类时,可以用 record 一行搞定不可变语义,代码干净很多。
3.2 继承:组合优先于继承,这是实战逼出来的规矩
“组合优于继承”是 OOP 里非常经典的一条设计原则。它的意思很直白:能用“包含一个对象”实现的复用,就不要用“继承一个类”实现。继承是一种非常强的耦合关系,子类会继承父类的全部实现细节,父类改动一个不相关的方法,可能悄悄影响子类行为。组合则是你持有另一个对象,只调用你关心的接口,耦合面小得多。
举一个我自己重构过的例子:之前有个类叫BaseExporter,里面既做了数据读取、格式转换、加密,又做了日志上报。新来一个需求要导出的数据要额外脱敏,开发直接继承了BaseExporter重写了读取方法,结果发现父类构造时机、加密顺序等被全盘打乱。后来我用组合重写:一个Exporter类持有DataFetcher、DataMasker、DataEncryptor几个组件,按需调用,改动彼此隔离。从此再也没因为“改一个导出格式”而连带弄坏加密逻辑。
所以我的建议是:继承关系一定要满足严格的”is-a”,比如Dog extends Animal这种,拿不准的时候一律选组合。想让多实现多个能力怎么办?用接口组合。
3.3 多态:重写和重载,面试必问但总有人卡壳
这两个名字相近,实际含义完全不同。
重写(Override)发生在父子类之间,子类用相同的签名重新实现父类的方法,运行时由 JVM 根据实际对象类型决定调用哪个版本,这是多态的核心机制。重载(Overload)发生在同一个类里,方法名相同但参数列表不同,编译期就能决定调用哪个版本,这事严格来说跟多态关系不大。
实际编码里最有用的多态场景是:方法参数声明为父类或接口类型,调用时传入具体的子类或实现类。比如方法接收一个List<String>,你传ArrayList还是LinkedList都能跑,方法本身不需要知道具体实现。这种写法是解耦的基础。
3.4 接口与抽象类:Java 8 之后,边界变模糊了
Java 8 之前,接口里只能有抽象方法和常量,抽象类里可以有成员变量、构造方法、普通方法。Java 8 给接口加了default方法和static方法,正好是“接口从纯契约变成含实现的契约”的分水岭。Java 9 又加了私有方法,接口甚至可以在内部复用一些逻辑了。
那场景上怎么选?我说说自己的判断逻辑:
- 如果你想描述“能做什么”的能力契约,允许一个类同时实现多个不同能力,用接口。
- 如果你想沉淀部分公共实现、并放置共享状态(比如公有的成员变量),用抽象类。
- 接口的
default方法是用来做平滑升级的,比如给一个已发布的接口加新方法时,加上 default 可以避免所有实现类被迫改动。但新设计的接口,我不太建议用 default 大段堆业务逻辑,那样接口的“契约感”会被稀释。
4. 容器是“选型题”,不是“背诵题”
Java 容器(集合框架)大概是面试里占比最大的基础模块,也是实际开发中用得最密集的工具集。但我不建议一口气背完所有容器的原理再去用,顺序应该是反过来:先知道这个东西能解决什么问题,在业务里遇到场景了再用,用出错题了再回头查原理。
4.1 从 ArrayList 和 LinkedList 的差异看“根据场景选容器”
ArrayList 底层是数组,LinkedList 底层是双向链表。教科书会告诉你:数组随机访问快,链表插入删除快。这句话出过太多误导,因为“插入删除快”是有前提的——在已知节点位置的前提下,链表插入确实只需要改变指针;但如果你靠索引去定位,比如list.add(1, element),LinkedList 需要从头或从尾遍历到索引位置,代价同样很大。
实际开发中 99% 的列表场景我都用 ArrayList,原因是它对 CPU 缓存友好,内存连续,遍历能力极强。LinkedList 的典型优势场景其实很少,比如你有一个队列,需要频繁在头部插入尾部移除,那它确实合适,但这种情况很多人会直接选择ArrayDeque,后者的综合性能更好。
记住一句话:选择容器不是看单个操作的上限,而是看你的整体访问模式。频繁按索引随机访问选 ArrayList,频繁在两端操作选 ArrayDeque,只有当你需要频繁在链表中间插入且已经持有节点引用时,LinkedList 才有真正的用武之地。
4.2 HashMap 的底层逻辑:从数组+链表到红黑树的演变
HashMap 是 Java 容器里的“天王级话题”,面试几乎没有不问的,工作里最容易出 bug 的也集中在这。
JDK 1.8 之后,HashMap 的底层结构是“数组 + 链表 + 红黑树”。先说数组:每个桶位置通过key.hashCode()再经过一次扰动函数计算出的 hash 决定。hash 相同或者碰撞时,同一个桶里的元素以链表形式挂起来,查找退化为链表的线性遍历。当链表长度超过阈值 8,同时数组长度大于 64 时,链表会树化成红黑树,把查找复杂度从 O(n) 降到 O(log n)。
这个设计的本质是防止 hash 碰撞极端情况下的性能恶化。注意阈值 8 不是“一超过就立刻树化”,它还要求数组长度先达到 64,否则优先扩容而不是树化。这些细节如果只看别人总结的“HashMap 八股文”,很容易记混,建议自己打开源码把putVal方法过一遍。OpenJDK 的源码不复杂,配合断点跑一跑,比死记硬背强十倍。
再说几个实战里最容易踩的坑:
第一,HashMap 不是线程安全的。多线程同时 put 导致扩容时,JDK 1.7 在某些条件下可能形成环形链表,get 时死循环;JDK 1.8 修复了这个死循环问题,但数据丢失、覆盖、size 不对的问题依然存在。并发场景用ConcurrentHashMap,不要自己给 HashMap 加裸锁。
第二,自定义对象做 key 时,一定要重写equals()和hashCode()。不重写的话,即使两个对象内容完全相同,hashCode 不同,get还是找不到值。重写时要保证:equals 相等的两个对象,hashCode 必须相等;反过来不要求。这是约定,违反了他你的 Map 就会表现得很诡异。
第三,不要在遍历 HashMap 时直接做 remove 操作,会抛ConcurrentModificationException。要用迭代器的remove()方法,或者 Java 8 的removeIf。
4.3 其他常用容器:Set、Queue 也有自己的脾气
HashSet 底层实际上是 HashMap 的壳,只是只用 key,value 存一个固定的空对象。所以 HashSet 的去重逻辑,本质上依赖你元素的equals和hashCode。
TreeSet 底层是红黑树,元素会按自然顺序或自定义比较器排序,但插入和删除时间复杂度是 O(log n),比 HashSet 的 O(1) 慢。你确需顺序访问时用 TreeSet,否则优先 HashSet。
队列里PriorityQueue是优先级队列,底层用堆实现,每次取出的都是“当前最小(或按比较器最大)”的元素。它可以用来做任务调度、TopK 问题,但要注意它只保证出队顺序,迭代遍历时不保证顺序。日常开发中如果只是先进先出,ArrayDeque比LinkedList更适合。
一句话总结容器这块:搞懂“数组、链表、树、哈希表”这四种底层结构,再看任何容器都像看透明的盒子,而不是背说明书。
5. 异常处理与 IO 流:框架帮你兜底,但你得知道底在哪
Java 的异常处理是很优秀的机制,但实际项目里,我看得最多的“坏味道”就是 catch 了异常之后默默吞掉,或者打一行e.printStackTrace()完事。这会给线上排查埋很多雷。
5.1 异常体系与分类:检查型和非检查型怎么分界
先梳理一下体系,Throwable 是所有错误和异常的祖先,下面分为 Error 和 Exception:
Error 代表 JVM 层面的严重问题,比如 OutOfMemoryError、StackOverflowError,一般是程序无法合理恢复的,不要试图去 catch,应该让程序尽早暴露。Exception 下面又分两类:受检异常(checked)和运行时异常(unchecked)。受检异常是编译器强制要求你处理的,比如 IOException、SQLException,要么 throws 显式声明,要么 try-catch。运行时异常则不需要强制声明,最常见的比如 NullPointerException、IllegalArgumentException。
我个人的看法是:受检异常的设计初衷是逼开发者考虑异常路径,但过度使用会让代码变得碎,比如有些方法每一行都要 catch。现在的趋势反而是对业务异常使用运行时异常,让异常向上抛到统一处理层,比如 Spring 的@ControllerAdvice,代码会清爽很多。受检异常则保留在必须由调用方感知的场景,比如文件不存在、网络断开这类。
5.2 try-with-resources:关闭资源不再靠 finally 硬写
处理 IO 流,最经典的教科书写法是用 finally 来关流:
FileInputStream in = null; try { in = new FileInputStream("file.txt"); // ... } finally { if (in != null) { try { in.close(); } catch (IOException ignored) { } } }这段代码写起来极其啰嗦,而且很容易漏掉一些流。Java 7 之后有了 try-with-resources,只要你的资源实现了AutoCloseable接口,就可以这样写:
try (FileInputStream in = new FileInputStream("file.txt"); ByteArrayOutputStream out = new ByteArrayOutputStream()) { // ... }编译器会自动生成关闭逻辑,而且如果关闭时又抛异常,它会处理成抑制异常,不会把主异常遮盖掉。Java 9 之后,你甚至可以用已有的 final 变量来声明资源,不用每次新起名字。
这里给一条经验:凡是打开了一个可关闭的资源,立刻放进 try 的小括号里,别等最后再说。流式 API 里比如 Files.lines 这样一行生成 Stream,后面用完也应该 close,或者直接用 try-with-resources 包住。
5.3 IO/NIO 的理解框架:阻塞、非阻塞、多路复用
Java 的 IO 体系内容很多,但真正需要建立的框架认知是:传统java.io是字节流 + 字符流、输入 + 输出的组合,上面叠着 BufferedInputStream、InputStreamReader 这样的装饰器。它的问题是阻塞式,每条连接都要一个线程等着,连接一多资源就不够用。
NIO(New IO)引入了 Channel、Buffer、Selector 的概念。Selector 可以让一个线程管理多个 Channel,监听哪些通道有事件就绪,这就是多路复用的思路。Netty 这类高性能网络框架的底层都是基于 NIO 演进出来的。
对做业务开发的人来说,你大概率不会直接写 NIO 代码,但理解“一个线程管多个连接”的模型至关重要,否则看网络框架的线程模型会一头雾水。打个比方:传统 IO 像开了一家餐厅,一个服务员只盯一张桌子,客人多了就得加服务员;NIO 多路复用是眼观六路的领班,一个人盯着整个大堂,哪个桌有需求就过去处理哪个桌。
关于 IO 这块,我觉得学习顺序建议是先把传统的 FileInputStream、FileOutputStream、InputStreamReader、BufferedReader 搞熟,会正确地用 try-with-resources 关闭资源,再去看 NIO 的 Buffer 和 Channel。只要理解到位,后面看 Netty 的高性能模型就是水到渠成的事。
6. 并发与数据一致性:基础里的“分水岭”知识
有多少人学完 Java 基础,碰到的第一个“感觉自己还没入门”的时刻,是看不懂多线程代码?上一份工作是维护一套库存扣减系统,第一次线上出现超卖,才真正意识到并发和数据一致性远不是背几个关键词能搞定的。
6.1 JMM 和线程安全:为什么会有“看不见”的变量
先扯一个根本问题:为什么多线程下会出现数据不一致?答案是,你的变量值不是单纯存在“共享内存”这一个地方。
JVM 内存模型(Java Memory Model)里,每个线程有一个自己的工作内存,里面的值是主内存变量的副本。线程操作的是副本,操作完再刷回主内存。如果多个线程同时操作同一个变量,彼此之间看不到对方在工作内存中的修改,就会出现“明明改了,另一个线程看不到”的诡异现象。
解决手段第一层是volatile。它的作用是:每次读都从主内存读,每次写立即刷回主内存,并且禁止指令重排序。注意,volatile 只保证可见性,不保证原子性。比如count++这种操作,本质是“读-改-写”三步,volatile 无法保证这三步不被并发打断,结果仍然可能丢更新。
要保证原子性,基本手段是锁。Java 里最朴素的锁是synchronized,它保证了互斥和可见性,还对代码块做了内存屏障处理。另一个方向是java.util.concurrent.atomic包里的原子类,它们是靠 CAS(Compare And Swap)实现的,无锁,但并发很高时可能出现自旋消耗。更高并发场景还要考虑LongAdder,它牺牲了一致性,把热点分散到多个 cell 上,适合统计场景。
6.2 一致性问题的场景化理解:从超卖说起
我自己印象最深的一个案例是做一个秒杀库存系统。最开始代码很简单:
if (stock > 0) { stock--; }在高并发下,两个线程同时读到 stock=1,都走过了判断,都执行了减一,最后 stock 可能变成 0 而不是预期中的 0?不对,两个线程都把库存从 1 减到 0,但实际是两次操作同时生效造成了逻辑判断重复执行,最终结果是库存扣成负数或者被覆盖为错误值。真实场景里,库存是数据库里的一个字段,如果不用原子化的 UPDATE(比如UPDATE stock SET count = count - 1 WHERE count > 0),或者不用分布式锁控制,就必然出现超卖。
这个案例说明一件事:数据一致性问题出现在“检查-操作”之间存在时间窗口。解决思路要么是缩小窗口(加锁、CAS),要么是让检查成为原子操作的一部分(数据库条件更新、乐观锁)。
正好搜索词里有一条是“java怎么保证数据一致性”,这里我展开说几句。单机多线程场景,有synchronized、ReentrantLock、Atomic系列可以用;分布式场景,就要引入分布式锁,常见的有基于 Redis 的 SET NX EX 和基于数据库的乐观锁版本号。你应该先分清场景:单 JVM 还是多 JVM?如果是多 JVM,本地锁再复杂也拦不住其他机器的线程。
6.3 线程池与并发工具:基础里的进阶题
Java 并发这块,java.util.concurrent包几乎是另一个世界,内容非常多。对基础阶段的人,我建议盯住三个点穷追猛打:
第一个是线程池。Executors 框架的newFixedThreadPool、newCachedThreadPool用起来方便,但有一个大坑:newFixedThreadPool底层用的是无界队列,任务无限堆积时会导致 OOM;newCachedThreadPool则允许最大线程数到Integer.MAX_VALUE,极端情况下也会 OOM。生产环境建议手动指定ThreadPoolExecutor的各个参数,包括核心线程数、最大线程数、队列类型和容量、拒绝策略。这里参数不是随便填,要结合业务的耗时和 QPS 估算,比如 IO 密集型任务核心线程数可以设成 CPU 核数两倍左右,计算密集型则接近 CPU 核数。这块知识不是“基础语法”层面,但它确实是 Java 开发者的起点级必修。
第二个是并发容器。前面已经提过 ConcurrentHashMap 替代 HashMap,类库里还有 CopyOnWriteArrayList、BlockingQueue 系列。BlockingQueue 和线程池深度绑定,是实现生产者-消费者模式的基础组件。面试时被问“线程池里怎么用队列”,如果脑子里没有 BlockingQueue 的概念,基本到这就卡住了。
第三个是 JUC 几个同步工具:CountDownLatch、Semaphore、CyclicBarrier。它们解决的问题各不相同,CountDownLatch 是“多个线程都完成后主线程再往下走”,Semaphore 是“限制同时访问的线程数量”,CyclicBarrier 是“多个线程互相等待到齐后才开始执行”。每个都能找到实际场景,哪怕只是用来控制测试并发度。
7. 学习路径与面试“八股文”的正确打开方式
最后想聊一点可能不算纯技术、但对学习 Java 基础的人来说很实用的话题:怎么学、怎么查、怎么准备面试。我见过那种啃完一遍《Java 核心技术》两卷就开始刷面试题的人,也见过只在 IDEA 里点运行、一开命令行就懵的人。这两种偏科都不太推荐。
7.1 从“能跑”到“懂为什么跑”的三步走
我的建议是把基础分成三个阶段,每阶段有明确目标。
第一阶段,目标是把语法和环境用顺。变量、循环、数组、方法、类、对象、String 操作,配合 IDE 能写出小型控制台程序。这个阶段不要深抠 JVM 原理,遇到异常报错能看明白 stack trace 就行。
第二阶段,目标是建立内存模型和容器意识。开始接触集合框架、泛型、常用工具类,并刻意练习读源码,尤其是ArrayList、HashMap的源码。同时可以开始自己做文件读写、异常处理的练习,学习怎么写出可复用的方法结构。
第三阶段,目标是理解并发和设计。理解线程怎么创建、怎么同步、怎么用线程池,然后横向对比几种锁和原子类;理解接口和抽象类的区别,尝试用组合替代继承,看一些基本的设计原则,在实战里尝试重构一段旧代码。
三个阶段没法完全割裂,但顺序别乱。没建立内存意识就去硬啃 JVM 调优,效果很差;没写过几个控制台程序就去看 Spring,学到的全是空中楼阁的配置。
7.2 “八股文”怎么背才有用
网上的 Java 面试题、八股文漫天飞,很多人拿到手就开始背,背完第二天就忘。我的建议是:把每一个面试题当成一条“源码阅读线索”。比如看到“HashMap 为什么线程不安全”,你最优的做法不是背三段结论,而是去打开源码,找到 put 方法里“没有锁”这个事实,再看并发下可能的覆盖场景,最后自己总结结论。这一套流程下来,知识点长在你脑子里,而不是只存在笔记里。
当然不是说结论完全不重要,面试表达也需要有层次感。我一般建议的回答结构是:先说结论,再给原理,最后给一个场景佐证。比如“HashMap 线程不安全,因为在 put 时没有同步机制,并发场景可能发生数据覆盖,JDK 1.7 的扩容还可能形成环形链表,所以并发应该用 ConcurrentHashMap。”这一句话里既有结论、有原理、有场景,也有不同版本的对比,比干巴巴背“HashMap 不安全”好得多。
7.3 给新人后来人的几句实在话
如果让我只给三条建议,我会说:
第一条,多用命令行,别把 IDE 当掩体。不要求你脱离 IDE 开发,但至少要会手动编译运行一个带包的类,会看异常栈。
第二条,看源码不是看天书,先把核心类的结构摸清。比如打开HashMap,你有不有第一时间找到它的字段,理解 table 是干什么的,看到内部类 Node 的定义。能做到这一步,很多源码类都能自己看下去。
第三条,遇到不确定性一定要查证,不要凭感觉下结论。写 Java 的人最忌讳“我在别的地方看到过类似写法,应该差不多吧”。基础阶段的任何一个疑问,都建议用一分钟在官方文档或源码里验证一下,长期积累下来的准确率会非常恐怖。
我到现在写代码,遇到集合选择、异常策略、并发方案,还是会先回到这些基础问题上过一遍。基础的收益就是这样:它不会让你第一天就写出惊艳的代码,但能保证你在第五年的时候不写出让人崩溃的代码。