synchronized 锁的升级
synchronized 中,锁存在四种状态分别是:无锁,偏向锁,轻量级锁,重量级锁;
偏向锁
适用场景
- 适用于锁会被同一个线程多次抢占的情况。
基本原理
- 当一个线程访问加了同步锁的代码块时,会在对象头中存储当前线程的 ID,后续这个线程进入和退出这段加了同步锁的代码块时,不需要再次加锁和释放锁。而是直接比较对象头里面是否存储了指向当前线程的偏向锁。如果相等表示偏向锁是偏向于当前线程的,就不需要再尝试获得锁了。
偏向锁获取逻辑
- 首先获取锁 对象的 Markword,判断是否处于可偏向状态。(biased_lock=1、且 ThreadId 为空);
- 如果是可偏向状态,则通过 CAS 操作,把当前线程的 ID写入到 MarkWord:
- 如果 cas 成功,那么 markword 就会变成这样。表示已经获得了锁对象的偏向锁,接着执行同步代码块;
- 如果 cas 失败,说明有其他线程已经获得了偏向锁,这种情况说明当前锁存在竞争,需要撤销已获得偏向锁的线程,并且把它持有的锁升级为轻量级锁(这个操作需要等到全局安全点,也就是没有线程在执行字节码)才能执行。
- 如果是已偏向状态,需要检查 markword 中存储的ThreadID 是否等于当前线程的 ThreadID:
- 如果相等,不需要再次获得锁,可直接执行同步代码块;
- 如果不相等,说明当前锁偏向于其他线程,需要撤销偏向锁并升级到轻量级锁。
偏向锁撤销逻辑
偏向锁的撤销并不是把对象恢复到无锁可偏向状态(因为偏向锁并不存在锁释放的概念),而是在获取偏向锁的过程中,发现 cas 失败也就是存在线程竞争时,直接把被偏向的锁对象升级到被加了轻量级锁的状态。
对原持有偏向锁的线程进行撤销时,原获得偏向锁的线程有两种情况:
- 原获得偏向锁的线程如果已经退出了临界区,也就是同步代码块执行完了,那么这个时候会把对象头设置成无锁状态并且争抢锁的线程可以基于 CAS 重新偏向但前线程;
- 如果原获得偏向锁的线程的同步代码块还没执行完,处于临界区之内,这个时候会把原获得偏向锁的线程升级为轻量级锁后继续执行同步代码块。
可以通过 jvm 参数UseBiasedLocking 来设置开启或关闭偏向锁
轻量级锁
适用场景
- 适用于多个线程在不同时间段内抢占同一把锁的情况。
轻量级锁的加锁逻辑
锁升级为轻量级锁之后,对象的 Markword 也会进行相应的的变化。升级为轻量级锁的过程:
- 线程在自己的栈桢中创建锁记录 LockRecord;
- 将锁对象的对象头中的MarkWord复制到线程的刚刚创建的锁记录中;
- 将锁记录中的 Owner 指针指向锁对象;
- 将锁对象的对象头的 MarkWord替换为指向锁记录的指针。
自旋锁
轻量级锁在加锁过程中,用到了自旋锁所谓自旋,就是指当有另外一个线程来竞争锁时,这个线程会在原地循环等待,而不是把该线程给阻塞,直到那个获得锁的线程释放锁之后,这个线程就可以马上获得锁的。注意,锁在原地循环的时候,是会消耗 cpu 的,就相当于在执行一个啥也没有的 for 循环。所以,轻量级锁适用于那些同步代码块执行的很快的场景,这样,线程原地等待很短的时间就能够获得锁了。
自旋锁的使用,其实也是有一定的概率背景,在大部分同步代码块执行的时间都是很短的。所以通过看似无异议的循环反而能提升锁的性能。但是自旋必须要有一定的条件控制,否则如果一个线程执行同步代码块的时间很长,那么这个线程不断的循环反而会消耗 CPU 资源。默认情况下自旋的次数是 10 次,可以通过 preBlockSpin 来修改。
在 JDK1.6 之后,引入了自适应自旋锁,自适应意味着自旋的次数不是固定不变的,而是根据前一次在同一个锁上自旋的时间以及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。
轻量级锁的解锁逻辑
- 轻量级锁的锁释放逻辑其实就是获得锁的逆向逻辑,通过CAS 操作把线程栈帧中的 LockRecord 替换回到锁对象的MarkWord 中,如果成功表示没有竞争。如果失败,表示当前锁存在竞争,那么轻量级锁就会膨胀成为重量级锁
重量级锁
适用场景
- 适用于多个线程同时竞争同一把锁的情况。
当轻量级锁膨胀到重量级锁之后,意味着线程只能被挂起阻塞来等待被唤醒了。
加了同步代码块以后,在字节码中会看到一个monitorenter 和 monitorexit。
每一个 JAVA 对象都会与一个监视器 monitor 关联,我们可以把它理解成为一把锁,当一个线程想要执行一段被synchronized 修饰的同步方法或者代码块时,该线程得先获取到 synchronized 修饰的对象对应的 monitor。monitorenter 表示去获得一个对象监视器。monitorexit 表示释放 monitor 监视器的所有权,使得其他被阻塞的线程可以尝试去获得这个监视器monitor 依赖操作系统的 MutexLock(互斥锁)来实现的, 线程被阻塞后便进入内核(Linux)调度状态,这个会导致系统在用户态与内核态之间来回切换,严重影响锁的性能。
重量级锁的加锁的基本流程
- 任意线程对 Object(Object 由 synchronized 保护)的访问,首先要获得 Object 的监视器。如果获取失败,线程进入同步队列,线程状态变为 BLOCKED。当访问 Object 的前驱(获得了锁的线程)释放了锁,则该释放操作唤醒阻塞在同步队列中的线程,使其重新尝试对监视器的获取。
volatile关键字
volatile的作用
volatile 可以使得在多处理器环境下保证了共享变量的可见性。
所谓可见性
在单线程的环境下,如果向一个变量先写入一个值,然后在没有写干涉的情况下读取这个变量的值,那这个时候读取到的这个变量的值应该是之前写入的那个值。这本来是一个很正常的事情。但是在多线程环境下,读和写发生在不同的线程中的时候,可能会出现:读线程不能及时的读取到其他线程写入的最新的值。这就是所谓的可见性。
volatile 关键字保证可见性
- 我们可以使用【hsdis】这个工具,来查看前面演示的这段代码的汇编指令,具体的使用请查看使用说明文档在运行的代码中,设置 jvm 参数如下【-server -Xcomp -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:CompileCommand=compileonly,App.(替换成实际运行的代码)】然后在输出的结果中,查找下 lock 指令,会发现,在修改带有 volatile 修饰的成员变量时,会多一个 lock 指令。lock是一种控制指令,在多处理器环境下,lock 汇编指令可以基于总线锁或者缓存锁的机制来达到可见性的一个效果。
从硬件层面了解可见性的本质
一台计算机中最核心的组件是 CPU、内存、以及 I/O 设备。在整个计算机的发展历程中,除了 CPU、内存以及 I/O 设备不断迭代升级来提升计算机处理性能之外,还有一个非常核心的矛盾点,就是这三者在处理速度的差异。CPU 的计算速度是非常快的,内存次之、最后是 IO 设备比如磁盘。而在绝大部分的程序中,一定会存在内存访问,有些可能还会存在 I/O 设备的访问为了提升计算性能,CPU 从单核升级到了多核甚至用到了超线程技术最大化提高 CPU 的处理性能,但是仅仅提升CPU 性能还不够,如果后面两者的处理性能没有跟上,意味着整体的计算效率取决于最慢的设备。为了平衡三者的速度差异,最大化的利用 CPU 提升性能,从硬件、操作系统、编译器等方面都做出了很多的优化:
- CPU 增加了高速缓存;
- 操作系统增加了进程、线程。通过 CPU 的时间片切换最大化的提升 CPU 的使用率;
- 编译器的指令优化,更合理的去利用好 CPU 的高速缓存。
然而每一种优化,都会带来相应的问题,而这些问题也是导致线程安全性问题的根源。为了了解前面提到的可见性问题的本质,我们有必要去了解这些优化的过程。
CPU高速缓存
线程是 CPU 调度的最小单元,线程设计的目的最终仍然是更充分的利用计算机处理的效能,但是绝大部分的运算任务不能只依靠处理器“计算”就能完成,处理器还需要与内存交互,比如读取运算数据、存储运算结果,这个 I/O 操作是很难消除的。而由于计算机的存储设备与处理器的运算速度差距非常大,所以现代计算机系统都会增加一层读写速度尽可能接近处理器运算速度的高速缓存来作为内存和处理器之间的缓冲:将运算需要使用的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步到内存之中。
通过高速缓存的存储交互很好的解决了处理器与内存的速度矛盾,但是也为计算机系统带来了更高的复杂度,因为它引入了一个新的问题,缓存一致性。
缓存一致性
首先,有了高速缓存的存在以后,每个 CPU 的处理过程是,先将计算需要用到的数据缓存在 CPU 高速缓存中,在 CPU进行计算时,直接从高速缓存中读取数据并且在计算完成之后写入到缓存中。在整个运算过程完成后,再把缓存中的数据同步到主内存。由于在多 CPU 种,每个线程可能会运行在不同的 CPU 内,并且每个线程拥有自己的高速缓存。同一份数据可能会被缓存到多个 CPU 中,如果在不同 CPU 中运行的不同线程看到同一份内存的缓存值不一样就会存在缓存不一致的问题。
为了解决缓存不一致的问题,在 CPU 层面做了很多事情,主要提供了两种解决办法:
- 总线锁
- 缓存锁
总线锁和缓存锁
总线锁,简单来说就是,在多 cpu 下,当其中一个处理器要对共享内存进行操作的时候,在总线上发出一个 LOCK#信号,这个信号使得其他处理器无法通过总线来访问到共享内存中的数据,总线锁定把 CPU 和内存之间的通信锁住了,这使得锁定期间,其他处理器不能操作其他内存地址的数据,所以总线锁定的开销比较大,这种机制显然是不合适的。
如何优化呢?最好的方法就是控制锁的保护粒度,我们只需要保证对于被多个 CPU 缓存的同一份数据是一致的就行。所以引入了缓存锁,它核心机制是基于缓存一致性协议来实现的。
缓存一致性协议
为了达到数据访问的一致,需要各个处理器在访问缓存时遵循一些协议,在读写时根据协议来操作,常见的协议有MSI,MESI,MOSI 等。最常见的就是 MESI 协议。接下来给大家简单讲解一下 MESI:
MESI 表示缓存行的四种状态,分别是:
- M(Modify) 表示共享数据只缓存在当前 CPU 缓存中,并且是被修改状态,也就是缓存的数据和主内存中的数据不一致;
- E(Exclusive) 表示缓存的独占状态,数据只缓存在当前CPU 缓存中,并且没有被修改;
- S(Shared) 表示数据可能被多个 CPU 缓存,并且各个缓存中的数据和主内存数据一致;
- I(Invalid) 表示缓存已经失效。
在 MESI 协议中,每个缓存的缓存控制器不仅知道自己的读写操作,而且也监听(snoop)其它 Cache 的读写操作。
对于 MESI 协议,从 CPU 读写角度来说会遵循以下原则:CPU 读请求:缓存处于 M、E、S 状态都可以被读取,I 状 态 CPU 只能从主存中读取数据。
CPU 写请求:缓存处于 M、E 状态才可以被写。对于 S 状态的写,需要将其他 CPU 中缓存行置为无效才可写使用总线锁和缓存锁机制之后,CPU 对于内存的操作大概可以抽象成下面这样的结构。从而达到缓存一致性效果。
总结可见性的本质
由于 CPU 高速缓存的出现使得 如果多个 cpu 同时缓存了相同的共享数据时,可能存在可见性问题。也就是 CPU0 修改了自己本地缓存的值对于 CPU1 不可见。不可见导致的后果是 CPU1 后续在对该数据进行写入操作时,是使用的脏数据。使得数据最终的结果不可预测。
了解到这里,大家应该会有一个疑问,刚刚不是说基于缓存一致性协议或者总线锁能够达到缓存一致性的要求吗?为什么还需要加 volatile 关键字?或者说为什么还会存在可见性问题呢?
MESI 优化带来的可见性问题
MESI 协议虽然可以实现缓存的一致性,但是也会存在一些问题。就是各个 CPU 缓存行的状态是通过消息传递来进行的。如 果 CPU0 要对一个在缓存中共享的变量进行写入,首先需要发送一个失效的消息给到其他缓存了该数据的 CPU。并且要等到他们的确认回执。CPU0 在这段时间内都会处于阻塞状态。为了避免阻塞带来的资源浪费。在 cpu 中引入了 Store Bufferes。
CPU0 只需要在写入共享数据时,直接把数据写入到 storebufferes 中,同时发送 invalidate 消息,然后继续去处理其他指令。当收到其他所有 CPU 发送了 invalidate acknowledge 消息时,再将 store bufferes 中的数据数据存储至 cache line中。最后再从缓存行同步到主内存。
但是这种优化存在两个问题:
- 数据什么时候提交是不确定的,因为需要等待其他 cpu给回复才会进行数据同步。这里其实是一个异步操作;
- 引入了 storebufferes 后,处理器会先尝试从 storebuffer中读取值,如果 storebuffer 中有数据,则直接从storebuffer 中读取,否则就再从缓存行中读取。
CPU 层面的内存屏障
什么是内存屏障?
从前面的内容基本能有一个初步的猜想,内存屏障就是将 store bufferes 中的指令写入到内存,从而使得其他访问同一共享内存的线程的可见性。X86 的 memory barrier 指令包括 lfence(读屏障) sfence(写屏障) mfence(全屏障)Store Memory Barrier(写屏障) 告诉处理器在写屏障之前的所有已经存储在存储缓存(store bufferes)中的数据同步到主内存,简单来说就是使得写屏障之前的指令的结果对屏障之后的读或者写是可见的Load Memory Barrier(读屏障) 处理器在读屏障之后的读操作,都在读屏障之后执行。配合写屏障,使得写屏障之前的内存更新对于读屏障之后的读操作是可见的Full Memory Barrier(全屏障) 确保屏障前的内存读写操作的结果提交到内存之后,再执行屏障后的读写操作有了内存屏障以后,对于上面这个例子,我们可以这么来改,从而避免出现可见性问题。
总的来说,内存屏障的作用可以通过防止 CPU 对内存的乱序访问来保证共享数据在多线程并行执行下的可见性但是这个屏障怎么来加呢?回到最开始我们讲 volatile 关键字的代码,这个关键字会生成一个 Lock 的汇编指令,这个指令其实就相当于实现了一种内存屏障这个时候问题又来了,内存屏障、重排序这些东西好像是和平台以及硬件架构有关系的。作为 Java 语言的特性,一次编写多处运行。我们不应该考虑平台相关的问题,并且这些所谓的内存屏障也不应该让程序员来关心。