上篇文章简单介绍了一下synchronized的使用,以及对象锁和类锁的概念,本篇文章就来介绍一下synchronized的底层原理。
上篇文章也提到,我们对synchronized的印象是重量级锁,使用效率低下,导致我们一般在使用synchronized有很多顾虑。但是随着Java6对synchronized进行的各种优化后,synchronized并不会显得那么重了。本篇文章就synchronized的实现机制以及Java6的各种优化,来对synchronized进行一个全面的认识。
1. 自己实现锁
在讲synchronized锁之前,我们先考虑一下,如何自己实现一个操作系统的锁?
1.1 自旋
volatile int status=0;
void lock(){
while(!compareAndSet(0,1)){
}
//获取锁成功
}
void unlock(){
status=0;
}
boolean compareAndSet(int except,int newValue){
//cas操作,修改status成功则返回true
}
上面的伪代码通过自旋和cas来实现了一个最简单的锁。但有个致命的缺点:浪费cpu资源。没有竞争到锁的线程会一直占用cpu资源进行cas操作,假如系统中一共又两个线程在运行,一个线程获得锁后要花费10s处理业务逻辑,那另外一个线程就会白白的花费10s的cpu资源。
1.2 yield + 自旋
要解决自旋锁的性能问题必须让竞争锁失败的线程不忙等,而是在获取不到锁的时候能把cpu资源给让出来,之前讲线程的使用的使用讲到yield()方法可以让正在执行的线程暂停并让出cpu资源,如下:
volatile int status=0;
void lock(){
while(!compareAndSet(0,1)){
//获取不到锁,让出cpu资源
yield();
}
//获取锁成功
}
当线程竞争锁失败时,会调用yield方法让出cpu。但该方法只是当前让出cpu,有可能操作系统下次还是选择运行该线程。
自旋+yield的方式并没有完全解决问题,当系统只有两个线程竞争锁时,yield是有效的。但是如果有100个线程竞争锁,当线程1获得锁后,还有99个线程在反复的自旋 + yield,线程2调用yield后,操作系统下次运行的可能是线程3。而线程3CAS失败后调用yield后,操作系统下次运行的可能是线程4。
假如运行在单核cpu下,在竞争锁时最差只有1%的cpu利用率,导致获得锁的线程1一直被中断,执行实际业务代码时间变得更长,从而导致锁释放的时间变的更长。
1.3 sleep + 自旋
除了上述的yield()方法,sleep方法也可以让出cpu,当cas失败时,可以通过sleep将cpu资源释放出来,如下:
volatile int status=0;
void lock(){
while(!compareAndSet(0,1)){
//竞争锁失败,使用sleep释放cpu资源
sleep(10);
}
//成功获取锁
}
上述方式我们可能见的比较多,通常用于实现上层锁。该方式不适合用于操作系统级别的锁,因为作为一个底层锁,sleep时间很难设置,sleep的时间取决于同步代码块的执行时间,sleep时间如果太短了,会导致线程切换频繁(极端情况和yield方式一样)。sleep时间如果设置的过长,会导致线程不能及时获得锁。因此没法设置一个通用的sleep值。就算sleep的值由调用者指定也不能完全解决问题:有的时候调用锁的人也不知道同步块代码会执行多久。
1.4 park + 自旋
上述yield + 自旋、sleep + 自旋都存在一个问题,竞争锁失败释放cpu的线程重新去竞争锁的时机不可控制。那能不能在获取不到锁的时候让线程释放cpu资源进行等待,当持有锁的线程释放锁的时候将等待的线程唤起呢?
volatile int status=0;
Queue parkQueue;
void lock(){
while(!compareAndSet(0,1)){
//竞争锁失败,线程释放cpu并放入等待队列
lock_wait();
}
//获取锁成功
}
void synchronized unlock(){
status = 0;
//解锁时,从等锁队列去除一个线程并唤醒
lock_notify();
}
void lock_wait(){
//将当期线程加入到等待队列
parkQueue.add(nowThread);
//将当期线程释放cpu
releaseCpu();
}
void lock_notify(){
//得到要唤醒的线程
Thread t=parkList.poll();
//唤醒等待线程
wakeAThread(t);
}
上面是伪代码,描述这种设计思想。这种方案相比于yield和sleep而言,只有在锁被释放的时候,竞争锁的线程才会被唤醒,不会存在过早或过完唤醒的问题。
对于锁冲突不严重的情况,用自旋锁会更适合,试想每个线程获得锁后很短的一段时间内就释放锁,竞争锁的线程只要经历几次自旋运算后就能获得锁,那就没必要等待该线程了,因为等待线程意味着需要进入到内核态进行上下文切换,而上下文切换是有成本的并且还不低,如果锁很快就释放了,那上下文切换的开销将超过自旋。
目前操作系统中,一般是用自旋+等待结合的形式实现锁:在进入锁时先自旋一定次数,如果还没获得锁再进行等待。
2. 操作系统实现锁
linux底层用futex实现锁,futex由一个内核层的队列和一个用户空间层的atomic integer构成。当获得锁时,尝试cas更改integer,如果integer原始值是0,则修改成功,该线程获得锁,否则就将当期线程放入到wait queue中。看着好想跟上面讲的park + 自旋差不多,其实操作系统实现的锁就是对上述park + 自旋的优化版本。下面讲一下在futex出现之前及futex是怎么实现锁的。
2.1 futex之前
在futex之前,linux下的同步机制可以归为两类:用户态的同步机制和内核同步机制。用户态的同步机制基本上就是利用原子指令实现的自旋锁。关于自旋锁其缺点也说过了,不适用于大的临界区(即锁占用时间比较长的情况)。
内核提供的同步机制,如semaphore等,使用的是上文说的park + 自旋的形式。 它对于大小临界区和都适用。但是因为它是内核层的(释放cpu资源和唤醒线程是内核级调用),所以每次lock与unlock都是一次系统调用,即使没有锁冲突,也必须要通过系统调用进入内核之后才能识别。
2.2 futex
在futex之前,内核同步机制存在问题,即使没有锁冲突,也必须要通过系统调用进入内核之后才能识别。而理想的同步机制应该是没有锁冲突时在用户态利用原子指令就解决问题,而需要挂起等待时再使用内核提供的系统调用进行睡眠与唤醒。换句话说就是,在用户态的自旋失败时,能不能让进程挂起,由持有锁的线程释放锁时将其唤醒。
我们想象一下,futex是不是可以通过如下伪代码实现:
void lock(int lockval) {
//trylock是用户级的自旋锁
while(!trylock(lockval)) {
//用户自旋失败,释放cpu,并将当期线程加入等待队列,是系统调用
wait();
}
}
boolean trylock(int lockval){
int i=0;
//localval=1代表上锁成功
while(!compareAndSet(lockval,0,1)){
//自旋10次,如果还没有获取锁,则自旋失败
if(++i>10){
return false;
}
}
return true;
}
void unlock(int lockval) {
//cas释放锁
compareAndSet(lockval,1,0);
//唤醒一个等待的线程
notify();
}
上述代码的问题是trylock和wait两个调用之间存在一个窗口,假如有两个线程竞争锁,一个线程trylock失败,在调用wait时持有锁的线程释放了锁,当前线程还是会调用wait进行等待,但之后就没有人再将该线程唤醒了。
下面来看一下futex的方法定义:
//uaddr指向一个地址,val代表这个地址期待的值,当*uaddr==val时,才会进行wait
int futex_wait(int *uaddr, int val);
//唤醒n个在uaddr指向的锁变量上挂起等待的进程
int futex_wake(int *uaddr, int n);
futex_wait真正将进程挂起之前会检查addr指向的地址的值是否等于val,如果不相等则会立即返回,由用户态继续trylock。否则将当期线程插入到一个队列中去,并挂起。
futex内部维护了一个队列,在线程挂起前会线程插入到其中,同时对于队列中的每个节点都有一个标识,代表该线程关联锁的uaddr。这样,当用户态调用futex_wake时,只需要遍历这个等待队列,把带有相同uaddr的节点所对应的进程唤醒就行了。
作为优化,futex维护的其实是个类似java 中的concurrent hashmap的结构。其持有一个总链表,总链表中每个元素都是一个带有自旋锁的子链表。调用futex_wait挂起的进程,通过其uaddr hash到某一个具体的子链表上去。这样一方面能分散对等待队列的竞争、另一方面减小单个队列的长度,便于futex_wake时的查找。每个链表各自持有一把spinlock,将”*uaddr和val的比较操作”与”把进程加入队列的操作”保护在一个临界区中。
3. synchronized
上面介绍了我们我们自己实现锁及操作系统实现锁的简单思路,相信我们都大致能了解一个基本锁的实现方式,接下来介绍一下java内置锁synchronized的实现。之前介绍synchronized的使用时,我们知道synchronized有三种用法,分别是synchronized代码块、synchronized实例方法和synchronized静态方法。他们之间的区别也就是对象锁和类锁的区别,上篇文章Java编程拾遗『synchronized使用』介绍的很清楚了,这里对用法不多介绍。这里重点介绍一下synchronized锁的底层实现,以及java6中对synchronized的优化技术的细节。
3.1 sychronized简介
这里用synchronized代码块和synchronized实例方法做示例(synchronized静态方法跟synchronized实例方法类似)。
public class SynchronizedTest {
public void synchronizedBlock(){
synchronized (this){
System.out.println("hello block");
}
}
public synchronized void synchronizedMethod(){
System.out.println("hello method");
}
}
把SynchronizedTest.java编译成class文件,用javap -verbose 命令查看class文件对应的JVM字节码信息,部分信息如下:
{
public void synchronizedBlock();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=3, args_size=1
0: aload_0
1: dup
2: astore_1
3: monitorenter
4: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
7: ldc #3 // String hello block
9: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
12: aload_1
13: monitorexit
14: goto 22
17: astore_2
18: aload_1
19: monitorexit
20: aload_2
21: athrow
22: return
Exception table:
from to target type
4 14 17 any
17 20 17 any
public synchronized void synchronizedMethod();
descriptor: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
stack=2, locals=1, args_size=1
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #5 // String hello method
5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
LineNumberTable:
line 17: 0
line 18: 8
LocalVariableTable:
Start Length Slot Name Signature
0 9 0 this Lcom/zhuoli/service/thinking/java/thread0/SynchronizedTest;
}
对于synchronized代码块而言,javac在编译时,会生成对应的monitorenter和monitorexit指令,分别对应synchronized同步块的进入和退出,有两个monitorexit指令的原因是:为了保证抛异常的情况下也能释放锁,所以javac为同步代码块添加了一个隐式的try-finally,在finally中会调用monitorexit命令释放锁。而对于synchronized方法而言,javac为其生成了一个ACC_SYNCHRONIZED关键字,在JVM进行方法调用时,发现调用的方法被ACC_SYNCHRONIZED修饰,则会先尝试获得锁。在JVM底层,对于这两种synchronized语义的实现大致相同。
当JVM执行字节码,遇到monitorenter指令时,便认为是线程在申请synchronized锁,随后JVM解析monitorenter指令,执行一些列操作来实现锁的语义(保证同一时刻同一个锁保护的代码只能被一个线程执行)。Java版本不同,JVM解析monitorenter指令的结果也不一样。Java6之前,锁只有一种重量级锁形式。但是Java6引入了synchronized的一些列优化,锁的形式也丰富了很多,出现了偏向锁、轻量级锁、重量级锁。(这里可以这样理解,Java6之前,JVM解析monitorenter指令只有一种结果,那就是重量级锁。但是Java6及之后的Java版本,JVM解析monitorenter指令时,会先尝试使用偏向锁,轻量级锁,这两种方式满足不了,才会升级为重量级锁。但无论使用那种锁,都可以保证synchronized的语义。)
重量级锁依赖于系统的同步函数,在linux上使用mutex互斥锁,最底层实现依赖于futex,这些同步函数都涉及到用户态和内核态的切换、进程的上下文切换,成本较高。对于加了synchronized关键字但运行时并没有多线程竞争,或两个线程接近于交替执行的情况,使用传统锁机制无疑效率是会比较低的。
Java6之前,synchronized只有传统的锁机制,因此给开发者留下了synchronized关键字相比于其他同步机制性能不好的印象。Java6引入了两种新型锁机制:偏向锁和轻量级锁,它们的引入是为了解决在没有多线程竞争或基本没有竞争的场景下因使用传统锁机制带来的性能开销问题。
3.2 对象头
在Java中任意对象都可以用作锁,因此必定要有一个映射关系,存储该对象以及其对应的锁信息(比如当前哪个线程持有锁,哪些线程在等待)。一种很直观的方法是,用一个全局map,来存储这个映射关系,但这样会有一些问题:需要对map做线程安全保障,不同的synchronized之间会相互影响,性能差。另外当同步对象较多时,该map可能会占用比较多的内存。
所以最好的办法是将这个映射关系存储在对象头中,因为对象头本身也有一些hashcode、GC相关的数据,所以如果能将锁信息与这些信息共存在对象头中就好了。
在JVM中,对象在内存中除了本身的数据外还会有个对象头,对于普通对象而言,其对象头中有两类信息:mark word和类型指针。另外对于数组而言还会有一份记录数组长度的数据。
类型指针是指向该对象所属类对象的指针,mark word用于存储对象的HashCode、GC分代年龄、锁状态等信息。在32位系统上mark word长度为32bit,64位系统上长度为64bit。为了能在有限的空间里存储下更多的数据,其存储格式是不固定的,在32位系统上各状态的格式如下:
可以看到锁信息也是存在于对象的mark word中的。当对象状态为偏向锁(biasable)时,mark word存储的是偏向的线程ID。当状态为轻量级锁(lightweight locked)时,mark word存储的是指向线程栈中Lock Record的指针。当状态为重量级锁(inflated)时,为指向堆中的monitor对象的指针。
3.3 重量级锁
重量级锁是我们常说的传统意义上的锁,其底层利用操作系统底层的同步机制去实现Java中的线程同步。重量级锁的状态下,对象的mark word为指向一个堆中monitor对象的指针。一个monitor对象包括这么几个关键字段:ContentionList,EntryList,OnDeck,Owner,WaitSet。其中ContentionList ,EntryList ,WaitSet都是由ObjectWaiter的链表结构,Owner指向持有锁的线程。
具体流程如下:
- 如果线程A执行Obj对象的同步方法,通过对象头查找到Monitor的位置,然后线程A会进入WaitQueue区域,该区域主要是用于存储所有竞争锁资源的线程,多个线程同时竞争锁资源,只会有一个线程竞争成功,其它线程就会存储到该区域中,该区域主要维护两个队列:
- ContentionList:当一个线程尝试获得锁时,如果该锁已经被占用,请求锁的线程会封装一个ObjectWaiter对象放置到该竞争队列ContentionList的尾部,然后暂停当前线程
- EntryList:持有锁的线程(Owner)释放锁时会把ContentionList中的ObjectWaiter移到EntryList,这个设计一方面也是从性能方面考虑:ContentionList在高并发场景下不断的有新线程加入该队列,并且存在多个线程同时操作ContentionList,所以要进行同步控制,如果持有锁的线程(Owner)释放锁时直接从ContentionList获取要竞争锁的ObjectWaiter显然存在并发访问问题。所以,持有锁的线程(Owner)释放锁时首先会从ContentionList中的元素移到到EntryList中,然后从EntryList中获取要竞争锁的ObjectWaiter,一般都是将EntryList的head赋值给OnDheck,EntryList不会存在并发访问问题,因为只有Owner线程才会从EntryList中提取数据,且也只有Owner才能从ContentionList迁移线程到EntryList中,所以不用进行并发控制,性能更好
- ReadyThread区域主要是存储下一个可以参与竞争锁资源的线程,等持有锁的线程释放锁时,让OnDeck指向的线程参与锁竞争,OnDeck中的元素就是在持有锁的线程释放锁前,从EntryList的head取出的元素。注意:Waiting Queue中只会有一个线程参与竞争,一般是FIFO方式参与竞争,避免所有等待线程一起竞争锁资源造成性能问题。
- OnDeck要竞争锁资源,而不是将Owner的锁资源直接传递给OnDeck线程,OnDeck只代表有资格竞争锁资源的线程,竞争锁资源就意味着可能会失败,失败就意味着这是一种非公平锁的实现机制。到底哪些线程会和OnDeck线程竞争锁资源呢?就是当前新加入申请锁资源的线程们,因为我们知道,只有申请锁失败的线程才会放入到ContentionList,现在假如新加入的线程还在刚申请,走了狗屎运这时刚好Owner线程释放了锁资源,这就导致了这些新加入线程会和OnDeck一起竞争锁资源,这些新加入的线程可能优先竞争到锁资源,这就是非公平性的体现。这么做主要是从性能方面考虑,毕竟新线程如果竞争失败要做一大堆初始化工作然后放入到等待队列ContentionList中,而OnDeck线程竞争失败只需要重新阻塞即可,显然工作量要小很多。但是,进入Waiting Queue中的线程基本上是按照先进先出FIFO策略获取到锁资源的,因此,这种机制只会牺牲一定的公平性。另外,至少OnDeck线程还可以参与竞争,而不是从性能考虑直接让新线程获取到锁,避免等待队列中线程饿死现象
- RunningThread区域主要是存储当前获取到锁后正在运行的线程,使用Owner指向当前运行线程
- BlockingQueue区域主要是存储那些获取到锁资源但是调用wait等方法被阻塞的线程,由于wait操作会释放当前锁,即Owner会被重置为null,当前线程进入WaitSet中,同时OnDeck线程参与锁竞争获取锁资源,等被阻塞的线程被唤醒后会被移入EntryList重新等待获取锁资源
现在我们回过头想一个问题,为什么wait()和notify()/notifyAll()方法必须在同步代码块或同步方法中?
调用wait()时会释放锁,进入BlockingQueue,并唤醒EntryList首部的元素进入OnDeck,去竞争锁资源。当调用notify()/notifyAll()方法时,会将BlockingQueue中的元素移到EntryList,让这部分线程有重新竞争锁的机会。也就是讲无论时wait(),还是notify()/notifyAll()都涉及Monitor中BlockingQueue和EntryList队列结构的改变,而Monitor是线程私有的,所有者就是当前持有锁的线程,没有持有锁的线程是无权改变Monitor中BlockingQueue和EntryList队列结构的。
使用重量级锁时,多个线程竞争锁资源要借助底层系统的Mutex Lock互斥锁实现,需要由用户态切换到内核态,由内核协调哪个线程获取到锁,哪些线程无法获取到锁,获取锁失败的线程会被内核进行阻塞,线程阻塞才能释放CPU资源。系统执行完后,会由内核态重新切换到用户态,将CPU的控制权交给获取锁的线程进行执行。
3.2 轻量级锁
JVM的开发者发现在很多情况下,在Java程序运行时,同步块中的代码都是不存在竞争的,而是不同的线程交替的执行同步块中的代码。这种情况下,用重量级锁的代价是很高的,因为每次申请锁释放锁都要依赖于操作系统的同步函数,涉及用户态和内核态的转化。因此JVM引入了轻量级锁的概念,以适应这种多个线程交替执行同步代码块中的代码或轻微锁竞争的情况。
线程在执行同步块之前,JVM会先在当前的线程的栈帧中创建一个Lock Record,其包括一个用于存储对象头中的 mark word(官方称之为Displaced Mark Word)以及一个指向对象的指针。下图右边的部分就是一个Lock Record。
3.1.1 加锁过程
- 在线程栈中创建一个Lock Record,将其obj(即上图的Object reference)字段指向锁对象
- 直接通过CAS指令将Lock Record的地址存储在对象头的mark word中,如果对象处于无锁状态则修改成功,代表该线程获得了轻量级锁。如果失败,进入到步骤3
- 如果是当前线程已经持有该锁了,代表这是一次锁重入。设置Lock Record第一部分(Displaced Mark Word)为null,起到了一个重入计数器的作用。然后结束
- 走到这一步说明发生了竞争,则会自旋获取锁,如果自旋失败膨胀为重量级锁
3.1.2 解锁过程
- 遍历线程栈,找到所有obj字段等于当前锁对象的Lock Record
- 如果Lock Record的Displaced Mark Word为null,代表这是一次重入,将obj设置为null后continue
- 如果Lock Record的Displaced Mark Word不为null,则利用CAS指令将对象头的mark word恢复成为Displaced Mark Word。如果成功,则continue,否则膨胀为重量级锁
这里我们看一个问题,在轻量级锁存在竞争时,并不是立即升级为重量级锁,这是为什么?
膨胀为重量级锁会涉及到有用户态切换到内核态进行线程的休眠和唤醒操作,然后再切换到用户态,这些操作给系统的并发性能带来了很大的压力,共享数据的锁定状态可能只会持续很短的一段时间,为了这段时间去挂起和恢复线程并不值得,可以让后面请求锁的那个线程”稍等一下”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。为了让线程等待,只需要让线程执行一个忙循环(自旋),所以自旋会对CPU造成资源浪费,特别是长时间无法获取锁的情况下,所以自旋次数一定要设置成一个合理的值,而不能无限自旋下去。Java6默认是开启了自旋锁功能,而且对自旋次数也不再是固定值,而是通过一套优化机制进行自适应,简化了对自旋锁的使用。但同时需要注意的是,自旋在多处理器上才有意义,这理解也很简单:自旋是不会释放CPU资源的,在单处理器上如果某个线程处于自旋状态,也就意味着没有其它线程处于同时处于运行状态,也就在自旋期间不可能存在线程释放锁资源。所以,单处理上自旋是没有意义的。
总结一下轻量级锁:“轻量级”是相对于使用操作系统互斥量来实现的传统锁而言的。首先需要强调一点的是,轻量级锁并不是用来代替重量级锁的,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用产生的性能消耗。轻量级锁在申请锁资源时通过一个CAS操作即可获取,释放锁资源时也是通过一个CAS操作即可完成,CAS是一种乐观锁的实现机制,其开销显然要比互斥开销小很多,这就是轻量级锁提升性能的核心所在。但是,轻量级锁只是对无锁竞争并发场景下的一个优化,如果锁竞争激烈,轻量级锁不但有互斥开销,还要多一次CAS开销,这时轻量级锁比重量级锁性能更差。
3.2 偏向锁
Java是支持多线程的语言,为了保证线程安全,都会加入如synchronized这样的同步语义。但是在应用在实际运行时,很可能只有一个线程会调用相关同步方法。比如下面这个demo:
public class SyncDemo1 {
public static void main(String[] args) {
SyncDemo1 syncDemo1 = new SyncDemo1();
for (int i = 0; i < 100; i++) {
syncDemo1.addString("test:" + i);
}
}
private List<String> list = new ArrayList<>();
public synchronized void addString(String s) {
list.add(s);
}
}
在这个demo中为了保证对list操纵时线程安全,对addString方法加了synchronized的修饰,但实际使用时却只有一个线程调用到该方法,对于轻量级锁而言,每次调用addString时,加锁解锁都有一个CAS操作。
JVM工程师们经过研究发现:在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,所以Java6中为了提高一个对象在一段很长的时间内都只被一个线程持有场景下的性能,引入了偏向锁,在第一次获得锁时,会有一个CAS操作,之后该线程再获取锁,只会执行几个简单的命令,而不是开销相对较大的CAS命令。
当JVM启用了偏向锁模式(Java6以上默认开启),当新创建一个对象的时候,如果该对象所属的class没有关闭偏向锁模式,那新创建对象的mark word将是可偏向状态,此时mark word中的thread id(参见上面mark word格式)为0,表示未偏向任何线程,也叫做匿名偏向(anonymously biased)。
3.2.1 加锁过程
- 当该对象第一次被线程获得锁的时候,发现是匿名偏向状态,则会用CAS指令,将mark word中的thread id由0改成当前线程Id。如果成功,则代表获得了偏向锁,继续执行同步块中的代码。否则,将偏向锁撤销,升级为轻量级锁。
- 当被偏向的线程再次进入同步块时,发现锁对象偏向的就是当前线程,会往当前线程的栈中添加一条Displaced Mark Word为空的Lock Record中,然后继续执行同步块的代码,因为操纵的是线程私有的栈,因此不需要用到CAS指令;由此可见偏向锁模式下,当被偏向的线程再次尝试获得锁时,不需要像上面轻量级锁加锁步骤2那样进行一次cas操作,在这种情况下,synchronized关键字带来的性能开销基本可以忽略。
- 当其他线程进入同步块时,发现已经有偏向的线程了,则会进入到撤销偏向锁的逻辑里,一般来说,会在safepoint中去查看偏向的线程是否还存活,如果存活且还在同步块中则将锁升级为轻量级锁,原偏向的线程继续拥有锁,当前线程则走入到锁升级的逻辑里;如果偏向的线程已经不存活或者不在同步块中,则将对象头的mark word改为无锁状态(unlocked),之后再升级为轻量级锁
由此可见,偏向锁升级的时机为:当锁已经发生偏向后,只要有另一个线程尝试获得偏向锁,则该偏向锁就会升级成轻量级锁。当然这个说法不绝对,因为还有批量重偏向和批量撤销偏向机制。
3.2.2 解锁过程
当有其他线程尝试获得锁时,是根据遍历偏向线程的lock record来确定该线程是否还在执行同步块中的代码。因此偏向锁的解锁很简单,仅仅将栈中的最近一条lock record的obj字段设置为null。需要注意的是,偏向锁的解锁步骤中并不会修改对象头中的thread id,这样同一个线程下次进入同步代码块时,就不需要在通过CAS修改mark word的thread id了。
3.2.3 偏向锁优化
从上文偏向锁的加锁解锁过程中可以看出,当只有一个线程反复进入同步块时,偏向锁带来的性能开销基本可以忽略,但是当有其他线程尝试获得锁时,就需要等到safe point时将偏向锁撤销为无锁状态或升级为轻量级/重量级锁。偏向锁的撤销是有一定成本的,如果说运行时的场景本身存在多线程竞争的,那偏向锁的存在不仅不能提高性能,而且会导致性能下降。因此,JVM中增加了一种批量重偏向/撤销的机制。下面来看一下如下这两种场景:
- 场景1
一个线程创建了大量对象并执行了初始的同步操作,之后在另一个线程中将这些对象作为锁进行之后的操作。
public class TestSynchronized {
public static void main(String[] args) {
List<MyLock> myLockList = Stream.generate(MyLock::new).limit(60).collect(Collectors.toList());
System.out.println(myLockList);
for (MyLock lock : myLockList) {
new Thread(new ThreadA(lock)).start();
}
}
private static class ThreadA implements Runnable{
private MyLock lock;
public ThreadA(MyLock lock) {
this.lock = lock;
}
@Override
public void run() {
synchronized (lock) {
new Thread(new ThreadB(lock)).start();
}
}
}
private static class ThreadB implements Runnable {
private MyLock lock;
public ThreadB(MyLock lock) {
this.lock = lock;
}
@Override
public void run() {
synchronized (lock) {
System.out.println("ThreadB run");
}
}
}
}
这个例子中,ThreadA获取锁之后就做了一件事情,将锁对象传递给ThreadB并启动ThreadB,但是MyLock的偏向锁是偏向ThreadA的。ThreadB申请锁时,会触发偏向锁撤销的逻辑。这种情况下,一旦ThreadB启动,其实MyLock就不用偏向ThreadA了,如果能让MyLock偏向ThreadB,而不需要进行偏向锁撤销,肯定更合适。
- 场景2
存在明显多线程竞争的场景,比如生产者/消费者模式。
批量重偏向机制是为了解决第一种场景,批量撤销机制则是为了解决第二种场景。
3.2.3.1 批量重偏向
上面我们讲到,每个对象都有一个mark word,用于记录一些元数据。其实Class对象也有元数据,Class对象元数据里有一个epoch,为了区分对象mark word中的epoch,我们把Class对象的epoch记为Ec,还有个偏向计数器记为Bc,这个Bc 是类概念上的。每进行一次撤销偏向,Bc+1。每个对象头mark word也有一个epoch,我们记为E。
当 Bc=20时,系统认为该类的对象可能有不合适偏向锁的使用情况,这时候,会执行批量重偏向。具体操作是,将Class元数据上的Ec+1,并遍历所有线程的线程栈。因为当前是在全局安全点,所以该操作是安全的。找到所有线程栈中该Class的实例对象,如果还在锁着,就将该对象上的epoch设为Ec。结果就是所有还被占用着的偏向锁对象的E=Ec,此为E有效,而已经退出同步块的E!=Ec,此为E失效。对象mark word锁标志位为01 && 是否为偏向锁位为1(是) && E失效的条件下,当有线程申请锁时,即使该锁偏向其它线程,也可以直接通过CAS将mark word的Thread Id修改为当前申请锁的线程,而不用走线程撤销逻辑。
3.2.3.2 批量撤销
批量重偏向后,如果继续遇到撤销偏向锁操作,Bc继续+1,如果在一定时间间隔(25秒)内,并没有达到阈值,这表明批量重偏向起到了很好的效果,Bc将被清零重新开始。但如果达到了新的阈值Bc=40,表明该Class类型对象使用上有问题,不适合使用偏向锁模式,将执行批量撤销偏向。首先将Class元数据置为无锁(偏向锁不可偏向)模式,然后遍历所有线程的线程栈,将所有该Class的实例对象撤销偏向,使偏向锁不可偏向。
以上就是Java6对synchronized的优化,这里借鉴一下《Java并发编程的艺术》书中的总结,如下:
锁 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
偏向锁 | 加锁和解锁不需要额外的消耗,和执行非同步方法比仅存在纳秒级的差距 | 如果线程间存在锁竞争,会带来额外的锁撤销的消耗 | 适用于只有一个线程访问同步块场景(一个线程多次获取锁) |
轻量级锁 | 竞争的线程不会阻塞,提高了程序的响应速度 | 追求响应时间,锁占用时间很短(多个线程交替获取锁,或接近交替) | |
重量级锁 | 线程阻塞,响应时间缓慢 | 追求吞吐量,锁占用时间较长(锁竞争激烈) |
这里我觉得书中给出的轻量级锁的缺点和重量级锁的有点有些问题,这里给一下我自己的理解。我觉得轻量级锁最大的问题不是使用自旋,而是如果存在激烈的锁竞争,最终还是会升级为重量级锁,除了自旋的消耗外,还有锁升级的消耗。而重量级锁,底层依赖于操作系统的同步函数,在Linux中使用的是futex,而futex是有自旋机制的,所以我觉得书中讲的重量级锁不适用自旋有可能是不合适的。当然上面只是我自己的理解,如果有大佬比较清楚的话,希望可以解答一下。最后附上一张synchronized锁状态的流转图:
参考链接:
2. 《Java并发编程的艺术》