synchronized 的基本认识
在多线程并发编程中synchronized一直是元老级角色,很多人都会称呼它为重量级锁。但是,随着 Java SE 1.6 对 synchronized进行了各种优化之后,有些情况下它就并不那么重,Java SE 1.6中为了减少获得锁和释放锁带来的性能消耗而引入的偏向锁和轻量级锁。
synchronized 的基本语法
- synchronized有三种方式来加锁。
- 修饰实例方法,作用于当前实例加锁,进入同步代码前要获得当前实例的锁
- 静态方法,作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁
- 修饰代码块,指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。 不同的修饰类型,代表锁的控制粒度
锁的存储原理
- 锁需要的因素
- 锁需要有一个东西来表示,比如获得锁是什么状态、无锁状态是什么状态。
- 这个状态需要对多个线程共享。
synchronized(lock)是基于 lock 这个对象的生命周期来控制锁粒度的,我们以对象在 JVM 内存中是如何存储作为切入点,去看看对象里面有什么特性能够实现锁
- 对象在内存中的布局
在 Hotspot 虚拟机中,对象在内存中的存储布局,可以分为三个区域:对象头(Header)、实例数据(Instance Data)、对齐填充(Padding)。而对象头中就包含了和锁有关的信息。
- 总体布局
- 源码中对于对象头信息中关于锁信息的保存
- 我们把该对象称为 Mark word
Mark word 记录了对象和锁有关的信息,当某个对象被 synchronized关键字当成同步锁时,那么围绕这个锁的一系列操作都和Mark word有关系。Mark Word在32位虚拟机的长度是32bit、在64位虚拟机的长度是64bit。 Mark Word里面存储的数据会随着锁标志位的变化而变化,也就是说他会根据不同的标志位对32bit进行解析,例如轻量级锁,他会把前30位bit当作整体记录存储拿到锁的线程的栈的地址,后两位记录锁标志位。
为什么任何对象都可以实现锁
- 首先,Java 中的每个对象都派生自 Object 类,而每个Java Object 在 JVM 内部都有一个 native 的 C++对象 oop/oopDesc进行对应。
- 线程在获取锁的时候,实际上就是获得一个监视器对象 (monitor) ,monitor 可以认为是一个同步对象,所有的 Java 对象是天生携带 monitor。在 hotspot 源码的 markOop.hpp文件中,可以看到下面这段代码。
- monitor对象也保存在每个对象的对象头中。
- 多个线程访问同步代码块时,相当于去争抢对象监视器 修改对象中的锁标识,上面的代码中ObjectMonitor这个对象和线程争抢锁的逻辑有密切的关系。
synchronized 锁的升级
在分析 markword 时,提到了偏向锁、轻量级锁、重量级锁。在分析这几种锁的区别时,我们先来思考一个问题使用锁能够实现数据的安全性,但是会带来性能的下降。不使用锁能够基于线程并行提升程序性能,但是却不能保证线程安全性。
所以 synchronized 在 JDK1.6 之后做了一些优化,为了减少获得锁和释放锁带来的性能开销,引入了偏向锁、轻量级锁的概念。因此大家会发现在synchronized中,锁存在四种状态分别是:无锁、偏向锁、轻量级锁、重量级锁。
锁的状态根据竞争激烈的程度从低到高不断升级。
- 这四种锁状态都是synchronized只不过会根据不同的状态,拿锁的方法会改变。
偏向锁
- 偏向锁只有在一个线程访问时会存在,只要有另一个线程试图拿锁,偏向锁就会升级。
当一个线程访问加了同步锁的代码块时,会在对象头中存储当前线程的ID,后续这个线程进入和退出这段加了同步锁的代码块时,不需要再次加锁和释放锁。而是直接比较对象头里面是否存储了指向当前线程的偏向锁。如果相等 示偏向锁是偏向于当前线程的,就不需要再尝试获得锁了。
CAS
- 先把CAS(Compare And Swap)这个方法说清楚。
CAS比较并交换,它是一条CPU并发原语。它的功能是判断内存某个位置的值是否为预期值,如果是则更新为新的值,这个过程是原子的。
那么在偏向锁中就是比较对象中存储的线程ID值是否和当前线程ID值一致。(和MYSQL的MVCC思想很像)
偏向锁的获取
- 首先获取锁 对象的Markword,判断是否处于可偏向状态。(biased_lock=1、且ThreadId为空)。
- 如果是可偏向状态,则通过CAS操作,把当前线程的ID 写入到MarkWord
- 如果 cas 成功,那么 markword 就会变成这样。 表示已经获得了锁对象的偏向锁,接着执行同步代码块
- 如果 cas 失败,说明有其他线程已经获得了偏向锁, 这种情况说明当前锁存在竞争,需要撤销已获得偏向锁的线程,并且把它持有的锁升级为轻量级锁(这个操作需要等到全局安全点,也就是没有线程在执行字节码)才能执行
- 如果是已偏向状态,需要检查 markword 中存储的 ThreadID是否等于当前线程的ThreadID。
- 如果相等,不需要再次获得锁,可直接执行同步代码块
- 如果不相等,说明当前锁偏向于其他线程,需要撤销偏向锁并升级到轻量级锁
偏向锁的撤销
偏向锁的撤销并不是把对象恢复到无锁可偏向状态(因为偏向锁并不存在锁释放的概念),而是在获取偏向锁的过程中,发现 cas 失败也就是存在线程竞争时,直接把被偏向的锁对象升级到轻量级锁的状态。对原持有偏向锁的线程进行撤销时,原获得偏向锁的线程有两种情况:
- 原获得偏向锁的线程如果已经退出了临界区,也就是同步代码块执行完了,那么这个时候会把对象头设置成没有任何线程拿到锁的状态并且争抢锁的线程可以基于 CAS 重新偏向当前线程。
- 如果原获得偏向锁的线程的同步代码块还没执行完,处于临界区之内,这个时候会把原获得偏向锁的线程升级为轻量级锁后继续执行同步代码块
- 我们可以通过设置Jvm参数来关闭偏向锁这个状态,也就是说只要加了synchronized那么他的初始状态就是轻量锁
轻量锁
锁升级为轻量级锁之后,对象的 Markword 也会进行相应 的的变化。升级为轻量级锁的过程:
- 线程在自己的栈桢中创建锁记录 LockRecord。
- 将锁对象的对象头中的MarkWord复制到线程的刚刚创建的锁记录中。
- 将锁记录中的Owner指针指向锁对象。
- 将锁对象的对象头的MarkWord替换为指向锁记录的指针。
- 升级完成后的状态
自旋锁
轻量级锁这个概念的底层有了自旋锁的方法来实现。
所谓自旋,就是指当有另外一个线程来竞争锁时,这个线程会在原地循环等待,而不是把该线程给阻塞,直到那个获得锁的线程释放锁之后,这个线程就可以马上获得锁
注意,锁在原地循环的时候,是会消耗cpu的,就相当于在执行一个啥也没有的for循环。
轻量级锁的升级
- 控制最大自旋次数
自旋必须要有一定的条件控制,否则如果一个线程执行同步代码块的时间很长,那么这个线程不断的循环反而会消耗CPU资源。默认情况下自旋的次数是10次,可以通过preBlockSpin来修改,超过了直接阻塞线程。
- 自适应自旋
自适应意味着自旋的次数不是固定不变的,而是根据前一次在同一个锁上自旋的时间以及锁的拥有者的状态来决定。
如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过, 那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。
- 阻塞线程就相当于升级为重量级锁
轻量级锁的升级
轻量级锁的锁释放逻辑其实就是获得锁的逆向逻辑,通过 CAS 操作把线程栈帧中的 LockRecord 替换回到锁对象的 MarkWord 中,如果成功表示没有竞争。如果失败,表示当前锁存在竞争已经膨胀成为重量级锁。把 MarkWord 中的锁状态替换为重量级锁,线程的LockRecord指向重量级锁。
重量级锁
当轻量级锁膨胀到重量级锁之后,意味着线程只能被挂起阻塞来等待被唤醒了。
- 重量级锁的 monitor 对象
加了同步代码块以后,在字节码中会看到一个 monitorenter和monitorexit。
每一个 JAVA 对象都会与一个监视器 monitor 关联,我们可以把它理解成为一把锁,当一个线程想要执行一段被synchronized修饰的同步方法或者代码块时,该线程得先获取到synchronized修饰的对象对应的monitor。monitorenter表示去获得一个对象监视器。monitorexit表示释放 monitor 监视器的所有权,使得其他被阻塞的线程可以尝试去获得这个监视器 monitor 依赖操作系统的 MutexLock(互斥锁)来实现的, 线程被阻塞后便进入内核(Linux)调度状态,这个会导致系统在用户态与内核态之间来回切换,严重影响锁的性能。
任意线程对 Object(Object 由 synchronized 保护)的访问,首先要获得Object的监视器。如果获取失败,线程进入同步队列,线程状态变为 BLOCKED。当访问 Object 的前驱(获得了锁的线程)释放了锁,则该释放操作唤醒阻塞在同步队列中的线程,使其重新尝试对监视器的获取。
锁状态总结
偏向锁
此时当Thread#1进入临界区时,JVM会将lockObject的 对象头Mark Word的锁标志位设为“01”,同时会用CAS操作把 Thread#1 的线程 ID 记录到 Mark Word 中,此时进入偏向模式。所谓“偏向”,指的是这个锁会偏向于Thread#1, 若接下来没有其他线程进入临界区,则 Thread#1 再出入 临界区无需再执行任何同步操作。也就是说,若只有 Thread#1 会进入临界区,实际上只有 Thread#1 初次进入临界区时需要执行CAS操作,以后再出入临界区都不会有同步操作带来的开销。
轻量级锁
偏向锁的场景太过于理想化,更多的时候是 Thread#2 也会尝试进入临界区,如果 Thread#2 也进入临界区但是 Thread#1 还没有执行完同步代码块时,会暂停 Thread#1 并且升级到轻量级锁。Thread#2通过自旋再次尝试以轻量级锁的方式来获取锁
重量级锁
如果Thread#1和Thread#2正常交替执行,那么轻量级锁基本能够满足锁的需求。但是如果Thread#1和Thread#2 同时进入临界区,或者自选次数过多,那么轻量级锁就会膨胀为重量级锁,意味着 Thread#1 线程获得了重量级锁的情况下 Thread#2就会被阻塞。
wait,notify,notifyAll
- wait:表示持有对象锁的线程 A 准备释放对象锁权限,释放cpu资源并进入等待状态。
- 当一个线程wait后,会保存执行的位置,下次拿到锁执行后,会接着执行。
- notify:表示持有对象锁的线程A准备释放对象锁权限, 知 jvm 唤醒某个竞争该对象锁的线程 X。线程 A synchronized 代码执行结束并且释放了锁之后,线程X直接获得对象锁权限,其他竞争线程继续等待(即使线程X同步完毕,释放对象锁,其他竞争线程仍然等待,直至有新的notify ,notifyAll被调用)。
- notifyAll:notifyall和notify的区别在于,notifyAll会唤醒 所有竞争同一个对象锁的所有线程,当已经获得锁的线程 A 释放锁之后,所有被唤醒的线程都有可能获得对象锁权限
-
notify和notifyAll都可以理解位随机从等待队列中拿出一个线程执行
需要注意的是:三个方法都必须在synchronized 同步关键字所限定的作用域中调用,否则会报错 java.lang.IllegalMonitorStateException ,意思是因为没有同步,所以线程对对象锁的状态是不确定的,不能调用这些方法。
另外,通过同步机制来确保线程从wait方法返回时能够感知到感知到notify线程对变量做出的修改 wait