- volatile
- 原理
- 内存屏障
- synchronized
- 锁升级
- 可重入
- 锁
- 各种锁
- Lock
- 分段锁
volatile
原理
volatile 修饰的共享变量在进行写操作的时候会多出与 Lock 相关的汇编代码,Lock 前缀指令在多核处理器下会引发两件事:
- 将当前处理器缓存行的数据写回到系统内存
- 这个写操作会使在其他 CPU 里缓存了该内存地址的数据无效。
volatile 具有以下特性:
- 可见性:对一个 volatile 变量的读,总能看到(任意线程)对这个 volatile 变量最后的写入。
- 原子性:对任意单个 volatile 变量的读/写具有原子性,但类似于volatile++这种附和操作不具有原子性。
内存屏障
final
synchronized
Java 中的每一个对象都可以作为锁,具体表现为以下三种形式:
- 对于普通同步方法,锁是当前实例对象。
- 对于静态同步方法,锁是当前类的 Class 对象。
- 对于同步方法块,锁是括号里指定的对象。
synchronized 的锁是存在 Java 对象头里的。Java 对象的内存主要包括以下三个部分:
- 对象头区域:包括对象自身的运行时数据 (MarkWord) 存储 hashCode、GC 分代年龄、锁类型标记、偏向锁线程 ID 、 CAS 锁指向线程 LockRecord 的指针等, synchronized 锁的机制与这个部分 (markwork) 密切相关,用 markword 中最低的三位代表锁的状态,其中一位是偏向锁位,另外两位是普通锁位;还包括对象类型指针 (Class Pointer),即对象指向它的类元数据的指针,JVM 就是通过它来确定是哪个 Class 的实例。
- 实例数据区域:对象中字段内容等。
- 对象填充区域:如果一个对象实际占的内存大小不是 8byte 的整数倍时,就"补位"到 8byte 的整数倍。
锁升级
锁一共有四种状态,级别从低到高依次是:无锁、偏向锁、轻量级锁、重量级锁。
偏向锁
大多数情况下锁通常由统一线程多次获得。当线程访问同步块时立刻上一把偏向锁,以后线程进入和退出不需要进行 CAS 操作来加锁和解锁,只需要测试是否存储指向当前线程的偏向锁。
偏向锁是直到其他线程尝试竞争时才释放。想要撤销偏向锁,需要等到全局安全点(没有正在执行的字节码)。首先暂停持有偏向锁的线程,检查其是否还活着,如果不处于活动状态,恢复到无锁状态;如果处于活动状态,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的 markword,要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程。
轻量级锁
当下一个线程参与到偏向锁竞争时,会先判断 markword 中保存的线程 ID 是否与这个线程 ID 相等,如果不相等,会立即撤销偏向锁,升级为轻量级锁。每个线程在自己的线程栈中生成一个 LockRecord ( LR ),然后每个线程通过 CAS (自旋)的操作将锁对象头中的 markwork 设置为指向自己的 LR 的指针,哪个线程设置成功,就意味着获得锁。
如果线程一直自旋,将会消耗大量的 CPU 资源。
重量级锁
如果锁竞争加剧(如线程自旋次数或者自旋的线程数超过某阈值, JDK1.6 之后,由 JVM 自己控制该规则),就会升级为重量级锁。此时就会向操作系统申请资源,线程挂起,进入到操作系统内核态的等待队列中,等待操作系统调度。
一旦锁升级成重量级锁,就不会再恢复到轻量级锁的状态了。在重量级锁的状态下,其他试图获得锁的线程都会被阻塞,直到锁释放时才被唤醒。
可重入
synchronized 是一把可重入锁。一个线程得到一个对象锁后再次请求该对象锁,是永远可以拿到锁的。synchronized 锁的对象头的 markwork 中会记录该锁的线程持有者和计数器,当一个线程请求成功后, JVM 会记下持有锁的线程,并将计数器计为1。此时其他线程请求该锁,则必须等待。而该持有锁的线程如果再次请求这个锁,就可以再次拿到这个锁,同时计数器会递增。当线程退出一个 synchronized 方法/块时,计数器会递减,如果计数器为 0 则释放该锁。
显然 synchronized 是悲观锁,也是独占锁。
锁
各种锁
乐观锁与悲观锁
事务,隔离级别和并发控制 一文中阐述了数据库中乐观锁和悲观锁的大致区别。它们并不是具体的锁,而只是一种思想(下文中的很多锁也仅仅只是一个概念)。不仅仅在数据库中它们很重要,在 JDK 中乐观锁和悲观锁也是非常重要的概念,其中 CAS 是乐观锁的实现,而 synchronized 和 lock 则是悲观锁的实现。
自旋锁和阻塞锁
-
阻塞锁会改变线程的运行状态,让线程进入阻塞状态进行等待。当休眠的线程获得相应的信号(唤醒或者时间)时,再次转为就绪状态的所有线程,通过竞争,进入运行状态。
-
自旋锁是指尝试获取锁的线程不会阻塞,而是循环的方式不断尝试,这样的好处是减少线程的上下文切换带来的开锁,提高性能,缺点是循环会消耗 CPU,如果长时间自旋,会耗费大量资源。JDK 1.8 中使用了大量 “自旋锁 + CAS” 的方式代替阻塞锁实现线程安全。
偏向锁、轻量级锁、重量级锁
见上文。
共享锁和排它(独占)锁
见下文。
Lock
AQS 是 JUC 包中锁实现的基础,其模型图如下所示:
引用自 聊聊 Java 的几把 JVM 级锁 。
AQS 框架下的锁继承自 Lock 接口,一般的流程是先尝试 CAS 方式(乐观锁)去获取锁,获取不到,才会转换为悲观锁,阻塞线程,如 ReentranLock。
ReentrantLock 主要有以下特点:
- 可重入。这一点和 synchronized 一样。
- 需要手动加锁解锁。具体来说就是显式调用 lock / unlock 相关的操作完成相关操作。
- 可以设置超时时间。如果超过等待时间,线程将会进行后续的操作,不会一直阻塞,这一特性避免了死锁。
- 支持公平和非公平模式。synchronized 是一种非公平锁,先抢到锁的线程先执行。ReentrantLock 允许设置 true/false 来实现公平、非公平锁,公平锁模式下线程获取锁的顺序为 FIFO。
- 可以设置响应中断或不响应中断。
ReentrantLock 是可重入锁,JDK 在 ReentrantLock 的基础上又实现了读写锁 ReentrantReadWriteLock。ReentrantReadWriteLock 其实包含了两把锁,一把读锁,一把写锁。
读锁和共享锁是同一种锁,写锁和排它锁、独占锁是同一种锁。读写锁的规则是:读读不互斥、读写互斥、写写互斥。这种锁适用于读多写少的场景。
读锁属于乐观锁,写锁属于悲观锁。
AQS、ReentrantLock、ReentrantReadWriteLock 源码详见 github。
分段锁
虽然 AtomicLong 是线程安全的长整型对象,但当大量线程同时访问时,会因为大量线程执行 CAS 操作失败而进行空旋转,导致 CPU 资源消耗过多,效率很低。在 JDK8 中出现了 LongAdder,也是线程安全的长整型对象,基于分段锁和 CAS 共同实现,效率提高了很多。
线程读写 LongAdder 对象时,流程如下所示:
图源自 聊聊 Java 的几把 JVM 级锁 。
LongAdder 对象中存储数据的主要有两个部分,一个是 long 类型的 base 变量,一个是 Cell 类型(实际上也是 long 类型)数组。当多个线程同时操作(写入)的时候,首先在 base 变量上进行操作,当发现线程增多时,就会使用 cell 数组,将线程分散到 cell 数组的每一个槽中去,分别执行各自的操作,这样就不用所有线程去 CAS 同一个变量,导致无谓的空旋转。
可以看出,AtomicLong 实际上只包括 base 部分,所有的线程都对同一个 base 操作,竞争同一个 base 上的锁。LongAdder 多了个 Cell 数组部分,将线程分散。只是要注意的是,一个LongAdder 代表的数值是 base 部分的值加上 Cell 数组所有槽数值的总和。
参考
- 方腾飞, 魏鹏, 程晓明, et al. Java并发编程的艺术[M]. 机械工业出版社. 2015.
- 聊聊 Java 的几把 JVM 级锁