提示:文章写完后,目录可以自动生成,如何生成可参考右边的帮助文档


前言

前面的多线程初阶中,我们学习了很多关于多线程的知识,比如线程的原理、多线程的使用Thread类的用法、、等待通知机制、多线程的代码案例、多线程安全问题,这个重点--其中锁时很重要的一个知识点。今天我们会学更多的锁。开始我们今天的表演吧!

一、常见的锁策略

乐观锁VS悲观锁

悲观锁:

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

乐观锁:

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

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

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

这两种思路不能说谁优谁劣,而是看当前的场景是否合适。

如果当前老师确实比较忙,那么使用乐观锁的策略更合适,使用悲观锁会让效率比较低。

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

就好比苏同学开始认为”老师比较闲的“,问问题都会直接去找老师。

但是直接来找老师两次之后,发现老师都挺忙的,于是下次再来问问他,就先发个消息问问老师忙不忙,再决定是否来问问题。

重量级锁VS轻量级锁

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

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

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

重量级锁:

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

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

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

轻量级锁:

加锁机制尽可能不使用mutex,而是尽量在用户态代码完成,实在搞不定了,在使用mtex。

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

理解用户态VS内核态

想象去银行办业务。

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

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

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

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

自旋锁

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

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

自旋锁伪代码:

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

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

理解自旋锁VS挂起等待锁

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

挂起等待锁:陷入沉沦不久,就开始提升自己……过了很久很久之后,突然女神发来消息,”咱两要不试试?“(注意,这个很长的时间间隔里,女神可能已经换了好几个男朋友了)。

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

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

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

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

公平锁VS非公平锁

假设三个线程1,2,3。1先尝试获取锁,获取成功。然后2再尝试获取锁,获取失败,阻塞等待;然后3也尝试获取锁,3也获取失败,也阻塞等待。

当线程1释放锁的时候,会发生啥呢?

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

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

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

注意:

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

synchronized是非公平锁。

可重入锁VS不可重入锁

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

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

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

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

理解”把自己锁死“

一个线程没有释放锁,然后又尝试再次加锁。

//第一次加锁,加锁成功
lock();
//第二次加锁,锁已经被占用,阻塞等待
lock();

按照之前对于锁的设定,第二次加锁的时候,就会阻塞等待,直到第一次的锁被释放,才能获取到第二个锁,但是释放第一个锁也是由该线程来完成,结果这个线程已经躺平了,啥都不想干了,也就无法进行解锁操作,这时候就会死锁

synchronized是可重入锁

读写锁

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

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

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

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

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

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

其中,

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

注意,只要时涉及到“互斥”,就会产生线程的挂起等待。一旦线程挂起,再次被唤醒就不知道隔了多久。因此尽可能减少“互斥”的机会,就是提高效率的重要途经。

读写锁特别适合于“频繁读,不频繁写”的场景中。(这样的场景其实也是非常广泛存在的)。

比如股票交易系统的价格查询模块:

高频读操作:成千上万的用户每秒都在查询股票价格。

低频写操作:只要当市场数据更新时(比如每分钟或每小时)才更新价格。

synchronized不是读写锁。

相关面试题

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

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

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

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

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

1.介绍写读写锁

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

读锁和读锁之间不互斥。

写锁和写锁之间互斥。

写锁和读锁之间互斥

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

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

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

相比于挂起等待锁,

优点:没有放弃CPU资源,一旦锁被释放就能第一时间获取到锁,更高效。在锁持有时间比较短的场景下非常有用。

缺点:如果锁的持有时间较长,就会浪费CPU资源。

1.synchronized是可重入锁吗?

是可重入锁。

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

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

二.CAS

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

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

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

CAS伪代码

下面写的代码不是原子的,真实的CAS是一个原子的硬件指令完成的,这个伪代码只是辅助理解CAS的工作流程。

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

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

1.check and set(if(判定然后设定值)【上面的CAS伪代码就是这种形式】

2.read and update (i++)[之前我们将线程安全的代码例子就是这种形式].

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

CAS可以视为是一种乐观锁。(或者可以理解成CAS是乐观锁的一种实现方式)。

CAS是怎么实现的

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

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

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

CAS有哪些应用

1)实现原子类

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

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

public class demo25 {
    //private static int count = 0;
    private  static AtomicInteger atomicInteger = new AtomicInteger(0);
    public static void main(String[] args) throws InterruptedException {

        //相当于i++
    Thread t1 = new Thread(()->{
        for (int i = 0; i < 50000; i++) {
            //count++
            atomicInteger.getAndIncrement();
           // atomicInteger.incrementAndGet();//++count
           // atomicInteger.addAndGet(10); //count+=
        } 
    });
    Thread t2 = new Thread(()->{
        for (int i = 0; i < 50000; i++) {
            atomicInteger.getAndIncrement();
        }
    });
    t1.start();
    t2.start();
    t1.join();
    t2.join();
    System.out.println("atomicInteger="+atomicInteger);
    }
}

假设两个线程同时调用getAndIncrement

1.两个线程都读取val的值到oldval中,(oldval是一个局部变量,在线上,每个线程有自己的栈)

2.线程1先执行CAS操作,由于oldval和val的值相同,直接进行对val赋值。

注意:

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

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

再循环里重新读取val的值赋给oldval.

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

5.线程1和线程2返回各自的oldval的值即可,

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

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

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;
}
}

CAS的ABA问题

什么是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

大部分的情况下,t2线程这样的一个反复横跳改动,对于t1是否修改num是没有影响的。但是不排除一些特殊情况。

假设小李有100存款。小李想从ATM取50块钱。取款机创建了两个线程,并发的来执行-50操作。我们期望一个线程执行-50成功,另一个线程-50失败。

如果使用CAS的方式来完成这个扣款过程就可能出现问题。

正常的过程

1.存款100,线程1获取到当前存款值为100,期望更新为50;线程2获取到当前存款值为100,期望更新为50.

2线程1执行扣款成功,存款被改成50.线程2阻塞等待中。

3.轮到线程2执行了,发现当前存款为50,和之前读到的100不相同,执行失败。

异常的过程

1存款100,线程1获取到当前存款值为100,期望更新为50;线程2获取到当前存款值为100,期望更新为50.

2.线程1执行扣款成功,存款被改成50.线程2阻塞等待中。

3.在线程2执行之前,小李的朋友正好给小李转账50,账户余额变成100!

4.轮到线程2执行了,发现当前存款为100,和之前读到的100相同,再次执行扣款操作。

解决方案

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

  • CAS操作时在读取旧值得同时,也要读取版本号。
  • 真正修改得时候,
  1. 如果当前版本号和读到的版本号相同,则修改数据,并把版本号+1.
  2. 如果当前版本号高于读到的版本号。就操作失败(认为数据已经被修改过了)。

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

对比理解上面的转账例子

假设小李有100存款。小李想从ATM取50块钱。取款机创建了两个线程,并发的来执行-50操作。

我们期望一个线程执行-50成功,另一个线程-50失败。

为了解决ABA问题,给余额搭配一个版本号,初始设为1.

1存款100.线程获取到存款值为100,版本号为1,期望更新为50;线程2获取到存款值为100,版本号为1,期望更新为50.

2.线程1执行扣款成功,存款被改成50,版本号改为2,线程2阻塞等待中。

3.在线程2执行之前,小李的朋友正好给小李转账50,账户余额变成100,版本号变成3.

4.轮到线程2执行了,发现当前存款为100,和之前读到的100相同,但是当前版本号为3,之前读到的版本号为1,版本小于当前版本,认为操作失败。

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

相关面试题

1.讲解下你自己理解的CAS机制

全称Compare and swap ,即”比较并交换“。相当于通过一个原子的操作,同时完成”读取内存,比较是否相等,修改内存“这三个步骤。本质上需要CPU指令的支撑。

1.ABA问题怎么解决?

给要修改的数据引入版本号,在CAS比较数据当前值和旧值得同时,也要比较版本号是否符合预期。如果发现当前版本号和之前读到得版本号一致,旧真正执行修改操作,并让版本号自增;如果发现当前版本号比之前读到得版本号大,就认为操作失败。

synchronized原理

基本特点

结合上面的锁策略,我们就可以总结出,synchronized具有以下特性(只考虑JDK1.8):

  1. 开始时是乐观锁,如果锁冲突频繁,就转换为悲观锁。
  2. 开始是轻量级锁实现,如果锁被持有的时间较长,就转换成重量级锁。
  3. 实现轻量级锁的时候大概率用到的自旋锁策略。
  4. 是一种不公平锁。
  5. 是一种可重入锁。
  6. 不是读写锁。

加锁工作过程

JVM将synchronized锁分为无锁、偏向锁、轻量级锁、重量级锁状态。会根据情况,进行依次升级。

1)偏向锁

第一个尝试加锁的线程,有限进入偏向锁状态。

偏向锁不是真的“加锁”,只是给对象头中做一个“偏向锁的标记”,记录这个锁属于哪个线程。如果后续没有其他线程来竞争该锁,那么就不用进行其他同步操作了(避免了加锁解锁的开销)如果后续有其他线程来竞争该锁(刚才已经在锁对象中记录了当前锁属于哪个线程了,很容易识别当前申请锁的线程是不是之前记录的线程),那就取消原来的偏向锁状态,进入一般的轻量级锁状态。偏向锁本质上相当于“延迟加锁”。能不加锁就不加锁,尽量避免不必要的加锁开销。但是该做的标记还是得做的,否则无法区分何时需要真正加锁。

举个栗子理解偏向锁

假设男主是一个锁,女主是一个线程。如果只有这一个线程来使用这个锁,那么男主女主即使不领证结婚(避免了高成本操作),也可以一直幸福的生活下去。

但是女配出现了,也尝试竞争男主,此时不管领证结婚这个操作成本多高,女主也势必要把这个动作完成了,让女配死心。

2)轻量级锁

随着其他线程进入竞争,偏向锁状态被消除,进入轻量级锁状态(自适应的自旋锁)。

此处的轻量级锁就是通过CAS来实现。

  • 通过CAS检查并更新一块内存(比如null=>该线程引用)
  • 如果更新成功,则认为加锁成功。
  • 如果更新失败,则认为被占用,继续自旋式的等待(并不放弃CPU)。

自旋操作是一直让CPU空转,比较浪费CPU资源。因此此处的自旋不会一直持续进行,而是达到一定的时间/重试次数,就不再自旋了。也就是所谓的“自适应”。

3)重量级锁

如果竞争进一步激烈,自旋不能快速获取到锁状态,就会膨胀为重量级锁,此处的重量级锁就是指用到内核提供的mutex。

  • 执行加锁操作,先进入内核态。
  • 在内核态判定当前锁是否已经被占用。
  • 如果该锁没有占用,则加锁成功,并切换回用户态。
  • 如果该锁被占用,则加锁失败。此时线程进入锁的等待队列,挂起。等待被操作系统唤醒。
  • 经历了一系列的沧海桑田,这个锁被其他线程释放了,操作系统也想起了这个挂起的线程,于是唤醒这个线程,尝试重新获取锁。 

其他的优化操作

锁消除

编译器+JVM判断锁是否可消除。如果可以,就直接消除。

什么是“锁消除”

有些应用程序的代码中,用到了synchronized,但其实没有在多线程环境下。(例如StringBuffer)

public class demo26 {
    public static void main(String[] args) {
        StringBuffer stringBuffer = new StringBuffer();
        stringBuffer.append('p');
        stringBuffer.append('a');
        stringBuffer.append('s');
        stringBuffer.append('s');
        stringBuffer.append('i');
        stringBuffer.append('o');
        stringBuffer.append('n');
        System.out.println(stringBuffer);
    }
}
//passion

此时每个append的调用都会涉及加锁和解锁。但如果只是在单线程中执行这个代码,那么这些加锁解锁操作是没有必要的,白白浪费了一些资源开销。

锁粗化

一段逻辑中如果出现多次加锁解锁,编译器+JVM会自动进行锁的粗化。

锁的粒度:粗和细。

实际开发过程中,使用细粒度锁,是期望释放锁的时候其他线程能使用锁。

但是实践上可能并没有其他线程来抢占这个锁,这种情况JVM就会自动把锁粗话,避免频繁申请释放锁。

举个栗子理解锁粗化

小李当了领导,给下属交代任务:

方式一:

  • 打电话,交代任务1,挂电话。
  • 打电话,交代任务2,挂电话。
  • 打电话,交代任务3,挂电话。

方式二:

  • 打电话,交代任务1,任务2,任务3,挂电话。

显然方式二是更高效的方案。

可以看到,synchronized的策略是比较复杂的,在背后做了很多事情,目的为了让程序员哪怕啥都不懂也不至于写出特别慢的程序。

相关面试题

1.什么是偏向锁?

偏向锁不是真的加锁,而只是在锁的对象头中记录一个标记(记录该锁所属的线程)。如果没有其他线程参与竞争锁,那么就不会真正执行加锁操作,从而降低程序开销。一旦真的涉及到其他的线程竞争,再取消偏向锁状态,进入轻量级锁状态。

1.synchronized实现原理是什么?

1. 开始时是乐观锁, 如果锁冲突频繁, 就转换为悲观锁.
2. 开始是轻量级锁实现, 如果锁被持有的时间较⻓, 就转换成重量级锁.
3. 实现轻量级锁的时候⼤概率⽤到的⾃旋锁策略
4. 是⼀种不公平锁
5. 是⼀种可重⼊锁
6. 不是读写锁

总结

本文系统覆盖多线程并发编程的核心技术:六大锁策略(含适用场景与特性)、CAS 机制(原理、应用、ABA 问题)、synchronized 底层实现(状态升级、自适应策略、优化手段),并整合高频面试考点。这些知识是解决线程安全问题、提升并发程序性能的关键,实际开发中需根据场景灵活选择锁策略与技术方案。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐