本文基于JDK1.8
编写
关键点
- 一把锁只能同时被一个线程获取,没有获得锁的线程只能等待。
- 每个实例都对应有自己的一把锁(this),不同实例之间互不影响。例外:锁对象是
*.class
以及synchronized
修饰的是static
方法的时候,所有对象公用同一把锁。 -
synchronized
修饰的方法,无论方法正常执行完毕还是抛出异常,都会释放锁
应用方式
public synchronized void A(){
...
}
public void B(){
synchronized(this){
...
}
}
这里的A和B方法意义相同,都锁定的是this,也就是锁定当前对象实例。
class SyncTest {
public synchronized static void A(){
...
}
public void B(){
synchronized(SyncTest.class){
...
}
}
}
这里的A和B方法意义相同,都锁定的是
SyncTest
类,也就是说不管是在哪个实例中,只要是SyncTest
类就用的是同一把锁
原理解析
原理概述
synchronized
是可重入锁。最初是将锁的工作交由操作系统解决的也就是我们说的重量级锁,JVM在JDK1.6
后对其进行了优化,加入了轻量级锁和偏向锁,引入了锁升级的概念,也加入了锁粗化和锁消除这样的优化方式。
什么是可重入锁?
可重入:(来源于维基百科)若一个程序或子程序可以“在任意时刻被中断然后操作系统调度执行另外一段代码,这段代码又调用了该子程序不会出错”,则称其为可重入(reentrant或re-entrant)的。即当该子程序正在运行时,执行线程可以再次进入并执行它,仍然获得符合设计时预期的结果。与多线程并发执行的线程安全不同,可重入强调对单个线程执行时重新进入同一个子程序仍然是安全的。
可重入锁:又名递归锁,是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象或者class),不会因为之前已经获取过还没释放而阻塞。
重量级锁是怎么实现的?
monitorenter
:用来获取锁,使锁计数器+1
monitorexit
:用来释放锁,使锁计数器-1
需要注意:
- 每一个对象在同一时间只与一个
monitor
(锁)相关联 - 一个
monitor
在同一时间只能被一个线程获得 - 一个对象在尝试获得与这个对象相关联的
monitor
锁的所有权的时,会发生下列三种情况之一:-
monitor
计数器为0,意味着目前还没有被获得,那这个线程就会立刻获得然后把锁计数器+1,一旦+1,别的线程再想获取,就需要等待 - 如果这个
monitor
已经拿到了这个锁的所有权,又重入了这把锁,那锁计数器就会累加,变成2,并且随着重入的次数,会一直累加 - 这把锁已经被别的线程获取了,等待锁释放
-
示例
public class SyncTest {
public static void main(String[] args) {
SyncTest test= new SyncTest();
test.method1();
}
private synchronized void A() {
System.out.println(Thread.currentThread().getId() + ": A()");
B();
}
private synchronized void B() {
System.out.println(Thread.currentThread().getId()+ ": B()");
C();
}
private synchronized void C() {
System.out.println(Thread.currentThread().getId()+ ": C()");
}
}
结合前文中加锁和释放锁的原理,不难理解:
- 执行monitorenter获取锁
-(monitor计数器=0,可获取锁)
- 执行A()方法,monitor计数器+1 -> 1 (获取到锁)
- 执行B()方法,monitor计数器+1 -> 2
- 执行C()方法,monitor计数器+1 -> 3
- 执行monitorexit命令
- C()方法执行完,monitor计数器-1 -> 2
- B()方法执行完,monitor计数器-1 -> 1
- A()方法执行完,monitor计数器-1 -> 0 (释放了锁)
-(monitor计数器=0,锁被释放了)
JVM锁优化
锁升级
synchronized
锁一共有四种类型,无锁,偏向锁,轻量级锁,重量级锁。偏向锁是默认关闭的,他们是逐步升级的,这个升级是不可逆的,也就是说,只会升级,不会降级。目的是为了在不同情况下选用最符合的锁类型,在计算资源消耗和执行效率之间取到最合适的平衡点。
对象与锁
看这部分以前建议先看下我这篇Java对象解析,这里讲解了对象头,里面Mark Word
部分就是这部分锁和对象的实现。
为了标记当前Mark Word
记录的内容,Mark Word
中记录了用lock
和biased_lock
两列记录了当前Mark Word
的含义。
biased_lock | lock | 状态 |
---|---|---|
0 | 01 | 无锁 |
1 | 01 | 偏向锁 |
0 | 00 | 轻量级锁 |
0 | 10 | 重量级锁 |
0 | 11 | GC标记 |
偏向锁
关闭延迟开启偏向锁:-XX:BiasedLockingStartupDelay=0
(默认延迟4s)
禁止偏向锁:-XX:-UseBiasedLocking
偏向锁会将首个进入锁的线程的线程ID
记录到Mark Word
中的thread
中,一旦产生锁竞争就会将锁升级为轻量级锁。
获取锁
- 访问Mark Word中偏向锁标志位
biased_lock
是否设置成1
,锁标志位是否为01
(确认为偏向锁)。 - 如果为可偏向状态,则测试线程ID是否指向当前线程。
- 如果
线程ID
并未指向当前线程,则通过CAS
操作竞争锁。如果竞争成功,则将Mark Word
中线程ID
设置为当前线程ID
。执行同步代码。 - 如果CAS获取偏向锁失败,则表示有竞争。当到达全局安全点(
safepoint
)时获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码。
- 如果
释放锁
偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动去释放偏向锁。
偏向锁的撤销
需要等待全局安全点(safepoint
),它会首先暂停拥有偏向锁的线程,然后判断这个线程:
- 线程如果在同步代码块内,就将锁升级为轻量级锁
- 线程如果不在同步代码块中,就直接将锁设置为无锁状态
轻量级锁
加锁和释放锁的逻辑
- 在轻量级锁上锁时,会在栈帧中创建一块名叫
Lock Record
的空间,而后将这些信息复制到当前线程的栈帧中,官方将这些信息称为Displaced Mark Word
。 - 用CAS的方式将对象的
Mark Word
信息更新为指向Lock Record
的指针。- 如果更新成功,那么这个线程就持有了该对象的锁,然后会把锁标志位
lock
更新为00
,也就是轻量级锁。 - 如果更新失败,会校验是否已经指向了当前线程
Lock Record
,如果已经指向了,那就直接使用。如果没有指向则进入自旋等待,如果有两条以上的线程竞争同一个锁,那轻量级锁就不再有效,直接膨胀位重量级锁,没有获得锁的线程会被阻塞。
- 如果更新成功,那么这个线程就持有了该对象的锁,然后会把锁标志位
- 轻量级解锁时,会使用原子的CAS操作将Displaced Mark Word替换回到对象头中,如果成功,则表示没有发生竞争关系。如果失败,说明有其他线程尝试过获取该锁(此时锁已膨胀),那就要在释放锁的同时,唤醒被挂起的线程。
锁消除
锁消除
是指虚拟机即时编译器再运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判定依据来源于逃逸分析
的数据支持。意思就是:JVM
会判断在一段程序中的同步明显不会逃逸出去从而被其他线程访问到,那JVM
就把它们当作栈
上数据对待,认为这些数据是线程独有的,不需要加同步。此时就会进行锁消除
。
当然在实际开发中,我们很清楚的知道哪些是线程独有的,不需要加同步锁,但是在Java API
中有很多方法都是加了同步的,那么此时JVM
会判断这段代码是否需要加锁。如果数据并不会逃逸,则会进行锁消除。
比如:在操作
String
类型数据时,由于String
是一个不可变类,对字符串的连接操作总是通过生成的新的String
对象来进行的。因此Javac
编译器会对String
连接做自动优化。在JDK 1.5
之前会使用StringBuffer
对象的连续append()
操作,在JDK 1.5
及以后的版本中,会转化为StringBuidler
对象的连续append()
操作。
众所周知,StringBuilder
不是安全同步的,但是JVM
判断该段代码并不会逃逸,则默认为线程独有的资源,并不需要同步,所以执行了锁消除操作。(还有Vector
中的各种操作也可实现锁消除。在没有逃逸出数据安全防卫内)
锁粗化
原则上,我们都知道在加同步锁时,尽可能的将同步块的作用范围限制到尽量小的范围(只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变小。在存在锁同步竞争中,也可以使得等待锁的线程尽早的拿到锁)。
大部分上述情况是完美正确的,但是如果存在连串的一系列操作都对同一个对象反复加锁和解锁,甚至加锁操作时出现在循环体中的,那即使没有线程竞争,频繁的进行互斥同步操作也会导致不必要的性能操作。
这里贴上根据上述Javap
编译的情况编写的实例java类
public static String test04(String s1, String s2, String s3) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
sb.append(s3);
return sb.toString();
}
在上述的连续append()
操作中就属于这类情况。JVM
会检测到这样一连串的操作都是对同一个对象加锁,那么JVM
会将加锁同步的范围扩展(粗化)到整个一系列操作的 外部,使整个一连串的append()
操作只需要加锁一次就可以了。
锁的优缺点比较
锁 | 优点 | 缺点 | 使用场景 |
---|---|---|---|
偏向锁 | 加锁和解锁不需要CAS操作,没有额外的性能消耗,和执行非同步方法相比仅存在纳秒级的差距 | 如果线程间存在锁竞争,会带来额外的锁撤销的消耗 | 适用于只有一个线程访问同步块的场景 |
轻量级锁 | 竞争的线程不会阻塞,提高了响应速度 | 如线程成始终得不到锁竞争的线程,使用自旋会消耗CPU性能 | 追求响应时间,同步块执行速度非常快 |
重量级锁 | 线程竞争不适用自旋,不会消耗CPU | 线程阻塞,响应时间缓慢,在多线程下,频繁的获取释放锁,会带来巨大的性能消耗 | 追求吞吐量,同步块执行速度较长 |