JVM 锁 4.2 Java 内存
synchronized 不管是读还是写,如果前面有锁,只能是等待,
lock 中有读写锁,可以做到读读并发,读写互斥,写写互斥,但是 synchronized 做不到 参考: https://blog.csdn.net/yangbindxj/article/details/122674364
记得开始学习 Java 的时候,一遇到多线程情况就使用 synchronized,相对于当时的我们来说 synchronized 是这么的神奇而又强大,那个时候我们赋予它一个名字“同步”,也成为了我们解决多线程情况的百试不爽的良药。但是,随着学习的进行我们知道在 JDK1.5之前 synchronized 是一个重量级锁,相对于j.u.c.Lock,它会显得那么笨重,以至于我们认为它不是那么的高效而慢慢摒弃它。
不过,随着 Javs SE 1.6 对 synchronized 进行的各种优化后,synchronized 并不会显得那么重了。下面来一起探索 synchronized 的基本使用、实现机制、Java 是如何对它进行了优化、锁优化机制、锁的存储结构等升级过程。
1 基本使用
Synchronized 是 Java 中解决并发问题的一种最常用的方法,也是最简单的一种方法。Synchronized 的作用主要有三个:
- 原子性:确保线程互斥的访问同步代码;
- 可见性:保证共享变量的修改能够及时可见,其实是通过 Java 内存模型中的 “ 对一个变量 unlock 操作之前,必须要同步到主内存中;如果对一个变量进行 lock 操作,则将会清空工作内存中此变量的值,在执行引擎使用此变量前,需要重新从主内存中 load 操作或 assign 操作初始化变量值 ” 来保证的;
- 有序性:有效解决重排序问题,即 “一个 unlock 操作先行发生(happen-before)于后面对同一个锁的 lock 操作”;
从语法上讲,Synchronized 可以把任何一个非 null 对象作为”锁”,在 HotSpot JVM 实现中, 锁有个专门的名字:对象监视器(Object Monitor) 。
Synchronized 总共有三种用法:
- 当 synchronized 作用在实例方法时,监视器锁(monitor)便是对象实例(this);
- 当 synchronized 作用在静态方法时,监视器锁(monitor)便是对象的 Class 实例,因为 Class 数据存在于永久代,因此静态方法锁相当于该类的一个全局锁;
- 当 synchronized 作用在某一个对象实例时,监视器锁(monitor)便是括号括起来的对象实例;
注意,synchronized 内置锁是一种对象锁(锁的是对象而非引用变量), 作用粒度是对象,可以用来实现对临界资源的同步互斥访问,是可重入的。其可重入最大的作用是避免死锁 ,如:
子类同步方法调用了父类同步方法,如没有可重入的特性,则会发生死锁;
2 同步原理
数据同步需要依赖锁,那锁的同步又依赖谁? synchronized 给出的答案是在软件层面依赖 JVM,而j.u.c.Lock 给出的答案是在硬件层面依赖特殊的 CPU 指令。
当一个线程访问同步代码块时,首先是需要得到锁才能执行同步代码,当退出或者抛出异常时必须要释放锁,那么它是如何来实现这个机制的呢?我们先看一段简单的代码:
查看反编译后结果:
反编译结果
-
monitorenter :每个对象都是一个监视器锁(monitor)。当 monitor 被占用时就会处于锁定状态,线程执行 monitorenter 指令时尝试获取 monitor 的所有权,过程如下:
- 如果 monitor 的进入数为 0,则该线程进入 monitor,然后将进入数设置为 1,该线程即为 monitor 的所有者;
- 如果线程已经占有该 monitor,只是重新进入,则进入 monitor 的进入数加1;
- 如果其他线程已经占用了 monitor,则该线程进入阻塞状态,直到 monitor 的进入数为 0,再重新尝试获取 monitor 的所有权;
-
monitorexit:执行 monitorexit 的线程必须是 objectref 所对应的 monitor 的所有者。指令执行时,monitor 的进入数减 1,如果减 1 后进入数为 0,那线程退出 monitor,不再是这个 monitor 的所有者。其他被这个 monitor 阻塞的线程可以尝试去获取这个 monitor 的所有权。
monitorexit 指令出现了两次,第 1 次为同步正常退出释放锁;第 2 次为发生异步退出释放锁;
通过上面两段描述,我们应该能很清楚的看出 Synchronized 的实现原理, Synchronized 的语义底层是通过一个 monitor 的对象来完成,其实 wait/notify 等方法也依赖于 monitor 对象,这就是为什么只有在同步的块或者方法中才能调用 wait/notify 等方法,否则会抛出 java.lang.IllegalMonitorStateException 的异常的原因。
再来看一下同步方法:
package com.paddx.test.concurrent; public class SynchronizedMethod { public synchronized void method() {
System.out.println("Hello World!");
}
}
查看反编译后结果:
反编译结果
从编译的结果来看,方法的同步并没有通过指令 monitorenter
和 monitorexit
来完成(理论上其实也可以通过这两条指令来实现),不过相对于普通方法,其常量池中多了 ACC_SYNCHRONIZED
标示符。JVM 就是根据该标示符来实现方法的同步的:
当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先获取 monitor,获取成功之后才能执行方法体,方法执行完后再释放 monitor。 在方法执行期间,其他任何线程都无法再获得同一个 monitor 对象。
两种同步方式本质上没有区别,只是方法的同步是一种隐式的方式来实现,无需通过字节码来完成。两个指令的执行是 JVM 通过调用操作系统的互斥原语 mutex 来实现,被阻塞的线程会被挂起、等待重新调度,会导致“用户态和内核态”两个态之间来回切换,对性能有较大影响。
3 同步概念
3.1 Java 对象头
在 JVM 中 ,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充。 如下图所示:
- 实例数据:存放类的属性数据信息,包括父类的属性信息;
- 对齐填充:由于虚拟机要求对象起始地址必须是 8 字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐;
- 对象头:Java 对象头一般占有 2 个机器码(在 32 位虚拟机中,1 个机器码等于 4 字节,也就是 32bit,在 64 位虚拟机中,1 个机器码是 8 个字节,也就是 64bit),但是如果对象是数组类型,则需要 3 个机器码,因为 JVM 虚拟机可以通过 Java 对象的元数据信息确定 Java 对象的大小,但是无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度。
Synchronized 用的锁就是存在 Java 对象头里的,那么什么是 Java 对象头呢?Hotspot 虚拟机的对象头主要包括两部分数据: Mark Word(标记字段)、 Class Pointer(类型指针)。其中 Class Pointer 是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例,Mark Word 用于存储对象自身的运行时数据,它是实现轻量级锁和偏向锁的关键。 Java 对象头具体结构描述如下:
Java 对象头结构组成
Mark Word 用于存储对象自身的运行时数据,如:哈希码(HashCode)、GC 分代年龄、 锁状态标志 、线程持有的锁、偏向线程 ID、偏向时间戳等。比如锁膨胀就是借助 Mark Word 的偏向的线程 ID 参考:JAVA锁的膨胀过程和优化(阿里) 阿里也经常问的问题
下图是 Java 对象头无锁状态下 Mark Word 部分的存储结构(32 位虚拟机):
Mark Word 存储结构
对象头信息是与对象自身定义的数据无关的额外存储成本,但是考虑到虚拟机的空间效率,Mark Word 被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据,它会根据对象的状态复用自己的存储空间,也就是说,Mark Word 会随着程序的运行发生变化,可能变化为存储以下 4 种数据:
Mark Word 可能存储 4 种数据
在 64 位虚拟机下,Mark Word 是 64bit 大小的,其存储结构如下:
64 位 Mark Word 存储结构
NOTE
对象头的最后两位存储了锁的标志位,01 是初始状态,未加锁,其对象头里存储的是对象本身的哈希码,随着锁级别的不同,对象头里会存储不同的内容。
偏向锁存储的是当前占用此对象的线程 ID;
轻量级则存储指向线程栈中锁记录的指针。
从这里我们可以看到,“锁”这个东西,可能是个锁记录+对象头里的引用指针(判断线程是否拥有锁时将线程的锁记录地址和对象头里的指针地址比较),也可能是对象头里的线程 ID(判断线程是否拥有锁时将线程的 ID 和对象头里存储的线程 ID 比较)。
HotSpot 虚拟机对象头 Mark Word
3.2 对象头中 Mark Word 与线程中 Lock Record
在线程进入同步代码块的时候,如果此同步对象没有被锁定,即它的锁标志位是 01,则虚拟机首先在当前线程的栈中创建我们称之为“锁记录(Lock Record)”的空间,用于存储锁对象的 Mark Word 的拷贝,官方把这个拷贝称为 Displaced Mark Word。整个 Mark Word 及其拷贝至关重要。
Lock Record 是线程私有的数据结构 ,每一个线程都有一个可用 Lock Record 列表,同时还有一个全局的可用列表。每一个被锁住的对象 Mark Word 都会和一个 Lock Record 关联(对象头的 MarkWord 中的 Lock Word 指向 Lock Record 的起始地址),同时 Lock Record 中有一个 Owner 字段存放拥有该锁的线程的唯一标识(或者 object mark word
),表示该锁被这个线程占用。如下图所示为 Lock Record 的内部结构:
Lock Record | 描述 |
---|---|
Owner | 初始时为 NULL 表示当前没有任何线程拥有该 monitor record,当线程成功拥有该锁后保存线程唯一标识,当锁被释放时又设置为 NULL; |
EntryQ | 关联一个系统互斥锁(semaphore),阻塞所有试图锁住 monitor record 失败的线程; |
RcThis | 表示 blocked 或 waiting 在该 monitor record 上的所有线程的个数; |
Nest | 用来实现重入锁的计数; |
HashCode | 保存从对象头拷贝过来的 HashCode 值(可能还包含 GC age)。 |
Candidate | 用来避免不必要的阻塞或等待线程唤醒,因为每一次只有一个线程能够成功拥有锁,如果每次前一个释放锁的线程唤醒所有正在阻塞或等待的线程,会引起不必要的上下文切换(从阻塞到就绪然后因为竞争锁失败又被阻塞)从而导致性能严重下降。Candidate 只有两种可能的值 0 表示没有需要唤醒的线程 1 表示要唤醒一个继任线程来竞争锁。 |
3.3 监视器(Monitor)
任何一个对象都有一个 Monitor 与之关联,当且一个 Monitor 被持有后,它将处于锁定状态。Synchronized 在 JVM 里的实现都是基于进入和退出 Monitor 对象来实现方法同步和代码块同步,虽然具体实现细节不一样,但是都可以通过成对的 MonitorEnter 和 MonitorExit 指令来实现。
- MonitorEnter 指令:插入在同步代码块的开始位置,当代码执行到该指令时,将会尝试获取该对象 Monitor 的所有权,即尝试获得该对象的锁;
-
- MonitorExit 指令:插入在方法结束处和异常处,JVM 保证每个 MonitorEnter 必须有对应的 MonitorExit;
那什么是 Monitor?可以把它理解为一个同步工具,也可以描述为一种同步机制,它通常被描述为一个对象。
与一切皆对象一样,所有的 Java 对象是天生的 Monitor,每一个 Java 对象都有成为 Monitor 的潜质,因为在 Java 的设计中, 每一个 Java 对象自打娘胎里出来就带了一把看不见的锁,它叫做内部锁或者 Monitor 锁 。
也就是通常说 Synchronized 的对象锁,MarkWord 锁标识位为 10,其中指针指向的是 Monitor 对象的起始地址。在 Java 虚拟机(HotSpot)中,Monitor 是由 ObjectMonitor 实现的,其主要数据结构如下(位于 HotSpot 虚拟机源码 ObjectMonitor.hpp 文件,C++实现的):
ObjectMonitor 中有两个队列,_WaitSet 和 _EntryList,用来保存 ObjectWaiter 对象列表( 每个等待锁的线程都会被封装成 ObjectWaiter 对象 ),_owner 指向持有 ObjectMonitor 对象的线程,当多个线程同时访问一段同步代码时:
- 首先会进入 _EntryList 集合,当线程获取到对象的 monitor 后,进入 _Owner 区域并把 monitor 中的 owner 变量设置为当前线程,同时 monitor 中的计数器 count 加1;
- 若线程调用 wait() 方法,将释放当前持有的 monitor,owner 变量恢复为 null,count 自减 1,同时该线程进入 WaitSet 集合中等待被唤醒;
- 若当前线程执行完毕,也将释放 monitor(锁)并复位 count 的值,以便其他线程进入获取 monitor(锁);
同时 ,Monitor 对象存在于每个 Java 对象的对象头 Mark Word 中(存储的指针的指向),Synchronized 锁便是通过这种方式获取锁的,也是为什么 Java 中任意对象可以作为锁的原因,同时 notify/notifyAll/wait 等方法会使用到 Monitor 锁对象,所以必须在同步代码块中使用。
监视器 Monitor 有两种同步方式:互斥与协作。多线程环境下线程之间如果需要共享数据,需要解决互斥访问数据的问题,监视器可以确保监视器上的数据在同一时刻只会有一个线程在访问。
什么时候需要协作? 比如:
一个线程向缓冲区写数据,另一个线程从缓冲区读数据,如果读线程发现缓冲区为空就会等待,当写线程向缓冲区写入数据,就会唤醒读线程,这里读线程和写线程就是一个合作关系。JVM 通过 Object 类的 wait 方法来使自己等待,在调用 wait 方法后,该线程会释放它持有的监视器,直到其他线程通知它才有执行的机会。一个线程调用 notify 方法通知在等待的线程,这个等待的线程并不会马上执行,而是要通知线程释放监视器后,它重新获取监视器才有执行的机会。如果刚好唤醒的这个线程需要的监视器被其他线程抢占,那么这个线程会继续等待。Object 类中的 notifyAll 方法可以解决这个问题,它可以唤醒所有等待的线程,总有一个线程执行。
如上图所示,一个线程通过 1 号门进入 Entry Set(入口区),如果在入口区没有线程等待,那么这个线程就会获取监视器成为监视器的 Owner,然后执行监视区域的代码。如果在入口区中有其它线程在等待,那么新来的线程也会和这些线程一起等待。线程在持有监视器的过程中,有两个选择,一个是正常执行监视器区域的代码,释放监视器,通过 5 号门退出监视器;还有可能等待某个条件的出现,于是它会通过 3 号门到 Wait Set(等待区)休息,直到相应的条件满足后再通过 4 号门进入重新获取监视器再执行。
注意:
当一个线程释放监视器时,在入口区和等待区的等待线程都会去竞争监视器,如果入口区的线程赢了,会从 2 号门进入;如果等待区的线程赢了会从 4 号门进入。只有通过 3 号门才能进入等待区,在等待区中的线程只有通过 4 号门才能退出等待区,也就是说一个线程只有在持有监视器时才能执行 wait 操作,处于等待的线程只有再次获得监视器才能退出等待状态。
4 锁的优化
从 JDK5引入了现代操作系统新增加的 CAS 原子操作( JDK5中并没有对 synchronized 关键字做优化,而是体现在J.U.C 中,所以在该版本 concurrent 包有更好的性能 ),从 JDK6 开始,就对 synchronized 的实现机制进行了较大调整,包括使用 JDK5 引进的 CAS 自旋之外,还增加了自适应的 CAS 自旋、锁消除、锁粗化、偏向锁、轻量级锁这些优化策略。由于此关键字的优化使得性能极大提高,同时语义清晰、操作简单、无需手动关闭,所以推荐在允许的情况下尽量使用此关键字,同时在性能上此关键字还有优化的空间。
锁主要存在四种状态,依次是 :无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态 ,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁。但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级。
在 JDK 1.6 中默认是开启偏向锁和轻量级锁的,可以通过-XX:-UseBiasedLocking 来禁用偏向锁。
4.1 自旋锁
线程的阻塞和唤醒需要 CPU 从用户态转为核心态,频繁的阻塞和唤醒对 CPU 来说是一件负担很重的工作,势必会给系统的并发性能带来很大的压力。同时我们发现在许多应用上面,对象锁的锁状态只会持续很短一段时间,为了这一段很短的时间频繁地阻塞和唤醒线程是非常不值得的。
所以引入自旋锁,何谓自旋锁?
所谓自旋锁,就是指当一个线程尝试获取某个锁时,如果该锁已被其他线程占用,就一直循环检测锁是否被释放,而不是进入线程挂起或睡眠状态。
自旋锁适用于锁保护的临界区很小的情况,临界区很小的话,锁占用的时间就很短。自旋等待不能替代阻塞,虽然它可以避免线程切换带来的开销,但是它占用了 CPU 处理器的时间。如果持有锁的线程很快就释放了锁,那么自旋的效率就非常好,反之,自旋的线程就会白白消耗掉处理的资源,它不会做任何有意义的工作,典型的占着茅坑不拉屎,这样反而会带来性能上的浪费。所以说,自旋等待的时间(自旋的次数)必须要有一个限度,如果自旋超过了定义的时间仍然没有获取到锁,则应该被挂起。
自旋锁在 JDK 1.4.2 中引入,默认关闭,但是可以使用-XX:+UseSpinning 开开启,在 JDK1.6 中默认开启。同时自旋的默认次数为 10 次,可以通过参数-XX:PreBlockSpin 来调整。
如果通过参数-XX:PreBlockSpin 来调整自旋锁的自旋次数,会带来诸多不便。假如将参数调整为 10,但是系统很多线程都是等你刚刚退出的时候就释放了锁(假如多自旋一两次就可以获取锁),是不是很尴尬。于是 JDK1.6 引入自适应的自旋锁,让虚拟机会变得越来越聪明。
4.2 适应性自旋锁
JDK 1.6 引入了更加聪明的自旋锁,即自适应自旋锁。所谓自适应就意味着自旋的次数不再是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。那它如何进行适应性自旋呢?
线程如果自旋成功了,那么下次自旋的次数会更加多,因为虚拟机认为既然上次成功了,那么此次自旋也很有可能会再次成功,那么它就会允许自旋等待持续的次数更多。反之,如果对于某个锁,很少有自旋能够成功,那么在以后要或者这个锁的时候自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源。
有了自适应自旋锁,随着程序运行和性能监控信息的不断完善,虚拟机对程序锁的状况预测会越来越准确,虚拟机会变得越来越聪明。
4.3 锁消除
为了保证数据的完整性,在进行操作时需要对这部分操作进行同步控制,但是在有些情况下,JVM 检测到不可能存在共享数据竞争,这是 JVM 会对这些同步锁进行锁消除。
锁消除的依据是逃逸分析的数据支持
如果不存在竞争,为什么还需要加锁呢?所以锁消除可以节省毫无意义的请求锁的时间。变量是否逃逸,对于虚拟机来说需要使用数据流分析来确定,但是对于程序员来说这还不清楚么?在明明知道不存在数据竞争的代码块前加上同步吗?但是有时候程序并不是我们所想的那样?虽然没有显示使用锁,但是在使用一些 JDK 的内置 API 时,如 StringBuffer、Vector、HashTable 等,这个时候会存在隐形的加锁操作。比如 StringBuffer 的 append()方法,Vector 的 add()方法:
在运行这段代码时,JVM 可以明显检测到变量 vector 没有逃逸出方法 vectorTest()之外,所以 JVM 可以大胆地将 vector 内部的加锁操作消除。
4.4 锁粗化
在使用同步锁的时候,需要让同步块的作用范围尽可能小—仅在共享数据的实际作用域中才进行同步,这样做的目的是为了使需要同步的操作数量尽可能缩小,如果存在锁竞争,那么等待锁的线程也能尽快拿到锁。
在大多数的情况下,上述观点是正确的。但是如果一系列的连续加锁解锁操作,可能会导致不必要的性能损耗,所以引入锁粗话的概念。
锁粗话概念比较好理解,就是将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁
如上面实例:
vector 每次 add 的时候都需要加锁操作,JVM 检测到对同一个对象(vector)连续加锁、解锁操作,会合并一个更大范围的加锁、解锁操作,即加锁解锁操作会移到 for 循环之外。
4.5 偏向锁
偏向锁是 JDK6 中的重要引进,因为 HotSpot 作者经过研究实践发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低,引进了偏向锁。
偏向锁是在单线程执行代码块时使用的机制,如果在多线程并发的环境下(即线程 A 尚未执行完同步代码块,线程 B 发起了申请锁的申请),则一定会转化为轻量级锁或者重量级锁。
在 JDK5 中偏向锁默认是关闭的,而到了 JDK6 中偏向锁已经默认开启。如果并发数较大同时同步代码块执行时间较长,则被多个线程同时访问的概率就很大,就可以使用参数-XX:-UseBiasedLocking 来禁止偏向锁(但这是个 JVM 参数,不能针对某个对象锁来单独设置)。
引入偏向锁主要目的是:为了在没有多线程竞争的情况下尽量减少不必要的轻量级锁执行路径。因为轻量级锁的加锁解锁操作是需要依赖多次 CAS 原子指令的,而偏向锁只需要在置换 ThreadID 的时候依赖一次 CAS 原子指令(由于一旦出现多线程竞争的情况就必须撤销偏向锁,所以偏向锁的撤销操作的性能损耗也必须小于节省下来的 CAS 原子指令的性能消耗)。
轻量级锁是为了在线程交替执行同步块时提高性能,而偏向锁则是在只有一个线程执行同步块时进一步提高性能。
那么偏向锁是如何来减少不必要的 CAS 操作呢?首先我们看下无竞争下锁存在什么问题:
现在几乎所有的锁都是可重入的,即已经获得锁的线程可以多次锁住/解锁监视对象,按照之前的 HotSpot 设计,每次加锁/解锁都会涉及到一些 CAS 操作(比如对等待队列的 CAS 操作),CAS 操作会延迟本地调用,因此偏向锁的想法是一旦线程第一次获得了监视对象,之后让监视对象“偏向”这个线程,之后的多次调用则可以避免 CAS 操作,说白了就是置个变量,如果发现为 true 则无需再走各种加锁/解锁流程。
CAS 为什么会引入本地延迟?这要从 SMP(对称多处理器)架构说起,下图大概表明了 SMP 的结构:
SMP(对称多处理器)架构
其意思是所有的 CPU 会共享一条系统总线(BUS),靠此总线连接主存。每个核都有自己的一级缓存,各核相对于 BUS 对称分布,因此这种结构称为“对称多处理器”。
而 CAS 的全称为 Compare-And-Swap,是一条 CPU 的原子指令,其作用是让 CPU 比较后原子地更新某个位置的值,经过调查发现,其实现方式是基于硬件平台的汇编指令,就是说 CAS 是靠硬件实现的,JVM 只是封装了汇编调用,那些 AtomicInteger 类便是使用了这些封装后的接口。
例如:Core1 和 Core2 可能会同时把主存中某个位置的值 Load 到自己的 L1 Cache 中,当 Core1 在自己的 L1 Cache 中修改这个位置的值时,会通过总线,使 Core2 中 L1 Cache 对应的值“失效”,而 Core2 一旦发现自己 L1 Cache 中的值失效(称为 Cache 命中缺失)则会通过总线从内存中加载该地址最新的值,大家通过总线的来回通信称为“Cache 一致性流量”,因为总线被设计为固定的“通信能力”,如果 Cache 一致性流量过大,总线将成为瓶颈。而当 Core1 和 Core2 中的值再次一致时,称为“Cache 一致性”,从这个层面来说,锁设计的终极目标便是减少 Cache 一致性流量。
而 CAS 恰好会导致 Cache 一致性流量,如果有很多线程都共享同一个对象,当某个 Core CAS 成功时必然会引起总线风暴,这就是所谓的本地延迟,本质上偏向锁就是为了消除 CAS,降低 Cache 一致性流量。
Cache 一致性:
上面提到 Cache 一致性,其实是有协议支持的,现在通用的协议是 MESI(最早由 Intel 开始支持),具体参考:http://en.wikipedia.org/wiki/MESI_protocol。
Cache 一致性流量的例外情况:
其实也不是所有的 CAS 都会导致总线风暴,这跟 Cache 一致性协议有关,具体参考:http://blogs.oracle.com/dave/entry/biased_locking_in_hotspot
NUMA(Non Uniform Memory Access Achitecture)架构:
与 SMP 对应还有非对称多处理器架构,现在主要应用在一些高端处理器上,主要特点是没有总线,没有公用主存,每个 Core 有自己的内存,针对这种结构此处不做讨论。
所以,当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程 ID,以后该线程进入和退出同步块时不需要花费 CAS 操作来争夺锁资源,只需要检查是否为偏向锁、锁标识为以及 ThreadID 即可,处理流程如下:
- 检测 Mark Word 是否为可偏向状态,即是否为偏向锁 1,锁标识位为 01;
- 若为可偏向状态,则测试线程 ID 是否为当前线程 ID,如果是,则执行步骤(5),否则执行步骤(3);
- 如果测试线程 ID 不为当前线程 ID,则通过 CAS 操作竞争锁,竞争成功,则将 Mark Word 的线程 ID 替换为当前线程 ID,否则执行线程(4);
- 通过 CAS 竞争锁失败,证明当前存在多线程竞争情况,当到达全局安全点,获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码块;
- 执行同步代码块;
偏向锁的释放采用了一种只有竞争才会释放锁的机制,线程是不会主动去释放偏向锁,需要等待其他线程来竞争。偏向锁的撤销需要等待全局安全点(这个时间点是上没有正在执行的代码)。其步骤如下:
- 暂停拥有偏向锁的线程;
- 判断锁对象是否还处于被锁定状态,否,则恢复到无锁状态(01),以允许其余线程竞争。是,则挂起持有锁的当前线程,并将指向当前线程的锁记录地址的指针放入对象头 Mark Word,升级为轻量级锁状态(00),然后恢复持有锁的当前线程,进入轻量级锁的竞争模式;
注意:此处将当前线程挂起再恢复的过程中并没有发生锁的转移,仍然在当前线程手中,只是穿插了个 “将对象头中的线程 ID 变更为指向锁记录地址的指针” 这么个事。
偏向锁的获取和释放过程
4.6 轻量级锁
引入轻量级锁的主要目的是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。当关闭偏向锁功能或者多个线程竞争偏向锁导致偏向锁升级为轻量级锁,则会尝试获取轻量级锁,其步骤如下:
在线程进入同步块时,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的 Mark Word 的拷贝,官方称之为 Displaced Mark Word。此时线程堆栈与对象头的状态如下图所示:
轻量级锁 CAS 操作之前线程堆栈与对象的状态
- 拷贝对象头中的 Mark Word 复制到锁记录(Lock Record)中;
- 拷贝成功后,虚拟机将使用 CAS 操作尝试将对象 Mark Word 中的 Lock Word 更新为指向当前线程 Lock Record 的指针,并将 Lock record 里的 owner 指针指向 object mark word。如果更新成功,则执行步骤(4),否则执行步骤(5);
如果这个更新动作成功了,那么当前线程就拥有了该对象的锁,并且对象 Mark Word 的锁标志位设置为“00”,即表示此对象处于轻量级锁定状态,此时线程堆栈与对象头的状态如下图所示:
轻量级锁 CAS 操作之后线程堆栈与对象的状态
- 如果这个更新操作失败了,虚拟机首先会检查对象 Mark Word 中的 Lock Word 是否指向当前线程的栈帧,如果是,就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。否则说明多个线程竞争锁,进入自旋执行(3),若自旋结束时仍未获得锁,轻量级锁就要膨胀为重量级锁,锁标志的状态值变为“10”,Mark Word 中存储的就是指向重量级锁(互斥量)的指针,当前线程以及后面等待锁的线程也要进入阻塞状态。
轻量级锁的释放也是通过 CAS 操作来进行的,主要步骤如下:
- 通过 CAS 操作尝试把线程中复制的 Displaced Mark Word 对象替换当前的 Mark Word;
- 如果替换成功,整个同步过程就完成了,恢复到无锁状态(01);
- 如果替换失败,说明有其他线程尝试过获取该锁(此时锁已膨胀),那就要在释放锁的同时,唤醒被挂起的线程;
对于轻量级锁,其性能提升的依据是 “对于绝大部分的锁,在整个生命周期内都是不会存在竞争的”,如果打破这个依据则除了互斥的开销外,还有额外的 CAS 操作,因此在有多线程竞争的情况下,轻量级锁比重量级锁更慢。
轻量级锁的获取和释放过程
-
为什么升级为轻量锁时要把对象头里的 Mark Word 复制到线程栈的锁记录中呢?
因为在申请对象锁时需要以该值作为 CAS 的比较条件,同时在升级到重量级锁的时候,能通过这个比较判定是否在持有锁的过程中此锁被其他线程申请过,如果被其他线程申请了,则在释放锁的时候要唤醒被挂起的线程。
-
为什么会尝试 CAS 不成功以及什么情况下会不成功?
CAS 本身是不带锁机制的,其是通过比较而来。假设如下场景:线程 A 和线程 B 都在对象头里的锁标识为无锁状态进入,那么如线程 A 先更新对象头为其锁记录指针成功之后,线程 B 再用 CAS 去更新,就会发现此时的对象头已经不是其操作前的对象 HashCode 了,所以 CAS 会失败。也就是说,只有两个线程并发申请锁的时候会发生 CAS 失败。
然后线程 B 进行 CAS 自旋,等待对象头的锁标识重新变回无锁状态或对象头内容等于对象 HashCode(因为这是线程 B 做 CAS 操作前的值),这也就意味着线程 A 执行结束(参见后面轻量级锁的撤销,只有线程 A 执行完毕撤销锁了才会重置对象头),此时线程 B 的 CAS 操作终于成功了,于是线程 B 获得了锁以及执行同步代码的权限。如果线程 A 的执行时间较长,线程 B 经过若干次 CAS 时钟没有成功,则锁膨胀为重量级锁,即线程 B 被挂起阻塞、等待重新调度。
此处,如何理解“轻量级”?“轻量级”是相对于使用操作系统互斥量来实现的传统锁而言的。但是,首先需要强调一点的是,轻量级锁并不是用来代替重量级锁的,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用产生的性能消耗。
轻量级锁所适应的场景是线程交替执行同步块的情况,如果存在同一时间访问同一锁的情况,必然就会导致轻量级锁膨胀为重量级锁。
4.7 重量级锁
Synchronized 是通过对象内部的一个叫做监视器锁(Monitor)来实现的。但是监视器锁本质又是依赖于底层的操作系统的 Mutex Lock 来实现的。而操作系统实现线程之间的切换这就需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么 Synchronized 效率低的原因。因此,这种依赖于操作系统 Mutex Lock 所实现的锁我们称之为 “重量级锁”。
4.8 重量级锁、轻量级锁和偏向锁之间转换
重量级锁、轻量级锁和偏向锁之间转换
Synchronized 偏向锁、轻量级锁及重量级锁转换流程
5 锁的优劣
各种锁并不是相互代替的,而是在不同场景下的不同选择,绝对不是说重量级锁就是不合适的。每种锁是只能升级,不能降级,即由偏向锁→轻量级锁→重量级锁,而这个过程就是开销逐渐加大的过程。
- 如果是单线程使用,那偏向锁毫无疑问代价最小,并且它就能解决问题,连 CAS 都不用做,仅仅在内存中比较下对象头就可以了;
- 如果出现了其他线程竞争,则偏向锁就会升级为轻量级锁;
- 如果其他线程通过一定次数的 CAS 尝试没有成功,则进入重量级锁;
在第 3 种情况下进入同步代码块就要做偏向锁建立、偏向锁撤销、轻量级锁建立、升级到重量级锁,最终还是得靠重量级锁来解决问题,那这样的代价就比直接用重量级锁要大不少了。所以使用哪种技术,一定要看其所处的环境及场景,在绝大多数的情况下,偏向锁是有效的,这是基于 HotSpot 作者发现的“大多数锁只会由同一线程并发申请”的经验规律。
锁的优劣
6 扩展资料
还有一篇写的不错:深入理解synchronized底层原理,一篇文章就够了!
另一篇少的:synchronize原理
synchronized 的缺陷
synchronized 是 java 中的一个关键字,也就是说是 Java 语言内置的特性。那么为什么会出现 Lock 呢?
在上面一篇文章中,我们了解到如果一个代码块被 synchronized 修饰了,当一个线程获取了对应的锁,并执行该代码块时,其他线程便只能一直等待,等待获取锁的线程释放锁,而这里获取锁的线程释放锁只会有两种情况:
1)获取锁的线程执行完了该代码块,然后线程释放对锁的占有;
2)线程执行发生异常,此时 JVM 会让线程自动释放锁。
那么如果这个获取锁的线程由于要等待 IO 或者其他原因(比如调用 sleep 方法)被阻塞了,但是又没有释放锁,其他线程便只能干巴巴地等待,试想一下,这多么影响程序执行效率。
因此就需要有一种机制可以不让等待的线程一直无期限地等待下去(比如只等待一定的时间或者能够响应中断),通过 Lock 就可以办到。
再举个例子:当有多个线程读写文件时,读操作和写操作会发生冲突现象,写操作和写操作会发生冲突现象,但是读操作和读操作不会发生冲突现象。
但是采用 synchronized 关键字来实现同步的话,就会导致一个问题:
如果多个线程都只是进行读操作,所以当一个线程在进行读操作时,其他线程只能等待无法进行读操作。
因此就需要一种机制来使得多个线程都只是进行读操作时,线程之间不会发生冲突,通过 Lock 就可以办到。
另外,通过 Lock 可以知道线程有没有成功获取到锁。这个是 synchronized 无法办到的。
总结一下,也就是说 Lock 提供了比 synchronized 更多的功能。但是要注意以下几点:
1)Lock 不是 Java 语言内置的,synchronized 是 Java 语言的关键字,因此是内置特性。Lock 是一个类,通过这个类可以实现同步访问;
2)Lock 和 synchronized 有一点非常大的不同,采用 synchronized 不需要用户去手动释放锁,当 synchronized 方法或者 synchronized 代码块执行完之后,系统会自动让线程释放对锁的占用;而 Lock 则必须要用户去手动释放锁,如果没有主动释放锁,就有可能导致出现死锁现象。