并发是对计算机资源的压榨,再增加资源使用率的同时也会带来一些问题,即线程安全如何保障,这就引入了互斥同步的概念。
同步指在多个线程并发访问共享数据时,保证共享数据在同一个时刻只能被一条线程使用,而互斥是实现同步的方法,临界区、互斥量和信号量都是实现互斥的方式。
在Java中是先互斥同步的手段是synchronized关键字。该关键字有三种主要的使用方式
synchronized不同的使用方式在JVM中的实现方式并不一样。
当synchronized修饰实例方法或者静态方法时,代码在经过编译后,被修饰的方法标志位上会有ACC_SYNCHRONIZED修饰。
当synchronized修饰同步代码块时,代码在经过编译后,会在同步快的前后分别形成monitorenter和monitorexit两个字节码指令,在执行monitorenter指令时,会尝试获取对象的锁,如果没有锁定,或者当前线程已经持有了这个对象的锁,就会把锁的计数器的值+1,当执行monitorexit指令时,会将该锁的计数器-1,一旦计数器的值为0,锁随即就被释放。这也是可重入的概念。如果获取对象的锁失败,当前线程就被阻塞等待。
从执行成本角度看,java的线程是映射到操作系统的原生内核线程之上的,如果要阻塞或者唤醒一条线程,需要操作系统来帮忙完成,这将会陷入用户态转到核心态的转换中,这种转换需要耗费很多的处理器时间。为了减少synchronized的同步成本,JDK6之后进行了锁优化。
互斥同步对性能最大的影响是阻塞的实现,挂起线程和恢复线程的操作需要转到内核态完成。研究人员发现在许多应用上,共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起和恢复线程是不值得的,此时,我们让后面请求锁的那个线程执行一个忙循环(自旋),不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。虽然自旋锁避免了线程切换的开销,但是仍占据着处理器,因此自旋的等待时间必须有一定的限度,自选的时间由JVM根据之前在锁上自旋的时间以及锁拥有者的状态来决定。超过自旋时间仍需使用传统挂起与恢复操作。
锁消除主要判定来源于逃逸分析,如果判定到一段代码中,在队上的所有数据都不会逃逸出去并被其他线程访问到,那么就可以把它们当作栈上的数据来对待,认为是线程私有的,同步加锁自然无需进行。
如果一系列的连续操作都对同一个对象反复的加锁和解锁,甚至是加锁操作是出现在循环体之中,JVM会把加锁同步的范围扩展到整个操作序列的外部。
优化后synchronized锁的分类级别从低到高依次是:
首先介绍一下Java的对象头,这一内容在java内存布局中有简单的介绍过一次。
在 HotSpot 虚拟机中,对象在内存中的布局分为三块区域:对象头,实例数据和对齐填充。而对象头中又分为两部分:Mark Word 和指向方法区中对象类型数据的指针。
Mark Word用于存储对象自身的运行时数据,比如哈希码、GC分代年龄等。下面以32为操作系统为例。
由于对象头信息是与对象自身定义的数据无关的额外存储成本,为了增加JVM的空间使用率,这部分空间将会被复用。
对象未被锁定时,对象头中前25bit存储的是对象的hash code,后面4bit存储的GC分代年龄,之后1bit存储偏向模式,未锁定状态为0,还有两bit为锁的标志位,01表示未锁定。
经验表明,同一块同步代码块中,大部分情况下只有一个线程会进入。
当第一个线程获得这个锁时,对象将会进入偏向状态,这个时候,虚拟机会将锁对象头中的标志位设置为01,偏向模式设置为1,表示进入偏向模型,并使用CAS操作把获取这个锁的线程ID记录在对象的Mark Word中,如果CAS操作成功,持有偏向锁的线程易购每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作(加锁、解锁及对Mark Word的更新操作)。
当出现另外一个线程去尝试获取这个锁时,对象发现其存储的线程ID与当前的线程ID不同,偏向模式马上结束,此时分为两种情况
如果锁对象目前未被锁定,将会撤销偏向,锁对象变为未锁定状态,并且后续不再允许偏向模式。
如果锁对象目前被锁定了,那么锁对象将会进入轻量锁状态。
使用轻量级锁的依据是:对于绝大部分的锁,在整个同步周期内都是不存在竞争的。
在代码即将进入同步块的时候, 如果此同步对象没有被锁定(锁标志位为“01”状态) , 虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record) 的空间, 用于存储锁对象目前的Mark Word的拷贝(官方为这份拷贝加了一个Displaced前缀, 即Displaced Mark Word)。之后虚拟机将尝试使用CAS操作把对象的Mark Word更新为指向锁记录的指针。如果CAS操作成功,代表该线程拥有了这个对象的锁,将锁对象Mark Word中的锁标志位改为00。
如果CAS更新操作失败,那就意味着还存在至少一条线程与当前线程竞争获取该对象的锁,如果对象的Mark Word指向的是当前线程的栈帧,那么说明当前线程已经拥有了这个锁,直接进入同步块继续执行;否则说明锁对象已经被其他线程抢占了,当前线程便尝试使用自旋来获取锁,当竞争线程的自旋次数 达到界限值,轻量级锁将会膨胀为重量级锁。。如果出现两条以上的线程争抢同一个锁,那么轻量锁就不太有效,将会膨胀为重量级锁,锁标志的状态值为10,此时Mark Word中存储的是指向重量级锁的指针,后面等待锁的线程将必须进入阻塞状态。
轻量级锁解锁过程为使用CAS操作把对象当前的Mark Word和线程中复制的Displaced Mark Word替换回来。如果替换成功,整个同步过程就会顺利完成;如果替换失败,说明有其他线程尝试过获取锁,在释放锁的童话,唤醒被挂起的线程。
由于轻量级锁了除了互斥量本身的开销外,还额外发生了CAS操作的开销,因此在有竞争的情况下,轻量级锁反而比传统的重量级锁更慢。
重量级锁,是使用操作系统互斥量(mutex
)来实现的传统锁。 当所有对锁的优化都失效时,将退回到重量级锁。它与轻量级锁不同竞争的线程不再通过自旋来竞争线程, 而是直接进入堵塞状态,此时不消耗CPU,然后等拥有锁的线程释放锁后,唤醒堵塞的线程, 然后线程再次竞争锁。但是注意,当锁膨胀(inflate
)为重量锁时,就不能再退回到轻量级锁。