synchronized 关键字
一、定义
在 Java 中,synchronized 关键字常被用于处理线程安全问题,实现线程间的同步。
synchronized 是 Java 语言中解决并发问题最常用、最简单的方法。它是通过加锁的方式,对线程进行同步控制,其主要的作用有
- 原子性:即能够保证数据中同一时刻内,只允许一个线程进行操作
- 可见性:即保证共享变量的修改能够及时可见
- 有序性:synchronized 关键字能够保证被修饰的代码同一时间只有一个线程执行,而即使进行了指令的重排序,也不会影响程序运行结果。
synchronized 关键字可以将任意的非空对象作为锁。而在 Java 虚拟机 JVM 中,对象的锁被称为对象监视器(Object Monitor)。每个对象都具有其对象监视器,同一时间,一个对象的监视器只能被一个线程所持有。具体的原理见后文分析。
二、用法
synchronized 关键字中 Java 中主要有三种用法
1. 同步代码块
synchronized (this) {
System.out.println("hello, world");
}
当 synchronized 关键字作用在同步代码块上时,监视器锁便是括号括起来的对象实例,代码运行到关键字时,会先尝试获取括号中对象的监视器锁,若当前监视器锁已被其他线程所持有,则当前线程阻塞。否则,获取该锁,运行同步代码块中的内容,运行结束时将锁释放,其他线程在此期间无法获得该监视器锁,因此被阻塞无法运行。
2. 非静态方法
当 synchronized 关键字作用在普通方法上时,线程执行方法需要占有的监视器锁就是当前对象实例 (this)。
public synchronized void print() {
System.out.println("hello, world");
}
其在功能上等同于
public void print() {
synchronized (this) {
System.out.println("hello, world");
}
}
3. 静态方法
当 synchronized 关键字作用在静态方法上时,线程执行方法需要占有的监视器锁就是当前类的 Class 对象。Class 对象在 JVM 内存模型中处于方法区中,该对象全局唯一,因此静态方法被 synchronized 修饰时,相当于对当前方法加上了全局锁。
public synchronized static void print() {
System.out.println("hello, world");
}
其在功能上等同于
public static void print() {
synchronized (Xxx.class) {
System.out.println("hello, world");
}
}
三、原理
synchronized 关键字实现同步的方式是依赖于 JVM。首先介绍几个关键概念。
1. 对象头
在 JVM 的堆内存中,每个对象主要由三部分组成
-
对象头:主要包括
- Mark Word(标记字段):用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等等。一般占用两个机器码。在 32-bit JVM 上占用 64bit, 在 64-bit JVM 上占用 128bit 即 16 bytes。如果对象是一个 Java 数组,那在对象头中还必须有一块用于记录数组长度的数据。Mark Word 被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息,它会根据对象的状态复用自己的存储空间。32 位虚拟机上的标记字段主要有以下几种状态
- Klass Pointer(类型指针):是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
- Mark Word(标记字段):用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等等。一般占用两个机器码。在 32-bit JVM 上占用 64bit, 在 64-bit JVM 上占用 128bit 即 16 bytes。如果对象是一个 Java 数组,那在对象头中还必须有一块用于记录数组长度的数据。Mark Word 被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息,它会根据对象的状态复用自己的存储空间。32 位虚拟机上的标记字段主要有以下几种状态
-
实例变量:对象的属性信息,包括父类的属性信息,按照4字节对齐
-
填充字节:因为虚拟机要求对象字节必须是8字节的整数倍,填充字符就是用于凑齐这个整数倍的
2. Monitor 对象
在Hotspot中,对象的监视器(monitor)锁对象由ObjectMonitor对象实现(C++),对象的数据结构如下:
class ObjectMonitor() {
_header = NULL;
_count = 0; // 记录对象被线程获取锁的此时
_waiters = 0,
_recursions = 0; // 记录锁的重入次数
_object = NULL;
_owner = NULL; // 指向持有该对象的线程
_WaitSet = NULL; // 处于 wait 状态的线程集合
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
_EntryList = NULL ; // 处于 block 状态的线程集合
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
_previous_owner_tid = 0;
}
指示锁状态的三个字段如下:
说明:
-
当多个线程对当前 ObjectMonitor 对象进行竞争时,获取到锁的线程将 _owner 字段设置为当前线程ID,并将 _count 字段加1,其余线程进入阻塞状态被加入 _entryList 集合,当线程执行完毕释放锁后,从 _entryList 集合中唤醒线程,等待线程进入对锁的竞争状态。
-
若运行中的线程调用了 wait() 方法,则该线程被加入 _waitSet 集合,同时将 _owner 字段设置为 null,并将 _count 字段减 1,即释放锁,而当其他线程调用当前锁对象的 notify() 或 notifyAll() 方法时,_waitSet 集合中一个或所有线程被唤醒并竞争锁,若竞争失败则放入 _entryList 中阻塞等待。
-
若运行中的线程执行完毕,同样将 _owner 字段设置为 null,并将 _count 字段减 1,释放当前锁对象
3. synchronized 关键字与 monitor
① synchronized修饰代码块:
synchronized 代码块同步在需要同步的代码块开始的位置插入 monitorentry 指令,在同步结束的位置或者异常出现的位置插入 monitorexit 指令;JVM要保证 monitorentry 和 monitorexit 都是成对出现的,任何对象都有一个 monitor 与之对应,当这个对象的 monitor 被持有以后,它将处于锁定状态。
② Synchronized修饰方法:
synchronized 方法同步不再是通过插入 monitorentry 和 monitorexit 指令实现,而是由方法调用指令来读取运行时常量池中的 ACC_SYNCHRONIZED 标志隐式实现的,如果方法表结构(method_info Structure)中的ACC_SYNCHRONIZED 标志被设置,那么线程在执行方法前会先去获取对象的 monitor 对象,如果获取成功则执行方法代码,执行完毕后释放 monitor 对象,如果 monitor 对象已经被其它线程获取,那么当前线程被阻塞。
4. synchronized 锁升级
① 偏向锁 -> 轻量级锁
为什么要引入偏向锁?
因为经过 HotSpot 的作者大量的研究发现,大多数时候是不存在锁竞争的,常常是一个线程多次获得同一个锁,因此如果每次都要竞争锁会增大很多没有必要付出的代价,为了降低获取锁的代价,才引入的偏向锁。
偏向锁的加锁及升级过程
当线程访问同步代码块时:
(1)检查 Mark Word 中的锁标志位,若锁标志位为 01 时,表明当前的状态为可偏向状态,进入下一步
(2)如果偏向锁的标志位为 0,则说明当前第一次进入该同步代码,需要将偏向锁标志位设置为1,并将 Mark Word 中线程 ID 设置为当前线程,执行同步代码块。否则进入下一步
(3)偏向锁的标志位为 1,则判断 Mark Word 中线程 ID 是否指向当前线程,如果是则执行同步代码,否则进入下一步
(4)如果线程 ID 并未指向当前线程,则通过CAS操作竞争锁。这里不直接锁升级的原因是:偏向锁不会主动释放,因此即使之前的线程执行完毕之后,该字段中存储的还是上个线程的线程 ID。具体操作是,检查存储的线程 ID 是否还存活,若没有存活,则将锁标志位置为无锁,线程即可通过 CAS 竞争重复之前的步骤。
(5)如果之前的线程仍然存活,则检查该线程的栈帧信息,如果需要继续持有这个锁对象,那么暂停该线程,撤销偏向锁,升级为轻量级锁,若不再使用该锁对象,则同上一步,将锁状态设置为无锁,进行 CAS 竞争。
关闭偏向锁:
偏向锁是默认开启的,而且开始时间一般是比应用程序启动慢几秒,如果不想有这个延迟,那么可以使用-XX:BiasedLockingStartUpDelay=0;
如果不想要偏向锁,那么可以通过-XX:-UseBiasedLocking = false来设置;
② 轻量级锁 -> 重量级锁
为什么要引入轻量级锁?
轻量级锁考虑的是竞争锁对象的线程不多,而且线程持有锁的时间也不长的情景。因为阻塞线程需要CPU从用户态转到内核态,代价较大,如果刚刚阻塞不久这个锁就被释放了,那这个代价就有点得不偿失了,因此这个时候就干脆不阻塞这个线程,让它自旋这等待锁释放。
轻量级锁的加锁及升级过程
每次置为无锁状态后,需要做一个准备工作以备锁的升级。虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的 Mark Word 的拷贝,官方称之为 Displaced Mark Word,以及指向锁对象的指针 owner。
当线程访问同步代码块时
(1)首先拷贝对象头中的Mark Word 复制到 Lock Record 中,如上图。
(2)拷贝成功后,虚拟机将使用CAS操作尝试将对象的 Mark Word 更新为指向 Lock Record 的指针,并将 Lock record里的 owner 指针指向 object 的 Mark Word,若成功则将锁标志位设置为 00,表明当前对象已经处于轻量级锁状态,如下图,之后执行同步代码块。
(3)如果这个 CAS 更新操作失败了,虚拟机首先会检查对象的 Mark Word 是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。
(4)如果对象的 Mark Word 并不是指向当前线程的栈帧,说明存在多个线程竞争锁,就会尝试使用自旋锁的方式来等待锁的释放。
(5)如果自旋一定次数后,还没有获得锁,或者在自旋期间又有其他的线程来竞争该锁对象,那么说明竞争较为激烈,此时轻量级锁就会膨胀为重量级锁。将锁的标志位设为 10,Mark Word 设置为指向重量级锁(互斥量)的指针,将除了拥有锁的其他线程全部置为阻塞状态。
注意:为了避免无用的自旋,轻量级锁一旦膨胀为重量级锁就不会再降级为轻量级锁了;偏向锁升级为轻量级锁也不能再降级为偏向锁。一句话就是锁可以升级不可以降级,但是偏向锁状态可以被重置为无锁状态。
5. 其他优化
- 适应性自旋(Adaptive Spinning):从轻量级锁获取的流程中我们知道,当线程在获取轻量级锁的过程中执行 CAS 操作失败时,是要通过自旋来获取重量级锁的。问题在于,自旋是需要消耗 CPU 的,如果一直获取不到锁的话,那该线程就一直处在自旋状态,白白浪费 CPU 资源。解决这个问题最简单的办法就是指定自旋的次数,例如让其循环 10 次,如果还没获取到锁就进入阻塞状态。但是 JDK 采用了更聪明的方式——适应性自旋,简单来说就是线程如果自旋成功了,则下次自旋的次数会更多,如果自旋失败了,则自旋的次数就会减少。
- 锁粗化(Lock Coarsening): 锁粗化就是将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁,避免频繁的加锁解锁操作。
- 锁消除(Lock Elimination): Java 虚拟机在 JIT 编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,经过逃逸分析,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间
参考:
https://www.cnblogs.com/paddix/p/5405678.html
https://blog.csdn.net/tongdanping/article/details/79647337