Java并发编程之synchronized详解

在Java多线程编程中,synchronized一直是元老级角色,很多人称呼它为重量级锁,但是随着Java SE1.6对其进行了各种优化之后,有些情况下它就不再那么重了,我们先看下利用synchronized实现同步的基础:Java中每一个对象都可以作为锁,具体表现为以下三种:

1、对于普通同步方法,锁是当前实例对象;

2、对于静态同步方法,锁是当前类的Class对象;

3、对于同步方法块,锁是synchronized括号里配置的对象。

synchronized在JVM里的实现都是基于进入和退出Monitor对象实现方法同步和代码块同步。先看下面这一段代码

public class SynchronizedTest {
	
	// synchronized修饰实例方法
	public synchronized void test1() {
		
	}
	
	// synchronized修饰静态方法
	public static synchronized void test2() {
		
	}
	
	// synchronized修饰同步代码块
	public void test3() {
		synchronized (this) {
			try {
				
			} catch (Exception e) {
				
			}
		}
	}
}

上述代码的每一个方法对应于synchronized实现的三种方式,利用javap工具来查看其生成的class文件信息来分析synchronizd的具体实现




从上面截图可以看出,同步代码块是使用monitorenter和monitorexit指令实现的,monitorenter指令插入在同步代码块的开始位置,当代码执行到该指令时,将会尝试获取该对象Monitor的所有权,即尝试获得该对象的锁,而monitorexit指令则插入在方法结束处和异常处,JVM保证每个monitorenter必须有对应的monitorexit。Java中任何一个对象都有一个Monitor与之关联,且当一个Monitor被持有后,它将处于锁定状态。

同步方法并不是由monitorenter和monitorexit指令来实现同步的,而是由方法调用指令读取运行时常量池中方法的 ACC_SYNCHRONIZED 标志来实现的。

synchronized用的锁是存在Java对象头里的,所以要深入了解synchronizd,需要先了解JVM中的对象头,它是实现synchronized的基础。

Java对象头

在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。

对象头:HotSpot虚拟机的对象头包括两部分信息,第一部分用于存储对象自身运行时数据,如哈希吗、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。这部分数据长度在32位和64位虚拟机中分别为32bit和64bit,称为“Mark Word”。对象头的另外一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

Java对象头一般占有两个机器码(在32位虚拟机中,1个机器码等于4字节,也就是32bit),但是如果对象是数组类型,则需要三个机器码,因为JVM可以通过Java对象的元数据信息确定Java对象的大小,但是无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度。

实例数据:实例数据部分是对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容,无论是从父类集成下来的,还是在子类中定义的,都需要记录下来。

对齐填充:对齐填充并不是必然存在的,它仅仅起着占位符的作用,由于HotSpot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,因此,当实例对象数据部分没有对齐时,就需要通过对齐填充来补全。

Java对象头是实现锁的关键,我们重点分析它。下面是32位HotSpot虚拟机的Mark Word默认存储结构

锁状态 25bit 4bit 1bit 是否是偏向锁 2bit 锁标志位
无锁状态 对象HashCode 对象分代年龄 0 01

由于对象头的信息是与对象自身定义的数据没有关系的额外存储成本,因此考虑到虚拟机的空间效率,Mark Word 被设计成为一个非固定的数据结构,以便在极小的空间内存储尽量多的信息,它会根据对象本身的状态复用自己的存储空间,如32位HotSpot虚拟机下,除了上述列出的Mark Word默认存储结构外,还有如下可能变化的结构

锁状态

25bit

4bit

1bit

2bit

23bit

2bit

是否是偏向锁

锁标志位

轻量级锁

指向栈中锁记录的指针

00

重量级锁

指向互斥量(重量级锁)的指针

10

GC标记

11

偏向锁

线程ID

Epoch

对象分代年龄

1

01

其中轻量级锁和偏向锁是Java SE1.6 对 synchronized 锁进行优化后新增加的,稍后我们会简要分析。这里我们主要分析一下重量级锁,即synchronized的对象锁,锁标识位为10,其中指针指向的是monitor对象(也称为管程或监视器锁)的起始地址。每个对象都存在着一个monitor与之关联,对象与其monitor之间的关系有存在多种实现方式,如monitor可以与对象一起创建销毁或当线程试图获取对象锁时自动生成,当一个monitor被某个线程持有后,它便处于锁定状态。在HotSpot虚拟机中,monitor是由ObjectMonitor实现的,其主要数据结构如下(objectMonitor.hpp

ObjectMonitor() {
    _header       = NULL;
    _count        = 0;
    _waiters      = 0,
    _recursions   = 0;
    _object       = NULL;
    _owner        = NULL;
    _WaitSet      = NULL;
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;
    FreeNext      = NULL ;
    _EntryList    = NULL ;
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
}

ObjectMonitor对象中有两个队列:

_WaitSet :处于wait状态的线程,会被加入到wait set。

_EntryList:处于等待锁block状态的线程,会被加入到entry set。

当线程获取到对象的monitor后进入_owner区域并把ObjectMonitor中的_owner变量设置为当前线程,同时ObjectMonitor中的计数器_recursions加1,_recursions初始值为0。若线程调用wait() 方法,将释放当前持有的ObjectMonitor,_owner变量恢复为null,_recursions自减1,同时该线程进入_WaitSet集合中等待被唤醒。若当前线程执行完毕也将释放ObjectMonitor (锁)并复位变量的值,以便其他线程进入获取ObjectMonitor (锁)。如下图所示


在Java早期版本中,synchronized属于重量级锁,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的Mutex Lock来实现的,而操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高;而且,线程的互斥同步是通过阻塞实现的,挂起线程与恢复线程的操作也都需要转入内核态中完成,给系统的并发性能带来很大压力,这也是为什么早期的synchronized效率低的原因。

Java SE1.6之后,为了减少获得锁和释放锁所带来的性能消耗,虚拟机开发团队对synchronized锁进行了多种优化,接下来我们来了解一下他们在虚拟机层面对synchronized锁的优化。

synchronized锁优化

高效并发是从JDK1.5到JDK1.6的一个重要改进,HotSpot虚拟机开发团队在这个版本上花费了大量的精力去实现各种锁优化技术。如适应性自旋、锁消除、锁粗化、轻量级锁和偏向锁等,这些技术都是为了在线程之间更高效地共享数据,以及解决竞争问题。Java SE1.6里锁一共有四种状态,无锁状态,偏向锁状态,轻量级锁状态和重量级锁状态,它会随着竞争情况逐渐升级。锁可以升级但不能降级。

自旋锁与适应性自旋

虚拟机开发团队注意到在许多应用上,共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起和恢复线程并不值得,这时,我们就可以让后面请求锁的线程“稍等一下”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。为了让线程等待,我们只需要让线程执行一个忙循环(自旋),这就是所谓的自旋锁。

自旋等待本身虽然避免了线程切换的开销,但它是要占用处理器时间的,因此,如果,自旋等待的时间很短,那自旋等待的效果就很好,否则,自旋的线程就只会白白消耗处理器资源,反而会带来性能上的浪费。因此,自旋等待必须要有一定的限度,如果超过限定的自旋次数仍没有成功获取锁,就应当使用传统的方式去挂起线程。

Java SE1.6引入了自适应的自旋锁。自适应意味着自旋的时间不在固定了,而是由前一次在同一个锁上的自旋时间以及锁的拥有者的状态来决定。线程如果自旋成功了,那么下次自旋的次数会更加多,因为虚拟机认为既然上次成功了,那么此次自旋也很有可能会再次成功,那么它就会允许自旋等待持续的次数更多。反之,如果对于某个锁,很少有自旋能够成功的,那么在以后要或者这个锁的时候自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源。

有了自适应自旋锁,随着程序运行和性能监控信息的不断完善,虚拟机对程序锁的状况预测会越来越准确,虚拟机会变得越来越聪明。

锁消除

锁消除是指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判定依据来源于逃逸分析的数据支持,如果判断在一段代码中,堆上的所有数据都不会逃逸出去从而被其他线程访问到,那就可以把它们当做栈上数据对待,认为它们是线程私有的,自然就不需要同步加锁了。

如下述代码

public String concatString(String s1, String s2, String s3) {
    StringBuffer stringBuffer = new StringBuffer();
    stringBuffer.append(s1);
    stringBuffer.append(s2);
    stringBuffer.append(s3);

    return stringBuffer.toString();
}

每一个StringBuffer.append()方法中都有一个同步块,锁就是stringBuffer对象,虚拟机观察到stringBuffer的作用域被限制在concatString()方法内部。即stringBuffer的所有引用都不会逃逸到该方法之外,其他线程无法访问到它,因此,虽然这里有锁,但是可以被安全的消除掉。

锁粗化

我们知道在使用同步锁的时候,需要让同步块的作用范围尽可能小,仅在共享数据的实际作用域中才进行同步,这样做的目的是为了使需要同步的操作数量尽可能缩小,如果存在锁竞争,那么等待锁的线程也能尽快拿到锁。

大部分情况下,上述原则都是正确的,但是如果一系列的连续操作都是对同一个对象反复进行加锁和解锁,即使没有线程竞争,但是频繁的进行互斥同步操作也会带来不必要的性能消耗,所以引入锁粗化的概念,就是将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。如锁消除中的代码,可以将锁扩展到第一个append()方法之前直至最后一个append()方法之后,这样,只需要加锁一次就可以了。

轻量级锁

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

获取锁

1、在代码进入同步块时,如果此对象没有被锁定(锁标志位为01),则虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝(官方把这份拷贝加了一个Displaced前缀,即Displaced Mark Word);若此对象已经被锁定,则转至步骤3。

2、虚拟机使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,如果成功表示竞争到锁,则将锁标志位变成00(表示此对象处于轻量级锁状态),执行同步操作;若竞争失败,则转至步骤3。

3、虚拟机检查对象的Mark Word是否指向当前线程的栈帧,如果是则表示当前线程已经持有当前对象的锁,则直接执行同步代码块;否则只能说明该锁对象已经被其他线程抢占了,这时轻量级锁需要膨胀为重量级锁,锁标志位变成10,后面等待的线程将会进入阻塞状态。

释放锁

它的解锁过程也是通过CAS操作来完成的。

1、用CAS操作将当前对象的Mark Word和线程中的Displaced Mark Word替换回来,若替换成功,则整个同步过程就完成了,否则执行步骤2。

2、如果CAS操作替换失败,说明有其他线程尝试获取该锁,则需要在释放锁的同时需要唤醒被挂起的线程。

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

下图是轻量级锁的获取和释放过程 


偏向锁

偏向锁的目的是消除数据在无竞争情况下的同步原语,进一步提升程序的运行性能。轻量级锁的加锁解锁操作是需要依赖多次CAS原子操作的,那偏向锁就是在无竞争的条件下,通过减少CAS操作的次数来获取锁。

获取锁

1、当锁对象第一次被线程获取时,将锁标志位设为01,即偏向模式。

2、使用CAS操作将线程ID记录到对象的Mark Word中,若CAS操作成功,则持有偏向锁的线程以后每次进入同步块时,虚拟机都不会进行任何同步操作;否则,执行步骤3。

3、通过CAS竞争锁失败,证明当前存在多线程竞争情况,当到达全局安全点,获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码块。

释放锁

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

1、暂停拥有偏向锁的线程,判断锁对象石是否还处于被锁定状态。

2、撤销偏向锁,恢复到无锁状态(01)或者轻量级锁的状态。

下图是偏向锁的获取和释放流程 


参考资料

周志明:《深入理解Java虚拟机》

方腾飞:《Java并发编程的艺术》

Java中synchronized的实现原理与应用

猜你喜欢

转载自blog.csdn.net/qq_38293564/article/details/80409861