解析Java多线程之常见锁策略与CAS中的ABA问题

目录
  • 1.常见的锁策略
    • 1.1乐观锁与悲观锁
    • 1.2读写锁与普通互斥锁
    • 1.3重量级锁与轻量级锁
    • 1.4挂起等待锁与自旋锁
    • 1.5公平锁与非公平锁
    • 1.6可重入锁与不可重入锁
    • 1.7死锁问题
      • 1.7.1常见死锁的情况
      • 1.7.2哲学家就餐问题
  • 2.CAS指令与ABA问题
    • 2.1CAS指令
    • 2.2ABA问题

本篇文章将介绍常见的锁策略以及CAS中的ABA问题,前面介绍使用synchronized关键字来保证线程的安全性,本质上就是对对象进行加锁操作,synchronized所加的锁到底是什么类型的锁呢?本文带你一探究竟。

1.常见的锁策略

1.1乐观锁与悲观锁

乐观锁与悲观锁是从处理锁冲突的态度方面来进行考量分类的。

  • 乐观锁预期锁冲突的概率很低,所以做的准备工作更少,付出更少,效率较高。
  • 悲观锁预期锁冲突的概率很高,所以做的准备工作更多,付出更多,效率较低。

1.2读写锁与普通互斥锁

对于普通的互斥锁只有两个操作:

  • 加锁
  • 解锁

而对于读写锁来说有三个操作:

  • 加读锁,如果代码仅进行读操作,就加读锁。
  • 加写锁,如果代码含有写操作,就加写锁。
  • 解锁。

针对读锁与读锁之间,是没有互斥关系的,因为多线程中同时读一个变量是线程安全的,针对读锁与写锁之间以及写锁与写锁之间,是存在互斥关系的。

在java中有读写锁的标准类,位于java.util.concurrent.locks.ReentrantReadWriteLock,其中ReentrantReadWriteLock.ReadLock为读锁,ReentrantReadWriteLock.WriteLock为写锁。

1.3重量级锁与轻量级锁

这两种类型的锁与悲观锁乐观锁有一定的重叠,重量级锁做的事情更多,开销更大,轻量级锁做的事情较少,开销也就较少,在大部分情况下,可以将重量级锁视为悲观锁,轻量级锁视为乐观锁。

如果锁的底层是基于内核态实现的(比如调用了操作系统提供的mutex接口)此时一般认为是重量级锁,如果是纯用户态实现的,一般认为是轻量级锁。

1.4挂起等待锁与自旋锁

挂起等待锁表示当获取锁失败之后,对应的线程就要在内核中挂起等待(放弃CPU,进入等待队列),需要在锁被释放之后由操作系统唤醒,该类型的锁是重量级锁的典型实现。 自旋锁表示在获取锁失败后,不会立刻放弃CPU,而是快速频繁的再次询问锁的持有状态一旦锁被释放了,就能立刻获取到锁,该类型的锁是轻量级锁的典型实现。

挂起等待锁与自旋锁的区别

  • 最明显的区别就是,挂起等待锁开销比自旋锁要大,且挂起等待锁效率不如自旋锁。
  • 挂起等待锁会放弃CPU资源,自旋锁不会放弃CPU资源,会一直等到锁释放为止。
  • 自旋锁相较于挂起等待锁更能及时获取到刚释放的锁。
  • 自旋锁相较于挂起等待锁的劣势就是当自旋的时间长了,会持续地销耗CPU资源,因此自旋锁也可以说是乐观锁。

1.5公平锁与非公平锁

公平锁遵循先来后到的原则,多个线程在等待一把锁的时候,谁先来尝试拿锁,那这把锁就是谁的。 非公平锁遵循随机的原则,多个线程正在等待一把锁时,当锁释放时,每个线程获取到这把锁的概率是均等的。

1.6可重入锁与不可重入锁

一个线程连续加锁两次,不会造成死锁,那么这个锁就是可重入锁。 反之,一个线程连续加锁两次,会造成死锁现象,那么这个锁就是不可重入锁。

关于死锁是什么,稍等片刻,后面就会介绍到。

综合上述的几种锁策略,synchronized加的所到底是什么锁?

  • 它既是乐观锁也是悲观锁,当锁竞争较小时它就是乐观锁,锁竞争较大时它就是悲观锁。
  • 它是普通互斥锁。
  • 它既是轻量级锁也是重量级锁,根据锁竞争激烈程度自适应。
  • 轻量级锁部分基于自旋锁实现,重量级锁部分基于挂起等待锁实现。
  • 它是非公平锁。
  • 它是可重入锁。

1.7死锁问题

1.7.1常见死锁的情况

死锁是指多个进程在运行过程中因争夺资源而造成的一种僵局,当进程处于这种僵持状态时,若无外力作用,它们都将无法再向前推进。

情况1:一个线程一把锁 比如下面这种情况

加锁 方法 () {
	加锁 (this) {
		//代码块
	}
}

首先,首次加锁,可以成功,因为当前对象并没有被加锁,然后进去方法里面,再次进行加锁,此时由于当前对象已经被锁占用,所以会加锁失败然后尝试再次加锁,此时就会陷入一个加锁的死循环当中,造成死锁。

情况2:两个线程两把锁 不妨将两个线程称为A,B,两把锁称为S1,S2,当线程A已经占用了锁S1,线程B已经占用了锁S2,当线程A运行到加锁S2时,由于锁S2被线程B占用,线程A会陷入阻塞状态,当线程B运行到加锁S1时,由于锁S1被线程A占用,会导致线程B陷入阻塞状态,两个线程都陷入了阻塞状态,而且自然条件下无法被唤醒,造成了死锁。

情况3:多个线程多把锁 最典型的栗子就是哲学家就餐问题,下面我们来分析哲学家就餐问题。

1.7.2哲学家就餐问题

哲学家就餐问题是迪杰斯特拉这位大佬提出并解决的问题,具体问题如下:

有五位非常固执的科学家每天围在一张圆桌上面吃饭,这个圆桌上一共有5份食物和5 筷子,哲学家们成天都坐在桌前思考,当饿了的时候就会拿起距离自己最近的2根筷子就餐,但是如果发现离得最近的筷子被其他哲学家占用了,这个哲学家就会一直等,直到旁边的哲学家就餐完毕,这位科学家才会拿起左右的筷子进行就餐,就餐完毕后哲学家们又开始进行思考状态,饿了就再次就餐。

当哲学家们每个人都拿起了左边的筷子或者右边的筷子,由于哲学家们非常地顽固,拿起一只筷子后发现另一只筷子被占用就会一直等待,所以所有的哲学家都会互相地等待,这样就会造成所有哲学家都在等待,即死锁。

从上述的几种造成死锁的情况,可以总结发生死锁的条件:

  • 互斥使用,一个锁被一个线程占用后,其他线程使用不了(锁本质,保证原子性)。
  • 不可抢占,一个锁被一个线程占用后,其他线程不能将锁抢占。
  • 请求和保持,当一个线程占据多把锁后,除非显式释放锁,否则锁一直被该线程锁占用。
  • 环路等待,多个线程等待关系闭环了,比如A等B,B等C,C等A。

如何避免环路等待? 只需约定好,线程针对多把锁加锁时有固定的顺序即可,当所有的线程都按照约定的顺序加锁就不会出现环路等待。

比如对于上述的哲学家就餐问题,我们可以对筷子进行编号,每次哲学家优先拿编号小的筷子就可以避免死锁。

2.CAS指令与ABA问题

2.1CAS指令

CAS即compare and awap,即比较加交换,具体说就是将寄存器或者某个内存上的值v1与另一个内存上的值v2进行比较,如果相同就将v1与需要修改的值swapV进行交换,并返回交换是否成功的结果。

伪代码如下:

boolean CAS(v1, v2, swapV) {
	if (v1 == v2) {
		v1=swapV;
		return true;
	}
	return false;
}

上面的这一段伪代码很明显就是线程不安全的,CPU中提供了一条指令能够一步到位实现上述伪代码的功能,即CAS指令。该指令是具有原子性的,是线程安全的。

java标准库中提供了基于CAS所实现的“原子类”,这些类的类名以Atomic开头,针对常用的int,long等进行了封装,它们可以基于CAS的方式进行修改,是线程安全的。

就比如上次使用多个线程对同一个变量进行自增操作的那个程序,它是线程不安全的,但是如果使用CAS原子类来实现,那就是线程安全的。

其中的getAndIncrement方法相当于i++操作。 现在我们来使用原子类中的“getAndIncrement方法(基于CAS实现)来实现该程序。

import java.util.concurrent.atomic.AtomicInteger;
public class Main {
    private static final int CNT = 50000;
    public static void main(String[] args) throws InterruptedException {
        AtomicInteger count = new AtomicInteger();
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < CNT; i++) {
                count.getAndIncrement();
            }
        });
        thread1.start();
        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < CNT; i++) {
                count.getAndIncrement();
            }
        });
        thread2.start();

        thread1.join();
        thread2.join();
        System.out.println(count);
    }
}

运行结果:

从结果我们也能看出来,该程序是线程安全的。

上面所使用的AtomicInteger类方法getAndIncrement实现的伪代码如下:

class AtomicInteger {
    private int value;//保存的值
    //自增操作
    public int getAndIncrement() {
        int oldValue = value;
        while ( CAS(value, oldValue, oldValue+1) != true) {
            oldValue = value;
        }
        return oldValue;
    }
}

首先,对于CAS指令,它的执行逻辑就是先判断value的值是否与oldValue的值相同,如果相同就将原来value的值与value+1的值进行交换,相当于将value的值加1,其中oldValue的值为提前获取的value值,在单线程中oldValue的值一定与value的值相同,但是多线程就不一定了,因为每时每刻都有可能被其他线程修改。

然后,我们再来看看下面的while循环,该循环使用CAS指令是否成功为判断条件,如果CAS成功了则退出循环,此时value的值已经加1,最终返回oldValue,因为后置++先使用后++
如果CAS指令失败了,这就说明有新线程提前对当前的value进行了++value的值发生了改变,这时候需要重新保存value的值给oldValue,然后尝试重新进行CAS操作,这样就能保证有几个线程操作,那就自增几次,从而也就保证了线程安全,总的来说相当于传统的++操作,基于CAS的自增操作只有两个指令,一个是将目标值加载到寄存器,然后在寄存器上进行CAS操作,前面使用传统++操作导致出现线程安全问题是指令交错的情况,现在我们来画一条时间轴,描述CAS实现的自增操作在多个线程指令交错时的运行情况。

发现尽管指令交错了,但是运行得到的结果预期也是相同的,也就说明基于CAS指令实现的多线程自增操作是线程安全的。

此外,基于CAS也能够实现自旋锁,伪代码如下:

//这是一个自旋锁对象,里面有一个线程引用,如果该引用不为null,说明当前锁对象被线程占用,反之亦然。
public class SpinLock {
    private Thread owner;
    public void lock(){
        // 通过 CAS 看当前锁是否被某个线程持有.
        // 如果这个锁已经被别的线程持有, 那么就自旋等待.
        // 如果这个锁没有被别的线程持有, 那么就把 owner 设为当前尝试加锁的线程.
        while(!CAS(this.owner, null, Thread.currentThread())){
       }
   }
    public void unlock (){
        this.owner = null;
   }
}

根据CAS与自旋锁的逻辑,如果当前锁对象被线程占用,则lock方法会反复地取获取该锁是否释放,如果释放了即owner==null,就会利用CAS操作将占用该锁对象的线程设置为当前线程,并退出加锁lock方法。

解锁方法非常简单,就将占用锁对象的线程置为null即可。

2.2ABA问题

根据上面的介绍我们知道CAS指令操作的本质是先比较,满足条件后再进行交换,在大部分情况下都能保证线程安全,但是有一种非常极端的情况,那就是一个值被修改后又被改回到原来的值,此时CAS操作也能成功执行,这种情况在大多数的情况是没有影响的,但是也存在问题。

像上述一个值被修改后又被改回来这种情况就是CAS中的ABA问题,虽说对于大部分场景都不会有问题,但是也存在bug,比如有以下一个场景就说明了ABA问题所产生的bug:

有一天。滑稽老铁到ATM机去取款,使用ATM查询之后,滑稽老铁发现它银行卡的余额还有200,于是滑稽老铁想去100块给女朋友买小礼物,但是滑稽老铁取款时,在点击取款按钮后机器卡了一下,滑稽老铁下意识又点了一下,假设这两部取款操作执行图如下:

如果没有出现意外,即使按下两次取款按钮也是正常的,但是在这两次CAS操作之间,如图滑稽老铁的朋友给它转账了100块,导致第一次CAS扣款100后的余额从100变回到了200,这时第二次CAS操作也会执行成功,导致又被扣款100块,最终余额是100块,这种情况是不合理的,滑稽老铁会组织滑稽大军讨伐银行的,合理的情况应该是第二次CAS仍然失败,最终余额为200元。

为了解决ABA问题造成的bug,可以引入应该版本号,版本号只能增加不能减少,加载数据的时候,版本号也要一并加载,每一次修改余额都要将版本号加1, 在进行CAS操作之前,都要对版本号进行验证,如果版本号与之前加载的版本号不同,则放弃此次CAS指令操作。

上面的这张图是引入版本号之后,滑稽老铁账户余额变化图,我们不难发现余额的变化是合理的。

总结一下,本篇文章介绍了常见的锁策略,并说明了synchronized关键字加的锁类型不是单一一种锁类型的,根据可重入锁与非可重入锁引出了死锁的概念与死锁条件,最后介绍了CAS指令以及CAS锁产生的ABA问题及其解决方案。

到此这篇关于Java多线程之常见锁策略与CAS中的ABA问题的文章就介绍到这了,更多相关Java多线程常见锁策略内容请搜索我们以前的文章或继续浏览下面的相关文章希望大家以后多多支持我们!

(0)

相关推荐

  • Java锁的升级策略 偏向锁 轻量级锁 重量级锁

    这三种锁是指锁的状态,并且是专门针对Synchronized关键字.JDK 1.6 为了减少"重量级锁"的性能消耗,引入了"偏向锁"和"轻量级锁",锁一共拥有4种状态:无锁状态.偏向锁.轻量级锁.重量级锁.锁状态是通过对象头的Mark Word来进行标记的: 锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁,这种锁升级却不能降级的策略,是为了提高获得锁和释放锁的效率 重量级锁:依赖于底层操作系统的Mutex Lock,线程会被阻

  • Java多线程异步调用性能调优方法详解

    目录 概述 同步调用和异步调用 Future类图 Future的不足 代码 代码地址 Test PaymentService CheckService OrderService 总结 概述 大型电商公司的支付聚合服务都有这类的场景: 调用校验服务校验待生成的订单是否合法 订单服务生成订单(校验服务和订单服务没有依赖关系) 调用1和2,支付服务实现支付核心的功能 结合步骤1至3完成支付服务的聚合调用 ​假如步骤1的耗时5秒,步骤2的耗时3秒,步骤3的耗时2秒,如果你是架构师,要求:​ 1.请实现微

  • 聊聊java多线程创建方式及线程安全问题

    什么是线程 线程被称为轻量级进程,是程序执行的最小单位,它是指在程序执行过程中,能够执行代码的一个执行单位.每个程序程序都至少有一个线程,也即是程序本身. 线程的状态 新建(New):创建后尚未启动的线程处于这种状态 运行(Runable):Runable包括了操作系统线程状态的Running和Ready,也就是处于此状态的线程有可能正在执行,也有可能正在等待着CPU为它分配执行时间. 等待(Wating):处于这种状态的线程不会被分配CPU执行时间.等待状态又分为无限期等待和有限期等待,处于无

  • Java面试最容易被刷的重难点之锁的使用策略

    目录 一. 乐观锁和悲观锁 1. 字面理解 2. 生活实例 3. 基于版本号方式实现乐观锁 二. 读写锁 1. 理解 2. 用法 三. 重量级锁和轻量级锁 1. 原理 2. 理解 3. 区分用户态和内核态 四. 自旋锁 1. 理解 2. 实现方式 3. 优缺点 五. 公平锁和非公平锁 1. 理解 2. 注意事项 六. 可重入锁和不可重入锁 1. 为什么要引入这两把锁 (1)实例一 (2)实例二 2. 实现方案 七. 面试题 第一题 第二题 第三题 第四题 在多线程的学习中,很多时候都要用到锁,但

  • 解析Java多线程之常见锁策略与CAS中的ABA问题

    目录 1.常见的锁策略 1.1乐观锁与悲观锁 1.2读写锁与普通互斥锁 1.3重量级锁与轻量级锁 1.4挂起等待锁与自旋锁 1.5公平锁与非公平锁 1.6可重入锁与不可重入锁 1.7死锁问题 1.7.1常见死锁的情况 1.7.2哲学家就餐问题 2.CAS指令与ABA问题 2.1CAS指令 2.2ABA问题 本篇文章将介绍常见的锁策略以及CAS中的ABA问题,前面介绍使用synchronized关键字来保证线程的安全性,本质上就是对对象进行加锁操作,synchronized所加的锁到底是什么类型的

  • 程序猿必须要掌握的多线程安全问题之锁策略详解

    一.常见的锁策略 1.1 乐观锁 乐观锁:乐观锁假设认为数据一般情况下不会产生并发冲突,所以在数据进行提交更新的时候,才会正 式对数据是否产生并发冲突进行检测,如果发现并发冲突了,则让返回用户错误的信息,让用户决定如 何去做.乐观锁的性能比较高. 悲观锁:总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会 上锁,这样别人想拿这个数据就会阻塞直到它拿到锁. 悲观锁的问题:总是需要竞争锁,进而导致发生线程切换,挂起其他线程:所以性能不高. 乐观锁的问题:并不总是能处理

  • 解析Java编程之Synchronized锁住的对象

    图片上传 密码修改为  synchronized是java中用于同步的关键字,一般我们通过Synchronized锁住一个对象,来进行线程同步.我们需要了解在程序执行过程中,synchronized锁住的到底是哪个对象,否则我们在多线程的程序就有可能出现问题. 看下面的代码,我们定义了一个静态变量n,在run方法中,我们使n增加10,然后在main方法中,我们开辟了100个线程,来执行n增加的操作,如果线程没有并发执行,那么n最后的值应该为1000,显然下面的程序执行完结果不是1000,因为我们

  • Java多线程之显示锁和内置锁总结详解

    总结多线程之显示锁和内置锁 Java中具有通过Synchronized实现的内置锁,和ReentrantLock实现的显示锁,这两种锁各有各的好处,算是互有补充,这篇文章就是做一个总结. *Synchronized* 内置锁获得锁和释放锁是隐式的,进入synchronized修饰的代码就获得锁,走出相应的代码就释放锁. synchronized(list){ //获得锁 list.append(); list.count(); }//释放锁 通信 与Synchronized配套使用的通信方法通常

  • ReentrantLock从源码解析Java多线程同步学习

    目录 前言 管程 管程模型 MESA模型 主要特点 AQS 共享变量 资源访问方式 主要方法 队列 node节点等待状态 ReentrantLock源码分析 实例化ReentrantLock 加锁 A线程加锁成功 B线程尝试加锁 释放锁 总结 前言 如今多线程编程已成为了现代软件开发中的重要部分,而并发编程中的线程同步问题更是一道难以逾越的坎.在Java语言中,synchronized是最基本的同步机制,但它也存在着许多问题,比如可重入性不足.死锁等等.为了解决这些问题,Java提供了更加高级的

  • Java多线程之多种锁和阻塞队列

    一.悲观锁和乐观锁 1.1. 乐观锁 顾名思义,就是很乐观,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号等机制. 乐观锁适用于多读的应用类型,乐观锁在Java中是通过使用无锁编程来实现,最常采用的是CAS算法,Java原子类中的递增操作就通过CAS自旋实现的. CAS全称 Compare And Swap(比较与交换),是一种无锁算法.在不使用锁(没有线程被阻塞)的情况下实现多线程之间的变量同步.java.util

  • 解析Java内存分配和回收策略以及MinorGC、MajorGC、FullGC

    目录 对象内存分配与回收策略 对象何时进入新生代.老年代 三种GC介绍 MinorGC Major GC/Full GC: 图示GC过程 对象内存分配与回收策略 对象的内存分配,往大方向讲,就是在堆上分配[但也可能经过JIT编译后被拆散为标量类型并间接地栈上分配),对象主要分配在新生代的Eden区上,如果启动了本地线程分配缓冲,将按线程优先在TLAB上分配.少数情况下也可能会直接分配在老年代中. 对象优先分配在Eden区,当Eden区可用空间不够时会进行MinorGC 大对象直接进入老年代:大对

  • Java多线程之并发编程的基石CAS机制详解

    目录 一.CAS机制简介 1.1.悲观锁和乐观锁更新数据方式 1.2.什么是CAS机制 1.3.CAS与sychronized比较 1.4.Java中都有哪些地方应用到了CAS机制呢? 1.5.CAS 实现自旋锁 1.6.CAS机制优缺点 1>ABA问题 2>可能会消耗较高的CPU 3>不能保证代码块的原子性 二.Java提供的CAS操作类--Unsafe类 2.1.Unsafe类简介 2.2.Unsafe类的使用 三.CAS使用场景 3.1.使用一个变量统计网站的访问量 3.2.现在我

  • 举例解析Java多线程编程中需要注意的一些关键点

    1. 同步方法或同步代码块? 您可能偶尔会思考是否要同步化这个方法调用,还是只同步化该方法的线程安全子集.在这些情况下,知道 Java 编译器何时将源代码转化为字节代码会很有用,它处理同步方法和同步代码块的方式完全不同. 当 JVM 执行一个同步方法时,执行中的线程识别该方法的 method_info 结构是否有 ACC_SYNCHRONIZED 标记设置,然后它自动获取对象的锁,调用方法,最后释放锁.如果有异常发生,线程自动释放锁. 另一方面,同步化一个方法块会越过 JVM 对获取对象锁和异常

  • Java多线程之悲观锁与乐观锁

    目录 1. 悲观锁存在的问题 2. 通过CAS实现乐观锁 3. 不可重入的自旋锁 4. 可重入的自旋锁 总结 问题: 1.乐观锁和悲观锁的理解及如何实现,有哪些实现方式? 2.什么是乐观锁和悲观锁? 3.乐观锁可以重入吗? 1. 悲观锁存在的问题 独占锁其实就是一种悲观锁,java的synchronized是悲观锁.悲观锁可以确保无论哪个线程持有锁,都能独占式访问临界区.虽然悲观锁的逻辑非常简单,但是存在不少问题. 悲观锁总是假设会发生最坏的情况,每次线程读取数据时,也会上锁.这样其他线程在读取

随机推荐