一文详解 Java 内置锁 synchronized
- 1. 前言
- 1.1 并发编程中存在线程安全问题
- 1.2 设计同步器的意义
- 1.3 如何解决线程并发安全问题?
- 2. synchronized 的用法
- 2.1 修饰实例方法,锁是当前实例对象
- 2.2 修饰静态方法,锁是当前类对象
- 2.3 修饰代码块,锁是括号里面的对象
- 2.4 总结
- 3. synchronized 的实现原理
- 3.1 synchronized 是怎么加锁的?
- (1)synchronized 修饰代码块时
- (2)synchronized 修饰实例方法
- 3.2 synchronized 同步概念
- (1)Java 对象头
- (2)监视器锁(Monitor)
- 3.3 synchronized 的特性
- 3.3.1 原子性
- 3.3.2 可见性
- 3.3.3 有序性
- 3.3.4 可重入
- 3.3.5 非公平
- 4. 锁优化
- 4.1 无锁
- 4.2 偏向锁
- 4.3 轻量级锁
- 4.4 重量级锁
- 4.5 锁升级全过程
1. 前言
1.1 并发编程中存在线程安全问题
-
存在共享数据;
-
多线程共同操作共享数。
关键字 synchronized
可以保证在同一时刻,只有一个线程可以执行某个方法或某个代码块,同时 synchronized
可以保证一个线程的变化可见(可见性),即可以代替 volatile。
1.2 设计同步器的意义
多线程编程中,有可能会出现多个线程同时访问同一个共享、可变资源的情况,这个资源我们称之为临界资源
。这种资源可能是:对象、变量、文件等。
-
共享:资源可以由多个线程同时访问;
-
可变:资源可以在其生命周期内被修改。
引出的问题:由于线程执行的过程是不可控的,所以需要采用同步机制来协调对对象可变状态的访问。
1.3 如何解决线程并发安全问题?
实际上,所有的并发模式在解决线程安全问题时,采用的方案都是序列化访问临界资源
。即在同一时刻,只能有一个线程访问临界资源,也称作同步互斥访问
。
Java 中,提供了两种方式来实现同步互斥访问:synchronized
和 Lock
。
同步器的本质就是加锁。加锁目的:序列化访问临界资源,即同一时刻只能有一个线程访问临界资源(同步互斥访问)。
不过有一点需要区别的是:当多个线程执行一个方法时,该方法内部的局部变量并不是临界资源,因为这些局部变量是在每个线程的私有栈中,并不具有共享性,不会导致线程安全问题。
2. synchronized 的用法
synchronized 内置锁是一种对象锁
(锁的是对象而非引用),作用粒度是对象
,可以用来实现对临界资源的同步互斥
访问,是可重入
的。
2.1 修饰实例方法,锁是当前实例对象
public class Juc_LockOnThisObject {
private Integer stock = 10;
public synchronized void decrStock() {
--stock;
System.out.println(ClassLayout.parseInstance(this).toPrintable());
}
}
synchronized 修饰非静态方法锁定的是该类的实例,同一实例在多线程中调用才会触发同步锁定 所以多个被 synchronized 修饰的非静态方法在同一实例下只能多线程同时调用一个。
2.2 修饰静态方法,锁是当前类对象
public class Juc_LockOnClass {
private static int stock;
public static synchronized void decrStock(){
System.out.println(--stock);
}
}
synchronized 修饰静态方法锁定的是类本身,而不是实例,会作用于类的所有对象实例 ,进入同步代码前要获得当前 class 的锁。因为静态成员不属于任何一个实例对象,是类成员( static 表明这是该类的一个静态资源,不管 new 了多少个对象,只有一份)。所以,如果一个线程 A 调用一个实例对象的非静态 synchronized 方法,而线程 B 需要调用这个实例对象所属类的静态 synchronized 方法,是允许的,不会发生互斥现象,因为访问静态 synchronized 方法占用的锁是当前类的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象锁。
2.3 修饰代码块,锁是括号里面的对象
public class Juc_LockOnObject {
public static Object object = new Object();
private Integer stock = 10;
public void decrStock() {
// T1,T2
synchronized (object) {
--stock;
if (stock <= 0) {
System.out.println("库存售罄");
return;
}
}
}
}
synchronized 指定加锁对象,对给定对象/类加锁。synchronized(this|object)
表示进入同步代码库前要获得给定对象的锁。synchronized(类.class)
表示进入同步代码前要获得当前 class 的锁。
2.4 总结
-
synchronized
关键字加到 static 静态方法和synchronized(class)
代码块上都是是给 Class 类上锁。 -
synchronized
关键字加到实例方法上是给对象实例上锁。
一起看一个 synchronized 使用经典实例—— 线程安全的单例模式:
public class LazySingleton {
/**
* 提供一个私有的静态属性,对于 volatile 修饰的字段,可以防止指令重排
*/
private volatile static LazySingleton instance = null;
/**
* 提供一个私有构造函数:避免类在外部被实例化
*/
private LazySingleton() {
}
public static LazySingleton getInstance() {
// 先判断对象是否已经实例过,没有实例化过才进入加锁代码
if (instance == null) {
synchronized (LazySingleton.class) {
if (instance == null) {
// 类对象加锁
instance = new LazySingleton();
}
}
}
return instance;
}
}
3. synchronized 的实现原理
3.1 synchronized 是怎么加锁的?
synchronized
是基于 JVM内置锁
实现,通过内部对象 Monitor(监视器锁)
实现,基于进入与退出 Monitor 对象实现方法与代码块同步,监视器锁的实现依赖底层操作系统的 Mutex lock(互斥锁) 实现,它是一个重量级锁,性能较低。当然,JVM 内置锁在1.5之后版本做了大量的优化,如锁粗化(Lock Coarsening)、锁消除(Lock Eliminaction)、轻量级锁(Lightweight Locking)、偏向锁(Biased Locking)、适应性自旋(Adaptive Spinning)等技术来减少锁操作的开销,内置锁的并发性能已经基本与 Lock 持平。
(1)synchronized 修饰代码块时
/**
* @author pointer
* @date 2023-04-21 21:16:47
*/
public class SynchronizedDemo {
public void method() {
synchronized (this) {
System.out.println("synchronized 代码块");
}
}
}
通过 JDK 自带的 javap 命令查看 SynchronizedDemo 类的相关字节码信息:首先切换到类的对应目录执行 javac SynchronizedDemo.java
命令生成编译后的 .class
文件,然后执行 javap -c -s -v -l SynchronizedDemo.class
。
synchronized
修饰代码块时,JVM 采用 monitorenter
、monitorexit
两个指令来实现同步,其中 monitorenter
指令指向同步代码块的开始位置, monitorexit
指令则指向同步代码块的结束位置。
(2)synchronized 修饰实例方法
/**
* @author pointer
* @date 2023-04-21 21:16:47
*/
public class SynchronizedDemo {
public synchronized void method() {
System.out.println("synchronized 实例方法");
}
}
反编译一下:
synchronized
修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是 ACC_SYNCHRONIZED
标识,该标识指明了该方法是一个同步方法。JVM 通过该 ACC_SYNCHRONIZED
访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。
3.2 synchronized 同步概念
monitorenter、monitorexit 或者 ACC_SYNCHRONIZED 都是基于 Monitor(监视器锁)
实现的。
实例对象结构里有对象头,对象头里面有一块结构叫 Mark Word,Mark Word 指针指向了 Monitor。
(1)Java 对象头
在 HotSpot 虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding):
synchronized
是悲观锁,在操作同步资源之前需要给同步资源先加锁,这把锁就是存在 Java对象头
里的,Java对象头是什么呢?
我们以 Hotspot 虚拟机为例,Hotspot 的对象头主要包括两部分数据:Mark Word(标记字段)、Klass Pointer(类型指针):
Mark Word: Mark Word存储对象自身的运行数据,如哈希码、GC分代年龄、锁状态标志、偏向时间戳(Epoch) 等信息。这些信息都是与对象自身定义无关的数据,所以 Mark Word 被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据。它会根据对象的状态复用自己的存储空间,也就是说在运行期间 Mark Word 里存储的数据会随着锁标志位的变化而变化。
Klass Point: 对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
(2)监视器锁(Monitor)
监视器锁(Monitor) 本质是依赖于底层的操作系统的Mutex Lock(互斥锁)来实现的。每个对象都对应于一个可称为 “互斥锁” 的标记,这个标记用来保证在任一时刻,只能有一个线程访问该对象。
互斥锁:用于保护临界区,确保同一时间只有一个线程访问数据。对共享资源的访问,先对互斥量进行加锁,如果互斥量已经上锁,调用线程会阻塞,直到互斥量被解锁。在完成了对共享资源的访问后,要对互斥量进行解锁。
- mutex的工作方式:
- 申请 mutex;
- 如果成功,则持有该mutex;
- 如果失败,则进行spin自旋。spin的过程就是在线等待mutex,不断发起mutex gets, 直到获得mutex或者达到spin_count限制为止;
- 依据工作模式的不同选择yiled还是sleep;
- 若达到sleep限制或者被主动唤醒或者完成yield,则重复1-4 步,直到获得为止。
由于Java的线程是映射到操作系统的原生线程之上的,如果要阻塞或唤醒一条线程,都需要操作系统来帮忙完成,这就需要从用户态转换到核心态中,因此状态转换需要耗费很多的处理器时间。所以synchronized是Java语言中的一个重量级操作。在JDK1.6中,虚拟机进行了一些优化,譬如在通知操作系统阻塞线程之前加入一段自旋等待过程,避免频繁地切入到核心态中;
synchronized与java.util.concurrent包中的ReentrantLock相比,由于JDK1.6中加入了针对锁的优化措施(见后面),使得synchronized与ReentrantLock的性能基本持平。ReentrantLock只是提供了synchronized更丰富的功能,而不一定有更优的性能,所以在synchronized能实现需求的情况下,优先考虑使用synchronized来进行同步。
3.3 synchronized 的特性
3.3.1 原子性
原子性就是指一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。被 synchronized
修饰的类或对象的所有操作都是原子的,因为在执行操作之前必须先获得类或对象的锁,直到执行完才能释放。
3.3.2 可见性
可见性是指多个线程访问一个资源时,该资源的状态、值信息等对于其他线程都是可见的。synchronized
和 volatile
都具有可见性。
synchronized怎么保证可见性?
-
线程加锁前,将清空工作内存中共享变量的值,从而使用共享变量时需要从主内存中重新读取最新的值;
-
线程加锁后,其它线程无法获取主内存中的共享变量;
-
线程解锁前,必须把共享变量的最新值刷新到主内存中。
3.3.3 有序性
有序性指的是对于一个线程的执行代码,从前往后依次执行,单线程下可以认为程序是有序的,但是并发时有可能会发生指令重排。
synchronized怎么保证有序性?
-
synchronized 同步的代码块,具有排他性,一次只能被一个线程拥有,所以 synchronized 保证同一时刻,代码是单线程执行的;
-
因为 as-if-serial 语义的存在,单线程的程序能保证最终结果是有序的,但是不保证不会指令重排;
-
所以 synchronized 保证的有序是执行结果的有序性,而不是防止指令重排的有序性。
3.3.4 可重入
可重入特性指的是同一个线程获得锁之后,可以再次获取该锁(一个线程可以多次执行 synchronized,重复获取同一把锁)。
public class NewThread {
static class MyThread extends Thread {
@Override
public void run() {
synchronized (NewThread.class) {
System.out.println("我是 run");
}
test01();
}
void test01(){
synchronized (NewThread.class) {
System.out.println("我是 test01");
}
}
}
public static void main(String[] args) {
MyThread myThread01 = new MyThread();
MyThread myThread02 = new MyThread();
myThread01.start();
myThread02.start();
}
}
- 原理
synchronized
的锁对象中有一个计数器(recursions变量)会记录线程获得几次锁,每重入一次,计数器就 + 1,在执行完一个同步代码块时,计数器数量就会减 1,直到计数器的数量为 0 才释放这个锁。
- 优点
可以避免死锁(如果不能重入,那就不能再次进入这个同步代码块,导致死锁);更好地封装代码(可以把同步代码块写入到一个方法中,然后在另一个同步代码块中直接调用该方法实现可重入)。
3.3.5 非公平
非公平是指多个线程在抢占锁时JVM并不会保证线程先来后到的顺序,非公平性可以提升吞吐量,因为少了维护线程顺序的开销。
4. 锁优化
synchronized 是通过对象内部的一个叫做监视器锁(monitor)来实现的,监视器锁本质又是依赖于底层的操作系统的Mutex Lock(互斥锁)来实现的。而操作系统实现线程之间的切换需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么 synchronized 效率低的原因。因此,这种依赖于操作系统Mutex Lock所实现的锁我们称之为 “重量级锁”。
Java SE 1.6 为了减少获得锁和释放锁带来的性能消耗,引入了 “偏向锁” 和 “轻量级锁”:锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态
。锁可以升级但不能降级
。
先看一下四种锁状态对应的的Mark Word内容,然后再分别讲解四种锁状态的思路以及特点:
锁状态 | 存储内容 | 存储内容 |
---|---|---|
无锁 | 对象的hashCode、对象分代年龄、是否是偏向锁(0) | 01 |
偏向锁 | 偏向线程ID、偏向时间戳、对象分代年龄、是否是偏向锁(1) | 01 |
轻量级锁 | 指向栈中锁记录的指针 | 00 |
重量级锁 | 指向互斥量(重量级锁)的指针 | 10 |
4.1 无锁
无锁没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功。
无锁的特点就是修改操作在循环内进行,线程会不断的尝试修改共享资源。如果没有冲突就修改成功并退出,否则就会继续循环尝试。如果有多个线程修改同一个值,必定会有一个线程能修改成功,而其他修改失败的线程会不断重试直到修改成功。上面我们介绍的CAS原理及应用即是无锁的实现。无锁无法全面代替有锁,但无锁在某些场合下的性能是非常高的。
4.2 偏向锁
偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁,降低获取锁的代价。在大多数情况下,锁总是由同一线程多次获得,不存在多线程竞争,所以出现了偏向锁。其目标就是在只有一个线程执行同步代码块时能够提高性能。
当一个线程访问同步代码块并获取锁时,会在Mark Word里存储锁偏向的线程ID。在线程进入和退出同步块时不再通过CAS操作来加锁和解锁,而是检测Mark Word里是否存储着指向当前线程的偏向锁。引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取及释放依赖多次CAS原子指令,而偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令即可。
偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态。撤销偏向锁后恢复到无锁(标志位为“01”)或轻量级锁(标志位为“00”)的状态。
偏向锁在JDK 6及以后的JVM里是默认启用的。可以通过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false,关闭之后程序默认会进入轻量级锁状态。
4.3 轻量级锁
是指当锁是偏向锁的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。
在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,然后拷贝对象头中的Mark Word复制到锁记录中。
拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock Record里的owner指针指向对象的Mark Word。
如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,表示此对象处于轻量级锁定状态。
如果轻量级锁的更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否则说明多个线程竞争锁。
若当前只有一个等待线程,则该线程通过自旋进行等待。但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁升级为重量级锁。
4.4 重量级锁
升级为重量级锁时,锁标志的状态值变为 10,此时Mark Word中存储的是指向重量级锁的指针,此时等待锁的线程都会进入阻塞状态。
4.5 锁升级全过程
参考文章:
https://zhuanlan.zhihu.com/p/29866981 - Java synchronized原理总结
https://www.cnblogs.com/xfeiyun/p/15871647.html#gallery-6 - Java内置同步锁synchronized深入理解
https://www.cnblogs.com/three-fighter/p/14396208.html - synchronized详解