返回 Java / Kotlin
Java / Kotlin
15 分钟阅读

JVM 并发入门:别被「线程安全」吓住

用生活化的秩序与踩踏比喻理解并发,再从 count++ 实验走进可见性、原子性与 Java 锁/CAS 的解法地图

很多人喜欢把这件事叫成线程安全(Thread Safety),但我不太喜欢这种叫法——它总在把一个简单的问题复杂化。实际上,它应该只是一个朴素的并发概念。

把一个概念性质的问题叫成「xxx 安全」,感觉就像国家安全、反恐安全那种宏大叙事。用一些听起来很厉害的名词去包装一个简单想法,有点像:我们明明只是在述职——这个季度做了什么、下季度展望在哪——互联网那帮人非要造一个通晒当新鲜词。

它的价值何在?我不理解。

本文想做的,是把 JVM 并发从「术语墙」后面拽出来,用你能推演的生活例子讲清楚;再落到几段可运行的 Java 代码,让你看见不加保护时数字为什么不对、加了锁为什么又对。更底层的锁实现、CAS、AQS,可以接着读 Java Lock;协程如何把「等待」从线程上剥离,可以看 协程与 Executors 的资源对比


一、并发从哪来:资源被同时使用

并发问题的由来,就是多个执行流同时碰同一份资源。做事情总要有一个先来后到的秩序。

比如 A 领导说:鹏,这里有一个需求 A,你来处理一下吧。
同一时刻,B 领导说:鹏,这里有一个需求 B,你来处理一下吧。

好的,但现在只有一个鹏——鹏到底先处理谁?这就尴尬了。

这时引入的第一个学问是:排队与挂起等待。你要么先 A 后 B,要么先 B 后 A,总之得给出秩序,否则就会乱套。

在 JVM 里,「鹏」就是某条 OS 线程;「需求 A / B」就是两个 Runnable 或两个 Thread,它们可能同时改同一个对象的字段、同一份 List、同一个计数器。


二、不止「先做谁」:还要「做得对」

问题不仅是调度顺序(谁先谁后),还包括结果正确(有没有互相踩脚)。

想象一下:

  • A 领导说:「先帮我把需求 A 做完,把状态更新成完成。」
  • 你刚做到一半,B 领导冲进来,把状态改成「处理中」,还顺手改了几个关键字段。
  • 等你回头再做 A,发现状态已经被改了;你再提交,就会把 B 的修改覆盖掉。

这就是数据竞争(data race)。听起来很技术,其实就是:多个人在同一个本子上写字,结果互相把对方写的擦掉了

在 Java 内存模型(JMM)里,这类问题通常拆成三个更精确的词——别被吓到,下面有人话翻译:

术语人话典型场景
原子性要么整件事一次做完,要么像没做过count++ 被拆成读-改-写三步
可见性A 线程改了,B 线程能不能立刻看见volatile / 没同步时,可能一直读旧值
有序性代码书写顺序 vs 实际执行顺序指令重排导致「以为先发生后发生」的错觉

大家爱发明很多高大上的词——线程安全、死锁、饥饿——翻译过来往往是:

  • 线程安全:别同时来捣乱(或捣乱时有规则)
  • 原子性:要么都成功,要么都失败
  • 死锁:大家都在等别人干完,结果谁也没干
  • 饥饿:有些人永远没机会排到队

三、我的看法:三个生活原则

与其把「线程安全」讲得很吓人,不如把它想象成:

  1. 分配秩序:谁先谁后(调度、锁、队列)
  2. 避免踩踏:不要互相覆盖(互斥、事务、版本号)
  3. 及时放手:用完资源就让别人用(解锁、unlock、缩小临界区)

并发并不是神秘的「技术黑魔法」,而是人类社会早就解决过的问题——排队、取号、签字确认——只是搬到了代码里。


四、实验:两个线程各加一万次,结果为什么不是 20000?

下面这段代码是 JVM 并发课的经典开场:没有锁的计数器。

public class CounterWithoutLock {
    private int count = 0;
 
    public void increment() {
        count++; // 看起来一行,其实不是原子操作
    }
 
    public int getCount() {
        return count;
    }
 
    public static void main(String[] args) throws InterruptedException {
        CounterWithoutLock counter = new CounterWithoutLock();
 
        Runnable task = () -> {
            for (int i = 0; i < 10_000; i++) {
                counter.increment();
            }
        };
 
        Thread t1 = new Thread(task);
        Thread t2 = new Thread(task);
 
        t1.start();
        t2.start();
        t1.join();
        t2.join();
 
        System.out.println("最终计数值: " + counter.getCount());
        // 期望 20000,实际往往小于 20000,且每次运行可能不同
    }
}

4.1 count++ 在字节码里不是「一步」

count++ 在 JVM 上大致等价于:

  1. count 从主内存读到线程工作内存(GETFIELD / ILOAD
  2. 加 1(IINC
  3. 写回字段(PUTFIELD

两个线程交错执行时,可能出现:

时刻   Thread-1              Thread-2              count
─────────────────────────────────────────────────────────
 1     读取 count = 100
 2                           读取 count = 100
 3     写入 count = 101
 4                           写入 count = 101   ← 两次 ++ 只涨 1

这就是丢失更新:不是「线程坏了」,而是没有互斥 + 没有原子写

4.2 运行结果说明什么

  • 结果经常小于 20000,且非确定性——每次运行可能不同;
  • 说明问题出在共享可变状态上的并发写,而不是 printlnmain 写错了;
  • 单线程跑 20000 次一定对——进一步证明是并发交错导致的。

五、解法地图:从加锁到 CAS

解决「多人改同一本子」的手段,在 Java 里有一条清晰的路径:

发现问题(count 不对)
  → 互斥锁(synchronized / ReentrantLock)
  → 更小粒度锁(分段锁、读写锁)
  → 无锁原子类(AtomicInteger + CAS)
  → 不变式 + 并发容器(ConcurrentHashMap 等)

5.1 synchronized:JVM 自带的「占本子」

语法最简单,适合临界区短、逻辑清晰的场景:

public class CounterWithSynchronized {
    private int count = 0;
 
    public synchronized void increment() {
        count++;
    }
 
    public synchronized int getCount() {
        return count;
    }
}

synchronized 保证:同一时刻只有一个线程进入被同一个监视器锁保护的区域,同时建立 happens-before——解锁前的写,对随后加锁的线程可见。

5.2 ReentrantLock:显式占坑,记得放手

ReentrantLock,这也是日常很常见的一种写法:

import java.util.concurrent.locks.ReentrantLock;
 
public class CounterWithLock {
    private int count = 0;
    private final ReentrantLock lock = new ReentrantLock();
 
    public void increment() {
        lock.lock();
        try {
            count++;
        } finally {
            lock.unlock(); // 一定要释放锁,放 finally 里
        }
    }
 
    public int getCount() {
        lock.lock();
        try {
            return count;
        } finally {
            lock.unlock();
        }
    }
 
    public static void main(String[] args) throws InterruptedException {
        CounterWithLock counter = new CounterWithLock();
 
        Runnable task = () -> {
            for (int i = 0; i < 10_000; i++) {
                counter.increment();
            }
        };
 
        Thread t1 = new Thread(task);
        Thread t2 = new Thread(task);
 
        t1.start();
        t2.start();
        t1.join();
        t2.join();
 
        System.out.println("最终计数值: " + counter.getCount()); // 稳定输出 20000
    }
}

synchronized 相比,ReentrantLock 更灵活:tryLock、可中断等待、公平/非公平策略。代价是必须手动 unlock,否则就是「占着本子不撒手」——别的线程永远排不上。

更细的对比(Monitor、AQS、公平锁)见 Java Lock

5.3 AtomicInteger:用 CAS 做「乐观核对」

如果只是单个计数器,不一定要上重锁:

import java.util.concurrent.atomic.AtomicInteger;
 
public class CounterWithAtomic {
    private final AtomicInteger count = new AtomicInteger(0);
 
    public void increment() {
        count.incrementAndGet(); // CAS 循环,直到改成功
    }
 
    public int getCount() {
        return count.get();
    }
}

思路是乐观锁:读当前值 → 算新值 → CAS 比较并交换;若期间被别人改过,就重试。适合竞争不激烈的计数、状态位;竞争激烈时 CAS 自旋也会烧 CPU——没有银弹。

5.4 方案怎么选(务实版)

场景倾向方案原因
短临界区、简单对象synchronized写法少、JVM 会优化
需要超时/可中断/公平ReentrantLockAPI 更全
单个整型/引用计数Atomic*无显式锁,代码干净
整表 Map 并发读写ConcurrentHashMap分段锁
复合逻辑「读-判断-写」锁或 synchronized 包住整段单独 CAS 字段不够,要保护不变式

六、可见性:volatile 管看得见,不管原子

很多人把 volatile 当成「轻量锁」,这是误解。

public class StopFlag {
    private volatile boolean stopped = false;
 
    public void stop() {
        stopped = true;
    }
 
    public void work() {
        while (!stopped) {
            // 若没有 volatile,子线程可能永远看不到 stopped 变成 true
        }
    }
}

volatile 保证:对这个变量的写,立刻对其他线程可见,并限制与之相关的重排。但它保证 count++ 这种复合操作的原子性。

记一句就够:volatile 解决「看不看见」;锁和原子类解决「会不会被打断」。


七、线程从哪来:ThreadExecutorService

上文一直用 new Thread(...) 是为了把并发交错看清楚。真实项目里更常见的是线程池:

ExecutorService pool = Executors.newFixedThreadPool(4);
 
for (int i = 0; i < 100; i++) {
    pool.submit(() -> {
        // 任务逻辑;若改共享状态,仍要锁/原子类/并发容器
    });
}
 
pool.shutdown();

线程池解决的是**「鹏不够,但活很多」时的调度与复用**——不会为每个任务新建一条 OS 线程。但它自动解决数据竞争:把任务丢进池子,若多个任务改同一个 CounterWithoutLock,结果依然会乱。

线程池管「谁去干活」;锁和原子类管「干活时别踩脚」。两件事正交,别混为一谈。

Kotlin 协程在 JVM 上则是再往上一层:用用户态任务复用少量线程,适合大量等待型 IO。那是另一条故事,见 协程与 Executors 的资源对比


八、死锁与饥饿:秩序走极端时

8.1 死锁

// 反例:两把锁交叉持有
synchronized (lockA) {
    synchronized (lockB) { /* ... */ }
}
// 另一个线程反过来先拿 lockB 再拿 lockA → 可能永远互相等

人话:大家都在等别人先放手,结果谁都动不了。 预防思路包括:锁顺序一致、tryLock 超时、缩小持锁范围。

8.2 饥饿

非公平锁下,某些线程可能长期抢不到锁——像排队永远插不进去。公平锁可以缓解,但吞吐通常更差。又是权衡,没有免费午餐。


九、Android / 业务开发里的落点

写 App 时,并发往往长这样:

  • 主线程:UI 渲染、点击响应(一条「鹏」专门面对用户)
  • 后台线程 / IO 池:网络、数据库、文件
  • 共享状态ViewModel 里的 StateFlowRepository 缓存、单例工具类

几条实用原则:

  1. 默认别共享可变状态——能 immutable、能拷贝、能局部变量,就别挂在多处引用的对象上。
  2. 必须共享时,选对的工具——ConcurrentHashMapAtomic*synchronized 块,而不是「应该没事吧」。
  3. IO 别堵主线程——ANR 是产品级故障,不是「线程安全」术语能描述的。
  4. 新 Kotlin 项目——viewModelScope + 协程管生命周期与取消,但协程不替代对共享数据的保护;Mutex 仍然有用。

十、总结

层次问题手段
概念多人同时用一份资源秩序、互斥、可见性
代码count++ 非原子synchronized / Lock / AtomicInteger
工程线程创建太贵、任务太多ExecutorService、协程
深入Monitor、AQS、分段锁Java Lock分段锁

我不喜欢「线程安全」四个字带来的压迫感。你真正需要记住的,还是开头那三句:分配秩序、避免踩踏、及时放手CounterWithoutLock 跑一遍,看数字不到 20000;加上锁或原子类再跑一遍,看数字稳定——比背十个定义更有用。

并发题在面试里会继续延伸到 HashMap 为什么线程不安全、ConcurrentHashMap 怎么分段、synchronizedReentrantLock 底层差别。这篇当作地图上的第一格就好:先把「资源争抢」想明白,再往锁和容器深处走,心里不会虚。


Original DIPENGXU · 2025年06月28日 · 广东珠海

相关文章

Java / Kotlin
8 分钟
Java Lock
synchronized、ReentrantLock、CAS——接 HashMap 面试链里「并发」那一棒的个人笔记。
Java / Kotlin
7 分钟
Java 分段锁 (Segmented Lock)
ConcurrentHashMap 在 JDK 1.7 里怎么「把一把大锁拆成多把小锁」——接 HashMap 那条面试链的下一棒。
Java / Kotlin
20 分钟
Kotlin 协程与 Java Executors:内核态与用户态的线程资源对比
从操作系统线程模型出发,对比 Java Executors 与 Kotlin Coroutine 在线程创建、上下文切换、阻塞语义与并发规模上的差异
Java / Kotlin
14 分钟
Kotlin 协程原理详解(挂起 / 恢复 / 取消 / 调度 / 异常处理 全面梳理)
本文由ChatGPT生成