在并发编程领域,有两大核心问题:一个是互斥,另一个是同步
管程技术是一把解决并发问题的万能钥匙,Java语言在1.5之前,提供的唯一并发原语就是管程并且在1.5之后提供的SDK并发包,JUC也是以管程技术为基础的
什么是管程
Java在1.5之前仅仅提供了synchronized关键字, wait() , notify() 和 notifyAll() 三个方法。在操作系统中,我们知道使用信号量能够解决所有并发问题,但是在Java中使用的是管程。synchronized关键词以及三个方法都是管程的组成部分。
管程指的是管理共享变量以及对共享变量的操作过程,让这些操作支持并发。在Java语言中,也就是管理类的成员变量和成员方法,让他们是线程安全的。这就需要保证同一时间,只有一个线程在管程中。
管程可以看作是和信号量等价的,能用管程实现信号量,也能用信号量实现管程。管程更加简单
管程模型
管程历史上有三种不同的管程模型,分别是Hasen模型、Hoare模型以及MESA模型。MESA模型广泛使用,Java管程的实现参考的也是MESA模型
MESA模型
使用入口等待队列保证了线程之间的互斥,使用条件变量的等待队列保证了线程之间的同步(线程操作共享变量之前需要检查是否满足条件)。条件变量可以有多个。
把找医生看病的流程用来举例子:
- 当医生还在给别人看病的时候,你只能够在门外等待,也就是在入口等待队列等待。
- 当等到你的时候,就进入管程中,这个时候判断条件变量是否满足,就像医生发现你还没抽血,就让你出门抽血去了。也就是进入条件变量等待队列,然后其他线程也能进入管程了
- 当你抽血完成,满足条件后,继续在条件变量等待队列中等待,因为其他线程还在管程中,当有一个线程从线程中退出的时候,就会通知在条件变量中满足条件的线程,然后又重新在入口队列中等待。
- 多个线程进入管程的入口队列e,并试图获取临界区锁。获取到锁的线程进入临界区,其他线程仍然在e中。
- 通过外部条件来判断进入临界区的线程是否能执行操作,分为以下3、4两种情况。
- 如果不能执行,则调用wait原语,该线程阻塞,释放临界区的锁,离开临界区并根据条件进入a.q或者b.q。
- 如果能执行,那么在执行完毕后调用notify原语(相当于signal),唤醒a.q或b.q中的一个线程。执行完毕的线程释放锁,并离开管程的作用域。
- 被唤醒的线程进入队列e,返回第1步重新开始。
下面以一段代码,结合await()和signal()来解释
1 | // 线程安全的阻塞队列 |
注意,上面的while循环调用wait,是MESA管程特有的
模型对比
管程要求同一时刻只允许一个线程执行,当线程T2的操作,使得T1等待的条件满足后,T1和T2执行的顺序在不同的管程模型中不同
Hasen 模型里面,要求 notify() 放在代码的最后,这样 T2 通知完 T1 后,T2 就结束了,然后 T1 再执行,这样就能保证同一时刻只有一个线程执行。
Hoare 模型里面,T2 通知完 T1 后,T2 阻塞,T1 马上执行;等 T1 执行完,再唤醒 T2,也能保证同一时刻只有一个线程执行。但是相比 Hasen 模型,T2 多了一次阻塞唤醒操作。
MESA 管程里面,T2 通知完 T1 后,T2 还是会接着执行,T1 并不立即执行,仅仅是从条件变量的等待队列进到入口等待队列里面。这样做的好处是 notify() 不用放到代码的最后,T2 也没有多余的阻塞唤醒操作。但是也有个副作用,就是当 T1 再次执行的时候,可能曾经满足的条件,现在已经不满足了,所以需要以循环方式检验条件变量。
MESA管程相对于其他两个模型更加公平,Hoare会让先唤醒的限制性,立刻切换上下文
使用notify()
如果没有深思熟虑,尽量使用notifyAll(),notify()使用的条件比较苛刻
- 所有等待线程拥有相同的等待条件
- 所有等待线程被唤醒后,执行相同的操作
- 只需要唤醒一个线程
上面的例子中,对于notFull条件变量,所有的等待条件都是wait not full,唤醒后执行的操作也都相同
Java Synchronized中的管程
Java中内置的管程synchronized对于MESA模型进行了精简,只有一个条件变量‘
’
synchronized关键字修饰的代码块,在编译时候会自动生成相关加锁和解锁的代码,但是仅仅支持一个条件变量,JUC并发包中实现的管程支持多个条件变量,但是需要开发人员自己进行加锁解锁操作
Synchronized锁什么
1 | public class SynchronizedExample { |
上述的代码给出了synchronized的四种用法,代码块锁this对象,代码块锁其他对象,代码块锁Class,static方法锁Class,非static方法锁this
synchronized其实也就锁了两个东西,Class和Object,锁Class根本还是锁的是这个类加载的时候默认创建的一个Object
Syncronized怎么锁
对于下面的代码,执行 javac SynchronizedTst.java 然后 javap -v SynchronizedTst.class
1 | public class SynchronizedTst { |
得到反编译的代码
1 | ... |
可以看出,
对于代码块反编译后,输出的字节码有monitorenter以及monitorexit语句,无论是锁对象还是锁类
对于非静态方法反编译后,输出的字节码有ACC_SYNCHRONIZED标志
对于静态方法反编译后,输出的字节码有ACC_SYNCHRONIZED和ACC_STATIC标志
所以在字节码层面:
- synchronized 的作用域不同,JVM 底层实现原理也不同
- synchronized 代码块是通过 monitorenter 和 monitorexit 来实现其语义的
- synchronized 方法是通过 ACC_SYNCRHONIZED 来实现其语义的,当线程执行有ACC_SYNCRHONIZED标志的方法,需要获得monitor锁
monitorenter和monitorexit
monitorenter
Each object is associated with a monitor. A monitor is locked if and only if it has an owner. The thread that executes monitorenter attempts to gain ownership of the monitor associated with objectref, as follows:
If the entry count of the monitor associated with objectref is zero, the thread enters the monitor and sets its entry count to one. The thread is then the owner of the monitor.
If the thread already owns the monitor associated with objectref, it reenters the monitor, incrementing its entry count.
If another thread already owns the monitor associated with objectref, the thread blocks until the monitor’s entry count is zero, then tries again to gain ownership.
每个对象都与一个monitor 相关联。当且仅当monitor被拥有的,monitor才会被锁定。执行到monitorenter指令的线程,会尝试去获得对应的monitor,如下:
每个对象维护着一个记录着被锁次数的计数器, 对象未被锁定时,该计数器为0。线程进入monitor(执行monitorenter指令)时,会把计数器设置为1.
当同一个线程再次获得该对象的锁的时候,计数器再次自增.
当其他线程想获得该monitor的时候,就会阻塞,直到计数器为0才能成功。
monitorexit
The thread that executes monitorexit must be the owner of the monitor associated with the instance referenced by objectref.
The thread decrements the entry count of the monitor associated with objectref. If as a result the value of the entry count is zero, the thread exits the monitor and is no longer its owner. Other threads that are blocking to enter the monitor are allowed to attempt to do so.
monitor的拥有者线程才能执行 monitorexit指令。
线程执行monitorexit指令,就会让monitor的计数器减一。如果计数器为0,表明该线程不再拥有monitor。其他线程就允许尝试去获得该monitor了。
ACC_SYNCRHONIZED
ACC_SYNCRHONIZED
Method-level synchronization is performed implicitly, as part of method invocation and return. A synchronized method is distinguished in the run-time constant pool’s methodinfo structure by the ACCSYNCHRONIZED flag, which is checked by the method invocation instructions. When invoking a method for which ACC_SYNCHRONIZED is set, the executing thread enters a monitor, invokes the method itself, and exits the monitor whether the method invocation completes normally or abruptly. During the time the executing thread owns the monitor, no other thread may enter it. If an exception is thrown during invocation of the synchronized method and the synchronized method does not handle the exception, the monitor for the method is automatically exited before the exception is rethrown out of the synchronized method.
方法级别的同步是隐式的,作为方法调用的一部分。同步方法的常量池中会有一个ACC_SYNCHRONIZED标志。
当调用一个设置了ACC_SYNCHRONIZED标志的方法,执行线程需要先获得monitor锁,然后开始执行方法,方法执行之后再释放monitor锁,当方法不管是正常return还是抛出异常都会释放对应的monitor锁。
在这期间,如果其他线程来请求执行方法,会因为无法获得管程锁而被阻断住。
如果在方法执行过程中,发生了异常,并且方法内部并没有处理该异常,那么在异常被抛到方法外面之前监视器锁会被自动释放。
ObjectMonitor
在Java虚拟机中,Monitor是由ObjectMonitor实现的,依赖于底层的操作系统的Mutex Lock(互斥锁)来实现的线程同步,使用Mutex Lock需要将当前线程挂起并从用户态切换到内核态来执行
主要结构如下
1 | ObjectMonitor() { |
- 想要获取monitor的线程,首先会进入_EntryList队列。
- 当某个线程获取到对象的monitor后,进入Owner区域,设置为当前线程,同时计数器count加1。
- 如果线程调用了wait()方法,则会进入WaitSet队列。它会释放monitor锁,将owner赋值为null,count自减1,进入WaitSet队列阻塞等待。
- 如果其他线程调用 notify() / notifyAll() ,会唤醒WaitSet中的某个线程,该线程再次尝试获取monitor锁,成功即进入Owner区域。
- 同步方法执行完毕了,线程退出临界区,会将monitor的owner设为null,并释放监视锁。
线程执行时如何与锁住的对象或者类的monitor关联
HotSpot堆中的对象实例由对象头、实例数据和对其填充3部分组成
- 对象头:Mark Word和Class Point
- 实例数据:对象的属性数据,父类的属性数据
- 对齐填充,保证对象起始地址是8字节的整数倍
对象头
对象头分为Mark Word以及类元数据指针,MarkWord非固定,存储对象自身的运行时数据,如下表
不同标识位(不同状态),存储的是不同的
每个对象都有一个monitor与之关联,对象与monitor可以一起销毁,或者在线程试图获取对象锁的时候自动生成,但是当一个monitor被线程持有后,就处于锁定状态。
synchronized是重量级锁,当对象被锁住的时候,MarkWord锁标志位10,指针指向的就是ObjectMonitor的地址。
锁住类而不是锁住对象的时候,monitor的指针在哪里呢?
在Java中,每个类都有一个Class对象,当虚拟机在类加载过程中,会自动创建一个Class对象来表示该类,该类的每个实例对象都包含一个指向该Class对象的引用,可以通过getClass()来访问。
所以锁住类的时候,monitor的指针就存在这个类的Class对象里
锁升级
在JDK1.6之前,使用synchronized就意味着使用重量级锁,即直接调用 ObjectSynchronizer::enter() 方法。每个锁都有一个monitor对象,当有很多线程同时访问的时候,线程的阻塞和唤醒需要CPU切换上下文,而锁状态一般持续时间很短,导致性能下降。
从JDK1.6开始,synchronized实现中引入偏向锁、轻量级锁、适应性自旋锁、锁消除等大量优化。
无锁
所有线程都能够访问并修改同一个资源,但同时只有一个线程能修改成功
无锁让修改操作在循环内进行,不断的尝试修改,也就是CAS,如果有多个线程修改同一个值,必定有一个线程能够修改成功,修改失败的线程也会重试,直到成功。
在这个状态下,线程都快乐运行
偏向锁
Java15废弃了偏向锁,因为撤销很复杂
大多数情况下,锁总是由同一个线程多次获得,不存在多线程竞争,所以出现了偏向锁,这样一个线程执行同步代码块的时候能够提高性能。
线程先判断是否为可偏向状态,然后判断Mark Word是否是当前线程的ID
- 如果不是,就用CAS在对象头的MarkWord中存储自己的线程ID
- 失败了,走撤销流程,当到达全局安全点(safepoint)时获得偏向锁的线程被挂起,偏向锁升级为轻量级锁
- 成功了就继续
- 如果是,就直接进入临界区
- 如果是空的,就CAS修改,冲突了还是得走撤销流程
这样线程进入、离开临界区的时候不用通过CAS操作来加锁和解锁,而是判断MarkWord中是否存储当前线程的偏向锁
这样减少了轻量级锁中多次CAS指令的消耗,只需要在置换ThreadID的时候用CAS换。
所以说偏向锁和无锁的标志位都是01,因为都没有锁,偏向锁靠字段biased_lock来区分,1代表使用
当遇到其他线程尝试竞争偏向锁的时候,持有偏向锁的线程就会释放,并且线程不会主动释放,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态。撤销偏向锁后恢复到无锁(标志位为“01”)或轻量级锁(标志位为“00”)的状态。
安全点会导致stw,导致性能下降,这种情况下应当禁用。所以一般JVM并不是一开始就开启偏向锁的,而是有一定的延迟,这也就是为什么会有无锁态的原因
关闭偏向锁的启动延迟 -XX:BiasedLockingStartupDelay=0
关闭偏向锁 -XX:-UseBiasedLocking=false
轻量级锁
monitorenter从无锁到加锁的时候,会根据是否启用偏向锁(UseBiasedLocking)来决定是使用偏向锁(调用
ObjectSynchronizer::fast_enter()方法)还是轻量级锁(调用ObjectSynchronizer::slow_enter()方法)
当锁是偏向锁的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁,试图抢占的线程一开始会使用自旋+CAS来获取锁,不阻塞从而提高性能。
加锁过程
1 | void ObjectSynchronizer::slow_enter(Handle obj, BasicLock* lock, TRAPS) { |
在代码进入临界区的时候
如果是无锁状态,就会在线程栈中创建一个Lock Record的空间,然后把对象头中的Mark Word复制到Lock Record中的_displaced_header字段。用以存储无锁状态的Mark Word,待释放锁时恢复
拷贝成功后,使用CAS更新对象的Mark Word,将其更新为Lock Record的指针,并且Lock Record里面的obj指针指向对象头。表示该Lock Record所在的线程获取了轻量级锁
如果当前是轻量级锁状态并且是当前线程,原先header中的displaced_header置空
更新成功后,线程就拥有了该对象的锁,并且更新锁标志位为00,进入临界区
更新失败了,则说明有其他线程比他替换得更快,就只有开始自旋去获取锁了
如果当前只有一个等待线程,那么等待线程就自旋获取锁,因为临界区的时间通常很短,就不用阻塞线程进行上下文切换。但是自旋是一直消耗CPU的,所以当自旋超过一定次数,或者有线程在持有锁,有线程在自旋,又来了新的线程,就升级为重量级锁
重量级锁
如果另外的线程尝试获取锁的时候,轻量锁正被其他线程占有,自旋多次失败后,就会修改markword,锁状态升级,表示该进入重量锁
轻量级锁释放的时候,使用CAS,如果发现当前的markword和之前拷贝在lock record中的不一样,就切换到重量级锁
锁的状态变为10,MarkWord存储的是指向重量级锁的指针,也就是ObjectMonitor的指针,等待锁的线程都会阻塞
综上,偏向锁通过对比Mark Word解决加锁问题,避免执行CAS操作。而轻量级锁是通过用CAS操作和自旋来解决加锁问题,避免线程阻塞和唤醒而影响性能。重量级锁是将除了拥有锁的线程以外的线程都阻塞。
锁自旋
为了让当前线程“稍等一下”,我们需让当前线程进行自旋,如果在自旋完成后前面锁定同步资源的线程已经释放了锁,那么当前线程就可以不必阻塞而是直接获取同步资源,从而避免切换线程的开销
自旋锁的实现原理同样也是CAS,AtomicInteger中调用unsafe进行自增操作的源码中的do-while循环就是一个自旋操作,如果修改数值失败则通过循环来执行自旋,直至修改成功
自旋等待的时间必须要有一定的限度,如果自旋超过了限定次数(默认是10次,可以使用-XX: PreBlockSpin来更改),就需要采取一定措施
自旋锁在JDK1.4.2中引入,使用-XX:+UseSpinning来开启。JDK 6中变为默认开启,并且引入了自适应的自旋锁(适应性自旋锁)
自适应意味着自旋的时间(次数)不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定,如果之前成功了,这次自旋将会有更长的时间,如果之前都很少成功,这次将忽略自旋,直接阻塞
可重入
synchronized是可重入锁
- 偏向锁:检查markWord中的线程ID是否是当前线程,如果是的话就获取锁,继续执行代码;
- 轻量级锁:检查markWord中指向lockRecord的指针是否是指向当前线程的lockRecord,是的话继续执行代码;
- 重量级锁:检查_owner属性,如果该属性指向了本线程,_count属性+1,并继续执行代码。
锁粗化
避免连续的加锁解锁操作,直接把锁连接在一起,拓展成一个范围更大的锁
锁消除
是jvm检测到这段程序里不存在共享数据竞争问题,也就是变量没有逃逸出方法外,这个时候jvm就会把这个锁消除掉
Reference
http://gk.link/a/121Hg
https://juejin.cn/post/7136205392337436686
https://www.jianshu.com/p/e624460c645c
https://juejin.cn/post/7007656138518822925
https://blog.csdn.net/weixin_40910372/article/details/107726978
[https://tech.meituan.com/2018/11/15/java-lock.html](