常见锁策略

目录

乐观锁和悲观锁

重量级锁和轻量级锁

自旋锁和挂起等待锁

互斥锁和读写锁

公平锁和非公平锁

可重入锁和不可重入锁

synchronized内部的工作原理

锁消除

锁粗化

CAS


锁策略,即加锁过程(处理冲突时)时的处理方式

乐观锁和悲观锁

乐观锁:在加锁之前,预估当前出现锁冲突的概率不大,因此在进行加锁的时候不会做太多工作。加锁过程需要做的事情少,则加锁的速度可能更快,但更容易出现一些其他问题(消耗更多的cpu资源)

悲观锁:在加锁之前,预估当前出现锁冲突的概率较大,因此在进行加锁的时候会做更多的工作。加锁过程需要做的事情多,则加锁速度可能更慢,但过程中不容易出现其他问题

重量级锁和轻量级锁

轻量级锁:加锁的开销小,加锁的速度更快(轻量级锁,一般就是乐观锁)

重量级锁:加锁的开销大,加锁的速度更慢(重量级锁,一般就是悲观锁)

轻量还是重量,是加锁之后对结果的评价,而乐观还是悲观是还未加锁前,对锁冲突的预估,但从两种角度都是描述的同一件事情

自旋锁和挂起等待锁

自旋锁:在进行加锁的时候,如果获取锁失败,就立即再尝试获取锁,无限循环,直到获取到锁为止(如:while ( 获取锁 == 失败 ) {})一旦锁被其他线程释放,就能够第一时间获取到锁

自旋锁是一种典型的 轻量级锁 的实现方式,也是一种乐观锁,适用于锁冲突不激烈的情况

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

缺点:若锁一直不被其他线程释放,则会持续消耗CPU资源

挂起等待锁:在进行加锁的时候,如果获取锁失败,就挂起等待(不消耗CPU资源),而当其他线程释放锁,由系统决定是否其进行加锁

挂起等待锁是一种典型的 重量级锁 的实现方式,也是一种悲观锁,适用于锁冲突激烈的情况

优点:减少了CPU资源的浪费

缺点:锁被释放后,不能第一时间获取到锁,再次加锁时间由系统决定

synchronized是哪一种锁呢?

synchronized具有自适应能力,某些情况下是 乐观锁(轻量级锁/自旋锁),而某些情况下是 悲观锁(重量级锁/挂起等待锁)。synchronized内部会自动评估当前锁冲突的激烈程度,若当前锁冲突激烈程度不大,就是 乐观锁(轻量级锁/自旋锁);而若当前锁冲突较为激烈,则是悲观锁(重量级锁/挂起等待锁) 

互斥锁和读写锁

互斥锁:加锁就是单纯的加锁,即资源只能当前被获取锁的线程访问

加锁操作是为了防止 一个线程在进行写操作时,另一个线程进行读取或者写操作

而,若线程都只对其进行读操作,此时不会涉及到线程安全问题,此时加上互斥锁就会影响读取速度,对性能有一定的损失,此时,我们可以使用读写锁解决上述问题

读写锁:

当一个线程加读锁时,另一个线程只能读,不能写,即当前线程正在进行读取操作,其他线程也可以进行读取操作,但是不能写

当一个线程加写锁时,另一个线程不能读,也不能写,即当前线程正在进行写操作,其他线程即不行读也不能写

synchronized属于互斥锁,其操作只涉及到加锁和写锁。在标准库中,提供了专门的读写锁(ReadWriteLock

公平锁和非公平锁

公平锁:遵循“先来后到”原则,即等待时间长的线程先获取到锁

非公平锁:不遵循“先来后到”原则,线程等概率获取到锁

在操作系统内部的线程调度可视为随机的,即锁是非公平锁,若想实现公平锁,就需要使用额外的数据结构来记录线程之间的先后顺序。synchronized 是非公平锁

可重入锁和不可重入锁

可重入锁:可重新进入的锁,即允许同一个线程多次获得到同一把锁

不可重入锁:不可重新进入的锁,即不允许同一个线程多次获取到同一把锁

例如:一个递归函数中有加锁操作,在递归过程中这个锁会阻塞自己吗?若不会,则是可重入锁;若会,则是不可重入锁(即自己把自己锁死)

在Java中,只要以Reentrant开头命名的锁都是可重⼊锁,⽽且JDK提供的所有现成的Lock实现类,包括synchronized关键字锁都是可重入的。而Linux系统提供的mutex是不可重入锁。

可重入锁的实现方式是在锁中记录持有该锁的线程身份,以及实现一个计数器(用于记录加锁次数),若当前加锁的线程是当前持有锁的线程,则只需要将计数器次数增加。

synchronized内部的工作原理

结合上面的锁策略我们可以看出sychronized:

1. 具有自适应能力(不同情况下使用不同锁)

2. 不是读写锁,是互斥锁

3. 是不公平锁

4.是可重入锁

synchronized的加锁过程:

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

偏向锁状态:

偏向锁并不是真的进行“加锁”,而是只给锁对象做一个“偏向锁标记”,记录这个锁属于哪个线程。若后续没有其他线程来竞争该锁,则不用进行其他操作(这样就避免了加锁和解锁的开销),而若后续有其他的线程来竞争该锁(由于已经对锁对象进行标记,因此很容易识别申请加锁的线程是否是之前记录的线程),则就进入 轻量级锁状态

偏向锁本质上相当于“延迟加锁”,即能不加锁就不加锁,尽量避免不必要的加锁开销

轻量级锁状态:

当其他线程竞争该锁的时候,偏向锁状态解除,进入轻量级锁状态(自适应的自旋锁)

每次加锁都会经历这三个阶段吗?

偏向锁标记,是对象头里的一个标记,每个锁对象都有自己的标记,当这个锁对象首次被加锁时,会先进入偏向锁状态,而在这个过程中,没有涉及锁竞争,则下次加锁时还是进入偏向锁状态;而若在此过程中升级为轻量级锁,后续再针对这个对象加锁,就直接是轻量级锁了(跳过偏向锁

重量级锁状态:

当锁竞争进一步激烈,自旋不能快速获取到锁状态,则会进入重量级锁状态

此时拿不到锁的线程就不会继续自旋了,而是进入阻塞等待,让出CPU。而当持有锁的线程释放锁时,就由系统随机唤醒一个线程来获取锁。

锁消除

编译器会判断当前锁是否可以消除,若可以消除,就直接将其消除掉

例如:

StringBuilder stringBuilder = new StringBuilder();
stringBuilder.append("ab");
stringBuilder.append("bc");
stringBuilder.append("cd");
stringBuilder.append("de");

在上述使用了synchronized,但没有多线程的环境下,若每个append的调用都进行加锁和解锁,则会浪费资源开销,降低效率,因此这些加锁和解锁操作是没有必要的,是可以进行“锁消除”的

锁粗化

若一段逻辑中多次出现加锁和解锁,则编译器会将自动将其合并为异常加锁和解锁,即将多个细粒度的锁合并为一个粗粒度的锁

常见锁策略_第1张图片

使用细粒度的锁,是期望释放锁的时候其他线程能够使用锁,但实际可能没有其他线程来抢占这个锁,此时JVM就会自动将锁粗化,避免频繁加锁和解锁

CAS

CAS:compare and swap,即比较并交换,一个CAS涉及以及操作:

设内存中原有数据V,旧的预期值为A,需要修改的新值为B

1. 比较A与V是否相同(比较)

2. 如果比较相等,则将B写入V(交换)

3.返回操作是否成功

例如,我们以下面的伪代码来理解CAS的工作流程:

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

address:内存地址,expectValue:寄存器中旧的预期值,swapValue:需要修改的新值

比较address内存地址中的值是否与expectValue相同,若相同则将swapValue与address内存中的值交换(也可以理解为“赋值”,我们往往只关注内存中最终的值,寄存器中的值用完就不需要了,),并返回true;若不相同,则不进行交换,并返回false

上述的伪代码,实际上表示的是一条CPU指令,而单个CPU指令,本身就是原子的,因此,使用CAS,不涉及加锁,也不会阻塞,合理使用也能够保证线程安全

CAS本身是CPU指令,操作系统对指令进行了封装,而JVM又对操作系统提供的api进行了一层封装。Java将CAS的api放到了unsafe包(CAS涉及到一些系统底层内容,使用不当可能会带来一定的风险)中。因此,Java标准库又对CAS进行了一层封装,提供了一些工具类

其中最主要的一个工具,叫做原子类

import java.util.concurrent.atomic

常见锁策略_第2张图片

例如,AtomicInteger,对Integer进行了封装,当针对Integer对象进行多线程修改时,就是线程安全的

 当我们使用AtomicInteger在多线程情况下对Integer对象count进行++操作:

class Demo{
    private static AtomicInteger count = new AtomicInteger(0);
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(()->{
            for (int i = 0; i < 5000; i++) {
                count.getAndIncrement();
            }
        });
        Thread t2 = new Thread(()->{
            for (int i = 0; i < 5000; i++) {
                count.getAndIncrement();
            }
        });
        t1.start();
        t2.start();
        
        t1.join();
        t2.join();
        System.out.println(count.get());
    }
}

其运行结果为10000

而当我们直接使用int类型的变量时:

class Demo1{
    private static int count = 0;

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(()->{
            for (int i = 0; i < 5000; i++) {
                count++;
            }
        });

        Thread t2 = new Thread(()->{
            for (int i = 0; i < 5000; i++) {
                count++;
            }
        });

        t1.start();
        t2.start();

        t1.join();
        t2.join();

        System.out.println(count);
    }
}

此时的运行结果则是随机的,但基本上是小于10000的

为什么会出现不同的结果呢?

在使用count++时,涉及到三个指令 load add save 

常见锁策略_第3张图片

因此,虽然执行了两次count++操作,但结果却为1,要想解决上述问题,则需要通过加锁操作,将这三个操作“打包”,即让其具有“原子性”(三个操作连在一起执行,执行过程中其他线程不能调度执行)

而当使用count.getAndIncrement()方法时,

常见锁策略_第4张图片 常见锁策略_第5张图片

 基于上图中的情况,其能够判断在执行++操作前是否有另一个线程修改了count,若count被修改,则重新获取预期值

你可能感兴趣的:(JavaEE,java,开发语言)