【JAVA】JAVA多线程基础8

目录

一、常见的锁策略

1、乐观锁与悲观锁

2、读写锁

3、重量级锁和轻量级锁

4、自旋锁和挂起等待锁

5、公平锁与非公平锁

6、可重入锁与不可重入锁

7、相关面试题

二、CAS

1、什么是CAS

2、CAS是怎么实现的

3、CAS有哪些应用

4、CAS的ABA问题


一、常见的锁策略

1、乐观锁与悲观锁

  • 乐观锁:

乐观锁总是假设最好的情况,认为共享资源每次被访问的时候不会出现问题,线程可以不停地执行,无需加锁也无需等待,只是在提交修改的时候去验证对应的资源(也就是数据)是否被其它线程修改了(具体方法可以使用版本号机制或CAS算法)。

  • 悲观锁:

悲观锁总是假设最坏的情况,认为共享资源每次被访问的时候就会出现问题(比如共享数据被修改),所以每次在获取资源操作的时候都会上锁,这样其他线程想拿到这个资源就会阻塞直到锁被上一个持有者释放。也就是说,共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程


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

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

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

这两种思路不能说谁优谁劣,而是看当前的场景是否合适。如果当前老师确实比较忙,那么使用悲观锁的策略更合适,使用乐观锁会导致"白跑很多趟",耗费额外的资源;如果当前老师确实比较闲,那么使用乐观锁的策略更合适,使用悲观锁会让效率比较低。

Synchronized初始使用乐观锁策略。当发现锁竞争比较频繁的时候,就会自动切换成悲观锁策略。就好比同学C开始认为"老师比较闲的",问问题都会直接去找老师。但是直接来找两次老师之后,发现老师都挺忙的,于是下次再来问问题,就先发个消息问问老师忙不忙,再决定是否来问问题。


乐观锁的一个重要功能就是要检测出数据是否发生访问冲突,我们可以引入一个"版本号"来解决。假设我们需要多线程修改"用户账户余额",设当前余额为100,引入一个版本号version,初始值为1。并且我们规定提交版本必须大于记录,当前版本才能执行更新余额

1)线程1此时准备将其读出(version=1,balance=100),线程2也读入此信息(version=1,balance=100)。

2)线程1从其帐户余额中扣除50(100-50),线程2从其帐户余额中扣除20(100-20)。

3)线程1完成修改工作,将数据版本号加1(version=2),连同帐户扣除后余额(balance=50),写回到内存中。

4)线程2完成了操作,也将版本号加1(version=2)试图向内存中提交数据(balance=80),但此时比对版本发现,操作员B提交的数据版本号为2,数据库记录的当前版本也为2,不满足“提交版本必须大于记录当前版本才能执行更新”的乐观锁策略。就认为这次操作失败。

2、读写锁

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

读写锁(readers-writer lock),看英文可以顾名思义,在执行加锁操作时需要额外表明读写意图,复数读者之间并不互斥,而写者则要求与任何人互斥。

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

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

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

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

其中:

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

注意,只要是涉及到"互斥",就会产生线程的挂起等待。一旦线程挂起,再次被唤醒就不知道隔了多久了。因此尽可能减少"互斥"的机会,就是提高效率的重要途径。所以读写锁特别适合于"频繁读,不频繁写"的场景中。(这样的场景其实也是非常广泛存在的)。

3、重量级锁和轻量级锁

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

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


重量级锁:加锁机制重度依赖了OS提供了mutex,有着大量的内核态用户态切换,很容易引发线程的调度。成本比较高。

轻量级锁:加锁机制尽可能不使用mutex,而是尽量在用户态代码完成。实在搞不定了,再使用mutex。内核态用户态切换较少,不太容易引发线程调度

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

4、自旋锁和挂起等待锁

按之前的方式,线程在抢锁失败后进入阻塞状态,放弃CPU,需要过很久才能再次被调度。但实际上,大部分情况下,虽然当前抢锁失败,但过不了很久,锁就会被释放。没必要就放弃CPU。这个时候就可以使用自旋锁来处理这样的问题。

我们看一下自旋锁伪代码:

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

如果获取锁失败,就立即再尝试获取锁,无限循环,直到获取到锁为止。第一次获取锁失败,第二次的尝试会在极短的时间内到来。一旦锁被其他线程释放,就能第一时间获取到锁。


我们想象一下去追求一个女神。当男生向女神表白后,女神说:你是个好人,但是我有男朋友了。

挂起等待锁:陷入沉沦不能自拔,过了很久很久之后,突然女神发来消息,"咱俩要不试试?"(这个很长的时间间隔里,女神可能已经换了好几个男票了)。

自旋锁:死皮赖脸坚韧不拔。仍然每天持续的和女神说早安晚安。一旦女神和上一任分手,那么就能立刻抓住机会上位。

自旋锁其实是一种典型的轻量级锁的实现方式。它没有放弃CPU,不涉及线程阻塞和调度,一旦锁被释放,就能第一时间获取到锁。但是如果锁被其他线程持有的时间比较久,那么就会持续的消耗CPU资源(而挂起等待的时候是不消耗CPU的)。synchronized中的轻量级锁策略大概率就是通过自旋锁的方式实现的

5、公平锁与非公平锁

假设三个线程A、B、C,A先尝试获取锁,获取成功。然后B再尝试获取锁,获取失败,阻塞等待;然后C也尝试获取锁,C也获取失败,也阻塞等待。那么当A释放锁的时候会发生啥呢?

  • 公平锁:遵守"先来后到"。B比C先来的,当A释放锁的之后,B就能先于C获取到锁。
  • 非公平锁:不遵守"先来后到"。B和C都有可能获取到锁。

这就好比一群男生追同一个女神。当女神和前任分手之后,先来追女神的男生上位,这就是公平锁;如果是女神不按先后顺序挑一个自己看的顺眼的,就是非公平锁。

公平锁:

非公平锁: 

注意:

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

6、可重入锁与不可重入锁

我之前的博客文章讲过,这里就简单提一下:

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

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

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

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

7、相关面试题

  • 你是怎么理解乐观锁和悲观锁的,具体怎么实现呢?

悲观锁认为多个线程访问同一个共享变量冲突的概率较大,会在每次访问共享变量之前都去真正加锁。

乐观锁认为多个线程访问同一个共享变量冲突的概率不大,并不会真的加锁,而是直接尝试访问数据。在访问的同时识别当前的数据是否出现访问冲突。

悲观锁的实现就是先加锁(比如借助操作系统提供的mutex),获取到锁再操作数据。获取不到锁就等待。

乐观锁的实现可以引入一个版本号,借助版本号识别出当前的数据访问是否冲突。

  • 介绍下读写锁?

读写锁就是把读操作和写操作分别进行加锁。

读锁和读锁之间不互斥。

写锁和写锁之间互斥。

写锁和读锁之间互斥。

读写锁最主要用在"频繁读,不频繁写"的场景中。

  • 什么是自旋锁,为什么要使用自旋锁策略呢,缺点是什么?

如果获取锁失败,立即再尝试获取锁,无限循环,直到获取到锁为止。第一次获取锁失败,第二次的尝试会在极短的时间内到来。一旦锁被其他线程释放,就能第一时间获取到锁。

相比于挂起等待锁,没有放弃CPU资源,一旦锁被释放就能第一时间获取到锁,更高效。在锁持有时间比较短的场景下非常有用。但是如果锁的持有时间较长,就会浪费CPU资源。

  • synchronized 是可重入锁么?

是可重入锁,可重入锁指的就是连续两次加锁不会导致死锁。

实现的方式是在锁中记录该锁持有的线程身份,以及一个计数器(记录加锁次数)。如果发现当前加锁的线程就是持有锁的线程,则直接计数自增。

二、CAS

1、什么是CAS

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

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

  1. 比较A与V是否相等(比较)
  2. 如果比较相等,将B写入V(交换),整个操作返回true
  3. 比较不相等,无事发生,整个操作返回false

我们看一下CAS伪代码:

//下面写的代码不是原子的,真实的CAS是一个原子的硬件指令完成的。
//这个伪代码只是辅助理解CAS的工作流程。 
boolean CAS(address, expectValue, swapValue) {
 if (&address == expectedValue) {
 &address = swapValue;
 return true;
    }
 return false;
 }

当多个线程同时对某个资源进行CAS操作,只能有一个线程操作成功,但是并不会阻塞其他线程,其他线程只会收到操作失败的信号。CAS可以视为是一种乐观锁。(或者可以理解成CAS是乐观锁的一种实现方式)


CAS其实是一个CPU指令,一个CPU指令就能完成上述比较交换的逻辑。单个CPU指令,是原子的,就可以使用CAS完成一些操作,进一步代替加锁。这给编写线程安全的代码引入了新思路。基于CAS实现线程安全的方式也称为“无锁编程”。

他的优点是保证线程安全,同时避免阻塞,这样效率较高,但是代码会很复杂,不好理解,同时CAS只能适合一些特定的场景,不如加锁方式更普遍。

2、CAS是怎么实现的

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

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

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

3、CAS有哪些应用

  • 实现原子类

标准库中提供了java.util.concurrent.atomic包,里面的类都是基于这种方式来实现的。典型的就是AtomicInteger类。其中的getAndIncrement相当于i++操作。我们运行如下代码:

import java.util.concurrent.atomic.AtomicInteger;

public class Demo1 {
    public static AtomicInteger count = new AtomicInteger(0);


    public static void main(String[] args) throws InterruptedException {

        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                count.getAndIncrement();
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                count.getAndIncrement();
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(count.get());
    }
}


我们看一下AtomicInteger的伪代码实现:

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不相等,不能进行赋值。因此需要进入循环,在循环里重新读取value的值赋给oldValue。

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

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


通过形如上述代码就可以实现一个原子类,不需要使用重量级锁,就可以高效的完成多线程的自增操作。本来check and set(if 判定然后设定值)这样的操作在代码角度不是原子的,但是在硬件层面上可以让一条指令完成这个操作,也就变成原子的了。

前面说的“线程不安全”本质上就是进行自增的过程中穿插执行了,CAS也是让这里的自增不要穿插执行,核心思路是和加锁相似的,加锁是通过阻塞的方式避免穿插,而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;
    }
}

其中Thread owner记录当前这个锁被哪个线程获取到了,如果是null,表示未加锁状态,这时候通过CAS获取锁。

4、CAS的ABA问题

  • 什么是 ABA 问题

假设存在两个线程t1和t2,有一个共享变量num,初始值为A。接下来,线程t1想使用CAS把num值改成Z,那么就需要先读取num的值,记录到oldNum变量中,然后使用CAS判定当前num的值是否为A,如果为A,就修改成Z。

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

线程t1的CAS是期望num不变就修改。但是num的值已经被t2改了。只不过又改成A了。这个时候t1究竟是否要更新num的值为Z呢?

到这一步,t1线程无法区分当前这个变量始终是A,还是经历了一个变化过程。这就好比,我们买一个手机,无法判定这个手机是刚出厂的新手机,还是别人用旧了又翻新过的手机。因为不能感知变量的变化

  • ABA问题引来的BUG

有这么一种情况,你银行存款为1000,你想取500,然后有人给你转500,你最后存款显示的应该是1000。但是,你取钱的时候,创建了2个线程,通过CAS的方式就可能有问题了。

如果不出问题,就是你创建的线程1先执行,你创建的线程2由于检测到读取的内存值与比较预期值不符就取消了。然后有人给你汇款500,你最后显示的是1000;

但是如果你创建的线程2,最开始读取了存款1000,在线程2比较之前,线程1执行完了,别人给你汇款也汇完了,这时候你的线程2比较,刚好1000等于1000,于是就减了500,最后你银行存款为500,你就亏了500元。

这个时候,扣款操作被执行了两次!都是ABA问题搞的鬼!

  • 解决方案

给要修改的值,引入版本号。在CAS比较数据当前值和旧值的同时,也要比较版本号是否符合预期。

CAS操作在读取旧值的同时,也要读取版本号。在真正修改的时候:

  1. 如果当前版本号和读到的版本号相同,则修改数据,并把版本号+1
  2. 如果当前版本号高于读到的版本号,就操作失败(认为数据已经被修改过了)

这就好比,判定这个手机是否是翻新机,那么就需要收集每个手机的数据,第一次挂在电商网站上的手机记为版本1,以后每次这个手机出现在电商网站上,就把版本号进行递增。这样如果买家不在意这是翻新机,就买。如果买家在意,就可以直接略过。

在Java标准库中提供了AtomicStampedReference类。这个类可以对某个类进行包装,在内部就提供了上面描述的版本管理功能。

  • 51
    点赞
  • 21
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值