【Java EE】多线程-进阶-锁策略

目录

1.常见的锁策略

1.1乐观锁 vs 悲观锁

1.2重量级锁 vs 轻量级锁

1.3自旋锁(Spin Lock)

1.4公平锁 vs 非公平锁

1.5可重入锁和不可重入锁

1.6读写锁

2.CAS

2.1什么事CAS

2.2CAS是怎么实现的

2.3CAS有哪些应用

2.3.1实现原子类

2.3.2实现自旋锁

2.4CAS的ABA问题

2.4.1什么是ABA问题


1.常见的锁策略

1.1乐观锁 vs 悲观锁

悲观锁:

总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。

乐观锁:

假设数据一般情况下并不会并发冲突,所以在数据进行提交更新的时候,才会正式对数据是否产生并发冲突进行检测,如果发现并发冲突了,则让返回用户错误的信息,让用户决定如何去做。

举个例子:同学A和同学B想请教老师一个问题。

同学A认为:“老师是比较忙的,我来问问题,老师不一定有空回答”。因此同学A会先给老师发信息:“老师你忙嘛?我下午两点能来找你问个问题嘛?”(相当于加锁操作)得到肯定的答复之后,才会真的来问问题。如果得到了否定的答复,那就等一段时间,下次再来和老师确定时间。这是个悲观锁。

同学B认为:“老师是比较闲的,我来问问题,老师大概率是有空解答的”。因此同学B直接就来找老师。(没加锁,直接访问资源)如果老师确定比较闲,那么直接问题就要解决了。如果老师这会确定很忙,那么同学B也不会打扰老师,就下次再来(虽然没加锁,但是能识别出数据访问冲突)。这个是乐观锁。

Synchronized初识使用乐观锁策略。当发现锁竞争比较频繁的时候,就会自动切换成悲观锁策略

1.2重量级锁 vs 轻量级锁

锁的核心特性“原子性”,这样的机制追根溯源是CPU这样的硬件设备提供的。

  • CPU提供了“原子操作指令”。
  • 操作系统基于CPU的原子指令,实现了mutex互斥锁。
  • JVM基于操作系统提供的互斥锁,实现了synchronized 和ReetrantLock 等关键字和类。

注意:synchronized 并不仅仅是对mutex进行封装,在synchronized 内部还做了很多其他的工作

重量级锁:加锁机制重度依赖了OS提供了mutex

  • 大量的内核态用户态切换
  • 很容易引发线程的调度

这两个操作,成本比较高,一旦涉及到用户态和内核态的切换,就意味着“沧海桑田”。

轻量级锁:加锁机制尽可能不适用mutex,而是尽量在用户态代码完成,是在搞不定了,再使用mutex。

  • 少量的内核态用户态切换。
  • 不太容易引发线程调度。

理解用户态 vs 内核态

想象去银行办业务。

在窗口外,自己做,这是用户态,用户态的时间成本是比较可控的。

在窗口内,工作人员做,这是内核态,内核态的时间成本是不太可控的。、

如果办业务的时候反复和工作人员沟通,还需要重新排队,这时效率是很低的。

synchronized 开始是一个轻量级锁,如果锁冲突比较严重,就会变成重量级锁。

1.3自旋锁(Spin Lock)

按之前的方式,线程在抢锁失败后进入阻塞状态,放弃CPU,需要过很久才能再次被调度。

但实际上,大部分情况下,虽然当前抢锁失败,但过不了很久,锁就会被释放。没必要就放弃CPU。这个时候可以使用自旋锁来处理这样的问题。

自旋锁伪代码:

while (抢锁(lock) == 失败) {}

如果获取锁失败,立即在尝试获取锁,无限循环,直到获取到锁为止,第一次获取锁失败,第二次的尝试会在极短的时间内到来。

一旦锁被其他线程释放,就能第一时间获取到锁。

自旋锁是一种典型的 轻量级锁 的实现方式。

  • 优点:没有放弃CPU,不涉及线程阻塞和调度,一旦被释放,就能第一时间获取到锁。
  • 缺点:如果锁被其他线程持有的时间比较久,那么就会持续的消耗CPU资源(而挂起等待的时候是不消耗CPU的)。

synchronized中的轻量级锁策略大概率是通过自旋锁的方式实现的。

1.4公平锁 vs 非公平锁

假设三个线程ABC,A先尝试获取锁,获取成功。然后B再尝试获取锁,获取失败,阻塞等待;然后C也尝试获取锁,C也会获取失败,也阻塞等待。

当线程A释放锁的时候,会发生什么呢?

公平锁:遵守“先来后到”,B比C先来的,当A释放锁之后,B就能先于C获取到锁。

非公平锁:不遵守“先来后到”,B和C都有可能获取到锁。

注意:

  • 操作系统内部的线程调度就可以视为随机的,如果不做任何额外的限制,锁就是非公平锁,如果要实现公平锁,就需要依赖额外的数据结构,来记录他们的先后顺序。
  • 公平锁和非公平锁没有好坏之分,关键还是看使用场景。

synchronized是非公平锁

1.5可重入锁和不可重入锁

可重入锁的字面意思是“可以重新进入的锁”,即允许同一个线程多次获取同一把锁。

比如一个递归函数里面有加锁操作,递归过程中这个锁会阻塞自己吗?如果不会,那么这个锁就是可重入锁(因为这个原因可重入锁也叫做递归所)。

Java里只要以Reentrant开头命名的锁都是可重入的,而且JDK提供的所有现成的Lock实现类,包括synchronized关键字锁都是可重入的。

而Linux系统提供的mutex是不可重入锁。

// 第⼀次加锁, 加锁成功
lock();
// 第⼆次加锁, 锁已经被占⽤, 阻塞等待. 
lock();

synchronized是可重入锁。

1.6读写锁

多线程之间,数据的读取方之间不会产生线程安全问题,但数据的写入方互相之间以及和读者之间都需要进行互斥。如果两种场景下都用一个锁,就会产生极大的性能损耗。所以读写锁因此而产生。

读写锁,在执行加锁操作时需要额外表明读写意图,复数读者之间并不互斥,而写者则要求任何人互斥。

一个线程对于数据的访问,主要存在两种操作:读数据和写数据。

  • 两个线程都只是读一个数据,此时并没有线程安全问题,直接并发的读取即可。
  • 两个线程都要写一个数据,有线程安全问题。
  • 一个线程读另外一个线程写,也有线程安全问题。

读写锁就是把读操作和写操作区分对待,Java标准库提供了ReentrantReadWriteLock类,实现了读写锁。

  • ReetrantReadWriteLock.ReadLock类表示一个读锁。这个对象提供了lock/unlock方法进行加锁解锁。
  • ReentrantReadWriteLock.WriteLock类表示一个写锁。这个对象也提供了lock/unlock方法进行加锁解锁。

其中,

  • 读加锁和读加锁之间,不互斥。
  • 写加锁和写加锁之间,互斥。
  • 读加锁和写加锁之间,互斥。

注意,只要是涉及到“互斥”,就会产生线程的挂起等待。一旦线程挂起,再次被唤醒就不知道隔了多久了。

因此尽可能减少“互斥”的机会,就是提高效率的重要途径。

读写锁特别适合于“频繁读,不频繁写”的场景中。

Synchronized不是读写锁。

2.CAS

2.1什么事CAS

CAS:全称Compare and swap,字面意思:“比较并交换”,一个CAS涉及到以下操作:

我们假设内存中的原数据V,旧的预期值A,需要修改的新值B。

  1. 比较A与V是否相等。(比较)
  2. 如果比较相等,将B写入V。(交换)
  3. 返回操作是否成功。

CAS伪代码

boolean CAS(address, expectValue, swapValue) {
 if (&address == expectedValue) {
 &address = swapValue;
 return true;
 }
 return false;
}

两种典型的不是“原子性”的代码

当多个线程同时对某个资源进行CAS操作,只能有一个线程操作成功,但是并不会阻塞其他线程,其他线程只会收到操作失败的信号。

CAS可以视为是一种乐观锁。

2.2CAS是怎么实现的

针对不同的操作系统,JVM用到了不同的CAS实现原理,简单原理:

  • java的CAS利用的是unsafe这个类提供的CAS操作。
  • unsafe的CAS依赖了的事jvm针对不同的操作系统实现的Atomic::cmpxchg;
  • Atomic::cmpxchgd的实现使用了汇编的CAS操作,并使用cpu硬件提供的Lock机制保证其原子性。

简而言之,是因为硬件给予了支持,软件层面才能做到。

2.3CAS有哪些应用
2.3.1实现原子类

标准库中提供了java.util.concurrent.atomic包,里面的类都是基于这种方式来实现的。

典型的就是AtomicInteger类。其中的getAndINcrement相当于i++操作。

AtomicInteger atomicInteger = new AtomicInteger(0);
// 相当于 i++
atomicInteger.getAndIncrement();

伪代码实现:

class AtomicInteger {
 private int value;
 public int getAndIncrement() {
 int oldValue = value;
 while ( CAS(value, oldValue, oldValue+1) != true) {
 oldValue = value;
 }
 return oldValue;
}
}

假设两个线程同时调用getAndIncrement

  1. 两个线程都读取value的值到oldValue中。(oldValue是一个局部变量,在栈上。每个线程有自己的栈)。
  2. 线程1先执行CAS操作,由于oldValue和value的值相同,直接进行对value赋值。

注意:

  • CAS是直接读写内存的,而不是操作寄存器。
  • CAS的读内存,比较,写内存操作是一条硬件指令,是原子的。

3.线程2执行CAS操作,第一次CAS的时候发现oldValue和Value不相等,不能进行赋值,因此需要进入循环。

4.线程2接下来第二次执行CAS,此时oldValue和value相同,于是直接执行赋值操作。

5.线程1和线程2返回各自的oldValue的值即可。

通过形如上述代码就可以实现一个原子类,不需要使用重量级锁,就可以高效的完成多线程的自增操作。

本来check and set这样的操作在代码角度不是原子的。但是在硬件层面上可以让一条指令完成这个操作,也就是变成原子的了。

2.3.2实现自旋锁

基于CAS实现更灵活的锁,获取到更多的控制权。

自旋锁伪代码

public class SpinLock {
 private Thread owner = null;
 public void lock(){
 // 通过 CAS 看当前锁是否被某个线程持有. 
 // 如果这个锁已经被别的线程持有, 那么就⾃旋等待. 
 // 如果这个锁没有被别的线程持有, 那么就把 owner 设为当前尝试加锁的线程. 
 while(!CAS(this.owner, null, Thread.currentThread())){
 }
 }
 public void unlock (){
 this.owner = null;
 }
}
2.4CAS的ABA问题
2.4.1什么是ABA问题

ABA的问题:

假设存在两个线程t1和t2,有一个共享变量num,初始值A。

接下来,线程t1想使用CAS把num值改成Z,那么就需要

  • 先读取num的值,记录到oldNum变量中。
  • 使用CAS判定当前num的值是否为A,如果为A,就修改成Z。

但是,在t1执行这两个之间,t2线程可能吧num的值从A改成了B,又从B改成了A

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值