JAVA SE 多线程(下)

发布于:2025-05-22 ⋅ 阅读:(29) ⋅ 点赞:(0)

📕1. 常见的锁策略

注意 : 以下所介绍的锁策略, 不仅仅局限于Java这一种语言 , 这些性质通常也是给锁的实现者参考的.当然我们普通人也是可以了解一下的 , 这或许对使用锁也有帮助.

✏️1.1 乐观锁VS悲观锁

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

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

🌰举个栗子: 同学 A 和 同学 B 想请教老师一个问题.

同学 A 认为 “老师是比较忙的, 我来问问题, 老师不一定有空解答”. 因此同学 A 会先给老师发消息: “老师你忙嘛? 我下午两点能来找你问个问题嘛?” (相当于加锁操作) 得到肯定的答复之后, 才会真的来问问题.如果得到了否定的答复, 那就等一段时间, 下次再来和老师确定时间. 这个是悲观锁.

同学 B 认为 “老师是比较闲的, 我来问问题, 老师大概率是有空解答的”. 因此同学 B 直接就来找老师.(没加锁, 直接访问资源) 如果老师确实比较闲, 那么直接问题就解决了. 如果老师这会确实很忙, 那么同学 B也不会打扰老师, 就下次再来(虽然没加锁, 但是能识别出数据访问冲突). 这个是乐观锁.

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

就好比同学 C 开始认为 “老师比较闲的”, 问问题都会直接去找老师.但是直接来找两次老师之后, 发现老师都挺忙的, 于是下次再来问问题, 就先发个消息问问老师忙不忙,再决定是否来问问题.

✏️1.2 轻量级锁VS重量级锁

轻量级锁: 当一个线程尝试获取某个对象的锁时,如果该对象没有被其他线程锁定,则当前线程会将对象头中的Mark Word设置为指向当前线程栈帧的一个指针,这个过程称为“偏向锁”。如果多个线程同时竞争同一个锁,那么JVM会升级锁的状态,从偏向锁升级到轻量级锁。此时,每个线程都会尝试使用CAS操作来获取锁,如果成功则获得锁并进入临界区;如果失败,则自旋等待一段时间后再次尝试。

特定:

  1. 减少了操作系统上下文切换的开销。
  2. 在线程间竞争不激烈的情况下表现良好。
  3. 如果竞争过于激烈,可能会导致频繁的自旋,浪费CPU资源。

重量级锁: 传统的Java锁机制,如synchronized关键字所实现的锁,通常被称为重量级锁。当一个线程获取了某个对象的锁后,其他试图获取同一对象锁的线程会被阻塞,直到第一个线程释放锁为止。被阻塞的线程将进入等待队列,由操作系统负责管理这些线程的调度。

特点:

  1. 线程阻塞和唤醒的代价较高。
  2. 更适用于线程竞争激烈的场景,因为它可以避免CPU空转浪费资源。
  3. 相比轻量级锁,重量级锁的实现更加简单直接。
✏️1.3 自旋锁

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

理解⾃旋锁 vs 挂起等待锁
想象⼀下, 去追求⼀个⼥神. 当男⽣向⼥神表⽩后, ⼥神说: 你是个好⼈, 但是我有男朋友了~~
挂起等待锁: 陷⼊沉沦不能⾃拔.... 过了很久很久之后, 突然⼥神发来消息, '咱俩要不试试?' (注意, 这
个很⻓的时间间隔⾥, ⼥神可能已经换了好⼏个男朋友了).
⾃旋锁: 死⽪赖脸坚韧不拔. 仍然每天持续的和⼥神说早安晚安. ⼀旦⼥神和上⼀任分⼿, 那么就能⽴刻
抓住机会上位.

优点: 没有放弃 CPU, 不涉及线程阻塞和调度, 一旦锁被释放, 就能第一时间获取到锁.
缺点: 如果锁被其他线程持有的时间比较久, 那么就会持续的消耗 CPU 资源. (而挂起等待的时候是不消耗 CPU 的).

✏️1.4 公平锁VS非公平锁

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

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

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

这就好⽐⼀群男⽣追同⼀个⼥神. 当⼥神和前任分⼿之后, 先来追⼥神的男⽣上位, 这就是公平锁; 
如果是⼥神不按先后顺序挑⼀个⾃⼰看的顺眼的, 就是⾮公平锁.

在这里插入图片描述
公平锁
在这里插入图片描述
非公平锁
在这里插入图片描述
💡💡💡注意 :

  1. 操作系统内部的线程调度就可以视为是随机的. 如果不做任何额外的限制, 锁就是非公平锁. 如果要想实现公平锁, 就需要依赖额外的数据结构, 来记录线程们的先后顺序.
  2. 公平锁和非公平锁没有好坏之分, 关键还是看适用场景.
  3. synchronized 是非公平锁
✏️1.5 可重入锁和不可重入锁

可重入锁: 简单来说就是一个线程如果抢占到了互斥锁资源,在锁释放之前再去该竞争同一把锁的时候,不需要等待,只需要记录重入次数。在多线程并发编程里面,绝大部分锁都是可重入的,比如Synchronized、ReentrantLock等,但是也有不支持重入的锁,比如JDK8里面提供的读写锁StampedLock。锁的可重入性,主要解决的问题是避免线程死锁的问题。

✏️1.6 读写锁

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

读写锁(readers-writer lock),看英文可以顾名思义,在执行加锁操作时需要额外表明读写意图,复数读者之间并不互斥,而写者则要求与任何人互斥。

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

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

读写锁就是把读操作和写操作区分对待. Java 标准库提供了 ReentrantReadWriteLock 类, 实现了读写锁

ReentrantReadWriteLock.ReadLock 类表示⼀个读锁. 这个对象提供了 lock / unlock 方法进行加锁解锁.
ReentrantReadWriteLock.WriteLock 类表示⼀个写锁. 这个对象也提供了 lock / unlock方法进行加锁解锁.

其中,
读加锁和读加锁之间, 不互斥.
写加锁和写加锁之间, 互斥.
读加锁和写加锁之间, 互斥

读写锁特别适合于 “频繁读, 不频繁写” 的场景中.

📕2. 死锁

什么是死锁?
死锁是这样一种情形:多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止。

🌰举个栗子理解死锁 :

当我和我对象(当然现在还没有😭)⼀起去饺子馆吃饺子时 , 吃饺子需要酱油和醋.

我拿起了酱油瓶, 我对象拿起了醋瓶.

我 : 你先把醋瓶给我, 我用完了就把酱油瓶给你.

我对象 : 你先把酱油瓶给我, 我用完了就把醋瓶给你.

如果我们俩彼此之间互不相让, 就构成了死锁.

酱油和醋相当于是两把锁, 我们两个人就是两个线程.

✏️2.1 哲学家就餐问题

有个桌子 , 围着一圈哲学家 , 桌子中间放着一盘意大利面 . 每个哲学家两两之间, 放着一根筷子.
在这里插入图片描述

每个哲学家只做两件事 : 思考人生或者吃面条. 思考人生的时候就会放下筷子. 吃⾯条就会拿起左右两边的筷子(先拿起左边, 再拿起右边).
在这里插入图片描述
如果哲学家发现筷子拿不起来了 , 就会阻塞等待
在这里插入图片描述
关键点 : 如果5位哲学家同时拿起左手边的筷子时 , 然后再尝试拿右手的筷子, 就会发现右手的筷子都被占用了. 由于哲学家们互不相让, 这个时候就形成了死锁
在这里插入图片描述
死锁是一种严重的 BUG!! 导致一个程序的线程 “卡死”, 无法正常工作!

✏️2.2 形成死锁的必要条件

死锁产生的四个必要条件:

  1. 互斥使用,即当资源被一个线程使用(占有)时,别的线程不能使用

  2. 不可抢占,资源请求者不能强制从资源占有者手中夺取资源,资源只能由资源占有者主动释放。

  3. 请求和保持,即当资源请求者在请求其他的资源的同时保持对原有资源的占有。

  4. 循环等待,即存在一个等待队列:P1占有P2的资源,P2占有P3的资源,P3占有P1的资源。这样就形成了一个等待环路。

当上述四个条件都成⽴的时候,便形成死锁。当然,死锁的情况下如果打破上述任何⼀个条件,便可让死锁消失。

✏️2.3 如何避免死锁

破坏循环等待

最常用的一种死锁阻止技术就是锁排序. 假设有 N 个线程尝试获取 M 把锁, 就可以针对 M 把锁进行编号 (1, 2, 3…M).

N 个线程尝试获取锁的时候, 都按照固定的按编号由小到⼤顺序来获取锁. 这样就可以避免环路等待

可能产生循环等待死锁的代码

//产生环路等待不是100%发生的,这只是概率问题,哲学家就餐产生死锁也是概率问题
public class Test {

    private static Object locker1 = new Object();
    private static Object lockre2 = new Object();

    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            synchronized (locker1) {
                synchronized (lockre2) {
                    System.out.println("this is thread t1");
                }
            }
        });

        Thread t2 = new Thread(() -> {
            synchronized (lockre2) {
                synchronized (locker1) {
                    System.out.println("this is thread t2");
                }
            }
        });

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


    }
}

不会产生循环等待的代码

public class Test {

    private static Object locker1 = new Object();
    private static Object lockre2 = new Object();

    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            synchronized (locker1) {
                synchronized (lockre2) {
                    System.out.println("this is thread t1");
                }
            }
        });

        Thread t2 = new Thread(() -> {
            synchronized (locker1) {
                synchronized (lockre2) {
                    System.out.println("this is thread t2");
                }
            }
        });

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


    }
}

📕3. JUC(java.util.concurrent) 的常见类

✏️3.1 Callable 接口

Callable 是一个 interface . 相当于把线程封装了一个 “返回值”. 方便我们借助多线程的方式计算结果

🌰代码示例: 创建线程计算 1 + 2 + 3 + … + 1000, 使用 Callable 版本

  1. 创建一个匿名内部类, 实现 Callable 接口. Callable 带有泛型参数. 泛型参数表示返回值的类型.
  2. 重写 Callable 的 call 方法, 完成累加的过程. 直接通过返回值返回计算结果.
  3. 把 callable 实例使用 FutureTask 包装一下
  4. 创建线程, 线程的构造方法传入FutureTask . 此时新线程就会执行FutureTask 内部的 Callable 的call 方法, 完成计算. 计算结果就放到了 FutureTask 对象中.
  5. 在主线程中调用futureTask.get() 能够阻塞等待新线程计算完毕. 并获取到 FutureTask 中的结果.
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;

public class Test {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        Callable<Integer> callable = new Callable<Integer>() {
            @Override
            public Integer call() throws Exception {
                int result = 0;
                for (int i = 0; i <= 1_000; i++) {
                    result+=i;
                }
                return result;
            }
        };

        FutureTask<Integer> futureTask = new FutureTask<>(callable);

        Thread t = new Thread(futureTask);
        t.start();
        System.out.println(futureTask.get());
    }
}

//500500

理解Callable

Callable 和 Runnable 相对, 都是描述一个 “任务”. Callable 描述的是带有返回值的任务, Runnable描述的是不带返回值的任务.
Callable 通常需要搭配 FutureTask 来使用. FutureTask 用来保存 Callable 的返回结果. 因为Callable 往往是在另一个线程中执行的, 啥时候执行完并不确定. FutureTask 就可以负责这个等待结果出来的工作.

理解 FutureTask
想象去吃麻辣烫. 当餐点好后, 后厨就开始做了. 同时前台会给你一张 “小票” . 这个小票就是FutureTask. 后面我们可以随时凭这张小票去查看自己的这份麻辣烫做出来了没.

✏️3.2 ReentrantLock

可重入互斥锁. 和 synchronized 定位类似, 都是用来实现互斥效果, 保证线程安全.

ReentrantLock 的用法:

lock(): 加锁, 如果获取不到锁就死等.

trylock(超时时间): 加锁, 如果获取不到锁, 等待一定的时间之后就放弃加锁.

unlock(): 解锁

ReentrantLock 和 synchronized 的区别:

  1. synchronized 是一个关键字, 是 JVM 内部实现的 , ReentrantLock 是标准库的一个类, 在 JVM 外实现的(基于 Java 实现).
  2. synchronized 使用时不需要手动释放锁. ReentrantLock 使用时需要手动释放. 使用起来更灵活, 但是也容易遗漏 unlock.
  3. synchronized 在申请锁失败时, 会死等. ReentrantLock 可以通过 trylock 的方式等待一段时间就放弃
  4. synchronized 是非公平锁, ReentrantLock 默认是非公平锁. 可以通过构造方法传入一个 true 开启公平锁模式.
  5. 更强大的唤醒机制. synchronized 是通过 Object 的 wait / notify 实现等待-唤醒. 每次唤醒的是一个随机等待的线程. ReentrantLock 搭配 Condition 类实现等待-唤醒, 可以更精确控制唤醒某个指定的线程.

如何选择使用哪个锁?

  1. 锁竞争不激烈的时候, 使用 synchronized, 效率更高, 自动释放更方便
  2. 锁竞争激烈的时候, 使用 ReentrantLock, 搭配 trylock 更灵活控制加锁的行为, 而不是死等
  3. 如果需要使用公平锁, 使用 ReentrantLock
✏️3.3 原子类

原子类内部用的是 CAS 实现,所以性能要比加锁实现 i++ ⾼很多。原子类有以下几个:

  1. AtomicBoolean
  2. AtomicInteger
  3. AtomicIntegerArray
  4. AtomicLong
  5. AtomicReference
  6. AtomicStampedReference

虽然原子类有多 , 但是很抱歉 , 有很多原子类我也并没有使用过 , 所提具体在什么场景下使用什么原子类 , 也是我的知识盲区😭

以 AtomicInteger 举例,常见方法有:

addAndGet(int delta); i += delta;
decrementAndGet(); --i;
getAndDecrement(); i--;
incrementAndGet(); ++i;
getAndIncrement(); i++;
✏️3.4 信号量 Semaphore

信号量, 用来表示 “可用资源的个数”. 本质上就是一个计数器.

🌰举个栗子理解一下:

可以把信号量想象成是停车场的展示牌: 当前有车位 100 个. 表示有 100 个可用资源.

当有车开进去的时候, 就相当于申请一个可用资源, 可用车位就 -1 (这个称为信号量的 P 操作(P是荷兰语单词首字母))

当有车开出来的时候, 就相当于释放一个可用资源, 可用车位就 +1 (这个称为信号量的 V 操作(V是荷兰语单词首字母))

如果计数器的值已经为 0 了, 还尝试申请资源, 就会阻塞等待, 直到有其他线程释放资源.

Semaphore 的 PV 操作中的加减计数器操作都是原⼦的, 可以在多线程环境下直接使⽤.

代码示例:

//创建 Semaphore ⽰例, 初始化为 4, 表⽰有 4 个可⽤资源.
//acquire ⽅法表⽰申请资源(P操作), release ⽅法表⽰释放资源(V操作)
//创建 20 个线程, 每个线程都尝试申请资源, sleep 1秒之后, 释放资源. 观察程序的执⾏效果.
import java.util.concurrent.Semaphore;

public class Test {
    public static void main(String[] args) {
        Semaphore semaphore = new Semaphore(4);
        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                System.out.println("apply");
                try {
                    semaphore.acquire();
                    System.out.println("accussful");
                    Thread.sleep(10000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                semaphore.release();
                System.out.println("release");

            }
        };

        for (int i = 0; i < 20; i++) {
            Thread t = new Thread(runnable);
            t.start();
        }
    }
}
✏️3.5 CountDownLatch

同时等待 N 个任务执行结束

🌰例如 : 好像跑步比赛,10个选手依次就位,哨声响才同时出发;所有选手都通过终点,才能公布成绩。

代码示例:

  1. 构造 CountDownLatch 实例, 初始化 10 表示有 10 个任务需要完成.
  2. 每个任务执行完毕, 都调⽤用countDown() . 在 CountDownLatch 内部的计数器同时自减.
  3. 主线程中使用 latch.await(); 阻塞等待所有任务执行完毕. 相当于计数器为 0 了.
import java.util.Random;
import java.util.concurrent.CountDownLatch;

public class Test {
    public static void main(String[] args) throws InterruptedException {
        CountDownLatch count = new CountDownLatch(10);
        Random random = new Random();
        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                try {
                    Thread.sleep(random.nextInt(5000));
                    count.countDown();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        };
        for (int i = 0; i < 10; i++) {
            Thread t = new Thread(runnable);
            t.start();
        }
        // 必须等到 10 ⼈全部回来
        count.await();
        System.out.println("game is over");
    }
}

📕4. synchronized 原理

首先 , 我们总结一下synchronized锁的基本特定 :

  1. 开始时是乐观锁, 如果锁冲突频繁, 就转换为悲观锁.
  2. 开始是轻量级锁实现, 如果锁被持有的时间较长, 就转换成重量级锁.
  3. 实现轻量级锁的时候大概率用到自旋锁策略
  4. 是一种不公平锁
  5. 是一种可重入锁
  6. 不是读写锁
✏️4.1 加锁工作过程

JVM 将 synchronized 锁分为无锁、偏向锁、轻量级锁、重量级锁 状态。会根据情况,进行依次升级。
在这里插入图片描述

  1. 偏向锁

第一个尝试加锁的线程, 优先进入偏向锁状态.

偏向锁不是真的 “加锁”, 只是给对象头中做一个 “偏向锁的标记”, 记录这个锁属于哪个线程.如果后续没有其他线程来竞争该锁, 那么就不用进行其他同步操作了(避免了加锁解锁的开销) , 如果后续有其他线程来竞争该锁(刚才已经在锁对象中记录了当前锁属于哪个线程了, 很容易识别当前申请锁的线程是不是之前记录的线程), 那就取消原来的偏向锁状态, 进⼊一般的轻量级锁状态.

偏向锁本质上相当于 “延迟加锁” . 能不加锁就不加锁, 尽量来避免不必要的加锁开销 . 但是该做的标记还是得做的, 否则无法区分何时需要真正加锁.

🌰举个栗子理解偏向锁 :

假设男主是一个锁, 女主是一个线程. 如果只有这一个线程来使用这个锁, 那么男主女主即使不领证结婚(避免了高成本操作), 也可以一直幸福的生活下去.

但是女配出现了, 也尝试竞争男主, 此时不管领证结婚这个操作成本多高, 女主也势必要把这个动作完成了, 让女配死心

  1. 轻量级锁

随着其他线程进入竞争, 偏向锁状态被消除, 进入轻量级锁状态(自适应的自旋锁)

  1. 重量级锁

如果竞争进一步激烈, 自旋不能快速获取到锁状态, 就会膨胀为重量级锁

✏️4.2 其他的优化操作
  1. 锁消除

有些应用程序的代码中, 用到了 synchronized, 但其实没有在多线程环境下 , 编译器+JVM 会判断锁是否可消除. 如果可以, 就直接消除 , 避免白白浪费了一些资源开销.

  1. 锁粗化

一段逻辑中如果出现多次加锁解锁, 编译器 + JVM 会⾃动进行锁的粗化
在这里插入图片描述

🌰举个例子理解锁粗化

滑稽老哥当了领导, 给下属交代工作任务:

⽅式⼀:
打电话, 交代任务1, 挂电话.
打电话, 交代任务2, 挂电话.
打电话, 交代任务3, 挂电话

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

明显方式二的效率更高

📕5. CAS

CAS: 全称Compare and swap,字面意思:”比较并交换“,一个 CAS 涉及到以下操作:

我们假设内存中的原数据V,旧的预期值A,需要修改的新值B。

  1. 比较 A 与 V 是否相等。(比较)
  2. 如果比较相等,将 B 写入 V。(交换)
  3. 返回操作是否成功
✏️5.1 CAS的ABA问题

在这里插入图片描述

ABA问题就好比 , 我们买⼀个手机, 无法判定这个手机是刚出厂的新手机, 还是别人用旧了, 又翻新过的手机.


网站公告

今日签到

点亮在社区的每一天
去签到