java常见锁策略与CAS

目录

1.常见的锁策略

2.CAS

3.Synchronized 原理


1.常见的锁策略

1.1 乐观锁与悲观锁

悲观锁:

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

乐观锁:

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

那么该如何去选择两种锁策略呢?

从上面对两种锁的介绍,我们知道两种锁各有优缺点,不可认为一种好于另一种,像乐观锁适用于写比较少的情况下(多读场景),即冲突真的很少发生的时候,这样可以省去了锁的开销,加大了系统的整个吞吐量。但如果是多写的情况,一般会经常产生冲突,这就会导致上层应用会不断的进行retry,这样反倒是降低了性能,所以一般多写的场景下用悲观锁就比较合适。 

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

这里我们接下来会讲解

1.2 读写锁 

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

读写锁其实就是将读和写这两种操作分离成了读锁和写锁

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

两个线程都只是读一个数据, 此时并没有线程安全问题. 直接并发的读取即可.

两个线程都要写一个数据, 有线程安全问题.

一个线程读另外一个线程写, 也有线程安全问题.

 

读加锁和读加锁之间, 不互斥.

写加锁和写加锁之间, 互斥.

读加锁和写加锁之间, 互斥. 

这一点和我们读写所造成的线程安全问题情况类似

读写锁特别适合于 "频繁读, 不频繁写" 的场景中. 。比如学校的教务系统,除了新同学加入很少需要修改。Synchronized 不是读写锁.

1.2 重量级锁和轻量级锁

我们简单了解一下锁的基本原理:

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

CPU 提供了 "原子操作指令".

操作系统基于 CPU 的原子指令, 实现了 mutex 互斥锁.

JVM 基于操作系统提供的互斥锁, 实现了 synchronized 和 ReentrantLock 等关键字和类.

重量级锁:悲观锁经常是重量级锁,重量级锁的加锁机制重度依赖了 OS 提供了 mutex 。由于涉及大量内核态的操作,所以效率较低,占用资源多,适合锁冲突严重的情况。

轻量级锁:乐观锁经常是轻量级锁,轻量级锁:加锁机制尽可能不使用 mutex, 而是尽量在用户态代码完成. 实在搞不定了, 再使用 mutex。较少涉及内核态操作,适用于锁冲突不严重的情况(频繁读的情况)。

1.3 自旋锁和挂起等待锁

 自旋锁:

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

我们用一行伪代码来描述自旋锁的行为:

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

如果获取锁失败, 立即再尝试获取锁, 无限循环, 直到获取到锁为止。 第一次获取锁失败, 第二次的尝试会 在极短的时间内到来. 一旦锁被其他线程释放, 就能第一时间获取到锁. (这个行为我们可以想象成一个天狗对女神不懈追求的场景

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

优点: 没有放弃 CPU, 不涉及线程阻塞和调度, 一旦锁被释放, 就能第一时间获取到锁.

缺点: 如果锁被其他线程持有的时间比较久, 那么就会持续的消耗 CPU 资源. (而挂起等待的时候是 不消耗 CPU 的). 

挂起等待锁:

它在发现锁冲突后会直接挂起等待,而不会立即尝试获取锁。就好像一个人追女神,但是人家已经有男朋友了,但是他并不会等女神一分手就去追求,而是先不管不顾放下一段时间,等到一段时间间隔后再去尝试。

 synchronized作为轻量级锁时内部是自旋锁,而作为重量级锁时内部是挂起等待锁

1.4 公平锁与非公平锁 

首先这里的公平其实是一个相对的概念,大家不用去纠结。

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

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

非公平锁: 不遵守 "先来后到". B 和 C 都有可能获取到锁. 

操作系统内部的线程调度就可以视为是随机的. 如果不做任何额外的限制, 锁就是非公平锁. 如果要 想实现公平锁, 就需要依赖额外的数据结构, 来记录线程们的先后顺序.

公平锁和非公平锁没有好坏之分, 关键还是看适用场景.

synchronized 是非公平锁.

1.5 可重入锁和不可重入锁 

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

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

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

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

 比如下面这串代码:

synchronized (Demo.class){//第一次加锁
        synchronized (Demo.class){//第二次加锁
            
        }
    }

 由于我们synchronized可重入锁,所以不会造成死锁现象。但是假如是不可重入锁的话,锁2等待锁1释放,而锁1的释放又等待内部的锁2加锁,两者互相矛盾就造成了死锁。

2. CAS 

2.1 概念

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

address相当于我们上面提到的V,expectValue相当于A,swapValue相当于B。

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

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

CAS的实现比较复杂,我们简单了解一下就好。

java 的 CAS 利用的的是 unsafe 这个类提供的 CAS 操作;

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

因为硬件予以了支持,软件层面才能做到CAS。 

2.2 CAS的应用 

2.2.1 实现原子类

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

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

我们简单了解一下java中有那些原子类:

 一些常用方法:

get():获取当前的值
getAndSet():获取当前值big设置新的值
getAndIncrement():获取当前的值并自增
getAndDecrement():获取当前的值并自减
getAndAdd(int delta):获取当前的值,并加上预期的值
compareAndSet(int expect,int update):如果当前的数组等于预期值,则以原子方式将该值设置为输入值(update)

这里我们只是简单介绍一下原子类,想详细了解的话可以自行上网搜索。

接下来我们看到一段代码:

public class Demo {
    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++;
                // 这个方法就相当于 count++
                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 = " + count);
    }
}

 两个线程各运行5w次自增操作,注意这里我们是没有加锁的。结果如下:

由于我们的count和自增操作都是原子类实现的,所以原本非原子性的自增操作也拥有了原子性。实现了在多线程情况下即使不加锁也实现线程安全。

我们这里简单用伪代码帮大家理解一下刚才所用到的原子类。

class AtomicInteger {
    private int value;
    public int getAndIncrement() {
        int oldValue = value;
        while ( CAS(value, oldValue, oldValue+1) != true) {
            oldValue = value;//不满足CAS条件则将旧数据更新
            //否则执行CAS操作
       }
        return oldValue;
   }
}

2.2.2 实现自旋锁

自旋锁伪代码:

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.3 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

用一句话来概括就是CAS无法判断你要修改的变量是一直都是A没有改变过,还是发生了A->B->A的变化

ABA 问题引来的 BUG: 

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

比如张三有1000存款。一天他去ATM想取走500(假设ATM按照CAS的方式工作)。但是它在取钱的时候不小心按了两下取款按钮(代表创建了两个取款线程)。本来按照正常CAS的方式,这两个线程并不会有影响,但是此时李四想起自己还欠张三500,便赶紧转了500给张三,此时张三存款又变回了1000,所以第二个取款线程也成功执行了,于是就产生了bug。

虽然发生这种事情的概率很小,但是我们依然要想办法解决。

解决方案:

给要修改的值, 引入版本号。

在 CAS 比较数据当前值和旧值的同时, 也要比较版本号是否符合预期.

CAS 操作在读取旧值的同时, 也要读取版本号.

真正修改的时候, 如果当前版本号和读到的版本号相同, 则修改数据, 并把版本号 + 1.

如果当前版本号高于读到的版本号. 就操作失败(认为数据已经被修改过了). 

3. Synchronized 原理 

3.1 基本特点

jdk1.8的情况下,我们可以总结出以下特点:

1. 开始时是乐观锁, 如果锁冲突频繁, 就转换为悲观锁.

2. 开始是轻量级锁实现, 如果锁被持有的时间较长, 就转换成重量级锁.

3. 实现轻量级锁的时候大概率用到的自旋锁策略

4. 是一种不公平锁

5. 是一种可重入锁

6. 不是读写锁

3.2 加锁工作过程 

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

1.无锁(没加锁)

2.偏向锁(刚开始加锁,还未产生竞争)

3.轻量级锁(产生锁竞争)

4.重量级锁(锁竞争加剧) 

其中1,3,4种情况我们都已经在上面讲过了,所以我们这里只讲解偏向锁 

偏向锁并不是真正加锁 ,只是给中做一个 "偏向锁的标记", 记录这个锁属于哪个线程。如果后续没有其他线程来竞争该锁,那么就不用进行后续加锁的操作了(节省加锁解锁的开销)

如果后续有其他线程来竞争该锁(刚才已经在锁对象中记录了当前锁属于哪个线程了, 很容易识别 当前申请锁的线程是不是之前记录的线程), 那就取消原来的偏向锁状态, 进入一般的轻量级锁状态. 偏向锁本质上相当于 "延迟加锁" . 能不加锁就不加锁, 尽量来避免不必要的加锁开销。

这个过程类似于单例模式中的懒汉模式,只在必要时加锁,节省开销。

3.3 其他优化操作 

锁消除

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

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

StringBuffer sb = new StringBuffer();
sb.append("a");
sb.append("b");
sb.append("c");
sb.append("d");

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

锁粗化

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

锁的粒度: 粗和细

这里锁的粒度粗细实际上指的是锁的范围,范围越大锁的粒度越粗。

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

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

比如下面这个例子:

假如张三需要给领导汇报工作,他有两种方式

方式一:

打电话, 交代任务1, 挂电话.

打电话, 交代任务2, 挂电话.

打电话, 交代任务3, 挂电话.

方式二: 打电话, 交代任务1, 任务2, 任务3, 挂电话. 

很明显假如你是老板你肯定更喜欢方式一,方式二的话可能你已经被烦死了准备开除张三。

这就是粗粒度锁的一种适用情景。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值