• JavaEE初阶Day 13:多线程(11)


    Day 13:多线程(11)

    常见的锁策略

    锁策略可以理解为,这把锁在加锁/解锁/遇到锁冲突的时候都会怎么做

    并非局限于Java中,其他编程语言,其他的系统级别的组件,但凡涉及到锁,都和锁策略有关系

    1. 悲观锁 vs 乐观锁

    加锁的时候,预测当前锁冲突的概率是大还是小

    • 预测当前锁冲突概率大,后续要做的工作往往就会更多,加锁的开销就更大(时间、系统资源),此时采用悲观锁
    • 预测当前锁冲突概率小,后续要做的工作往往就会更少,加锁的开销就更小(时间、系统资源),此时采用乐观锁

    Java中的synchronized既是乐观锁也是悲观锁,支持自适应,能够自动的统计出当前锁冲突的次数,进行判定当前锁冲突的概率高低

    • 当冲突概率低的时候,按照乐观锁的方式来执行(速度更快)
    • 当冲突概率高的时候,升级为悲观锁的方式执行(做的工作更多)

    悲观锁往往是要通过内核来完成一些操作的,要做的工作就多

    乐观锁往往是纯用户态的一些操作,要做的工作就少

    2. 重量级锁 vs 轻量级锁

    一般来说,悲观锁往往就是重量级锁;乐观锁往往就是轻量级锁

    • 加锁过程做的事情多,重量
    • 加锁过程做的事情少,轻量

    3. 自旋锁 vs 挂起等待锁

    • 自旋锁是轻量级锁的一种典型实现方式
    //伪代码
    void lock() {
    	while(true) {
    		if(锁是否被占用) {
    			continue;
    		}
    		获取到锁
    		break;
    	}
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    cpu在空转忙等,消耗了更多的CPU资源,但是一旦锁被释放,就能第一时间拿到锁

    • 挂起等待锁是重量级锁的一种典型实现方式

      • 借助系统中的线程调度机制,当尝试加锁,并且锁被占用了,出现锁冲突,就会让当前这个尝试加锁的线程被挂起(阻塞状态)
      • 此时这个线程就不会参与调度了,直到这个锁被释放,然后系统才能唤醒这个线程,去尝试重新获取锁,拿到锁的速度更慢,节省CPU,消耗的时间更长,一旦线程被阻塞了,什么时候被唤醒,这个过程是不可控的

    synchronized轻量级锁部分,基于自旋锁实现;重量级锁部分,基于挂起等待锁实现

    4. 可重入锁 vs 不可重入锁

    • Java中的synchronized是可重入锁,一个线程针对同一把锁连续加锁两次,不会死锁

    • C++中的std::mutex是不可重入锁,一个线程针对同一把锁连续加锁两次,会出现死锁

    5. 公平锁 vs 非公平锁

    • 公平锁:严格按照先来后到的顺序来获取锁,哪个线程等待的时间长,哪个线程就拿到锁
    • 非公平锁:若干个线程,各凭本事,随机的获取到锁,和线程等待时间就无关了

    synchronized属于非公平锁,多个线程尝试获取到这个锁,此时是按照概率均等的方式进行获取

    系统本身线程调度的顺序就是随机的,如果需要实现公平锁,就需要引入额外的队列,按照加锁顺序,把这些获取锁的线程入队列,再一个一个出队列

    6. 互斥锁 vs 读写锁

    • 互斥锁:一个线程获取到锁并进行加锁,另一个线程就不能对其加锁了

    • 读写锁:多个线程读同一个变量,不会有线程安全问题

      • 读锁和读锁之间,不会产生互斥
      • 写锁和写锁之间,会产生互斥
      • 读锁和写锁之间,会产生互斥

      突出体现的是读操作和读操作之间是共享的,不会互斥的,有利于降低锁冲突的概率,提高并发能力

    日常开发中,有很多场景,属于**”读多,写少“**,大部分操作都是读,偶尔有写的操作

    • 如果使用普通的互斥锁,此时,每次读操作之间都会互斥,影响效率

    • 如果使用读写锁,就能够有效的降低锁冲突的概率,提高效率

    Java标准库/操作系统api也提供了读写锁的实现

    synchronized实现原理

    synchronized既是悲观锁,也是乐观锁,既是轻量级锁,也是重量级锁,轻量级锁是自旋锁实现,重量级锁是挂起等待锁实现,是可重入锁,不是读写锁,是非公平锁

    那么synchronized如何”自适应“

    1. 锁升级

    锁升级的过程:

    在这里插入图片描述

    偏向锁

    • 首次使用synchronized对对象进行加锁的时候,不是真正的加锁,而只是做一个”标记“,非常轻量非常快,几乎没有开销
    • 如果没有别的线程尝试对这个对象加锁,就可以保持这个状态,一直到解锁,解锁也就是修改一下上述标记,几乎没有开销,前述过程就相当于没有任何加锁操作,速度非常快
    • 但是,如果在偏向锁状态下,有某个线程也尝试来对这个对象加锁,立马把偏向锁升级为轻量级锁,实现真正的加锁

    上述的升级过程,针对一个锁对象来说,是不可逆的,只能升级不能降级,一旦升级到重量级锁,不会回退到轻量级锁

    2. 锁消除

    锁消除是一种编译器优化策略

    代码中写了加锁操作,编译器和JVM会对当前的代码做出判定,看这个地方是否真的需要加锁,如果不需要加锁,就会自动把加锁操作给优化掉

    最典型的就是:在只有一个线程里,使用synchronized

    由于编译器优化,需要保证优化后的逻辑和优化前要等价,这里做的是比较保守的,能够起到的作用有限,与之前谈到的偏向锁互不相干,也不冲突

    3. 锁粗化

    锁的粒度:加锁的范围内,包含多少代码,代码越多,就认为锁的粒度越粗,反之越细

    锁粗化:一种优化策略,有些逻辑中,需要频繁加锁解锁,编译器就会自动的把多次细粒度的锁,合并成一次粗粒度的锁

    例如:领导安排了三个工作

    • 分三次给领导打电话会把每个工作
    • 一次电话,汇报三个工作

    CAS

    CAS:compare and swap(比较和交换),这是一条CPU指令,就可以完成比较和交换这一套操作

    可以将CAS的流程想象成一个方法

    boolean cas(address, reg1, reg2){
    	if(*address == reg1){
    		把address内存地址的值和reg2寄存器的值进行交换
    		return true;
    	}
    	
    	return false;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    这里说的交换,实际更多的是用来赋值,一般更关心内存中交换后的数据,而不关心reg2寄存器交换后的数据,可以近似认为上述操作把reg2的值赋值给内存中

    • 由于CPU提供了上述指令,因此操作系统内核,也就能够完成上述操作,就会提供出这样的CAS的api,JVM又对于系统的CAS的api进一步封装了,在Java代码中就可以使用CAS操作了

    • 但是实际上,CAS被封装到了一个unsafe包中,容易出错,不鼓励大家直接使用CAS

    Java中也有一些类,对CAS进行了进一步的封装,典型的就是原子类

    例如java.util.comcurrent.atomia中的AtomicInteger,相当于针对int进行了封装,可以保证此处的++或–操作,是原子的

    Java中不支持运算符重载,无法针对原子类进行++、–;C++和python能够支持运算符重载,可以重新定义±*/等各种运算符的作用

    package thread;
    
    import java.util.concurrent.atomic.AtomicInteger;
    
    public class Demo38 {
        private static AtomicInteger count = new AtomicInteger(0);
    
        public static void main(String[] args) throws InterruptedException {
            Thread t1 = new Thread(()->{
                //count++
                count.getAndIncrement();
                //++count
                count.incrementAndGet();
                //count--
                count.getAndDecrement();
                //--count
                count.decrementAndGet();
                //count+=10
                count.getAndAdd(10);
    
            });
    
    
            t1.start();
            t1.join();
            System.out.println("count = " + count);
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28

    此处我们的代码中,没有用到任何加锁操作,使得代码以更高的效率来执行程序

    这一套基于CAS不加锁来实现线程安全代码的方式,也成为无锁编程

    • 这一套操作适用范围没有加锁更广泛,针对一些特殊场景,使用CAS是更高效的,但是有些场景,不太适合使用CAS
    • 一种更加折中的办法,可以基于CAS来封装成自旋锁(自旋锁也是基于CAS来实现的),这样做其实也就失去了“无锁编程”的意义了
  • 相关阅读:
    文本语义表征(Sentence-Bert、Simcse)的应用和实践
    c++(五)
    浏览器连不上 Flink WebUI 8081 端口
    ​力扣解法汇总792. 匹配子序列的单词数
    浅尝Spring注解开发_自定义注册组件、属性赋值、自动装配
    SpringBoot ApplicationEvent详解
    【李宏毅】机器学习——作业1-PM2.5预测
    Pod详解
    Eureka 概述与 Eureka Server 配置
    Mac使用brew搭建kafka集群
  • 原文地址:https://blog.csdn.net/m0_50444008/article/details/137929764