多线程中的锁
首先讲讲锁的分类
锁的分类
- 公平锁/非公平锁
- 可重入锁(递归锁)
- 独享锁/共享锁
- 独享锁/共享锁
- 互斥锁/读写锁
- 乐观锁/悲观锁
- 分段锁
- 偏向锁/轻量级锁/重量级锁
- 自旋锁/自适应自旋锁
- 锁粗化/锁消除
公平锁和非公平锁
线程挂起和线程真正运行之间存在着很长的时间差
公平锁
-
多个线程按照申请锁的顺序去获取锁,线程会在一个FIFO队列中排队,队列首个线程才能获取锁
-
优点:所有线程都能获取锁, 不会出现线程饥饿的情况
-
缺点:效率较低,吞吐量会下降,因为只有第一个线程才能获取锁,其余线程均处于阻塞状态,cpu唤醒阻塞线程的开销较大,从而导致效率低。
非公平锁
-
多个线程同时去获取锁,如果没有获取到锁,重新进入等待队列中,等待下一次获取锁,如果获取到了锁,会执行相应线程。
-
优点:效率较高,充分利用cpu的时间片,cpu的空闲时间减少,利用率提高。
-
缺点:存在线程饥饿的情况,最坏的可能是线程获取不到锁,出现线程饿死的情况。
可重入锁(递归锁)
可重入锁指的是一个线程获取了一个锁,此线程重新获取这个锁,不会造成死锁。
- 可重入锁指的是同一个线程重新获得同一个锁不会造成死锁
Synchronized和ReentrantLock都是可重入锁。
- 但是这两者还是略微有点区别, Synchronized获取锁的时候是自动释放锁,而ReentrantLock获取锁必须要手动释放锁,否则别的线程无法获取锁(获取锁的次数必须要和释放锁的次数相同)
- ReentrantLock可重入锁的实现,当前线程已经获取到锁的时候,重新获取锁会把state加1,这就保证了同一个锁可以被同一个线程获取多次,每次释放都会将state-1,如果释放次数少于获取锁的次数,则其余线程无法获取锁
可重入锁的实际应用
public class Demo1 {
public synchronized void functionA(){
System.out.println("FunctionA");
functionB();
}
public synchronized void functionB(){
System.out.println("FunctionB");
}
}
如上代码: 当一个带有锁的方法调用另外一个带有锁的方法, 如果不是可重入锁,就会产生死锁,因为无法获取到第二把锁。因为可重入锁可以实现锁的递归,即锁的外层嵌套锁。
独享锁/共享锁
独享锁
- 从理论上来说,独享锁就是互斥锁,当一个线程获取了独享锁之后,其余线程无法获取锁只能进入等待状态,Synchronized和ReenTrantLock都是独享锁
共享锁
- 当一个线程获取了共享锁之后,其余线程依然能够获得此共享锁,典型例子RenentrantReadWriteLock(读写锁)的读锁就是一个共享锁,在没有写锁的情况下,读书可以被多个线程获取,但是只要有一个线程获取了写锁,其余的线程无法获取到读锁和写锁。因此写锁也是一个独享锁。
互斥锁/读写锁
互斥锁
-
具有唯一性和排他性
-
典型的例子就是Synchronized和ReenTrantLock以及RenentrantReadWriteLock中的写锁
-
只要有一个线程获取互斥锁,其余的线程只能进入等待状态,无法获取锁
读写锁
- 典型例子RenentrantReadWriteLock
- 在没有写锁的情况下,读锁可以被多个线程获取
- 如果有线程获取了写锁,那么其余线程无法获取写锁和读锁,只能进入等待装填
- 获取写锁的线程会被优先唤醒,如果有读锁的存在,那么必须要等所有的读锁全部释放掉才能获取写锁
乐观锁/悲观锁
悲观锁
- 悲观地认为程序处于高并发状态,在数据处理的过程中,使数据处于锁定状态。在java中Synchronized和ReenTrantLock等独占锁都是悲观锁
- 共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程
- 悲观锁适用于写比较多的情况下(多写场景)
乐观锁
-
乐观地认为程序的并发程度不深,认为每次去拿数据的时候都不会被其他线程修改,所以不会再处理数据的时候锁定数据。
-
在拿到数据的时候保存拿到的数据,当要进行修改的时候再次获取这个数据和之前拿到的数据进行对比,如果数据一致,才进行操作。
-
乐观锁适用于写比较少的情况下(多读场景)
-
常见的乐观锁操作就是CAS操作,全民Compare And Swap(是一个非阻塞同步操作),主要有三个参数需要读写的内存值V,再次读取的值A,需要修改成的值B。只有当V和A相同时,才用新值B来替换V。如果前后两次读取的值不一样,则通过自旋操作(不断重试)直到修改成功。
-
CAS操作会带来一个ABA问题,ABA问题的意思如下
如果有三个线程对A值进行操作
第一个线程第一次读取A值
第二个线程将A值修改成了B值
第三个线程将B值修改成了A值
第一个线程第二次读取A值会认为前后两个A值一样,会进行替换操作
但是实际上前后两次的A值并不是同一个A值
为了解决这个问题,加入一个版本号,每次修改值之后可以改变版本号,这样就知道值是否被修改过JUC中的原子类操作都是基于CAS的
-
分段锁
分段锁出自jdk1.7中的ConcurrentHashMap,在jdk1.8中去掉了分段锁,但是依然有分段的影子在里面。
ConcurrentHashMap在1.7中为了提高并发效率,将Map划分成了很多个Segment, 每一个Segment都实现了RenentrantLock,这个锁就叫做分段锁,只有对每一个分段Segment进行操作的时候才对这个分段进行上锁,不影响其他线程对其他的Segment进行操作。
偏向锁/轻量级锁/重量级锁
偏向锁
- 作用:减少在没有竞争和只有一个线程访问的情况下轻量级锁带来的性能消耗。
- 如何减少性能消耗?
- 轻量级锁每次获取锁和释放锁至少会进行一次CAS操作,而偏向锁只有在第一次才会进行CAS操作。
- 具体实现
- 偏向锁的前提条件是没有竞争或者是只有一个申请锁的线程(不会有其他线程来申请锁),因此只需要利用CAS操作在MarkWord中记录下线程信息,如果记录成功,则偏向锁获取成功,记录锁的状态改为偏向锁,以后当前线程就能直接获取锁而不需要额外开销,否则,表示有其他线程竞争,则膨胀成轻量级锁。
- 偏向锁无法使用自旋锁优化,因为一旦有其他线程申请锁,那么偏向锁膨胀成轻量级锁
- 缺点:如果存在其他线程申请锁,立马膨胀成轻量级锁
轻量级锁
-
作用:减少在没有实际竞争的情况下,重量级锁带来的性能开销
-
如何减少性能开销?
- 使用CAS来代替互斥量
-
具体实现
-
当一个没有锁的线程(锁标志位为“01”状态,是否为偏向锁为“0”)来申请锁的时候
-
首先在栈帧上创建LockRecord, 将对象投中的MarkWord复制到锁记录(LockRecord)中。
-
拷贝成功后,通过CAS操作将MarkWord指向LockRecord,并将所记录中的owner指向Object的LockRecord。
-
如果这个更新动作成功了,那么这个线程的锁状态会被更改为00,即轻量级锁
-
如果操作失败,检查对象的MarkWord是否指向当前线程的栈帧。如果是的那么说明当前线程已经拥有了这个对象的锁。直接进入同步代码块继续执行。如果不是,说明有多个线程竞争锁, 轻量级锁会膨胀成重量级锁。锁的状态变成了“10”,MarkWord中存储的是指向重量级锁(互斥量)的指针,后面等待锁的线程也进入阻塞状态
如果存在锁竞争但是不激烈的情况下,可以通过自旋锁优化,自旋失败后再膨胀为重量级锁
-
重量级锁
- 重量级锁也就是通常说synchronized的对象锁, 锁标志位位“10”, 指针指向监视器锁。每一个对象都有一个monitor与之相关联。monitor可以和对象一起创建销毁或者当对象锁自动生成,当一个monitor被某个线程持有后,对象便处于锁定状态。通过monitor中的计数器count来判断对象是否被锁定。
自旋锁/自适应自旋锁
自旋锁
减少线程阻塞造成的线程切换
首先,内核态与用户态的切换上不容易优化。但通过自旋锁,可以减少线程阻塞造成的线程切换(包括挂起线程和恢复线程)。
如果锁的粒度小,那么锁的持有时间比较短(尽管具体的持有时间无法得知,但可以认为,通常有一部分锁能满足上述性质)。那么,对于竞争这些锁的而言,因为锁阻塞造成线程切换的时间与锁持有的时间相当,减少线程阻塞造成的线程切换,能得到较大的性能提升。具体如下:
- 当前线程竞争锁失败时,打算阻塞自己
- 不直接阻塞自己,而是自旋(空等待,比如一个空的有限for循环)一会
- 在自旋的同时重新竞争锁
- 如果自旋结束前获得了锁,那么锁获取成功;否则,自旋结束后阻塞自己
如果在自旋的时间内,锁就被旧owner释放了,那么当前线程就不需要阻塞自己(也不需要在未来锁释放时恢复),减少了一次线程切换。
“锁的持有时间比较短”这一条件可以放宽。实际上,只要锁竞争的时间比较短(比如线程1快释放锁的时候,线程2才会来竞争锁),就能够提高自旋获得锁的概率。这通常发生在锁持有时间长,但竞争不激烈的场景中。
缺点
- 单核处理器上,不存在实际的并行,当前线程不阻塞自己的话,旧owner就不能执行,锁永远不会释放,此时不管自旋多久都是浪费;进而,如果线程多而处理器少,自旋也会造成不少无谓的浪费。
- 自旋锁要占用CPU,如果是计算密集型任务,这一优化通常得不偿失,减少锁的使用是更好的选择。
- 如果锁竞争的时间比较长,那么自旋通常不能获得锁,白白浪费了自旋占用的CPU时间。这通常发生在锁持有时间长,且竞争激烈的场景中,此时应主动禁用自旋锁。
使用-XX:-UseSpinning参数关闭自旋锁优化;-XX:PreBlockSpin参数修改默认的自旋次数。
自适应自旋锁
自适应意味着自旋的时间不再固定了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定:
- 如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而它将允许自旋等待持续相对更长的时间,比如100个循环。
- 相反的,如果对于某个锁,自旋很少成功获得过,那在以后要获取这个锁时将可能减少自旋时间甚至省略自旋过程,以避免浪费处理器资源。
自适应自旋解决的是“锁竞争时间不确定”的问题。JVM很难感知到确切的锁竞争时间,而交给用户分析就违反了JVM的设计初衷。自适应自旋假定不同线程持有同一个锁对象的时间基本相当,竞争程度趋于稳定,因此,可以根据上一次自旋的时间与结果调整下一次自旋的时间。
缺点
然而,自适应自旋也没能彻底解决该问题,如果默认的自旋次数设置不合理(过高或过低),那么自适应的过程将很难收敛到合适的值。
锁粗化/锁消除
锁粗化
当一个线程高频地请求, 同步和释放锁,会消耗系统资源。在这种情况下把多次锁请求合并成一次锁请求,减小锁请求,同步,释放带来的性能损耗。
锁粗化的情况:
public void doSomethingMethod(){
synchronized(lock){
//do some thing
}
//这是还有一些代码,做其它不需要同步的工作,但能很快执行完毕
synchronized(lock){
//do other thing
}
}
合并请求后的代码
public void doSomethingMethod(){
//进行锁粗化:整合成一次锁请求、同步、释放
synchronized(lock){
//do some thing
//做其它不需要同步但能很快执行完的工作
//do other thing
}
}
上面做法的前提是, 两个锁请求之间的工作可以迅速做完。如果不能迅速做完,合并之后会导致同步代码块执行需要花费很长时间,极大影响了多线程的工作
合并前代码
for(int i=0;i<size;i++){
synchronized(lock){
}
}
合并后代码
synchronized(lock){
for(int i=0;i<size;i++){
}
}
锁清除
锁清除是发生在编译器级别的一种锁优化方式。
有时候代码完全不需要加锁,却执行了加锁操作,这个时候编译器就会进行锁清除优化。
例子:StringBuffer的append操作已经是线程安全的了
@Override
public synchronized StringBuffer append(String str) {
toStringCache = null;
super.append(str);
return this;
}
可能在实际使用中并不需要进行加锁处理, 这里StringBuffer作为局部变量使用, 函数执行完, 变量就会被清除,每一个线程都会拥有自己的局部变量, 不会涉及到并发问题, 因此也没必要进行加锁操作;
public class Demo {
public static void main(String[] args) {
long start = System.currentTimeMillis();
int size = 10000;
for (int i = 0; i < size; i++) {
createStringBuffer("Hyes", "为分享技术而生");
}
long timeCost = System.currentTimeMillis() - start;
System.out.println("createStringBuffer:" + timeCost + " ms");
}
public static String createStringBuffer(String str1, String str2) {
StringBuffer sBuf = new StringBuffer();
sBuf.append(str1);// append方法是同步操作
sBuf.append(str2);
return sBuf.toString();
}
}
这个时候可以通过编译器来消除同步锁。