javaee:多线程-锁策略和常见JUC

常见的锁策略

乐观锁和悲观锁(以锁冲突的概率区分)

  • 乐观锁:预测接下来锁的冲突概率不高
    具体体现:只有在数据提交更新的时候才会检测有没有发生锁冲突,如果冲突就返回错误信息,让用户决定怎么做
  • 悲观锁:预测接下来锁的冲突概率较高
    具体体现:每次去拿数据的时候都会给数据上锁,阻止别人在自己拿数据的时候进行修改

重量级锁和轻量级锁(以加锁开销的大小区分)

  • 重量级锁:加锁开销比较大的
  • 轻量级锁:加锁开销比较小的

大部分情况下,我们可以简单的将重量级锁约等于悲观锁;轻量级锁约等于乐观锁

挂起等待锁和自旋锁(以获取锁的方式区分)

  • 挂起等待锁:重量级锁的典型实现方式.当出现锁冲突的时候,让线程进入阻塞状态(由于该线程进入了阻塞状态,放出了cpu资源,故消耗的时间更多)
  • 自旋锁:轻量级锁的典型实现方式.当出现锁冲突的时候,通过忙等来等待锁释放(通过忙等能第一时间拿到释放的锁,会消耗cpu资源,但消耗的时间更短)

忙等是这个线程会一直不断的尝试拿到锁,这个尝试过程会一直消耗cpu

公平锁和非公平锁(以获取锁的优先级来区分)

在计算机中.公平的定义是先来后到 .也就是说对于一个公平锁,先尝试获取锁的线程会优先的到锁:对于非公平锁,则不论顺序,每个尝试获取锁的线程都有均等概率[1](#1)来获得锁

  • 公平锁:需要创建一个额外的队列来维护多个线程的加锁先后顺序

ReentrantLock就实现了公平锁

  • 非公平锁:每个线程都均等概率获取锁

大部分情况使用非公平锁足以.synchronized就是非公平锁

可重入锁和不可重入锁(以连续加锁多次是否会死锁来区分)

  • 可重入锁:一个线程对同一把锁进行连续加锁不会死锁
  • 不可重入锁:一个线程对同一把锁进行连续加锁会死锁

互斥锁和读写锁(以锁定的内容来区分)

  • 互斥锁:多个线程不能对同一个变量进行读/写操作
  • 读写锁:多个线程能对同一个变量进行读/写操作
    读写锁的核心操作中分为了三个锁:
    1. 读锁:只影响读的内容
    2. 写锁:只影响写的内容
    3. 解锁
      写锁和写锁会竞争
      读锁和写锁不会竞争
      读锁和读锁会竞争
      解锁会和读/写锁竞争

Synchronized 原理

synchronized的特性

synchronized 是一个特殊的锁.

  1. 既是悲观锁,又是乐观锁
    开始是乐观锁,但若锁冲突频繁,就会转换成悲观锁
  2. 既是重量级锁,又是轻量级锁
    开始是轻量级锁,如果锁被持有的时间长,就转换为悲观锁
  3. 既是挂起等待锁,又是自旋锁
    当实现轻量级锁的时候大概率是使用的自旋锁策略
  4. 是可重入锁
  5. 非公平锁
  6. 普通的互斥锁

锁升级/膨胀

synchronized
进入synchronized代码块
发生锁冲突时
多次发生锁冲突时
无锁
偏向锁
自旋锁
重量级锁

  • 升级到偏向锁
    当一个线程进入 synchronized 标记的代码块时,这个线程就从无锁过度到偏向锁.偏向锁不是一进入{...}代码块中就进行一个加锁操作(性能损耗大).而是先进行"标记"(标记的过程很轻量快速,性能损耗小).
    如果一直运行到离开代码块时也没有其它线程竞争锁,这个锁就会一直处于偏向锁状态,解锁也只需要取消"标记"即可
  • 升级到自旋锁
    但若有其他线程来竞争这个锁时,这个处于偏向锁的进程会先一步把偏向锁升级为自旋锁.竞争的线程就只能忙等
  • 升级到重量级锁
    每一次遇到锁竞争时,synchronized都会统计锁冲突的频率,如果频率较高,就会升级为重量级锁(竞争频率高说明其它线程容易忙等,故升级为重量级锁,避免浪费系统资源)

对于当前的JVM,锁升级后是不支持降级的

锁消除

锁消除的作用就和名字一样,消除加锁代码.

但此机制只会在编译器有十足把握确定你的加锁是无意义的,编译器才会把锁去掉(很多时候虽然加锁是无意义的,但编译器也不一定能够消除掉,所以不能完全指望编译器帮助你消除无用锁)

锁粗化

  • 先解释一下锁的粒度:
    锁的粒度就是指加锁和解锁之间包含代码的多少
    代码越多,锁的粒度越粗
    代码越少,锁的粒度越细
    锁粗化是指把一些细粒度的锁合并成一个更粗粒度的锁.由于加锁解锁会造成性能损耗,故通过锁粗化机制会优化性能
  • 常见的锁粗化场景
    对于下段代码
java 复制代码
for (int i = 0; i < 10000; i++) {
    synchronized (lock) {
        // 执行同步操作
    }
}

编译器大抵会优化成这样

java 复制代码
synchronized (lock) {
    for (int i = 0; i < 10000; i++) {
        // 执行同步操作
    }
}

或者对于下段代码

java 复制代码
public void doSomething() {
    synchronized (lock) {
        // 任务 A
    }
    synchronized (lock) {
        // 任务 B
    }
    synchronized (lock) {
        // 任务 C
    }
}

编译器会将这三个细粒度锁优化为一个粗粒度锁

java 复制代码
public void doSomething() {
    synchronized (lock) {
        // 任务 A
        // 任务 B
        // 任务 C
    }
}

CAS

CAS全称是 Compare And Swap 即比较和交换

我们可以看一段模拟CAS实现的伪代码

java 复制代码
boolean CAS(address, expectValue, swapValue) {
  if(&address == expectValue) {
    &address = SwapValue;
    return true;
    }
  return false;
}
  • 其中:
    address是一个内存地址
    expectValue和swapValue都是寄存器的值
    观看上面的代码我们能很容易看出它的逻辑
  • 如果address这个内存地址的值等于expectValue,那么就让内存中的值更新为swapValue
  • 如果address这个内存地址的值不等于expectValue,那么不更新这个内存中的值
    虽然我们为了理解CAS写出了一段逻辑代码,但实际在电脑中运行的CAS不是一个简单的函数,而是一条CPU指令.也就是说CAS是具有原子性的.即不存在线程安全问题

在java中一般不会使用原生的CAS,而是使用基于CAS封装好的类

ABA问题

CAS是原子性的,是线程安全的.但是它也会存在一个类似于线程安全的问题,即ABA问题

在代码调用compareAndSet()方法时

  • 第一步(普通get):它会先获取当前变量的值(比如oldValue).这只是一个普通的get操作,不具备原子性
  • 第二步(原子CAS):把获取到的oldValue交给CPU进行比较和交换,这个过程才具有原子性
    因此会有一种可能:
    第一个线程使用compareAndSet()方法时获取到的值是A,第一个线程还没来得及进行CAS时,第二个线程就抢先一步获取到值是A并且修改为了B再又把B改回了A.此时第一个线程再拿到的值仍然是A,于是就将这个值修改为了C.以上过程便是ABA问题
  • 如何解决ABA问题?
    只需要加上一个版本号即可
    还是接着上一个例子,只不过这次修改为了带版本号的CAS
    第一个线程使用AtomicStampedReference类的的 compareAndSet() 方法时时获取到的值是A(版本号是1),第一个线程还没来得及进行CAS时,第二个线程就抢先一步获取到值是A并且修改为了B(版本号变成了2)再又把B改回了A(版本号变成了3).此时第一个线程再拿到的值虽然仍是A,但此时CPU发现现在的A版本号为3,而不是最开始获取到的版本号1.于是CAS交换失败.以上过程便是带版本号的ABA问题

JUC的常见类

JUC是java.util.concurrent类.这个类中是Java并发编程工具包.在此我只列举几个常见的类

Semaphore 信号量

对于synchronized.它一次性只能控制一个线程的访问.semaphore的作用与它类似,但是它内部维护了一个最大线程数.同一时间只允许一定的线程数访问该资源

简单来说:信号量本质就是一个计数器,用来描述"可用资源"的个数

它的常用API有以下几个

API 作用
new Semaphore(int count) 构造方法,指定count总可用资源数
acquire() 获取一个资源数,如果拿不到就阻塞
tryACquire() 尝试获取一个资源数,如果拿不到就立刻返回false
release() 释放一个资源数.如果此时有阻塞的acquire()就会唤醒该方法
  • 代码示例1:
java 复制代码
import java.util.concurrent.Semaphore;
public class Main {
    public static void main(String[] args) throws InterruptedException {
       Semaphore semaphore = new Semaphore(2);
        semaphore.acquire();
        System.out.println("拿到一个资源");
        semaphore.acquire();
        System.out.println("拿到一个资源");
        System.out.println("尝试拿到一个资源:" + semaphore.tryAcquire());
        semaphore.release();
        System.out.println("释放一个资源");
    }
}

运行结果:

拿到一个资源

拿到一个资源

尝试拿到一个资源false

释放一个资源

*代码示例2-利用semaphore实现线程安全:

java 复制代码
public class Main {
    private static int count = 0;
    public static void main(String[] args) throws InterruptedException {

        Semaphore semaphore = new Semaphore(1);
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                try {
                    semaphore.acquire();
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                count++;
                semaphore.release();
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                try {
                    semaphore.acquire();
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                count++;
                semaphore.release();
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(count);
    }
}

运行结果:

10000

  • semaphore在实现线程安全上和synchronized差异点
    • semaphore任何线程都能执行释放锁操作,而synchronized必须由加锁线程来释放
    • semaphore不支持重入锁
    • semaphore支持阻塞中断

CountDownLatch

作用:等待所有线程执行完毕才能进入下一步

类似于王者荣耀的加载界面

一局10个人都必须等到所有人加载完毕才能进入对局.

API|作用

new CountDownLatch(int count)|初始化一个CountDownLatch并设定所有线程数count

countDown()|每调用一次计数器就减一

await()|使当前线程挂起等待,直到count减到0才能继续执行

  • 代码示例:
java 复制代码
public static void main(String[] args) throws InterruptedException {
        CountDownLatch countDownLatch = new CountDownLatch(3);
        System.out.println("模拟系统启动");
        Thread thread1 = new Thread(() -> {
            try {
                System.out.println("正在启动磁盘检查");
                Thread.sleep(2000);
                System.out.println("磁盘检查完毕");
                countDownLatch.countDown();

            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        });
        Thread thread2 = new Thread(() -> {
            try {
                System.out.println("正在启动内存检查");
                Thread.sleep(3000);
                System.out.println("内存检查完毕");
                countDownLatch.countDown();

            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        });
        Thread thread3 = new Thread(() -> {
            try {
                System.out.println("正在启动主板检查");
                Thread.sleep(1000);
                System.out.println("主板检查完毕");
                countDownLatch.countDown();

            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        });
        thread1.start();
        thread2.start();
        thread3.start();
        System.out.println("等待系统自检完毕");
        countDownLatch.await();
        System.out.println("所有自检完毕,进入系统");
    }

输入内容:

模拟系统启动

等待系统自检完毕

正在启动磁盘检查

正在启动内存检查

正在启动主板检查

主板检查完毕

磁盘检查完毕

内存检查完毕

所有自检完毕,进入系统

  • CountDownLatch是一次性的.它的count计数器是无法重置的

  1. 在操作系统中由于线程的调度是随机的,所以即便每个线程都有均等概率来获取锁,但也不是数学上的严格均等 ↩︎
相关推荐
郝学胜-神的一滴2 小时前
[ 力扣 1124 ] 解锁最长良好时段问题:前缀和+哈希表的优雅解法
java·开发语言·数据结构·python·算法·leetcode·散列表
戴西软件2 小时前
戴西CAxWorks.VPG车辆工程仿真软件|假人+座椅双调整 汽车仿真效率直接拉满
java·开发语言·人工智能·python·算法·ui·汽车
skiy2 小时前
Spring WebFlux:响应式编程
java·后端·spring
FeBaby2 小时前
使用mat 分析java OOM问题
java·开发语言
indexsunny2 小时前
互联网大厂Java面试实战:基于微服务与云原生的电商场景问答解析
java·数据库·spring boot·docker·微服务·云原生·kubernetes
小明的IT世界2 小时前
编程智能体为何能让LLM在实际工作中表现更好
java·开发语言·人工智能·ai编程
下地种菜小叶2 小时前
接口幂等怎么设计?一次讲清重复提交、支付回调、幂等键与防重落地方案
java·spring boot·spring·kafka·maven
YDS8292 小时前
大营销平台 —— 模板方法串联前中置抽奖规则
java·spring boot·ddd