并发编程:Synchronized底层实现&锁升级

synchronized是Java中加锁的关键字,它可以用来修饰实例方法、静态方法以及代码块。值得注意的是,synchronized是一个对象锁,也就是它锁的是一个对象,因此无论使用哪一种方法,synchronized都需要一个锁对象。

如果某一个资源被多个线程共享,为了避免因为资源抢占导致资源数据错乱,我们需要对线程进行同步,在Java中,synchronized 就是实现线程同步的关键字。使用synchronized关键字,拿到Java对象的锁,保护锁定的代码块,JVM保证同一时间只有一个线程拿到这个Java对象的锁,从而达到保证线程安全的目的。

1. synchronized的作用

synchronized可以同时保证并发操作的原子性、可见性和有序性。

  • 原子性

原子性是指一个或多个操作,要么全部执行并且执行过程中不会被任何因素打断,要么都不执行。synchronized修饰的类或对象的操作都是原子的,因为在执行操作之前会获得类或对象的锁,知道操作执行完毕后才会释放锁。

  • 可见性

可见性是指当一个线程去修改某个共享变量的时候,这个修改对其他所有线程都是立即可见的。synchronizedvolatile都保证可见性,其中synchronized对一个类或对象加锁时,一个线程如果要访问该类或对象必须要获得它的锁,而这个锁状态对其他线程都是可见的,并且在锁释放之前会将修改的变量刷新到主内存中,从而保证资源的可见性。

  • 有序性

有序性是指程序完全按照代码的先后顺序执行。synchronizedvolatile都保证有序性,Java允许编译器和处理器对指令进行「重排」,但「指令重拍」并不会影响单线程的执行顺序,它影响的是多线程并发执行的顺序。synchronized保证了每个时刻只有一个线程访问同步代码块,也就确定了多线程执行同步代码块是分先后顺序的,从而保证了有序性。

2. synchronized的使用

synchronized关键字可以用来修饰三个地方:

  • 修饰实例方法,锁对象是当前的this对象
  • 修饰静态方法,锁对象是方法区中的类对象,是一个全局锁。
  • 修饰代码块,指定加锁对象,也就是synchronized(object){},锁对象是()中的对象。

针对synchronized修饰的地方不同,实现的原理不同。

2.1 synchronized修饰实例方法

1
2
3
4
5
6
public class SyncTest {

public synchronized void sync() {
System.out.println("sync method");
}
}

通过javap -v SyncTest.class查看反编译后的结果:

从反编译后的结果,我们可以看到sync()方法多了一个ACC_SYNCHRONIZED标识,JVM就是根据ACC_SYNCHRONIZED标识符来实现对象方法的同步

当方法被执行时,JVM调用指令检查该方法上是否设置了ACC_SYNCHRONIZED标识,如果设置了则会获取锁对象的monitor对象,线程执行完方法体后,会释放锁对象的monitor对象。在此期间,其他线程无法获取锁对象的monitor对象。

2.2 synchronized修饰静态方法

1
2
3
4
5
6
public class SyncTest {

public static synchronized void sync() {
System.out.println("sync method");
}
}

反编译后的结果:

可以看到和修饰实例方法相同,也是在sync()方法上多了一个ACC_SYNCHRONIZED标识,可以得出synchronized修饰实例方法和静态方法的实现原理相同,都是通过ACC_SYNCHRONIZED标识符来实现的。但二者的差别在于锁的对象不同,修饰实例方法锁的对象为当前类的实例(this)对象;而修饰静态方法锁的对象则为当前类(Class)对象。

一个类的A对象实例访问这个类的B对象实例正在执行的非静态synchronized方法,是被允许的,因为非静态synchronized方法锁住的是对象实例,对于两个不同的对象实例,synchronized锁住的对象不同。然而当一个类的A对象实例试图访问这个类的B对象实例正在执行的静态synchronized方法,这是不允许的,因为对于由synchronized修饰的静态方法锁住的是Class对象,A实例和B实例是同一个Class对象,相当于被同一把「锁」锁住了,因此只允许同步访问。

如果一个线程 A 调用一个实例对象的非静态 synchronized 方法,而线程 B 需要调用这个实例对象所属类的静态 synchronized 方法,是允许的,不会发生互斥现象,因为访问静态 synchronized 方法占用的锁是当前类的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象锁

2.3 synchronized修饰代码块

1
2
3
4
5
6
7
8
public class SyncTest {

public void sync() {
synchronized (this) {
System.out.println("sync method");
}
}
}

字节码信息:

我们可以看到在synchronized修饰的代码块起止位置生成了monitorentermonitorexit指令:

  • monitorenter:该指令表示获取对象的monitor对象,这时monitor对象中的count会+1,如果monitor对象被其他线程所获取,该线程会被阻塞,直到count=0,再重新获取monitor对象。
  • monitorexit:该指令表示线程释放锁对象的monitor对象,这时monitor对象的count会-1变为0,其他阻塞的线程可以重新尝试获取锁对象的monitor对象。

synchronized关键字是如何对一个对象加锁实现代码同步的呢?如果想弄清楚,那就不得不先了解一下Java对象的对象头了。

3. Java对象头和Monitor对象

在JVM中,对象在内存中的布局可以分为三个区域:对象头、实例数据以及填充数据。

  • 实例数据:存放类的属性信息,包括父类的属性信息,这部分按4字节对齐。

  • 填充数据:由于虚拟机要求对象起始地址必须是8字节的整数倍,填充数据不是必须存在的,仅仅是为了字节对齐。

  • 对象头:在HotSpot虚拟机中,对象头又被分为两部分:Mark Word(标记字段)和Class Point(类型指针)。如果是数组,还会有数组长度。对象头是本次讨论的重点,下面详细展开。

3.1 对象头

在对象头的Mark Word中存储了对象自身的运行时数据,如哈希码、GC分代年龄、锁状态、线程持有的锁、偏向线程ID及偏向时间戳等。同时,Mark Word也记录的对象和锁有关的信息。

当对象被synchronized当成同步锁时,和锁相关的一系列操作都与Mark Word有关。由于JDK1.6版本中对synchronized进行了锁优化,引入了偏向锁和轻量级锁(关于锁优化后面详细讨论)。Mark Word在不同锁状态下存储的内容有所不同,我们以32位虚拟机中对象头的存储内容为例:

从图中我们可以清除地看到,Mark Word中有2bit的数据来标记锁的状态信息。无锁和偏向锁标记状态为01,轻量级锁为00,重量级锁为10。

  • 当状态为偏向锁时,Mark Word存储了偏向锁的线程ID。
  • 当状态为轻量级锁时,Mark Word存储了指向线程栈中Lock Record的指针。
  • 当状态为重量级锁时,Mark Word存储了指向堆中Monitor对象的指针。

当前我们只讨论重量级锁,因为重量级锁相当于对synchronized优化之前的状态。关于偏向锁和轻量级锁在后边锁优化章节中详细讲解。

可以看到,当为重量级锁时,对象头的MarkWord中存储了指向Monitor对象的指针。那么Monitor又是什么呢?

3.2 Monitor对象

Monitor对象被成为管程或监视器锁。在Java中,每个对象都关联了一个Monitor对象,这个Monitor对象既可以与对象一起创建销毁,也可以在线程试图获取对象锁时生成。当这个Monitor对象被线程持有后,它便处于锁定状态。

在HotSpot虚拟机中,Monitor是由ObjectMonitor实现的,它是一个使用c++实现的类,主要数据结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
ObjectMonitor() {
_header = NULL;
_count = 0; //记录个数
_waiters = 0,
_recursions = 0; // 线程重入次数
_object = NULL;
_owner = NULL;
_WaitSet = NULL; // 调用wait方法后的线程会被加入到_WaitSet
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ; // 阻塞队列,线程被唤醒后根据决策判读是放入cxq还是EntryList
FreeNext = NULL ;
_EntryList = NULL ; // 没有抢到锁的线程会被放到这个队列
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}

ObjectMonitor中有五个重要部分,分别为_owner, _WaitSet, _cxq, _EntryList和count。

  • _owner用来指向持有Monitor的线程,它的初始值为Null,表示当前没有任何线程持有Monitor。当一个线程成功持有该锁后会保存线程ID,等到线程释放锁后_owner又会重置为Null。
  • _WaitSet调用锁对象的wait方法后的线程会被加入到_WaitSet
  • _cxq是一个阻塞队列,线程被唤醒后根据决策判断是放入cxq还是EntryList
  • _EntryList没有抢到锁的线程会放到这个队列。
  • count用于记录线程获取锁的次数,成功获取锁count会+1,释放锁会-1。

如果线程获取到对象的Monitor后,会将Monitor中的_owner设置为该线程的ID,并且count会+1;如果调用了锁对象的wait()方法,线程会释放当前持有的Monitor,并将_owner置为null,且count减一,同时该线程会进入到_WaitSet集合中等待被唤醒。

注意_WaitSet, _cxq, _EntryList都是链表结构,存放的是封装了线程的ObjectMonitor对象。

在多条线程竞争Monitor锁时,所有没有竞争到锁的线程都会被封装为ObjectMonitor并加入到_EntryList队列。当一个已经获得锁的线程调用对象的wait()方法后,线程也会被会被封装为ObjectMonitor并加入到_WaitSet队列中。当调用线程的notify()方法后,会根据不同情况来决定是将_WaitSet中的元素转移到_cxq队列还是_EntryList队列。等到获得锁的线程释放锁后,会根据不同条件执行_EntryList中的线程或者将_cxq转移到_EntryList再执行EntryList中的线程。

所以,可以看出_WaitSet中存放的是处于WAITING状态等待被唤醒的线程;而_EntryList队列中存放的是等待锁BLOCKED状态的线程。_cxq队列仅仅是用来临时存放,最终还是会被转移到EntryList队列中等待获取锁。

了解了对象头和Monitor,那么synchronized是如何利用Monitor进行工作的?

4. synchronized同步原理

从上面synchronized放置的位置不同可以得出,synchronized用来修饰方法时,是通过ACC_SYNCHRONIZED标识符来保持线程同步的。而用来修饰代码块时,是通过monitorentermonitorexit指令来完成。

  • monitorenter:每个对象都是一个监视器锁(monitor)。当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权,过程如下:
  1. 如果monitor的count为0,则该线程进入Monitor,然后count变为1,该线程即为monitor对象的持有者。
  2. 如果线程已占有monitor,只是重新进入,则count加1。
  3. 如果其他线程占有了该monitor,则当前线程获取锁失败进入阻塞状态并加入到_EntryList中,直到等待的锁释放,再重新尝试获取monitor的所有权。
  • monitorexit:执行monitorexit的线程必须是所对应的monitor的持有者者。指令执行时,monitor的count减1,如果减1后count为0,那线程退出monitor,不再是这个monitor的所有者。其他被这个monitor阻塞的线程可以尝试去获取这个 monitor 的所有权。

注:monitorexit指令出现了两次,第1次为同步正常退出释放锁;第2次为发生异步退出释放锁;

  • ACC_SYNCHRONIZE:当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先获取 monitor,获取成功之后才能执行方法体,方法执行完后再释放monitor。在方法执行期间,其他任何线程都无法再获得同一个monitor对象。

两种同步的方式本质上没有区别,都是通过JVM调用操作系统的互斥锁mutex来实现的,被阻塞的线程会被挂起,等待重新调度,会导致「用户态」和「内核态」的切换,对性能影响比较大。

通过上面的描述,我们可以知道synchronized的语义底层都是通过Monitor对象来实现的,其实wait()/notify()方法也依赖于Monitor对象,这就是为什么只能在同步方法或代码块中调用,否则会抛出java.lang.IllegalMonitorStateException的异常。

5. 锁升级

在JDK6之前,Monitor的实现完全依赖于操作系统内部的「互斥锁」来实现的,因为需要进行用户态到内核态的切换,所以其同步操作

是一个无差别的重量级锁操作;JDK6版本中,Java官方从JVM层面对synchronized进行了优化,提供了三种不同的Monitor实现,也就是常说的三种不同锁:偏向锁、轻量级锁、重量级锁。

随着锁的竞争变激烈,锁的状态会出现一个升级的过程。即可以从偏向锁升级到轻量级锁,再从轻量级锁升级为重量级锁。注意,锁升级的过程是不可逆的,即一旦锁升级为重量级锁就不会降级为轻量级锁。

5.1 偏向锁

大多数情况下锁不仅不存在多线程竞争关系,而且大多数时候都是同一线程多次获得,因此,为了减少同一线程获取锁的代价而引入了「偏向锁」的概念。

获取锁:

  1. 检测Mark Word是否为可偏向状态,即判断对象头第30个bit的位置是否为偏向锁1,锁标志位为01;
  2. 若为可偏向状态,则测试Mark Word中的线程ID是否为当前线程ID,如果是则执行步骤(5),否则执行步骤(3);
  3. 如果线程ID不是当前线程ID,则通过CAS竞争锁,竞争成功则将Mark Word的线程ID替换为当前线程ID,否则执行步骤(4);
  4. 通过CAS竞争锁失败,说明当前存在多线程竞争的情况,当达到全局安全点,获得偏向锁的线程会被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码;
  5. 执行同步代码。

**释放锁:**偏向锁的释放采用了一种只有竞争才会释放锁的机制,线程不会主动释放偏向锁,需要等待其他线程来竞争。偏向锁的撤销需要等待全局安全点(这个时间点上没有正在执行的代码)。其步骤如下:

  1. 暂停拥有偏向锁的线程,判断锁对象是否还处于被锁定状态;
  2. 撤销偏向锁,恢复到无锁状态(1)或者升级为轻量级锁的状态。

在有锁状态下,位置被锁指针占用,那么hashCode等信息存放在哪里?(如图Mark Word内存布局

答案:

  1. 当一个对象已经计算过identity hash code,它就无法进入偏向锁状态;
  2. 当一个对象当前正处于偏向锁状态,并且需要计算其identity hash code的话,则它的偏向锁会被撤销,并且锁会膨胀为重量锁;
  3. 重量锁的实现中,ObjectMonitor类里有字段可以记录非加锁状态下的mark word,其中可以存储identity hash code的值。或者简单说就是重量锁可以存下identity hash code。

注意:这里讨论的hash code都只针对identity hash code。用户自定义的hashCode()方法所返回的值跟这里讨论的不是一回事。Identity hash code是未被覆写的 java.lang.Object.hashCode() 或者 java.lang.System.identityHashCode(Object) 所返回的值。

5.2 轻量级锁

引入轻量级锁的主要目的是在多线程竞争不激烈的情况下,通过CAS竞争锁,减少传统重量级锁使用操作系统互斥产生的性能消耗。当关闭偏向锁功能或者多个线程竞争偏向锁导致偏向锁升级为轻量级锁,则会尝试获取轻量级锁,其步骤如下:

获取锁:

  1. 判断当前对象是否处于无锁状态(偏向锁标志位为0,锁状态我01),若是,则JVM首先将当前线程的栈帧中建立一份名为锁记录(Lock Record)的空间,用于存储锁对象目前Mark Word的拷贝(官方把这份拷贝加了一个Displaced前缀,即Displaced Mark Word);否则执行步骤(3);
  2. JVM利用CAS操作尝试将对象的Mark Word指向为Lock Record的指向,如果成功则表示竞争到锁,则将锁标志位变为00(表示此对象处于轻量级锁状态),执行同步操作;如果失败,则执行步骤(3);
  3. 判断当前对象的Mark Word是否指向当前线程的栈帧,如果是则表示当前线程已经持有当前锁,则直接执行同步代码;否则只能说该锁对象已经被其他线程抢占了,这是轻量级锁需要膨胀为重量级锁,锁标志位变为10,后面的线程将会进入阻塞状态。

**释放锁:**轻量级锁也是通过CAS来释放的,具体步骤:

  1. 取出轻量级锁保存在Displaced Mark Word中的数据;
  2. 用CAS操作取出数据替换当前Mark Word中的数据,如果成功则表示释放锁成功,否则执行(3);
  3. 如果CAS替换操作失败,说明有其他线程尝试获取锁,则需要在释放锁的同时唤醒被挂起的线程。

对于轻量级锁,其提升性能的依据是”对于绝大部分锁,在整个生命周期内是不存在竞争的“,如果打破了这个依据则除了互斥的开销外,还有额外的CAS操作,因此在有多线程锁竞争的情况下,轻量级锁会比重量级锁更慢。

自旋锁:

轻量级获取锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。

自旋锁是基于在大多数情况下,线程持有锁的时间都不会太长。如果直接挂起操作系统层面的线程可能会得不偿失,毕竟操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高。因此自旋锁会假设在不久将来,当前的线程可以获得锁,因此虚拟机会让当前想要获取锁的线程做几个空循环(这也是称为自旋的原因),不断的尝试获取锁。

在jdk1.6以前,默认轻量级锁自旋次数是10次,如果超过这个次数或自旋线程数超过CPU核数的一半,就会升级为重量级锁。这时因为如果自旋次数过多,或过多线程进入自旋,会导致消耗过多cpu资源,重量级锁情况下线程进入等待队列可以降低cpu资源的消耗。自旋次数的值也可以通过jvm参数进行修改:

1
-XX:PreBlockSpin

jdk1.6以后加入了自适应自旋锁Adapative Self Spinning),自旋的次数不再固定,由jvm自己控制,由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定:

  • 对于某个锁对象,如果自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而允许自旋等待持续相对更长时间
  • 对于某个锁对象,如果自旋很少成功获得过锁,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。

5.3 重量级锁

重量级锁是由轻量级锁升级而来,当同一时间有多个线程竞争锁时,锁就会被升级成重量级锁,此时其申请锁带来的开销也就变大。重量级锁一般使用场景会在追求吞吐量,同步块或者同步方法执行时间较长的场景。

前面讲到synchronized是通过对象内部的监视器锁(Monitor)来实现的(实现原理在第四节已经详细介绍过了)。但是监视器锁本质又是依赖于底层的操作系统的Mutex Lock来实现的。而操作系统实现线程之间的切换这就需要从用户态转换到内核态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么synchronized效率低的原因。因此,这种依赖于操作系统Mutex Lock所实现的锁我们称之为重量级锁。