Synchorinzed底层语义
Java 虚拟机中的同步(Synchronization)基于进入和退出管程(Monitor)对象实现, 无论是显式同步(有明确的 monitorenter 和 monitorexit 指令,即同步代码块)还是隐式同步都是如此。在 Java 语言中,同步用的最多的地方可能是被 synchronized 修饰的同步方法。同步方法 并不是由 monitorenter 和 monitorexit 指令来实现同步的,而是由方法调用指令读取运行时常量池中方法的 ACC_SYNCHRONIZED 标志来隐式实现的,关于这点,稍后详细分析。下面先来了解一个概念Java对象头,这对深入理解synchronized实现原理非常关键。
一、首先我们要理解JAVA对象头和Monitor
在JVM中,对象在内存中的布局分为三块区域,对象头,实例数据和对齐填充。
1)实例变量:存放类的属性数据信息,包括父类的属性数据信息,如果是数组的实力部分还包括数组的长度,这部分按4字节对齐。
2)填充数据:由于虚拟机要求对象起始地址必须是8字节的整数倍,填充数据不是必须存在的,仅仅是为了字节对齐,这点稍微了解即可。
3)对象头:它是Sychronized锁的基础,一般而言Sychronized用的锁对象是存储在对象头中的,JVM采用2个字节在存储对象头,若对象是数组则会是3个字,多出来一个来记录数组长度。其主要结构是Mark Word和Class Metadata Address组成
虚拟机位数 | 头对象结构 | 说明 |
---|---|---|
32/64bit | Mark Word | 存储对象的hashCode、锁信息或分代年龄或GC标志等信息 |
32/64bit | Class Metadata Address | 类型指针指向对象的类元数据,JVM通过这个指针确定该对象是哪个类的实例。 |
其中Mark Word在默认情况下存储着对象的HashCode、分代年龄、锁标记位等以下是32位JVM的Mark Word默认存储结构
锁状态 | 25bit | 4bit | 1bit是否是偏向锁 | 2bit 锁标志位 |
---|---|---|---|---|
无锁状态 | 对象HashCode | 对象分代年龄 | 0 | 01 |
由于对象头的信息是与对象自身定义的数据没有关系的额外存储成本,因此考虑到JVM的空间效率,Mark Word 被设计成为一个非固定的数据结构,以便存储更多数据。
这里我们主要分析一下重量级锁也就是通常说synchronized的对象锁,锁标识位为10,其中指针指向的是monitor对象(也称为管程或监视器锁)的起始地址。每个对象都存在一个monitor与之关联,每当一个对象持有monitor后,monitor处于锁定状态。monitor有ObjectMonitor实现,由C++实现。
ObjectMonitor拥有两个队列,用来保存ObjectMonitor对象列表(每个等待锁的进程都会被封装成ObjectMonitor对象),当多个线程同时进入同步区时会进入EntryList队列,当线程执行wait方法时会进入WaitSet队列此时owner对象恢复为null且计数器count-1。当线程成功获得monitor锁时进入Owner区此时monitor中owner变量设置为当前线程并把count+1。
由此看来Synchronized锁便是通过这种方式获取锁的,也是为什么JAVA中任意对象可作为锁的原因,同时也是notify/notifyAll/wait等方法存在于定级定级对象Object中的原因。
二、有了上述基础后详细分析Synchronized语义实现
synchronized同步快底层原理
可以通过对同步代码块的反编译了解实现,从反编译字节码中得知同步语句块的实现是用monitorenter和monitorexit指令。其中monitorenter指令指向同步代码块的开始位置,monitorexit指向结束位置。当执行monitorenter时,当前线程试图获取objectref(即对象锁)所对应的monitor的持有权,当成功进入后,monitor中计数器count+1,取锁成功。如果当前线程已经拥有objectref的monitor持有权,重入时计数器count也会+1。倘若其他线程已经拥有objectref的monitor持有权,则当前线程进入阻塞状态,当monitorexit被执行,monitor锁被释放并充值计数器count。当monitorenter指令执行后,monitorexit指令一定会被执行,无论是否发生异常(自带异常处理)。
synchronized方法底层原理
方法的同步是隐式的,即无须通过字节码指令控制,它的实现在方法调用和返回操作中。JVM可以从方法常量池中的方法表结构的ACC_SYNCHORINZED访问标志区分一个方法是否是同步方法。当方法调用时,调用指令将会检查方法ACC_SYNCHORINZED访问标志是否被设置,若设置了,执行线程将先持有monitor,再执行方法,最后方法完成后释放monitor(无论是否正常退出)。在早期,Synchorinzed属于重量级锁,效率低下,因为监视器锁monitor是依赖底层操作系统的mutex lock来实现的,而操作系统实现线程之间的切换需要从用户态转换到核心态,这个状态转换需要很长时间,但是在JDK1.6之后,JVM对synchorinzed进行了优化,引入了偏向锁和轻量级锁。
JVM对synchronized的优化
锁的状态有私有,无状态锁,偏向锁,轻量级锁,重量级锁。随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级成重量级锁,锁的升级是单向的,锁只能升级不能降级。关于重量级锁前面已经详细分析过。
偏向锁:
JDK1.6后引入,它是一种对锁的优化手段,经过研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程获得,因此为了减少同一线程获取锁(会涉及到CAS操作,耗时)的代价而引入了偏向锁。偏向锁的核心思想就是如果一个线程获取了锁,那么该线程进入偏向模式,此时Mark word的锁结构变成偏向锁结构,当这个线程再次请求该锁时,无需任何同步操作即可获取锁,这样就减少了获取锁的代价,提高了程序性能。所以对于没有竞争的场合,偏向锁有很好的效果,但是对于竞争激烈的场合,偏向锁就失效了。因为这样的场合很有可能每次申请锁的的线程都是不同的,因此不应该使用偏向锁,否则得不偿失,需要注意的是,偏向锁失效后并不会马上膨胀为重量级锁,而是先升级为轻量级锁。
轻量级锁:
JDK1.6后引入,偏向锁失败后升级为轻量级锁,此时Mark Word的锁结构为轻量级锁结构。轻量级锁能够提升程序性能的依据是,对于绝大部分锁,在整个同步周期内都不存在竞争,即不存在竞争但不总是由同一线程获得,这是经验依据。需要了解的是,轻量级锁使用的场景是线程交替执行的同步快场合,如果存在同一时间访问同一同步快的场合,就会导致轻量级锁膨胀为重量级锁。本意是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能损耗(轻量级锁使用CAS操作)。在有竞争的情况下,除了互斥量开销外,还额外发生了CAS操作,轻量级锁比重量级锁更慢。
自旋锁:
JDK1.4后引入,只不过是默认关闭的,在JDK1.6后默认开启。自旋等待不能代替阻塞,自旋锁虽然避免了线程切换的开销,但它要占用处理器的执行时间,因此锁被占用的时间很短的情况,自旋锁的优化效果就很好。JDK1.6之后引入了自适应的自旋锁,在轻量级锁失败后,虚拟机为了避免线程真实地在操作系统挂起(为了避免操作系统从用户态到核心态的性能损耗),自旋锁会假设在不久将来,当前线程能成功获取锁,因此JVM会让当前想要获取锁的线程做几个空循环也就是自旋,自旋的时间由前一次在同一个锁上的自旋时间以及拥有者的状态来决定,即在同一个对象上,自旋等待刚刚成功获得锁,并且持有锁的线程正在运行中,那么虚拟机会认为这次自旋很可能会成功,那么允许自旋的时间较长,相反如果对于某个锁很少通过自旋成功获取,那么很可能会省略自旋过程,以避免浪费系统资源。
锁消除也是JVM对锁机制的优化,指虚拟机即使编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判定依据来源于逃逸分析的数据支持,如果判断在一段代码中,堆上所有数据都不会逃逸出去从而被其它线程访问到,那就可以把它当做栈上的数据对待,认为是线程私有的,同步加锁自然就无须进行。即运行对上下文的扫描,去除不存在共享资源竞争的锁,节省请求锁的消耗。
关于Sychronized的可重入性:
从互斥锁的设计上来说,当一个线程试图操作其他线程持有的对象锁的临界资源时,将会处于阻塞状态,但当一个线程再次请求自己持有的对象锁的临界资源时,这种情况属于重入锁,请求将会成功,在JAVA中Synchorinzed是基于原子性的内部锁机制,是可重入的,因此在一个线程调用Synchorinzed方法的同时在其方法体内部调用该对象的另一个Sychronized方法,是运行的,这就是Synchronized的可重入性。需要注意的是,子类也可通过可重入锁调用父类的同步方法,Synchronized是基于monitor实现的,因此每次重入,计数器count+1.
线程中断与synchronized:
线程中断:在运行run方法时打断它。在JAVA中提供了3个中断线程的方法:
1)实例方法 public void Thread.interrupt();//中断线程,设置线程中断标识为true
2)实例方法 public boolean Thread.isInterrputed();//判断线程是否被中断
3)静态方法 public static boolean Thread.interrputed();//判断线程是否被中断并清除中断状态
阻塞库方法,例如Thread.sleep和Object.wait等,都会检查线程的中断状态,并且在发现中断时提前返回。他们在响应中断时的执行操作:清除中断状态,抛出InterruptedException。中断操作不会真正的中断一个正在运行的线程,而是发出中断请求。然后由线程在一个合适的时刻中断自己。(这个时刻也被称为取消点)。通常,中断是实现取消的最合理方式。
可以简单总结一下中断两种情况,一种是当线程处于阻塞状态或者试图执行一个阻塞操作时,我们可以使用实例方法interrupt()进行线程中断,执行中断操作后将会抛出interruptException异常(该异常必须捕捉无法向外抛出)并将中断状态复位
(一旦方法抛出InterruptedException,当前调用该方法的线程的中断状态就会被jvm自动清除了,就是说我们调用该线程的isInterrupted 方法时是返回false),另外一种是当线程处于运行状态时,我们也可调用实例方法interrupt()进行线程中断,但同时必须手动判断中断状态,并编写中断线程的代码(其实就是结束run方法体的代码)。中断与synchronized
事实上线程的中断操作对于正在等待获取的锁对象的synchronized方法或者代码块并不起作用,也就是对于synchronized来说,如果一个线程在等待锁,那么结果只有两种,要么它获得这把锁继续执行,要么它就保存等待,即使调用中断线程的方法,也不会生效。
等待唤醒机制与synchronized
所谓等待唤醒机制本篇主要指的是notify/notifyAll和wait方法,在使用这3个方法时,必须处于synchronized代码块或者synchronized方法中,否则就会抛出IllegalMonitorStateException异常,这是因为调用这几个方法前必须拿到当前对象的监视器monitor对象,也就是说notify/notifyAll和wait方法依赖于monitor对象,在前面的分析中,我们知道monitor 存在于对象头的Mark Word 中(存储monitor引用指针),而synchronized关键字可以获取 monitor ,这也就是为什么notify/notifyAll和wait方法必须在synchronized代码块或者synchronized方法调用的原因。
需要特别理解的一点是,与sleep方法不同的是wait方法调用完成后,线程将被暂停,但wait方法将会释放当前持有的监视器锁(monitor),直到有线程调用notify/notifyAll方法后方能继续执行,而sleep方法只让线程休眠并不释放锁。同时notify/notifyAll方法调用后,并不会马上释放监视器锁,而是在相应的synchronized(){}/synchronized方法执行结束后才自动释放锁。