锁的机制
如内存与零拷贝技术一文所示。CPU读取数据进行计算的内存全景如下:
我们可以看到,数据可能发生不一致的地方就是缓存不共用的地方,这是缓存不共用的地方主要有两处,一是CPU高速缓存不一致。二是Java线程中的工作内存。
单机
单核
单个 CPU 对于每个任务的执行,都只执行一小段时间,不断的在多个任务之间快速的切换。多进程和多线程是一种提高 CPU 使用率的方案,即并发。
CPU 在任务执行过程中,本身是感知不到时间片存在。在晶振产生的时钟周期驱动下,CPU 会不间断的根据 PC 寄存器里的地址进行取址译码执行。晶振是一个中断,中断的频率越高,CPU 执行的速度也就越快。而时间片一切,PC 寄存器的地址改变,CPU 就被动的在多个任务之间切换执行。
时间片切换有两个来源,一个是操作系统控制的并发,一个是软硬件中断。并发本身也是通过晶振中断来实现的,因为由操作系统控制,额外增加了优先级队列等一系列控制,所以单独作为一个来源来分析比较清晰。
所以现在就通过并发和中断,来分析时间片切换可能会导致的共享数据安全问题。
非原子操作
并发
就是大家都知道的,我们写的代码如果在多线程场景下,如果有共享数据,那么共享数据会不安全,会产生数据紊乱。
1 | |
上面代码,flag 是共享数据,threads 被并发执行。当 threads 函数被 1000 个线程并发执行的时候,最后 flag 的值 < 1000,极小概率 = 1000。因为 flag++ 需要最少三条指令才能运行完毕,分别是读内存、寄存器赋值、写内存。这里就会出现 a 线程执行了读内存指令后,时间片切到了 b 线程,b 线程完成了读、赋值、写指令,又切回到 a 线程,a 继续完成赋值和写指令。因为 a、b 读到的内存值是一样的,最后写入的也就是一样的,所以 flag 相当于少了一次 + 1 操作。时间片轮转对于指令执行流程来说是随机的,所以 a 和 b 的三个指令完全有可能任意交叉执行。详见下表:
中断
上面也说到,并发是根据晶振中断实现的。除了晶振中断,还有其他的软硬件中断会改变 CPU 的执行流程。如果改写了中断向量表的中断指向或者我们在监控到中断到来时执行特定函数,一样会遇到和上面的并发一样的数据安全问题,代码如下:
1 | |
我们假设 thread_run 函数是单线程执行的,因为中断时机是未知的,完全有可能 interrupt_run 和 thread_run 的执行时机会出现上面并发场景下的情况,这个时候 flag 也不再数据安全。和并发一样,详见下表:
非原子操作,即任务执行过程中可能会因为时间片轮转发生执行中断情况的操作。如果非原子操作中出现共享数据,则共享数据不在安全,可能会产生紊乱。
这里有两个前提,即非原子操作和共享数据。如果没有共享数据,相当于 1 个 CPU 的两条任务线独立执行,是没有问题的。那如果是原子操作呢?
原子操作
对于原子操作,即执行周期内不会被打断的指令。该指令可能需要多个时钟周期才能运行完毕,因为有取址、译码、执行一套动作,最少也需要 1 个时钟周期,全过程称为执行周期。在执行周期内,该指令一定有头有尾的被执行完毕,即要么不执行,要么全执行。
当一个任务不会被时间片轮转后中途暂停执行,那么这个任务在单核场景下就是安全的。
其实 CPU 提供的指令集基本都是原子操作的,比如读写内存的 “load xxx” 和 “store xxx”,这些指令在单核下都是安全的。如果我们都写汇编并且实际任务运算都可以通过原子操作完成,那么在单核分时机制下就不会有数据安全问题。但实际上,即使我们都写汇编,但我们真实执行的任务都不是原子操作可以完成的,即我们需要解决的任务需要 N 个原子操作配合才能够完成。只要 >= 2 个原子操作配合的任务流,都有可能在时间片轮转的情况下被中断执行,中断过程中共享数据就有可能被其他任务修改,不在数据安全。
CPU原子操作指令
xchg 原子操作
1 | |
上面代码中,xchg 是 x86 提供的内存交换指令,即将一个寄存器值和一个内存地址中的值进行原子操作交换。比如上面例子,lock 内存地址默认值为 0,ax 寄存器默认值为 1。在 threads 执行一次后,lock 内存地址中的值会变成 1,ax 寄存器中值会变成 0。再执行一次,则因为 ax 寄存器中值为 0,所以再次互换后,就回到了初始的默认状态,即 lock 为 0,ax 为 1。
cmpxchg 原子操作
1 | |
上面代码中,cmpxchg 是 x86 提供的比较交换指令,共需要 2 个寄存器和 1 个寄存器或者内存地址。
首先需要有一个用于比较的值,这个值需要在寄存器中,上面我们用 ax 存储。
还需要一个 “首操作数”,即 cmpxchg 指令后面的第一个操作数,这个操作数可以是寄存器或者内存地址,上面我们用 bx 或者 &lock 表示。
最后还需要 “源操作数”,即 cmpxchg 指令后面的第二个操作数,这个操作数需要在寄存器中,上面我们用 cx 存储。
cmpxchg 就是让比较值和首操作数比大小,如果相等,则首操作数赋值为源操作数。如果不想等,则比较值赋值为源操作数。
我们分析 cmpxchg &lock, cx; 这种场景,因为 ax 为 1,lock 内存地址值为 0,两者不想等,所以 cmpxchg 指令执行完成后,lock 没有变化,ax 寄存器变成了 2。如果按照上面注释里面 lock 初始化为 1,则 ax 和 lock 内存地址值都是 1,两者想等,这个时候 lock 内存地址值会变成 2。
多核
CPU Cache
随着时间的推移,CPU 和内存的访问性能相差越来越大,于是就在 CPU 内部嵌入了 CPU Cache(高速缓存),CPU Cache 离 CPU 核心相当近,因此它的访问速度是很快的,于是它充当了 CPU 与内存之间的缓存角色。
CPU Cache 通常分为三级缓存:L1 Cache、L2 Cache、L3 Cache,级别越低的离 CPU 核心越近,访问速度也快,但是存储容量相对就会越小。其中,在多核心的 CPU 里,每个核心都有各自的 L1/L2 Cache,而 L3 Cache 是所有核心共享使用的。
缓存一致性问题
那什么是缓存一致性呢?我们拿一个有两个核心的 CPU,来看一下。你可以看这里这张图,我们结合图来说。
比方说,iPhone 降价了,我们要把 iPhone 最新的价格更新到内存里。为了性能问题,它采用了上一讲我们说的写回策略,先把数据写入到 L2 Cache 里面,然后把 Cache Block 标记成脏的。这个时候,数据其实并没有被同步到 L3 Cache 或者主内存里。1 号核心希望在这个 Cache Block 要被交换出去的时候,数据才写入到主内存里。
如果我们的 CPU 只有 1 号核心这一个 CPU 核,那这其实是没有问题的。不过,我们旁边还有一个 2 号核心呢!这个时候,2 号核心尝试从内存里面去读取 iPhone 的价格,结果读到的是一个错误的价格。这是因为,iPhone 的价格刚刚被 1 号核心更新过。但是这个更新的信息,只出现在 1 号核心的 L2 Cache 里,而没有出现在 2 号核心的 L2 Cache 或者主内存里面。这个问题,就是所谓的缓存一致性问题,1 号核心和 2 号核心的缓存,在这个时候是不一致的。
为了解决这个缓存不一致的问题,我们就需要有一种机制,来同步两个不同核心里面的缓存数据。那这样的机制需要满足什么条件呢?我觉得能够做到下面两点就是合理的。
第一点叫写传播(Write Propagation)。写传播是说,在一个 CPU 核心里,我们的 Cache 数据更新,必须能够传播到其他的对应节点的 Cache Line 里。
第二点叫事务的串行化(Transaction Serialization),事务串行化是说,我们在一个 CPU 核心里面的读取和写入,在其他的节点看起来,顺序是一样的。
第一点写传播很容易理解。既然我们数据写完了,自然要同步到其他 CPU 核的 Cache 里。但是第二点事务的串行化,可能没那么好理解,我这里仔细解释一下。
我们还拿刚才修改 iPhone 的价格来解释。这一次,我们找一个有 4 个核心的 CPU。1 号核心呢,先把 iPhone 的价格改成了 5000 块。差不多在同一个时间,2 号核心把 iPhone 的价格改成了 6000 块。这里两个修改,都会传播到 3 号核心和 4 号核心。
然而这里有个问题,3 号核心先收到了 2 号核心的写传播,再收到 1 号核心的写传播。所以 3 号核心看到的 iPhone 价格是先变成了 6000 块,再变成了 5000 块。而 4 号核心呢,是反过来的,先看到变成了 5000 块,再变成 6000 块。虽然写传播是做到了,但是各个 Cache 里面的数据,是不一致的。
事实上,我们需要的是,从 1 号到 4 号核心,都能看到相同顺序的数据变化。比如说,都是先变成了 5000 块,再变成了 6000 块。这样,我们才能称之为实现了事务的串行化。
事务的串行化,不仅仅是缓存一致性中所必须的。比如,我们平时所用到的系统当中,最需要保障事务串行化的就是数据库。多个不同的连接去访问数据库的时候,我们必须保障事务的串行化,做不到事务的串行化的数据库,根本没法作为可靠的商业数据库来使用。
而在 CPU Cache 里做到事务串行化,需要做到两点,第一点是一个 CPU 核心对于数据的操作,需要同步通信给到其他 CPU 核心。第二点是,如果两个 CPU 核心里有同一个数据的 Cache,那么对于这个 Cache 数据的更新,需要有一个“锁”的概念。只有拿到了对应 Cache Block 的“锁”之后,才能进行对应的数据更新。
非原子性
1 | |
非原子操作在单核下因为执行过程被打断,会出现数据紊乱。在并行也是下一样的,只是执行过程被打断的原因不是因为时间片轮转,而是同时操作。
core1 拿到 flag 为 0,core2 拿到 flag 也为 0。两个核心执行完毕后,flag 没有变成 2,而是 1。
和并发 & 中断相比,现象是一样的,原因的本质也是一样的,只是原因的表现有些不同。所以非原子操作在多核下,并发 & 中断 & 并行 一起导致了数据不安全。
原子性
原子操作在单核下是安全的,但是在多核下原子操作就不在安全了。拿 cmpxchg 举例子来说
1 | |
前面说到 cmpxchg 的执行需要很多个时钟周期,包含内存写、寄存器读等多个操作,但是 cmpxchg 的执行流程不会被时间片轮转所打算,从开头到结尾一鼓作气执行完毕,所以在单核场景下是数据安全的原子操作。
但是在多核场景下,有可能会出现一个 CPU 核心把 “首操作数” 取值完毕,另一个 CPU 核心同时把 lock 内存地址的值给改了。因为 cmpxchg 需要好几个任务流程,需要很多时钟周期,很难说执行过程中会不会有另一个 CPU 也对共享内存值做了其他的操作。这时候就有可能出现 ax 寄存器值和 “首操作数” 开始的时候不一样,cx 被赋值到 ax 寄存器。可是指令执行结束后,发现 lock 内存地址的值和当时 cx 寄存器值却是一样的,这就产生了问题。所以 cmpxchg 没有死于 时间片轮转,却死在了并行上。
共享数据不一致原因总结
原子性、可见性有序性三个重要的问题,其实这就是共享数据安全的三大核心。
这三个原因都会导致共享数据不再安全,使得我们写的代码稍不注意就会有错误风险。
如果要解决共享数据安全问题,就可以从这三个方面找切入点。而且这三个核心因素之间还有一层间接的联系,即三大核心的包含关系。
有序性
也没啥特效药。编译器或者解释器,都会提供一些前缀给开发人员。开发人员只要觉得一个问题能够单纯通过有序性来解决,那就可以这么做。
比如 C/CPP 中,可以通过下面禁用:
1 | |
C/CPP 中还提供了 volatile 描述符,该描述符可以停止编译器优化,也可以直接用。Java 中也提供了 volatile,比 C 的 volatile 含义要丰富很多,也可以停止编译器优化。
可见性
高速缓存内存一致性-总线嗅探机制和 MESI 协议
要解决缓存一致性问题,首先要解决的是多个 CPU 核心之间的数据传播问题。最常见的一种解决方案呢,叫作总线嗅探(Bus Snooping)。这个名字听起来,你多半会很陌生,但是其实特很好理解。
这个策略,本质上就是把所有的读写请求都通过总线(Bus)广播给所有的 CPU 核心,然后让各个核心去“嗅探”这些请求,再根据本地的情况进行响应。
总线本身就是一个特别适合广播进行数据传输的机制,所以总线嗅探这个办法也是我们日常使用的 Intel CPU 进行缓存一致性处理的解决方案。关于总线这个知识点,我们会放在后面的 I/O 部分更深入地进行讲解,这里你只需要了解就可以了。
基于总线嗅探机制,其实还可以分成很多种不同的缓存一致性协议。不过其中最常用的,就是今天我们要讲的 MESI 协议。和很多现代的 CPU 技术一样,MESI 协议也是在 Pentium 时代,被引入到 Intel CPU 中的。
MESI 协议,是一种叫作写失效(Write Invalidate)的协议。在写失效协议里,只有一个 CPU 核心负责写入数据,其他的核心,只是同步读取到这个写入。在这个 CPU 核心写入 Cache 之后,它会去广播一个“失效”请求告诉所有其他的 CPU 核心。其他的 CPU 核心,只是去判断自己是否也有一个“失效”版本的 Cache Block,然后把这个也标记成失效的就好了。
相对于写失效协议,还有一种叫作写广播(Write Broadcast)的协议。在那个协议里,一个写入请求广播到所有的 CPU 核心,同时更新各个核心里的 Cache。
写广播在实现上自然很简单,但是写广播需要占用更多的总线带宽。写失效只需要告诉其他的 CPU 核心,哪一个内存地址的缓存失效了,但是写广播还需要把对应的数据传输给其他 CPU 核心。
MESI 协议的由来呢,来自于我们对 Cache Line 的四个不同的标记,分别是:
- M:代表已修改(Modified)
- E:代表独占(Exclusive)
- S:代表共享(Shared)
- I:代表已失效(Invalidated)
我们先来看看“已修改”和“已失效”,这两个状态比较容易理解。所谓的“已修改”,就是我们上一讲所说的“脏”的 Cache Block。Cache Block 里面的内容我们已经更新过了,但是还没有写回到主内存里面。而所谓的“已失效“,自然是这个 Cache Block 里面的数据已经失效了,我们不可以相信这个 Cache Block 里面的数据。
然后,我们再来看“独占”和“共享”这两个状态。这就是 MESI 协议的精华所在了。无论是独占状态还是共享状态,缓存里面的数据都是“干净”的。这个“干净”,自然对应的是前面所说的“脏”的,也就是说,这个时候,Cache Block 里面的数据和主内存里面的数据是一致的。
那么“独占”和“共享”这两个状态的差别在哪里呢?这个差别就在于,在独占状态下,对应的 Cache Line 只加载到了当前 CPU 核所拥有的 Cache 里。其他的 CPU 核,并没有加载对应的数据到自己的 Cache 里。这个时候,如果要向独占的 Cache Block 写入数据,我们可以自由地写入数据,而不需要告知其他 CPU 核。
在独占状态下的数据,如果收到了一个来自于总线的读取对应缓存的请求,它就会变成共享状态。这个共享状态是因为,这个时候,另外一个 CPU 核心,也把对应的 Cache Block,从内存里面加载到了自己的 Cache 里来。
而在共享状态下,因为同样的数据在多个 CPU 核心的 Cache 里都有。所以,当我们想要更新 Cache 里面的数据的时候,不能直接修改,而是要先向所有的其他 CPU 核心广播一个请求,要求先把其他 CPU 核心里面的 Cache,都变成无效的状态,然后再更新当前 Cache 里面的数据。这个广播操作,一般叫作 RFO(Request For Ownership),也就是获取当前对应 Cache Block 数据的所有权。
有没有觉得这个操作有点儿像我们在多线程里面用到的读写锁。在共享状态下,大家都可以并行去读对应的数据。但是如果要写,我们就需要通过一个锁,获取当前写入位置的所有权。
整个 MESI 的状态,可以用一个有限状态机来表示它的状态流转。需要注意的是,对于不同状态触发的事件操作,可能来自于当前 CPU 核心,也可能来自总线里其他 CPU 核心广播出来的信号。
Java线程可见性

先介绍一下Java内存模型中定义的8种工作内存与主内存之间的原子操作
- lock( 锁定 ):作用于主内存的变量,把一个变量标识为一条线程独占的状态。
- unlock(解锁):作用于主内存的变量,把一个处于锁定的变量释放出来,释放变量才可以被其他线程锁定。
- read(读取):作用于主内存的变量,把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。
- load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
- use(使用):作用于工作内存种的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作。
- assign(赋值):作用于工作内存中的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
- store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用
- write(写入):作用于主内存的变量,它把store操作从工作内存中得到的值放入主内存的变量中。
volatile变量对对象的操作有严格要求:
- use之前不能被read&load
- assign之后必须紧跟store&write
也就是说 read-load-use 和 assign-store-write成为了两个不可分割的原子操作。这样就保证了assign的值会直接写到主存中。
尽管这时候在use和assign之间依然有一段真空期,有可能变量会被其他线程读取,但是无论在哪一个时间点主内存的变量和任一工作内存的变量的值都是相等的。这个特性就导致了volatile变量不适合参与到依赖当前值的运算,如自增。 那么依靠可见性的特点volatile可以用在哪些地方呢? 《Java虚拟机》提到:
运算结果并不依赖变量的当前值(即结果对产生中间结果不依赖),或者能够确保只有单一的线程修改变量的值
通常volatile用做保存某个状态的boolean值。
原子性
硬件层面支持指令原子性安全
在单核的时候,我们提到的原子性指令都是安全的,但是在多核并行的时候,就不再安全了。在并行 - 原子操作那节,我们用 cmpxchg 做为示例说明了原子指令的不安全。
所以原子性解决方案里面,第一步就是如何把那些 CPU 的原子性指令变得安全。因为这些指令不安全,那到底是用不是不用呢?不用感觉可惜,他们是很强大的指令,用吧就不安全。所以这个问题必须要解决。
解决原子性问题的方案有两种,一种是 Bus 总线锁,一种是高速缓存行锁。但具体使用那个锁,是由硬件决定的,我们要做的仅仅是加锁:
1 | |
上面的 volatile 是为了防止编译器优化,为了防止有序性和可见性问题。lock 则是 xchg 的锁。
如果是总线锁,则 core0 开始执行 xchg 的时候,会将整个 bus 总线锁住,其他的任何总线操作都不允许执行。这样的性能开销非常大,所以出现了缓存锁。
缓存锁即对于 &lock 的缓存行加锁。如果 core0 加了缓存锁,那么其他核在访问 &lock 的时候,因为不同的高速缓存的 &lock 缓存行均被锁住,所以其他核心无法执行。只有当 core0 的 xchg 指令执行完毕,解开了缓存锁,其他指令才会继续执行下去。
所以通过总线锁或者缓存锁,就可以使得 xchg 和 cmpxchg 这些原子指令在多核并行场景下也能够正常执行。
总线锁还是缓存锁
x86汇编中,如果对一个指令加“lock”前缀,对于Lock指令区分两种实现方法。对于早期的CPU,总是采用的是锁总线的方式。具体方法是,一旦遇到了Lock指令,就由仲裁器选择一个核心独占总线。其余的CPU核心不能再通过总线与内存通讯。从而达到“原子性”的目的。
具体做法是,某一个核心触发总线的“Lock#”那根线,让总线仲裁器工作,把总线完全分给某个核心。
这种方式的确能解决问题,但是非常不高效。为了个原子性结果搞得其他CPU都不能干活了。因此从Intel P6 CPU开始就做了一个优化,改用Ringbus + MESI协议,也就是文档里说的cache conherence机制。这种技术被Intel称为“Cache Locking”。
根据文档原文:如果是P6后的CPU,并且数据已经被CPU缓存了,并且是要写回到主存的,则可以用cache locking处理问题。否则还是得锁总线。因此,lock到底用锁总线,还是用cache locking,完全是看当时的情况。当然能用后者的就肯定用后者。
Intel P6是Intel第6代架构的CPU,其实也很老了,差不多1995年出的…… 比如Pentium Pro,Pentium II,Pentium III都隶属于P6架构。
Java原子性安全
在Java中可以通过锁和循环CAS的方式来实现原子操作。
CAS
我们通过 Java 中的 AtomicInteger类中的 getAndIncrement()来看下 CAS 底层是怎么实现的。
1 | |
可以看到它是调用的Unsafe类的getAndAddInt方法,
1 | |
可以看到该方法内部是先获取到该对象的偏移量对应的值(value),然后调用 compareAndSwapInt 方法通过对比来修改该值,如果这个值和value一样,说明此过程中间没有 人修改该数据,此时可以将该地址的值改为 value+delta, 返回true,结束循环。否则,说明有人修改该地址处的值,返回false,继续下一次循环。 那么是怎么保证 compareAndSwapInt(CAS)的原子性呢?这个就由操作系统底层来提供了,要不然就无限套娃了。
compareAndSwapInt 是一个 native 方法, 我们看下 Hotspot 源码中 对 compareAndSwapInt的实现:
1 | |
可以看到这里最后调用了Atomic::cmpxchg方法,我们来看下linux下atomic_linux_x86.inline.hpp这个方法的实现
1 | |
is_MP() 是判断是否有多个CPU,如果是多个CPU返回1,单个CPU返回0
可以看下 LOCK_IF_MP 方法, LOCK_IF_MP(%4) 入参是第四个参数,
“r” (exchange_value),// 第一个参数
“a” (compare_value), // 第二个参数
“r” (dest), // 第三个参数
“r” (mp) // 第四个参数
#define LOCK_IF_MP(mp) “cmp $0, “ #mp “; je 1f; lock; 1: “
可以看到 如果 mp 不为0,这里加了 lock 指令,根据CPU不同,lock 指令会对总线或者缓存加锁,其他CPU的请求将被阻塞,当前CPU是可以独占共享内存的。
CAS虽然很高效地解决了原子操作,但是CAS仍然存在三 大问题。ABA问题,循环时间长开销大,以及只能保证一个共享变量的原子操作。
- ABA问题。因为CAS需要在操作值的时候,检查值有没有发生变化,如果没有发生变化 则更新,但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它 的值没有发生变化,但是实际上却变化了。ABA问题的解决思路就是使用版本号。在变量前面 追加上版本号,每次变量更新的时候把版本号加1,那么A→B→A就会变成1A→2B→3A。从 Java 1.5开始,JDK的Atomic包里提供了一个类AtomicStampedReference来解决ABA问题。这个 类的compareAndSet方法的作用是首先检查当前引用是否等于预期引用,并且检查当前标志是 否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。
- 自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销。如果JVM能支持处理器提供的pause指令,那么效率会有一定的提升。pause指令有两个作用:第 一,它可以延迟流水线执行指令(de-pipeline),使CPU不会消耗过多的执行资源,延迟的时间 取决于具体实现的版本,在一些处理器上延迟时间是零;第二,它可以避免在退出循环的时候 因内存顺序冲突(Memory Order Violation)而引起CPU流水线被清空(CPU Pipeline Flush),从而 提高CPU的执行效率。
- 只能保证一个共享变量的原子操作。当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁。还有一个取巧的办法,就是把多个共享变量合并成一个共享变量来操作。比如,有两个共享变量i=2,j=a,合并一下ij=2a,然后用CAS来操作ij。从Java 1.5开始,JDK提供了AtomicReference类来保证引用对象之间的原子性,就可以把多个变量放在一个对象里来进行CAS操作
单机锁机制
synchronized锁
在多线程并发编程中synchronized一直是元老级角色,很多人都会称呼它为重量级锁。但是,随着Java SE 1.6对synchronized进行了各种优化之后,有些情况下它就并不那么重了。上面总共定义了三把锁,分别保护三个资源。 1、2、3 是同一把锁,保护当前 Person 的每一个实例对象。 4 是一把锁,保护当前 Person.class 类。5 也是一把锁,保护 obj 局部对象。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class Person {
// 1 修饰非静态方法
synchronized void foo() {
// 临界区
}
// 2 修饰非静态方法
synchronized void getMoney() {
// 临界区
}
// 3 修饰非静态方法
synchronized void setMoney() {
// 临界区
}
// 4 修饰静态方法
synchronized static void bar() {
// 临界区
}
// 5 修饰代码块
Object obj = new Object();
void baz() {
synchronized(obj) {
// 临界区
}
}
}自旋锁
自旋锁是其他锁的基石,基于内存交换指令的 CPU 原子性安全指令 lock; xchg 实现。xchg 和 lock 已经在 “单核 - xchg 原子操作” 和 “原子性解决方案 - 硬件支持” 两趴说明过了。下面看看自旋锁如何实现。
自旋锁的原理是首先读取锁变量,判断其值是否已经加锁,如果未加锁则执行加锁,然后返回,表示加锁成功;如果已经加锁了,就要返回第一步继续判断其值是否已经加锁不断循环,因而得名自旋锁。
使用自旋锁,会产生非常大的性能消耗。因为在没有拿到锁的时候,会一直循环尝试获取锁,会使得 CPU 的使用率飙升,但只要上一把锁还没有释放,飙升的 CPU 使用率都是徒劳的。
但是自旋锁却又是最高效的,因为下一把等待锁的线程一直在尝试加锁,所以只要上一把锁被释放,下一把锁就会立刻响应。
毫不夸张的说,除了硬件层面的 lock 锁,自旋锁的所有锁中效率最高的。因为其他锁都是依靠自旋锁不断加临界区的判断条件,不可能效率上比得过自旋锁。
所以,目前对于自旋锁的使用都很谨慎,主要是担心过大的性能消耗。比较好的办法呢,是即使用自旋锁的高效率,又让自旋锁仅仅执行非常少的时间,这样就可以低消耗、高性能的使用自旋锁。所以操作系统和高级语言就依靠以自旋锁为底层实现,依靠银弹 “中间层” 这个神器,群魔乱舞了。介绍其他妖魔之前,再说一下自旋锁的优先级反转问题。偏向锁
如果不仅仅没有实际竞争,自始至终,使用锁的线程都只有一个,那么,维护轻量级锁都是浪费的。偏向锁的目标是,减少无竞争且只有一个线程使用锁的情况下,使用轻量级锁产生的性能消耗。轻量级锁每次申请、释放锁都至少需要一次CAS,但偏向锁只有初始化时需要一次CAS。
“偏向”的意思是,偏向锁假定将来只有第一个申请锁的线程会使用锁(不会有任何线程再来申请锁),因此,只需要在Mark Word中CAS记录owner(本质上也是更新,但初始值为空),如果记录成功,则偏向锁获取成功,记录锁状态为偏向锁,以后当前线程等于owner就可以零成本的直接获得锁;否则,说明有其他线程竞争,膨胀为轻量级锁。
偏向锁无法使用自旋锁优化,因为一旦有其他线程申请锁,就破坏了偏向锁的假定。轻量级锁
轻量级锁的目标是,减少无实际竞争情况下,使用重量级锁产生的性能消耗,包括系统调用引起的内核态与用户态切换、线程阻塞造成的线程切换等。
顾名思义,轻量级锁是相对于重量级锁而言的。使用轻量级锁时,不需要申请互斥量,仅仅_将Mark Word中的部分字节CAS更新指向线程栈中的Lock Record,如果更新成功,则轻量级锁获取成功_,记录锁状态为轻量级锁;否则,说明已经有线程获得了轻量级锁,目前发生了锁竞争(不适合继续使用轻量级锁),接下来膨胀为重量级锁。重量级锁
内置锁在Java中被抽象为监视器锁(monitor)。在JDK 1.6之前,监视器锁可以认为直接对应底层操作系统中的互斥量(mutex)。这种同步方式的成本非常高,包括系统调用引起的内核态与用户态切换、线程阻塞造成的线程切换等。因此,后来称这种锁为“重量级锁”。Lock与AQS
Java的ReentrantLock、ReentrantReadWriteLock、CountDownLatch、Semaphore等都是基于AQS来实现的。
AQS中 维护了一个volatile int state(代表共享资源)和一个FIFO线程等待队列(多线程争用资源被阻塞时会进入此队列)。
这里volatile能够保证多线程下的可见性,当state=1则代表当前对象锁已经被占有,其他线程来加锁时则会失败,加锁失败的线程会被放入一个FIFO的等待队列中,比列会被UNSAFE.park()操作挂起,等待其他获取锁的线程释放锁才能够被唤醒。
另外state的操作都是通过CAS来保证其并发修改的安全性。
具体原理我们可以用一张图来简单概括:
AQS 中提供了很多关于锁的实现方法,
- getState():获取锁的标志 state 值
- setState():设置锁的标志 state 值
- tryAcquire(int):独占方式获取锁。尝试获取资源,成功则返回 true,失败则返回 false。
- tryRelease(int):独占方式释放锁。尝试释放资源,成功则返回 true,失败则返回 false。
假设我们现在有三个线程,此时线程一抢占锁成功,线程二和线程三抢占锁失败,具体执行流程如下:
线程一抢占成功,AQS的state设置为1。线程二线程三加入到FIFO队列。此时AQS的内部数据为:
线程二、线程三加锁失败:
线程一释放锁之后,唤醒Head的下一个节点,尝试加锁。这时如果有线程四过来也尝试加锁,并且加锁成功,则线程二等待了很久之后,却被线程四抢了先,这就是非公平锁。
那要实现公平锁,线程四尝试加锁之前会先判断FIFO队列中是否有等待线程。如果有的话则加入到FIFO队列中。
分布式
基于Redis实现分布式锁
使用 SETNX 指令
最简单的加锁方式就是直接使用 Redis 的 SETNX 指令,该指令只在 key 不存在的情况下,将 key 的值设置为 value,若 key 已经存在,则 SETNX 命令不做任何动作。key 是锁的唯一标识,可以按照业务需要锁定的资源来命名。
比如在某商城的秒杀活动中对某一商品加锁,那么 key 可以设置为 lock_resource_id ,value 可以设置为任意值,在资源使用完成后,使用 DEL 删除该 key 对锁进行释放,整个过程如下:
1 | |
很显然,这种获取锁的方式很简单,但也存在一个问题,就是我们上面提到的分布式锁三个核心要素之一的锁超时问题,即如果获得锁的进程在业务逻辑处理过程中出现了异常,可能会导致 DEL 指令一直无法执行,导致锁无法释放,该资源将会永远被锁住。
所以,在使用 SETNX 拿到锁以后,必须给 key 设置一个过期时间,以保证即使没有被显式释放,在获取锁达到一定时间后也要自动释放,防止资源被长时间独占。由于 SETNX 不支持设置过期时间,所以需要额外的 EXPIRE 指令,整个过程如下:
1 | |
这样实现的分布式锁仍然存在一个严重的问题,由于 SETNX 和 EXPIRE 这两个操作是非原子性的, 如果进程在执行 SETNX 和 EXPIRE 之间发生异常,SETNX 执行成功,但 EXPIRE 没有执行,导致这把锁变得“长生不老”,这种情况就可能出现前文提到的锁超时问题,其他进程无法正常获取锁。
使用 SET 扩展指令
为了解决 SETNX 和 EXPIRE 两个操作非原子性的问题,可以使用 Redis 的 SET 指令的扩展参数,使得 SETNX 和 EXPIRE 这两个操作可以原子执行,整个过程如下:
1 | |
但是这种方式仍然不能彻底解决分布式锁超时问题:
- 锁被提前释放。假如线程 A 在加锁和释放锁之间的逻辑执行的时间过长(或者线程 A 执行过程中被堵塞),以至于超出了锁的过期时间后进行了释放,但线程 A 在临界区的逻辑还没有执行完,那么这时候线程 B 就可以提前重新获取这把锁,导致临界区代码不能严格的串行执行。
- 锁被误删。假如以上情形中的线程 A 执行完后,它并不知道此时的锁持有者是线程 B,线程 A 会继续执行 DEL 指令来释放锁,如果线程 B 在临界区的逻辑还没有执行完,线程 A 实际上释放了线程 B 的锁。
为了避免以上情况,建议不要在执行时间过长的场景中使用 Redis 分布式锁,同时一个比较安全的做法是在执行 DEL 释放锁之前对锁进行判断,验证当前锁的持有者是否是自己。
具体实现就是在加锁时将 value 设置为一个唯一的随机数(或者线程 ID ),释放锁时先判断随机数是否一致,然后再执行释放操作,确保不会错误地释放其它线程持有的锁,除非是锁过期了被服务器自动释放,整个过程如下:
1 | |
但判断 value 和删除 key 是两个独立的操作,并不是原子性的,所以这个地方需要使用 Lua 脚本进行处理,因为 Lua 脚本可以保证连续多个指令的原子性执行。
1 | |
基于 Redis 单节点的分布式锁基本完成了,但是这并不是一个完美的方案,只是相对完全一点,因为它并没有完全解决当前线程执行超时锁被提前释放后,其它线程乘虚而入的问题。
使用 Redisson 的分布式锁
怎么能解决锁被提前释放这个问题呢?
可以利用锁的可重入特性,让获得锁的线程开启一个定时器的守护线程,每 expireTime/3 执行一次,去检查该线程的锁是否存在,如果存在则对锁的过期时间重新设置为 expireTime,即利用守护线程对锁进行“续命”,防止锁由于过期提前释放。
当然业务要实现这个守护进程的逻辑还是比较复杂的,可能还会出现一些未知的问题。
目前互联网公司在生产环境用的比较广泛的开源框架 Redisson 很好地解决了这个问题,非常的简便易用,且支持 Redis 单实例、Redis M-S、Redis Sentinel、Redis Cluster 等多种部署架构。
ZooKeeper分布式锁机制
zk里有一把锁,这个锁就是zk上的一个节点,假设有两个客户端,都要来获取这个锁,具体是怎么来获取呢?
咱们就假设客户端A抢先一步,对zk发起了加分布式锁的请求,这个加锁请求是用到了zk中的一个特殊的概念,叫做“临时顺序节点”。
简单来说,就是直接在”my_lock”这个锁节点下,创建一个顺序节点,这个顺序节点有zk内部自行维护的一个节点序号。
比如说,第一个客户端来搞一个顺序节点,zk内部会给起个名字叫做:xxx-000001。然后第二个客户端来搞一个顺序节点,zk可能会起个名字叫做:xxx-000002。大家注意一下,最后一个数字都是依次递增的,从1开始逐次递增。zk会维护这个顺序。
后来的客户端如果发现不是第一个客户端,会通过ZK的API对他的顺序节点的上一个顺序节点加一个监听器。zk天然就可以实现对某个节点的监听。
删除了节点之后,zk会负责通知监听这个节点的监听器,也就是下一个客户端加的那个监听器,说:兄弟,你监听的那个节点被删除了,有人释放了锁。
此时后一个客户端的监听器感知到了上一个顺序节点被删除,也就是排在他之前的某个客户端释放了锁。
此时,就会通知后一个客户端重新尝试去获取锁。
参考文章
AQS 万字图文全面解析
七张图彻底讲清楚ZooKeeper分布式锁的实现原理
浅析 Redis 分布式锁解决方案
CPU 缓存一致性
锁 - 共享数据安全指↑
CAS真的无锁吗
每日一个知识点:Volatile 和 CAS 的弊端之总线风暴原创
聊聊CPU的LOCK指令
Java并发编程-volatile可见性的介绍
MESI协议:如何让多核CPU的高速缓存保持一致?
