写了几年Java之后,再回头看“Java核心知识”这几个字,我的感受是:真正拉开程序员差距的,往往不是谁先学会了某个新框架,而是最基础的地基有没有被打通。上个月帮一个开发者朋友排查线上问题,一个服务在流量高峰时频繁Full GC,他第一反应是调堆内存,结果毫无起色。我问他:这个类的加载过程是什么,它的静态成员什么时候初始化,这些对象的引用链到底怎么判活。他答不上来。那一刻我才确定,他脑子里装着很多零散结论,但还没有形成一张可推断问题的体系网。
这篇文章不求面面俱到,教科书已经做得够全了。我挑五个最实用也最容易被“自以为懂”的区域——类加载机制、并发内存模型、集合框架的深层逻辑、异常处理策略、反射泛型注解这套语言特性——逐个讲清“为什么这么设计”“实际用起来有哪些坑”“我排障时怎么用它们”。刚入门想梳理体系的人能看,写了两三年代码但遇到性能问题就发怵的人同样值得收藏。
1. 类加载机制:JVM如何“读取”你的字节码
类加载是JVM运行一切Java程序的入口,但我发现很多人对它的理解停留在“把.class塞进内存”这一步。实际上,每次执行new关键字、访问静态字段、反射调用,都会触发类的加载过程。它的流程设计直接决定了ClassNotFoundException、NoClassDefFoundError这类问题好不好查。
1.1 一个类从字节码到对象,中间经历哪五个阶段
一个.class文件在变成可以被使用的对象之前,要经过加载、验证、准备、解析、初始化这五个阶段。
- 加载(Loading):类加载器根据全限定名找到.class的二进制字节流,在堆中生成对应的Class对象,这个Class对象就是后续反射操作的信息源头。
- 验证(Verification):JVM检查字节码的格式和语义,防止非法的字节码混进来,这一步是安全防线。
- 准备(Preparation):为类的静态变量分配内存,并赋默认零值。注意这里非常关键:
static int count = 5的count,在准备阶段是0,不是5。可以理解为先给每个工位贴上标签、摆好空筐,但不往筐里放原料。 - 解析(Resolution):把常量池里的符号引用替换成直接引用,具体地说,把一个方法名字符串解析成内存中的实际入口地址。
- 初始化(Initialization):执行类初始化方法
<clinit>,也就是依次执行静态代码块和静态变量的赋值语句。到了这里,static int count才真正等于5。
这里面最容易翻车的点是准备阶段和初始化阶段的混淆。某些线上排查里,我们看到一个静态配置项是默认值(0或者null)而怎么调试都对不上,往往就是因为读取它的代码在类初始化之前的静态方法里跑了。我曾经遇到过一个类似的问题:某个路由配置类在静态方法里依赖另外两个静态变量,由于静态变量的赋值顺序是按代码书写顺序来的,工具类先被调用时另一个变量还是null,最终导致路由规则静默失效。理解了生命周期后,这类问题几乎一眼就能定位。
另外要提一个隐藏坑:如果类初始化过程中抛了异常,JVM会把类标记为初始化失败状态,下次再使用这个类会直接抛NoClassDefFoundError,而不会重新走一遍初始化。这解释了为什么同一个类第一次报ExceptionInInitializerError、后面就变成NoClassDefFoundError,其实是同一个根因。
1.2 双亲委派模型:为什么“爸爸先看”是一道安全闸
类加载器不是“谁先拿到谁说了算”,而是按照父子层级向上委托。加载一个类时,先请父加载器处理,父加载器处理不了,子加载器才自己动手。这个模型一句话概括:爸爸先看,爸爸不管的才轮到儿子。
这样设计最直接的原因是安全:java.lang.String这类核心类必须由启动类加载器加载,保证你写不出一个同名同包的类去“伪装”核心库。如果每个加载器都自己加载,类的身份就会被用户代码污染。
但双亲委派不是万能的,日常开发中最常踩的坑是SPI机制。例如JDBC的DriverManager由启动类加载器加载,它要调用各个数据库驱动jar里的Driver实现,而这些实现在应用类加载器管辖范围内。标准双亲委派下,DriverManager拿不到Driver类,于是SPI引入了线程上下文类加载器来打破层级,让DriverManager可以用“当前线程的加载器”去加载驱动。因此,当你遇到ClassNotFoundException: com.mysql.cj.jdbc.Driver时,先别怀疑jar存在不存在——大概率是SPI配置文件META-INF/services里漏了驱动类全限定名,或者classpath被启动脚本搞丢了。
我还在某个公共服务里遇到过一个更隐蔽的情况:扩展jar放在共享目录,父加载器优先加载了旧的公共类,新业务jar里相同包名的类永远不被加载,运行时反复报ClassCastException。当时排查了很久,最后用双亲委派的思路检查加载器层级,才定位到是jar版本冲突。排查类加载问题,第一步永远是把当前类的加载器和它的父子链打出来看,这会省掉大量瞎猜。
1.3 静态代码块执行顺序:一个反复被问到的小实验
类加载时机一旦被触发,初始化顺序是有明确规则的。我用一个很经典的代码来演示:
public class Parent { static { System.out.println("Parent 静态块"); } { System.out.println("Parent 实例块"); } public Parent() { System.out.println("Parent 构造器"); } } public class Child extends Parent { static { System.out.println("Child 静态块"); } { System.out.println("Child 实例块"); } public Child() { System.out.println("Child 构造器"); } }执行new Child(),控制台输出顺序是:
Parent 静态块 Child 静态块 Parent 实例块 Parent 构造器 Child 实例块 Child 构造器规则拆开看其实只有几句话:父类初始化在前,子类初始化在后;创建实例时,先初始化父类的实例变量和构造器,再初始化子类的实例变量和构造器;同一个类里,各静态语句按书写顺序执行。这个顺序在项目里远比面试重要——很多启动日志、资源初始化、框架装配的顺序逻辑都依赖它。
我见过一个项目在静态块里用了后面才声明的静态变量,结果读到的全是默认值;也见过启动脚本里检查日志文件生成的时机,和类的静态初始化时机对不上,导致观察窗口错位。建议大家遇到这类“启动日志顺序诡异”的问题,先回归到这个模型上来,不要急着改代码。
聊完类加载机制,顺着JVM继续往内存与执行方向走,就进入并发那块最让人头疼的内存模型问题。
2. 并发编程的“三层契约”:原子性、可见性、有序性
并发是Java里最容易被“背会”的部分。很多人能背出synchronized和Lock的区别,但遇到“加了volatile为什么还是错”“两个线程怎么就互相看不到更新了”就懵。根子在于没理解Java内存模型(JMM)到底约定了一套什么规则。
2.1 从CPU缓存到主内存:可见性问题到底从哪来
现代CPU每个核心都有自己的高速缓存,线程被调度到不同核心上执行时,各自变量副本可能只存在于核心的缓存中,如果不写回主内存,另一个核心上的线程就永远读不到。JMM正是在这个硬件事实基础上定义了一套抽象:每个线程有自己的工作内存(对应CPU缓存和寄存器的抽象),共享变量存储在主内存中;线程对共享变量的所有读改写操作,都必须先在工作内存进行,再同步回主内存,不能直接绕过工作内存操作主内存。
这就产生了可见性问题——线程A在它自己的工作内存里改了值,线程B的工作内存里还是旧值,B拿到的是“过期的快照”。很多初学者第一次遇到volatile,就是因为跑了一个类似下面的多线程开关场景:主线程把flag改成false,循环线程却还在死循环读取旧值。volatile之所以能解决这个问题,是因为它在底层会插入内存屏障指令:写volatile变量时,会强制把该变量及之前的写操作刷新到主内存;读volatile变量时,会强制从主内存重新拉取最新值。用一句话理解:volatile保证的是“你一定能看到最新写进去的值”。但volatile不保证原子性。
最典型的坑就是多个线程同时执行volatile int count; count++。count++不是一条指令,而是“读count、计算count+1、写回count”三步。即使每一步都读到最新值,也不能保证三步之间别的线程没有插入修改。等你在结果里看到数字小于预期时,问题往往已经发生了很久。这一点必须和synchronized分开理解:volatile管可见性,synchronized管互斥访问,两者解决的是不同维度的问题。
2.2 synchronized、volatile、Lock:三个工具各自管住什么
把三者放在同一张表里看更清楚:
| 能力 | volatile | synchronized | Lock(如ReentrantLock) |
|---|---|---|---|
| 原子性 | 不保证 | 保证 | 保证 |
| 可见性 | 保证 | 保证 | 保证 |
| 有序性 | 禁止特定重排序 | 保证 | 保证 |
| 使用代价 | 无锁,开销最小 | JVM层锁,有优化 | API层锁,功能更丰富 |
| 额外能力 | 无 | 可重入 | 可中断、超时、公平锁、多条件队列 |
synchronized是JVM字节码层面的monitorenter/monitorexit指令,Lock是java.util.concurrent包里的API实现。早期人们嫌synchronized重,但JDK后面做了大量优化——偏向锁、轻量级锁、锁膨胀、锁消除,现在无竞争状态下synchronized的开销已经非常小。我的建议是,能用synchronized解决的并发控制,不要急着上Lock,代码可读性更好。只有需要尝试获取锁(tryLock)、等待超时、公平排队这些能力时,才值得切换成ReentrantLock。
还要提一嘴:synchronized修饰的是一个“对象监视器”,锁的粒度和对象边界一致。如果锁的是类的不同实例,互斥效果就打了折扣;如果锁的是静态方法或Class对象,所有实例才会被同一把锁管住。很多团队在公共组件里碰到的“为什么两个线程还能同时进临界区”,最后查出来多半是synchronized加在了实例方法上而调用方new了多个对象。这是容易踩、也容易忽略的细节。
2.3 并发计数器的几个版本:一个经典场景的演进
用一个最经典的并发计数需求来看手段取舍。
版本一,裸写int count++,在并发下结果要么小于预期,要么时大时小,因为可见性和原子性双双失效。
版本二,给方法加synchronized。正确,直观,缺点是所有线程都竞争同一把锁,高并发下锁竞争会拖慢吞吐。
版本三,用AtomicInteger。核心是CAS(比较并交换)乐观锁机制,不阻塞,直接以自旋方式尝试更新,大部分情况下性能比synchronized好:
private final AtomicInteger counter = new AtomicInteger(0); public void increase() { counter.incrementAndGet(); }incrementAndGet()底层走的是Unsafe.compareAndSwapInt,大概逻辑是:读当前值,计算加一后的新值,然后尝试用CAS把新值写回;如果发现当前值已经被别的线程改掉了,就重新读取再次尝试,直到成功为止。这个“自旋重试”模型在无锁数据结构里随处可见。
版本四,更高并发场景用LongAdder。它把单个CAS热点拆分到多个cell上,各个线程分散到不同槽位累加,最后再汇总,核心思路是“热点分散”。并发量极高时,LongAdder的抗争明显优于AtomicInteger。我测试过一次高并发写入,AtomicInteger自旋次数高得吓人,换成LongAdder后吞吐提升非常明显。日常开发里如果只是一般的计数,AtomicInteger已经够用;不要为了炫技盲目替换。
注意:AtomicInteger和LongAdder都能保证数值不丢,但它们解决不了“必须先判断后操作”这类复合业务逻辑。需要把“检查”和“执行”绑成一体时,仍然要回到synchronized或者Lock上。
3. 集合框架:HashMap是考点,但更重要的是背后的设计
集合可能是Java里被用得最多、也最容易隐藏性能问题的标准库。HashMap的面试题人人会背,但“什么场景该用LinkedHashMap”“为什么集合要设计成fail-fast”“ArrayList和LinkedList真正的差距在哪”,这些点写真实业务时影响更大。
3.1 HashMap的数据结构与hash散列逻辑
JDK 8的HashMap由数组、链表、红黑树三部分构成。key的hashCode先经过扰动函数处理(把高16位和低16位做异或),再用(length - 1) & hash计算桶下标。这里要求数组容量必须是2的幂,是为了让位运算等价于取模,同时让扩容时的元素迁移变得高效:扩容到原来两倍后,元素要么留在原索引,要么移动到“原索引+旧容量”,不需要重新计算每个key的hash。
默认容量16,负载因子0.75,当元素数超过容量 * 0.75时触发扩容,容量翻倍。负载因子不是随便选的,0.75是空间利用率和冲突概率的平衡点——调大了空间利用率高但链表变长,调小了查找快但浪费内存。
当一个桶中的链表长度超过8并且数组容量达到64时,链表会树化成红黑树;当树的节点数降到6以下,又退化成链表。为什么阈值是8?设计者的依据是泊松分布:在理想随机hash下,链表长度到达8的概率已经极小,树化更多是防止有人恶意构造相同hash值的key来拖慢哈希表,属于一种“兜底策略”。把阈值背后的统计逻辑弄清楚,比背数字更有用。
在实际业务里,最影响HashMap性能的不是树化阈值,而是初始化容量。例如我们知道大概要放300个键值对,直接new HashMap<>(300)会比默认容量一路扩容省下大量rehash时间。初始容量偏小导致的频繁扩容,在写高频缓存时会让接口RT忽高忽低,这属于检查清单里容易被漏掉的项。
3.2 并发扩容的经典事故:JDK 7死循环与JDK 8丢数据
HashMap在并发下使用,经典事故有两个版本。
JDK 7时代,扩容时采用头插法迁移链表。两个线程同时对同一个桶执行resize时,可能出现节点互相引用成环的情况,之后任何一次get命中这个桶位置都会进入死循环,CPU飙到100%,线程栈停在HashMap的resize方法上。这个现象在网上有大量图解,核心结论是:不要在并发环境里用HashMap,再小心也不行。
JDK 8改成尾插法后,并发resize导致的死循环问题大幅缓解,但HashMap本身没有锁,多个线程同时put时,仍然会发生键值覆盖、size计数不准确,甚至在某些边界情况下丢失数据。所以正确选择永远是ConcurrentHashMap,而不是“这次应该没事”的HashMap。
我自己在排查一类CPU异常时见过类似现场:线程栈清一色卡在HashMap的某个方法上,分析后发现是某个缓存模块为了“读取性能”用了HashMap并在并发写入时收到脏数据,修复方案就是换成ConcurrentHashMap,一次到位。顺带说一下,其实JDK 8的HashMap还引入了putIfAbsent和computeIfAbsent这些方法,它们是单线程条件下比“先判断再put”更简洁的做法,但在多线程环境下依然不是原子操作,别混淆了原本的定位。
3.3 日常开发中集合选型的几个实际判断
集合选型通常不需要看太多文档,按下面几条判断基本不会错:
- 几乎无条件选ArrayList,而不是LinkedList。LinkedList在随机访问、内存局部性上劣势太明显,只有“频繁头尾插入且不随机访问”的极少数场景,才能发挥它双向链表的优势。
- 需要保留插入顺序、且以读为主时,用LinkedHashMap。比如构建一个“最近最少使用”缓存,可以基于LinkedHashMap重写
removeEldestEntry实现LRU。 - 需要按键排序且数据会频繁变动时,用TreeMap,配合自定义Comparator控制排序规则。
- 并发读多写少,用CopyOnWriteArrayList,读操作不加锁;并发写入频繁,用ConcurrentHashMap连写带读一起搞定。
- 需要去重又保留出现顺序,用LinkedHashSet;单纯去重用HashSet。
- 容量已知且形态固定时,直接用普通数组或预分配容量的ArrayList,比反复扩容的默认ArrayList省内存。
- 键值数量很大且不确定时,首选HashMap,但要提前给一个合理的初始容量,避免扩容风暴。
给一个简单对照表格:
| 场景 | 推荐 | 原因 |
|---|---|---|
| 普通列表 | ArrayList | 随机访问快、缓存友好 |
| 队列式头尾操作 | ArrayDeque / LinkedList | 双端操作结构上有优势 |
| 保序的键值存储 | LinkedHashMap | 保留插入/访问序 |
| 排序键值存储 | TreeMap | 红黑树按key排序 |
| 并发缓存 | ConcurrentHashMap | 分段锁/无锁读,线性安全 |
| 读多写少的列表 | CopyOnWriteArrayList | 读不锁,写时复制 |
这个表格在评审代码时可以直接当checklist用,很多性能问题在选型阶段就能被拦下来。
4. 异常处理:从“会抛出”到“设计得让人省心”
异常处理在项目里最容易被当成“try一包了事”,但恰恰是这个环节决定了线上日志好排查不好排查、系统出故障后恢复顺不顺。我的经验是把异常拆成三块看:异常体系本身的设计意图、写代码时最容易忽略的坑、以及业务系统里异常该如何组织。
4.1 受检异常与运行时异常:现代框架为何倒向Unchecked
受检异常(Checked Exception)是Java语言里很独特的设计,编译器强制要求捕获或声明IOException这类异常。设计初衷是“让开发者正面处理外部失败”,但实践久了就会发现,它在程序边界处有用,在业务代码内部往往会变成负担。很多老项目里能看到大段这样的代码:捕获受检异常后直接打印日志再抛出的橡皮图章,异常发生了等于没发生。
Spring等框架大量采用运行时异常,理由是把“要不要捕获”的判断权交给调用方,而不是在编译期一刀切。你调用一个方法时,心里清楚它可能失败、且你能对失败做点什么,那受检异常是合理约束;如果你就是想统一记日志或者快速失败,运行时异常明显更干净。
我个人的准则很简单:业务代码里统一用运行时异常,只有像文件IO、网络连接这类明确的外部资源交互边界,才保留受检异常。这样主干流程不会被try-catch代码块切割得七零八落,排查时异常栈也容易一眼定位。
4.2 finally中的return:一个容易吞掉异常的坑
写异常处理时,最容易出的问题之一就是finally块里的return。看下面这段“问题代码”:
public String readContent(File file) { BufferedReader reader = null; try { reader = new BufferedReader(new FileReader(file)); // 某处抛出 IOException throw new IOException("read failed"); } catch (IOException e) { return "error"; } finally { // 危险:这里如果 return,会覆盖 try/catch 里所有的返回值 return "finallyResult"; } }finally块无论如何都会执行,如果它在执行过程中return了,那么try块或catch块里的返回值全部作废,方法最终返回的是finally里的值。更糟的是,如果finally块里抛出了一个新异常,它会直接掩盖try块里本该抛出的原始异常,日志里看到的是完全无关的新错误,排查成本暴涨。
Java 7之后,正确的做法是使用try-with-resources自动关闭实现了AutoCloseable的资源:
try (BufferedReader reader = new BufferedReader(new FileReader(file))) { // 业务逻辑 }资源关闭交给编译器生成的close调用,finally块就彻底不需要出现了。我的建议是:在代码审查里,看到finally里出现return或者可能抛出异常的语句,一律标记为需要修改;整理一下资源关闭逻辑,代码简洁,也少一个隐藏炸弹。
4.3 业务异常设计:错误码、异常类型与全局处理
曾经指导过一个内部服务做规范化改造,起初它的代码里到处都是throw new RuntimeException("用户操作失败"),前端拿到消息后一头雾水,后端日志也统计不出问题分布。后来我们统一设计了业务异常和错误码枚举:
public enum ErrorCode { PARAM_INVALID(400001, "参数校验失败"), ORDER_NOT_FOUND(404001, "订单不存在"), BALANCE_NOT_ENOUGH(409001, "余额不足"); private final int code; private final String message; ErrorCode(int code, String message) { this.code = code; this.message = message; } // getter ... } public class BizException extends RuntimeException { private final ErrorCode errorCode; public BizException(ErrorCode errorCode) { super(errorCode.getMessage()); this.errorCode = errorCode; } }配合一个统一的异常处理器,将所有BizException转换成固定结构的JSON响应,未知异常记录详细日志并返回兜底错误码。这套体系上线后,调用方看到错误码就能知道参数问题、订单问题还是余额问题,运维也能按错误码聚合统计,哪一类故障占比高一目了然。
关键体会是:错误码是给机器和接口对接方用的,message是给人看的,两者各司其职,不要都塞进一句话里。另外,异常不要当流程控制来用,像“未登录”“参数为空”这类边界条件,能预先判断就先用if处理掉。我见过一些模块把每个用户输入校验都写成异常,异常对象创建的开销虽然不算天价,但大量抛异常会让调用栈变得异常难看,也掩盖了真正的异常语义。
5. 反射、泛型、注解:框架背后的三大语言特性
业务代码里可能很少直接写反射,但Spring的依赖注入、MyBatis的Mapper代理、各种注解驱动的功能,全建立在这三样语言特性之上。理解它们之后,看框架源码会顺畅很多,遇到“为什么这个泛型信息丢了”“为什么自定义注解没生效”这类问题,也直接有排查方向。
5.1 反射与它的使用边界
反射能在运行时获取一个类的字段、方法、构造器,甚至绕过访问限制调用私有成员。容器框架正是靠它扫描包、读取类元数据、实例化Bean并注入依赖。但反射绝不是免费的:相比直接方法调用,反射调用的性能低一个量级,在JDK高版本虽然通过MethodHandle和invokedynamic做了一些优化,但它仍然不适合作为热路径上的常规手段。
一个很实在的建议:业务代码里不要用反射去做“通用工具”,比如写一个把任意DTO所有字段都反射出来打印的日志工具。某次排查一个接口RT过高的问题,最后发现是日志组件在每次请求里对一个几十个字段的对象做全量反射遍历并拼接字符串,这条路径成了性能瓶颈。改成白名单字段手动拼装后,RT直接降了下来。反射适合放在框架层、序列化层这类“必须通用”的代码里,日常业务里能用普通方法调用解决的问题,就不要动用它。
5.2 泛型擦除:为什么运行时不认识List
Java的泛型是编译期语法,编译之后,泛型参数信息会被擦除。运行时,List<String>和List<Integer>在JVM看来都是同一个ArrayList。这是为了兼容早期没有泛型的版本而做的设计选择。
擦除带来了几个经典限制:不能用instanceof检查“这个List装的是不是String”;不能直接创建泛型数组;不能有两个只以泛型参数不同的重载方法,比如void test(List<String>)和void test(List<Integer>)放在同一个类里编译会直接报错,因为擦除之后方法签名冲突。
有一个看起来很高级、其实早已被框架广泛使用的绕过技巧:通过匿名子类保留泛型信息。例如Gson库里常见的new TypeToken<List<String>>() {},之所以能拿到List<String>这个类型,是因为匿名类继承了父类并“记住了”泛型参数。反射里可以这样读出来:
Type genericSuperclass = new ArrayList<String>() {}.getClass().getGenericSuperclass(); ParameterizedType pt = (ParameterizedType) genericSuperclass; Type actualType = pt.getActualTypeArguments()[0]; // 得到 String网上的序列化框架反序列化带泛型的对象时,普遍就是这么干的。理解这一点之后,再看到源码里突然冒出来的匿名类,就不会觉得是魔法了,它只是在和类型擦除做对抗。
5.3 注解:从元数据标记变成编译期和运行时的指令入口
注解最早只是给代码加一点机器可读的元数据,后来通过反射读取和编译期注解处理器,成了Java生态里非常强大的元编程工具。
实际使用中可以分成几类:
- 编译期注解:@Override、@Deprecated,以及Lombok的@Getter/@Builder等,它们往往配合APT在编译阶段生成代码或做静态检查。
- 运行时注解:Spring的@Component、@Transactional等,容器启动时通过反射读取注解信息,再决定怎么创建Bean、怎么织入事务。
- 自定义注解:关键不是注解声明本身,而是“谁来读取它”。没有读取逻辑的注解只是一段优雅的注释。
如果要做一个运行时注解,通常的做法是定义一个注解,再在切面或拦截器里通过反射拿到注解上的参数去执行逻辑。以限流为例,可以定义@RateLimit(limit = 100),切面里读取limit后执行令牌桶判断,超限就抛异常。这个设计里,注解只是配置入口,真正的逻辑全部在切面的解释器里,这就解释了为什么自定义注解往往要和AOP搭配出现。
理解了反射、泛型、注解这三样后,再回头看各种框架源码,很多过去觉得神秘的自动装配、动态代理、Mapper绑定,其实都是这些基础特性的组合。我自己在学习时有个习惯:每看一个框架的“魔法”功能,就往前追一层,看它底层用了哪个Java基础特性,追到反射就查反射,追到代理就查代理,这样学到的不是孤立的框架用法,而是整套语言体系的延伸。
最后再分享一个个人体会:Java核心知识真正的价值,不在于面试时能把概念背得多顺口,而在于当你面对一个线上诡异问题时,脑子里能自然地浮现出“这个环节可能涉及哪个底层机制”的猜测列表。类加载、内存模型、集合、异常、语言特性,这五块补牢以后,排查类似问题的时间通常会缩短一半以上。如果读完这篇文章你有感触,就去写一段带静态代码块的类、跑一个并发计数器实验、自定义一个注解并试着用反射读取它——这些看似简单的小实验,比收藏一堆源码解读有用得多。