Java 并发知识很容易越学越散:线程状态、synchronized、volatile、Lock、死锁、jstack、ThreadLocal、SimpleDateFormat,每个点都能延伸很远。真正写业务代码时,最重要的是先把几个边界分清:共享状态在哪里,是否需要原子性,锁会不会嵌套,出问题时怎么把线程栈抓出来。
这篇从源 note 里抽出一条主线,按日常开发最常用的顺序整理一遍。
线程创建只是入口,不是并发设计
Java 创建线程常见有三种方式:
- 继承
Thread,重写run。 - 实现
Runnable,没有返回值。 - 实现
Callable,配合FutureTask或线程池拿返回值。
业务代码里更常见的是线程池、Spring @Async、CompletableFuture。这些本质上还是把任务交给线程执行,只是多了生命周期、队列、拒绝策略和异常处理。
所以不要把“能启动线程”当成并发设计。并发设计真正要回答的是:
- 这个任务有没有共享状态?
- 共享状态是否会被多个线程修改?
- 修改是否需要整体原子性?
- 失败、超时、取消和资源释放怎么处理?
线程状态先够用就好
线程状态不用一开始就背得很玄。先记几个排障时常见的:
RUNNABLE:可运行,可能正在跑,也可能等 CPU。BLOCKED:等进入synchronized锁。WAITING:无限期等待,例如Object.wait()、LockSupport.park()。TIMED_WAITING:带超时等待,例如sleep、wait(timeout)、join(timeout)。TERMINATED:线程结束。
如果线上线程很多处于 BLOCKED,先看大家在抢哪把锁。如果大量 WAITING,要看是不是线程池空转、队列等待,或者某个条件永远没被唤醒。
sleep、wait、join、yield 的差别
最容易混的是 sleep 和 wait。
sleep 属于 Thread,只是让当前线程睡一会儿,时间到了回到可运行状态。它不会释放当前持有的对象锁。
wait 属于 Object,必须在同步块里调用。调用后会释放当前对象的锁,等待其他线程 notify 或 notifyAll。
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();
}
这类代码看起来啰嗦,但它防的是异常路径忘记释放锁。
死锁先看四个条件
死锁通常同时满足四个条件:
- 资源互斥。
- 持有一个资源时又请求另一个资源。
- 资源不能被强制剥夺。
- 多个线程形成环路等待。
业务里最容易出问题的是锁嵌套。例如一个方法里先拿分布式锁,再开事务更新数据库;内部又调用另一个方法,那个方法也尝试拿同一把分布式锁。锁不可重入时,线程可能被自己卡住。
常见修复思路:
- 固定加锁顺序。
- 减少锁嵌套。
- 把锁放到调用链最外层。
- 加超时,不要无限等。
- 能用并发容器和原子类时,不要手写大锁。
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。如果只是锁竞争严重,可以搜 BLOCKED、waiting to lock、parking 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 状态。多个线程共享同一个实例时,format 和 parse 会互相覆盖内部状态,偶发解析错、年份错,甚至抛奇怪的 NumberFormatException。
几种处理方式:
- 每次使用时 new 一个,简单但有对象创建成本。
- 外层加
synchronized,安全但吞吐差。 - 用
ThreadLocal<SimpleDateFormat>,每个线程一份。 - JDK 8 以后优先用
DateTimeFormatter,不可变且线程安全。
如果是新代码,直接用 java.time 系列会干净很多。
小结
Java 并发不用一上来就陷进所有 API。先抓住几条工程规则:
- 共享可变状态才是并发风险的核心。
volatile只解决可见性,不解决复合操作原子性。- 普通互斥优先
synchronized,高级等待能力再上Lock。 - 加锁顺序、锁粒度和超时比“锁类型”更重要。
- 线上并发问题先抓线程栈,少凭感觉改。
- 构造方法里不要发布
this。 - 有状态工具类不要做全局静态共享。
并发代码最怕“看起来没问题”。只要一个变量会被多个线程改,就要把可见性、原子性和生命周期想清楚。




