多线程编程常见面试题讲解(锁策略,CAS策略,synchronized原理,JUC组件,集合类)

"跑起来就有意义"
作者:Mylvzi
文章主要内容:多线程编程常见面试题讲解
在这里插入图片描述

hello各位朋友们,最近笔者刚刚结束了学校的期末考试,现在回来继续更新啦!!!
今天要学习的是多线程常见面试题讲解,这些内容都是面试中常考的一些问题!

一.常见的锁策略

1.乐观锁/悲观锁

乐观/悲观都是对某件事情发展的预测,在多线程中,乐观/悲观都是对锁冲突发生概率的一个预测

乐观锁:如果预测接下来锁冲突发生的概率,就减少一些工作,称之为乐观锁
悲观锁:如果预测接下来锁冲突发生的概率,就增加一些工作,称之为悲观锁

乐观锁和悲观锁不是一把具体的锁,而是描述锁的特性,是对锁冲突概率的一个预测!

2.轻量级/重量级锁

轻量级锁和重量级锁的本质区别就在于是否使用了阻塞这种策略

对于轻量级锁来说,不涉及到阻塞等待,而是一种纯用户态的操作,最常见的策略就是使用while循环不断等待获取锁

对于重量级锁来说,需要通过阻塞等待来避免锁冲突,是用户态和内核态交互的一种方式,阻塞必然会带来一定的系统资源消耗,使性能降低

轻量级锁对应的就是乐观锁,重量级锁对应的是悲观锁
如果你认为前路是乐观的,你就轻装上阵,如果你认为前路是充满坎坷与荆棘的,那就负重前行

3.自旋锁(Spin Lock)/挂起等待锁

自旋锁是轻量级锁的一种典型实现方式,是一种纯用户态的操作,尽管消耗了一定的cpu资源,但是带来了更快地响应速度
挂起等待锁是重量级锁的一种典型实现方式,是需要操纵系统的api的,往往要使用阻塞等待,性能下降,反应变慢,但是更加安全

以上三种锁策略其实是一一对应的,可以总结为下图
多线程编程常见面试题讲解(锁策略,CAS策略,synchronized原理,JUC组件,集合类)_第1张图片

自旋锁的特点可以总结为以下两点:

  1. 无阻塞:使用自旋锁,不会让线程进入阻塞等待的状态,而是不断地尝试获取,直到获取到,减少了因为阻塞带来的开销
  2. 忙等:自旋锁会让线程进入忙等的状态,在某下情况下会出现长时间消耗cpu资源的情况

自旋锁的适用场景:

  1. 对共享资源是短暂访问/持有的,如果长时间的持有,会导致其他线程处于忙等的状态
  2. 并发程度较低的场景,如果是高并发,竞争激烈的场景会消耗过多的cpu资源,得不偿失

4.读写锁

读写锁其实并不是第一次接触,在之前的MySQL学习过程中就接触过.MySQL的读写锁主要是为了解决脏读,幻读的问题,最典型的特征就是读的时候不能写,写的时候不能读,在多线程中,读写锁和MySQL中的有一点区别,主要在于:

读加锁:当前线程读的时候,别的线程可以读取,但是不能写
写加锁:当前线程写的时候,别的线程既不能读取,也不能写

读写锁在多线程中是一种控制对共享资源进行并发访问的机制,读加锁表示允许多个线程对共享资源进行同时读取,因为同时读取共享资源不会发生线程安全问题,这样也提升了多个线程读取数据的效率,写加锁对于共享资源的访问更加严苛一些,当一个线程获取到共享资源时,其他线程既不能读,也不能写,只能等该线程释放锁.这是因为操作往往会引发多个线程针对同一个变量进行修改这样的线程安全问题,灵活的使用读写锁,既可以提高并发编程的效率,也能保证线程安全

为什么读写锁这么重要呢?因为在日常的开发过程中,读往往要比写更加的频繁,在Java的标准库内部提供了现成的读写锁,需要用的时候直接查询即可

不同的语言中有不同的读写锁的实现方式,在许多编程语言中,常见的读写锁实现包括Java的ReentrantReadWriteLock、C++的std::shared_mutex等。这里主要介绍Java中的ReentrantReadWriteLock

5.公平/非公平锁

公平/非公平描述的是线程获取锁对象的几率

当拥有锁的线程释放锁之后,等待的线程获得锁对象的概率是遵从先来后到,这就是公平锁
当拥有锁的线程释放锁之后,等待的线程获得锁对象的概率是均等的,这就是非公平锁

对于系统api提供的锁,默认都是非公平锁,也就是每个线程获取到锁对象的几率是均等的
那如何实现一个公平锁呢?最重要的点在于如何确定获取锁对象的线程的先来后到,最直观的想法是可以通过一个队列来规定他们之间的执行顺序
多线程编程常见面试题讲解(锁策略,CAS策略,synchronized原理,JUC组件,集合类)_第2张图片
公平锁的实现依赖于Java标准库内部的一个类ReentrantReadWriteLock,他有两个构造方法

  1. 无参构造方法 ReentrantLock()
  2. 有参的构造方法 ReentrantLock(boolean fair)
    默认是非公平锁,如果将fair设置为true,那他就是公平锁,会按照线程的调度顺序去执行

二.CAS策略

CAS全程Comapre and Swap,比较并交换,是Java内部一个方法,这里比较交换的是寄存器和内存的值,比如现在有一个内存M,和两个寄存器A,B
多线程编程常见面试题讲解(锁策略,CAS策略,synchronized原理,JUC组件,集合类)_第3张图片
如果内存M上的值和寄存器A的值相同,就把B赋值给M,并返回true,如果不相同,就什么也不做,返回false
伪代码:

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

本质上,CAS其实不是方法,而是一个cpu指令,也就是说一个CAS指令就完成了比较+交换+返回值的操作,这不就保证了操作的原子性吗?java中的CAS方法知识JVM对原有cpu指令的一个包装!通过CAS方法就能实现无锁编程

下面讲解CAS最常见的两种用法

1.实现原子类

首先,如果我们使用Integer来定义一个变量cnt,并让其加一

Integer cnt = 0;
cnt++;

从cpu的角度来看cnt++这个操作,其实是分为三步的

  1. load 将内存的值加载到寄存器中
  2. add 将寄存器中的值+1
  3. save 将修改过后的值重新存储到内存之中

也真是因为这个操作是分步的,当多个线程尝试对cnt进行修改的时候就会触发线程安全问题,要想解决需要加锁,如果使用基于CAS实现的AtomicInteger类来修饰,cnt++这个操作就只有一步,相当于通过CAS将上述三步骤给封装起来,让三步变为了一步,这样就不会触发线程安全问题,也不需要加锁来避免线程安全,以代码为例
现在需要使用两个线程对cnt变量分别自增5000次的操作

    private static int cnt;

    public static void main(String[] args) throws InterruptedException {

        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                cnt++;
            }
        });

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                cnt++;
            }
        });

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

        // 让主线程等待两个线程都执行完毕
        t1.join();
        t2.join();

        System.out.println(cnt);
    }

如果不加限制,cnt的值是随机的,要想精确地获得答案,有两种解决方式
1.加锁

    // 设置一个加锁的对象
    private static Object locker = new Object();
    private static int cnt;
    public static void main(String[] args) throws InterruptedException {

        Thread t1 = new Thread(() -> {
            synchronized (locker) {
                for (int i = 0; i < 5000; i++) {
                    cnt++;
                }
            }
        });

        Thread t2 = new Thread(() -> {
            synchronized (locker) {
                for (int i = 0; i < 5000; i++) {
                    cnt++;
                }
            }
        });

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

        t1.join();
        t2.join();

        System.out.println(cnt);// 输出10000

    }

2.使用基于CAS实现的AtomicInteger类

    // 这里有一个小细节  必须要实例化一个AtomicInteger对象
    // 常规的成员类模式是0  但是此处是利用了外部类  是一个引用  如果不实例化就会产生空指针异常
    private static AtomicInteger cnt = new AtomicInteger(0);
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                // 等价于cnt++;
                cnt.getAndIncrement();
                // 等价与cnt--
//                cnt.getAndDecrement();
            }
        });

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

        t1.join();
        t2.join();

        System.out.println(cnt);// 输出10000
    }

我们可以使用伪代码的方式来理解这里的操作

// 这里存储的就是内存中的值
private int value;

public int getAndIncrement() {
	int oldvalue = value;
	// 这里的判断本质上是判断在这之前有没有其他线程传插进来执行
	// CAS方法既实现了判断又实现了++的操作
	while(CAS(value,oldvalue,oldvalue+1) != true) {
		// 返回false 证明有其他线程穿插进来执行
		// 此时value的值已经被更新  同时也要更新本线程的oldvalue
		oldvalue = value;
	}

	return oldvalue;
}

使用CAS实现的AtomicInteger类来实现cnt++这个操作相比于加锁(synchronized)效率更高,因为这是纯用户态的操作,不涉及到阻塞的开销,但同时,CAS会吃大量的cpu资源,且这种操作没有加锁具有普适性,只能在一些特定场景(比如整数的±)使用,在需要使用的时候直接查看文档即可

还有其他的基于CAS实现的原子类,都存储在java.util.concurrent.atomic包中
多线程编程常见面试题讲解(锁策略,CAS策略,synchronized原理,JUC组件,集合类)_第4张图片

2.实现自旋锁(Spin Lock)

上文已经说过自旋锁是一种轻量级锁,通过吃cpu资源的方式来避免加锁的开销,使用CAS也可以帮助我们实现一个自旋锁

class SpinLock {
    // 此处owner就相当于"内存值"
    private Thread owner = null;

    // 其他线程进行加锁
    public void lock() {
        // 通过CAS来判断当前锁是否被其他线程持有
        // 如果没由被其他线程持有 就是null  当前线程就可以持有这个锁
        // 如果不为null  证明这个锁已经被其他线程持有  当前线程需要等待
        while (!CAS(this.owner,null,Thread.currentThread())) {

        }
    }

    // 解锁
    public void unlcok() {
        this.owner = null;
    }
}

3.ABA问题

自旋锁部分我们已经介绍到CAS其实是通过内存值和寄存器值是否相等来作为线程是否穿插执行的判断依据,值相等就证明线程没有穿插执行,不相等,有线程穿插执行,但如果另一个线程执行的逻辑是A->B->A这样的逻辑,最后值没有发生改变,但实际上已经有了线程穿插执行.

对于这种问题,一般来说一般不会发生bug,在逻辑上其实是没有影响的,这就好比手机中的翻新机,虽然不是新的,但是不影响正常使用,但也有一些极端情况可能会因为ABA问题导致bug的出现,下面以一个账户存取的例子进行讲解
多线程编程常见面试题讲解(锁策略,CAS策略,synchronized原理,JUC组件,集合类)_第5张图片
此时这种情况并不会产生bug,但如果在t2线程扣款完毕之后紧接着在t3线程中执行存储500r的操作,就会产生bug
多线程编程常见面试题讲解(锁策略,CAS策略,synchronized原理,JUC组件,集合类)_第6张图片

这就是一个经典的由于CAS的漏洞而引发的"A-B-A"问题,那如何解决这种问题呢?核心思路在于让判定的数值不要反复横跳,而是保持只增不减/只减不增,可以引入一个版本号来解决,使用版本号(stamp)来规避,线程每执行一次操作,就让版本号++一次,这样线程的每次操作对应的就不是相同的版本号,此时比较对象就不再是账户余额了,而是版本号是否相同.如果不相同,就证明一定有线程穿插执行,即使有"A-B-A"这样的问题出现,也能规避掉

在实际的开发中,我们并不会直接使用CAS,而是使用已经封装好的,但是在面试中会考关于CAS的一些问题,最常见的就是"A-B-A"问题

三.synchronized原理

1.synchronized的基本特性

第一部分的锁策略大部分描述的是锁的特性,synchronized都具有哪些特性呢?

  1. 乐观/悲观 是自适应的
  2. 轻量级/重量级 是自适应的
  3. 自旋/挂起等待 是自适应的
  4. 不是读写锁
  5. 非公平锁
  6. 是可重入锁

所谓的自适应就是根据当前代码的具体情况而定,发生锁冲突的概率大,就自动升级为悲观锁,锁冲突概率小,就是轻量级锁,在synchronized背后是存在一系列的编译器的优化机制来帮助我们更加高效的使用加锁这个机制,对于synchronized来说,有几个常用的机制:

  1. 锁升级
  2. 锁消除
  3. 锁的粒度粗化

2.锁升级

被synchronized包裹起来的代码块又被称为同步块,在synchronized关键字的使用过程中,锁的状态会发生一系列的升级,主要涉及到四个方面的升级:

  1. 无锁
  2. 偏向锁
  3. 轻量级锁
  4. 重量级锁

无锁,就是不加锁,就是不适用synchronized修饰的代码

偏向锁(Biased Locking) 当一个线程访问到同步块时,JVM会将对象头的标志位设置为偏向锁,如果以后只有一个线程持有该对象的锁,JVM就会做出优化,即当该线程再次访问同步块时,并不会加锁,这样就减少了每次加锁/开锁的开销,所以偏向锁适用于单个线程对于共享资源的频繁访问的场景,如果有多个线程尝试访问这个同步块,偏向锁就会升级为轻量级锁

有人可能会说为什么要有偏向锁的存在呢?既然只有一个线程访问该资源,就一定不会发生锁冲突,直接不加锁不就行了吗?其实,偏向锁的设置体现了未雨绸缪的思想,我们无法保证在未来的系统优化过程中其他线程不会访问同样的共享资源,假设存在,如果使用无锁就会产生线程不安全问题,但是如果使用偏向锁这种机制,就可以及时的升级为轻量级锁,来避免线程安全问题的出现!

其实偏向锁体现了一种能不加锁就不加锁的思想,和单例模式中的懒汉模式有异曲同工之妙,懒汉模式是"能不创建对象就先不创建,什么时候用就什么时候创建"

轻量级锁(Lightweight Locking) 当多个线程同时竞争一个锁的时候,偏向锁就会升级为轻量级锁,轻量级锁通过CAS(Compare and Swap)策略来实现多线程之间的同步访问,提高了并发性,区别于传统的重量级锁的互斥访问,不会产生线程的阻塞

重量级锁(Heavyweight Locking) 当轻量级锁无法满足锁竞争时,就会升级为重量级锁.对于重量级锁来说,线程与线程之间如果同时竞争同一把锁,就会产生阻塞等待,直到一个线程释放了锁

锁的升级主要依赖于JVM,JVM会根据不同场景的锁的竞争程度,线程的访问频率来进行相应的锁的升级,注意升级是单向的,不会发生退化

3.锁消除

锁消除也是编译器优化手段的一种,编译器在编译阶段会对synchronized修饰的代码进行判定,如果编译器觉得你写的代码不需要加锁,就会自动消除锁.注意,锁的消除是发生在编译阶段

当然,为了线程安全,触发锁消除的概率是很小的,编译器只有在其有把握的情况下才会进行消除.

举一个简单的例子,我们熟知的StringBuffer和StringBuilder的最主要的区别在于StringBuffer是带有synchronized的,但是如果编译器发现只有一个线程操纵你的StringBuffer对象,就会自动消除掉锁,减少不必要的开销

4.锁粗化

先来了解什么是粒度,对于一把锁来说,锁的粒度描述的是其内部被加锁的代码数量,如果被加锁代码的数量,就是一个粗粒度的锁,如果被加锁的代码数量,就是一个细粒度的锁

对于细粒度的锁来说,能够并发执行的代码更多,能够充分的利用多核CPU资源,能够更好的实现并发编程,但是如果细粒度的锁涉及到频繁的锁竞争,其效率可能还不及粗粒度的锁,最常见的就是将一个大任务拆分为小任务的场景中,粗粒度的锁可能直接对整个大任务进行加锁,一次只能有一个线程去执行对应的任务,细粒度的锁是将整个大任务拆分为一个一个的小任务,给有必要加锁的地方加锁,但如果在高并发环境下,就会出现因为任务的细分导致频繁地锁竞争,就会产生频繁的上下文切换,带来更多的因锁竞争带来的开销

就像给老板汇报任务一样,老板给你布置了三个任务,你每完成一个任务就给老板打一次电话,不如你一次性把所有任务都完成,一个电话就能解决,这样也省去了老板的时间!

锁粗化也是一种编译器的优化手段,用于减少因为细粒度锁导致的频繁的锁竞争带来的开销.比如当编译器发现有多个锁涉及到频繁地上锁和解锁,而这些锁包含的代码之间的执行时间很短,编译器就会讲这些锁合并,转换为一个范围更大锁,使锁的粒度加粗

同样的,锁粗化可能也会带来一些问题,比如降低了代码并发执行的程度,没有充分利用多核CPU资源,在实际的开发中应该针对具体的场景进行性能测试,判断锁粗化的必要性

四.JUC部分组件讲解

JUC全程Java.util.concurrent,是Java标准库内部关于多线程常见的一个包,在多线程编程的过程中,会经常使用到包中的常用的组件,这里介绍几个常用的组件

1.Callable接口

Callable接口和Runnable接口类似,都是用于执行任务的,区别在于Runnable执行的任务的返回值是void,也就是说不要求返回值,但是Callable接口执行的任务是有返回值

考虑这么一个场景,我需要额外的一个线程去计算1+到100的结果(注意不是在主线程中执行,而是额外创建一个线程),并在主线程中打印结果,如果使用Runnable接口来完成这个任务,涉及到主线程去等待计算结果的线程执行完毕,才能去打印结果

    // 使用Runnable解决
    private static int sum;
    private static Object locker = new Object();

    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(() -> {
            synchronized (locker) {
                for (int i = 0; i <= 100; i++) {
                    sum += i;
                }

                // 任务执行完毕  唤醒加锁的线程
                locker.notify();
            }
        });

        t.start();

        synchronized (locker) {
            while (sum == 0) {
                // 等待线程执行解锁
                locker.wait();
            }
        }
        System.out.println(sum);
    }

使用Runnable接口就涉及到线程的等待与唤醒,如果使用Callable接口可以不适用notify和wait方法

    public static void main(String[] args) throws ExecutionException, InterruptedException {
        // 使用匿名内部类创建出Callable接口
        // 类似于Runnable接口 都适用于存储要执行的任务
        // 区别在于Callable接口里的任务有返回值
        Callable<Integer> callable = new Callable<Integer>() {
            @Override
            public Integer call() throws Exception {
                int sum = 0;
                for (int i = 0; i <= 100; i++) {
                    sum += i;
                }

                return sum;
            }
        };

        // Thread类不能直接使用Callble接口创建
        // 需要通过Runnable的子类FutureTask来实现
        // 这个子类还是很常见的  只是叫的名字不同  如JS中被称作promise
        FutureTask<Integer> futureTask = new FutureTask<>(callable);

        // 创建一个线程 让线程执行上述的任务
        Thread t1 = new Thread(futureTask);
        t1.start();

        // get方法会接受任务的返回值
        // 但是可能存在Callable接口内部的任务还没有执行完毕
        // 此时get方法就会阻塞等待
        System.out.println(futureTask.get());
    }

说明:

  1. call方法是Callable接口的核心方法,用于存储有返回值的要执行的任务,类似于Runnable接口中的run方法
  2. Thread类的构造只能通过Runnable及其子类来实现,Callable接口无法直接参与到Thread类的构造方法,需要先将其转换为Runnable的子类FutureTask,再让FutureTask参与到Thread类的构造方法之中(FuturTask的参数要和Callable接口所执行任务的返回值相同)
  3. 要获取到任务的返回值,可以使用futuretask中的get方法,如果执行计算的线程并未执行完毕,get方法就会阻塞等待,一直等到计算完毕!

所以,对于要执行有返回值的任务来说,使用Callable接口会更加简便,实际上,Callable接口也是创建线程方式的一种,致此我们已经学过很多的线程创建的方式,这里总结一下:

  1. 继承Thread类,重写run(创建单独的类/匿名内部类)
  2. 实现RUnnable接口,重写run(创建单独的类/匿名内部类)
  3. 实现Callable接口,重写call
  4. 使用lambda表达式
  5. 使用ThreadFactory
  6. 通过线程池创建

2.ReentrantLock

ReentrantLock也是java中用于加锁的一种方式,英文名直译为"可重入的锁",证明ReentrantLock具有可重入的特性,相较于synchronized有以下几个优势

  1. ReentrantLock在加锁的时候有两种方式,lock和trylock,提供了更灵活的操作空间
  2. ReentrantLock可以搭配Condition类实现更加灵活的线程间通信
  3. ReentrantLock还提供了公平锁的实现机制(默认是非公平锁)

以下是ReentrantLock的一个简单的使用示例:

	    // 创建一个ReentrantLock对象
        ReentrantLock lock = new ReentrantLock();
        
        // 加锁
        lock.lock();
        try{
            // 需要加锁执行的代码
        }finally {
            // 注意ReentrantLock不会自动解锁 需要手动解锁!!!
            lock.unlock();
        }
    }

使用ReentrantLock 实现公平锁,基于ReentrantLock 的构造方法实现,ReentrantLock 的构造方法中有一个布尔类型的参数,默认是false,所以默认情况下是非公平锁,如果改为true,那就是公平锁,公平锁就会按照"先来后到"的顺序让线程获取到锁

在这里插入图片描述
使用Reentrant实现公平锁的一个代码示例:

    // 设置为true 表示是一个公平锁
    public static ReentrantLock lock = new ReentrantLock(true);

    // 定义要执行的任务
    static class Worker implements  Runnable {
        // 用于标识线程id
        public int id;

        // 构造方法
        public Worker(int id) {
            this.id = id;
        }

        // 规定要执行的任务
        @Override
        public void run() {
            System.out.println("线程: " + id + "正在尝试获取锁");
            lock.lock();
            try {
                System.out.println("线程: " + id + "已经获取到锁");
            }finally {
                // 解锁
                lock.unlock();
                System.out.println("线程: " + id + "释放锁");
            }
        }
    }
    public static void main(String[] args) {

        for (int i = 0; i < 5; i++) {
            Thread t = new Thread(new Worker(i));
            t.start();
        }
    }

结果说明
多线程编程常见面试题讲解(锁策略,CAS策略,synchronized原理,JUC组件,集合类)_第7张图片

虽然 ReentrantLock提供了更加灵活的操纵方式,但是特别容易忘记解锁,而且ReentrantLock在使用上也会更加的复杂,在需要加锁的时候,还是更推荐使用synchronized进行加锁(当然了,如果在一些需要使用公平锁的情境下,还是需要使用ReentrantLock)

3.Semaphore

Semaphore(信号量),是一个计数器,用于描述可用资源的个数

就像自动停车场一样,停车场中的停车位的个数就是可用资源的个数,每进去一个车,车位就减少1,每出来一个车,车位就加1.在Java中我们使用P操作代表使用一个可用资源(车进去),每P操作一次,可用资源个数就减1,使用V操作代表返还一个可用资源(车出来),每V操作一次,可用资源的个数就加1

P,V操作的最初命名是由荷兰的数学家迪杰斯特拉命名的,实际上,P操作就是acquire(获得),V操作就是release(释放)

假设我们将信号量设置为5,也就是可用资源的个数是5,我们连续P操作5次之后,可用资源的个数为0,如果想继续进行P操作,就要阻塞等待有其他线程进行V操作,讲到这里有没有一种的感觉?实际上,锁是一种特殊的信号量,是可用资源为1的信号量,又被称为二元信号量,每P操作一次,可用资源减1,变为0,其他线程想要获取(P操作),就要阻塞等待,只能等到持有锁的线程进行V操作,使可用资源个数再次变为1

Semaphore的简单使用(规定并发编程的线程数目)

    public static void main(String[] args) {
        // 限制最多可以并发编程的线程数目为2
        final int MAX_CONCURRENT_TASKS = 2;

        // 创建可用资源为2的信号量
        Semaphore semaphore = new Semaphore(2);

        // 线程执行任务
        for (int i = 1; i <= 5; i++) {
            final int taskId = i;

            // 启动线程 执行任务
            Thread t = new Thread(() -> {
                try {
                    // 先打印获取信号量的线程id
                    System.out.println("线程 " + taskId + "正在获取信号量");

                    // 可用资源数目减1  如果为0 线程就阻塞等待
                    semaphore.acquire();
                    System.out.println("线程 " + taskId + "已经获取到信号量");

                    // 规定任务的执行时间
                    Thread.sleep(2000);

                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }finally {
                    // 任务执行完毕  释放信号量
                    semaphore.release();
                    System.out.println("线程 " + taskId + "执行完毕,可用资源加1");
                }
            });

            t.start();
        }
    }

执行结果:
多线程编程常见面试题讲解(锁策略,CAS策略,synchronized原理,JUC组件,集合类)_第8张图片

4.CountDownLatch

CountDownLatch是java并发包中常用的一个类,用于多线程编程中需要等待多个线程完成某一操作之后再继续执行其它代码的场景

下面讲解CountDownLatch的基本用法

1.创建CountDownLatch对象

		import java.util.concurrent.CountDownLatch;
        // 等待的线程数为3
        CountDownLatch latch= new CountDownLatch(3);

2.等待线程调用await方法

		// 阻塞等待直到所有线程都执行完毕
        latch.await();

3.线程执行完毕 要等待的线程数目减1

		latch.countDown();

以下是一个简单的使用案例

    public static void main(String[] args) throws InterruptedException {
        // 等待的线程数为3
        int numberOfTasks = 3;
        CountDownLatch latch = new CountDownLatch(numberOfTasks);
        for (int i = 0; i < numberOfTasks; i++) {
            Thread t = new Thread(() ->{
                System.out.println(Thread.currentThread().getId() + "正在工作!");

                // 每完成一个任务就减1
                latch.countDown();
            });

            t.start();
        }

        // 主线程等待所有的线程执行完毕
        latch.await();
        System.out.println("所有线程都执行完毕");
    }

CountDownLatch 最常使用的场景是将大任务拆分为小任务一个一个执行,使用CountDownLatch 让一个线程去执行一个任务,让主线程等待所有的线程执行完毕,最后再合并结果,这样可以大大的提高效率

包括网络上常见的一些提高下载速度的软件,本质上也是使用了"先拆分为小任务,让一个线程去执行一个小任务,最后再合并"的思路,多条线路去进行下载,大大提高了效率

5.线程安全的集合类

在之前,我们所学习过得数据结构都是线程不安全的,如stackqueue等,在单个线程下使用时没有问题的,但是在多线程下机会发生问题,其实,java中也引入了一些线程安全的集合类,如:

Vector,Stack,HashTable

但是这些集合类都是比较古老的集合类,尤其是Vector和Stack未来是要被废弃的,不推荐继续使用了

为了解决这种数据结构的集合能够在多线程环境下安全的使用,java也提供了更多的安全使用方式,这些方式的核心还是通过加锁来实现线程安全

1.多线程环境使用 ArrayList

方法一:使用同步机制保证线程安全

常见的同步机制包括synchronized,Semaphore,ReentrantLock之前已经做过详细的介绍,这里不再解释

方法二:Collections.synchronizedList

Collections.synchronizedList()

这个方法会返回一个自带synchronized的List集合,相当于给常规的ArrayList了一个线程安全的盔甲

        List<Integer> arrayList = 
                Collections.synchronizedList(new ArrayList<Integer>());

为什么要通过给ArrayList套盔甲的方式来实现线程安全呢?主要是吸取了Vector集合将集合与加锁牢牢绑定在一起,导致处理较多数据时,性能会很低,而通过Collections.synchronizedList这种方式,实现了数据集合和加锁的分离,降低了耦合性**,既可以在单线程中保证效率,又可以在多线程中保证线程安全

方法三:使用 CopyOnWriteArrayList

CopyOnWriteArrayList 从组成的单词也可以大概看出这个集合的用法,即当需要写的时候进行复制,这种方法通过额外开辟空间的方式来保证线程安全,没有加锁,是通过空间换性能提升

当存在两个线程同时访问共享资源时,两个线程读的时候正常读,因为这不会引发线程安全问题,当一个线程尝试修改数据时,不是直接对原有的资源进行修改,而是先对要修改资源进行复制,创建出一个副本,对这个副本进行修改,另一个线程读取数据时还可以读取原来的数据,保证了线程安全

但实际上,这种方法也有一定的局限性,只适用于特定的场景,

  1. 当前的ArrayList不能太大,否则拷贝资源的成本会更大
  2. 更适用于一个线程修改,多个线程读取的情况,如果多个线程尝试修改,也会导致拷贝资源过大,发生混乱

CopyOnWriteArrayList经常在服务器配置更新中使用,服务器配置的更新是通过修改服务器文件的形式进行的,通常是使用一个线程去修改服务器配置文件,修改完毕之后,配置文件被存到内存之中,其他的服务器就可以进行读取

2.多线程环境使用队列

在Java标准库内部有一些专门用于线程安全的队列

  1. ArrayBlockingQueue 基于数组实现的阻塞队列
  2. LinkedBlockingQueue 基于链表实现的阻塞队列
  3. PriOritityBlockingQueue 基于优先级队列实现的阻塞队列
  4. TransferQueue 用于传输的阻塞队列

3.ConcurrentHashMap

hash表的一个优化的线程安全实现方式

最开始,java中有一个集合类HashTable就是哈希表的线程安全实现方式,他的安全是通过加锁实现的,但是它是对整个哈希表进行加锁,只要涉及到对hash表的操作,就会发生阻塞等待,但如果我们观察一下哈希表的结构,就会发现其实不需要对整个哈希表进行加锁,哈希表是通过哈希桶实现的,哈希桶就是一个特殊的数组,数组的每个元素是队列,下面是哈希表的结构
多线程编程常见面试题讲解(锁策略,CAS策略,synchronized原理,JUC组件,集合类)_第9张图片
实际上,只有在多个线程同时访问同一个链表时才会发生线程安全问题,如果线程之间访问的不是同一个链表,就不会发生线程安全问题,所以没有必要对整个hash表进行加锁,所以ConcurrentHashMap通过对每个链表的头结点进行加锁的方式来降低了锁冲突发生的概率,提高了哈希表的使用性能.ConcurrentHashMap主要有以下四个方面的优化

  1. 最核心的优化就是降低了锁的粒度,通过对每个链表的头结点进行加锁的方式降低了锁冲突发生的概率,提高了性能
  2. ConcurrentHashMap还对哈希表中的一些操作进行了优化,比如通过CAS来统计哈希表中元素的数量,保证++的操作的原子性
  3. ConcurrentHashMap还有一些比较激进的做法,在HashTable中,多个线程无论是读还是写都会引发锁冲突,但是ConcurrentHashMap对于读,读和写这样的操作都不加锁,只会对写与写之间进行加锁
    这样难道不会引发读到一个修改一般的值这样的问题么?实际上在ConcurrentHashMap内部尽量避免使用++/–这样的非原子的操作,而是直接使用"="这样的操作来进行修改的操作,保证了原子性
  4. ConcurrentHashMap还对扩容做出了一定的优化,对于在单线程中使用的HashMap来说,扩容时需要对整个哈希表的所有元素进行重新分配,对于数据量特别大的场景来说,这样的操作可能会导致系统短时间内的卡顿,为了避免这种情况发生,ConcurrentHashMap采用"分段扩容"的方式,即一个链表一个链表元素进行重新分配,保证了系统不会因大量数据的重新分配导致崩溃

补充:
在Java8之前,ConcurrentHashMap加锁的方式是"分段锁",即几个链表作为一个整体进行加锁,但是在Java8之后就采用了每个链表头结点加锁的方式

你可能感兴趣的:(java,后端)