社区问答区里泡久了,你会发现 Java 板块的提问翻来覆去就那么几类:一段代码跑出来的结果和预期不一样、线上偶发一个说不清的异常、某个注解加了却像没加。标题起得五花八门,点进去看,根子大多落在几个老地方——对象语义没吃透、共享可变状态没管住、容器和框架的默认行为没搞清楚。这些问题单看都很碎,但它们背后的认知缺口高度重合,所以我一直想找个机会把它们按层拆一遍。
这篇东西不是面试题合集,也不是 API 手册。它解决的是“看到一段有问题的 Java 代码,我该从哪一层开始怀疑”这个问题。适合工作一到三年的开发,也适合带新人的同学拿去当排查思路的参照。读完之后你至少能做到两件事:别人贴一段代码问为什么不对,你能在三分钟内判断问题出在语义层、并发层还是框架层;自己写代码时,能提前避开那批最容易踩的坑。
1. 这些问题为什么值得单独拎出来讲
1.1 一个提问背后往往藏着三层认知缺口
我观察过大量提问帖,发现一个规律:提问者描述的“现象”和他真正的“问题”经常不是一回事。有人问“为什么我的 HashSet 里出现了两个一模一样的对象”,现象是集合行为异常,真正的问题是equals和hashCode只重写了一半。有人问“为什么加了锁还是超卖”,现象是并发不安全,真正的问题是他锁的对象每次都在变。
再往下挖还有一层:他根本不知道自己不知道什么。比如很多人从没意识到 Java 的整数装箱是有缓存的,==比较两个Integer有时候是 true 有时候是 false,于是他把这当成“玄学”,绕着走,而不是去搞明白规则。
这三层缺口对应的解决方式完全不同。现象层只需要改代码,语义层需要补概念,认知层需要重建判断习惯。如果只按现象给答案,提问的人下个月还会拿一个变形的问题回来问。
1.2 这类内容真正的价值在“判断顺序”而不是“标准答案”
我不太喜欢直接给答案。Java 的很多问题有多个“正确”答案,取决于版本、取决于使用场景、取决于你到底想要什么语义。比如问“字符串拼接用+还是StringBuilder”,答案是“看你在哪拼”。单条表达式里编译器自己就优化了,循环里手写+就是自找麻烦。
真正有价值的是判断顺序:先确认事实,再定位层次,最后才谈方案。这个顺序错了,后面全是白费功夫。我见过太多人一上来就怀疑框架有 bug,查了三个小时,最后发现是自己类没被 Spring 管理。
所以下面我按“语义、并发、集合性能、框架、排查”这五层来组织,每层挑几个被问得最多的问题,把现象、根因、验证方法、修正方案都摆出来。你可以把它当成一份排查地图,遇到新问题的时候先定位它在哪一层,再往下查。
2. 先给问题分个层:把“报错”翻译成“知识点”
2.1 四类高频问题的共同特征
我把社区里反复出现的 Java 问题归成四类,每类的特征很鲜明。
语义类问题的特征是“结果确定但不符合直觉”。同样的代码跑一百次都是一样的错误结果,这恰恰说明它不是偶发 bug,而是你对语言规则的理解有偏差。典型代表是==与equals的区别、基本类型与包装类型的转换、重载方法的解析顺序。
并发类问题的特征是“偶发、难复现、加日志就消失”。这类问题最耗时间,因为你在本机跑一万次都正常,上线之后一天出一次。典型代表是共享SimpleDateFormat、非原子自增、可见性问题。
集合与性能类问题的特征是“功能对,但慢或者内存涨”。这类问题不会立刻暴露,往往在数据量上去之后才显现。典型代表是循环里拼字符串、集合没预设容量、无界队列堆积。
框架类问题的特征是“配置看起来没问题,但就是不生效”。这类问题最容易被误判为框架 bug。典型代表是事务注解失效、异步注解失效、循环依赖启动报错。
分清这四类,你就知道该往哪个方向找证据。语义类去看代码本身,并发类去看共享状态,性能类去看数据量和调用频次,框架类去看代理和容器管理。
2.2 我常用的分类框架与判断顺序
具体怎么判断?我做了一张对照表,遇到问题时按“现象关键词”先去匹配,能省掉大量试错时间。
| 现象关键词 | 大概率所属层 | 第一件该确认的事 | 常用验证手段 |
|---|---|---|---|
| 结果稳定复现、逻辑看着没问题 | 语义层 | 涉及的类型是基本类型还是包装类型 | 打印getClass()和实际值 |
| 偶发、并发量上来才出现 | 并发层 | 有没有共享的可变对象 | 压测复现、加线程数 |
| 单次正常、批量处理变慢 | 性能层 | 循环内有没有重复创建大对象 | 打时间戳分段计时 |
| 注解加了但行为没变 | 框架层 | 当前调用是否经过代理 | 打印this.getClass()看是否被代理 |
判断顺序上,我的习惯是“先排除简单解释”。如果一个问题是稳定复现的,那它百分之百是代码逻辑或语言规则问题,跟并发、跟框架都没关系,别浪费时间去看源码。反过来,如果一个问题只在压力下出现,先别急着改逻辑,先确认是不是共享状态。
这里有个很多人会忽略的细节:代理对象的类名和原始类名不一样。你在被 Spring 管理的 bean 里打印this.getClass().getName(),如果看到类似XxxService$$EnhancerBySpringCGLIB$$...这样的名字,说明它被代理了;如果是干净的类名,说明某些代理机制没生效,事务、异步这些依赖代理的功能自然也失效了。这一招在排查注解失效时特别好使。
提示:判断代理是否生效,比翻文档快十倍。打印一次类名,胜过查半小时配置。
3. 语义层:那些“看起来对、跑起来错”的坑
3.1 equals 与 hashCode 的契约,破坏一半会怎样
这是社区里被问得最多的问题,没有之一。提问形式通常是:“我往HashSet里加了两个内容一样的对象,为什么 size 是 2?”
根本原因是 Java 对equals和hashCode有一条明确契约:如果两个对象equals返回 true,它们的hashCode必须相等。如果你只重写了equals,两个内容相同的对象会有不同的哈希值,落到哈希表的不同桶里,压根不会触发equals比较,于是“重复”就被放进去了。
public class User { private String name; private int age; public User(String name, int age) { this.name = name; this.age = age; } @Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof User)) return false; User user = (User) o; return age == user.age && Objects.equals(name, user.name); } // 故意不重写 hashCode public static void main(String[] args) { Set<User> set = new HashSet<>(); set.add(new User("A", 20)); set.add(new User("A", 20)); System.out.println(set.size()); // 输出 2,不是 1 } }哈希表的查找逻辑是两步:先用hashCode定位桶,再在桶里用equals逐个比较。第一步定位错了,后面的比较根本不会发生。所以正确的做法是两个一起重写,且参与计算的字段要一致——equals里用了name和age,hashCode里也得用这两个。
还有一个更隐蔽的坑:对象作为 key 之后修改了参与哈希计算的字段。比如你把User放进HashMap之后改了name,这个 key 的哈希值变了,再用它去get就找不到了。这不是HashMap的 bug,是你违反了自己声明的契约。我在实际项目里碰到过一次,排查了整整一个下午,最后发现是缓存层复用了同一个可变对象当 key。
提示:凡是进集合当 key、或者进
Set去重的对象,尽量设计成不可变对象。改不了,就不会有哈希漂移。
3.2 Integer 缓存与 == 的关系
第二个高频问题:“为什么Integer a = 127; Integer b = 127;打印 true,换成 128 就变成 false 了?”
这属于典型的语义层认知缺口,很多人把它当成编译器抽风。实际规则很清晰:Integer的自动装箱走的是Integer.valueOf(int),这个方法内部维护了一个缓存数组,默认范围是 -128 到 127。落在这个范围内的整数直接返回缓存对象,所以两次装箱拿到的是同一个引用,==比较自然为 true。
Integer a = 127; Integer b = 127; System.out.println(a == b); // true,命中缓存 Integer c = 128; Integer d = 128; System.out.println(c == d); // false,超出缓存,各自 new Integer e = new Integer(127); System.out.println(a == e); // false,new 一定创建新对象 System.out.println(a.equals(e)); // true,equals 比的是值缓存上限默认是 127,可以通过启动参数-XX:AutoBoxCacheMax=1000调整,但它调的是上限,下限 -128 是写死的。
这个问题在业务代码里的实际危害比你想象的大。我见过有人用==比较订单金额的Integer字段,测试环境金额小,全部命中缓存,跑得飞快;生产环境订单一过千,判断集体失效,变成了“金额不同的两笔订单被判为相等”。这种 bug 不报错、不抛异常,只是默默算错。
结论很干脆:包装类型的值比较一律用equals,或者先转成基本类型再比。别依赖缓存范围,那是实现细节,不是语义保证。
3.3 i++ 的原子性错觉与 volatile 的边界
“我加了 volatile,为什么自增还是会少?”这个问题的提问频率仅次于前面那个。
先看代码:
public class Counter { private volatile int count = 0; public void increment() { count++; // 这不是一个原子操作 } }count++在字节码层面是四步:读取字段值、压入常量 1、执行加法、写回字段。volatile保证的是可见性和禁止重排序,不保证这四步作为整体不被打断。线程 A 读到 5,还没写回,线程 B 也读到 5,两个线程各自加一写回,最终结果是 6 而不是 7。
解决办法有三个层次的取舍。最低成本是加锁,synchronized或者ReentrantLock,正确但吞吐差。第二种是用AtomicInteger的incrementAndGet(),基于 CAS 循环,无锁且正确,适合竞争不激烈的场景。第三种是高竞争场景用LongAdder,把热点分散到多个单元格上,最后求和,吞吐更高但只能拿到最终结果,中途读取的值不精确。
private final AtomicInteger counter = new AtomicInteger(0); public void increment() { counter.incrementAndGet(); }注意:
volatile的正确用途是“一个线程写、多个线程读”的状态标记,比如volatile boolean running。凡是涉及“读—改—写”复合操作的,它都顶不住。
这里还牵出一个常被忽略的点:volatile的可见性保证是有前提的,它保证的是对这个变量的读写不会被缓存到线程本地,但不保证复合操作的原子性。这两个概念经常被混为一谈,导致有人觉得“加了 volatile 就线程安全了”。真要建立正确认知,得把 Java 内存模型里“原子性、可见性、有序性”这三件事分开理解,缺一不可。
4. 并发层:共享可变状态是万恶之源
4.1 SimpleDateFormat 出错的真实原因
这个坑几乎每个团队都踩过。现象是:日期格式化偶尔出现乱码、年份变成奇怪的数字,或者直接抛NumberFormatException。而且诡异的是,本地单线程测试永远正常。
根因在于SimpleDateFormat内部持有一个Calendar对象作为可变状态,format和parse方法都会先 clear 再 set 这个 Calendar。多个线程共用一个实例时,A 线程刚 set 完年份,B 线程把整个 Calendar 清了,A 再去读就拿到了脏数据。
解决办法按推荐程度排序。第一选择是换成DateTimeFormatter,它是不可变且线程安全的,配合LocalDateTime、LocalDate使用,API 也更清晰:
private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); public String format(LocalDateTime time) { return FORMATTER.format(time); } public LocalDateTime parse(String text) { return LocalDateTime.parse(text, FORMATTER); }如果因为历史原因必须用SimpleDateFormat,那就用ThreadLocal每个线程一份,或者每次方法内 new 一个——后者在小流量下完全没问题,别为了省一个对象搞出并发 bug。至于synchronized包一层,正确但会把这行代码变成串行点,高频调用下得不偿失。
我在实际项目里做过一次统计,一个中等流量的接口,每次请求 new 一个DateTimeFormatter是完全可接受的,因为它本身就是不可变对象,构造开销很小。真正贵的是SimpleDateFormat那种带内部状态的类。所以选型的时候优先看“是否可变”,而不是“是否 new”。
4.2 线程池参数该怎么算,队列怎么选
线程池的问题在社区里分两派:一派问参数怎么配,一派问为什么不生效。先解决配参数。
参数计算有个常用的经验公式。设 CPU 核数为 N:
- CPU 密集型任务(大量计算、少量等待):核心线程数设为 N + 1。多出来的一个用于在某个线程偶发缺页或短暂阻塞时顶上。
- IO 密集型任务(大量数据库查询、远程调用):核心线程数设为 N × (1 + 平均等待时间 / 平均计算时间)。这个公式的本质是让 CPU 在等待期间有活可干。
举个例子,8 核机器,任务平均计算 10 毫秒、等待 90 毫秒,那么理想线程数是 8 × (1 + 9) = 80。当然这是理论上限,实际还要考虑下游数据库能扛多少并发,别把压力全推给数据库。
比参数更容易出错的是队列的选择。很多人用默认的Executors.newFixedThreadPool,它内部是无界队列,任务堆积起来永远不会触发拒绝策略,内存一路涨到 OOM。正确的做法是显式指定有界队列,比如new ArrayBlockingQueue<>(200),队列满了触发拒绝策略。
拒绝策略我优先选CallerRunsPolicy,它让提交任务的线程自己执行,形成天然的背压——线程池忙不过来时,上游会被拖慢,而不是把任务丢掉或者抛异常。这在 web 场景下比AbortPolicy温和得多。
还有一个执行顺序的细节,很多人搞反了:线程池接到任务后,先看核心线程数满没满,没满就新建线程;满了就进队列;队列满了才把线程数扩到最大;最大也满了才走拒绝策略。也就是说,队列容量设得太大,最大线程数永远用不上。这个顺序搞明白,配参数的时候就不会盲目调大队列。
4.3 双重检查锁为什么少不得 volatile
单例模式的双重检查写法,是并发层里被讨论最多的一段代码:
public class Singleton { private static volatile Singleton instance; public static Singleton getInstance() { if (instance == null) { synchronized (Singleton.class) { if (instance == null) { instance = new Singleton(); } } } return instance; } }为什么必须加volatile?因为instance = new Singleton()在字节码层面分三步:分配内存、初始化对象、把引用赋给变量。步骤二和步骤三在某些处理器上可能重排序。如果发生重排,另一个线程在第一次判空时可能看到一个非 null 但还没初始化完成的对象,然后拿着它去用,拿到的字段全是默认值。
volatile在这里的作用是禁止这个重排序,保证“初始化完成”一定发生在“引用可见”之前。少了这个关键字,代码在单线程下永远正确,在多线程下概率性出错——最难查的那一类。
顺带说一句,实际项目里我更推荐静态内部类写法,靠类加载机制保证线程安全,代码还更短:
public class Singleton { private Singleton() {} private static class Holder { private static final Singleton INSTANCE = new Singleton(); } public static Singleton getInstance() { return Holder.INSTANCE; } }类加载是线程安全的,INSTANCE在类初始化时创建,天然延迟加载。除非你需要传参构造,否则没理由手写双重检查。
5. 集合与性能:别让容器替你的疏忽买单
5.1 ArrayList 扩容的代价与预设容量
ArrayList的默认初始容量是 10,但这个数组是在第一次add时才分配的,构造时只是给了一个共享的空数组。之后每次容量不够,按oldCapacity + (oldCapacity >> 1)扩容,也就是 1.5 倍,然后调用Arrays.copyOf把老数据整体复制过去。
单次扩容不贵,但累积起来很可观。假设你要装 100 万个元素,从 10 开始一路 1.5 倍扩上去,中间会经历二十多次扩容,累计复制的元素数量大约是最终规模的两倍多。如果预先知道规模,直接new ArrayList<>(1000000),一次分配到位,省掉所有复制。
// 不推荐:默认容量,反复扩容 List<Order> list = new ArrayList<>(); for (Order o : orders) { list.add(o); } // 推荐:已知规模直接指定 List<Order> list = new ArrayList<>(orders.size());还有一个反直觉的点:ArrayList删除元素比添加贵得多。remove(i)之后要把后面所有元素往前挪一位,是 O(n) 操作。如果在循环里做删除,用迭代器的remove,或者干脆倒序遍历,避免索引错位。这个坑我在写批量处理逻辑时踩过,删除条件命中率高的时候,性能直接从秒级掉到分钟级。
5.2 HashMap 的 8 和 64 到底在防什么
面试常问“HashMap的树化阈值为什么是 8”,很多人背了答案但没理解。这里说清楚。
HashMap在链表长度达到 8 且数组容量至少 64 时,会把链表转成红黑树。选 8 这个数不是拍脑袋,是基于泊松分布的统计结果:在哈希函数分布均匀的理想情况下,一个桶里链表长度达到 8 的概率大约是千万分之六。也就是说,正常情况下你几乎遇不到树化,一旦遇到,说明哈希分布出了问题,比如大量 key 的hashCode冲突,这时候需要红黑树把查询从 O(n) 拉回 O(log n)。
MIN_TREEIFY_CAPACITY = 64这个条件更值得注意。当数组容量还小的时候,即使链表长了,也优先扩容而不是树化——扩容会把元素重新打散到更多桶里,可能冲突就自然消失了。扩容比维护红黑树便宜。所以真实的判断顺序是:链表长度到 8,先看数组容量够不够 64,不够就扩容,够了才树化。
退化阈值是 6,不是 8,中间留了一个缓冲,防止在 7、8 之间反复横跳,来回转换结构。
实际使用上的建议只有一条:能预估规模就指定初始容量。比如确定要放 1000 个元素,按负载因子 0.75 反推,初始容量设 1024 就够,能避免多次扩容。但别设得过于精确,扩容的触发条件是size > capacity * 0.75,卡在边界上很容易多扩一次。
5.3 循环里拼字符串为什么慢得离谱
这个问题在社区里出现频率极高,代码通常长这样:
String result = ""; for (int i = 0; i < 10000; i++) { result += i; }看起来没什么,实际复杂度是 O(n²)。因为String是不可变的,每次+=都会创建一个新的StringBuilder、append 进去、再toString()生成新字符串,上一轮的字符串变成垃圾。一万次循环意味着创建一万个StringBuilder和一万个中间字符串,GC 压力巨大。
StringBuilder sb = new StringBuilder(10000 * 5); for (int i = 0; i < 10000; i++) { sb.append(i); } String result = sb.toString();这里我还想补一个容易被忽略的细节:编译器确实会优化字符串拼接,但只在单条表达式内。像"a" + b + "c"这样的单句,编译器会合成一次StringBuilder操作。但循环里的+=涉及跨迭代的状态,编译器不会帮你提到循环外面,只能老老实实每次都 new。
StringBuilder和StringBuffer的选择也很简单:单线程用前者,需要线程安全用后者。但说实话,我极少见到真正需要StringBuffer的场景,因为字符串拼接几乎总在方法内部的局部变量上进行,天然线程隔离。把共享的StringBuilder当成员变量用,才是真正该警惕的写法。
6. 框架层:注解不生效的几种典型姿势
6.1 事务失效的六种场景与自检清单
“我加了@Transactional,为什么还是脏数据?”这个问题在问答区几乎每周都有人问。事务失效的原因很集中,我列成清单,遇到问题逐条排查就行。
| 场景 | 失效原因 | 修正方式 |
|---|---|---|
| 同类内部方法直接调用 | 走的是this,不经过代理 | 拆到另一个 bean,或注入自身代理 |
| 方法不是 public | 早期版本代理无法拦截非 public 方法 | 改成 public |
| 异常被自己 catch 了 | 容器感知不到异常,不会回滚 | 别吞异常,或手动setRollbackOnly |
| 抛的是受检异常 | 默认只对RuntimeException和Error回滚 | 加rollbackFor = Exception.class |
| 在新线程里调用 | 事务绑定在线程上下文上,跨线程丢失 | 别在异步线程里指望外层事务 |
| 类没被容器管理 | 自己new出来的对象没有代理 | 交给容器管理 |
第一条是最容易中招的。this.saveOrder()这种调用看起来是“调了自己的方法”,实际上绕过了代理对象,事务拦截器根本没机会介入。解决办法要么把方法挪到另一个 bean 里,要么注入自己的代理引用(注意可能有循环依赖问题,需要配@Lazy)。
第四条也很典型。很多团队封装了统一的业务异常,继承自Exception,抛出去之后事务不回滚,数据已经写了一半。这类问题在测试环境很难发现,因为测试用例通常不校验数据一致性。
提示:排查事务问题,第一步永远是确认当前对象是不是代理对象。打印类名,一秒见分晓。
6.2 循环依赖为什么能解决,又为什么有时解决不了
Spring 用三级缓存解决单例 bean 的循环依赖:一级缓存放完整对象,二级缓存放早期暴露的对象,三级缓存放对象工厂。A 依赖 B、B 依赖 A 时,A 实例化后先把工厂放进三级缓存,去填充 B 的属性,B 需要 A 时从三级缓存拿到早期引用,B 完成初始化,A 再完成初始化。
但有两种情况它解决不了。第一种是构造器注入的循环依赖,因为对象还没实例化完,压根没有引用可以提前暴露。第二种是原型作用域的循环依赖,容器不缓存原型 bean。
实际项目里,循环依赖能解决不代表应该存在。我见过一个 service 里注入了七八个其他 service,互相之间还打转,最后靠三级缓存勉强启动。这种设计一旦有改动就容易炸。合理的做法是把公共逻辑下沉到更细的组件,或者用事件机制解耦。真遇到启动报错,第一反应应该是“我的依赖关系画错了吗”,而不是“怎么把这个错绕过去”。
6.3 @Async 静默失败的排查路径
@Async的问题特点是不报错、不生效、毫无提示。方法照常返回,只是变成同步执行了。
排查路径我固定成三步。第一,看注解所在的方法是不是被同类内部调用,如果是,跟事务失效是同一个原因,没走代理。第二,看启动类或配置类上有没有开@EnableAsync,这个漏了的情况非常多,因为编译期不报错。第三,看有没有自定义线程池,不配的话默认用SimpleAsyncTaskExecutor,每次调用新建线程且不复用,在高并发下会直接把线程数打爆。
@Configuration @EnableAsync public class AsyncConfig { @Bean("bizExecutor") public Executor bizExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(8); executor.setMaxPoolSize(16); executor.setQueueCapacity(200); executor.setThreadNamePrefix("biz-async-"); executor.setRejectedExecutionHandler( new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; } }另外注意返回值。异步方法返回void时,异常会被吞掉,你连出错都不知道;返回Future或CompletableFuture才能拿到异常。生产环境我建议一律返回CompletableFuture,至少留个可以捕获异常的口子。
7. 现场排查:从一段报错到定位根因
7.1 异常与现象速查表
排查这件事,经验的价值主要体现在“看到这个现象先怀疑什么”。我整理了一份自己常用的速查表,都是这些年踩出来的。
| 现象 | 优先怀疑 | 验证方式 |
|---|---|---|
ConcurrentModificationException | 遍历集合时结构性修改 | 检查循环里有没有add/remove |
NullPointerException找不到源头 | 链式调用中某环为 null | 拆开链式调用逐段打日志 |
| 内存持续上涨不回落 | 集合只增不减、缓存没淘汰 | 导出堆看对象数量排名 |
| CPU 打满但 QPS 不高 | 死循环、正则回溯、频繁 GC | 抓线程栈看热点方法 |
| 接口偶发超时 | 下游慢、连接池不够、锁竞争 | 分段计时定位耗时环节 |
| 启动报循环依赖 | 构造器注入形成的环 | 看报错里的 bean 链路 |
ConcurrentModificationException这个特别值得说。它不是“并发”导致的,单线程也会抛。ArrayList内部维护一个modCount,迭代过程中只要结构被改动(增加、删除),modCount就和迭代器记录的期望值对不上,直接 fail-fast。正确做法是用迭代器的remove方法,或者用removeIf。
7.2 我的四步定位法
遇到问题我固定走四步,顺序不乱。
第一步,稳定复现。不能复现的问题等于没找到问题。如果本地复现不了,就去找触发条件:特定数据、特定并发量、特定时间点。我遇到过只在整点出现的超时,最后发现是定时任务和业务请求抢同一个连接池。
第二步,缩小范围。二分法砍代码,注释掉一半看还出不出。日志也是同理,在关键节点打时间戳,看耗时集中在哪一段。这一步最枯燥但不能跳。
第三步,提出假设并验证。别一次改五个地方再跑,那样即使好了你也不知道是哪个改动起的作用。一次只改一个变量,改完立刻验证。
第四步,回写记录。解决完把根因、验证方式、修正方案记下来。我有个毛病是同一个坑踩两次,后来强迫自己每次排查完写三行笔记,情况好了很多。
7.3 手上要常备的几个工具命令
不用装任何额外东西,JDK 自带的几个命令就能解决大部分现场问题。
# 看 Java 进程号和启动参数 jps -lvm # 抓线程栈,连续抓三次,看哪个线程一直停在同一个位置 jstack <pid> > stack1.txt jstack <pid> > stack2.txt # 看堆内存分布和各代使用情况 jmap -heap <pid> # 导出堆快照,配合分析工具看对象占用 jmap -dump:format=b,file=heap.hprof <pid> # 看 GC 频率和停顿 jstat -gcutil <pid> 1000jstack连抓三次这个技巧特别实用。单次线程栈只能看到瞬间状态,连抓三次对比,一直卡在同一个方法上的线程就是嫌疑对象。死锁、长时间等待、线程池打满,基本都能这么看出来。
jstat的输出重点看两个:老年代使用率涨得快不快,以及 Full GC 的次数和耗时。如果老年代几分钟就涨满、Full GC 频繁但回收效果差,基本可以确定有对象被长期持有,往缓存或者静态集合的方向查。
8. 回答别人问题时,我自己养成的几个习惯
8.1 先问版本,再谈结论
Java 的版本差异比很多人想象的大。Optional是 8 才有的,var是 10 才有的,switch表达式是 14 才转正的,虚拟线程是 21 才正式发布的。同一个问题在 8 和 17 上答案可能完全不同。
我见过有人拿着 8 的项目问“为什么 record 不能用”,也见过有人在新版本上纠结HashMap的扩容细节,而新版本已经改过实现。所以我现在看到提问,第一反应是先确认版本,版本不对,后面所有分析都是浪费。
框架版本同理。有的行为在某个小版本里改过,升级之后语义就变了。严谨的做法是把版本号写进问题描述里,这也是我建议提问的人养成的习惯。
8.2 把“能跑”和“正确”分开看
这是我这些年最深的体会。一段代码能跑通、能出正确结果,和它在所有情况下都正确,是两码事。前面提到的Integer用==比较,金额小的时候能跑;SimpleDateFormat单线程用能跑;事务在没异常的情况下能跑。这些都是“能跑但不正确”。
养成区分这两者的习惯之后,我审查代码的角度变了。以前看“逻辑对不对”,现在还会看“在并发下、在大数据量下、在异常路径下,它还对吗”。这三个问题问出来,能提前发现一大半隐患。
最后分享一个我自己在用的笨办法:每次解决完一个社区问题,我会把根因用一句话写下来,攒在一个文档里。写了两年多,现在遇到新问题,翻一翻旧记录,经常能找到结构相似的案例。排查能力这东西,靠的不是记性好,是把踩过的坑整理成了可以复用的判断路径。