一片文章总结Java初学阶段的常见的几种锁策略(内含八股文知识点)

一、多线程编程中常见的锁策略

1.乐观锁 VS 悲观锁

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

什么是乐观锁?
乐观锁 总是假设最好的情况,认为数据一般情况下不会产生并发冲突,在自己使用的时候不会有别人使用。 所以在数据进行提交更新的时候才会正事对数据是否产生并发冲突进行检测,如果此时发现了并发冲突了,则返回用户错误的信息,让用户决定如何去做。

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

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

这两种思路不能说谁优谁劣, 而是看当前的场景是否合适.
如果当前老师确实比较忙, 那么使用悲观锁的策略更合适, 使用乐观锁会导致 “白跑很多趟”, 耗费额
外的资源.
如果当前老师确实比较闲, 那么使用乐观锁的策略更合适, 使用悲观锁会让效率比较低.

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

乐观锁的一个重要功能就是要检测出数据是否发生访问冲突. 我们可以引入一个 “版本号” 来解决.

2.轻量级锁 VS 重量级锁

前言:锁的核心特性 “原子性”, 这样的机制追根溯源是 CPU 这样的硬件设备提供的.

  • CPU 给操作系统提供了 “原子操作指令”.
  • 操作系统基于 CPU 的原子指令, 实现了 mutex 互斥锁提供给JVM.
  • JVM 基于操作系统提供的mutex互斥锁, 实现了 synchronized 和 ReentrantLock 等关键字和类.

什么是重量级锁?
重量级锁:加锁机制重度依赖了OS提供的mutex

  • 大量的内核态用户态切换
  • 很容易引发线程的调度

这两个操作, 成本比较高. 一旦涉及到用户态和内核态的切换, 就意味着 “沧海桑田”.

什么是轻量级锁?
轻量级锁:加锁机制尽可能不使用 mutex, 而是尽量在用户态代码完成. 实在搞不定了, 再使用 mutex.

  • 少量的内核态用户态切换。
  • 不太容易引发线程调度。

理解用户态 vs 内核态
想象去银行办业务.
在窗口外, 自己做, 这是用户态. 用户态的时间成本是比较可控的.
在窗口内, 工作人员做, 这是内核态. 内核态的时间成本是不太可控的.
如果办业务的时候反复和工作人员沟通, 还需要重新排队, 这时效率是很低的.

Synchronized刚开始是一个轻量级锁. 如果锁冲突比较严重, 就会变成重量级锁.

3.自旋锁 VS 挂起等待锁

什么是挂起等待锁?
挂起等待锁:当某个线程没有申请到锁的时候,此时该线程会放弃CPU,被挂起,即加入到等待队列等待。当锁被释放的时候,就会被唤醒,重新竞争锁。

按挂起等待锁来的话,线程在抢锁失败后会进入阻塞状态,放弃CPU,需要很长时间才能再次被调度。但实际上,大部分情况下,虽然当前抢锁失败,但过不了多久,锁就会被释放,没必要放弃CPU,这个时候就可以使用自旋锁来处理这样的问题.

什么是自旋锁?
自旋锁:如果获取锁失败, 立即再尝试获取锁, 无限循环, 直到获取到锁为止. 第一次获取锁失败, 第二次的尝试会
在极短的时间内到来.

这样一旦锁被其他线程释放, 就能第一时间获取到锁

自旋锁是一种典型的 轻量级锁 的实现方式

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

synchronized 中的轻量级锁策略大概率就是通过自旋锁的方式实现的.

4.互斥锁 VS 读写锁

互斥锁就是前面用过的Synchronized这样的锁,提供了 加锁 和 解锁 两个操作。如果一个线程加锁了,另一个线程也尝试加锁,就会阻塞等待,称为互斥锁

读写锁提供了三种操作:

(1).针对读加锁

(2). 针对写加锁

(3).解锁

基于一个事实,多线程针对同一个变量并发读,这个时候没有线程安全问题,所以就不需要加锁控制。但如果并发写,或者有读有写,就要进行加锁控制, 读写锁就是针对这种情况采取的特殊处理!

总的来说,互斥关系如下:

  • 读锁和读锁之间,没有互斥
  • 写锁和写锁之间,存在互斥
  • 写锁和读锁之间,存在互斥

Java标准库中有两个类:读锁类和写锁类

5.公平锁 VS 非公平锁

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

公平锁:遵守“先来后到”原则,B比C先来的,当A释放锁后,B就能先于C获取到锁。
非公平锁不遵守 “先来后到”. A释放锁后,B 和 C 都有可能获取到锁.
注意

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

Synchronized是非公平锁

6.可重入锁 VS 不可重入锁

可重入锁的字面意思是“可以重新进入的锁”,即允许同一个线程多次获取同一把锁。
比如一个递归函数里有加锁操作,递归过程中这个锁会阻塞自己吗?如果不会,那么这个锁就是可重入
(因为这个原因可重入锁也叫做递归锁)。

Java里只要以Reentrant开头命名的锁都是可重入锁,而且JDK提供的所有现成的Lock实现类,包括
synchronized关键字锁都是可重入的。

而 Linux 系统提供的 mutex 是不可重入锁

理解 “把自己锁死”,一个线程没有释放锁, 然后又尝试再次加锁.
按照之前对于锁的设定, 第二次加锁的时候, 就会阻塞等待. 直到第一次的锁被释放, 才能获取到第二个锁. 但是释放第一个锁也是由该线程来完成, 结果这个线程已经躺平了, 啥都不想干了, 也就无法进行解锁操作. 这时候就会 死锁

synchronized是可重入锁

7.总结

(1)怎么理解乐观锁和悲观锁,具体怎么实现呢?

悲观锁认为多个线程访问同一个共享变量冲突的概率较大, 会在每次访问共享变量之前都去真正加锁.
乐观锁认为多个线程访问同一个共享变量冲突的概率不大. 并不会真的加锁, 而是直接尝试访问数据. 在访问的同时识别当前的数据是否出现访问冲突.
悲观锁的实现就是先加锁(比如借助操作系统提供的 mutex), 获取到锁再操作数据. 获取不到锁就等待.
乐观锁的实现可以引入一个版本号. 借助版本号识别出当前的数据访问是否冲突.

(2)介绍一下读写锁

读写锁就是把读操作和写操作分别进行加锁.
读锁和读锁之间不互斥.
写锁和写锁之间互斥.
写锁和读锁之间互斥.
读写锁最主要用在 “频繁读, 不频繁写” 的场景中.

(3) 什么是自旋锁,为什么要使用自旋锁策略呢,缺点是什么?

如果获取锁失败, 立即再尝试获取锁, 无限循环, 直到获取到锁为止. 第一次获取锁失败, 第二次的尝试会在极短的时间内到来. 一旦锁被其他线程释放, 就能第一时间获取到锁.
相比于挂起等待锁,
优点: 没有放弃 CPU 资源, 一旦锁被释放就能第一时间获取到锁, 更高效. 在锁持有时间比较短的场景下非常有用.
缺点: 如果锁的持有时间较长, 就会浪费 CPU 资源

(4)synchronized 是可重入锁么?

是可重入锁.
可重入锁指的就是连续两次加锁不会导致死锁.
实现的方式是在锁中记录该锁持有的线程身份, 以及一个计数器(记录加锁次数). 如果发现当前加锁的线程就是持有锁的线程, 则直接计数自增

二、CAS

1.什么是CAS?

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

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

  1. 比较 A 与 V 是否相等。(比较)
  2. 如果比较相等,将 B 写入 V。(交换)
  3. 返回操作是否成功。

2.CAS是怎么实现的

针对不同的操作系统,JVM 用到了不同的 CAS 实现原理,简单来讲:

  • java 的 CAS 利用的的是 unsafe 这个类提供的 CAS 操作;
  • unsafe 的 CAS 依赖了的是 jvm 针对不同的操作系统实现的 Atomic::cmpxchg;
  • Atomic::cmpxchg 的实现使用了汇编的 CAS 操作,并使用 cpu 硬件提供的 lock 机制保证其原子性。

简而言之,就是因为硬件给予了支持,软件层面才能做到这些。
总的来说CAS可以理解成CPU给咱么提供的一个特殊指令,通过这个指令,就可以一定程度的处理线程安全问题,

3.CAS的应用场景

(1)实现原子类

标准库中提供了 java.util.concurrent.atomic 包, 里面的类都是基于这种方式来实现的.
典型的就是 AtomicInteger 类.
AtomicInteger简单演示:

  AtomicInteger count = new AtomicInteger(0); 定义原子类的变量count,初始值为0
  count.getAndIncrement();// 相当于 count++   此时count=1

介绍一下原子类的工作原理
原子类伪代码实现:``

class AtomicInteger {
    private int value;
    public int getAndIncrement() {
        int oldValue = value;
        while ( CAS(value, oldValue, oldValue+1) != true) {
            oldValue = value;
       }
        return oldValue;
   }
}

通过形如上述代码就可以实现一个原子类. 不需要使用重量级锁, 就可以高效的完成多线程的自增操作.

假设两个线程同时调用getAndIncrement方法
工作过程:
1)两个线程都读取 value 的值到 oldValue 中. (oldValue 是一个局部变量, 在栈上. 每个线程有自己的栈)
2) 线程1 先执行 CAS 操作. 由于 oldValue 和 value 的值相同, 直接进行对 value 赋值.

注意:

  • CAS 是直接读写内存的, 而不是操作寄存器.
  • CAS 的读内存, 比较, 写内存操作是一条硬件指令, 是原子的.

3) 线程2 再执行 CAS 操作, 第一次 CAS 的时候发现 oldValue 和 value 不相等, 不能进行赋值. 因此需要进入循环.在循环里重新读取 value 的值赋给 oldValue。
4)线程2 接下来第二次执行 CAS, 此时 oldValue 和 value 相同, 于是直接执行赋值操作.
5)线程1 和 线程2 返回各自的 oldValue 的值即可.

(2)实现自旋锁

基于 CAS 实现更灵活的锁, 获取到更多的控制权.
自旋锁伪代码:

public class SpinLock {
    private Thread owner = null;
    public void lock(){
        // 通过 CAS 看当前锁是否被某个线程持有. 
        // 如果这个锁已经被别的线程持有, 那么就自旋等待. 
        // 如果这个锁没有被别的线程持有, 那么就把 owner 设为当前尝试加锁的线程. 
        while(!CAS(this.owner, null, Thread.currentThread())){
       }
   }
    public void unlock (){
        this.owner = null;
   }
}


4.CAS的ABA问题

什么是ABA问题?

假设存在两个线程 t1 和 t2. 有一个共享变量 num, 初始值为 A. 接下来, 线程 t1 想使用 CAS 把 num 值改成Z,
那么就需要先读取 num 的值, 记录到 oldNum 变量中. 使用 CAS 判定当前 num 的值是否为 A, 如果为 A,就修改成 Z.
但是, 在 t1 执行这两个操作之间, t2 线程可能把 num 的值从 A 改成了 B, 又从 B 改成了 A线程 t1的 CAS 是期望 num 不变就修改. 但是 num 的值已经被 t2 给改了. 只不过又改成 A 了. 这个时候 t1 究竟是否要更新num 的值为 Z 呢?
到这一步, t1 线程无法区分当前这个变量始终是 A, 还是经历了一个变化过程.

ABA问题引来的BUG:
大部分的情况下, t2 线程这样的一个反复横跳改动, 对于 t1 是否修改 num 是没有影响的. 但是不排除一些特殊情况.
举个特殊情况的例子:

假设 某人A 有 100 存款. 想从 ATM 取 50 块钱. 取款机创建了两个线程, 并发的来执行 -50 操作.
我们期望一个线程执行 -50 成功, 另一个线程 -50 失败.
如果使用 CAS 的方式来完成这个扣款过程就可能出现问题.
正常的过程
1.存款 100. 线程1 获取到当前存款值为 100, 期望更新为 50; 线程2 获取到当前存款值为 100, 期望更新为 50.
2. 线程1 执行扣款成功, 存款被改成 50. 线程2 阻塞等待中.
3. 轮到线程2 执行了, 发现当前存款为 50, 和之前读到的 100 不相同, 执行失败.
异常的过程
1.存款 100. 线程1 获取到当前存款值为 100, 期望更新为 50; 线程2 获取到当前存款值为 100, 期
望更新为 50.
2. 线程1 执行扣款成功, 存款被改成 50. 线程2 阻塞等待中.
3. 在线程2 执行之前, A的朋友正好给A转账 50, 账户余额变成 100 !!
4. 轮到线程2 执行了, 发现当前存款为 100, 和之前读到的 100 相同, 再次执行扣款操作这个时候, 扣款操作被执行了两次!!! 都是 ABA 问题搞的鬼

解决方案:
给要修改的值, 引入版本号. 在 CAS 比较数据当前值和旧值的同时, 也要比较版本号是否符合预期.

CAS 操作在读取旧值的同时, 也要读取版本号.
真正修改的时候:

如果当前版本号和读到的版本号相同, 则修改数据, 并把版本号 + 1.
如果当前版本号高于读到的版本号. 就操作失败(认为数据已经被修改过了).

对比上面异常的过程:

假设 A 有 100 存款. A想从 ATM 取 50 块钱. 取款机创建了两个线程, 并发的来执行 -50 操作.
我们期望一个线程执行 -50 成功, 另一个线程 -50 失败.
为了解决 ABA 问题, 给余额搭配一个版本号, 初始设为 1.
1.存款 100. 线程1 获取到 存款值为 100, 版本号为 1, 期望更新为 50; 线程2 获取到存款值为 100, 版本号为 1, 期望更新为 50.
2.线程1 执行扣款成功, 存款被改成 50, 版本号改为2. 线程2 阻塞等待中.
3.在线程2 执行之前, A的朋友正好给A转账 50, 账户余额变成 100, 版本号变成3.
4.轮到线程2 执行了, 发现当前存款为 100, 和之前读到的 100 相同, 但是当前版本号为 3, 之前读到的版本号为 1, 版本小于当前版本, 认为操作失败.

在 Java 标准库中提供了 AtomicStampedReference 类. 这个类可以对某个类进行包装, 在内部就提供了上面描述的版本管理功能。

三、Synchronized原理及加锁优化过程

基本特点:
结合开头的锁策略,可以总结出,Synchronized具有一下特性

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

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

四、JUC(java.util.concurrent)的常见类

1.Callable接口

使用方法:

public static void main(String[] args) throws ExecutionException, InterruptedException {
        //计算1+2+3+...+100
        Callable<Integer> callable=new Callable<Integer>() {   //描述任务
            @Override
            public Integer call() throws Exception {
                int sum=0;
                for (int i = 1; i <=100 ; i++) {
                    sum=sum+i;
                }
                return sum;
            }
        };

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

        t.start();
        Integer result=futureTask.get();   //get()方法就是获取结果,get会发生阻塞,知道callable执行完毕,get才阻塞完成,才获取到结果
        System.out.println(result);  //5050
    }

理解Callable,及其与Runnable的区别

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

2.ReentrantLock

可重入互斥锁. 和 synchronized 定位类似, 都是用来实现互斥效果, 保证线程安全
基础用法:
在这里插入图片描述

ReentrantLock lock = new ReentrantLock(); 
-----------------------------------------
lock.lock();   
try {    
 // working    
} finally {    
 lock.unlock()    
}  

ReentrantLock和Synchronized的区别

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

如何选择使用哪个锁?

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

3.原子类

上面介绍CAS的时候引入了原子类,及简单地使用方法。 这里介绍一下**原子类的常见方法:**

原子类有以下几个:

  • AtomicBoolean
  • AtomicInteger
  • AtomicIntegerArray
  • AtomicLong
  • AtomicReference
  • AtomicStampedReference

AtomicInteger举例,常见方法有:

addAndGet(int delta); //i += delta;
decrementAndGet(); //–i;
getAndDecrement(); //i–;
incrementAndGet(); //++i;
getAndIncrement(); //i++;

例:基于 AtomicInteger 实现多线程自增同一个变量

public static void main(String[] args) throws InterruptedException {
        AtomicInteger count=new AtomicInteger(0);

        Thread t1=new Thread(()->{
            for (int i = 0; i < 5000; i++) {
                count.getAndIncrement();    //count++
            }
        });

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

        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("count="+count);
    }

4.线程池

虽然创建销毁线程比创建销毁进程更轻量, 但是在频繁创建销毁线程的时候还是会比较低效.
线程池就是为了解决这个问题. 如果某个线程不再使用了, 并不是真正把线程释放, 而是放到一个 “池子” 中, 下次如果需要用到线程就直接从池子中取, 不必通过系统来创建了.

ExecutorService 和 Executors

代码示例:

  • ExecutorService 表示一个线程池实例.
  • Executors 是一个工厂类, 能够创建出几种不同风格的线程池.
  • ExecutorService 的 submit 方法能够向线程池中提交若干个任务.
ExecutorService pool = Executors.newFixedThreadPool(10);
pool.submit(new Runnable() {
    @Override
    public void run() {
        System.out.println("hello");
   }
});

Executors 创建线程池的几种方式:

  • newFixedThreadPool: 创建固定线程数的线程池
  • newCachedThreadPool: 创建线程数目动态增长的线程池.
  • newSingleThreadExecutor: 创建只包含单个线程的线程池.
  • newScheduledThreadPool: 设定 延迟时间后执行命令,或者定期执行命令. 是进阶版的 Timer.
    Executors 本质上是 ThreadPoolExecutor 类的封装.

ThreadPoolExecutor

ThreadPoolExecutor 提供了更多的可选参数, 可以进一步细化线程池行为的设定

在这里插入图片描述

理解ThreadPoolExecutor构造方法的参数
把创建一个线程池想象成开个公司. 每个员工相当于一个线程.

  • corePoolSize: 正式员工的数量. (正式员工, 一旦录用, 永不辞退)
  • maximumPoolSize: 正式员工 + 临时工的数目. (临时工: 一段时间不干活, 就被辞退).
  • keepAliveTime: 临时工允许的空闲时间.
  • unit: keepaliveTime 的时间单位, 是秒, 分钟, 还是其他值.
  • workQueue: 传递任务的阻塞队列
  • threadFactory: 创建线程的工厂, 参与具体的创建线程工作.
  • RejectedExecutionHandler: 拒绝策略, 如果任务量超出公司的负荷了接下来怎么处理.
  • AbortPolicy(): 超过负荷, 直接抛出异常.
  • CallerRunsPolicy(): 调用者负责处理
  • DiscardOldestPolicy(): 丢弃队列中最老的任务.
  • DiscardPolicy(): 丢弃新来的任务.

代码示例:

ExecutorService pool = new ThreadPoolExecutor(1, 2, 1000, TimeUnit.MILLISECONDS, 
                                              new SynchronousQueue<Runnable>(),
                                              Executors.defaultThreadFactory(),
                                              new ThreadPoolExecutor.AbortPolicy());
for(int i=0;i<3;i++) {
    pool.submit(new Runnable() {
        @Override
        void run() {
            System.out.println("hello");
       }
   });
}

5.信号量Semaphore

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

理解信号量
可以把信号量想象成是停车场的展示牌: 当前有车位 100 个. 表示有 100 个可用资源.
当有车开进去的时候, 就相当于申请一个可用资源, 可用车位就 -1 (这个称为信号量的 P 操作)
当有车开出来的时候, 就相当于释放一个可用资源, 可用车位就 +1 (这个称为信号量的 V 操作)
如果计数器的值已经为 0 了, 还尝试申请资源, 就会阻塞等待, 直到有其他线程释放资源.

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

代码示例:

  • 创建 Semaphore 示例, 初始化为 4, 表示有 4 个可用资源.

  • acquire 方法表示申请资源(P操作), release 方法表示释放资源(V操作)

  • 创建 20 个线程, 每个线程都尝试申请资源, sleep 1秒之后, 释放资源. 观察程序的执行效果.

Semaphore semaphore = new Semaphore(4);   //资源被申请完的话,再申请就要等待
Runnable runnable = new Runnable() {
    @Override
    public void run() {
        try {
            System.out.println("申请资源");
            semaphore.acquire();
            System.out.println("我获取到资源了");
            Thread.sleep(1000);
            System.out.println("我释放资源了");
            semaphore.release();
       } catch (InterruptedException e) {
            e.printStackTrace();
       }
   }
};
for (int i = 0; i < 20; i++) {
    Thread t = new Thread(runnable);
    t.start();
}

6.CountDownLatch

CountDownLatch类 :同时等待 N 个任务执行结束.

  • 构造 CountDownLatch 实例, 初始化 10 表示有 10 个任务需要完成.
  • 每个任务执行完毕, 都调用 latch.countDown() . 在 CountDownLatch 内部的计数器同时自减.
  • 主线程中使用 latch.await(); 阻塞等待所有任务执行完毕. 相当于计数器为 0 了.
public class Demo {
    public static void main(String[] args) throws Exception {
        CountDownLatch latch = new CountDownLatch(10);
        Runnable r = new Runable() {
            @Override
            public void run() {
                try {
                    Thread.sleep(Math.random() * 10000);
                    latch.countDown();
               } catch (Exception e) {
                    e.printStackTrace();
               }
           }
       };
        for (int i = 0; i < 10; i++) {
            new Thread(r).start();
       }
   // 必须等到 10 人全部回来
        latch.await();
        System.out.println("比赛结束");
    }
}
  • 2
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值