synchronized
是 Java 提供的一种原子性内置锁,Java 中的每个对象都可以把它当作一个同步锁来使用,这些 Java 内置的使用者看不到的锁被称为内部锁,也叫作监视器锁。线程的执行代码在进入 synchronized 代码块前会自动获取内部锁,这时候其他线程访问该同步代码块时会被阻塞挂起。拿到内部锁的线程会在正常退出同步代码块或者抛出异常后或者在同步块内调用了该内置锁资源的 wait 系列方法时释放该内置锁。内置锁是排它锁,也就是当一个线程获取这个锁后,其他线程必须等待该线程释放锁后才能获取该锁。
synchronized 的特性
可见性
见性是指多个线程访问一个资源时,当其中一个线程修改该资源时,这个修改立即对其他线程可见。
进入 synchronized 块的内存语义是把在 synchronized 块内使用到的变量从线程的工作内存中清除,这样在 synchronized 块内使用到该变量时就不会从线程的工作内存中获取,而是直接从主内存中获取。退出 synchronized 块的内存语义是把 在synchronized 块内对共享变量的修改刷新到主内存。
有序性
synchronized 保证每个时刻都只能有一个线程访问同步代码块,因此是符合 as-if-serial
原则的,是有序的。
原子性
所谓原子性就是指一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
被 synchronized 修饰的类或对象的所有操作都是原子的,因为在执行操作之前必须先获得类或对象的锁,直到执行完才能释放,这中间的过程无法被中断的。
可重入性
当一个线程获取到 synchronized 锁时,在锁未释放之前其他线程是不能在获取到这把锁的。但是对于已经获取到这把锁的线程来说,仍然还是可以访问其锁住的临界资源。同一个线程可以多次获得同一把锁称之为可重入,但是需要注意的是,获取锁的次数必须与所释放的次数一次,才能保证线程完全释放掉锁,别的线程才能竞争该锁。
synchronized 的使用
synchronized 同步方法
使用 synchronized 关键字修饰一个方法的时候,该方法被声明为同步方法。同步方法的代码执行流程是排他性的。任何时间只允许一个线程进入同步方法(临界区代码段),如果其他线程需要执行同一个方法,那么只能等待和排队。
public synchronized void increament() {
i++;
}
synchronized 代码块
有的时候,对一个方法加锁保护的区域过大,我们需要保护的临界资源规模不足方法范围时,就可以使用同步代码块的形式对临界资源进行保护。在 synchronized 同步块后边的括号中是一个 syncObject 对象,代表着进入临界区代码段需要获取 syncObject 对象的监视锁,或者说将 syncObject 对象监视锁作为临界区代码段的同步锁。由于每一个 Java 对象都有一把监视锁,因此任何 Java 对象都能作为 synchronized 的同步锁。
public void plus(int val1, int val2) {
synchronized(sLock1) {
sum1 += val1;
}
synchronized(sLock2) {
sum2 += val2;
}
}
:::info synchronized 方法是一种粗粒度的并发控制,某一时刻只能有一个线程执行该 synchronized 方法;而 synchronized 代码块是一种细粒度的并发控制,处于 synchronized 块之外的其他代码是可以被多个线程并发访问的。 :::
synchronized 静态同步方法
普通的 synchronized 实例方法,其同步锁是当前对象 this 的监视锁。静态的 synchronized 实例方法,其同步锁是当前类的 Class 对象的监视锁。
由于类的对象实例可以有很多,但是每个类只有一个 Class 实例,因此使用 Class 作为 synchronized 的同步锁时会造成同一个 JVM 内的所有线程只能互斥地进入临界区段。所以,使用 synchronized 关键字修饰 static 方法是非常粗粒度的同步机制
synchronized 锁的实现
synchronized 有两种形式上锁,一个是对方法上锁,一个是构造同步代码块。他们的底层实现其实都一样,在进入同步代码之前先获取锁,获取到锁之后锁的计数器 +1,同步代码执行完锁的计数器 -1,如果获取失败就阻塞式等待锁的释放。
对于同步方法来说,会给方法添加上 ACC_SYNCHRONIZED
标志。如果设置此标志,执行线程需要持有 Monitor 才能运行此方法,在方法运行结束或异常退出时释放 Monitor。
public synchronized void increment() {
sum++;
}
javap -v -c L:\Java\project\Xxx\SynchronizedTest.class
public synchronized void increment();
descriptor: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
stack=3, locals=1, args_size=1
0: aload_0
1: dup
2: getfield #2 // Field sum:I
5: iconst_1
6: iadd
7: putfield #2 // Field sum:I
10: return
LineNumberTable:
line 8: 0
line 9: 10
LocalVariableTable:
Start Length Slot Name Signature
0 11 0 this Lcom/bujian/concurrence/SynchronizedTest;
代码块同步则是通过 monitorenter
和 monitorexit
指令实现的,monitorenter
指令是在编译后插入到同步代码块的开始位置,而 monitorexit
是插入到方法结束处和异常处。任何对象都有一个 monitor 与之关联,当且一个 monitor 被持有后,它将处于锁定状态。
public void plus() {
synchronized (this) {
sum++;
}
}
javap -v -c L:\Java\project\Xxx\SynchronizedTest.class
public void plus();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=3, locals=3, args_size=1
0: aload_0
1: dup
2: astore_1
3: monitorenter
4: aload_0
5: dup
6: getfield #2 // Field sum:I
9: iconst_1
10: iadd
11: putfield #2 // Field sum:I
14: aload_1
15: monitorexit
16: goto 24
19: astore_2
20: aload_1
21: monitorexit
22: aload_2
23: athrow
24: return
.....
synchronized 锁在对象头的表示
在 JVM 中,对象在内存中的布局分为三块内存区域:对象头、实例数据以及对齐填充。其中,对象头中的 mark word 字中包含了锁标志位,用来表示当前对象的锁状态。
锁状态 | 56bit | 1bit | 4bit | 1bit | 2bit | |
---|---|---|---|---|---|---|
是否偏向锁 | 锁标志位 | |||||
无锁 | unused:25bit | 对象的hashcode:31bit | unused | 分代年龄 | 0 | 01 |
偏向锁 | 线程ID:54bit | Epoch:2bit | unused | 分代年龄 | 1 | 01 |
轻量级锁 | 指向栈中锁记录的指针(ptr_to_lock_record) | 00 | ||||
重量级锁 | 指向互斥锁(重量级锁)的指针(ptr_to_heavyweight_monitor) | 10 | ||||
GC 标记 | 空 | 11 |
- 无锁表示一个对象没有被加锁时的状态
- 偏向锁,对象会偏向于第一个访问锁的线程,当同步锁只有一个线程访问时,JVM 会将其优化为偏向锁,此时就相当于没有同步语义;当发生多线程竞争时,偏向锁就会膨胀为轻量级锁
- 轻量级锁采用 CAS(Compare And Swap)实现,避免了用户态和内核态之间的切换。如果某个线程获取轻量级锁失败,该锁就会继续膨胀为重量级锁
- 重量级锁使得 JVM 会向操作系统申请互斥量,因此性能消耗是最高的
Monitor
monitor 可以理解为一个同步工具,每一个对象都有一个对应的 monitor 相关联。当一个对象处于竞争激烈的场景下,就会晋升为重量级锁,也就是 synchronized 对象锁,其中的指向互斥锁(重量级锁)的指针就是指向 monitor 对象的起始地址。当 monitor 被一个线程持有时,便处于锁定状态。在 Java 虚拟机 Hotspot 中,monitor 由 objectMonitor 进行定义且实现,其主要数据结构如下:
// initialize the monitor, exception the semaphore, all other fields
// are simple integers or pointers
ObjectMonitor() {
_header = NULL;
_count = 0; // 获取到monitor的次数
_waiters = 0,
_recursions = 0;
_object = NULL;
_owner = NULL;
_WaitSet = NULL; // 处于wait状态的线程,会被加入到_WaitSet
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
_EntryList = NULL ; // 处于等待锁block状态的线程,会被加入到该列表
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
_previous_owner_tid = 0;
}
ObjectMonitor 中有两个队列,_WaitSet
和 _EntryList
,用来保存 ObjectWaiter 对象列表( 每个等待锁的线程都会被封装成 ObjectWaiter 对象 ),_owner
指向持有 ObjectMonitor 对象的线程,当多个线程同时访问一段同步代码时:
- 首先会进入 _EntryList 集合,当线程获取到对象的 monitor 后,进入 _Owner区域并把 monitor 中的 owner 变量设置为当前线程,同时 monitor 中的计数器 count 加 1
- 若线程调用
wait()
方法,将释放当前持有的 monitor,owner 变量恢复为 null,count 自减 1,同时该线程进入 WaitSet 集合中等待被唤醒 - 若当前线程执行完毕,也将释放 monitor(锁)并复位 count 的值,以便其他线程进入获取 monitor(锁)
Monitor 对象存在于每个 Java 对象的对象头 Mark Word 中(存储的指针的指向),Synchronized 锁便是通过这种方式获取锁的,也是为什么 Java 中任意对象可以作为锁的原因,同时 notify/notifyAll/wait 等方法会使用到 Monitor 锁对象,所以必须在同步代码块中使用。
Mutex Lock
监视器锁(Monitor)本质是依赖于底层的操作系统的 Mutex Lock(互斥锁)来实现的。每个对象都对应于一个可称为” 互斥锁” 的标记,这个标记用来保证在任一时刻,只能有一个线程访问该对象。
mutex 是阻塞锁,多个线程尝试获取锁时,没有获取到锁的线程会被操作系统调度为阻塞状态直到锁被释放然后才会被重新唤醒。OS 线程调度,线程上下文切换带来的开销是很大的,多线程程序如果有大量的线程切换,最坏情况下性能甚至会比单线程运行的代码效率还要差。
由于 Java 的线程是映射到操作系统的原生线程之上的,如果要阻塞或唤醒一条线程,都需要操作系统来帮忙完成,这就需要从用户态转换到核心态中,因此状态转换需要耗费很多的处理器时间。所以 synchronized 是 Java 语言中的一个重量级操作。在 JDK1.6 中,虚拟机进行了一些优化,譬如在通知操作系统阻塞线程之前加入一段自旋等待过程,避免频繁地切入到核心态中等,使得 synchronized 与 ReentrantLock 的性能基本持平。
锁的优化
在 JDK1.5 之前, synchronized 是属于重量级锁,锁的启用和释放都需要依赖 Mutex 来实现,操作系统需要切换用户态和内核态,消耗很大,所以性能不如 J.U.C 中提供的同步工具。但是在 JDK 1.6 之后,JDK 开发人员对 synchronized 进行了大量优化,增加了自适应的 CAS 自旋,偏向锁,轻量级锁,锁粗化、锁消除等策略,大大优化了 synchronized 的性能。
锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁。但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级。
在 JDK 1.6 中默认是开启偏向锁和轻量级锁的,可以通过 -XX:-UseBiasedLocking
来禁用偏向锁。
自旋锁
所谓自旋锁,就是指当一个线程尝试获取某个锁时,如果该锁已被其他线程占用,就一直循环检测锁是否被释放,而不是进入线程挂起或睡眠状态。
自旋锁的好处在于不用阻塞线程,进而不用切换用户态和内核态;但是循环检测会消耗 CPU 资源,如果持有锁的线程短时间不会释放锁,那么循环就会变得毫无意义且还消耗了大量的 CPU 时间,反而会带来性能上的消耗。因此自旋的时间或次数必须有一个限度,超过该限度还没有获取到锁,线程就应该被挂起。
自旋锁在 JDK 1.4.2 中引入,默认关闭,但是可以使用 -XX:+UseSpinning
开启,在 JDK1.6 中默认开启。同时自旋的默认次数为 10 次,可以通过参数 -XX:PreBlockSpin
来调整。
如果通过参数 -XX:PreBlockSpin
来调整自旋锁的自旋次数,会带来诸多不便。假如将参数调整为10,但是系统很多线程都是等你刚刚退出的时候就释放了锁(假如多自旋一两次就可以获取锁),是不是很尴尬。于是JDK1.6引入自适应的自旋锁,让虚拟机会变得越来越聪明。
自适应的自旋锁
所谓自适应就意味着自旋的次数不再是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。那它如何进行适应性自旋呢?
线程如果自旋成功了,那么下次自旋的次数会更加多,因为虚拟机认为既然上次成功了,那么此次自旋也很有可能会再次成功,那么它就会允许自旋等待持续的次数更多。反之,如果对于某个锁,很少有自旋能够成功,那么在以后要或者这个锁的时候自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源。
有了自适应自旋锁,随着程序运行和性能监控信息的不断完善,虚拟机对程序锁的状况预测会越来越准确,虚拟机会变得越来越聪明。
偏向锁
偏向锁是 Java 6 之后加入的新锁,它是一种针对加锁操作的优化手段,经过研究发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,因此为了减少同一线程获取锁(会涉及到一些 CAS 操作,耗时)的代价而引入偏向锁。偏向锁的核心思想是,如果一个线程获得了锁,那么锁就进入偏向模式,此时 Mark Word 的结构也变为偏向锁结构,当这个线程再次请求锁时,无需再做任何同步操作,即获取锁的过程,这样就省去了大量有关锁申请的操作,从而也就提供程序的性能。所以,对于没有锁竞争的场合,偏向锁有很好的优化效果,毕竟极有可能连续多次是同一个线程申请相同的锁。但是对于锁竞争比较激烈的场合,偏向锁就失效了,因为这样场合极有可能每次申请锁的线程都是不相同的,因此这种场合下不应该使用偏向锁,否则会得不偿失,需要注意的是,偏向锁失败后,并不会立即膨胀为重量级锁,而是先升级为轻量级锁。
偏向锁主要是通过 CAS 将 mark word 的线程 ID 指向当前线程,以此来获取锁:
- 线程通过 CAS 获取锁,成功进入步骤2,失败进入步骤3
- 线程获取成功,标志偏向锁01,执行同步代码块,进入步骤5
- 线程获取失败,说明已有线程占用,此时进行锁升级,进入步骤4
- 在 JVM 到达全局安全点(这是 JVM 决定的,一般将循环的末尾、方法返回前等作为安全点),
获得偏向锁的线程被挂起,撤销偏向锁,并升级锁,锁标志位变为00,完成之后获得锁的线程继续执行,未获得锁的线程进行自旋,尝试获取锁(这时是轻量级锁了),进入步骤6 - 如果锁没有升级,则通过 CAS 的方式将 mark word 中线程ID清除即可
- 如果升级成了轻量级锁,那么请看轻量级锁的撤销步骤
轻量级锁
如果获取偏向锁失败,说明存在多个线程竞争,因此就会升级为轻量级锁, mark word 的结果也会改变为轻量级锁的结构。轻量级锁能够提升程序性能的依据是“对绝大部分的锁,在整个同步周期内都不存在竞争”,注意这是经验数据。需要了解的是,轻量级锁所适应的场景是线程交替执行同步块的场合,如果存在同一时间访问同一锁的场合,就会导致轻量级锁膨胀为重量级锁。
轻量级锁的获取与释放过程如下:
- 线程通过 CAS 的方式将 mark word 的记录指针指向当前线程,成功则进入步骤2
- 获取成功,执行同步代码块,结束后进入步骤5
- 获取失败,进行自旋,并一直尝试通过 CAS 的方式修改 mark work,如果成功则进入步骤2,如果失败到一定次数,进入步骤4
- 多次自旋失败进入锁膨胀,膨胀完成之后获得锁的线程继续执行代码,未获得锁的线程被挂起,等待被唤醒
- 代码运行结束,通过CAS替换 mark word来释放锁,如果锁进行膨胀,此时看重量级锁的撤销
重量级锁
重量级锁就是一个悲观锁了,但是其实不是最坏的锁,因为升级到重量级锁,是因为线程占用锁的时间长(自旋获取失败),锁竞争激烈的场景,在这种情况下,让线程进入阻塞状态,进入阻塞队列,能减少cpu消耗。所以说在不同的场景使用最佳的解决方案才是最好的技术。synchronized在不同的场景会自动选择不同的锁,这样一个升级锁的策略就体现出了这点。
重量级锁获取和释放过程如下:
- 通过 CAS 将 monitor 的 owner 设置为当前线程
- 如果 owner 为当前线程,表示重入锁,记录重入的次数
- 如果锁获取失败,线程会被挂起,并且进入等待队列
- 持有锁的线程执行完毕,通过 CAS 方式将 owner 清除,然后取出等待队列的线程,将其唤醒
锁粗化
原则上,我们在编写代码的时候,总是推荐将同步块的作用范围限制得尽量小,只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变小,如果存在锁竞争,那等待锁的线程也能尽快拿到锁。如果一系列的连续加锁解锁操作,可能会导致不必要的性能损耗,所以引入锁粗话的概念。意思是将多个连续加锁、解锁的操作连接在一起,扩展成为一个范围更大的锁。
例如:连续调用 StringBuffer#append()
方法就会启用锁粗化技术。append 的源码如下:
@Override
public synchronized StringBuffer append(String str) {
toStringCache = null;
super.append(str);
return this;
}
连续调用:
public static void main(String[] args) {
// write your code here
StringBuffer sb = new StringBuffer();
for (int i = 0; i < 100; i++) {
sb.append("aa");
}
System.out.println(sb.toString());
}
JVM 会检测到 StringBuffer#append()
方法连续调用且一直在使用同一个对象,那么就会将同步范围进行方法,有可能直接加 synchronized那么内部就可能将该段代码优化为:
private void lockCoarsening() {
StringBuffer sb = new StringBuffer();
synchronized (sb) {
for (int i = 0; i < 100; i++) {
// 该方法不再有synchronized关键字
sb.append("aa");
}
System.out.println(sb.toString());
}
}
锁消除
JVM 在 JIT(just in time,即时编译) 编译时,通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间。比如 StringBuffer#append()
方法,就是使用 synchronized 进行加锁的。
@Override
public synchronized StringBuffer append(String str) {
toStringCache = null;
super.append(str);
return this;
}
如果在实例方法中 StringBuffer 作为局部变量使用 append()
方法,StringBuffer 是不可能存在共享资源竞争的,因此会自动将其锁消除。例如:
public String add(String s1, String s2) {
//sb属于不可能共享的资源,JVM会自动消除内部的锁
StringBuffer sb = new StringBuffer();
sb.append(s1).append(s2);
return sb.toString();
}
锁升级小结
- 偏向锁:适用于单线程执行
- 轻量级锁:适用于锁竞争较不激烈的情况
- 重量级锁:适用于锁竞争激烈的情况