一、Mutex Lock

java-monitor本质是依赖于底层操作系统的Mutex Lock来实现的,每个对象都对应于一个可以称为“互斥锁”的标记,这个标记用于保证在任一时刻,只能有一个线程访问该对象。
互斥锁用于保护临界区,确保同一时刻只有一个线程访问数据。互斥锁可以通过互斥量实现:对共享资源的访问,先对互斥量进行加锁,如果互斥量已经上锁,调用线程会被阻塞,直到互斥量被解锁。在完成了对共享资源的访问后,要对互斥量进行解锁。

1.1 锁的语义

1)锁的内存语义

  1. 内存可见性(由两条规则保证)
    • 如果对一个变量执行lock操作,则会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行load或者assign操作初始化变量的值。这一点保证了:当线程获取锁时,JVM会把该线程对应的本地内存置为无效,从而使得被监视器保护的临界区代码必须从主内存中读取共享变量
    • 如果对一个变量执行unlock操作,则必须先把此变量同步回主内存中(执行store和write操作)。即当线程释放锁时,JVM会把该线程对应的本地内存中的共享变量刷新回主内存中
  2. 操作原子性
    • 持有同一个锁的两个同步块只能串行地进入(互斥)

2)锁的线程语义

  1. 线程A释放一个锁,实质上是线程A向接下来将要获取这个锁的某个线程发出了(线程A对共享变量所做修改的)消息
  2. 线程B获取一个锁,实质上是线程B接受了之前某个线程发出的(在释放这个锁之前对共享变量锁做修改的)消息
  3. 线程A释放锁,随后线程B获取这个锁,这个过程实质上是线程A通过主内存向线程B发送消息

1.2 mutex的工作方式

  1. 申请mutex
  2. 如果成功,则持有该mutex
  3. 如果失败,则进行spin;spin的过程就是在线等待mutex,不能发起mutex gets,直到获得mutex或者达到spin_count数量限制为止
  4. 依据工作模式的不同选择yield或者sleep
  5. 若达到sleep限制或者被主动唤醒或者完成yield,则重复1~4步,直至获得为止

二、Synchronized

synchronized关键字的底层是使用操作系统的mutex lock来实现的。
由于Java的线程是映射到操作系统的原生线程之上的,如果要阻塞或唤醒一条线程,都需要操作系统来帮忙完成,这就需要从用户态转换到内核态中,因此状态转移需要耗费很多的cpu时间,所以synchronized是Java中的一个重量级操作。
在JDK1.6中,虚拟机进行了一些优化,例如在通知操作系统阻塞线程之前加入一段自旋等待过程,避免频繁地切入到核心态中。

synchronized影响性能的原因有两点:

  1. 加锁解锁操作需要额外操作
  2. 互斥同步对性能最大的影响是阻塞的实现,因为阻塞涉及到的挂起线程和恢复线程的操作都需要转入内核态中完成(用户态和内核态切换的性能代价较大)

synchronized与ReentrantLock相比,由于JDK1.6中加入了针对锁的优化措施,使得synchronized与ReentrantLock的性能基本持平。ReentrantLock只是提供了比synchronized更丰富的功能,而不一定有更优的性能,因此在synchronized能实现需求的情况下,优先考虑使用synchronized来进行同步。

2.1 Java对象头

在运行期间,Mark Word中存储的数据会随着锁标识位的变化而变化,以32位的JDK为例

2.2 Java锁优化

synchronized依赖于底层操作系统的Mutex Lock实现,而操作系统实现线程之间的切换需要从用户态转换到内核态,这个成本非常高,状态之间的装换需要相对比较长的时间,这就是为什么synchronized效率低的原因。因此,这种依赖于操作系统Mutex Lock所实现的锁,我们称之为“重量级锁”

JDK1.6为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”,锁一共有四种状态,锁可以升级而不可以降级,级别从低到高依次是:

  • 无锁
  • 偏向锁
  • 轻量级锁
  • 重量级锁

从某种意义上讲,偏向锁、轻量级锁都是乐观锁,重量级锁是悲观锁

1)偏向锁

前提
HotSpot的作者经过研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得。偏向锁是为了在只有一个线程执行同步块时提高性能

过程

  1. 一个对象刚开始实例化时,没有任何线程来访问它的时候。它现在认为只可能有一个线程来访问它,所以当第一个线程来访问它的时候,它会“偏向”这个线程,此时,对象持有偏向锁。偏向第一个线程,这个线程在修改对象头成为偏向锁的时候使用CAS操作,并将对象头中的ThreadID更改为自己的ID,之后再次访问这个对象时,只需要对比ID,不需要再使用CAS进行操作。

    简而言之,当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。

  2. 一旦有第二个线程访问这个对象,因为偏向锁不会主动释放,所以第二个线程可以看到对象是偏向状态,这时表明在这个对象上已经存在竞争了,则检查原来持有该对象锁的线程是否依然存活:
    a. 如果原来的线程未存活,则可以将对象变为无锁状态,然后重新偏向新的线程;
    b. 如果原来的线程依然存活,则马上执行那个线程的操作栈,检查该对象的使用情况:
    i. 如果仍然需要持有偏向锁,则偏向锁升级为轻量级锁(偏向锁就是这个时候升级为轻量级锁的)
    ii. 如果不存在使用了,则可以将对象恢复为无锁状态,然后重新偏向

原理
引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取及释放依赖多次CAS原子指令,而偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令(由于一旦出现多线程竞争的情况就必须撤销偏向锁,所以偏向锁的撤销操作的性能损耗必须小于节省下来的CAS原子指令的性能损耗)

偏向锁的加锁过程

  1. 访问Mark Word中偏向锁的标识是否设置成1,锁标志位是否为01——确认为可偏向状态。
  2. 如果为可偏向状态,则测试线程ID是否指向当前线程,如果是,进入步骤5,否则进入步骤3。
  3. 如果线程ID并未指向当前线程,则通过CAS操作竞争锁。如果竞争成功,则将MarkWord中线程ID设置为当前线程ID,然后执行步骤5;如果竞争失败,执行步骤4。
  4. 如果CAS获取偏向锁失败,则表示有竞争(CAS获取偏向锁失败说明至少有过其他线程曾经获得过偏向锁,因为线程不会主动去释放偏向锁)。当到达全局安全点(safe point)时,会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程时候还存活着(因为可能持有偏向锁的线程已经执行完毕,但是该线程并不会主动去释放偏向锁),如果线程不处于活动暂停,则将对象头设置为无锁状态(标志位为“01”),然后重新偏向新的线程;如果线程仍然活着,撤销偏向锁后升级到轻量级锁状态(标志位为“00”,撤销偏向锁的时候会导致stop the word),此时轻量级锁由原持有偏向锁的线程持有,继续执行其同步代码,而正在竞争的线程会进入自旋等待获得该轻量级锁。
  5. 执行同步代码。

偏向锁的释放过程
如上步骤4,偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动去释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态,撤销偏向锁后恢复到未锁定(标志位为“01”)或轻量级锁(标志位为“00”)的状态。

偏向锁开关
偏向锁在JDK1.6及以上是默认启用的,由于偏向锁是为了在只有一个线程执行同步块时提高性能,如果确定应用程序里所有的锁通常情况下处于竞争状态,可以通过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false,那么程序默认会进入轻量级锁状态。

2)轻量级锁

概述
轻量级锁是为了在线程近乎交替执行同步块时提高性能。
轻量级锁认为竞争存在,但是竞争的程度很轻,一般两个线程对于同一个锁的操作都会错开,或者说稍微等待一下(自旋),另一个线程就会释放锁
但是当自旋超过一定的次数,或者一个线程持有锁,另一个在自旋,又有第三个来访时,轻量级锁将膨胀为重量级锁,重量级锁使除了拥有锁的线程以外的线程都阻塞,防止cpu空转。

轻量级锁加锁过程

  1. 在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,官方称之为 Displaced Mark Word。这时候线程堆栈与对象头的状态如图:
  2. 拷贝对象头中的Mark Word复制到锁记录中
  3. 拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock record里的owner指针指向object mark word。如果更新成功,则执行步骤4,否则执行步骤5。
  4. 如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,即表示此对象处于轻量级锁定状态,这时候线程堆栈与对象头的状态如图所示。
  5. 如果这个更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。否则说明多个线程竞争锁,若当前只有一个等待线程,则可通过自旋稍微等待一下,可能另一个线程很快就会释放锁。但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个线程来访时,轻量级锁就要膨胀为重量级锁,重量级锁使除了拥有锁的线程意外的线程都阻塞,防止cpu空转,锁标志的状态值变为“10”,Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。

轻量级锁释放过程

  1. 通过CAS操作尝试把线程中复制的Displaced Mark Word对象替换当前的Mark Word
  2. 如果替换成功,整个同步过程就完成了
  3. 如果替换失败,说明有其他线程尝试过获取该锁(此时锁已膨胀),那就要在释放锁的同时,唤醒被挂起的线程。

3)重量级锁

如上轻量级锁的加锁过程步骤5,轻量级锁所适应的场景是线程近乎交替执行同步块的情况,如果存在同一时间访问同一锁的情况,就会导致轻量级锁膨胀为重量级锁。Mark Word的锁标记位更新为10,Mark Word指向互斥量(重量级锁)

如前面提到的,synchronized的重量级锁是通过对象内部的一个叫做监视器锁(monitor)来实现的,监视器所本质又是依赖于底层操作系统的Mutex Lock(互斥锁)实现,而操作系统实现线程之间的切换需要从用户态转换到内核态,这个成本非常高,状态之间的装换需要相对比较长的时间,这就是为什么synchronized效率低的原因。

4)总结:偏向锁/轻量级锁/重量级锁之间的转换

  1. 一个对象刚开始实例化时,没有任何线程来访问它的时候。它现在认为只可能有一个线程来访问它,所以当第一个线程来访问它的时候,它会“偏向”这个线程,此时,对象持有偏向锁。偏向第一个线程,这个线程在修改对象头成为偏向锁的时候使用CAS操作,并将对象头中的ThreadID更改为自己的ID,之后再次访问这个对象时,只需要对比ID,不需要再使用CAS进行操
  2. 一旦有第二个线程访问这个对象,因为偏向锁不会主动释放,所以第二个线程可以看到对象是偏向状态,这时表明在这个对象上已经存在竞争了,则检查原来持有该对象锁的线程是否依然存活:如果原来的线程未存活,则可以将对象变为无锁状态,然后重新偏向新的线程;如果原来的线程依然存活,则马上执行那个线程的操作栈,检查该对象的使用情况,如果仍然需要持有偏向锁,则偏向锁升级为轻量级锁(偏向锁就是这个时候升级为轻量级锁的);如果不存在使用了,则可以将对象恢复为无锁状态,然后重新偏向。
  3. 轻量级锁认为竞争存在,但是竞争的程度很轻,一般两个线程对于同一个锁的操作都会错开,或者说稍微等待一下(自旋),另一个线程就会释放锁
    但是当自旋超过一定的次数,或者一个线程持有锁,另一个在自旋,又有第三个来访时,轻量级锁将膨胀为重量级锁,重量级锁使除了拥有锁的线程以外的线程都阻塞,防止cpu空转。

三、其他锁优化

锁消除

删除不必要的加锁操作,即如果根据代码逃逸技术,判断到一段代码中,堆上的数据不会逃逸出当前线程,那么可以认为这段代码是安全的,不必要加锁

锁粗化

如果虚拟机检测到有一串零碎的操作都是对同一对象的加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部

自旋锁与自适应自旋锁

引入自旋锁的原因
互斥同步对性能最大的影响是阻塞的实现,因为挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作给系统的并发性能带来很大的压力。同时虚拟机的开发团队也注意到在许多应用上面,共享数据的锁定状态只会持续很短一段时间,为了这一段很短的时间频繁地阻塞和唤醒线程是非常不值得的。因此引入自旋锁。

自旋锁
让线程执行一段无意义的忙循环(自旋)等待一段时间,不会被立即挂起(自旋不放弃处理器的执行时间),看持有锁的线程是否会很快释放锁。
自旋锁在JDK1.4.2中引入,默认关闭,但是可以使用-XX:+UseSpinning开启;在JDK1.6及以上默认开启。

自旋锁的缺点
自旋等待不能替代阻塞,虽然它可以避免线程切换带来的开销,但是它占用了处理器的时间。如果持有锁的线程很快就释放了锁,那么自旋的效率就非常好;反之,自旋的线程就会白白消耗掉处理器的资源,它不会做任何有意义的工作,这样反而会带来性能上的浪费。
所以说,自旋等待的时间(自旋的次数)必须要有一个限度,例如让其循环10次,如果自旋超过了定义的时间仍然没有获取到锁,则应该被挂起(进入阻塞状态)。
通过参数-XX:PreBlockSpin可以调整自旋次数,默认的自旋次数为10。

自适应的自旋锁
JDK1.6引入自适应的自旋锁,自适应就意味着自旋的次数不再是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定:

  1. 如果在同一个锁的对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而它将允许自旋等待持续相对更长的时间
  2. 如果对于某个锁,自旋很少成功获得过,那在以后要获取这个锁时将可能省略掉自旋过程,以避免浪费处理器资源。
    简单来说,就是线程如果自旋成功了,则下次自旋的次数会更多,如果自旋失败了,则自旋的次数就会减少。

自旋锁使用场景
从轻量级锁获取的流程中我们知道,当线程在获取轻量级锁的过程中执行CAS操作失败时,是要通过自旋来获取重量级锁的。(见前面“轻量级锁”)

Read More

[1]Java synchronized原理总结
[2]java 中的锁 – 偏向锁、轻量级锁、自旋锁、重量级锁