Java 并发基础怎么抓重点:synchronized、volatile、Lock、死锁和 jstack

2次阅读
没有评论

Java 并发知识很容易越学越散:线程状态、synchronizedvolatileLock、死锁、jstack、ThreadLocal、SimpleDateFormat,每个点都能延伸很远。真正写业务代码时,最重要的是先把几个边界分清:共享状态在哪里,是否需要原子性,锁会不会嵌套,出问题时怎么把线程栈抓出来。

这篇从源 note 里抽出一条主线,按日常开发最常用的顺序整理一遍。

线程创建只是入口,不是并发设计

Java 创建线程常见有三种方式:

  • 继承 Thread,重写 run
  • 实现 Runnable,没有返回值。
  • 实现 Callable,配合 FutureTask 或线程池拿返回值。

业务代码里更常见的是线程池、Spring @AsyncCompletableFuture。这些本质上还是把任务交给线程执行,只是多了生命周期、队列、拒绝策略和异常处理。

所以不要把“能启动线程”当成并发设计。并发设计真正要回答的是:

  • 这个任务有没有共享状态?
  • 共享状态是否会被多个线程修改?
  • 修改是否需要整体原子性?
  • 失败、超时、取消和资源释放怎么处理?

线程状态先够用就好

线程状态不用一开始就背得很玄。先记几个排障时常见的:

  • RUNNABLE:可运行,可能正在跑,也可能等 CPU。
  • BLOCKED:等进入 synchronized 锁。
  • WAITING:无限期等待,例如 Object.wait()LockSupport.park()
  • TIMED_WAITING:带超时等待,例如 sleepwait(timeout)join(timeout)
  • TERMINATED:线程结束。

如果线上线程很多处于 BLOCKED,先看大家在抢哪把锁。如果大量 WAITING,要看是不是线程池空转、队列等待,或者某个条件永远没被唤醒。

sleep、wait、join、yield 的差别

最容易混的是 sleepwait

sleep 属于 Thread,只是让当前线程睡一会儿,时间到了回到可运行状态。它不会释放当前持有的对象锁。

wait 属于 Object,必须在同步块里调用。调用后会释放当前对象的锁,等待其他线程 notifynotifyAll

join 可以理解成当前线程等另一个线程结束。内部也是等待机制。

yield 只是告诉调度器“我愿意让一下 CPU”,但调度器不一定听它,所以不要拿它做业务同步。

synchronized 解决原子性和可见性

synchronized 可以修饰实例方法、静态方法,也可以包住代码块。

  • 实例方法锁的是当前对象。
  • 静态方法锁的是类对象。
  • 代码块可以显式选择锁对象。

它提供两层效果:同一时刻只有一个线程进入临界区,并且进入和退出锁会建立内存可见性边界。也就是说,它既解决原子性,也顺带解决可见性。

synchronized 是可重入的。同一个线程已经拿到一把锁,再进入同一把锁保护的代码,不会把自己锁死。父类和子类都加同步方法时,这一点很重要。

volatile 只适合轻量信号

volatile 常被误用。它能保证可见性,也能限制一部分指令重排,但不保证复合操作的原子性。

例如:

volatile int count = 0;
count++;

count++ 不是一个动作,而是读、加、写三步。多个线程同时执行仍然会丢更新。

volatile 更适合这些场景:

  • 停止标志位,例如 volatile boolean running
  • 单线程写、多线程读的状态。
  • 写入不依赖当前值。

如果要保护一组状态,或者修改依赖当前值,还是要用锁、原子类或并发容器。

synchronized 和 Lock 怎么选

普通场景优先 synchronized,它是语言级关键字,写法简单,JDK 对它也做了很多优化。

Lock 更适合需要高级能力的场景:

  • 尝试获取锁,不想一直等:tryLock
  • 等锁时允许中断:lockInterruptibly
  • 需要公平锁。
  • 需要多个条件队列:Condition

使用 Lock 时一定要把释放写在 finally

lock.lock();
try {
    // 临界区
} finally {
    lock.unlock();
}

这类代码看起来啰嗦,但它防的是异常路径忘记释放锁。

死锁先看四个条件

死锁通常同时满足四个条件:

  1. 资源互斥。
  2. 持有一个资源时又请求另一个资源。
  3. 资源不能被强制剥夺。
  4. 多个线程形成环路等待。

业务里最容易出问题的是锁嵌套。例如一个方法里先拿分布式锁,再开事务更新数据库;内部又调用另一个方法,那个方法也尝试拿同一把分布式锁。锁不可重入时,线程可能被自己卡住。

常见修复思路:

  • 固定加锁顺序。
  • 减少锁嵌套。
  • 把锁放到调用链最外层。
  • 加超时,不要无限等。
  • 能用并发容器和原子类时,不要手写大锁。

jstack 定位高 CPU 和死锁

并发问题不能只靠猜,先拿线程栈。

高 CPU 的标准动作:

top
top -Hp <pid>
printf '%x\n' <tid>
jstack <pid> > stack.txt
grep -A 30 "nid=0x<tid_hex>" stack.txt

第一步找进程,第二步找进程里 CPU 高的线程,第三步把线程 ID 转成 16 进制,第四步在 jstack 里找到对应 nid

如果是死锁,jstack 里通常能看到 Found one Java-level deadlock。如果只是锁竞争严重,可以搜 BLOCKEDwaiting to lockparking to wait for

抓栈的关键不是抓一次,而是在问题持续时连续抓几次。如果几次栈都卡在同一段代码,那就是热点或阻塞点。

this 引用逸出要警惕

对象还没构造完,就把 this 交给别的线程或外部组件,这叫 this escape。

典型反例是在构造方法里启动线程、注册监听器、把内部类传出去。内部类和 Lambda 往往会隐式持有外部类的 this,外部一旦提前调用,就可能看到半初始化对象。

更稳的做法是把构造和发布拆开:

public static SomeService create(EventSource source) {
    SomeService service = new SomeService();
    source.register(service.listener);
    return service;
}

构造方法里只初始化字段,不启动线程、不注册监听、不把自己泄露出去。

SimpleDateFormat 为什么不安全

SimpleDateFormat 内部有可变的 Calendar 状态。多个线程共享同一个实例时,formatparse 会互相覆盖内部状态,偶发解析错、年份错,甚至抛奇怪的 NumberFormatException

几种处理方式:

  • 每次使用时 new 一个,简单但有对象创建成本。
  • 外层加 synchronized,安全但吞吐差。
  • ThreadLocal<SimpleDateFormat>,每个线程一份。
  • JDK 8 以后优先用 DateTimeFormatter,不可变且线程安全。

如果是新代码,直接用 java.time 系列会干净很多。

小结

Java 并发不用一上来就陷进所有 API。先抓住几条工程规则:

  • 共享可变状态才是并发风险的核心。
  • volatile 只解决可见性,不解决复合操作原子性。
  • 普通互斥优先 synchronized,高级等待能力再上 Lock
  • 加锁顺序、锁粒度和超时比“锁类型”更重要。
  • 线上并发问题先抓线程栈,少凭感觉改。
  • 构造方法里不要发布 this
  • 有状态工具类不要做全局静态共享。

并发代码最怕“看起来没问题”。只要一个变量会被多个线程改,就要把可见性、原子性和生命周期想清楚。

正文完
 0
bdspAdmin
版权声明:本站原创文章,由 bdspAdmin 于2026-06-20发表,共计3020字。
转载说明:除特殊说明外本站文章皆由CC-4.0协议发布,转载请注明出处。
评论(没有评论)