一文吃透Java并发基础:从三类问题到线程状态机
一句话概括
SharkChili 的并发编程基础梳理:从「为什么需要多线程」讲起,系统拆解并发编程必须关注的三类问题——安全性、活跃性(死锁/活锁/线程饥饿)、性能,再辨析并发 vs 并行、同步 vs 异步、JVM 视角下进程与线程的资源划分(程序计数器/虚拟机栈/本地方法栈为何线程私有、栈封闭技术),最后对比两种建线程方式 Thread vs Runnable。
实践内容
安全性问题:自增竞态
自增非复合操作且多线程操作彼此不可见,结果小于预期:
private static int num = 0;
public static void main(String[] args) throws InterruptedException {
CountDownLatch countDownLatch = new CountDownLatch(2);
new Thread(() -> {
for (int i = 0; i < 100_0000; i++) {
num++;
}
countDownLatch.countDown();
}).start();
new Thread(() -> {
for (int i = 0; i < 100_0000; i++) {
num++;
}
countDownLatch.countDown();
}).start();
countDownLatch.await();
System.out.println(num);//输出1499633
}活跃性问题:可见性导致的忙等死循环
在没有 volatile 或锁建立 happens-before 关系时,JIT 可能把 val 读进寄存器后不再回读主内存,线程 0 一直看到旧值、陷入无限循环:
private static boolean val = false;
public static void main(String[] args) {
CountDownLatch countDownLatch = new CountDownLatch(2);
new Thread(() -> {
while (!val) {//下方线程操作对于线程1不可见,进行无限循环
}
System.out.println("thread-1 executed finished");
countDownLatch.countDown();
}).start();
new Thread(() -> {
ThreadUtil.sleep(5, TimeUnit.SECONDS);
val = true;
System.out.println("设置val为true");
countDownLatch.countDown();
}).start();
try {
countDownLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
}广义活跃性问题分三类:死锁(两个线程互相等待对方持有的资源而阻塞)、活锁(线程都没阻塞、一直重试或改变状态,但因彼此谦让/响应导致整体没有进展)、线程饥饿(某线程长时间分不到 CPU 时间片)。
活锁示例:两人共用一把勺子反复谦让
public class LivelockDemo {
static class Spoon { // 勺子,同一时刻只能一个人持有
private Diner owner;
Spoon(Diner d) { owner = d; }
synchronized void setOwner(Diner d) { owner = d; }
synchronized Diner getOwner() { return owner; }
}
static class Diner {
private final String name;
private boolean full = false; // 是否吃饱
Diner(String name) { this.name = name; }
boolean isHungry() { return !full; }
void eatWith(Spoon spoon, Diner partner) {
while (isHungry()) { // 没阻塞,一直在循环重试
if (spoon.getOwner() != this) { // 勺子不在我手上,继续等
continue;
}
if (partner.isHungry()) { // 对方还饿 → 我谦让,把勺子让出去
System.out.println(name + ":你先吃," + partner.name);
spoon.setOwner(partner);
continue; // 勺子易主,但我没吃成
}
full = true; // 真正吃到饭
System.out.println(name + ":我吃完了");
spoon.setOwner(partner);
}
}
}
public static void main(String[] args) {
Diner a = new Diner("A");
Diner b = new Diner("B");
Spoon spoon = new Spoon(a);
// 两人都"只要对方还饿就把勺子让出去",勺子反复横跳,谁都吃不上
new Thread(() -> a.eatWith(spoon, b)).start();
new Thread(() -> b.eatWith(spoon, a)).start();
}
}死锁是大家都卡住不动,活锁是大家都在动、却始终没有进展。
栈封闭技术:局部变量天然线程安全
共享变量 globalVariable 位于多线程共享的堆中,并发自增有竞争;neverGoOut 是方法内局部变量,存放在各自线程私有的虚拟机栈帧里,互不干扰、天然线程安全:
public class StackConfinement implements Runnable {
//全局变量,多线程操作会有线程安全问题
int globalVariable = 0;
public void inThread() {
//栈封闭:变量定义在方法内部,属于每个线程私有的虚拟机栈,天然线程安全
int neverGoOut = 0;
for (int i = 0; i < 10000; i++) {
neverGoOut++;
}
System.out.println("栈内保护的数字是线程安全的:" + neverGoOut);//10000
}
@Override
public void run() {
for (int i = 0; i < 10000; i++) {
globalVariable++;
}
inThread();
}
}同步 vs 异步(代码对照)
// 同步:必须等 compute() 算完返回,才会执行下一行
int result = compute();
System.out.println(result);
// 异步:submit 立即返回 Future,主线程先做别的,需要结果时再 get
Future<Integer> future = executor.submit(() -> compute());
doSomethingElse();
int result = future.get(); // 真正需要结果时再取,此时才可能阻塞创建线程:Thread vs Runnable
// 方式一:继承 Thread
class MyThread extends Thread {
@Override public void run() { /* 任务逻辑 */ }
}
new MyThread().start();
// 方式二:实现 Runnable(工程上更推荐)
class MyTask implements Runnable {
@Override public void run() { /* 任务逻辑 */ }
}
new Thread(new MyTask()).start();Runnable 更被推荐的三个原因:① 不占用单继承名额(Java 单继承,已 extends 业务基类就无法再 extends Thread);② 把「任务」和「线程」解耦,同一任务可交给不同线程甚至线程池;③ 多个线程可共享同一个 Runnable 实例,方便共享数据。
摘录
线程活跃性问题即线程未能按照预期的时序执行……对于 java 并发编程而言,如果没有添加保证可见性的关键字进行修饰,线程1的修改操作对于线程0来说是不可见的:在没有 volatile 或锁建立 happens-before 关系时,JIT 可能把 val 读进寄存器后不再回读主内存,于是线程0一直看到旧值、陷入无限循环,这就是我们所说的可见性导致的活跃性问题。
并发(Concurrency)指的是同一段时间内多个任务交替推进。在单核 CPU 上,同一时刻其实只有一个线程在执行,操作系统靠时间片轮转快速切换……并行(Parallelism)则是同一时刻多个任务真的在同时执行,这必须依赖多核 CPU。所以可以这样记:并发强调的是任务的切换与调度,单核也能并发;并行强调的是物理上的同时执行,必须有多核。并行一定是并发,但并发不一定是并行。
程序计数器私有:有了程序计数器才能保证在多线程的情况下,这个线程被挂起再被恢复时,可以根据程序计数器找到下一次要执行的指令位置。虚拟机栈私有:每个 Java 线程执行方法时都会创建栈帧保存局部变量、常量池引用、操作数栈等信息。在任务可充分并行、忽略同步与上下文切换开销的理想情况下,执行时间才接近「单线程时间/核心数」,若存在串行部分则受 Amdahl 定律约束,加速比会明显低于核心数。
涉及实体
- []
涉及主题
- []
我的评注
非 AI 主题,归入「通用工程实践」。文章把并发的三类问题(安全性/活跃性/性能)讲得很扎实,活锁的「两人共用勺子」例子和栈封闭技术是亮点。标题中的「线程状态机」在本文正文中并未展开(应为系列后续或以配图呈现),本次仅消化文字部分。对 AI Agent 工程的迁移价值:Amdahl 定律、可见性/happens-before 等同样适用于多 Agent 并行与共享状态设计。