传统的锁(也就是下文要说的重量级锁)依赖于系统的同步函数,在linux上使用mutex
互斥锁,这些同步函数都涉及到用户态和内核态的切换、进程的上下文切换,成本较高。对于加了synchronized
关键字但运行时并没有多线程竞争,或两个线程接近于交替执行的情况,使用传统锁机制无疑效率是会比较低的。
在JDK 1.6之前,synchronized
只有传统的锁机制,因此给开发者留下了synchronized
关键字相比于其他同步机制性能不好的印象。在JDK 1.6引入了两种新型锁机制:偏向锁和轻量级锁,它们的引入是为了解决在没有多线程竞争的场景下因使用传统锁机制带来的性能开销问题。
关于锁我们知道它可以让临界区互斥,但它还有另一个重要功能,锁的内存语义。
当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中。 当线程获取锁时,JMM会把该线程对应的本地内存置为无效。
线程A释放一个锁,实质上是线程A向接下来将要获取这个锁的某个线程发出了(线程A对共享变量所做修改的)消息。 线程B获取一个锁,实质上是线程B接收了之前某个线程发出的(在释放这个锁之前对共享变量所做修改的)消息。 线程A释放锁,随后线程B获取这个锁,这个过程实质上是线程A通过主内存向线程B发送消息。
java中每个对象都可以作为锁。
JVM基于进入和退出Monitor(监视器)对象来实现方法同步和代码块同步,但两者的实现细节不一样。
代码块的同步是使用monitorenter和monitorexit指令实现的,而方法的同步是依靠方法修饰符上的ACC_SYNCHRONIZED来完成的。无论采取哪种方式,其本质都是对一个对象的监视器进行获取,而这个获取过程是排他的,也就是同一时刻只能有一个线程获取到synchronized所保护对象的监视器。
monitorenter指令是在编译后插入到代码块的起始位置,monitorexit指令插入到代码块结束和异常的位置。
JVM保证monitorenter一定会有一个monitorexist对应,不然就死锁了。
当线程执行到monitorenter指令时,将会尝试获取对象关联的monitor(监视器)的所有权,即尝试获取对象的锁。
每一个对象都有一个monitor(监视器)与之关联。当一个monitor被持有后,它将处于锁定状态。当线程执行到monitorenter,线程将尝试获取对象关联的monitor的所有权,即尝试获取对象的锁。
线程获取到对象的监视器,才能进入同步代码块,而没有获取到监视器的线程则被阻塞到同步代码块入口处,进入BLOCKED状态。
下图描述了对象、对象的监视器、同步队列、执行线程之间的关系:
从图中可以看到,任意线程对Object对象的访问,首先需要获取Object的监视器,如果获取失败,线程进入同步队列,线程状态变为BLOCKED。当上一个获取了锁的线程,释放了锁,则该释放操作会唤醒在同步队列中的线程,使其重新尝试对监视器的获取。
在JVM中,对象在内存中除了本身的数据外还会有个对象头,对于普通对象而言,其对象头中有两类信息:mark word
和类型指针。另外对于数组而言还会有一份记录数组长度的数据。
synchronize使用的锁是存储在java对象头里面的。
在32位虚拟机中,1字宽等于4字节。
java对象头的mark world默认存储对象的HashCode、分代年龄、锁标志位。32位的JVM的mark world存储结构如图:
在运行期间,mark world里面存储的数据会随着锁标志位的变化而变化,如图:
在64位虚拟机下,Mark World是64bit大小,其存储结构如下:
Java SE1.6为了减少获得锁和释放锁带来的性能损耗,引入了“偏向锁”和“轻量级”锁。在java SE 1.6中,锁一共有4种状态,级别从高到底依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态。这几个状态会随着锁竞争加剧而逐渐升级。
**锁可以升级,但不能降级。**意味着偏向锁升级为轻量级锁之后,不能降级为偏向锁。为什么这样子做呢?这样子是为了提高获取锁和释放锁的效率。 其实很好理解,如果锁升级了,证明这块同步区域将来也很有可能面临锁竞争,达到这个锁的级别,如果目前没有竞争,就把锁降级的话,将来产生同样的锁竞争,又将进行锁升级,就会降低获取锁的效率。
HotSpot的作者经研究发现,大多数情况下,锁不仅不存在多线程竞争,并且总是由同一个线程获得。这样子每次获取锁都进行同步,代价也太大了。为了让线程获取锁的代价更低而引入了偏向锁。
当JVM启用了偏向锁模式(1.6以上默认开启),新创建一个对象的时候,那新创建对象的mark word
将是可偏向状态,此时mark word中
的thread id为0,表示未偏向任何线程,也叫做匿名偏向(anonymously biased)。
mark word
中的thread id由0改成当前线程Id。如果成功,则代表获得了偏向锁,继续执行同步块中的代码。否则,将偏向锁撤销,升级为轻量级锁。Displaced Mark Word
为空的Lock Record
,并且将Lock Record的obj字段指向锁对象,然后继续执行同步块的代码,因为操纵的是线程私有的栈,因此不需要用到CAS指令;由此可见偏向锁模式下,当被偏向的线程再次尝试获得锁时,仅仅进行几个简单的操作就可以了,在这种情况下,synchronized
关键字带来的性能开销基本可以忽略。safepoint
中去查看偏向的线程是否还存活, mark word
改为无锁状态(unlocked),之后再升级为轻量级锁。由此可见,偏向锁升级的时机为:当锁已经发生偏向后,只要有另一个线程尝试获得偏向锁,则该偏向锁就会升级成轻量级锁。当然这个说法不绝对,因为还有批量重偏向这一机制。
释放锁指的是线程退出同步块的时候,释放偏向锁。
当有其他线程尝试获得锁时,是根据遍历偏向线程的lock record
来确定该线程是否还在执行同步块中的代码
,如果lock record中obj字段指向锁对象,那么其他线程就认为该线程还在执行同步块代码。
因此偏向锁的释放(解锁)很简单,仅仅将栈中的最近一条lock record
的obj
字段设置为null。需要注意的是,偏向锁的解锁步骤中并不会修改对象头中的thread id。
偏向锁的撤销指线程在获取偏向锁的时候失败了,导致要将锁对象改为非偏向锁状态,升级为轻量级锁
safepoint
中去查看偏向的线程是否还存活 mark word
改为无锁状态(unlocked),之后再升级为轻量级锁。下图是发生竞争的情况下,进行偏向锁的撤销:
偏向锁是默认开启的,而且开始时间一般是比应用程序启动慢几秒,如果不想有这个延迟,那么可以使用-XX:BiasedLockingStartUpDelay=0; 如果不想要偏向锁,那么可以通过-XX:-UseBiasedLocking = false来设置;
等待轻量锁的线程不会阻塞,它会自旋等待一段时间。这就是自旋锁。
尝试获取锁的线程,在没有获得锁的时候,不被挂起,而转而去执行一个空循环,即自旋。在若干个自旋后,如果还没有获得锁,则才被挂起。
获得锁,则执行代码。虽然自旋可以防止阻塞,节省从内核态到用户态的开销,但是如果长时间自旋,则会导致CPU长时间做一个同样的无用循环操作。浪费CPU的资源。这时候引入了自适应自旋。
#####自适应自旋锁
此操作为了防止长时间的自旋,在自旋操作上加了一些限制条件。
轻量级解锁时,会使用原子的CAS操作将Displaced Mark Word替换回到对象头。
因为自旋会消耗CPU,为了避免过多的自旋,一旦锁升级成重量级锁,就不会再 恢复到轻量级锁状态。当锁处于这个状态下,其他线程试图获取锁时,都会被阻塞住,当持有锁的线程释放锁之后 会唤醒这些线程,被唤醒的线程就会进行新一轮的夺锁之争。
下图是两个锁同时竞争,导致锁升级的流程图:
因为自旋会消耗CPU,为了避免无用的自旋(比如获得锁的线程被阻塞住了),一旦锁升级成重量级锁,就不会再 恢复到轻量级锁状态。当锁处于这个状态下,其他线程试图获取锁时,都会被阻塞住,当持有锁的线程释放锁之后 会唤醒这些线程,被唤醒的线程就会进行新一轮的夺锁之争。
重量级锁的状态下,对象的mark word
为指向一个monitor对象的指针。
一个monitor对象包括这么几个关键字段:cxq(下图中的ContentionList),EntryList ,WaitSet,owner。
其中cxq ,EntryList ,WaitSet都是由ObjectWaiter的链表结构,owner指向持有锁的线程。
Java虚拟机中synchronized关键字的实现,按照代价由高到低可以分为重量级锁、轻量锁和偏向锁三种。
轻量级锁为什么要使用自旋锁?
为了避免调用系统的同步函数,造成用户态和内核态的切花
自旋锁适用于什么场景?
线程的任务执行时间比较短的场景
《并发编程艺术》
[死磕Synchronized底层实现–重量级锁