「多线程」CAS & ReentrantLock & 信号量

发布于:2024-05-23 ⋅ 阅读:(53) ⋅ 点赞:(0)

🎇个人主页Ice_Sugar_7
🎇所属专栏JavaEE
🎇欢迎点赞收藏加关注哦!

🍉CAS

compare and swap 的缩写,它是一个特殊的 cpu 指令,负责完成“比较和交换”的工作
下面是 CAS 内部运行的伪代码
address 是内存中的值;expectedValue 是寄存器 expected 中的值;swapValue 是另一个寄存器 swap 中的值

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

CAS 中会比较 address 的值是否和 expected 寄存器中的值相同。若相同,就交换 swap 的值和 address 的值,并返回 true;若不同,就只返回 false

我们刚才说 CAS 是一条 cpu 指令,这就说明上面的操作是原子的,它为我们编写线程安全的代码提供了新思路
之前线程安全都是靠加锁保证的,而使用 CAS 不涉及加锁,也就不会阻塞,合理使用可以保证线程安全,而且效率更高

Java 的标准库对 CAS 进行进一步的封装,提供一些工具类让我们直接使用,原子类是最主要的工具类之一

在这里插入图片描述

java.util.concurrent.atomic 里面所有类都是基于 CAS 实现的
拿 AtomicInteger 来说,对这个类的对象进行 ++(getAndIncrement 方法或 incrementAndGet 方法) 或 – 操作对变量的修改是一个 CAS 指令,而一个指令天然就是原子的,所以是线程安全的
getAndIncrement 的实现如下(在标准库源码的基础上进行了简化)

class AtomicInteger {
    private int value;
    public int getAndIncrement() {
        int oldValue = value; //这个 oldValue 实际上是寄存器中存放的值,这一步是把它初始化为 AtomicInteger 实例里保存的 value
        while(CAS(value,oldValue,oldValue+1) != true) { //如果比较交换成功,那循环就结束了,此时 value 更新为 value + 1
            oldValue = value;
        }
        return oldValue;
    }
}

在 while 循环的判定条件中,如果 value 和 oldValue 不一样,那意味着在 CAS 之前有另一个线程修改了 value,就会进入循环修改 oldValue 的值(重新读取新的 value 到 oldValue 中,此时的 value 是内存中最新的值)

之前涉及的线程不安全是因为内存中的值变了,但是寄存器的值没有变,所以接下来的修改就会出错。使用 CAS 这种方式可以识别内存的值是否改变,巧妙地解决了之前的线程安全问题

确保线程安全也是有代价的——自旋消耗了更多 cpu 资源
下面演示一下 AtomicInteger 在多线程中同时对一个变量进行自增操作:

public class TestDemo {
    public static void main(String[] args) throws InterruptedException {
        AtomicInteger count = new AtomicInteger();
        Thread t1 = new Thread(()-> {
            for(int i = 0;i < 50000;i++) 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.get());
    }
}

在这里插入图片描述


🍉Callable 接口

之前我们学习创建线程时,主要了解了继承 Thread 类、实现 Runnable 接口、使用 lambda 表达式这几种方法,但是它们没法获取到返回值,只能通过引入新的成员变量,在线程中修改这个变量来间接实现“获取返回值”
如果我们希望获取线程中的返回值时,可以用 Callable 接口

在这里插入图片描述

泛型参数 V 表示返回值类型
Callable 用起来还是有一点小麻烦的,因为 Thread 没有提供构造方法来传入 Callable,所以需要引入一个 Futuretask 类作为“中间人”,把 Callable 和 Thread 连接起来

Futuretask 意为“未来的任务”,也就是待执行的任务,这里的任务在 Callable 的实例 callable里面,需要把 callable “投喂” 给 Futuretask
先创建一个 FutureTask 实例 futuretask,然后将 callable 放进去,再在创建线程对象的时候把 futuretask 放进去

举个例子,创建一个线程,求 1+2+3+…+1000

public class TestDemo7 {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        Callable<Integer> callable = new Callable<Integer>() {
            @Override
            public Integer call() throws Exception {
                int sum = 0;
                for(int i = 1;i <= 1000;i++)
                    sum += i;
                return sum;
            }
        };
        FutureTask<Integer> futureTask = new FutureTask<>(callable); //FutureTask 的泛型参数和 Callable 的一致
        Thread t= new Thread(futureTask);
        t.start();
        System.out.println(futureTask.get());
    }
}

我们通过 Futuretask 的 get 方法来获取返回值,这个操作带有阻塞功能,如果线程还没执行完毕,那么 get 就会阻塞,等到线程执行完,return 的结果就会被 get 返回回来

虽然 Callable 能做的任务,使用 Runnable 也可以做,不过对于有返回值的任务,还是用 Callable 的代码比较直观一点


🍉ReentrantLock

早期 Java 的 synchronized 功能不够强大,而且没有各种优化,于是用 ReentrantLock 实现可重入锁。synchronized 功能丰富起来之后就比较少用 ReentrantLock 了
不过还是有必要了解一下 ReentrantLock,下面来说说它和 synchronized 的不同之处

  1. ReentrantLock 需要手动加锁,解锁
    传统的锁提供了 lock 和 unlock 两种方法分别用于加锁、解锁,不像 synchronized 那么自动化,所以传统的写法可能会出现加锁后忘记 unlock 的问题,或者由于触发了 return、异常导致执行不到 unlock
    所以要用 ReentrantLock 就最好把 unlock 放到 finally 中(因为 finally 的语句是一定会执行的),不过这样就是麻烦了一些

  2. ReentrantLock 提供了 tryLock 的操作
    使用 lock 加锁,如果加锁没成功就会阻塞
    而使用 tryLock,加锁不成功的话不会阻塞,而是直接返回 false。因此通过 tryLock 可以提供更多的可操作空间

  3. ReentrantLock 提供了公平锁的实现
    只需在 ReentrantLock 的构造方法中填写参数就可以设置为公平锁(通过队列记录线程加锁的先后顺序)

  4. 等待通知机制不一样
    synchronized 是搭配 wait、notify 使用;ReentrantLock 是搭配 Condition 类,它的功能比 wait、notify 强一丢丢(不过在实际开发中 synchronized 已经够用了)


🍉信号量

以生活中的例子引入

一些停车场的门口会有一块电子牌,上面显示当前剩余多少个车位,每开进一辆车,上面的数字就会 -1,反之,车开出停车场就会 +1

这里的“剩余的车位数”就是信号量,表示可用资源的个数(在这个例子中就是车位)
申请一个可用资源,信号量就 -1,这个操作称为 P 操作;释放一个可用资源,信号量就 +1,这个操作称为 V 操作。如果数值为 0 了还继续 P 操作,那 P 操作就会阻塞(没有车位了当然不能开进去了)

信号量也是操作系统内部提供的机制,jvm 对操作系统对应的 api 进行封装,我们可以通过 Java 的 Semaphore 类来调用这些相关操作

public static void main(String[] args) throws InterruptedException {
    Semaphore semaphore = new Semaphore(2); //构造方法的参数表示信号量
    semaphore.acquire(); //获取一个可用资源
    System.out.println("P 操作");
    semaphore.acquire();
    System.out.println("P 操作");
    semaphore.acquire(); //信号量已经为 0 了,再 acquire 就会阻塞
    System.out.println("P 操作");
    semaphore.release(); //释放一个可用资源
    System.out.println("V 操作");
}

在这里插入图片描述

信号量其实和锁有联系,锁本质上也是一种特殊的信号量,我们可以认为它是计数值为 1 的信号量(同一时间只能有一个线程持有某个锁)。在释放状态下就是 1,处于加锁状态时就是 0,对于这种非 0 即 1 的信号量,我们称为二元信号量


🍉CountDownLatch

CountDownLatch 是一个同步工具类,它可以控制一个或多个线程等待其他线程完成操作,我们之前使用 join 一次只能等待一个线程,如果要等待多个线程,要写很多个 join 是很麻烦的,使用 CountDownLatch 就只需写一次,可以有效简化代码

有时候下载一个很大的文件,可以把它拆成多个部分,每个线程下载一小部分,当所有线程下载完之后,把下载的结果拼到一起,这称为“多线程下载”。下面模拟一下这个过程(注意 sleep 只是模拟下载时间而已)

    public static void main(String[] args) throws InterruptedException {
        CountDownLatch Latch = new CountDownLatch(5); //要等待 5 个线程
        for(int i = 0;i < 5;i++) {
            int n = i;
            Thread t = new Thread(()->{
                int time = (new Random().nextInt(5)+1) * 1000;
                System.out.println("线程 " + n + " 开始下载");
                try {
                    Thread.sleep(time);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                System.out.println("线程 " + n + " 下载完成");
                Latch.countDown(); //告知 CountDownLatch 该线程执行完毕
            });
            t.start();
            
        }
        Latch.await(); //调用 await 来等待所有任务结束
        System.out.println("所有任务执行完成");
    }

在这里插入图片描述


网站公告

今日签到

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