ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

并发 01 · 开篇-并发为什么难

并发 01 · 开篇-并发为什么难

这是「Java 并发编程」系列的第一篇。开个头,先问一个看着简单、却几乎每个后端都栽过跟头的问题:两个线程同时给一个int变量各加一万次,最后结果一定是两万吗?答案是——不一定,甚至大概率不是。一段这么朴素的代码,为什么会算错?把这件事从根儿上讲明白,基本就摸到了并发编程的门槛。

并发难,不是难在 API 记不住。ThreadsynchronizedLock、线程池,名词就那么几十个,背一背都会。真正难的地方在于:并发世界里,很多你以为"理所当然"的常识是不成立的——代码不一定按你写的顺序执行,一个线程改了的值另一个线程不一定看得见,一行count++其实是三步操作、随时可能被别人插队。这些"反直觉"的现象,来自 CPU 多核、缓存、编译器优化层层叠叠的底层机制。不理解这些机制,写并发就是在拜运气;理解了,才知道每个锁、每个volatile到底在防什么。

这篇作为全系列的地基,不急着讲任何具体工具,而是先把"为什么并发这么难"这件事讲透。文章按这条线索展开:先搞清楚我们为什么非要用并发、进程和线程到底是什么、上下文切换贵在哪;再把并发世界的三个"幽灵"——可见性、原子性、有序性——用转账和秒杀的例子逐个现形;接着完整走一遍一个线程从生到死的状态流转,以及怎么优雅地叫停它;最后给出整个 14 篇系列的全景地图,告诉你这一路要拆解些什么。

目录

  1. 我们为什么非要并发
  2. 进程、线程与上下文切换
  3. 三个幽灵:可见性、原子性、有序性
  4. 线程的一生:六种状态如何流转
  5. 中断:怎么优雅地叫停一个线程
  6. 全景地图:这个系列要拆解什么

一、我们为什么非要并发

先讲动机——不然一上来就讲锁,会觉得"这些麻烦是凭空冒出来的"。并发的所有复杂度,其实都是为了换两样东西:榨干多核 CPU别让 CPU 干等

第一个动机:摩尔定律换挡了。二十年前 CPU 靠不断提主频变快,程序员什么都不用管,代码明年就自动跑得更快——这叫"免费的午餐"。但主频卡在几个 GHz 上不去之后(功耗和发热顶不住了),芯片厂商改走另一条路:不提频率,改堆核心。今天一台普通服务器动辄几十个核。问题是,一段单线程的代码,无论多少个核,它永远只用得上其中一个——其余的核在旁边闲着。想把机器的算力吃满,就只能把任务拆开、让多个线程分到不同核上同时跑。免费的午餐没了,想要性能,得自己动手做并发这顿饭。

第二个动机:让 CPU 别干等。后端服务干的活,很多时候不是在"算",而是在"等"——等数据库返回、等下游 RPC、等磁盘、等网络。一次数据库查询可能要几毫秒,对 CPU 来说这几毫秒足够执行几百万条指令了。如果一个线程发起查询后就傻等,这段时间 CPU 完全被浪费。用多线程,就能让"等 A 的数据库"和"算 B 的逻辑"重叠起来,等待的时间被别的活填满,吞吐量整个上一个台阶。

这里要澄清一对常被混用的词:并发(Concurrency)不等于并行(Parallelism)

  • 并行是"真的同时":4 个核,同一时刻真的有 4 个线程在各自的核上跑。它要求硬件上有多个执行单元。
  • 并发是"看起来同时":哪怕只有 1 个核,操作系统靠飞快地在多个线程之间切换(这个线程跑一小会、切走、那个线程跑一小会),让你感觉它们在一起推进。单核也能并发,但单核不可能并行。

一个经典类比:并发是一个咖啡师在两台机器间来回照看、交替出品(一个人应付多单);并行是两个咖啡师各站一台机器同时出品(多人多单)。我们写的并发代码,最终能不能"并行"起来跑满多核,要看运行时有几个核、操作系统怎么调度。并发是我们写代码的方式,并行是运行时可能获得的效果。这篇之后统一用"并发"这个词,讲的是编程模型。

二、进程、线程与上下文切换

要理解并发的代价,得先分清进程和线程这两个操作系统概念。

进程是资源分配的基本单位,线程是 CPU 调度的基本单位。这句话是重点,拆开说:

  • 你启动一个 Java 程序,操作系统就创建一个进程,给它分配一整套独立的资源——最重要的是一块独立的内存地址空间。进程 A 和进程 B 的内存互相隔离,A 崩了不会带崩 B,但也正因为隔离,进程间通信(IPC)很麻烦,得靠管道、共享内存、socket 这些专门机制。
  • 一个进程里可以有多个线程。线程是真正被 CPU 拿去执行的单位——操作系统调度的是线程,不是进程。同一个进程里的所有线程,共享这个进程的内存地址空间:同一个堆、同一批全局变量、同一份打开的文件句柄。

看到这里,并发问题的根源其实已经浮出水面了:线程之间共享内存。多个线程能直接读写同一块内存,通信方便、切换轻量,这是线程相对进程最大的优势;但"同一块内存谁都能改"这件事,恰恰就是数据竞争、可见性、原子性一切麻烦的策源地。并发的便利和并发的危险,是同一个硬币的两面。

对照 JVM 内存模型:同一进程内,线程私有的是各自的虚拟机栈、程序计数器(局部变量放这里,天然不共享、天然安全);线程共享的是堆和方法区(对象实例放堆里,这才是竞争发生的地方)。所以并发问题几乎总是围着"堆里的共享对象"打转。这块内存划分在 JVM 系列里详细讲过,这里只需记住这条分界线。

上下文切换:并发不是免费的。前面说单核靠"快速切换"实现并发,这个切换本身有成本。CPU 任一时刻只能跑一个线程,当操作系统决定把 CPU 从线程 A 交给线程 B 时,要做一整套动作:

  1. 把线程 A 当前的"现场"——CPU 寄存器的值、程序计数器指到哪了——保存起来(存到 A 的内核栈 / TCB 里),不然下次 A 回来就不知道自己执行到哪了;
  2. 把线程 B 上次保存的现场恢复到 CPU 寄存器里;
  3. 如果 A 和 B 属于不同进程,还要切换内存地址空间(切页表),这会导致TLB、CPU 缓存大面积失效,代价更大。

这一套下来,单次上下文切换的直接开销通常在微秒级,听着不多,但它有两个隐蔽的坑:一是频率——如果线程切换太频繁(比如锁竞争激烈、线程数远超核数),CPU 大量时间耗在"搬现场"而不是"干活"上,这叫**“上下文切换风暴”;二是缓存失效**的间接成本——切换后新线程要的数据不在 CPU 缓存里,得重新从内存捞,这部分开销比那几微秒更隐蔽也更致命。

这就解释了一个常见的反直觉现象:线程不是开得越多越快。线程数一旦远超 CPU 核数,多出来的线程并不能真的并行,反而增加了调度和切换的负担,性能不升反降。后面第 8 篇讲线程池参数怎么定,本质上就是在回答"到底该开多少线程"这个问题——而它的地基就是这里的上下文切换成本。

怎么观察上下文切换?Linux 上vmstat 1输出里的cs列就是每秒上下文切换次数;pidstat -w -p <pid> 1能看某个进程的切换频率,还能区分自愿切换cswch/s,线程主动等 IO / 锁而让出)和非自愿切换nvcswch/s,时间片用完被抢占)。非自愿切换偏高,往往意味着线程数过多在抢 CPU。

三、三个幽灵:可见性、原子性、有序性

现在进入并发最核心的部分。开篇那个"两个线程各加一万、结果却不到两万"的怪事,背后其实站着三个幽灵。几乎所有并发 bug,都能归到这三个问题里的一个或几个。后面整个系列的锁、volatile、CAS、JMM,全是在对付它们。

3.1 原子性:一行 count++ 其实是三步

先看那段代码:

privateintcount=0;publicvoidincrement(){count++;// ← 看起来是"一步",其实是三步}

count++在 Java 源码里是一行,但编译成字节码 / 机器指令后,是三个独立的步骤

1. read: 从内存把 count 的值读到 CPU 寄存器 (比如读到 100) 2. modify:在寄存器里把它加 1 (算出 101) 3. write: 把 101 写回内存

原子性指的就是"一个操作要么全做完、要么全不做,中间不能被打断"。而count++这三步之间是可以被线程切换插进来的。设想两个线程同时执行:

线程 A:read count(100) ─────────────────→ modify(101) → write(101) 线程 B: read count(100) → modify(101) → write(101) (B 读的时候 A 还没写回,读到的也是 100) 最终 count = 101 ← 两次自增,却只涨了 1,丢了一次更新

两个线程各读到 100、各算出 101、各写回 101,本该是 102 的结果变成了 101。这就是那"消失的一次自增"。放到我们贯穿全系列的秒杀扣库存场景里,这个 bug 就是超卖

// 库存 = 1,两个请求同时进来抢最后一件if(stock>0){// 两个线程都读到 stock=1,都判断通过stock--;// 都执行减一 → stock 变成 -1,卖出了两件}

stock--不是原子的,if 判断 + 扣减这个组合更不是原子的。库存只有 1 件,却有两个线程都通过了stock > 0的检查——超卖就这么发生了。解决原子性,靠的是第 3 篇的synchronized、第 4 篇的 CAS、第 5 篇的锁,这是并发工具的主线。

3.2 可见性:你改了,我却没看见

第二个幽灵更隐蔽。看这段"关不掉的循环":

privatebooleanrunning=true;// 没加任何修饰publicvoidrun(){while(running){// 线程 A 在这里空转// do work...}}publicvoidstop(){running=false;// 线程 B 把开关关掉}

直觉上,线程 B 把running改成false,线程 A 的循环下一圈就该退出。但实际运行,线程 A 很可能永远停不下来

原因藏在硬件里。现代 CPU 每个核都有自己的高速缓存(L1/L2),比主内存快上百倍。线程 A 在某个核上跑,为了快,它会把running从主内存读进自己核的缓存,之后循环每一圈都只看缓存里的副本。线程 B 在另一个核上把running改成false,但这个新值可能只写到了B 那个核的缓存里,还没同步回主内存、更没通知 A 去刷新——于是A 的缓存里running还是true,它压根不知道有人改过

可见性问题,就是"一个线程对共享变量的修改,另一个线程不能及时看到"。它的根源是"每个核有自己的缓存"这个硬件事实。volatile关键字(第 3 篇)就是专门治它的——被volatile修饰的变量,写了立刻刷回主内存、读时强制从主内存拿,保证大家看到的是同一个最新值。

3.3 有序性:代码不一定按你写的顺序跑

第三个幽灵最反直觉:你写的代码,真正执行时顺序可能被打乱。

为了让 CPU 流水线更满、让内存访问更高效,编译器和 CPU 都会对指令做重排序——只要"在单线程看来最终结果不变",它就有权调整指令的实际执行顺序(这条底线叫as-if-serial)。在单线程里这没有任何问题,你永远察觉不到。但到了多线程,重排序就可能造出诡异的中间状态。最经典的例子是双重检查锁定的单例

instance=newSingleton();// 这一行其实是三步

new一个对象,底层是:① 分配内存 → ② 调用构造器初始化 → ③ 把instance指向这块内存。②和③如果被重排成 ①→③→②,那么在③执行完、②还没执行时,另一个线程恰好看到instance != null,就会拿到一个**"分配了内存但还没初始化完"的半成品对象**,一用就出错。

有序性问题,就是重排序在多线程下暴露出的破坏性。解决它,靠的是volatile禁止重排序、synchronized保证临界区串行。而"到底哪些重排序被允许、哪些被禁止"这套规则,就是第 2 篇要讲的 **Java 内存模型(JMM)**和happens-before原则——它是整个并发大厦的理论地基。


把三个幽灵并排放一起,它们的区别就清楚了:

幽灵一句话根源主要克星
原子性操作做到一半被插队一条语句对应多条指令、可被中断synchronized、Lock、CAS
可见性改了别人看不见每个 CPU 核有独立缓存volatilesynchronized
有序性执行顺序被打乱编译器与 CPU 指令重排序volatilesynchronized、happens-before

记住这张表:往后每学一个并发工具,都可以问一句——它到底在解决这三个里的哪几个?这是贯穿全系列的一把钥匙。

四、线程的一生:六种状态如何流转

讲完"为什么难",回到最具体的载体——线程本身。在 Java 里,一个线程从生到死会经历几种状态?这是高频面试题,答案有明确出处:java.lang.Thread.State这个枚举,清清楚楚定义了六种状态,不多不少。

状态含义
NEW线程对象已创建(new Thread()),但还没调用start()
RUNNABLE可运行:包括"正在 CPU 上跑"和"就绪、等着被调度"两种情况
BLOCKED阻塞:在等一把synchronized锁(拿不到进不了临界区)
WAITING无限期等待:主动等别人来唤醒,不设超时
TIMED_WAITING限时等待:等待带超时,时间到自动醒
TERMINATED终止:run()执行完毕或抛异常退出

这里有几个特别容易踩的坑,恰恰是面试爱抠的点:

坑一:Java 没有单独的"运行中"状态。操作系统层面,线程有"就绪(Ready,排队等 CPU)"和"运行(Running,正占着 CPU)"之分。但Java 把这两者合并成了一个RUNNABLE。所以一个 Java 线程显示为RUNNABLE,它可能正在飞速执行,也可能只是排在就绪队列里等 CPU 翻牌子——JVM 不区分,因为这层调度是操作系统的事,JVM 管不着也没必要管。

坑二:BLOCKED只跟synchronized有关。一个线程抢不到synchronized锁时进BLOCKED。但如果它是在等ReentrantLock(也就是LockSupport.park()),状态却是WAITINGTIMED_WAITING不是BLOCKED。这是synchronizedLock在底层实现上就不同的一个外显——记住"BLOCKEDsynchronized专属",排查线程 dump 时不会看错。

坑三:WAITINGTIMED_WAITING只差一个超时参数。同样是等待,调用不带超时的方法进WAITING,调用带超时的进TIMED_WAITING

obj.wait();// WAITING ↔ obj.wait(1000); TIMED_WAITINGlock.lock();/park();// WAITING ↔ tryLock(1,SECONDS); TIMED_WAITINGthread.join();// WAITING ↔ thread.join(1000); TIMED_WAITING// Thread.sleep(1000); TIMED_WAITING

注意一个细节:Thread.sleep()只有带时长的版本,所以它永远是TIMED_WAITING;而且sleep睡觉时不释放锁(这跟wait会释放锁形成经典对比,第 3 篇细讲)。

状态之间怎么流转,画成一张图最清楚——start()让线程从NEW进入RUNNABLE,抢锁失败掉进BLOCKEDwait/park掉进WAITING,被唤醒或超时回到RUNNABLE,最后run()跑完走向TERMINATED

顺带说说创建线程的方式。常被问"创建线程有几种方法",本质上只有一种——new Thread()start(),其余都是"给线程塞任务"的不同姿势:

  • 继承Thread、重写run():最原始,但 Java 单继承,占了继承名额,不推荐。
  • 实现Runnable、传给Thread:把"任务"和"线程"解耦,推荐。run()没有返回值、不能抛受检异常。
  • 实现Callable+FutureTask:任务能返回结果、能抛异常,配合Future拿结果。第 13 篇异步编排的起点。
  • 线程池ExecutorService:生产环境的标准答案。手动new Thread()在实际项目里是要被 code review 拦下来的——线程不复用、数量不可控,正确做法是交给线程池统一管理(第 8 篇)。

一句话收口:面试答"创建线程的方式",别只背四种写法,要点破"底层都是 Thread,区别在任务怎么给、结果要不要",再补一句"生产一律用线程池",这才是有理解的回答。

再补一个容易被问、又容易答漏的点:用户线程与守护线程。线程分两种——用户线程(User Thread)守护线程(Daemon Thread),通过thread.setDaemon(true)设置(必须在start()之前设,否则抛异常)。它俩唯一的区别,是决定 JVM 什么时候退出

JVM 会等所有"用户线程"都结束才退出,但完全不管"守护线程"死没死。只要还有一个用户线程在跑,JVM 就不退;而当最后一个用户线程结束时,哪怕守护线程还在忙,JVM 也会直接退出、把守护线程一并带走。

一个类比:用户线程是"正式员工",守护线程是"后勤保洁"——公司(JVM)只要还有一个正式员工在上班就不关门,但一旦正式员工全下班了,保洁不管扫到哪都得立刻走人。最典型的守护线程就是GC 线程:它默默在后台回收内存,但绝不会因为它还在工作就阻止程序退出。主线程main是用户线程,所以我们写的程序默认会等它跑完。这里有个实践红线:守护线程里不要放"必须善后"的逻辑(比如写文件、刷缓冲区、释放外部资源)——因为它可能在任意时刻被 JVM 无情掐断,来不及收尾,数据就丢了。

五、中断:怎么优雅地叫停一个线程

线程能启动,也得能停下。但怎么"停"一个线程,是并发里一个被严重低估的难点

先说结论:Thread有个stop()方法能强行杀死线程,但它早就被废弃、绝对不能用。因为它会让线程在任意位置戛然而止——可能正改到共享数据的一半、锁还没释放,直接留下一堆残缺状态和死锁。强行掐断从来不是好办法。

Java 的正确思路是协作式中断:你没法强制别人停下,只能"礼貌地打个招呼",告诉那个线程"该收尾了",至于什么时候真正停、怎么收尾,由那个线程自己决定。这套机制围绕一个中断标志位展开,三个方法要分清:

thread.interrupt();// 请求中断:把目标线程的中断标志位置为 truethread.isInterrupted();// 查询:读标志位,不清除Thread.interrupted();// 查询并清除:读完把标志位复位成 false(静态方法,作用于当前线程)

正确的响应姿势,是被中断的线程在自己的循环里主动检查这个标志位,决定何时退出:

publicvoidrun(){// 用中断标志位作为循环条件,而不是自定义的 booleanwhile(!Thread.currentThread().isInterrupted()){doWork();}// 跳出循环后做清理:释放资源、回滚状态……cleanup();}

还有个关键细节:当线程正卡在sleep()wait()join()这些阻塞方法里时,别的线程对它interrupt(),会让阻塞方法立刻抛出InterruptedException提前返回——但同时会把中断标志位清掉(复位成 false)。这就带来一个经典陷阱:

try{Thread.sleep(1000);}catch(InterruptedExceptione){// ✗ 坑:这里 catch 住却什么都不做,中断信号就被"吃掉"了,// 上层再也感知不到"有人请求过中断",线程可能继续跑下去}

正确处理是要么继续把异常往上抛,要么手动把中断标志位补回去,别让中断信号凭空消失:

try{Thread.sleep(1000);}catch(InterruptedExceptione){Thread.currentThread().interrupt();// ✓ 重新设置中断标志,把信号传下去// 然后再决定是退出还是收尾}

为什么讲开篇要专门花一节讲中断?因为它体现了并发编程一个贯穿始终的哲学:你无法命令另一个线程,只能与它协作。你不能强行改它的执行、不能强行停它,能做的只是设置一个共享的信号,等它在合适的时机自己来看、自己来响应。理解了这一点,后面学wait/notify、学Condition、学线程池的优雅关闭(shutdown就是对池里线程逐个interrupt),都会顺很多——它们全是"协作"这两个字的不同展开

六、全景地图:这个系列要拆解什么

到这儿,"并发为什么难"讲清楚了:为榨干多核和不让 CPU 干等,我们用多线程;多线程共享内存带来便利,也带来原子性、可见性、有序性三个幽灵;线程有自己的生命周期,而我们只能以协作的方式驾驭它。接下来的 13 篇,就是沿着"如何驯服这三个幽灵"这条主线,一层层往下拆。

先给你整张地图,让你知道每一篇站在什么位置:

  • 地基(02):JMM 内存模型——把三个幽灵背后的规则(happens-before、重排序、内存屏障)用一套模型讲透,这是所有并发工具的理论根。
  • 两把基础锁(03-06)volatilesynchronized(含锁升级)→ CAS 与原子类 → AQS 原理(ReentrantLock的骨架)→ Lock 家族。这四篇是"怎么保证原子性和可见性"的核心武器库。
  • 写对并发(07):死锁怎么产生和排查、线程安全到底有哪几种策略、不可变对象为什么天生安全。
  • 管理线程(08):线程池原理与调优——生产环境几乎所有并发任务的入口,回答"到底开多少线程"。
  • 并发容器与队列(09-10)ConcurrentHashMap的演进、阻塞队列与生产者-消费者模型。
  • 协作工具(11-12)CountDownLatch/CyclicBarrier/Semaphore等同步工具、ThreadLocal与它的内存泄漏坑。
  • 异步与未来(13-14)CompletableFuture与 Fork/Join 的异步编排;最后落到Loom 虚拟线程——JDK 21 带来的这场变革,可能会重写我们对"该开多少线程"的全部认知,用它给整个系列收尾。

版本提示:本系列以 JDK 8 为主线,涉及版本差异会随篇标注(如偏向锁在 JDK 15 起默认禁用、JEP 374)。而虚拟线程(Virtual Threads)在 JDK 21 转正(JEP 444),是这几年并发领域最大的一次变化,放在收官篇专门讲。读的时候不用切 JDK,跟着主线走,差异点我会明确提示。


并发难,难在它挑战直觉:代码不按顺序跑、改了的值别人看不见、一行自增能被插队。但这些"反常"背后,是多核、缓存、重排序这些非常"讲道理"的底层机制——一旦你把机制看清,并发就从"玄学"变成了"可以推理的工程"。这,就是这个系列想带你抵达的地方。

这一篇你只要带走三样东西就够了:并发的动机(榨干多核 + 不让 CPU 干等)、三个幽灵(原子性 / 可见性 / 有序性,以及"每个工具都在治其中哪几个"这把钥匙)、协作式的线程观(你无法命令线程,只能与它协作)。

下一篇,我们钻进最底层——Java 内存模型(JMM),看看 Java 到底用什么规则,把这三个幽灵关进笼子里。

返回列表