
写了几年Java之后再回头看“Java核心知识”这几个字我的感受是真正拉开程序员差距的往往不是谁先学会了某个新框架而是最基础的地基有没有被打通。上个月帮一个开发者朋友排查线上问题一个服务在流量高峰时频繁Full GC他第一反应是调堆内存结果毫无起色。我问他这个类的加载过程是什么它的静态成员什么时候初始化这些对象的引用链到底怎么判活。他答不上来。那一刻我才确定他脑子里装着很多零散结论但还没有形成一张可推断问题的体系网。这篇文章不求面面俱到教科书已经做得够全了。我挑五个最实用也最容易被“自以为懂”的区域——类加载机制、并发内存模型、集合框架的深层逻辑、异常处理策略、反射泛型注解这套语言特性——逐个讲清“为什么这么设计”“实际用起来有哪些坑”“我排障时怎么用它们”。刚入门想梳理体系的人能看写了两三年代码但遇到性能问题就发怵的人同样值得收藏。1. 类加载机制JVM如何“读取”你的字节码类加载是JVM运行一切Java程序的入口但我发现很多人对它的理解停留在“把.class塞进内存”这一步。实际上每次执行new关键字、访问静态字段、反射调用都会触发类的加载过程。它的流程设计直接决定了ClassNotFoundException、NoClassDefFoundError这类问题好不好查。1.1 一个类从字节码到对象中间经历哪五个阶段一个.class文件在变成可以被使用的对象之前要经过加载、验证、准备、解析、初始化这五个阶段。加载Loading类加载器根据全限定名找到.class的二进制字节流在堆中生成对应的Class对象这个Class对象就是后续反射操作的信息源头。验证VerificationJVM检查字节码的格式和语义防止非法的字节码混进来这一步是安全防线。准备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、计算count1、写回count”三步。即使每一步都读到最新值也不能保证三步之间别的线程没有插入修改。等你在结果里看到数字小于预期时问题往往已经发生了很久。这一点必须和synchronized分开理解volatile管可见性synchronized管互斥访问两者解决的是不同维度的问题。2.2 synchronized、volatile、Lock三个工具各自管住什么把三者放在同一张表里看更清楚能力volatilesynchronizedLock如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 泛型擦除为什么运行时不认识ListJava的泛型是编译期语法编译之后泛型参数信息会被擦除。运行时ListString和ListInteger在JVM看来都是同一个ArrayList。这是为了兼容早期没有泛型的版本而做的设计选择。擦除带来了几个经典限制不能用instanceof检查“这个List装的是不是String”不能直接创建泛型数组不能有两个只以泛型参数不同的重载方法比如void test(ListString)和void test(ListInteger)放在同一个类里编译会直接报错因为擦除之后方法签名冲突。有一个看起来很高级、其实早已被框架广泛使用的绕过技巧通过匿名子类保留泛型信息。例如Gson库里常见的new TypeTokenListString() {}之所以能拿到ListString这个类型是因为匿名类继承了父类并“记住了”泛型参数。反射里可以这样读出来Type genericSuperclass new ArrayListString() {}.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核心知识真正的价值不在于面试时能把概念背得多顺口而在于当你面对一个线上诡异问题时脑子里能自然地浮现出“这个环节可能涉及哪个底层机制”的猜测列表。类加载、内存模型、集合、异常、语言特性这五块补牢以后排查类似问题的时间通常会缩短一半以上。如果读完这篇文章你有感触就去写一段带静态代码块的类、跑一个并发计数器实验、自定义一个注解并试着用反射读取它——这些看似简单的小实验比收藏一堆源码解读有用得多。