1. 为什么每个Java开发者都要经历一段刷题期如果你已经在Java这条路上走了半年到一年大概率会遇到一个瓶颈日常业务代码写得很顺CRUD、接口对接、简单的并发处理都没问题但一碰到面试、一接手复杂项目、一看框架源码就发怵。原因很简单你的知识是平铺的缺少纵向的深度连接。所谓刷题在这个阶段已经不是大学里那种数据结构算法题了而是针对Java核心机制的专项拆解。我见过太多人把刷题理解成背八股文其实这是两码事。八股文是结论刷题是推导过程。比如你背过HashMap在JDK8以后是数组加链表加红黑树但你能说清楚为什么链表长度到8才转红黑树、为什么是0.75的负载因子、并发put的时候到底会发生什么吗把这些为什么逐个击破才是进阶的正确打开方式。这篇博文我不会从JDK安装、环境变量这种基础讲起那些内容随便搜一篇教程都比我写得好。我聚焦的是中级开发者向高级进阶时最该补的几块核心内容线程协作的底层逻辑、动态代理的真正用法、JVM类加载机制与实际的排错联动、Lambda和Stream的进阶实战以及我踩过的那些坑。这些都是我这些年做项目、带新人、复盘面试时反复验证过的东西希望能帮你少走一段弯路。2. 线程协作从等所有线程完成引发的连锁思考2.1 CountDownLatch、CyclicBarrier与CompletableFuture的选型热搜词里有一个非常典型的场景java线程等待都完成。这是并发编程里最基础也最容易写错的需求。 每个Java开发者都会遇到这样的需求一个任务太大拆成几个子任务并行处理等所有子任务都完成后再汇总结果。新手第一反应可能是用Thread.join()稍微有点经验的人会用CountDownLatch再往后会接触到CyclicBarrier和CompletableFuture。这几个方案看着都能实现等待完成但适用场景差别很大。我简单梳理一下思路如果只是干等别人完成不需要返回值用CountDownLatch计数器减到0就放行如果是一组线程互相等先到的在屏障处等等人齐了再一起行动用CyclicBarrier如果需要并行执行任务并收集每个任务的返回结果CompletableFuture是体验最好的没有之一。实际开发里80%的等待完成场景最终都适合用CompletableFuture解决。CountDownLatch最大的痛点是你得手动管理计数器而且一旦子线程抛异常countDown()可能不会执行主线程直接卡死。这种资源死等问题我在生产环境见过不止一起。2.2 用CompletableFuture组合异步任务的完整示例下面我直接给一个能跑的示例场景是从三个数据源拉取用户的基础信息、订单记录和积分明细全部完成后合并展示。import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; public class CompletableFutureDemo { public static void main(String[] args) { ExecutorService executor Executors.newFixedThreadPool(3); CompletableFutureString userInfoFuture CompletableFuture .supplyAsync(() - queryUserInfo(), executor); CompletableFutureString orderFuture CompletableFuture .supplyAsync(() - queryOrder(), executor); CompletableFutureString pointsFuture CompletableFuture .supplyAsync(() - queryPoints(), executor); // 三个都完成后合并结果 CompletableFutureVoid allDone CompletableFuture .allOf(userInfoFuture, orderFuture, pointsFuture); allDone.join(); String result userInfoFuture.join() orderFuture.join() pointsFuture.join(); System.out.println(合并结果: result); executor.shutdown(); } private static String queryUserInfo() { sleep(800); return [用户信息]; } private static String queryOrder() { sleep(500); return [订单记录]; } private static String queryPoints() { sleep(900); return [积分明细]; } private static void sleep(long millis) { try { Thread.sleep(millis); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } }这段代码有几个细节值得注意supplyAsync支持自定义线程池不要用默认的ForkJoinPool.commonPool()因为公共池的线程数默认是CPU核数减一IO密集场景下会严重拖慢吞吐多个业务模块混用还会互相干扰allOf返回的是一个CompletableFuture 它本身没有结果作用是等待所有任务完成之后再用join()逐个拿结果join()相比get()好处是不会抛受检异常写起来更干净但它会把异常包装成CompletionException排查的时候要看cause链。2.3 生产环境踩坑异步任务的异常吞没问题上面那个示例看着很完美但放到生产环境马上会遇到一个棘手问题如果queryOrder()里抛了异常会发生什么答案可能跟你想的不一样——异常不会被打印也不会直接导致allDone失败你调用orderFuture.join()的时候异常才从CompletionException里冒出来。这带来的最大隐患是异常被延迟暴露甚至在某些代码路径里被吞掉。比如你只校验了allDone.isDone()然后就去处理其他逻辑异常就被静默忽略了。我自己的习惯是给每个异步任务都接一下 exceptionally 或 handle把异常日志在子线程里打清楚CompletableFutureString orderFuture CompletableFuture .supplyAsync(() - queryOrder(), executor) .exceptionally(ex - { System.err.println(查询订单失败: ex.getMessage()); return []; });注意exceptionally返回的默认值类型要保持一致否则编译都过不了。如果你的需求是某个子任务失败就整体失败那就不要用exceptionally兜底而是用allOf配合join让异常在汇总点统一抛出再走全局异常处理。2.4 线程池参数设置的一个建议前面提到最好自定义线程池那参数怎么定很多文章会甩出一个公式CPU密集型用CPU核数1IO密集型用CPU核数乘以2。实际经验是这种公式只能当起点真正的参数取决于你的IO等待占比。打个比方如果一次任务里IO等待占90%CPU计算占10%4核机器上线程数可以大胆地设到40甚至更高。但线程不是越多越好线程多了上下文切换成本会抵销并行收益。我惯用的做法是先按CPU核数 / (1 - 阻塞系数)估算阻塞系数先取0.8跑一轮压测再逐步调整。关键是要在代码里把线程池参数放到配置中心而不是写死在代码里这样线上出问题才能动态调不用发版。3. 动态代理面试必问代码却很少写明白3.1 JDK动态代理和CGLIB的区别动态代理是Java进阶绕不开的一环Spring的AOP、MyBatis的Mapper接口、Feign的远程调用底层全是它。面试必问日常写业务却几乎不用这就导致很多人对它的理解停留在会背概念。JDK动态代理的核心接口有两个InvocationHandler和Proxy要求目标类必须实现接口。它的原理是运行时基于接口生成一个Proxy类这个Proxy类实现同样的接口把方法调用转发给InvocationHandler的invoke方法。CGLIB则不同它直接在运行时生成目标类的子类通过覆写父类方法实现代理所以不要求目标类实现接口。一句话区分JDK代理是面向接口的代理CGLIB是面向继承的代理。Spring里默认策略是目标类有接口就用JDK代理没有接口才用CGLIB这个策略可以通过配置覆盖。3.2 手写一个通用日志代理理解原理比背概念有用原理说了半天不如自己写一个。我用JDK动态代理写一个方法耗时统计的通用工具import java.lang.reflect.InvocationHandler; import java.lang.reflect.Method; import java.lang.reflect.Proxy; public class LogProxy { public static T T createProxy(T target) { ClassLoader classLoader target.getClass().getClassLoader(); Class?[] interfaces target.getClass().getInterfaces(); InvocationHandler handler (proxy, method, args) - { long start System.currentTimeMillis(); Object result method.invoke(target, args); long cost System.currentTimeMillis() - start; System.out.println(方法 method.getName() 耗时 cost ms); return result; }; return (T) Proxy.newProxyInstance(classLoader, interfaces, handler); } public static void main(String[] args) { UserService service new UserServiceImpl(); UserService proxy createProxy(service); proxy.getUserById(1L); } interface UserService { void getUserById(Long id); } static class UserServiceImpl implements UserService { Override public void getUserById(Long id) { System.out.println(查询用户: id); } } }这段代码跑起来你会看到查询用户: 1和方法 getUserById 耗时 xxms两行输出。中间发生的事是你调用proxy.getUserById()实际进入的是handler里的invoke方法method参数就是被调用的方法对象再通过method.invoke(target, args)反射调用真实对象。很多人第一次写会踩一个坑在invoke里直接调用target里的方法这个没问题但如果是在代理类内部方法之间互相调用比如getUserById内部调用了另一个login方法这个login调用不会经过代理因为内部调用走的是target对象的this引用跟代理对象没关系。这个特性在Spring AOP里也有体现自调用不走代理是很多诡异问题的根源。3.3 动态代理的性能与替代方案有人会担心动态代理的性能尤其是反射调用method.invoke()。在绝大多数业务代码里这个开销完全可忽略。但如果你的代理方法调用频率极高比如每秒几十万次那method.invoke的反射开销就不能忽视了。这时候可以考虑两个优化手段一是缓存Method对象虽然反射调用本身还是有开销但至少省去了每次查找方法的时间二是换成字节码增强技术比如Byte Buddy或直接生成字节码像CGLIB的底层就是ASM。说白了JDK动态代理和CGLIB已经满足99%的场景再往底层走那是写框架的人该操心的。4. JVM类加载跳出双亲委派的背诵层面4.1 双亲委派模型到底解决了什么问题一聊到类加载几乎所有资料都会提到双亲委派但很少有人把它的意义讲透。我自己的理解是双亲委派解决的是类身份唯一性的问题。JVM判断两个类是不是同一个类的标准是类全限定名 定义它的类加载器。如果没有双亲委派每个类加载器都自己加载java.lang.String那么你写的代码里用的String是一个类某个框架里引用的String是另一个类两者类型不兼容就会抛出各种奇怪的ClassCastException。双亲委派的核心思想是优先让父加载器去加载父加载器加载不了才轮到子加载器这样核心类库永远由最顶层的启动类加载器加载保证全系统类身份一致。4.2 什么场景要打破双亲委派双亲委派不是万能的它有两个典型的不适场景SPI机制比如JDBC的DriverManager核心类是启动类加载器加载的但它需要调用由应用类加载器加载的mysql驱动实现父加载器拿不到子加载器的类只能通过线程上下文类加载器来突破热部署/热加载比如开发调试时的热重载、某些中间件的插件机制需要同一个类名可以被不同的类加载器加载出不同的版本双亲委派反而成了阻碍。这两个场景有个共同点需要打破父优先的默认顺序改成子加载器先加载或直接指定某个加载器加载。实现方式也简单继承ClassLoader覆写loadClass方法改一下委托逻辑就行。但我要提醒一句热部署这种玩法能手写就手写别一上来就上OSGi那套重量级框架中小项目根本扛不住它的复杂度。4.3 一次ClassNotFoundException的真实排查过程我回忆一次印象比较深刻的线上报错。某个服务升级之后部分机器启动时报ClassNotFoundException但同一个jar包在测试环境跑得好好的。当时第一反应是依赖冲突用mvn dependency:tree排查了一通没看出问题。最后发现是类加载器的关系这些机器上的应用被中间件通过自定义类加载器隔离加载新加的某个依赖在parent类加载器里也有一个旧版本双亲委派优先加载了旧版旧版里没有新加的类于是抛异常。这个问题的排查思路值得记一下遇到ClassNotFoundException第一反应不应该是这个类不存在而是这个类被谁加载了加载的是哪个版本。按这个思路去查类加载器通常比漫无目的地排除依赖更高效。5. Lambda与Stream写出高效且不被人骂的代码5.1 从list.stream().toArray()说起热搜词里有一个很细的写法list.stream().toArray()。这是JDK11开始支持的新语法之前是list.stream().toArray(String[]::new)。别小看这一个写法变化它背后牵扯的是Stream的一个常见困惑怎么把流收集成数组。日常开发里把Collection转数组的需求其实不少比如调用某个老接口、传参要求String[]。最传统的写法是String[] arr new String[list.size()]; list.toArray(arr);用Stream的写法是String[] arr list.stream().toArray(String[]::new);JDK11之后还能更精简String[] arr list.stream().toArray();但注意这个无参toArray返回的是Object[]不是String[]。如果你需要String[]还是要带构造器引用。这一点特别容易踩坑我看过不少同事在代码评审时因为这个被挂。5.2 Stream的惰性求值与短路求值Stream的底层设计里有几个概念不搞清楚写出来的代码容易出性能问题或者排查问题的时候思路全歪。第一个是惰性求值。filter、map这些中间操作不会立即执行只有遇到终结操作collect、forEach、count等才会触发整条流水线。这个设计的高明之处在于可以优化执行路径比如skip(1000).limit(10)这种组合底层可能只需要遍历很少的元素不用全量跑完。但副作用是如果断点在中间操作上你会发现代码没执行以为是bug其实是断点位置不对。第二个是短路求值。limit、anyMatch、findFirst这类操作在满足条件后可以提前终止流计算。这是处理无限流的关键比如你可以用Stream.generate(() - random.nextInt()).filter(n - n 1000).findFirst()它不会真的生成无限个随机数找到第一个大于1000的就会停。这两个概念对调优意义重大。有一个经典场景从一个很大的列表里找第一个满足复杂条件的元素直接用filterfindFirst跟先把整个列表collect成一个新列表再遍历找性能可能差一个数量级。5.3 parallelStream的性能陷阱很多人一听说parallelStream能并行处理就直接把所有的stream都换成parallelStream这种偷懒做法在多数情况下是灾难。parallelStream底层用的是共享的ForkJoinPool线程数默认是CPU核数减一。如果你的任务里有IO操作比如查数据库、调外部API这些线程会被阻塞严重影响整个JVM里所有使用parallelStream的任务。更严重的是如果某个IO操作超时很久线程池会被占满其他任务排着队等表现就是系统突然变得非常卡顿。我对parallelStream的态度是只用在CPU密集、数据量大、元素之间无共享状态且无顺序要求的场景。比如对100万个数字做质数判断这个适合。但如果你要在并行流里操作共享变量或者任务的执行顺序会影响结果还是老老实实手写线程池吧。5.4 收集器的几个实战技巧collect是Stream里最常被人用错的操作。很多人只会用Collectors.toList()其实收集器能做很多事分组后统计Collectors.groupingBy(category, Collectors.counting())一行代替好几行for循环多级分组groupingBy可以嵌套比如先按分类再按状态分组聚合操作Collectors.toMap的第三个参数可以传合并函数解决key冲突问题。举一个toMap的例子这是我最常看到同事踩坑的地方MapLong, String userMap userList.stream() .collect(Collectors.toMap(User::getId, User::getName));如果userList里有两条记录的id相同这一行直接抛IllegalStateException。正确的写法是加一个合并函数MapLong, String userMap userList.stream() .collect(Collectors.toMap(User::getId, User::getName, (oldVal, newVal) - newVal));看上去只是多了一个参数但这个报错在生产环境真的会深夜把人叫醒因为你可能逻辑上坚信id不会重复结果上游数据一脏整个接口就挂了。把合并函数一直写上是成本最低的防御性写法。6. 反射、泛型与枚举进阶绕不开的三个细节6.1 反射获取泛型真实类型的写法Java的泛型是编译期类型擦除的运行时你拿不到List 里的String。但有一个例外类字段的泛型信息通过反射是可以拿到的。比如你在类里定义了private ListUser userList;用field.getGenericType()拿到的是ParameterizedType从它里面可以取到实际类型参数Field field clazz.getDeclaredField(userList); ParameterizedType genericType (ParameterizedType) field.getGenericType(); Type[] actualTypeArguments genericType.getActualTypeArguments(); System.out.println(actualTypeArguments[0]); // class com.example.User这个技巧最常见的应用场景是写通用Mapper或者JSON反序列化工具时需要知道目标类型。比如封装一个通用的根据泛型类型转换数据的工具方法就必须走这条链路。不过这个逻辑有一定理解成本建议封装好之后写单元测试覆盖别指望一次性写对。6.2 枚举不只是常量它还能携带行为很多人用枚举只把它当成一组常量比如public enum Status { PENDING, APPROVED, REJECTED }这是浪费。Java的枚举是完整的类可以带字段、构造方法、抽象方法。进阶玩法是把策略逻辑放进枚举里。比如支付状态的流转判断用枚举加抽象方法public enum OrderStatus { PENDING { Override public boolean canCancel() { return true; } }, PAID { Override public boolean canCancel() { return false; } }; public abstract boolean canCancel(); }这种写法把原本散落在if-else里的逻辑收拢到枚举内部改一个逻辑只动一个地方比在service里写一堆状态判断好维护得多。我在一些中大型项目里推行过这种风格代码量的减少不一定明显但review和改需求的成本确实降了不少。6.3 枚举与switch的空指针隐患枚举配合switch有一个非常隐蔽的坑如果switch的表达式是null会直接抛NullPointerException。注意不是进入default分支而是直接异常。我见过很多老手在这个地方翻车因为他们的逻辑判断里枚举字段一定有值但实际上数据库里脏数据、接口传参不规范都可能传进来null。解决办法是进入switch之前先判空或者把判断逻辑封装到枚举内部方法里让枚举自己处理null问题。我个人更推荐后者因为判断状态是否允许某个操作这个逻辑本来就该属于状态本身不该让调用方来关心边界情况。7. 面向面试与实战的几道高频题拆解7.1 HashMap的底层演进Java面试十大高频题之一HashMap的底层原理。之前说过了JDK8以后是数组链表红黑树。我建议准备这个问题的时候不要只背结论而是把几个关键参数串成一条线初始容量16这个数字是2的整数次幂因为hash定位用的是位运算(table.length - 1) hash保证下标均匀分布负载因子0.75这是个时间与空间的折中值太高了冲突变多太低了浪费内存链表转红黑树的阈值是8是因为泊松分布的推算下负载因子0.75时链表长度到8的概率已经极低约千万分之六此时转树是防止极端hash冲突的兜底红黑树转回链表的阈值是6中间差了2是为了避免树和链表在阈值附近频繁切换。把这些数字背后的推算逻辑讲清楚面试官对你的评价就不会停留在背过八股这个层面。7.2 线程池的核心参数结合拒绝策略线程池的核心参数ThreadPoolExecutor的7个参数多数人能背出来corePoolSize、maximumPoolSize、keepAliveTime、unit、workQueue、threadFactory、handler。但面试官真正想听的不是参数名单而是你对任务提交流程的理解。正确流程是核心线程数没满直接开新线程满了任务进队列队列满了开新线程直到最大线程数最大线程数也满了走拒绝策略。这个流程里最值得讨论的是核心线程数、最大线程数、队列容量三者如何匹配你业务的流量模型。举一个实际的配置例子一个接收外部回调消息的服务大约每秒1000到5000条消息每条消息处理耗时为几十毫秒IO有网络等待。我会这么配new ThreadPoolExecutor( 50, 200, 60L, TimeUnit.SECONDS, new LinkedBlockingQueue(10000), new ThreadFactoryBuilder().setNameFormat(callback-pool-%d).build(), new ThreadPoolExecutor.CallerRunsPolicy() );我的考量是核心线程50应对平时每秒一千的量级绰绰有余队列1万缓冲突发流量最大线程200处理峰值拒绝策略用CallerRunsPolicy宁可让回调线程自己在压力大时处理也不轻易丢弃消息。线程名用ThreadFactoryBuilder这是排查问题时最值钱的习惯没有之一。7.3 synchronized与ReentrantLock的选择逻辑这俩对比也是高频题。基础结论synchronized是JVM内置锁自动释放ReentrantLock是API层面的锁需要手动解锁。JDK6优化synchronized之后两者的性能差距基本消失。那么选型依据就变成了功能的差异需要超时获取锁tryLock(3, TimeUnit.SECONDS)用ReentrantLock需要支持多个条件变量Condition用ReentrantLocksynchronized只有一个等待队列需要公平锁用ReentrantLock(true)如果是简单的互斥需求优先synchronized代码简洁不容易出错。多线程编程的复杂度一般不来自锁本身而来自你拿锁之后干了什么不可控的事。我见过最惨的线上故障是某个同学在持锁状态下调了一个外部HTTP接口对方服务超时锁迟迟不释放线程池被打满雪崩。锁内别做IO、别做远程调用这个铁律一定要记住。8. 从刷题到实战学习路线与资源推荐刷题刷到一定程度你可能会发现一个尴尬面试聊得头头是道回到工位写代码还是老样子。这说明你缺的不是知识而是把知识落到代码里的习惯。我整理了三条我认为对进阶最有用的实践路径。第一条把学到的知识点改写到自己的项目里。比如你刚学会用CompletableFuture优化异步逻辑就找一个老代码里的CountDownLatch实现重写一遍学会了Stream的groupingBy就回老项目里找一个写的很啰嗦的分组统计逻辑改写成Stream版本。这个过程看似简单但在真实业务里应用和在Demo里应用的难度完全不同你能迅速发现哪些知识是纸上谈兵。第二条保持刷题但改变刷题方式。很多人的刷题是看答案、记答案、复述答案这样效果很差。我带人的习惯是要求候选人把一道题拆成三问这题解决什么问题、为什么要这么解决、如果不这么做会怎样。一道题能回答出这三问才算真正掌握。第三条学会看源码。不是让你从头到尾读完JDK源码那不现实也没必要。挑高频类看比如ArrayList的扩容、HashMap的put流程、ThreadPoolExecutor的execute方法、AQS的acquire流程每个类只看主流程跳过细节分支。先建立整体骨架再逐步填充细节。推荐的资源如果让我只保留三个阿里开源的Java开发手册里面没废话全是规范适合日常对照自查经典在线题库网站刷题要用那种带讨论区、能看到别人不同解法的思路碰撞比答案本身更有价值JDK源码本身IDEA里点击类名就能看到没有比这更权威的资料了。我自己的经验是学习路线定得太满反而执行不下去。与其列一个20本的读书计划不如把上面三个资源反复吃透消化完再扩展。9. 环境与工具链进阶路上的隐形绊脚石9.1 JDK版本选择与多版本管理涉及环境问题热搜词里有一条java环境变量配置一条java下载安装说明很多人还在环境配置上消耗精力。这事看起来基础但版本选错、多版本切换搞不定后续所有的学习都会被拖慢。我的建议是装机时直接装JDK 17或21LTS版本稳定性和新特性都不错是当前Java开发的主流。但如果你要维护老项目可能还得保留JDK 8。这时候别手动改环境变量用版本管理工具Linux/macOS下用sdkmanWindows下可以用官方安装包自带的版本切换功能或者一些第三方管理器。按一下命令就能切版本省下的时间远超配置成本。环境变量配置本身不难核心是JAVA_HOME指向JDK安装目录PATH里加上%JAVA_HOME%\bin。配置完一定要重新开一个终端窗口因为环境变量只在新的进程里生效这个问题我见新人踩过无数次。9.2 IDE配置里的几个实用项IntelliJ IDEA是多数Java开发者的首选但很多人装了就用默认配置有几个设置项建议开一下。第一是代码编译级别和项目SDK一致。这个设置不对经常出现编译期不报错、运行期报错或者本地好好的、打包到服务器上就报错的诡异问题。第二是强制开启编译告警。把unchecked warningdeprecated API这些设置成警告级别而不是忽略。这些警告里藏着很多潜在问题比如泛型裸类型、过时API调用早发现早处理比上线后爆炸强得多。第三是善用Live Templates。把常用代码片段存成模板比如try-finally关闭资源的写法、日志打点的格式、单元测试的骨架。这个东西初看不觉得熟练后写代码效率至少提升两成。9.3 构建工具不止是打包Maven和Gradle很多人的认知停留在点一下编译就能跑。进阶开发者至少要知道如何看依赖树、如何排除传递依赖、如何处理依赖冲突、如何在多模块项目里组织依赖关系。遇到ClassNotFoundException或NoSuchMethodError的时候第一步就是看依赖树定位这个类来自哪个jar被什么依赖引入又和哪个jar里的版本冲突了。Maven下的命令是mvn dependency:treeGradle下是gradle dependencies。排查的次数多了你就会发现大量线上异常案例的根源都在依赖冲突上这个经验属于踩坑才能积累的今天先帮你们把排查路径指明了。10. 常见问题与排查技巧实录10.1 编译报错乱码问题热搜词里有一条vscode运行java报错乱码。这个问题的本质是编码不一致。VSCode运行Java程序时控制台输出用的编码跟代码文件声明的编码不一致最常见的是UTF-8的代码文件在Windows默认GBK的控制台下输出乱码。解决办法有两个方向 一是统一文件编码确保文件、编译编码、控制台编码都是UTF-8。VSCode的settings.json里可以设置java.debug.settings.consoleEncoding: utf-8以及terminal.integrated.profiles.windows相关的编码配置。二是每个Java程序里强制指定输出编码System.setOut(new PrintStream(System.out, true, UTF-8));这个写法在排查问题的时候很好用但如果在正式代码里用会有一些争议我更推荐把运行环境的默认编码统一为UTF-8这属于一劳永逸的解法。10.2 常见运行时异常排查速查表不同阶段会遇到不同类型的异常我把它们整理成一个表格方便到时候对照异常类型常见原因排查思路ClassNotFoundException类不在classpath或类加载器隔离检查依赖树、jar打包是否完整、确认类加载器NoSuchMethodError依赖冲突运行期版本与编译期不一致定位异常调用方反编译确认方法签名检查依赖版本NullPointerException调用链上某处值为null看完整堆栈优先定位链路的源头不要在下游排查ConcurrentModificationException遍历时修改集合检查是否有并发写改用迭代器或并发集合OutOfMemoryError堆内存不足先dump堆栈分析对象占用区分是泄漏还是峰值过大10.3 排查工具的基本用法最后一个板块分享几个我常用的排查工具和它们的适用场景。线上Java服务出问题时jstack用于看线程状态能定位死锁和线程阻塞jstat看GC情况判断是不是GC过频繁导致停顿jmap生成堆转储分析内存泄漏。这些命令被很多文章写过基础用法我补充一个经验grep之前先用jstack -l拿到完整堆栈再对比线程的nid和CPU占用高的线程号。用top -Hp看到CPU高的线程ID转成十六进制去jstack输出里找到对应的线程栈就能定位到具体代码行这套操作是线上排障的基本功。遇到内存泄漏别急着提issue先用jmap -dump:formatb,fileheap.bin导出堆用MAT或VisualVM分析Dominator Tree看大对象被谁引用。多数情况下几分钟就能定位到问题类。排查的过程比结论更重要。每解决一个问题把排查思路记下来过半年回头看你会发现自己对Java的理解已经不在一个层面上了。我最后再啰嗦一句进阶最忌讳贪多嚼不烂、一上来就想全面开花。找一个正在开发的小项目把一个知识点反复用、用出花样比看一百篇帖子有用得多。当初我就是靠把一个工具类反复重写了七八遍才把反射和泛型这块彻底吃透的这个笨办法到今天我都想推荐给你。
