当前位置:网站首页 > 技术博客 > 正文

java线程切换消耗



我们先来看看操作系统中的线程状态转换。在操作系统中,线程被视为轻量级的进程,所以线程状态其实和进程状态是一致的

系统进程/线程转换图
系统进程/线程转换图

操作系统的线程主要有以下三个状态:

  • 就绪状态(ready):线程正在等待使用 CPU,经调度程序调用之后进入 running 状态。
  • 执行状态(running):线程正在使用 CPU。
  • 等待状态(waiting): 线程经过等待事件的调用或者正在等待其他资源(如 I/O)。

然后我们来看 Java 线程的 6 个状态:

处于 NEW 状态的线程此时尚未启动。这里的尚未启动指的是还没调用 Thread 实例的方法。

从上面可以看出,只是创建了线程而并没有调用 start 方法,此时线程处于 NEW 状态。

  1. 反复调用同一个线程的 start 方法是否可行?
  2. 假如一个线程执行完毕(此时处于 TERMINATED 状态),再次调用这个线程的 start 方法是否可行?

要分析这两个问题,我们先来看看的源码:

可以看到,在内部,有一个 threadStatus 变量。如果它不等于 0,调用会直接抛出异常。

接着往下看,有一个 native 的 方法。这个方法并没有对threadStatus进行处理。到这里我们仿佛拿这个 threadStatus 没辙了,通过 debug 再看一下:

在 start 方法内部的最开始打断点:

  • 第一次调用时 threadStatus 的值是 0。
  • 第二次调用时 threadStatus 的值不为 0。

查看当前线程状态的源码:

还记得我们引申的两个问题吗?

  1. 反复调用同一个线程的 start 方法是否可行?
  2. 假如一个线程执行完毕(此时处于 TERMINATED 状态),再次调用这个线程的 start 方法是否可行?

结合上面的源码可以得到的答案是:

  1. 都不行,在调用 start 之后,threadStatus 的值会改变(),再次调用 start 方法会抛出 IllegalThreadStateException 异常。
  2. threadStatus 为 2 代表当前线程状态为 TERMINATED(下面会讲)。

表示当前线程正在运行中。处于 RUNNABLE 状态的线程在 Java 虚拟机中运行,也有可能在等待 CPU 分配资源。

我们来看看 Thread 源码里对 RUNNABLE 状态的定义:

意思大家应该都能看得懂,不懂翻译一下(其实前面已经翻译过了)。

也就是说,Java 线程的RUNNABLE状态其实包括了操作系统线程的readyrunning两个状态。

阻塞状态。处于 BLOCKED 状态的线程正等待锁(锁会在后面细讲)的释放以进入同步区。

我们用 BLOCKED 状态举个生活中的例子:

假如今天你下班后准备去食堂吃饭。你来到食堂仅有的一个窗口,发现前面已经有个人在窗口前了,此时你必须得等前面的人从窗口离开才行。

假设你是线程 t2,你前面的那个人是线程 t1。此时 t1 占有了锁(食堂唯一的窗口),t2 正在等待锁的释放,所以此时 t2 就处于 BLOCKED 状态。

等待状态。处于等待状态的线程变成 RUNNABLE 状态需要其他线程唤醒。

调用下面这 3 个方法会使线程进入等待状态:

  • :使当前线程处于等待状态直到另一个线程唤醒它;
  • :等待线程执行完毕,底层调用的是 Object 的 wait 方法;
  • :除非获得调用许可,否则禁用当前线程进行线程调度。LockSupport 我们在后面会细讲。

我们延续上面的例子继续解释一下 WAITING 状态:

你等了好几分钟,终于轮到你了,突然你们有一个“不懂事”的经理来了。你看到他你就有一种不祥的预感,果然,他是来找你的。

他把你拉到一旁叫你待会儿再吃饭,说他下午要去作报告,赶紧来找你了解一下项目的情况。你心里虽然有一万个不愿意但是你还是从食堂窗口走开了。

此时,假设你还是线程 t2,你的经理是线程 t1。虽然你此时都占有锁(窗口)了,“不速之客”来了你还是得释放掉锁。此时你 t2 的状态就是 WAITING。然后经理 t1 获得锁,进入 RUNNABLE 状态。

要是经理 t1 不主动唤醒你 t2(notify、notifyAll..),可以说你 t2 只能一直等待了。

超时等待状态。线程等待一个具体的时间,时间到后会被自动唤醒。

调用如下方法会使线程进入超时等待状态:

  • :使当前线程睡眠指定时间;
  • :线程休眠指定时间,等待期间可以通过/唤醒;
  • :等待当前线程最多执行 millis 毫秒,如果 millis 为 0,则会一直执行;
  • : 除非获得调用许可,否则禁用当前线程进行线程调度指定时间;LockSupport 我们在后面会细讲;
  • :同上,也是禁止线程进行调度指定时间;

我们继续延续上面的例子来解释一下 TIMED_WAITING 状态:

到了第二天中午,又到了饭点,你还是到了窗口前。

突然间想起你的同事叫你等他一起,他说让你等他十分钟他改个 bug。

好吧,那就等等吧,你就离开了窗口。很快十分钟过去了,你见他还没来,你想都等了这么久了还不来,那你还是先去吃饭好了。

这时你还是线程 t1,你改 bug 的同事是线程 t2。t2 让 t1 等待了指定时间,此时 t1 等待期间就属于 TIMED_WATING 状态。

t1 等待 10 分钟后,就自动唤醒,拥有了去争夺锁的资格。

终止状态。此时线程已执行完毕。

根据上面关于线程状态的介绍我们可以得到下面的线程状态转换图

我们在上面说过:处于 BLOCKED 状态的线程在等待锁的释放。假如这里有两个线程 a 和 b,a 线程提前获得了锁并暂未释放锁,此时 b 就处于 BLOCKED 状态。我们来看一个例子:

初看之下,大家可能会觉得线程 a 会先调用同步方法,同步方法内又调用了方法,必然会输出 TIMED_WAITING,而线程 b 因为等待线程 a 释放锁所以必然会输出 BLOCKED。

其实不然,有两点需要值得大家注意:

  • 一是在测试方法内还有一个 main 线程
  • 二是启动线程后执行 run 方法还是需要消耗一定时间的

测试方法的 main 线程只保证了 a,b 两个线程调用 start 方法(转化为 RUNNABLE 状态),如果 CPU 执行效率高一点,还没等两个线程真正开始争夺锁,就已经打印此时两个线程的状态(RUNNABLE)了。

当然,如果 CPU 执行效率低一点,其中某个线程也是可能打印出 BLOCKED 状态的(此时两个线程已经开始争夺锁了)。

下面是我执行了几次的结果对比:

这时你可能又会问了,要是我想要打印出 BLOCKED 状态我该怎么处理呢?

BLOCKED 状态的产生需要两个线程争夺锁才行。那我们处理下测试方法里的 main 线程就可以了,让它“休息一会儿”,调用一下方法。

这里需要注意的是 main 线程休息的时间,要保证在线程争夺锁的时间内,不要等到前一个线程锁都释放了你再去争夺锁,此时还是得不到 BLOCKED 状态的。

我们把上面的测试方法 blockedTest 改动一下:

运行结果如下所示:

在这个例子中两个线程的状态转换如下

  • a 的状态转换过程:RUNNABLE() -> TIMED_WATING()->RUNABLE(时间到)->BLOCKED(未抢到锁) -> TERMINATED
  • b 的状态转换过程:RUNNABLE() -> BLOCKED(未抢到锁) ->TERMINATED

斜体表示可能出现的状态, 大家可以在自己的电脑上多试几次看看输出。同样,这里的输出也可能有多钟结果。

根据转换图我们知道有 3 个方法可以使线程从 RUNNABLE 状态转为 WAITING 状态。我们主要介绍下Object.wait()Thread.join()

调用方法前线程必须持有对象的锁。

线程调用方法时,会释放当前的锁,直到有其他线程调用/方法唤醒等待锁的线程。

需要注意的是,其他线程调用方法只会唤醒单个等待锁的线程,如有有多个线程都在等待这个锁的话不一定会唤醒到之前调用方法的线程。

同样,调用方法唤醒所有等待锁的线程之后,也不一定会马上把时间片分给刚才放弃锁的那个线程,具体要看系统的调度。

调用方法,会一直等待这个线程执行完毕(转换为 TERMINATED 状态)。

我们再把上面的例子线程启动那里改变一下:

要是没有调用 join 方法,main 线程不管 a 线程是否执行完毕都会继续往下走。

a 线程启动之后马上调用了 join 方法,这里 main 线程就会等到 a 线程执行完毕,所以这里 a 线程打印的状态固定是TERMINATED

至于 b 线程的状态,有可能打印 RUNNABLE(尚未进入同步方法),也有可能打印 TIMED_WAITING(进入了同步方法)。

TIMED_WAITING 与 WAITING 状态类似,只是 TIMED_WAITING 状态等待的时间是指定的。

使当前线程睡眠指定时间。需要注意这里的“睡眠”只是暂时使线程停止执行,并不会释放锁。时间到后,线程会重新进入 RUNNABLE 状态。

方法使线程进入 TIMED_WAITING 状态。这里的方法与无参方法 wait()相同的地方是,都可以通过其他线程调用或方法来唤醒。

不同的地方是,有参方法就算其他线程不来唤醒它,经过指定时间 long 之后它会自动唤醒,拥有去争夺锁的资格。

使当前线程执行指定时间,并且使线程进入 TIMED_WAITING 状态。

我们再来改一改刚才的示例:

这里调用,因为是指定了具体 a 线程执行的时间的,并且执行时间是小于 a 线程 sleep 的时间,所以 a 线程状态输出 TIMED_WAITING。

b 线程状态仍然不固定(RUNNABLE 或 BLOCKED)。

在某些情况下,我们在线程启动后发现并不需要它继续执行下去时,需要中断线程。目前在 Java 里还没有安全方法来直接停止线程,但是 Java 提供了线程中断机制来处理需要中断线程的情况。

线程中断机制是一种协作机制。需要注意,通过中断操作并不能直接终止一个线程,而是通知需要被中断的线程自行处理。

简单介绍下 Thread 类里提供的关于线程中断的几个方法:

  • :中断线程。这里的中断线程并不会立即停止线程,而是设置线程的中断状态为 true(默认是 flase);
  • :测试当前线程是否被中断。
  • :检测当前线程是否被中断,与 方法不同的是,这个方法如果发现当前线程被中断,会清除线程的中断状态。

在线程中断机制里,当其他线程通知需要被中断的线程后,线程中断的状态被设置为 true,但是具体被要求中断的线程要怎么处理,完全由被中断线程自己决定,可以在合适的时机中断请求,也可以完全不处理继续执行下去。

本文详细解析了 Java 线程的 6 种状态 — 新建、运行、阻塞、等待、定时等待和终止,以及这些状态之间的切换过程。

编辑:沉默王二,原文内容来源于朋友小七萤火虫开源的这个仓库:深入浅出 Java 多线程,强烈推荐。


GitHub 上标星 10000+ 的开源知识库《二哥的 Java 进阶之路》第二份 PDF 《并发编程小册》终于来了!包括线程的基本概念和使用方法、Java的内存模型、sychronized、volatile、CAS、AQS、ReentrantLock、线程池、并发容器、ThreadLocal、生产者消费者模型等面试和开发必须掌握的内容,共计 15 万余字,200+张手绘图,可以说是通俗易懂、风趣幽默……详情戳:太赞了,二哥的并发编程进阶之路.pdf

加入二哥的编程星球,在星球的第二个置顶帖「知识图谱」里就可以获取 PDF 版本。

二哥的并发编程进阶之路获取方式
二哥的并发编程进阶之路获取方式

Java 提供了 ThreadGroup 类来创建一组相关的线程,使线程组管理更方便。每个 Java 线程都有一个优先级,这个优先级会影响到操作系统为这个线程分配处理器时间的顺序。

这篇内容将分别来介绍一下线程组和线程优先级。

Java 用 ThreadGroup 来表示线程组,我们可以通过线程组对线程进行批量控制。

ThreadGroup 和 Thread 的关系就如同他们的字面意思一样简单粗暴,每个 Thread 必然存在于一个 ThreadGroup 中,Thread 不能独立于 ThreadGroup 存在。执行方法的线程名字是 main,如果在 new Thread 时没有显式指定,那么默认将父线程(当前执行 new Thread 的线程)线程组设置为自己的线程组。

示例代码:

输出结果:

ThreadGroup 是一个标准的向下引用的树状结构,这样设计可以防止"上级"线程被"下级"线程引用而无法有效地被 GC 回收

线程组还可以包含其他的线程组,不仅仅是线程。首先看看 源码中的成员变量。

然后看看构造方法:

第三个构造方法里调用了方法,来看看这个方法的源码:

这里涉及到这个类,它是 Java 的安全管理器,它允许应用程序在执行一个可能不安全或敏感的操作前确定该操作是什么,以及是否允许在执行该操作的上下文中执行它。

比如引入了第三方类库,但是并不能保证它的安全性。

其实 Thread 类也有一个 checkAccess 方法,不过是用来当前运行的线程是否有权限修改被调用的这个线程实例。(Determines if the currently running thread has permission to modify this thread.)

总结一下,线程组是一个树状的结构,每个线程组下面可以有多个线程或者线程组。线程组可以起到统一控制线程的优先级和检查线程权限的作用。

线程优先级可以指定,范围是 1~10。但并不是所有的操作系统都支持 10 级优先级的划分(比如有些操作系统只支持 3 级划分:低、中、高),Java 只是给操作系统一个优先级的参考值,线程最终在操作系统中的优先级还是由操作系统决定。

Java 默认的线程优先级为 5,线程的执行顺序由调度程序来决定,线程的优先级会在线程被调用之前设定。

通常情况下,高优先级的线程将会比低优先级的线程有更高的概率得到执行。类的方法可以用来设定线程的优先级。

输出结果:

既然有 10 个级别来设定线程的优先级,那是不是可以在业务实现的时候,采用这种方法来指定线程执行的先后顺序呢?

对于这个问题,答案是:No!

Java 中的优先级不是特别的可靠,Java 程序中对线程所设置的优先级只是给操作系统一个建议,操作系统不一定会采纳。而真正的调用顺序,是由操作系统的线程调度算法来决定的

我们通过代码来验证一下:

运行该程序,有时候可以按照优先级执行,有时却不行,这是某次输出:

Java 提供了一个线程调度器来监视和控制处于RUNNABLE 状态的线程。

  • 线程的调度策略采用抢占式的方式,优先级高的线程会比优先级低的线程有更大的几率优先执行。
  • 在优先级相同的情况下,会按照“先到先得”的原则执行。
  • 每个 Java 程序都有一个默认的主线程,就是通过 JVM 启动的第一个线程——main 线程。

还有一种特殊的线程,叫做守护线程(Daemon),守护线程默认的优先级比较低。

  • 如果某线程是守护线程,那如果所有的非守护线程都结束了,这个守护线程也会自动结束。
  • 当所有的非守护线程结束时,守护线程会自动关闭,这就免去了还要继续关闭子线程的麻烦。
  • 线程默认是非守护线程,可以通过 Thread 类的 setDaemon 方法来设置为守护线程。

之前我们谈到一个线程必然存在于一个线程组中,那么当线程和线程组的优先级不一致的时候会怎样呢?我们来验证一下:

输出:

所以,如果某个线程的优先级大于线程所在线程组的最大优先级,那么该线程的优先级将会失效,取而代之的是线程组的最大优先级。

Java 提供了 ThreadGroup 类来创建一组相关的线程,使线程组管理更方便;每个 Java 线程都有一个优先级,这个优先级会影响到操作系统为这个线程分配处理器时间的顺序。

编辑:沉默王二,原文内容来源于朋友小七萤火虫开源的这个仓库:深入浅出 Java 多线程,强烈推荐。


GitHub 上标星 10000+ 的开源知识库《二哥的 Java 进阶之路》第二份 PDF 《并发编程小册》终于来了!包括线程的基本概念和使用方法、Java的内存模型、sychronized、volatile、CAS、AQS、ReentrantLock、线程池、并发容器、ThreadLocal、生产者消费者模型等面试和开发必须掌握的内容,共计 15 万余字,200+张手绘图,可以说是通俗易懂、风趣幽默……详情戳:太赞了,二哥的并发编程进阶之路.pdf

加入二哥的编程星球,在星球的第二个置顶帖「知识图谱」里就可以获取 PDF 版本。

二哥的并发编程进阶之路获取方式
二哥的并发编程进阶之路获取方式

经过前面几章的学习,我们对线程的基本概念和使用方式已经有了比较充分的了解,那么接下来我们就来分析一下,线程是如何从进程进化而来的,它们之间又有哪些区别,搞清楚两者之间的差别对接下来的学习也是至关重要的,甚至有些公司的面试官也喜欢问这个。

最初的计算机只能接受一些特定的指令,用户每输入一个指令,计算机就做出一个操作。当用户在思考或者输入时,计算机就在等待。这样效率非常低下,在很多时候,计算机都处在等待状态。

后来有了批处理操作系统,把一系列需要操作的指令写下来,形成一个清单,一次性交给计算机。用户将多个需要执行的程序写在磁带上,然后交由计算机去读取并逐个执行这些程序,并将输出结果写在另一个磁带上。

批处理操作系统在一定程度上提高了计算机的效率,但是由于批处理操作系统的指令运行方式仍然是串行的,内存中始终只有一个程序在运行,后面的程序需要等待前面的程序执行完成后才能开始执行,而前面的程序有时会由于 I/O 操作、网络等原因阻塞,所以批处理操作效率也不高

人们对于计算机的性能要求越来越高,现有的批处理操作系统并不能满足人们的需求,而批处理操作系统的瓶颈在于内存中只存在一个程序,那么内存中能不能存在多个程序呢?这是人们亟待解决的问题。

于是,科学家们提出了进程的概念。

进程就是应用程序在内存中分配的空间,也就是正在运行的程序,各个进程之间互不干扰。同时进程保存着程序每一个时刻运行的状态。

程序:用某种编程语言(Java、Python 等)编写,能够完成一定任务或者功能的代码集合,是指令和数据的有序集合,是一段静态代码

此时,CPU 采用时间片轮转的方式运行进程:CPU 为每个进程分配一个时间段,称作它的时间片。如果在时间片结束时进程还在运行,则暂停这个进程的运行,并且 CPU 分配给另一个进程(这个过程叫做上下文切换)。如果进程在时间片结束前阻塞或结束,则 CPU 立即进行切换,不用等待时间片用完。

当进程暂停时,它会保存当前进程的状态(进程标识,进程使用的资源等),在下一次切换回来时根据之前保存的状态进行恢复,接着继续执行。

使用进程+CPU 时间片轮转方式的操作系统,在宏观上看起来同一时间段执行多个任务,换句话说,进程让操作系统的并发成为了可能。虽然并发从宏观上看有多个任务在执行,但在事实上,对于单核 CPU来说,任意具体时刻都只有一个任务在占用 CPU 资源。

虽然进程的出现,使得操作系统的性能大大提升,但是随着时间的推移,人们并不满足一个进程在一段时间只能做一件事情,如果一个进程有多个子任务时,只能逐个得执行这些子任务,很影响效率。

比如杀毒软件在检测用户电脑时,如果在某一项检测中卡住了,那么后面的检测项也会受到影响。或者说当你使用杀毒软件中的扫描病毒功能时,在扫描病毒结束之前,无法使用杀毒软件中清理垃圾的功能,这显然无法满足人们的要求。

那么能不能让这些子任务同时执行呢?于是人们又提出了线程的概念,让一个线程执行一个子任务,这样一个进程就包含了多个线程,每个线程负责一个单独的子任务。

  • 使用线程之后,事情就变得简单多了。当用户使用扫描病毒功能时,就让扫描病毒这个线程去执行。同时,如果用户又使用清理垃圾功能,那么可以先暂停扫描病毒线程,先响应用户的清理垃圾的操作,让清理垃圾这个线程去执行。响应完后再切换回来,接着执行扫描病毒线程。
  • 注意:操作系统是如何分配时间片给每一个线程的,涉及到线程的调度策略,有兴趣的同学可以看一下《操作系统》相关的内容,这里就不再展开了,涉及的内容比较多。

总之,进程和线程的提出极大的提高了操作系统的性能。进程让操作系统的并发性成为了可能,而线程让进程的内部并发成为了可能。

既然多进程的方式可以实现并发,为什么还要使用多线程呢?

多进程方式确实可以实现并发,但使用多线程,有以下几个好处:

  • 进程间的通信比较复杂,而线程间的通信比较简单,通常情况下,我们需要使用共享资源,这些资源在线程间的通信很容易。
  • 进程是重量级的,而线程是轻量级的,多线程方式的系统开销更小。

进程是一个独立的运行环境,而线程是在进程中执行的一个任务。他们两个本质的区别是是否单独占有内存地址空间及其它系统资源(比如 I/O)

  • 进程单独占有一定的内存地址空间,所以进程间存在内存隔离,数据是分开的,数据共享复杂但是同步简单,各个进程之间互不干扰;而线程共享所属进程占有的内存地址空间和资源,数据共享简单,但是同步复杂。
  • 进程单独占有一定的内存地址空间,一个进程出现问题不会影响其他进程,不影响主程序的稳定性,可靠性高;一个线程崩溃可能影响整个程序的稳定性,可靠性较低。
  • 进程单独占有一定的内存地址空间,进程的创建和销毁不仅需要保存寄存器和栈信息,还需要资源的分配回收以及页调度,开销较大;线程只需要保存寄存器和栈信息,开销较小。

另外一个重要区别是,进程是操作系统进行资源分配的基本单位,而线程是操作系统进行调度的基本单位,即 CPU 分配时间的单位 。

再用一些图来表达一下,感官会更清晰一些。

计算机的核心是 CPU,它承担了所有的计算任务。它就像一座工厂,时刻在运行。

假定工厂的电力有限,一次只能供给一个车间使用。也就是说,一个车间开工的时候,其他车间都必须停工。背后的含义就是,单个 CPU 一次只能运行一个任务。

进程就好比工厂的车间,它代表 CPU 所能处理的单个任务。任一时刻,CPU 总是运行一个进程,其他进程处于非运行状态。

一个车间里,可以有很多工人。他们协同完成一个任务。

线程就好比车间里的工人。一个进程可以包括多个线程。

车间的空间是工人们共享的,比如许多房间是每个工人都可以进出的。这象征一个进程的内存空间是共享的,每个线程都可以使用这些共享内存。

可是,每间房间的大小不同,有些房间最多只能容纳一个人,比如厕所。里面有人的时候,其他人就不能进去了。这代表一个线程使用某些共享内存时,其他线程必须等它结束,才能使用这一块内存。

一个防止他人进入的简单方法,就是门口加一把锁。先到的人锁上门,后到的人看到上锁,就在门口排队,等锁打开再进去。这就叫"互斥锁"(Mutual exclusion,缩写 Mutex),防止多个线程同时读写某一块内存区域。

还有些房间,可以同时容纳 n 个人,比如厨房。也就是说,如果人数大于 n,多出来的人只能在外面等着。这好比某些内存区域,只能供给固定数目的线程使用。

这时的解决方法,就是在门口挂 n 把钥匙。进去的人就取一把钥匙,出来时再把钥匙挂回原处。后到的人发现钥匙架空了,就知道必须在门口排队等着了。这种做法叫做"信号量"(Semaphore),用来保证多个线程不会互相冲突。

不难看出,mutex 是 semaphore 的一种特殊情况(n=1 时)。也就是说,完全可以用后者替代前者。但是,因为 mutex 较为简单,且效率高,所以在必须保证资源独占的情况下,还是采用这种设计。

操作系统的设计,因此可以归结为三点:

  • 以多进程形式,允许多个任务同时运行;
  • 以多线程形式,允许单个任务分成不同的部分运行;
  • 提供协调机制,一方面防止进程之间和线程之间产生冲突,另一方面允许进程之间和线程之间共享资源。

一直以来,硬件的发展极其迅速,其中有一个很著名的"摩尔定律"。

摩尔定律并不是一种自然法则或者是物理定律,它只是基于一些观测数据后,对未来的一种预测。按照所预测的速度,我们的计算能力会按照指数级别的速度增长,不久以后会拥有超强的计算能力。2004 年,Intel 宣布 4GHz 芯片的计划推迟到 2005 年,然后在 2004 年秋季,Intel 宣布彻底取消 4GHz 的计划,也就是说摩尔定律的有效性超过半个世纪后戛然而止。但是,聪明的硬件工程师并没有停止研发的脚步,他们为了进一步提升计算速度,不再追求单独的计算单元,而是将多个计算单元整合到了一起,于是就出现了多核 CPU。

短短十几年的时间,家用型 CPU,比如 Intel i7 就可以达到 4 核心甚至 8 核心。而专业服务器通常可以达到几个独立的 CPU,每一个 CPU 甚至拥有多达 8 个以上的内核。因此,摩尔定律似乎在 CPU 核心扩展上继续得以验证。因此,多核 CPU 的背景下,并发编程变得越来越受重视,因为通过并发编程的形式可以将多核 CPU 的计算能力发挥到极致

顶级计算机科学家 Donald Ervin Knuth 如此评价这种情况:在我看来,这种现象(并发)或多或少是由于硬件设计者无计可施导致的,他们将摩尔定律的责任推给了开发者。

另外,有些特殊的业务场景下,先天就适合于并发编程。比如在图像处理领域,一张 1024X768 像素的图片,包含达到 78 万 6 千多个像素。要在短时间内将所有的像素遍历一边需要很长的时间,面对如此复杂的计算量就需要充分利用多核计算的能力。

又比如当我们在网上购物时,为了提升响应速度,需要拆分,减库存,生成订单等等这些操作,就可以利用多线程的技术来完成。面对复杂业务模型,并行程序会比串行程序更适用于业务需求 。正是因为这些优点,使得多线程技术得到了进一步的重视,Java 开发者也应该掌握并发编程,以便:

  • 充分利用多核 CPU 的计算能力;
  • 方便进行业务拆分,提升应用性能

怎么样,进程和线程的概念就彻底搞懂了吧?再遇到面试官问这个问题,就直接吊打他吧。

总结来说,进程和线程都是操作系统用于并发执行的方式,但是它们在资源管理、独立性、开销以及影响范围等方面有所不同。

  • 进程是操作系统分配资源的基本单位,线程是操作系统调度的基本单位。
  • 进程拥有独立的内存空间,线程共享所属进程的内存空间。
  • 进程的创建和销毁需要资源的分配和回收,开销较大;线程的创建和销毁只需要保存寄存器和栈信息,开销较小。
  • 进程间的通信比较复杂,而线程间的通信比较简单。
  • 进程间是相互独立的,一个进程崩溃不会影响其他进程;线程间是相互依赖的,一个线程崩溃可能影响整个程序的稳定性。

编辑:沉默王二,部分内容来源于朋友小七萤火虫开源的这个仓库:深入浅出 Java 多线程,强烈推荐。还有一部分图片来源于阮一峰的博客,地址戳这里。


GitHub 上标星 10000+ 的开源知识库《二哥的 Java 进阶之路》第二份 PDF 《并发编程小册》终于来了!包括线程的基本概念和使用方法、Java的内存模型、sychronized、volatile、CAS、AQS、ReentrantLock、线程池、并发容器、ThreadLocal、生产者消费者模型等面试和开发必须掌握的内容,共计 15 万余字,200+张手绘图,可以说是通俗易懂、风趣幽默……详情戳:太赞了,二哥的并发编程进阶之路.pdf

加入二哥的编程星球,在星球的第二个置顶帖「知识图谱」里就可以获取 PDF 版本。

二哥的并发编程进阶之路获取方式
二哥的并发编程进阶之路获取方式

前面我们了解到,多线程技术有很多好处,比如说多线程可以充分利用多核 CPU 的计算能力,那多线程难道就没有一点缺点吗?

有。

多线程很难掌握,稍不注意,就容易使程序崩溃。我们以在路上开车为例:

在一个单向行驶的道路上,每辆汽车都遵守交通规则,这时候整体通行是正常的。『单向车道』意味着『一个线程』,『多辆车』意味着『多个 job 任务』。

单线程顺利同行
单线程顺利同行

如果需要提升车辆的同行效率,一般的做法就是扩展车道,对应程序来说就是『加线程池』,增加线程数。这样在同一时间内,通行的车辆数远远大于单车道。

多线程顺利同行
多线程顺利同行

然而车道一旦多起来,『加塞』的场景就会越来越多,出现碰撞后也会影响整条马路的通行效率。这么一对比下来『多车道』就比『单车道』慢多了。

多线程故障
多线程故障

防止汽车频繁变道加塞可以在车道间增加『护栏』,那在程序的世界里该怎么做呢?

多线程遇到的问题归纳起来就三类:、、。

有时候我们会发现,明明在单线程环境中正常运行的代码,在多线程环境中就会出现意料之外的结果,这就是大家常说的『线程不安全』。那到底什么是线程不安全呢?

举一个银行转账的例子,比如从账户 A 向账户 B 转 1000 元,那么必然包括 2 个操作:从账户 A 减去 1000 元,往账户 B 加上 1000 元,两个操作都成功才意味着一次转账最终成功。

试想一下,如果这两个操作不具备原子性,从 A 的账户扣减了 1000 元之后,操作突然终止了,账户 B 没有增加 1000 元,那问题就大了。

银行转账有两个步骤,出现意外后导致转账失败,说明没有原子性。

  • 原子性:即一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
  • 原子操作:即不会被线程调度机制打断的操作,没有上下文切换。

在并发编程中很多操作都不是原子操作,出个小题目:

上面这四个操作中哪些是原子操作,哪些不是呢?

有些小伙伴可能认为这些都是原子操作,其实只有操作 1 是原子操作。

  • 操作 1:这是原子操作,因为它是一个单一的、不可分割的步骤。
  • 操作 2:这不是原子操作。这实际上是一个 "read-modify-write" 操作,它包括了读取 i 的值,增加 i,然后写回 i。
  • 操作 3:这是原子操作,因为它是一个单一的、不可分割的步骤。
  • 操作 4:这不是原子操作。和 i++ 一样,这也是一个 "read-modify-write" 操作。

在单线程环境下上述四个操作都不会出现问题,但是在多线程环境下,如果不加锁的话,可能会得到意料之外的值。我们来测试一下,看看输出结果。

输出如下:

i 期望的值为 ,但实际跑出来的是 ,这证明 i++ 不是一个原子操作,对吧?

talk is cheap,show me code,来看这段代码:

假如有两个线程,线程 1 执行 update 方法将 i 赋值为 100,一般情况下线程 1 会在自己的工作内存中完成赋值操作,但不会及时将新值刷新到主内存中。

这个时候线程 2 执行 get 方法,首先会从主内存中读取 i 的值,然后加载到自己的工作内存中,此时读到 i 的值仍然是 50,再将 50 赋值给 j,最后返回 j 的值就是 50 了。原本期望返回 100,结果返回 50,这就是可见性问题,线程 1 对变量 i 进行了修改,线程 2 并没有立即看到 i 的新值。

可见性:当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。

如上图,每个线程都有属于自己的工作内存,工作内存和主内存间需要通过 store 和 load 等进行交互。

为了解决多线程的可见性问题,Java 提供了这个关键字。当一个共享变量被 volatile 修饰时,它会保证修改的值立即更新到主存当中,这样的话,当有其他线程需要读取时,就会从内存中读到新值。普通的共享变量不能保证可见性,因为变量被修改后什么时候刷回到主存是不确定的,因此另外一个线程读到的可能就是旧值。

当然 Java 的锁机制如 synchronized 和 lock 也是可以保证可见性的。

上面讲到为了解决的问题,我们可以采取加锁的方式来解决,但如果加锁使用不当也容易引入其他问题,比如『死锁』。

在讲『死锁』之前,我们需要先引入另外一个概念:。

活跃性是指某件正确的事情最终会发生,但当某个操作无法继续下去的时候,就会发生活跃性问题。

概念可能有点拗口,活跃性问题一般有这样几类:,,。

死锁是指多个线程因为环形等待锁的关系而永远地阻塞下去。

死锁是两个线程都在等待对方释放锁导致阻塞。而的意思是线程没有阻塞,还活着呢。当多个线程都在运行并且都在修改各自的状态,而其他线程又依赖这个状态,就导致任何一个线程都无法继续执行,只能重复着自身的动作,于是就发生了活锁。

举一个生活中的例子,大家平时在走路的时候,迎面走来一个人,两个人互相让路,但是又同时走到了一个方向,如果一直这样重复着避让,这俩人就发生了活锁,学到了吧,嘿嘿。

如果一个线程无其他异常却迟迟不能继续运行,那基本上是处于饥饿状态了。

常见的有几种场景:

  • 高优先级的线程一直在运行消耗 CPU,所有的低优先级线程一直处于等待;
  • 一些线程被永久堵塞在一个等待进入同步块的状态,而其他线程总是能在它之前持续地对该同步块进行访问;

有一个非常经典的饥饿问题就是,如下图所示,有五个哲学家在用餐,每个人必须要同时拿两把叉子才开始就餐,如果哲学家 1 和哲学家 3 同时开始就餐,那哲学家 2、4、5 就得饿肚子等待了。

前面讲到了线程安全和死锁、活锁这些问题,如果这些都没有发生,多线程并发一定比单线程串行执行快吗?答案是不一定,因为多线程有和的开销。

创建线程是直接向系统申请资源的,对操作系统来说,创建一个线程的代价是十分昂贵的,需要给它分配内存、列入调度等。

线程创建完之后,还会遇到线程。

CPU 是很宝贵的资源,速度非常快,为了保证雨露均沾,通常会给不同的线程分配,当 CPU 从执行一个线程切换到执行另一个线程时,CPU 需要保存当前线程的本地数据,程序指针等状态,并加载下一个要执行线程的本地数据,程序指针等,也就是『上下文切换』。

一般减少上下文切换的方法有:

  • 无锁并发编程:可以参照 ConcurrentHashMap 锁分段的思想,不同的线程处理不同段的数据,这样在多线程竞争的条件下,可以减少上下文切换的时间。
  • CAS 算法,利用 Atomic + CAS 算法来更新数据,采用乐观锁的方式,可以有效减少一部分不必要的锁竞争带来的上下文切换。
  • 使用最少线程:避免创建不必要的线程,如果任务很少,但创建了很多的线程,这样就会造成大量的线程都处于等待状态。
  • 协程:在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换。

多线程用好了可以让程序的效率成倍提升,用不好可能比单线程还要慢。

用一张图来总结一下上面讲的:

编辑:沉默王二,编辑前的内容来自于朋友雷小帅的开源仓库Java 八股文,内容很不错,强烈推荐。


GitHub 上标星 10000+ 的开源知识库《二哥的 Java 进阶之路》第二份 PDF 《并发编程小册》终于来了!包括线程的基本概念和使用方法、Java的内存模型、sychronized、volatile、CAS、AQS、ReentrantLock、线程池、并发容器、ThreadLocal、生产者消费者模型等面试和开发必须掌握的内容,共计 15 万余字,200+张手绘图,可以说是通俗易懂、风趣幽默……详情戳:太赞了,二哥的并发编程进阶之路.pdf

加入二哥的编程星球,在星球的第二个置顶帖「知识图谱」里就可以获取 PDF 版本。

二哥的并发编程进阶之路获取方式
二哥的并发编程进阶之路获取方式

某年某月的某一天,小二去蚂蚁金服面试了,一上来,面试官老王就问他:『说说什么是 Java 的内存模型(JMM)吧?』

小二内心狂喜,不假思索地答道:『Java 内存主要分为五大块:堆、方法区、虚拟机栈、本地方法栈、PC 寄存器,balabala……』

老王会心一笑,露出一道光芒,略带轻蔑地打断了小王,说:『好了,今天的面试先到这里了,回去等通知吧』

一听到等通知这句话,小二心里知道,这场面试大概率是凉凉了。为什么呢?小二很是不解。

回到宿舍翻了翻《二哥的 Java 进阶之路并发编程篇》,小二才恍然大悟,原来自己弄错了概念,面试官是想考察 JMM,但是小二一听到这几个关键字就开始背Java 运行时内存区域的八股文了,害,Java 内存模型(JMM)和 Java 运行时内存区域的区别可大着呢。

Java 内存模型(Java Memory Model,JMM)定义了 Java 程序中的变量、线程如何和主存以及工作内存进行交互的规则。它主要涉及到多线程环境下的共享变量可见性、指令重排等问题,是理解并发编程中的关键概念。

并发编程的线程之间存在两个问题:

  • 线程间如何通信?即:线程之间以何种机制来交换信息
  • 线程间如何同步?即:线程以何种机制来控制不同线程间发生的相对顺序

有两种并发模型可以解决这两个问题:

  • 消息传递并发模型
  • 共享内存并发模型

这两种模型之间的区别如下图所示:

两种并发模型的比较
两种并发模型的比较

Java 使用的是共享内存并发模型

先来看一下运行时数据区,相信大家一点都不陌生:

Java运行时数据区域
Java运行时数据区域

对于每一个线程来说,栈都是私有的,而堆是共有的。

也就是说,在栈中的变量(局部变量、方法定义的参数、异常处理的参数)不会在线程之间共享,也就不会有内存可见性的问题,也不受内存模型的影响。而在堆中的变量是共享的,一般称之为共享变量。

所以,内存可见性针对的是堆中的共享变量

那可能就有小伙伴会问:既然堆是共享的,为什么在堆中会有内存不可见问题

这是因为现代计算机为了高效,往往会在高速缓存区中缓存共享变量,因为 CPU 访问缓存区比访问内存要快得多。

线程之间的共享变量存在于主存中,每个线程都有一个私有的本地内存,存储了该线程的读、写共享变量的副本。本地内存是 Java 内存模型的一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器等。

Java 线程之间的通信由 Java 内存模型(简称 JMM)控制,从抽象的角度来说,JMM 定义了线程和主存之间的抽象关系。JMM 的抽象示意图如图所示:

JMM抽象示意图
JMM抽象示意图

从图中可以看出:

  1. 所有的共享变量都存在主存中。
  2. 每个线程都保存了一份该线程使用到的共享变量的副本。
  3. 如果线程 A 与线程 B 之间要通信的话,必须经历下面 2 个步骤:
    1. 线程 A 将本地内存 A 中更新过的共享变量刷新到主存中去。
    2. 线程 B 到主存中去读取线程 A 之前已经更新过的共享变量。

所以,线程 A 无法直接访问线程 B 的工作内存,线程间通信必须经过主存。

注意,根据 JMM 的规定,线程对共享变量的所有操作都必须在自己的本地内存中进行,不能直接从主存中读取

  • 主内存:Java堆中对象实例数据部分,对应于物理硬件的内存
  • 工作内存:Java栈中的部分区域,优先存储于寄存器和高速缓存

所以线程 B 并不是直接去主存中读取共享变量的值,而是先在本地内存 B 中找到这个共享变量,发现这个共享变量已经被更新了,然后本地内存 B 去主存中读取这个共享变量的新值,并拷贝到本地内存 B 中,最后线程 B 再读取本地内存 B 中的新值。

那么怎么知道这个共享变量的被其他线程更新了呢?这就是 JMM 的功劳了,也是 JMM 存在的必要性之一。JMM 通过控制主存与每个线程的本地内存之间的交互,来提供内存可见性保证

Java 中的 volatile 关键字可以保证多线程操作共享变量的可见性以及禁止指令重排序,synchronized 关键字不仅保证可见性,同时也保证了原子性(互斥性)。

在更底层,JMM 通过内存屏障来实现内存的可见性以及禁止重排序。为了程序员更方便地理解,设计者提出了 happens-before 的概念(下文会细讲),它更加简单易懂,从而避免了程序员为了理解内存可见性而去学习复杂的重排序规则,以及这些规则的具体实现方法。

前面提到了 JMM 和 Java 运行时内存区域的划分,这两者既有差别又有联系:

  • 区别

    两者是不同的概念。JMM 是抽象的,他是用来描述一组规则,通过这个规则来控制各个变量的访问方式,围绕原子性、有序性、可见性等展开。而 Java 运行时内存的划分是具体的,是 JVM 运行 Java 程序时必要的内存划分。

  • 联系

    都存在私有数据区域和共享数据区域。一般来说,JMM 中的主存属于共享数据区域,包含了堆和方法区;同样,JMM 中的本地内存属于私有数据区域,包含了程序计数器、本地方法栈、虚拟机栈。

总结一下:

Java 运行时内存区域描述的是在 JVM 运行时,如何将内存划分为不同的区域,并且每个区域的功能和工作机制。主要包括以下几个部分:

  • 方法区:存储了每一个类的结构信息,如运行时常量池、字段和方法数据、构造方法和普通方法的字节码内容。
  • 堆:几乎所有的对象实例以及数组都在这里分配内存。这是 Java 内存管理的主要区域。
  • 栈:每一个线程有一个私有的栈,每一次方法调用都会创建一个新的栈帧,用于存储局部变量、操作数栈、动态链接、方法出口等信息。所有的栈帧都是在方法调用和方法执行完成之后创建和销毁的。
  • 本地方法栈:与栈类似,不过本地方法栈为 JVM 使用到的 native 方法服务。
  • 程序计数器:每个线程都有一个独立的程序计数器,用于指示当前线程执行到了字节码的哪一行。

Java 内存模型 (JMM) 主要针对的是多线程环境下,如何在主内存与工作内存之间安全地执行操作。

它涵盖的主题包括变量的可见性、指令重排、原子操作等,旨在解决由于多线程并发编程带来的一些问题。

  • 可见性:当一个线程修改了共享变量的值,这个新值对于其他线程来说可以立即知道。
  • 原子性:一个或多个操作在整个过程中,不会被其他的线程或者操作所打断,这些操作是一个整体,要么都执行,要么都不执行。
  • 有序性:程序执行的顺序按照代码的先后顺序执行的。

前面提到了,JMM 定义了多线程之间如何互相交互的规则,主要目的是为了解决由于编译器优化、处理器优化和缓存系统等导致的可见性、原子性和有序性。

那我们接下来就来聊聊重排序以及它所带来的顺序问题。

大家都知道,计算机在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排。

那可能有小伙伴就要问:为什么指令重排序可以提高性能?

简单地说,每一个指令都会包含多个步骤,每个步骤可能使用不同的硬件。因此,流水线技术产生了,它的原理是指令 1 还没有执行完,就可以开始执行指令 2,而不用等到指令 1 执行结束后再执行指令 2,这样就大大提高了效率。

但是,流水线技术最害怕中断,恢复中断的代价是比较大的,所以我们要想尽办法不让流水线中断。指令重排就是减少中断的一种技术。

我们分析一下下面这段代码的执行情况:

先加载 b、c(注意,有可能先加载 b,也有可能先加载 c),但是在执行 的时候,需要等待 b、c 装载结束才能继续执行,也就是需要增加停顿,那么后面的指令(加载 e 和 f)也会有停顿,这就降低了计算机的执行效率。

为了减少停顿,我们可以在加载完 b 和 c 后把 e 和 f 也加载了,然后再去执行 ,这样做对程序(串行)是没有影响的,但却减少了停顿。

换句话说,既然 需要停顿,那还不如去做一些有意义的事情(加载 e 和 f)。

综上所述,指令重排对于提高 CPU 性能十分必要,但也带来了乱序的问题。

指令重排一般分为以下三种:

  • 编译器优化重排,编译器在不改变单线程程序语义的前提下,重新安排语句的执行顺序。
  • 指令并行重排,现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性(即后一个执行的语句无需依赖前面执行的语句的结果),处理器可以改变语句对应的机器指令的执行顺序。
  • 内存系统重排,由于处理器使用缓存和读写缓存冲区,这使得加载(load)和存储(store)操作看上去可能是在乱序执行,因为三级缓存的存在,导致内存与缓存的数据同步存在时间差。

指令重排可以保证串行语义一致,但是没有义务保证多线程间的语义也一致。所以在多线程下,指令重排序可能会导致一些问题。

当程序未正确同步的时候,就可能存在数据竞争。

数据竞争:在一个线程中写一个变量,在另一个线程读同一个变量,并且写和读没有通过同步来排序。

如果程序中包含了数据竞争,那么运行的结果往往充满了不确定性,比如读发生在了写之前,可能就会读到错误的值;如果一个线程能够正确同步,那么就不存在数据竞争。

Java 内存模型(JMM)对于正确同步多线程程序的内存一致性做了以下保证:如果程序是正确同步的,程序的执行将具有顺序一致性。即程序的执行结果和该程序在顺序一致性模型中执行的结果相同。

这里的同步包括使用 volatile、final、synchronized 等关键字实现的同步。

如果我们开发者没有正确使用、、 等关键字,那么即便是使用了同步,JMM 也不会有内存可见性的保证,很可能会导致程序出错,并且不可重现,很难排查。

顺序一致性模型是一个理想化的理论参考模型,它为程序提供了极强的内存可见性保证。顺序一致性模型有两大特性:

  • 一个线程中的所有操作必须按照程序的顺序(即 Java 代码的顺序)来执行。
  • 不管程序是否同步,所有线程都只能看到一个单一的操作执行顺序。即在顺序一致性模型中,每个操作必须是原子性的,且立刻对所有线程可见

为了理解这两个特性,我们举个例子,假设有两个线程 A 和 B 并发执行,线程 A 有 3 个操作,他们在程序中的顺序是 A1->A2->A3,线程 B 也有 3 个操作,B1->B2->B3。

假设正确使用了同步,A 线程的 3 个操作执行后释放锁,B 线程获取同一个锁。那么在顺序一致性模型中的执行效果如下所示:

正确同步图
正确同步图

操作的执行整体上有序,并且两个线程都只能看到这个执行顺序。

假设没有使用同步,那么在顺序一致性模型中的执行效果如下所示:

没有正确同步图
没有正确同步图

操作的执行整体上无序,但是两个线程都只能看到这个执行顺序。之所以可以得到这个保证,是因为顺序一致性模型中的每个操作必须立即对任意线程可见

但是 JMM 没有这样的保证。

比如,在当前线程把写过的数据缓存在本地内存中,在没有刷新到主存之前,这个写操作仅对当前线程可见;从其他线程的角度来观察,这个写操作根本没有被当前线程所执行。

只有当前线程把本地内存中写过的数据刷新到主存之后,这个写操作才对其他线程可见。在这种情况下,当前线程和其他线程看到的执行顺序是不一样的。

在顺序一致性模型中,所有操作完全按照程序的顺序串行执行。但是 JMM 中,临界区内(同步块或同步方法中)的代码可以发生重排序(但不允许临界区内的代码“逃逸”到临界区之外,因为会破坏锁的内存语义)。

虽然线程 A 在临界区做了重排序,但是因为锁的特性,线程 B 无法观察到线程 A 在临界区的重排序。这种重排序既提高了执行效率,又没有改变程序的执行结果。

同时,JMM 会在退出临界区和进入临界区做特殊的处理,使得在临界区内程序获得与顺序一致性模型相同的内存视图。

由此可见,JMM 的具体实现方针是:在不改变(正确同步的)程序执行结果的前提下,尽量为编译期和处理器的优化打开方便之门

对于未同步的多线程,JMM 只提供最小安全性:线程读取到的值,要么是之前某个线程写入的值,要么是默认值,不会无中生有。

为了实现这个安全性,JVM 在堆上分配对象时,首先会对内存空间清零,然后才会在上面分配对象(这两个操作是同步的)。

JMM 没有保证未同步程序的执行结果与该程序在顺序一致性中执行结果一致。因为如果要保证执行结果一致,那么 JMM 需要禁止大量的优化,对程序的执行性能会产生很大的影响。

未同步程序在 JMM 和顺序一致性内存模型中的执行特性有如下差异:

  1. 顺序一致性保证单线程内的操作会按程序的顺序执行;JMM 不保证单线程内的操作会按程序的顺序执行。(因为重排序,但是 JMM 保证单线程下的重排序不影响执行结果)
  2. 顺序一致性模型保证所有线程只能看到一致的操作执行顺序,而 JMM 不保证所有线程能看到一致的操作执行顺序。(因为 JMM 不保证所有操作立即可见)
  3. 顺序一致性模型保证对所有的内存读写操作都具有原子性,而 JMM 不保证对 64 位的 long 型和 double 型变量的写操作具有原子性。

一方面,我们开发者需要 JMM 提供一个强大的内存模型来编写代码;另一方面,编译器和处理器希望 JMM 对它们的束缚越少越好,这样它们就可以尽可能多的做优化来提高性能,希望的是一个弱的内存模型。

JMM 考虑了这两种需求,并且找到了平衡点,对编译器和处理器来说,只要不改变程序的执行结果(单线程程序和正确同步了的多线程程序),编译器和处理器怎么优化都行。

对于我们开发者来说,JMM 提供了happens-before 规则(JSR-133 规范),满足了我们的诉求——简单易懂,并且提供了足够强的内存可见性保证。 换言之,我们开发者只要遵循 happens-before 规则,那么我们写的程序就能保证在 JMM 中具有强的内存可见性。

JMM 使用 happens-before 的概念来定制两个操作之间的执行顺序。这两个操作可以在一个线程内,也可以是不同的线程种。

happens-before 关系的定义如下:

  1. 如果一个操作 happens-before 另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
  2. 两个操作之间存在 happens-before 关系,并不意味着 Java 平台的具体实现必须要按照 happens-before 关系指定的顺序来执行。如果重排序之后的执行结果,与按 happens-before 关系来执行的结果一致,那么 JMM 也允许这样的重排序。

happens-before 关系本质上和 as-if-serial 语义是一回事。

as-if-serial 语义保证单线程内重排序后的执行结果和程序代码本身应有的结果是一致的,happens-before 关系保证正确同步的多线程程序的执行结果不被重排序改变。

总之,如果操作 A happens-before 操作 B,那么操作 A 在内存上所做的操作对操作 B 都是可见的,不管它们在不在一个线程。

在 Java 中,有以下天然的 happens-before 关系:

  • 程序顺序规则:一个线程中的每一个操作,happens-before 于该线程中的任意后续操作。
  • 监视器锁规则:对一个锁的解锁,happens-before 于随后对这个锁的加锁。
  • volatile 变量规则:对一个 volatile 域的写,happens-before 于任意后续对这个 volatile 域的读。
  • 传递性:如果 A happens-before B,且 B happens-before C,那么 A happens-before C。
  • start 规则:如果线程 A 执行操作 启动线程 B,那么 A 线程的 操作 happens-before 于线程 B 中的任意操作。
  • join 规则:如果线程 A 执行操作 并成功返回,那么线程 B 中的任意操作 happens-before 于线程 A 从 操作成功返回。

举例:

根据以上介绍的 happens-before 规则,假如只有一个线程,那么不难得出:

注意,真正在执行指令的时候,其实 JVM 有可能对操作 A & B 进行重排序,因为无论先执行 A 还是 B,他们都对对方是可见的,并且不影响执行结果。

如果这里发生了重排序,这在视觉上违背了 happens-before 原则,但是 JMM 是允许这样的重排序的。

所以,我们只关心 happens-before 规则,不用关心 JVM 到底是怎样执行的。只要确定操作 A happens-before 操作 B 就行了。

重排序有两类,JMM 对这两类重排序有不同的策略:

  • 会改变程序执行结果的重排序,比如 A -> C,JMM 要求编译器和处理器都禁止这种重排序。
  • 不会改变程序执行结果的重排序,比如 A -> B,JMM 对编译器和处理器不做要求,允许这种重排序。
  • Java 内存模型(JMM)定义了 Java 程序中的变量、线程如何和主存以及工作内存进行交互的规则。它主要涉及到多线程环境下的共享变量可见性、指令重排等问题,是理解并发编程中的关键概念。
  • Java 内存模型(JMM)主要针对的是多线程环境下,如何在主内存与工作内存之间安全地执行操作。
  • Java 运行时内存区域描述的是在 JVM 运行时,如何将内存划分为不同的区域,并且每个区域的功能和工作机制。主要包括方法区、堆、栈、本地方法栈、程序计数器。
  • 指令重排是为了提高 CPU 性能,但是可能会导致一些问题,比如多线程环境下的内存可见性问题。
  • happens-before 规则是 JMM 提供的强大的内存可见性保证,只要遵循 happens-before 规则,那么我们写的程序就能保证在 JMM 中具有强的内存可见性。

编辑:沉默王二,编辑前的内容部分来源于朋友雷小帅的开源仓库 Java 八股文,强烈推荐。部分内容来源于朋友小七萤火虫开源的这个仓库:深入浅出 Java 多线程,强烈推荐。


GitHub 上标星 10000+ 的开源知识库《二哥的 Java 进阶之路》第二份 PDF 《并发编程小册》终于来了!包括线程的基本概念和使用方法、Java的内存模型、sychronized、volatile、CAS、AQS、ReentrantLock、线程池、并发容器、ThreadLocal、生产者消费者模型等面试和开发必须掌握的内容,共计 15 万余字,200+张手绘图,可以说是通俗易懂、风趣幽默……详情戳:太赞了,二哥的并发编程进阶之路.pdf

加入二哥的编程星球,在星球的第二个置顶帖「知识图谱」里就可以获取 PDF 版本。

二哥的并发编程进阶之路获取方式
二哥的并发编程进阶之路获取方式

“三妹啊,这节我们来学习 Java 中的 volatile 关键字吧,以及容易遇到的坑。”看着三妹好学的样子,我倍感欣慰。

“好呀,哥。”三妹愉快的答应了。

这是我们在《二哥的 Java 进阶之路基础篇》中常见的对话模式,老读者应该对这种模式不陌生。

在讲并发编程带来了哪些问题的时候,我们提到了可见性和原子性,那我现在可以直接告诉大家了:volatile 可以保证可见性,但不保证原子性:

  • 当写一个 volatile 变量时,JMM 会把该线程在本地内存中的变量强制刷新到主内存中去;
  • 这个写操作会导致其他线程中的 volatile 变量缓存无效。

在讲 JMM 的时候,我们提到了指令重排,相信大家都还有印象,我们来回顾一下重排序需要遵守的规则:

  • 重排序不会对存在数据依赖关系的操作进行重排序。比如: 这个指令序列,因为第二个操作依赖于第一个操作,所以在编译时和处理器运行时这两个操作不会被重排序。
  • 重排序是为了优化性能,但是不管怎么重排序,单线程下程序的执行结果不能被改变。比如: 这三个操作,第一步 (a=1) 和第二步 (b=2) 由于不存在数据依赖关系,所以可能会发生重排序,但是 c=a+b 这个操作是不会被重排序的,因为需要保证最终的结果一定是 c=a+b=3。

使用 volatile 关键字修饰共享变量可以禁止这种重排序。怎么做到的呢?

当我们使用 volatile 关键字来修饰一个变量时,Java 内存模型会插入内存屏障(一个处理器指令,可以对 CPU 或编译器重排序做出约束)来确保以下两点:

  • 写屏障(Write Barrier):当一个 volatile 变量被写入时,写屏障确保在该屏障之前的所有变量的写入操作都提交到主内存。
  • 读屏障(Read Barrier):当读取一个 volatile 变量时,读屏障确保在该屏障之后的所有读操作都从主内存中读取。

换句话说:

  • 当程序执行到 volatile 变量的读操作或者写操作时,在其前面操作的更改肯定已经全部进行,且结果对后面的操作可见;在其后面的操作肯定还没有进行;
  • 在进行指令优化时,不能将 volatile 变量的语句放在其后面执行,也不能把 volatile 变量后面的语句放到其前面执行。

“也就是说,执行到 volatile 变量时,其前面的所有语句都必须执行完,后面所有得语句都未执行。且前面语句的结果对 volatile 变量及其后面语句可见。”我瞅了了三妹一眼继续说。

先看下面未使用 volatile 的代码:

因为重排序影响,所以最终的输出可能是 0,重排序请参考上一篇 JMM 的介绍,如果引入 volatile,我们再看一下代码:

这时候,volatile 会禁止指令重排序,这个过程建立在 happens before 关系(上一篇介绍过了)的基础上:

  1. 根据程序次序规则,1 happens before 2; 3 happens before 4。
  2. 根据 volatile 规则,2 happens before 3。
  3. 根据 happens before 的传递性规则,1 happens before 4。

上述 happens before 关系的图形化表现形式如下:

在上图中,每一个箭头链接的两个节点,代表了一个 happens before 关系:

  • 黑色箭头表示程序顺序规则;
  • 橙色箭头表示 volatile 规则;
  • 蓝色箭头表示组合这些规则后提供的 happens before 保证。

这里 A 线程写一个 volatile 变量后,B 线程读同一个 volatile 变量。A 线程在写 volatile 变量之前所有可见的共享变量,在 B 线程读同一个 volatile 变量后,将立即变得对 B 线程可见。

下面是变量自加的示例:

测试输出:

“为什么呀?二哥?” 看到这个结果,三妹疑惑地问。

“因为 inc++不是一个原子性操作(前面讲过),由读取、加、赋值 3 步组成,所以结果并不能达到 10000。”我耐心地回答。

“哦,你这样说我就理解了。”三妹点点头。

怎么解决呢?

01、采用 synchronized(下一篇会讲,戳链接直达),把 拎出来单独加 synchronized 关键字:

02、采用 Lock,通过重入锁 ReentrantLock 对 加锁(后面都会细讲,戳链接直达):

03、采用原子类 AtomicInteger(后面也会细讲,戳链接直达)来实现:

三者输出都是 1000,如下:

下面是一个使用"双重检查锁定"(double-checked locking)实现的单例模式(Singleton Pattern)的例子。

在这个例子中,Penguin 类只能被实例化一次。来看代码解释:

  • 声明了一个类型为 Penguin 的 volatile 变量 m_penguin,它是类的静态变量,用来存储 Penguin 类的唯一实例。
  • 构造方法被声明为 private,这样就阻止了外部代码使用 new 来创建 Penguin 实例,保证了只能通过 方法获取实例。
  • 方法是获取 Penguin 类唯一实例的公共静态方法。
  • 第一次 检查是否已经存在 Penguin 实例。如果不存在,才进入同步代码块。
  • 对类的 Class 对象加锁,确保在多线程环境下,同时只能有一个线程进入同步代码块。在同步代码块中,再次执行 检查实例是否已经存在,如果不存在,则创建新的实例。这就是所谓的“双重检查锁定”,一共两次。
  • 最后返回 m_penguin,也就是 Penguin 的唯一实例。

其中,使用 volatile 关键字是为了防止 这一步被指令重排序。因为实际上, 这一行代码分为三个子步骤:

  • 步骤 1:为 Penguin 对象分配足够的内存空间,伪代码 。
  • 步骤 2:调用 Penguin 的构造方法,初始化对象的成员变量,伪代码 。
  • 步骤 3:将内存地址赋值给 m_penguin 变量,使其指向新创建的对象,伪代码 。

如果不使用 volatile 关键字,JVM 可能会对这三个子步骤进行指令重排。

  • 为 Penguin 对象分配内存
  • 将对象赋值给引用 m_penguin
  • 调用构造方法初始化成员变量

这种重排序会导致 m_penguin 引用在对象完全初始化之前就被其他线程访问到。具体来说,如果一个线程执行到步骤 2 并设置了 m_penguin 的引用,但尚未完成对象的初始化,这时另一个线程可能会看到一个“半初始化”的 Penguin 对象。

假如此时有两个线程 A 和 B,要执行 方法:

  • 线程 A 执行到 ,判断为 true,进入同步块。
  • 线程 B 执行到 ,判断为 true,进入同步块。

如果线程 A 执行 时发生指令重排序:

  • 线程 A 分配内存并设置引用,但尚未调用构造方法完成初始化。
  • 线程 B 此时判断 ,直接返回这个“半初始化”的对象。

这样就会导致线程 B 拿到一个不完整的 Penguin 对象,可能会出现空指针异常或者其他问题。

于是,我们可以为 m_penguin 变量添加 volatile 关键字,来禁止指令重排序,确保对象的初始化完成后再将其赋值给 m_penguin。

“好了,三妹,我们来总结一下。”我舒了一口气说。

volatile 可以保证线程可见性且提供了一定的有序性,但是无法保证原子性。在 JVM 底层 volatile 是采用“内存屏障”来实现的。

观察加入 volatile 关键字和没有加入 volatile 关键字时所生成的汇编代码就能发现,加入 volatile 关键字时,会多出一个 lock 前缀指令,lock 前缀指令实际上相当于一个内存屏障(也称内存栅栏),内存屏障会提供 3 个功能:

  • 它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
  • 它会强制将对缓存的修改操作立即写入主存;
  • 如果是写操作,它会导致其他 CPU 中对应的缓存行无效。

最后,我们学习了 volatile 不适用的场景,以及解决的方法,并解释了双重检查锁定实现的单例模式为何需要使用 volatile。

编辑:沉默王二,编辑前的内容主要来自于二哥的技术派团队成员楼仔,原文链接戳:volatile。


GitHub 上标星 10000+ 的开源知识库《二哥的 Java 进阶之路》第二份 PDF 《并发编程小册》终于来了!包括线程的基本概念和使用方法、Java的内存模型、sychronized、volatile、CAS、AQS、ReentrantLock、线程池、并发容器、ThreadLocal、生产者消费者模型等面试和开发必须掌握的内容,共计 15 万余字,200+张手绘图,可以说是通俗易懂、风趣幽默……详情戳:太赞了,二哥的并发编程进阶之路.pdf

加入二哥的编程星球,在星球的第二个置顶帖「知识图谱」里就可以获取 PDF 版本。

二哥的并发编程进阶之路获取方式
二哥的并发编程进阶之路获取方式

“三妹,今天我们来学习 synchronized 关键字吧。”我瞅了一眼坐在沙发上的三妹说,她正在王者荣耀呢。

三妹(颜值在线,气质也在线)喝了一口冰可乐接着说:“好的,二哥。马上,打完这把,看我三杀。”

在 Java 中,关键字 synchronized 可以保证在同一个时刻,只有一个线程可以执行某个方法或者某个代码块(主要是对方法或者代码块中存在共享数据的操作),同时我们还应该注意到 synchronized 的另外一个重要的作用,synchronized 可保证一个线程的变化(主要是共享数据的变化)被其他线程所看到(保证可见性,完全可以替代 volatile 功能)。

synchronized 关键字最主要有以下 3 种应用方式:

  • 同步方法,为当前对象(this)加锁,进入同步代码前要获得当前对象的锁;
  • 同步静态方法,为当前类加锁(锁的是 Class 对象),进入同步代码前要获得当前类的锁;
  • 同步代码块,指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。

通过在方法声明中加入 synchronized 关键字,可以保证在任意时刻,只有一个线程能执行该方法。

来看代码:

输出结果:

如果在方法 前不加 synchronized,因为 i++ 不具备原子性,所以最终结果会小于 ,具体分析可以参考《volatile》的内容。

注意:一个对象只有一把锁,当一个线程获取了该对象的锁之后,其他线程无法获取该对象的锁,所以无法访问该对象的其他 synchronized 方法,但是其他线程还是可以访问该对象的其他非 synchronized 方法。

但是,如果一个线程 A 需要访问对象 obj1 的 synchronized 方法 f1(当前对象锁是 obj1),另一个线程 B 需要访问对象 obj2 的 synchronized 方法 f2(当前对象锁是 obj2),这样是允许的:

输出结果:

上述代码与前面不同的是,我们创建了两个对象 AccountingSyncBad,然后启动两个不同的线程对共享变量 i 进行操作,但很遗憾,操作结果是 而不是期望的结果 。

因为上述代码犯了严重的错误,虽然使用了 synchronized 同步 increase 方法,但却 new 了两个不同的对象,这也就意味着存在着两个不同的对象锁,因此 t1 和 t2 都会进入各自的对象锁,也就是说 t1 和 t2 线程使用的是不同的锁,因此线程安全是无法保证的。

每个对象都有一个对象锁,不同的对象,他们的锁不会互相影响。

解决这种问题的的方式是将 synchronized 作用于静态的 increase 方法,这样的话,对象锁就锁的是当前的类,由于无论创建多少个对象,类永远只有一个,所有在这样的情况下对象锁就是唯一的。

参考:对象和类

当 synchronized 同步静态方法时,锁的是当前类的 Class 对象,不属于某个对象。当前类的 Class 对象锁被获取,不影响实例对象锁的获取,两者互不影响,本质上是 this 和 Class 的不同。

由于静态成员变量不专属于任何一个对象,因此通过 Class 锁可以控制静态成员变量的并发操作。

需要注意的是如果线程 A 调用了一个对象的非静态 synchronized 方法,线程 B 需要调用这个对象所属类的静态 synchronized 方法,是不会发生互斥的,因为访问静态 synchronized 方法占用的锁是当前类的 Class 对象,而访问非静态 synchronized 方法占用的锁是当前对象(this)的锁,看如下代码:

由于 synchronized 关键字同步的是静态的 increase 方法,与同步实例方法不同的是,其锁对象是当前类的 Class 对象。

注意代码中的 increase4Obj 方法是实例方法,其对象锁是当前实例对象(this),如果别的线程调用该方法,将不会产生互斥现象,毕竟锁的对象不同,这种情况下可能会发生线程安全问题(操作了共享静态变量 i)。

某些情况下,我们编写的方法代码量比较多,存在一些比较耗时的操作,而需要同步的代码块只有一小部分,如果直接对整个方法进行同步,可能会得不偿失,此时我们可以使用同步代码块的方式对需要同步的代码进行包裹。

示例如下:

输出结果:

我们将 synchronized 作用于一个给定的实例对象 instance,即当前实例对象就是锁的对象,当线程进入 synchronized 包裹的代码块时就会要求当前线程持有 instance 实例对象的锁,如果当前有其他线程正持有该对象锁,那么新的线程就必须等待,这样就保证了每次只有一个线程执行 操作。

当然除了用 instance 作为对象外,我们还可以使用 this 对象(代表当前实例)或者当前类的 Class 对象作为锁,如下代码:

指令重排我们前面讲 JMM 的时候讲过, 这里我们再结合 synchronized 关键字来讲一下。

看下面这段代码:

假设线程 A 执行 方法,随后线程 B 执行 方法。根据 happens before 规则,这个过程包含的 happens before 关系可以分为:

  • 根据程序次序规则,1 happens before 2, 2 happens before 3; 4 happens before 5, 5 happens before 6。
  • 根据监视器锁规则,3 happens before 4。
  • 根据 happens before 的传递性,2 happens before 5。

在 Java 内存模型中,监视器锁规则是一种 happens-before 规则,它规定了对一个监视器锁(monitor lock)或者叫做互斥锁的解锁操作 happens-before 于随后对这个锁的加锁操作。简单来说,这意味着在一个线程释放某个锁之后,另一个线程获得同一把锁的时候,前一个线程在释放锁时所做的所有修改对后一个线程都是可见的。

上述 happens before 关系的图形化表现形式如下:

在上图中,每一个箭头链接的两个节点,代表了一个 happens before 关系。黑色箭头表示程序顺序规则;橙色箭头表示监视器锁规则;蓝色箭头表示组合这些规则后提供的 happens before 保证。

上图表示在线程 A 释放了锁之后,随后线程 B 获取同一个锁。在上图中,2 happens before 5。因此,线程 A 在释放锁之前所有可见的共享变量,在线程 B 获取同一个锁之后,将立刻变得对 B 线程可见。

也就是说,synchronized 会防止临界区内的代码与外部代码发生重排序, 方法中 a++ 的执行和 方法中 a 的读取之间存在 happens-before 关系,保证了执行顺序和内存可见性。

从互斥锁的设计上来说,当一个线程试图操作一个由其他线程持有的对象锁的临界资源时,将会处于阻塞状态,但当一个线程再次请求自己持有对象锁的临界资源时,这种情况属于重入锁,请求将会成功。

synchronized 就是可重入锁,因此一个线程调用 synchronized 方法的同时,在其方法体内部调用该对象另一个 synchronized 方法是允许的,如下:

1、AccountingSync 类中定义了一个静态的 AccountingSync 实例 instance 和两个静态的整数 i 和 j,静态变量被所有的对象所共享。

2、在 run 方法中,使用了 来加锁。这里的锁对象是 this,即当前的 AccountingSync 实例。在锁定的代码块中,对静态变量 i 进行增加,并调用了 increase 方法。

3、increase 方法是一个同步方法,它会对 j 进行增加。由于 increase 方法也是同步的,所以它能在已经获取到锁的情况下被 run 方法调用,这就是 synchronized 关键字的可重入性。

4、在 main 方法中,创建了两个线程 t1 和 t2,它们共享同一个 Runnable 对象,也就是共享同一个 AccountingSync 实例。然后启动这两个线程,并使用 join 方法等待它们都执行完成后,打印 i 的值。

此程序中的 和 synchronized 方法都使用了同一个锁对象(当前的 AccountingSync 实例),并且对静态变量 i 和 j 进行了增加操作,因此,在多线程环境下,也能保证 i 和 j 的操作是线程安全的。

“好了,三妹,今天就学到这吧。”好不容易讲完了,我长吁一口气,扶了扶眼镜对三妹说。

记住 synchronized 的三种应用方式,指令重排情况分析,以及 synchronized 的可重入性,通过今天的学习,你基本可以掌握 synchronized 的使用姿势了。

同步会带来一定的性能开销,因此需要合理使用。不应将整个方法或者更大范围的代码块做同步,而应尽可能地缩小同步范围。

在 JVM 的早期版本中,synchronized 是重量级的,因为线程阻塞和唤醒需要操作系统的介入。但在 JVM 的后续版本中,对 synchronized 进行了大量优化,如偏向锁、轻量级锁和适应性自旋等,所以现在的 synchronized 并不一定是重量级的,其性能在许多情况下都很好,可以大胆地用。

编辑:沉默王二,编辑前的内容主要来自于二哥的技术派团队成员楼仔,原文链接戳:volatile。


GitHub 上标星 10000+ 的开源知识库《二哥的 Java 进阶之路》第二份 PDF 《并发编程小册》终于来了!包括线程的基本概念和使用方法、Java的内存模型、sychronized、volatile、CAS、AQS、ReentrantLock、线程池、并发容器、ThreadLocal、生产者消费者模型等面试和开发必须掌握的内容,共计 15 万余字,200+张手绘图,可以说是通俗易懂、风趣幽默……详情戳:太赞了,二哥的并发编程进阶之路.pdf

加入二哥的编程星球,在星球的第二个置顶帖「知识图谱」里就可以获取 PDF 版本。

二哥的并发编程进阶之路获取方式
二哥的并发编程进阶之路获取方式

前面一节我们讲了 synchronized 关键字的基本使用,它能用来同步方法和代码块,那 synchronized 到底锁的是什么呢?随着 JDK 版本的升级,synchronized 又做出了哪些改变呢?“synchronized 性能很差”的谣言真的存在吗?

我想这是很多小伙伴感兴趣的。

首先需要明确的一点是:Java 多线程的锁都是基于对象的,Java 中的每一个对象都可以作为一个锁。

还有一点需要注意的是,我们常听到的类锁其实也是对象锁,上一节我们也讲到了,应该有不少小伙伴注意到了。

这里再多说几句吧。Class 对象是一种特殊的 Java 对象,代表了程序中的类和接口。Java 中的每个类型(包括类、接口、数组以及基础类型)在 JVM 中都有一个唯一的 Class 对象与之对应。这个 Class 对象被创建的时机是在 JVM 加载类时,由 JVM 自动完成。

Class 对象中包含了与类相关的很多信息,如类的名称、类的父类、类实现的接口、类的构造方法、类的方法、类的字段等等。这些信息通常被称为元数据(metadata)。

可以通过 Class 对象来获取类的元数据,甚至动态地创建类的实例、调用类的方法、访问类的字段等。这就是Java 的反射(Reflection)机制。

所以我们常说的类锁,其实就是 Class 对象的锁。

翻译成中文就是“同步”的意思。

我们通常使用关键字来给一段代码或一个方法上锁,我们上一节已经讲过了,这里简单回顾一下,因为 synchronized 真的非常重要,面试常问,开发常用。它通常有以下三种形式:

这里介绍一下“临界区”的概念。所谓“临界区”,指的是某一块代码区域,它同一时刻只能由一个线程执行。在上面的例子中,如果关键字在方法上,那临界区就是整个方法内部。而如果是 synchronized 代码块,那临界区就指的是代码块内部的区域。

通过上面的例子我们可以看到,下面这两个写法其实是等价的作用:

同理,下面这两个方法也应该是等价的:

在 JDK 1.6 以前,所有的锁都是”重量级“锁,因为使用的是操作系统的互斥锁,当一个线程持有锁时,其他试图进入synchronized块的线程将被阻塞,直到锁被释放。涉及到了线程上下文切换和用户态与内核态的切换,因此效率较低。

这也是为什么很多开发者会认为 synchronized 性能很差的原因。

那为了减少获得锁和释放锁带来的性能消耗,JDK 1.6 引入了“偏向锁”和“轻量级锁” 的概念,对 synchronized 做了一次重大的升级,升级后的 synchronized 性能可以说上了一个新台阶。

在 JDK 1.6 及其以后,一个对象其实有四种锁状态,它们级别由低到高依次是:

  1. 无锁状态
  2. 偏向锁状态
  3. 轻量级锁状态
  4. 重量级锁状态

无锁就是没有对资源进行锁定,任何线程都可以尝试去修改它,很好理解。

几种锁会随着竞争情况逐渐升级,锁的升级很容易发生,但是锁降级发生的条件就比较苛刻了,锁降级发生在 Stop The World(Java 垃圾回收中的一个重要概念,JVM 篇会细讲)期间,当 JVM 进入安全点的时候,会检查是否有闲置的锁,然后进行降级。

关于锁降级有一点需要说明:

不同于大部分文章说的锁不能降级,实际上 HotSpot JVM 是支持锁降级的,这篇帖子里有一个很关键的论述,帖子是 R 大给出的。

In its current implementation, monitor deflation is performed during every STW pause, while all Java threads are waiting at a safepoint. We have seen safepoint cleanup stalls up to 200ms on monitor-heavy-applications。

大致的意思就是重量级锁降级发生于 STW(Stop The World)阶段,降级对象为仅仅能被 VMThread 访问而没有其他 JavaThread 访问的对象。

各种锁的优缺点对比(来自《Java 并发编程的艺术》):

锁优点缺点适用场景偏向锁加锁和解锁不需要额外的消耗,和执行非同步方法比仅存在纳秒级的差距。如果线程间存在锁竞争,会带来额外的锁撤销的消耗。适用于只有一个线程访问同步块场景。轻量级锁竞争的线程不会阻塞,提高了程序的响应速度。如果始终得不到锁竞争的线程使用自旋会消耗 CPU。追求响应时间。同步块执行速度非常快。重量级锁线程竞争不使用自旋,不会消耗 CPU。线程阻塞,响应时间缓慢。追求吞吐量。同步块执行时间较长。

前面我们提到,Java 的锁都是基于对象的。

首先我们来看看一个对象的“锁”是存放在什么地方的。

每个 Java 对象都有一个对象头。如果是非数组类型,则用 2 个字宽来存储对象头,如果是数组,则会用 3 个字宽来存储对象头。在 32 位处理器中,一个字宽是 32 位;在 64 位虚拟机中,一个字宽是 64 位。对象头的内容如下表所示:

长度内容说明32/64bitMark Word存储对象的 hashCode 或锁信息等32/64bitClass Metadata Address存储到对象类型数据的指针32/64bitArray length数组的长度(如果是数组)

我们主要来看看 Mark Word 的格式:

锁状态29 bit 或 61 bit1 bit 是否是偏向锁?2 bit 锁标志位无锁001偏向锁线程 ID101轻量级锁指向栈中锁记录的指针此时这一位不用于标识偏向锁00重量级锁指向互斥量(重量级锁)的指针此时这一位不用于标识偏向锁10GC 标记此时这一位不用于标识偏向锁11

可以看到,当对象状态为偏向锁时,存储的是偏向的线程 ID;当状态为轻量级锁时,存储的是指向线程栈中的指针;当状态为重量级锁时,为指向堆中的 monitor(监视器)对象的指针。

在 Java 中,监视器(monitor)是一种同步工具,用于保护共享数据,避免多线程并发访问导致数据不一致。在 Java 中,每个对象都有一个内置的监视器。

监视器包括两个重要部分,一个是锁,一个是等待/通知机制,后者是通过 Object 类中的, , 等方法实现的(我们会在讲Condition和生产者-消费者模式)详细地讲。

下面分别介绍这几种锁以及它们之间是如何升级的。

Hotspot 的作者经过以往的研究发现大多数情况下锁不仅不存在多线程竞争,而且总是由同一线程多次获得,于是引入了偏向锁。

偏向锁会偏向于第一个访问锁的线程,如果在接下来的运行过程中,该锁没有被其他的线程访问,则持有偏向锁的线程将永远不需要触发同步。也就是说,偏向锁在资源无竞争情况下消除了同步语句,连 CAS(后面会细讲,戳链接直达) 操作都不做了,着极大地提高了程序的运行性能。

大白话就是对锁设置个变量,如果发现为 true,代表资源无竞争,则无需再走各种加锁/解锁流程。如果为 false,代表存在其他线程竞争资源,那么就会走后面的流程。

一个线程在第一次进入同步块时,会在对象头和栈帧中的锁记录里存储锁偏向的线程 ID。当下次该线程进入这个同步块时,会去检查锁的 Mark Word 里面是不是放的自己的线程 ID。

如果是,表明该线程已经获得了锁,以后该线程在进入和退出同步块时不需要花费 CAS 操作来加锁和解锁;如果不是,就代表有另一个线程来竞争这个偏向锁。这个时候会尝试使用 CAS 来替换 Mark Word 里面的线程 ID 为新线程的 ID,这个时候要分两种情况:

  • 成功,表示之前的线程不存在了, Mark Word 里面的线程 ID 为新线程的 ID,锁不会升级,仍然为偏向锁;
  • 失败,表示之前的线程仍然存在,那么暂停之前的线程,设置偏向锁标识为 0,并设置锁标志位为 00,升级为轻量级锁,会按照轻量级锁的方式进行竞争锁。

CAS: Compare and Swap 会在后面细讲,可戳链接直达,这里简单提一嘴。

CAS 是比较并设置的意思,用于在硬件层面上提供原子性操作。在 在某些处理器架构(如x86)中,比较并交换通过指令 CMPXCHG 实现((Compare and Exchange),一种原子指令),通过比较是否和给定的数值一致,如果一致则修改,不一致则不修改。

线程竞争偏向锁的过程如下:

图中涉及到了 lock record 指针指向当前堆栈中的最近一个 lock record,是轻量级锁按照先来先服务的模式进行了轻量级锁的加锁。

偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。

偏向锁升级成轻量级锁时,会暂停拥有偏向锁的线程,重置偏向锁标识,这个过程看起来容易,实则开销还是很大的,大概的过程如下:

  1. 在一个安全点(在这个时间点上没有字节码正在执行)停止拥有锁的线程。
  2. 遍历线程栈,如果存在锁记录的话,需要修复锁记录和 Mark Word,使其变成无锁状态。
  3. 唤醒被停止的线程,将当前锁升级成轻量级锁。

所以,如果应用程序里所有的锁通常处于竞争状态,那么偏向锁就会是一种累赘,对于这种情况,我们可以一开始就把偏向锁这个默认功能给关闭:

下面这个经典的图总结了偏向锁的获得和撤销:

多个线程在不同时段获取同一把锁,即不存在锁竞争的情况,也就没有线程阻塞。针对这种情况,JVM 采用轻量级锁来避免线程的阻塞与唤醒。

JVM 会为每个线程在当前线程的栈帧中创建用于存储锁记录的空间,我们称为 Displaced Mark Word。如果一个线程获得锁的时候发现是轻量级锁,会把锁的 Mark Word 复制到自己的 Displaced Mark Word 里面。

然后线程尝试用 CAS 将锁的 Mark Word 替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示 Mark Word 已经被替换成了其他线程的锁记录,说明在与其它线程竞争锁,当前线程就尝试使用自旋来获取锁。

自旋:不断尝试去获取锁,一般用循环来实现。

自旋是需要消耗 CPU 的,如果一直获取不到锁的话,那该线程就一直处在自旋状态,白白浪费 CPU 资源。解决这个问题最简单的办法就是指定自旋的次数,例如让其循环 10 次,如果还没获取到锁就进入阻塞状态。

但是 JDK 采用了更聪明的方式——适应性自旋,简单来说就是线程如果自旋成功了,则下次自旋的次数会更多,如果自旋失败了,则自旋的次数就会减少。

自旋也不是一直进行下去的,如果自旋到一定程度(和 JVM、操作系统相关),依然没有获取到锁,称为自旋失败,那么这个线程会阻塞。同时这个锁就会升级成重量级锁

在释放锁时,当前线程会使用 CAS 操作将 Displaced Mark Word 的内容复制回锁的 Mark Word 里面。如果没有发生竞争,那么这个复制的操作会成功。如果有其他线程因为自旋多次导致轻量级锁升级成了重量级锁,那么 CAS 操作会失败,此时会释放锁并唤醒被阻塞的线程。

一张图说明加锁和释放锁的过程:

重量级锁依赖于操作系统的互斥锁(mutex,用于保证任何给定时间内,只有一个线程可以执行某一段特定的代码段) 实现,而操作系统中线程间状态的转换需要相对较长的时间,所以重量级锁效率很低,但被阻塞的线程不会消耗 CPU。

前面说到,每一个对象都可以当做一个锁,当多个线程同时请求某个对象锁时,对象锁会设置几种状态用来区分请求的线程:

  • Contention List:所有请求锁的线程将被首先放置到该竞争队列
  • Entry List:Contention List 中那些有资格成为候选人的线程被移到 Entry List
  • Wait Set:那些调用 wait 方法被阻塞的线程被放置到 Wait Set
  • OnDeck:任何时刻最多只能有一个线程正在竞争锁,该线程称为 OnDeck
  • Owner:获得锁的线程称为 Owner
  • !Owner:释放锁的线程

当一个线程尝试获得锁时,如果该锁已经被占用,则会将该线程封装成一个对象插入到 Contention List 队列的队首,然后调用 方法挂起当前线程。

当线程释放锁时,会从 Contention List 或 EntryList 中挑选一个线程唤醒,被选中的线程叫做即假定继承人,假定继承人被唤醒后会尝试获得锁,但是非公平的,所以假定继承人不一定能获得锁。

这是因为对于重量级锁,如果线程尝试获取锁失败,它会直接进入阻塞状态,等待操作系统的调度。

如果线程获得锁后调用方法,则会将线程加入到 WaitSet 中,当被唤醒后,会将线程从 WaitSet 移动到 Contention List 或 EntryList 中去。需要注意的是,当调用一个锁对象的或方法时,如当前锁的状态是偏向锁或轻量级锁则会先膨胀成重量级锁

每一个线程在准备获取共享资源时: 第一步,检查 MarkWord 里面是不是放的自己的 ThreadId ,如果是,表示当前线程是处于 “偏向锁” 。

第二步,如果 MarkWord 不是自己的 ThreadId,锁升级,这时候,用 CAS 来执行切换,新的线程根据 MarkWord 里面现有的 ThreadId,通知之前线程暂停,之前线程将 Markword 的内容置为空。

第三步,两个线程都把锁对象的 HashCode 复制到自己新建的用于存储锁的记录空间,接着开始通过 CAS 操作, 把锁对象的 MarKword 的内容修改为自己新建的记录空间的地址的方式竞争 MarkWord。

第四步,第三步中成功执行 CAS 的获得资源,失败的则进入自旋 。

第五步,自旋的线程在自旋过程中,成功获得资源(即之前获的资源的线程执行完成并释放了共享资源),则整个状态依然处于 轻量级锁的状态,如果自旋失败 。

第六步,进入重量级锁的状态,这个时候,自旋的线程进行阻塞,等待之前线程执行完成并唤醒自己。

  • Java 中的每一个对象都可以作为一个锁,Java 中的锁都是基于对象的。
  • synchronized 关键字可以用来修饰方法和代码块,它可以保证在同一时刻最多只有一个线程执行该段代码。
  • synchronized 关键字在修饰方法时,锁为当前实例对象;在修饰静态方法时,锁为当前 Class 对象;在修饰代码块时,锁为括号里面的对象。
  • Java 6 为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁“。在 Java 6 以前,所有的锁都是”重量级“锁。所以在 Java 6 及其以后,一个对象其实有四种锁状态,它们级别由低到高依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态。
  • 偏向锁会偏向于第一个访问锁的线程,如果在接下来的运行过程中,该锁没有被其他的线程访问,则持有偏向锁的线程将永远不需要触发同步。也就是说,偏向锁在资源无竞争情况下消除了同步语句,连 CAS 操作都不做了,提高了程序的运行性能。
  • 轻量级锁是通过 CAS 操作和自旋来实现的,如果自旋失败,则会升级为重量级锁。
  • 重量级锁依赖于操作系统的互斥量(mutex) 实现的,而操作系统中线程间状态的转换需要相对较长的时间,所以重量级锁效率很低,但被阻塞的线程不会消耗 CPU。

编辑:沉默王二,原文内容来源于朋友小七萤火虫开源的这个仓库:深入浅出 Java 多线程,强烈推荐。


GitHub 上标星 10000+ 的开源知识库《二哥的 Java 进阶之路》第二份 PDF 《并发编程小册》终于来了!包括线程的基本概念和使用方法、Java的内存模型、sychronized、volatile、CAS、AQS、ReentrantLock、线程池、并发容器、ThreadLocal、生产者消费者模型等面试和开发必须掌握的内容,共计 15 万余字,200+张手绘图,可以说是通俗易懂、风趣幽默……详情戳:太赞了,二哥的并发编程进阶之路.pdf

加入二哥的编程星球,在星球的第二个置顶帖「知识图谱」里就可以获取 PDF 版本。

二哥的并发编程进阶之路获取方式
二哥的并发编程进阶之路获取方式

在 JDK 1.5 之前,面对 Java 并发问题, synchronized 是一招鲜的解决方案:

  1. 同步方法,锁上当前实例对象
  2. 同步静态方法,锁上当前类的 Class 对象
  3. 同步块,锁上代码块里面配置的对象

拿同步块来举例:

经过 编译后的指令如下:

  • 指令在编译后会插入到同步代码块的开始位置;
  • 指令会插入到方法结束和异常的位置(实际隐藏了try-finally)。
  • 每个对象都有一个 monitor 与之关联,当一个线程执行到 monitorenter 指令时,就会获得对象所对应 的所有权,也就获得到了对象的锁。

这里再简单说一下 monitor 的概念。

在 Java 中,monitor 可以被看作是一种守门人或保安,它确保同一时刻只有一个线程可以访问受保护的代码段。你可以将它想象成一个房间的门,门的里面有一些重要的东西,而 monitor 就是那个保护门的保安。

这里是 monitor 的工作方式:

  • 进入房间: 当一个线程想要进入受保护的代码区域(房间)时,它必须得到 monitor 的允许。如果房间里没有其他线程,monitor 会让它进入并关闭门。
  • 等待其他线程: 如果房间里已经有一个线程,其他线程就必须等待。monitor 会让其他线程排队等候,直到房间里的线程完成工作离开房间。
  • 离开房间: 当线程完成它的工作并离开受保护的代码区域时,monitor 会重新打开门,并让等待队列中的下一个线程进入。
  • 协调线程: monitor 还可以通过一些特殊的机制(例如 wait 和 notify 方法,讲 Condtion 的时候会细讲)来协调线程之间的合作。线程可以通过 monitor 来发送信号告诉其他线程现在可以执行某些操作了。

当另外一个线程执行到同步块的时候,由于它没有对应 的所有权,就会被阻塞,此时控制权只能交给操作系统,也就会从 切换到 (在讲 JMM 的时候有提到,戳链接直达), 由操作系统来负责线程间的调度和线程的状态变更, 这就需要频繁的在这两个模式下切换(上下文转换)。

有点竞争就找内核的行为很不好,会引起很大的开销,所以大家都叫它重量级锁,自然效率也很低,这也就给很多小伙伴留下了一个根深蒂固的印象 —— synchronized 关键字相比于其他同步机制性能不好,但其实不然,我们前面也讲过了。

如果 CPU 通过 CAS(后面会细讲,戳链接直达)就能处理好加锁/释放锁,这样就不会有上下文的切换。

但是当竞争很激烈,CAS 尝试再多也是浪费 CPU,权衡一下,不如升级成重量级锁,阻塞线程排队竞争,也就有了轻量级锁升级成重量级锁的过程。

HotSpot 的作者经过研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是由同一个线程多次获得,同一个线程反复获取锁,如果还按照 CAS 的方式获取锁,也是有一定代价的,如何让这个代价更小一些呢?

偏向锁实际上就是「锁对象」潜意识「偏向」同一个线程来访问,让锁对象记住这个线程 ID,当线程再次获取锁时,亮出身份,如果是同一个 ID 直接获取锁就好了,是一种 的过程,相较 CAS 又轻量级了一些。

可是多线程环境,也不可能只有同一个线程一直获取这个锁,其他线程也是要干活的,如果出现多个线程竞争的情况,就会有偏向锁升级的过程。

这里可以思考一下:偏向锁可以绕过轻量级锁,直接升级到重量级锁吗?

都是同一个锁对象,却有多种锁状态,其目的显而易见:

占用的资源越少,程序执行的速度越快。

偏向锁和轻量级锁,都不会调用系统互斥量(Mutex Lock),它们只是为了提升性能多出来的两种锁状态,这样可以在不同场景下采取最合适的策略:

  • 偏向锁:无竞争的情况下,只有一个线程进入临界区,采用偏向锁
  • 轻量级锁:多个线程可以交替进入临界区,采用轻量级锁
  • 重量级锁:多线程同时进入临界区,交给操作系统互斥量来处理

关于这部分内容,我们在讲 进击的synchronized也曾讲过,但是这部分内容的确又很需要多花点时间去搞透彻,所以我们这里就从不同的角度切入,多花点时间来盘一下。

到这里,大家应该理解了,但仍然会有很多疑问:

  1. 锁对象是在哪存储线程 ID 的?
  2. 整个升级过程是如何过渡的?

想理解这些问题,就需要先知道 Java 对象头的结构。

按照常规理解,识别线程 ID 需要一组 mapping 映射关系来搞定,如果单独维护这个 mapping 关系又要考虑线程安全的问题。根据奥卡姆剃刀原理,Java 万物皆是对象,对象皆可用作锁,与其单独维护一个 mapping 关系,不如中心化将锁的信息维护在 Java 对象本身上。

奥卡姆剃刀原理是一种问题解决原则,简单来说就是:在解释某事物时,没有必要假设更多的东西,当有多个解释时,应选择假设最少、最简单的那个解释。

Java 对象头最多由三部分构成:

  1. MarkWord
  2. ClassMetadata Address
  3. Array Length (如果对象是数组才会有这部分

其中 是保存锁状态的关键,对象锁状态可以从偏向锁升级到轻量级锁,再升级到重量级锁,加上初始的无锁状态,可以理解为有 4 种状态。想在一个对象中表示这么多信息自然就要用来存储,在 64 位操作系统中,是这样存储的(注意颜色标记),想看具体注释的可以看 hotspot(1.8) 源码文件 第 30 行。

有了这些基本信息,接下来我们就只需要弄清楚,MarkWord 中的锁信息是怎么变化的。

单纯看上图,还十分抽象,作为程序员的我们最喜欢用代码说话,贴心的 openjdk 官网提供了可以查看对象内存布局的工具 JOL (java object layout),我们直接通过 Maven 引入到项目中。

Maven Package

接下来我们就通过代码来深入了解一下偏向锁。

来看输出结果:

上面我们用到的 JOL 版本为 ,接下来我们要用 版本查看输出结果,因为这个版本给了我们更友好的说明,同样的代码,来看输出结果:

看到这个结果,有些小伙伴会有疑问,JDK 1.6 之后默认是开启偏向锁的,为什么初始化的代码是无锁状态,进入同步块产生竞争就绕过偏向锁直接变成轻量级锁了呢?

虽然默认开启了偏向锁,但是开启有延迟,大概 4s。原因是 JVM 内部的代码有很多地方用到了 synchronized,如果直接开启偏向,产生竞争就要有锁升级,会带来额外的性能损耗,所以就有了延迟策略。

可以通过参数 将延迟改为 0,但是不建议这么做。

那我们就代码延迟 5 秒来创建对象,来看看偏向是否生效

重新查看运行结果:

这样的结果是符合我们预期的,但是结果中的 状态,在 MarkWord 表格中并不存在,其实这是一种匿名偏向状态,是对象初始化中,JVM 帮我们做的。这样当有线程进入同步块时:

  1. 可偏向状态:直接就 CAS 替换 ThreadID,如果成功,就可以获取偏向锁了
  2. 不可偏向状态:就会变成轻量级锁

那问题又来了,现在锁对象有具体偏向的线程,如果新的线程过来执行同步块会偏向新的线程吗?

来看运行结果,奇怪的事情发生了:

  • : 初始可偏向状态
  • :偏向主线程后,主线程退出同步代码块
  • : 新线程进入同步代码块,升级成了轻量级锁
  • : 新线程的轻量级锁退出同步代码块,主线程查看,变为不可偏向状态
  • : 由于对象不可偏向,同场景 1主线程再次进入同步块,自然就会用轻量级锁

至此,场景一二三可以总结为一张图:

从这样的运行结果上来看,偏向锁像是“一锤子买卖”,只要偏向了某个线程,后续其他线程尝试获取锁,都会变为轻量级锁,这样的偏向非常局限。事实上并不是这样,如果你仔细看标记 2(已偏向状态),还有个 epoch 我们没有提及,这个值就是打破这种局限性的关键,在了解 epoch 之前,我们还要了解一个概念——偏向撤销(后面在讲批量撤销的时候会细讲这个陌生的 epoch)。

在讲偏向撤销之前,需要和大家明确一个概念——偏向锁撤销和偏向锁释放是两码事:

  1. 撤销:笼统的说,就是多个线程竞争导致不能再使用偏向模式,主要是告知这个锁对象不能再用偏向模式
  2. 释放:和你的常规理解一样,对应的就是 synchronized 方法的退出或 synchronized 块的结束

何为偏向撤销?

从偏向状态撤回到原来的状态,也就是将 MarkWord 的第 3 位(是否偏向撤销)的值,

如果只是一个线程获取锁,再加上「偏心」的机制,是没有理由撤销偏向的,所以偏向的撤销只能发生在有竞争的情况下

想要撤销偏向锁,还不能对持有偏向锁的线程有影响,就要等待持有偏向锁的线程到达一个 (这里的安全点是 JVM 为了保证在垃圾回收的过程中引用关系不会发生变化设置的一种安全状态,在这个状态上会暂停所有线程工作), 在这个安全点会挂起获得偏向锁的线程,后续讲 JVM 的时候会详细讲。

在这个安全点,线程可能还是处在不同的状态,先说结论(因为源码就是这么写的)

  1. 线程不存活,或者活着的线程退出了同步块,很简单,直接撤销偏向就好了
  2. 活着的线程但仍在同步块之内,那就升级成轻量级锁

这个和 epoch 貌似还是没啥关系,因为这还不是全部场景。

偏向锁是特定场景下提升程序效率的方案,可并不代表所有程序都满足这些特定场景,比如这些场景(在开启偏向锁的前提下):

  1. 一个线程创建了大量对象并执行了初始的同步操作,之后在另一个线程中将这些对象作为锁进行之后的操作。这种 case 下,会导致大量的偏向锁撤销操作
  2. 明知有多线程竞争(生产者/消费者队列),还要使用偏向锁,也会导致各种撤销

很显然,这两种场景肯定会导致偏向撤销的,一个偏向撤销的成本无所谓,大量偏向撤销的成本是不能忽视的。那怎么办?

既不想禁用偏向锁,还不想忍受大量撤销偏向增加的成本,这种方案就是设计一个有阶梯的底线

这是第一种场景的快速解决方案,以 class 为单位,为每个 class 维护一个偏向锁撤销计数器,只要 class 的对象发生偏向撤销,该计数器 ,当这个值达到重偏向阈值(默认 20)时:

JVM 就认为该 class 的偏向锁有问题,因此会进行批量重偏向, 它的实现方式就用到了我们上面说的 。

,如其含义「纪元」一样,就是一个时间戳。每个 class 对象会有一个对应的字段,每个处于偏向锁状态对象的 中也有该字段,其初始值为创建该对象时 class 中的的值(此时二者是相等的)。

每次发生批量重偏向时,就将该值加 1,同时遍历 JVM 中所有线程的栈:

  1. 找到该 class 所有正处于加锁状态的偏向锁对象,将其字段改为新值
  2. class 中不处于加锁状态的偏向锁对象(没被任何线程持有,但之前是被线程持有过的,这种锁对象的 markword 肯定也是有偏向的),保持 字段值不变

这样下次获得锁时,发现当前对象的值和 class 的,本着今朝不问前朝事 的原则(上一个纪元),就算当前已经偏向了其他线程,也不会执行撤销操作,而是直接通过 CAS 操作将其的线程 ID 改成当前线程 ID,这也算是一定程度的优化,毕竟没升级锁;

如果 都一样,说明没有发生过批量重偏向, 如果 有线程 ID,还有其他锁来竞争,那锁自然是要升级的(如同前面举的例子 epoch=0)。

批量重偏向是第一阶梯底线,还有第二阶梯底线

当达到重偏向阈值后,假设该 class 计数器继续增长,当其达到批量撤销的阈值后(默认 40)时,

JVM 就认为该 class 的使用场景存在多线程竞争,会标记该 class 为不可偏向。之后对于该 class 的锁,直接走轻量级锁的逻辑。

这就是第二阶梯底线,但是在第一阶梯到第二阶梯的过渡过程中,也就是在彻底禁用偏向锁之前,还会给一次改过自新的机会,那就是另外一个计时器:

  1. 如果在距离上次批量重偏向发生的 25 秒之内,并且累计撤销计数达到 40,就会发生批量撤销(偏向锁彻底 game over)
  2. 如果在距离上次批量重偏向发生超过 25 秒之外,就会重置在 内的计数, 再给次机会

大家有兴趣可以写代码测试一下临界点,观察锁对象 的变化。至此,整个偏向锁的工作流程可以用一张图表示:

到此,你应该对偏向锁有个基本的认识了。

上面场景一,无锁状态,对象头中没有 hashcode;偏向锁状态,对象头还是没有 hashcode,那我们的 hashcode 哪去了?

首先要知道,hashcode 不是创建对象就帮我们写到对象头中的,而是要经过第一次调用 或者 才会存储在对象头中的。

第一次生成 hashcode 后,该值应该是一直保持不变的,但偏向锁又是来回更改锁对象的 markword,必定会对 hashcode 的生成有影响,那怎么办呢?我们来用代码验证:

来看运行结果

结论就是:即便初始化为可偏向状态的对象,一旦调用 或者 ,进入同步块就会直接使用轻量级锁。

假如已偏向某一个线程,然后生成了 hashcode,然后同一个线程又进入同步块,会发生什么呢?来看代码:

查看运行结果:

结论就是:同场景一,会直接使用轻量级锁。

那假如对象处于已偏向状态,在同步块中调用了那两个方法会发生什么呢?继续代码验证:

来看运行结果:

结论就是:如果对象处在已偏向状态,生成 hashcode 后,就会直接升级成重量级锁。

最后用书中的一段话来描述锁和 hashcode 之间的关系。

Object 除了提供上述的 hashcode 方法,还有 方法,这也是我们在同步块中常用的,调用 wait 方法会对锁产生哪些影响呢?来看代码:

查看运行结果:

结论就是,wait 方法是互斥量(重量级锁)独有的,一旦调用该方法,就会升级成重量级锁(这个是面试可以说出的亮点内容哦)

最后再继续丰富一下锁对象变化图:

看到这个副标题你可能有些慌,为啥要告别偏向锁,因为维护成本有些高了,来看 Open JDK 官方声明,JEP 374: Deprecate and Disable Biased Locking

这个说明的更新时间距离现在很近,在 JDK 15 版本就已经开始了

一句话解释就是维护成本太高

最终就是,JDK 15 之前,偏向锁默认是 enabled,从 JDK 15 开始,默认就是 disabled,除非显示的通过 。

其中在 quarkus 上的一篇文章说明的更加直接

偏向锁给 JVM 增加了巨大的复杂性,只有少数非常有经验的程序员才能理解整个过程,维护成本很高,大大阻碍了开发新特性的进程(换个角度理解,你掌握了,是不是就是那少数有经验的程序员了呢?哈哈)

偏向锁可能就这样的走完了它的一生,有些小伙伴可能直接发问,都被 deprecated 了,JDK 都 17 了,还讲这么多干什么?

  1. Java 任它发,我用 Java 8,这是很多主流的状态,至少你用的版本没有被 deprecated
  2. 面试还是会被经常问到
  3. 万一哪天有更好的设计方案,“偏向锁”又以新的形式回来了呢,了解变化才能更好理解背后设计
  4. 奥卡姆剃刀原理,我们现实中的优化也一样,如果没有必要不要增加实体,如果增加的内容带来很大的成本,不如大胆的废除掉,接受一点落差

编辑:沉默王二,编辑前的内容主要来自于日拱一兵的这篇知乎文章https://zhuanlan.zhihu.com/p/


GitHub 上标星 10000+ 的开源知识库《二哥的 Java 进阶之路》第二份 PDF 《并发编程小册》终于来了!包括线程的基本概念和使用方法、Java的内存模型、sychronized、volatile、CAS、AQS、ReentrantLock、线程池、并发容器、ThreadLocal、生产者消费者模型等面试和开发必须掌握的内容,共计 15 万余字,200+张手绘图,可以说是通俗易懂、风趣幽默……详情戳:太赞了,二哥的并发编程进阶之路.pdf

加入二哥的编程星球,在星球的第二个置顶帖「知识图谱」里就可以获取 PDF 版本。

二哥的并发编程进阶之路获取方式
二哥的并发编程进阶之路获取方式

CAS(Compare-and-Swap)是一种乐观锁的实现方式,全称为“比较并交换”,是一种无锁的原子操作。

在并发编程中,我们都知道操作是非线程安全的,这是因为 操作不是原子操作,我们之前在讲多线程带来了什么问题中有讲到,大家应该还记得吧?

如何保证原子性呢?

常见的做法就是加锁。

在 Java 中,我们可以使用 synchronized关键字和 来实现加锁效果。

是悲观锁,尽管随着 JDK 版本的升级,synchronized 关键字已经“轻量级”了很多(前面有细讲,戳链接回顾),但依然是悲观锁,线程开始执行第一步就要获取锁,一旦获得锁,其他的线程进入后就会阻塞并等待锁。

如果不好理解,我们来举个生活中的例子:一个人进入厕所后首先把门锁上(获取锁),然后开始上厕所,这个时候有其他人来了就只能在外面等(阻塞),就算再急也没用。上完厕所完事后把门打开(解锁),其他人就可以进入了。

是乐观锁,线程执行的时候不会加锁,它会假设此时没有冲突,然后完成某项操作;如果因为冲突失败了就重试,直到成功为止。

锁可以从不同的角度来分类。比如我们在前面讲 synchronized 四种锁状态的时候,提到过偏向锁、轻量级锁、重量级锁,对吧?乐观锁和悲观锁也是一种分类方式。

对于悲观锁来说,它总是认为每次访问共享资源时会发生冲突,所以必须对每次数据操作加上锁,以保证临界区的程序同一时间只能有一个线程在执行。

乐观锁,顾名思义,它是乐观派。乐观锁总是假设对共享资源的访问没有冲突,线程可以不停地执行,无需加锁也无需等待。一旦多个线程发生冲突,乐观锁通常使用一种称为 CAS 的技术来保证线程执行的安全性。

由于乐观锁假想操作中没有锁的存在,因此不太可能出现死锁的情况,换句话说,乐观锁天生免疫死锁

  • 乐观锁多用于“读多写少“的环境,避免频繁加锁影响性能;
  • 悲观锁多用于”写多读少“的环境,避免频繁失败和重试影响性能。

在 CAS 中,有这样三个值:

  • V:要更新的变量(var)
  • E:预期值(expected)
  • N:新值(new)

比较并交换的过程如下:

判断 V 是否等于 E,如果等于,将 V 的值设置为 N;如果不等,说明已经有其它线程更新了 V,于是当前线程放弃更新,什么都不做。

这里的预期值 E 本质上指的是“旧值”

我们以一个简单的例子来解释这个过程:

  1. 如果有一个多个线程共享的变量原本等于 5,我现在在线程 A 中,想把它设置为新的值 6;
  2. 我们使用 CAS 来做这个事情;
  3. 首先我们用 i 去与 5 对比,发现它等于 5,说明没有被其它线程改过,那我就把它设置为新的值 6,此次 CAS 成功,的值被设置成了 6;
  4. 如果不等于 5,说明被其它线程改过了(比如现在的值为 2),那么我就什么也不做,此次 CAS 失败,的值仍然为 2。

在这个例子中,就是 V,5 就是 E,6 就是 N。

那有没有可能我在判断了为 5 之后,正准备更新它的新值的时候,被其它线程更改了的值呢?

不会的。因为 CAS 是一种原子操作,它是一种系统原语,是一条 CPU 的原子指令,从 CPU 层面已经保证它的原子性。

当多个线程同时使用 CAS 操作一个变量时,只有一个会胜出,并成功更新,其余均会失败,但失败的线程并不会被挂起,仅是被告知失败,并且允许再次尝试,当然也允许失败的线程放弃操作。

前面提到,CAS 是一种原子操作。那么 Java 是怎样来使用 CAS 的呢?我们知道,在 Java 中,如果一个方法是 native 的,那 Java 就不负责具体实现它,而是交给底层的 JVM 使用 C 语言 或者 C++ 去实现。

在 Java 中,有一个类(后面会细讲,戳链接直达),它在包中。它里面都是一些方法,其中就有几个是关于 CAS 的:

Unsafe 对 CAS 的实现是通过 C++ 实现的,它的具体实现和操作系统、CPU 都有关系。

Linux 的 X86 下主要是通过这个指令在 CPU 上完成 CAS 操作的,但在多处理器情况下,必须使用指令加锁来完成。当然,不同的操作系统和处理器在实现方式上肯定会有所不同。

CMPXCHG是“Compare and Exchange”的缩写,它是一种原子指令,用于在多核/多线程环境中安全地修改共享数据。CMPXCHG在很多现代微处理器体系结构中都有,例如Intel x86/x64体系。对于32位操作数,这个指令通常写作CMPXCHG,而在64位操作数中,它被称为CMPXCHG8B或CMPXCHG16B。

除了上面提到的方法,Unsafe 里面还有其它的方法。比如支持线程挂起和恢复的和 方法, LockSupport 类(后面会讲)底层就调用了这两个方法。还有支持反射操作的方法。

上面介绍了 Unsafe 类的几个支持 CAS 的方法。那 Java 具体是如何通过这几个方法来实现原子操作的呢?

JDK 提供了一些用于原子操作的类,在包下面。在 JDK 8 中,有以下这些类:

从名字就可以看出来这些类大概的用途(原子类后面会细讲,戳链接直达):

  • 原子更新基本类型
  • 原子更新数组
  • 原子更新引用
  • 原子更新字段(属性)

这里我们以类的方法为例,来看看 Java 是如何实现原子操作的。

先来看 getAndAdd 方法的源码:

这里的 unsafe 其实就是一个对象:

所以,类的方法是通过调用类的方法实现的:

让我们详细分析下这段代码,先看参数:

  • Object var1,这个参数代表你想要进行操作的对象。
  • long var2,这个参数是你想要操作的 var1 对象中的某个字段的偏移量。这个偏移量可以通过 Unsafe 类的 objectFieldOffset 方法获得。
  • int var4,这个参数是你想要增加的值。

再来看方法执行的过程:

  • 首先,在 do while 循环开始,通过获取当前对象指定字段的值,将其存入临时变量 var5 中。这里的 getIntVolatile 方法能保证读操作的可见性,即读取的结果是最新的写入结果,不会因为 JVM 的优化策略(如指令重排序)或者 CPU 的缓存导致读取到过期的数据。
  • 然后,执行进行 CAS 操作。如果对象 var1 在内存地址 var2 处的值等于预期值 var5,则将该位置的值更新为 var5 + var4,并返回 true;否则,不做任何操作并返回 false。
  • 如果 CAS 操作成功,说明我们成功地将 var1 对象的 var2 偏移量处的字段的值更新为 var5 + var4,并且这个更新操作是原子性的,因此我们跳出循环并返回原来的值 var5。
  • 如果 CAS 操作失败,说明在我们尝试更新值的时候,有其他线程修改了该字段的值,所以我们继续循环,重新获取该字段的值,然后再次尝试进行 CAS 操作。

这里使用的是do-while 循环。这种循环不多见,它的目的是保证循环体内的语句至少会被执行一遍。这样才能保证 return 的值是我们期望的值。

JDK 9 及其以后版本中,getAndAddInt 方法和 JDK 8 中的实现有所不同,我们就拿 JDK 11 的源码来做一个对比吧:

这个方法上面增加了 注解。这个注解允许 HotSpot VM 自己来写汇编或 IR 编译器来实现该方法以提供更加的性能。

IR(Intermediate Representation)是一种用于帮助优化编译器的中间代码表示方法。编译器通常将源代码首先转化为 IR,然后对 IR 进行各种优化,最后将优化后的 IR 转化为目标代码。在 JVM(Java Virtual Machine)中,JIT(Just-In-Time)编译器将 Java 字节码(即.class 文件的内容)转化为 IR,然后对 IR 进行优化,最后将 IR 编译为机器码。这个过程在 Java 程序运行时进行,因此被称为“即时编译”。JVM 中的 C1 和 C2 编译器就是 IR 编译器。C1 编译器在编译时进行一些简单的优化,然后快速地将 IR 编译为机器码。C2 编译器在编译时进行更深入的优化,以获得更高的执行效率,但编译的时间也相对更长。

也就是说,虽然表面上看到的是 weakCompareAndSet 和 compareAndSet,但是不排除 HotSpot VM 会手动来实现 weakCompareAndSet 真正功能的可能性。

简单来说, 操作仅保留了 自身变量的特性,而除去了 happens-before 规则带来的内存语义。换句话说,无法保证处理操作目标的 volatile 变量外的其他变量的执行顺序(编译器和处理器为了优化程序性能而对指令序列进行重新排序),同时也无法保证这些变量的可见性。 但这在一定程度上可以提高性能。

再回到循环条件上来,可以看到它是在不断尝试去用 CAS 更新。如果更新失败,就继续重试。

为什么要把获取“旧值”v 的操作放到循环体内呢?

这也好理解。前面我们说了,CAS 如果旧值 V 不等于预期值 E,就会更新失败。说明旧的值发生了变化。那我们当然需要返回的是被其他线程改变之后的旧值了,因此放在了 do 循环体内。

尽管 CAS 提供了一种有效的同步手段,但也存在一些问题,主要有以下三个:ABA 问题、长时间自旋、多个共享变量的原子操作。

所谓的 ABA 问题,就是一个值原来是 A,变成了 B,又变回了 A。这个时候使用 CAS 是检查不出变化的,但实际上却被更新了两次。

ABA 问题的解决思路是在变量前面追加上版本号或者时间戳。从 JDK 1.5 开始,JDK 的 atomic 包里提供了一个类类来解决 ABA 问题。

这个类的方法的作用是首先检查当前引用是否等于预期引用,并且检查当前标志是否等于预期标志,如果二者都相等,才使用 CAS 设置为新的值和标志。

先来看参数:

  • expectedReference:预期引用,也就是你认为原本应该在那个位置的引用。
  • newReference:新引用,如果预期引用正确,将被设置到该位置的新引用。
  • expectedStamp:预期标记,这是你认为原本应该在那个位置的标记。
  • newStamp:新标记,如果预期标记正确,将被设置到该位置的新标记。

执行流程:

①、 这行代码获取当前的 pair 对象,其中包含了引用和标记。

②、接下来的 return 语句做了几个检查:

  • :首先检查当前的引用和标记是否和预期的引用和标记相同。如果二者中有任何一个不同,这个方法就会返回 false。
  • 如果上述检查通过,也就是说当前的引用和标记与预期的相同,那么接下来就会检查新的引用和标记是否也与当前的相同。如果相同,那么实际上没有必要做任何改变,这个方法就会返回 true。
  • 如果新的引用或者标记与当前的不同,那么就会调用 casPair 方法来尝试更新 pair 对象。casPair 方法会尝试用 newReference 和 newStamp 创建的新的 Pair 对象替换当前的 pair 对象。如果替换成功,casPair 方法会返回 true;如果替换失败(也就是说在尝试替换的过程中,pair 对象已经被其他线程改变了),casPair 方法会返回 false。

CAS 多与自旋结合。如果自旋 CAS 长时间不成功,会占用大量的 CPU 资源。

解决思路是让 JVM 支持处理器提供的pause 指令

pause 指令能让自旋失败时 cpu 睡眠一小段时间再继续自旋,从而使得读操作的频率降低很多,为解决内存顺序冲突而导致的 CPU 流水线重排的代价也会小很多。

当对一个共享变量执行操作时,CAS 能够保证该变量的原子性。但是对于多个共享变量,CAS 就无法保证操作的原子性,这时通常有两种做法:

  1. 使用类保证对象之间的原子性,把多个变量放到一个对象里面进行 CAS 操作;
  2. 使用锁。锁内的临界区代码可以保证只有当前线程能操作。

CAS(Compare-and-Swap)是一种被广泛应用在并发控制中的算法,它是一种乐观锁的实现方式。CAS 全称为“比较并交换”,是一种无锁的原子操作。

CAS 的全称是:比较并交换(Compare And Swap)。在 CAS 中,有这样三个值:

  • V:要更新的变量(var)
  • E:预期值(expected)
  • N:新值(new)

比较并交换的过程如下:

判断 V 是否等于 E,如果等于,将 V 的值设置为 N;如果不等,说明已经有其它线程更新了 V,于是当前线程放弃更新,什么都不做。

这里的预期值 E 本质上指的是“旧值”

CAS 虽好,但也有一些问题,比如说 ABA 问题、循环时间长开销大、只能保证一个共享变量的原子操作等。在开发中,我们要根据实际情况来选择使用 CAS 还是使用锁。

编辑:沉默王二,编辑前的内容来源于朋友开源的这个仓库:深入浅出 Java 多线程,强烈推荐。


GitHub 上标星 10000+ 的开源知识库《二哥的 Java 进阶之路》第二份 PDF 《并发编程小册》终于来了!包括线程的基本概念和使用方法、Java的内存模型、sychronized、volatile、CAS、AQS、ReentrantLock、线程池、并发容器、ThreadLocal、生产者消费者模型等面试和开发必须掌握的内容,共计 15 万余字,200+张手绘图,可以说是通俗易懂、风趣幽默……详情戳:太赞了,二哥的并发编程进阶之路.pdf

加入二哥的编程星球,在星球的第二个置顶帖「知识图谱」里就可以获取 PDF 版本。

二哥的并发编程进阶之路获取方式
二哥的并发编程进阶之路获取方式

  • 上一篇: vs2012是干嘛用的
  • 下一篇: java官网jdk下载
  • 版权声明


    相关文章:

  • vs2012是干嘛用的2025-05-07 15:01:05
  • uoj开源2025-05-07 15:01:05
  • sql语句编写技巧2025-05-07 15:01:05
  • c语言scanf函数的用法2025-05-07 15:01:05
  • md5 字符串2025-05-07 15:01:05
  • java官网jdk下载2025-05-07 15:01:05
  • imread函数 opencv2025-05-07 15:01:05
  • swap函数实现方法2025-05-07 15:01:05
  • java单元测试是什么意思2025-05-07 15:01:05
  • ubuntu性能监控2025-05-07 15:01:05