CH05-Synchronized
应用实践
- 一把锁同时只能被一个线程获取,没有获得锁的线程只能等待。
- 每个对象实例都有自己的锁(this),该锁不同实例之间互不影响。
- synchronized 修饰的方法,物理方法成功还是抛出异常,都会释放锁。
对象锁
包含实例方法锁(this)和同步代码块锁(自定义)。
代码块形式:手动设置锁定对象,也可以是 this,也可以是自定义的(对象实例)锁。
syhchronized(this)
synchronized(object)
,比如new Object()
作为一个实例锁。
方法锁形式:修饰实例的方法,锁对象是 this。
class Example { public synchronized void show() { System.out.println("example..."); } }
类锁
指 synchronized 修饰静态的方法或指定锁对象为 Class 对象。
静态方法:
class Example { public synchronized static void show() { System.out.println("example..."); } }
Class 对象:
class Example { public void show() { synchronized(Example.class) { System.out.println("example..."); } } }
原理分析
加锁-解锁
创建如下代码:
public class SynchronizedDemo2 {
Object object = new Object();
public void method1() {
synchronized (object) {
}
}
}
使用 javac 命令编译生成 class 文件:
javac SynchronizedDemo2.java
使用 javap 命令反编译查看 class 文件的信息:
javap -verbose SynchronizedDemo2.class
得到如下信息:
mointorenter 和 moniterexit 指令,会在程序执行时,使其锁计数器加一或减一。每个对象在同一时间只有一个 mointor(锁) 与其相关联,而一个 mointor 在同一时间只能被一个线程获得,一个对象在尝试获得与该对象关联的 monitor 锁的所有权时,monitorenter 指令会发生如下三种情况之一:
- mointor 计数器为 0,意味着目前尚未被某个线程获得,该线程会立即获得锁并将计数器加一,一旦执行加一,别的线程要想再获取就需要等待。
- 如果该线程已经拿到了该 mointor 锁的所有权,又重入了这把锁,锁计数器会继续累加一,值变为 2,随着重入次数的增加,计数值会一直累加。
- 如果该 monitor 锁已经被其他线程获得,当前线程等待锁被释放。
monitorexit 指令将释放对应 monitor 锁的所有权,释放过程很简单,即将 monitor 的计数器减一,如果结果不为 0,则表示当前是重入获得的锁,当前线程还继续持有该锁的所有权,如果计数器为 0,则表示当前线程不再拥有该 monitor 的所有权,即释放了锁。
下图描绘了真个过程:
上图可以看出,任意线程对 Object 的访问,首先要获得 Object 的监视锁,如果获取失败,该线程就会进入同步状态,线程状态变为 Blocked,当 Object 的监视器占有者释放后,在同步队列中的线程就有就会获取到该监视器。
可冲入:加锁次数计数器
在同一个线程中,线程不需要再次获取通一把锁。synchronized 先天具有重入性。每个对象拥有一个计数器,当线程获取对象 monitor 锁后,计数器就会加一,释放锁后就会减一。
可见性保证:内存模型与 happens-before
synchronized 的 happens-before 规则,即监视器锁规则:(一个线程)对同一个监视器解锁,happens-before 于(另一个线程)对该监视器加锁。
public class MonitorDemo {
private int a = 0;
public synchronized void writer() { // 1
a++; // 2
} // 3
public synchronized void reader() { // 4
int i = a; // 5
} // 6
}
图中每个箭头的两个节点之间都是 happens-before 关系。黑色箭头由程序顺序规则推导得出,红色为监视器锁规则推导而出:线程 A 释放锁先于线程 B 获得锁。蓝色则是通过程序顺序规则和监视器锁规则推测出来的 happens-before 关系,通过传递性规则进一步推导出 happens-before 规则。
根据 happens-before 的定义:如果 A 先于 B,则 A 的执行顺序先于 B,并且 A 的执行结果对 B 可见。
线程 A 先对共享变量 +1,由 2 先于 5 得知线程 A 的执行结果对 B 可见,即 B 读取到 a 的值为 1。
JVM 锁优化
JVM 在执行 monitorenter 和 monitorexit 这些指令时,依赖于底层操作系统的 Mutex Lock(互斥锁),但是由于 Mutex Lock 需要挂起当前线程,并从用户态切换到内核态来执行,这种切换的代价昂贵。然而在大部分的实际情况中,同步方法是运行在单线程环境(无锁竞争环境),如果每次都调用 Mutex Lock 则会严重影响性能。
JDK 1.6 引入了大量优化来提升性能:
- 锁粗化:减少不必要的紧连在一起的加锁、解锁操作,将多个连续的小锁扩展为一个更大的锁。
- 锁消除:通过运行时 JIT 编译器的逃逸分析来消除一些没有在当前同步块以外被其他线程共享的数据的锁保护,通过逃逸分析也可以在线程本地 Stack 上进行空间对象的分配(通知还可以减少 Heap 上垃圾收集的开销)。
- 轻量级锁:实现的原理是基于这样的假设,即在真是情况下程序中的大部分同步代码一般都属于无锁竞争状态(单线程执行环境),在无锁竞争的情况下完全可以避免调用操作系统层次的重量级互斥锁,取而代之的是在 monitorenter 和 monitorexit 之间依靠一条 CAS 原子指令就可以完成加锁解锁操作。但存在锁竞争时,执行 CAS 指令失败的线程将再去调用操作系统互斥锁进入阻塞状态,当锁被释放时再被唤醒。
- 偏向锁:为了在无锁竞争的情况下,避免在加锁过程中执行不必要的 CAS 原子指令,因为 CAS 指令虽然轻于OS 互斥锁,但还是存在(相对)可观的本地延迟。
- 适应性自旋:当线程在获取轻量级锁的过程中,如果 CAS 执行失败,在进入与 monitor 相关联的 OS 互斥锁之前,首先进入忙等待(自旋-Spinning),然后再次尝试 CAS,当尝试一定次数知乎仍然失败,再去调用与该 mointor 相关的 OS 互斥锁,进入阻塞状态。
锁的类型
Java 1.6 中 synchronized 同步锁,共有 4 种状态:无锁、偏向锁、轻量锁、重量锁。
会随着竞争状况逐渐升级。锁可以升级但不能降级,目的是为了提高获取锁和释放锁的效率。
自旋锁、自适应自旋
自旋锁
在多线程竞争锁时,当一个线程获取锁时,它会阻塞所有正在竞争的线程,这样对性能带来了极大的影响。在挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作对系统的并发性能带来了很大的压力。同时HotSpot团队注意到在很多情况下,共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起和回复阻塞线程并不值得。在如今多处理器环境下,完全可以让另一个没有获取到锁的线程在门外等待一会(自旋),但不放弃CPU的执行时间。等待持有锁的线程是否很快就会释放锁。为了让线程等待,我们只需要让线程执行一个忙循环(自旋),这便是自旋锁由来的原因。
自旋锁早在JDK1.4 中就引入了,只是当时默认时关闭的。在JDK 1.6后默认为开启状态。自旋锁本质上与阻塞并不相同,先不考虑其对多处理器的要求,如果锁占用的时间非常的短,那么自旋锁的新能会非常的好,相反,其会带来更多的性能开销(因为在线程自旋时,始终会占用CPU的时间片,如果锁占用的时间太长,那么自旋的线程会白白消耗掉CPU资源)。因此自旋等待的时间必须要有一定的限度,如果自选超过了限定的次数仍然没有成功获取到锁,就应该使用传统的方式去挂起线程了,在JDK定义中,自旋锁默认的自旋次数为10次,用户可以使用参数-XX:PreBlockSpin
来更改。
可是现在又出现了一个问题:如果线程锁在线程自旋刚结束就释放掉了锁,那么是不是有点得不偿失。所以这时候我们需要更加聪明的锁来实现更加灵活的自旋。来提高并发的性能。
自适应自旋
在JDK 1.6中引入了自适应自旋锁。这就意味着自旋的时间不再固定了,而是由前一次在同一个锁上的自旋 时间及锁的拥有者的状态来决定的。如果在同一个锁对象上,自旋等待刚刚成功获取过锁,并且持有锁的线程正在运行中,那么JVM会认为该锁自旋获取到锁的可能性很大,会自动增加等待时间。比如增加到100次循环。相反,如果对于某个锁,自旋很少成功获取锁。那再以后要获取这个锁时将可能省略掉自旋过程,以避免浪费处理器资源。有了自适应自旋,JVM对程序的锁的状态预测会越来越准备,JVM也会越来越聪明。
锁消除
锁消除是指虚拟机即时编译器在运行过冲中,对一些在代码上要求同步、但是被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判定依据来源于逃逸分析的数据支持。意思就是:JVM 会判断在一段程序中的同步数据明显不会逃逸出去从而被其他线程访问到,那 JVM 就把它们当作栈上数据对待,认为这些数据是线程独有的,不需要加同步。此时就会进行锁消除。
当然在实际开发中,我们很清楚的知道那些地方是线程独有的,不需要加同步锁,但是在 Java API 中有很多方法都是加了同步的,那么此时 JVM 会判断这段代码是否需要加锁。如果数据并不会逃逸,则会进行锁消除。比如如下操作:在操作 String 类型数据时,由于 String 是一个不可变类,对字符串的连接操作总是通过生成的新的 String 对象。因此 Javac 编译器会对 String 连接做自动优化。在 JDK 1.5 之前会使用 StringBuffer 对象的连续 append() 操作,在 JDK 1.5 及以后的版本中,会转化为 StringBuidler 对象的连续 append() 操作。
锁粗化
原则上,我们都知道在加同步锁时,尽可能的将同步块的作用范围限制到尽量小的范围(只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变小。在存在锁同步竞争中,也可以使得等待锁的线程尽早的拿到锁。
大部分上述情况是正确的,但是如果存在连串的一系列操作都对同一个对象反复加锁和解锁,甚至加锁操作时出现在循环体中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要地性能操作。
轻量锁
在 JDK 1.6 之后引入的轻量级锁,需要注意的是轻量级锁并不是替代重量级锁的,而是对在大多数情况下同步块并不会有竞争出现时提供的一种优化。它可以减少重量级锁对线程的阻塞带来地线程开销。从而提高并发性能。
如果要理解轻量级锁,那么必须先要了解 HotSpot 虚拟机中对象头地内存布局。在对象头中(Object Header
)存在两部分。第一部分用于存储对象自身的运行时数据,HashCode
、GC Age
、锁标记位
、是否为偏向锁
等。一般为32位或者64位(视操作系统位数定)。官方称之为Mark Word
,它是实现轻量级锁和偏向锁的关键。 另外一部分存储的是指向方法区对象类型数据的指针(Klass Point
),如果对象是数组的话,还会有一个额外的部分用于存储数据的长度。
如上图所示,如果当前对象没有被锁定,那么锁标志位为 01 状态,JVM 在指向当前线程时,首先会在当前线程帧栈中创建锁记录 Lock Record 的空间,用于存储锁对象目前的 Mark Word 的拷贝。
然后,虚拟机使用 CAS 操作将标记字段 Mark Word 拷贝到锁记录中,并将 Mark Word 更新为指向 Lock Record 的指针。如果更新成功了,那么这个线程就有了使用该对象的锁,并且对象 Mark Word 的所标志位更新为(Mark Word 中最后为 2 bit) 00,即表示该对象处于轻量级锁定状态,如图:
如果更新操作失败,JVM 会检查当前 Mark Word 中是否存在指向当前线程帧栈的指针,如果有,则表示锁已经被获取,可以直接使用。如果没有,则说明该锁已经被其他线程抢占,如果有两条以上的线程同时经常一个锁,那么轻量级锁就不再有效,直接升级为重量级锁,没有获得锁的线程会被阻塞。此时,锁的标志位为 10,Mark Word 中存储的是指向重量级锁的指针。
轻量级锁解锁时,会使用原子的 CAS 操作将 Displaced Mark Word 替换会对象头中,如果成功,则表示没有发生竞争,如果失败,则表示当前锁存在竞争关系。锁就会升级为重量级锁。
两个线程同时抢占锁,导致锁升级的流程如下:
偏向锁
在大多数实际环境中,锁不仅不存在多线程竞争,而且总是由同一个线程多次获取,那么在同一个线程反复加锁解锁的过程中,其中并没有对锁的竞争,这样一来,多次加锁解锁带来了不必要的性能开销。
为了解决这一问题,HotSpot 的作者在 Java SE 1.6 中对 Synchronized 进行了优化,引入了偏向锁。
当一个线程访问同步块并获取锁时,会在对象头和帧栈中的锁记录里存储偏向锁偏向的线程 ID,以后该线程在进入和退出同步块时不需要进行 CAS 操作来加锁和解锁。只需要简单的测试一下对象头的 Mark Word 中是否保存了指向当前线程的偏向锁。如果成功,表示线程已经获得了锁。
偏向锁使用了一种等待竞争出现时才会释放锁的机制。当其他线程尝试获取偏向锁时,持有偏向锁的线程才会释放锁。但是偏向锁的撤销需要等到全局安全点(即当前线程没有正在执行的字节码)。
它首先会暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否还活着。如果线程不处于活动状态,直接将对象头设置为无锁状态。如果活着,JVM 会遍历帧栈中的锁记录,帧栈中的锁记录和对象头要么偏向于其他线程,要么恢复到无锁状态或者标记对象不适合作为偏向锁。
锁对比
锁 | 优点 | 缺点 | 场景 |
---|---|---|---|
偏向锁 | 加锁解锁不需要 CAS,没有额外性能开销 | 如果线程间存在竞争,撤销锁会带来额外开销 | 仅一个线程访问同步块 |
轻量锁 | 竞争的线程不会阻塞,提供响应速度 | 如果线程始终得到到锁,自旋会消耗性能 | 同步块执行速度非常快 |
重量锁 | 线程竞争不适用自旋,不会消耗 CPU | 线程阻塞、响应慢、频繁加解锁开销大 | 追求吞吐量,同步块执行速度慢 |
Synchronized 与 Lock
Synchronized 的缺陷
- 效率低:锁的释放情况少,只有代码指向完或抛出异常时才会解锁;试图获取锁时不能设置超时,不能中断正在使用锁的线程,而 Lock 可以中断或设置超时。
- 不灵活:加锁和解锁的时机单一,每个锁仅有一个单一的条件(对象实例),Lock 更加灵活。
- 无法感知是否获得锁:Lock 可以显式获取状态,然后基于状态执行判断。
相比 Lock
Lock 的方法:
- lock:加锁
- unlock:解锁
- tryLock:尝试加锁,返回布尔值
- tryLock(long,TimeUnit):尝试加锁,设定超时
多线程竞争锁时,其余未获得锁的线程只能不停的尝试加锁,而不能中断,高并发情况下会导致性能下降。
ReentrantLock 的 lockInterruptibly() 方法可以优先考虑响应中断。 一个线程等待时间过长,它可以中断自己,然后 ReentrantLock 响应这个中断,不再让这个线程继续等待。有了这个机制,使用 ReentrantLock 时就不会像 synchronized 那样产生死锁了。
注意事项
Synchronized 由 JVM 实现,无需显式控制加解锁逻辑。
- 锁对象不能为空,因为锁的信息都保存在对象头里
- 作用域不宜过大,影响程序执行的速度,控制范围过大,编写代码也容易出错
- 避免死锁
- 在能选择的情况下,既不要用 Lock 也不要用 synchronized 关键字,用 JUC 包中的各种各样的类,如果不用该包下的类,在满足业务的情况下,可以使用 synchronized 关键,避免手动操作引起错误
- synchronized 是公平锁吗?
- 实际上是非公平的,新来的线程有可能立即获得监视器,而在等待区中等候已久的线程可能再次等待。
- 但这种抢占的方式可以预防饥饿。
Feedback
Was this page helpful?
Glad to hear it! Please tell us how we can improve.
Sorry to hear that. Please tell us how we can improve.