很多人问我Java知识点那么多到底该怎么梳理网上的资料五花八门有的讲原理讲到源码深处有的又浅得跟没讲一样。我自己从写业务代码到啃源码再到现在带团队做技术评审踩过的坑不少也沉淀了一套自己的知识框架。这篇东西不打算按教科书顺序罗列而是把 Java 里最核心、最容易被问倒、也是在实战里真正决定代码质量的那几个知识点拎出来掰开揉碎讲清楚。适合正在准备面试的开发者也适合写了两三年业务代码、想往深处走一步的朋友。我会从集合框架、并发编程、JVM 内存、Java 8 新特性、以及泛型反射注解这几个大块展开每一块都结合真实的踩坑经历和排查思路来说。如果你能把这篇的内容真正吃透不敢说成为专家但至少在日常开发和面试里不会再被这些基础问题卡住。1. 集合框架别只会用 ArrayList 和 HashMap1.1 List 家族的底层真相先说 List。很多人用 ArrayList 用了一年问他和 LinkedList 的区别只能答出“一个数组一个链表”。但真实开发里这个区别直接决定了你的接口性能。ArrayList 底层就是 Object[] 数组默认容量 10每次扩容变成原来的 1.5 倍。这里有个细节扩容用的是 Arrays.copyOf也就是新建数组然后把旧元素拷贝过去。如果你在循环里往 ArrayList 头部插入数据每一次插入都要把后面所有元素往后挪一位复杂度是 O(n)一万条数据还好十万条就能明显感觉到卡顿。LinkedList 底层是双向链表头尾插入删除是 O(1)但如果你按索引访问它得从头或尾遍历过去复杂度是 O(n)。我见过有人在 for 循环里用 get(i) 遍历 LinkedList那性能简直是灾难。我的习惯是读多写少选 ArrayList头尾频繁增删选 LinkedList但大多数场景下 ArrayList 就够了。还有一个细节ArrayList 的 subList 方法返回的是内部类 SubList它和原 List 共享同一个数组你对 subList 做结构性修改原 List 也会变。我之前在业务代码里踩过这个坑用 subList 做删除结果原数据被改了排查了半天。1.2 HashMap 的核心机制与容量选择HashMap 是面试必问也是实战里最容易用错的东西。它的底层是数组加链表加红黑树数组默认容量 16负载因子 0.75。当元素数量超过容量乘以负载因子时触发扩容容量翻倍。JDK 1.8 之后链表长度超过 8 且数组容量大于等于 64 时链表转红黑树。为什么是 8因为理想情况下随机 hash 落到某个桶里的概率遵循泊松分布链表长度到 8 的概率已经低于千万分之一。但如果你自定义了糟糕的 hashCode比如所有对象的 hashCode 都返回同一个值那链表就会很长性能急剧下降。我在实战里给一个统计系统设计 key 时最开始用的 SimpleDateFormat 格式化后的字符串结果发现大量 key 冲突因为时间精度只到秒。后来改成带毫秒和随机后缀的组合hash 分布立刻均匀了。HashMap 的容量设置也有讲究。如果你知道大概要存多少数据最好在初始化时指定容量避免频繁扩容。容量要设置成 2 的幂次方。为什么要 2 的幂因为 HashMap 计算桶下标用的是 hash (n-1)等价于取模但只有 n 是 2 的幂时hash (n-1) 才能均匀分布。我一般用这个公式容量 预期数据量 / 0.75 1。比如预期 1000 条数据容量设 1367HashMap 会自动向上取整到 2048。1.3 Map 家族选型对照除了 HashMap还有几个 Map 值得掌握。LinkedHashMap 保留了插入顺序适合做 LRU 缓存它内部维护了一个双向链表。我实现过一个简单的热点数据缓存继承 LinkedHashMap 重写 removeEldestEntry容量超过设定值就删除最老的数据代码量不超过二十行稳得很。ConcurrentHashMap 是并发场景下的首选JDK 1.8 之后它放弃了分段锁改用 CAS 加 synchronized 锁住桶的头节点。读操作基本无锁写操作锁的粒度也细并发度比 HashTable 高了几个数量级。我有个统计接口之前用 HashTable 做累计压测到 200 并发就出现严重竞争换 ConcurrentHashMap 之后1000 并发都能稳稳扛住。TreeMap 底层是红黑树key 有序适合需要范围查询的场景。比如按时间排序的批量操作用 TreeMap 的 subMap 方法能很方便地取出一段时间范围内的数据。但它插入删除的代价比 HashMap 高不是所有场景都适用。2. 并发编程从线程池到锁的实战选择2.1 线程池的核心参数你真的懂吗线程池是 Java 并发的基础设施但很多人只是背了参数不知道每个参数在真实压力下意味着什么。核心线程数、最大线程数、存活时间、任务队列、拒绝策略这五个参数组合起来决定了一个系统的吞吐量和稳定性。核心线程数怎么定有个经验公式CPU 密集型任务设成 CPU 核心数加一IO 密集型任务设成 CPU 核心数乘以二。因为 IO 密集型的线程大部分时间在等待更多的线程能提高利用率。但公式只是起点我一般还会压测微调。任务队列用的是 BlockingQueue常见的有 ArrayBlockingQueue、LinkedBlockingQueue、SynchronousQueue。这里最容易踩坑的是核心线程满了新任务先进队列队列满了才创建非核心线程直到达到最大线程数。也就是说最大线程数是在队列也扛不住的时候才启用的。很多人以为核心线程满了就直接创建新线程这是理解偏差。我维护过一个上报系统最开始队列用的无界 LinkedBlockingQueue任务堆积越来越多内存直接撑爆进程 OOM。后来改成有界队列配合 CallerRunsPolicy 拒绝策略队列满时让提交任务的线程自己执行既不会丢任务又天然实现了背压。系统稳定运行到现在再没出现过内存问题。2.2 拒绝策略的选择与背压思想线程池的拒绝策略有四种。AbortPolicy 直接抛异常DiscardPolicy 静默丢弃DiscardOldestPolicy 丢弃最老任务CallerRunsPolicy 让提交线程自己执行。很多教程推荐 AbortPolicy但实际业务里直接抛异常往往导致调用方崩溃。我的经验是核心链路用 CallerRunsPolicy让上游感知到压力自行降速非核心链路比如日志上报用 DiscardOldestPolicy 丢最老的数据保证最新数据优先处理。这里有一个很重要的思想背压。不要让生产者无限制地往队列里塞任务而是当系统处理不过来时把压力反向传递给生产者。CallerRunsPolicy 就是这种思想的体现。我后来给团队做分享时反复强调线程池不只是线程管理工具它更是一个流量控制阀门。2.3 synchronized 与 Lock 的选择synchronized 是 JVM 内置的锁经过锁升级过程从偏向锁到轻量级锁再到重量级锁。在竞争不激烈时它的性能其实很好而且使用简单自动释放锁。我用它来保护短小的临界区比如对一个计数器累加。Lock 接口的实现 ReentrantLock 则更灵活支持公平锁、非公平锁、可中断、超时获取锁、多个 Condition 条件队列。我写分布式任务调度时需要多个线程在不同条件下互相唤醒synchronized 的 wait/notify 很难管理多个条件用 ReentrantLock 的 Condition 就清晰得多。还有一个容易忽略的点synchronized 在异常时会自动释放锁但 ReentrantLock 必须在 finally 里手动 unlock否则死锁。我建议团队统一封装一个工具方法把 lock 和 unlock 包在模板方法里避免人为遗漏。Lock 还有一个 tryLock 方法可以指定等待时间。在真实业务里我经常用它来避免线程无限期等待如果锁在 3 秒内没拿到就记录日志并返回降级结果。这在微服务调用链里非常实用避免了单个节点卡死拖垮整个链路。3. JVM 内存与调优性能分析的必修课3.1 运行时数据区划分JVM 内存区域大致分为堆、虚拟机栈、本地方法栈、方法区、程序计数器。堆是对象分配的主要区域又分成新生代和老年代。新生代里再分 Eden 区和两个 Survivor 区比例默认是 8:1:1。大多数对象在 Eden 区创建Minor GC 之后存活的对象移到 Survivor 区每次 GC 年龄加一超过阈值默认 15移到老年代。这个阈值可以通过 -XX:MaxTenuringThreshold 调整。如果你的系统创建大量短期对象Eden 区应该适当调大减少 Minor GC 频率。虚拟机栈里存的是栈帧每个方法调用对应一个栈帧栈帧里有局部变量表、操作数栈、动态链接、方法返回地址。栈深度超过默认配置时会抛 StackOverflowError。我遇到过递归调用层级过深导致的栈溢出排查方法是打印堆栈找到最深处的调用链多半是递归缺少终止条件。方法区在 JDK 1.8 之后改名为元空间使用本地内存。之前的方法区在堆内经常出现 PermGen OOM现在元空间默认不受上限限制但如果加载的类太多也可能撑爆系统内存。动态生成类的框架比如 CGLIB、反射代理尤其要注意这点。3.2 垃圾收集器选型与调优参数G1 垃圾收集器是 JDK 9 之后的默认选择它把堆分成多个 Region优先回收垃圾最多的 Region目标是控制停顿时间。在低延迟场景ZGC 能实现毫秒级停顿适合大堆内存比如几十 GB 的堆。调优的核心指标有三个吞吐量、停顿时间、内存占用。三者不可兼得。我的调优思路是先确定停顿时间目标比如 200ms 以内然后设置 -XX:MaxGCPauseMillis再调整堆大小让吞吐量达到要求。不要一上来就去调各种参数看效果那样和瞎蒙没区别。常见参数给大家列一下。堆大小 -Xms 和 -Xmx 要设置成一样的值避免运行时扩容震荡。新生代大小 -Xmn 一般设为老年代的八分之一到四分之一。GC 日志一定要开-Xloggc 指定日志文件。我排查线上 GC 问题时第一件事就是看 GC 日志里的停顿时间、晋升情况、回收前后堆内存变化。有一个参数值得留意-XX:PrintGCDetails 在 JDK 9 之后改成了 -Xlog:gc*。版本升级的时候很多旧启动脚本直接不能用就是因为这些参数改了。3.3 线上 OOM 排查的实战套路OOM 是每个 Java 开发都会遇到的事。我的排查套路是四步走第一步加启动参数 -XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/path让 JVM 在 OOM 时自动导出堆快照。第二步用 MAT 或 JVisualVM 打开堆快照查 Largest Objects 或者 Dominator Tree找到占用内存最大的对象。第三步看对象的引用链。比如发现一个 List 里有几百万个对象顺着引用链找谁往里面 add 的就能定位到代码位置。第四步分析内存泄漏和内存过大的区别。如果快照里大部分是业务数据那就是容量规划不足如果是同一个类的大量实例那就得看是不是有集合静态持有、缓存没有过期策略、连接池泄漏。我排查过一个最典型的案例一个定时任务在循环里创建线程池因为线程池对象被静态变量引用一直不被回收每跑一次就多一堆线程和队列对象最终老年代堆满。定位到源码时发现就是一行创建线程池的代码被误放进了循环。OOM 排查最重要的是保留现场。不要着急重启先把堆快照导出否则重启之后线索全断了。4. Java 8 新特性函数式编程与流式处理4.1 Lambda 表达式与方法引用的使用边界Java 8 的 Lambda 改变了写代码的方式但很多人用得很随意。Lambda 本质是函数式接口的实例它让代码更简洁但过度使用会降低可读性。我的原则是Lambda 适合逻辑简单的操作比如排序、筛选、遍历、比较器如果逻辑超过三行就别硬塞进 Lambda拆成独立方法更清晰。方法引用比 Lambda 更具可读性比如 list.stream().map(User::getName) 比 x - x.getName() 更直观因为它直接表达了意图。有一个坑Lambda 表达式里引用外部变量变量必须是 final 或者 effectively final。这是什么意思就是变量在初始化之后不能再重新赋值。我之前在循环里用 Lambda 收集结果不小心在循环体里对变量重新赋值编译直接报错后来用局部变量绕开了。函数式接口 FunctionalInterface 是 Lambda 的基础常见的 Predicate、Function、Consumer、Supplier 对应布尔判断、转换、消费、供给四种场景。我封装公共工具类时经常用 Function 做统一的类型转换模板消除大量重复的 switch-case。4.2 Stream 流水线的原理与性能陷阱Stream 操作分中间操作和终端操作。中间操作是惰性的比如 filter、map、sorted它们不立即执行而是在终端操作比如 collect、forEach 触发时才一次性执行。这带来一个优化空间只要在终端操作前加上 limit前面的 filter 和 map 就只会处理有限个元素而不是处理整个集合。性能陷阱主要在 boxed 类型、sorted 和并行流。基本类型 int 经过 Stream 会装箱成 Integer导致额外的对象分配。大量数据统计时我用 IntStream、LongStream 避免装箱性能提升明显。sorted 是一个有状态的操作会保存所有元素排序内存开销不小。并行流 parallelStream 在数据量小或存在共享可变变量时性能不仅不提升还会因为线程切换和竞争而下降。我在一个并行流里对共享 List 做 add结果抛了 ConcurrentModificationException排查后才知道并行流里不能操作共享可变对象。Stream 的正确打开方式是链式操作流畅表达处理逻辑但复杂业务不要强行用 Stream 硬套传统 for 循环可读性更好。我在 code review 时经常跟人说Stream 的价值在表达力不在代码量。4.3 Optional 与新的日期时间 APIOptional 是为了减少空指针异常设计的但很多人用错了方向。比如 Optional.ofNullable(x).map(...).get() 和直接判断 null 没有本质区别反而多了一层包装。我的用法是Optional 作为返回值的类型说明告诉调用方这个方法可能没有结果。比如从 Map 里取值用 Optional.ofNullable(map.get(key))然后 orElse 提供默认值或者 orElseThrow 抛出明确异常。不要用 Optional 包装字段类型那是 Java 语言设计者自己都不推荐的。java.time 包下的 LocalDate、LocalDateTime、Instant 彻底解决了 SimpleDateFormat 线程不安全的问题。我之前写过一次 SimpleDateFormat 静态实例被多线程并发格式化结果数据错乱全是乱码。换成 DateTimeFormatter 配合 LocalDateTime线程安全代码也更清晰。如果需要和旧代码交互日期用 Date.from(instant) 和 localDateTime.toInstant() 做转换。日期时间 API 还有一个好用之处是 Period 和 Duration一个处理日期差距一个处理时间差距。我之前计算订单超时时间用 Duration.between 轻松算出两个时间点之间隔了多少分钟再也不用自己算毫秒差除来除去。5. 泛型、反射与注解框架设计的底层逻辑5.1 泛型擦除与桥接方法泛型是 Java 类型安全的重要保障但它在运行时会被擦除所有泛型信息只剩上限类型。比如 List 在运行时就是 List类型参数 String 被擦除掉。这带来很多坑最典型的是不能直接实例化泛型数组不能通过 T.class 获取类型。桥接方法是编译器为了保证多态和泛型覆盖一致而生成的。如果一个父类泛型方法签名的返回值是 Object子类覆盖为 String编译器会生成一个桥接方法让调用方通过 Object 类型调到 String 版本。这个过程对上层透明但反射拿方法时可能同时看到两个方法不少反射框架都需要额外处理。泛型通配符 ? extends T 和 ? super T 是面试常考的。extends 限定上界只能读不能写因为编译器不知道具体类型super 限定下界只能写不能读。简化记忆就是 PECS 原则生产者用 extends消费者用 super。写一个 copy 方法时src 是生产者用 extendsdest 是消费者用 super代码立刻清晰了。泛型擦除还有一个实际影响重载时不能通过泛型类型不同来区分方法。比如方法参数 List 和 List 在编译期是同一个签名直接编译报错。我设计接口时会用不同的方法名来规避这个问题。5.2 反射的核心机制和性能代价反射可以在运行时获取类的字段、方法、注解信息动态调用方法。Spring 框架的核心 IOC 和 AOP 都建立在这个基础上。但反射的效率低主要原因是每次调用都要进行安全检查和方法解析。反射的性能优化手段有三个第一缓存 Method 对象避免重复获取第二setAccessible(true) 跳过安全检查能大幅提升调用速度第三尽量用 MethodHandle 或者直接接口调用。我做过一个简单的测试普通方法调用是反射调用的几十倍加了 setAccessible 后缩小到几倍。所以反射适合低频操作不适合放在热路径里。反射框架里经常遇到的一个问题是 getDeclaredMethod 只能获取当前类声明的方法拿不到继承来的方法getMethod 获取的是所有 public 方法包括继承的。我在封装一个基础工具时为了找父类里的私有字段得循环向上遍历 getSuperclass。反射在框架里还有一个应用就是动态代理。JDK 动态代理要求目标类必须实现接口CGLIB 则通过生成子类代理。Spring AOP 默认用 JDK 动态代理但如果目标类没实现接口就得切到 CGLIB。这是一个经常让人困惑的知识点面试也爱问。5.3 注解背后的设计逻辑注解本质是一种元数据它不直接改变程序行为而是需要在运行时被框架解析。自定义注解常用 Retention、Target、Documented 三个元注解。Retention 决定注解的生命周期是编译期还是运行时。如果想在运行时反射获取必须设为 RUNTIME。Target 定义注解能用在哪是字段、方法还是类上。我把统一鉴权注解的 Target 设为 METHOD然后通过拦截器解析方法上的注解判断是否需要登录权限。这是注解最典型的使用方式。注解的解析有两种时机运行时反射和编译期处理。运行时反射简单直接适合业务框架但性能有损耗。编译期处理则是通过注解处理器生成代码比如很多自动生成 Mapper 的框架。编译期处理看起来高深其实核心就是继承 AbstractProcessor然后在 process 方法里遍历被注解标记的元素生成 Java 文件。我尝试写过一个小型代码生成器利用编译期注解自动生成建造者模式的代码效果不错。注解还有一个容易踩的坑同一个注解在继承体系里父类注解默认不被子类继承除非加了 Inherited 注解。而且这个 Inherited 只对类上的注解有效对方法、字段上的注解没有继承效果。我在设计一个基础类的通用日志注解时以为子类重写方法会自动带上注解结果日志没打出来排查半天才发现需要把注解加到子类方法上或者用 AOP 切到父类方法上。注解设计要克制不要为了炫技把一个业务需求堆上七八个注解。我见过一些代码注解的语义互相重叠维护成本极高。好的注解设计应该像好接口一样语义单一责任清楚最好一个注解只表达一个功能维度。我个人在实际操作中的体会是Java 知识点这东西不能靠硬背得在真实项目里反复碰撞。集合框架用熟了HashMap 扩容时的性能开销你就有了体感线程池参数调过一轮你就知道 CallerRunsPolicy 比想象中好用得多OOM 排查过一次看 JVM 日志的速度会快不少。这些知识点单独看都是散的但当你带着问题去学它们会自动串成一张网。后面我还会就 JVM 调优实战、并发编程的深水区单独写几篇那些才是真正拉开差距的地方。
阅读完成 · 觉得有帮助?