多线程进阶篇

多线程进阶篇

文章目录

  • 多线程进阶篇
    • 1、常见的锁策略
      • 1) 乐观锁 vs 悲观锁
      • 2) 重量级锁 vs 轻量级锁
      • 3) 自旋锁 vs 挂起等待锁
      • 4) 读写锁 vs 互斥锁
      • 5) 公平锁 vs 非公平锁
      • 6) 可重入锁 vs 不可重入锁
    • 2、死锁
      • 1) 死锁的三种典型情况:
      • 2) 如何解决死锁问题
      • 3) 死锁产生的必要条件
    • 3、Synchronized 采用策略
      • 1) 偏向锁
      • 2) 轻量级锁
      • 3) 其他优化操作
        • 1.锁消除
        • 2.锁粗化
    • 4、CAS
      • 1) CAS的应用
        • 1. 实现原子类
        • 2. 实现自旋锁
      • 2) CAS 的 ABA 问题
      • 3) 解决方案
    • 5、Callable 接口
    • 6、JUC
      • 1) ReentrantLock 可重入锁
      • 2) 原子类的应用场景
      • 3) 信号量 Semaphore
      • 4) CountDownLatch
    • 7、集合类
      • 1) 多线程环境使用 ArrayList
      • 2) 多线程环境使用队列
      • 3) 多线程环境使用哈希表

1、常见的锁策略

这里讨论的锁策略不仅仅局限于 Java,此篇幅主要是认识几种常见的锁策略,能够知道概念。

接下来提及到的都不是某个具体的锁,而是抽象的概念。

描述的是锁的特性,描述的是“一类锁”。


1) 乐观锁 vs 悲观锁

二者都是对后续场景中的锁冲突现象进行一个预估。

乐观锁:预测后续的场景中,不会出现很多锁冲突的现象。(后续的工作会更少

悲观锁:预测后续的场景中,很容易出现锁冲突的现象。(后续会做出更多的工作来保证线程安全

锁冲突:两个线程尝试获取同一把锁,一个线程能获取成功,另一个线程阻塞等待。

锁冲突的概率大还是小,对后续的工作,是有一定影响的。


2) 重量级锁 vs 轻量级锁

重量级锁:加锁的开销是比较大的(花的时间多,占用系统资源多)

轻量级锁:加锁开销比较小的(花的时间少,占用系统资源少)

乐观悲观锁 vs 重量轻量锁

乐观悲观锁,是在加锁之前,对锁冲突概率的预测,决定工作的多少。

重量轻量,是在加锁之后,考量实际的锁的开销。

正是因为这样的概念存在重合,针对一个具体的锁,可能把它叫做乐观锁,也可能叫做轻量锁。但是此观点是不绝对的,反之也成立。


3) 自旋锁 vs 挂起等待锁

自旋锁:是轻量级锁的一种典型实现。

在用户态下,通过自旋的方式(while…循环),实现类似于加锁的效果。

这种锁,会消耗一定 cpu 资源,但是可以做到最快速度拿到锁

等待挂起锁:是重量级锁的一种典型实现。

通过内核态,借助系统提供的锁机制,当出现锁冲突的时候,会牵扯到内核对于线程的调度,是冲突的线程出现挂起(阻塞等待)

这种方式,消耗 cpu 资源更少.也就无法保证第一时间拿到锁


4) 读写锁 vs 互斥锁

互斥锁:就是简单的加锁(synchronized)解锁

读写锁:把读操作枷锁和写操作加锁分开了。

读锁:是在读的时候加锁。 写锁:是在写的时候加锁。

如果两个线程,都对读加锁加锁,则不会产生锁竞争。(多线程并发执行的效率就更高)

如果两个线程,一个线程写加锁,一个线程也是写加锁,则会产生锁竞争。

如果两个线程,一个线程写加锁,两一个线程读加锁,则也会产生锁竞争。

实际开发中,读操作的频率,往往比读操作,高更多。

java标准库中也提供了现成的读写锁。


5) 公平锁 vs 非公平锁

公平锁:遵循先来后到。(通过一定的数据结构去实现)

非公平锁:一拥而上,抢占式。(原生)

操作系统自带的锁(pthread_mutex)属于是非公平锁。


6) 可重入锁 vs 不可重入锁

一个线程,针对同一把锁,连续加锁多次。如果产生了死锁,则是不可重入锁,如果没有产生死锁,就是可重入锁。

可以按照字面意思来理解,可重入锁的字面意思是“可以重新进入的锁”,即允许同一个线程多次获取同一把锁

观察下面这串伪代码:

public synchronized void increase() {
    synchronized (this) {
        count ++;
    }
}

1.调用方法,先针对this加锁。 此时假设加锁成功了。
⒉接下来往下执行到代码块中的 synchronized ,此时,还是针对this来进行加锁。

在不可重入锁中

第二次 this 上的锁,得在 increase 方法执行完毕之后,才能释放。要想让代码继续往下执行,就需要把第二次加锁获取到,也就是把第一次加锁释放。要想把第一次加锁释放,又需要保证代码先继续执行。这就陷入了一个死锁状态,程序无法执行。(这个状态是非常不合理的,第二次尝试加锁的时候,该线程已经有了这个锁的权限了,这个时候不应该加锁失败,不应该阻塞等待


不可重入锁:如果是一个不可重入锁。这把锁不会保存,是哪个线程对它加的锁,只要它当前处于加锁状态之后,收到了"加锁”这样的请求,就会拒绝当前加锁。而不管当下的线程是哪个。就会产生死锁。

可重入锁:一把可重入锁,是会让这个锁保存,是哪个线程加上的锁。后续收到加锁请求之后,就会先对比一下,看看加锁的线程是不是当前持有自己这把锁的线程,这个时候就可以灵活判定了。

注:synchronized 实际上是一个可重入锁


可重入锁,是让锁记录了当前是哪个线程持有了锁,观察下面伪代码。

synchronized (this) {             //这个是真正加了锁,下面的锁只是虚晃一枪。
    synchronized (this) {         //判定了一下持有线程就是当前线程
        synchronized (this) {	  //同上
            …………
		}                 //执行到这个代码,出了代码块的时候,刚才加上的锁是否要释放??        答案是:不释放。
    }                     //如果在里层就释放了锁,意味着最外面的 synchronized 和次外层的代码,就没有处于锁的保护之中了
}

问题:如果加了 N 层锁,在遇到大括号时,JVM 咋知道当前这个大括号是最后一个(最外层的)呢??

答:让锁这里持有一个“计数器”就行了。让锁对象不光要记录是哪个线程持有的锁,同时再通过一个整形变量记录当前这个线程加了几次锁!!


2、死锁

什么是死锁??

死锁是一种严重的 BUG!!导致一个程序的线程 “卡死”, 无法正常工作!

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


1) 死锁的三种典型情况:

1. 一个线程,一把锁,但是是不可重入锁。该线程针对这个锁连续加锁两次,就会出现死锁。

public synchronized void increase() {
    synchronized (this) {
        count ++;
    }
}

2. 两个线程,两把锁。这两个线程先分别获取到一把锁,然后再同时尝试获取对方的锁。

public class Demo1 {
    private static Object locker1 = new Object();    	 //第一把锁
    private static Object locker2 = new Object();    	 //第二把锁

    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            synchronized (locker1) {                  	//获取第一把锁,成功获取。
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

                synchronized (locker2) {              //获取第二把锁,由于 locker2 被占用,获取失败。(死锁)
                    System.out.println("t1 两把锁加锁成功!");
                }
            }
        },"t1");
        
        Thread t2 = new Thread(() -> { 
            synchronized (locker2) {				    //获取第二把锁,成功获取。
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

                synchronized (locker1) {  			  //获取第一把锁,由于 locker1 被占用,获取失败。(死锁)
                    System.out.println("t2 两把锁加锁成功!");
                }
            }
        },"t2");

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

从 jconsole 中可以看到程序中两个线程中(t1、t2)的死锁。死锁的线程就僵住了,无法正常工作,会对程序造成严重的影响。
多线程进阶篇_第1张图片


3. N个线程M把锁,哲学家就餐问题。

可以通过一个抽象的图来进行理解。有五个哲学家(五个线程),五根筷子(五把锁)。

多线程进阶篇_第2张图片

每个哲学家,主要要做两件事:

  1. 思考人生。(此时会放下筷子)
  2. 吃面,会拿起左手和右手的筷子,再去夹面条吃。(拿起筷子)

其他设定:

  1. 每个哲学家,啥时候思考人生,啥时候吃面条,都很随机。
  2. 每个哲学家一旦想吃面条了,就会非常固执的完成吃面条的操作。如果此时,他的筷子被别人使用了,就会阻塞等待,而且等待过程中不会放下手里已经拿着的筷子。

基于上述的模型设定,绝大部分情况下,这些哲学家都是可以很好的工作。

但是,如果出现了极端情况,就会出现死锁

比如,同一时刻,五个哲学家都想吃面,并且同时伸出左手拿起左边的筷子。再尝试伸右手拿右边的筷子。

2) 如何解决死锁问题

解决方法:针对锁进行编号,并且规定加锁的顺序。每个线程如果要获取多把锁,必须先获取编号小的锁,后获取编号大的锁

利用上述办法,1 2 3 4 号哲学家分别获取到 1 2 3 4 号筷子。当 5 号哲学家开始获取筷子时,只能去获取 4 号筷子,但是 4 号筷子已经被 4 号哲学家获取到了,因此只能阻塞等待 4 号哲学家用完后释放,才能获取到。当 1 号哲学家用完 1 5 两根筷子时,1 5 均被释放,2 号就可以获取到 1 号筷子……以此类推,当 4 号哲学家释放 4 号筷子时,5号哲学家才能开始动筷。

多线程进阶篇_第3张图片


3) 死锁产生的必要条件

  1. 互斥使用:当一个线程获取到一把锁后,别的线程不能获取到着吧锁。(锁的基本特性)
  2. 不可抢占:锁只能是被持有者主动释放,而不能是被其他线程直接抢走。(锁的基本特性)
  3. 请求和保持:这一个线程去尝试获取多把锁,在获取第二把锁的过程中,会保持对第一把锁的获取状态。
  4. 循环等待:t1 尝试获取 locker2,需要 t2 执行完,释放 locker2; t2尝试获取 locker1,需要 t1 执行完,释放 locker1。

3、Synchronized 采用策略

多线程进阶篇_第4张图片

synchronized 加锁过程:代码中写了一个 synchronized 之后,这里可能会产生一系列的"自适应的过程",锁升级(锁膨胀)

无锁→偏向锁→轻量级锁→重量级锁


1) 偏向锁

偏向锁,不是真的加锁,而只是做了一个"标记"。如果有别的线程来竞争锁了,才会真的加锁。如果没有别的线程竞争,就自始至终都不会真的加锁了。

加锁本身,有一定开销。能不加,就不加。非得是有人来竞争了,才会真的加锁~

偏向锁在没有其他人竞争的时候,就仅仅是一个简单的标记(非常轻量)。一旦有别的线程尝试进行加锁,就会立即把偏向锁,升级成真正加锁的状态,让别人只能阻塞等待。


2) 轻量级锁

synchronized 通过自旋锁的方式来实现轻量级锁。

当一个线程把锁占用时,其它线程就会按照自旋的方式,来反复查询当前的锁的状态是不是被释放了。

但是,后续如果竞争这把锁的线程越来越多了(锁冲突更激烈了),从轻量锁升级成重量级锁。


3) 其他优化操作

1.锁消除

编译器,会智能的判定当前代码是否有必要加锁

如果你写了加锁,但是实际上没有必要加锁,就会把加锁操作自动删除掉。

有些应用程序的代码中, 用到了 synchronized, 但其实没有在多线程环境下. (例如 StringBuffer)

StringBuffer sb = new StringBuffer();
sb.append("a");
sb.append("b");
sb.append("c");
sb.append("d");

此时每个 append 的调用都会涉及加锁和解锁.。但如果只是在单线程中执行这个代码,那么这些加锁解锁操作是没有必要的,白白浪费了一些资源开销。

2.锁粗化

关于"锁的粒度",如果加锁操作里包含的实际要执行的代码越多,就认为锁的粒度越大。

多线程进阶篇_第5张图片

有的时候,希望锁的粒度小比较好,并发程度更高

有的时候,也希望锁的粒度大比较好 (因为加锁解锁本身也有开销).


4、CAS

CAS: 全称Compare and swap,字面意思:”比较并交换“。

能够比较和交换某个寄存器中的值和内存中的值,看是否相等。如果相等,则把另外一个寄存器中的值和内存进行交换。

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

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

CAS 伪代码

boolean CAS(address, expectValue, swapValue) {  //判断 address 与 expectValue 是否相等,若相等则将 swapValue                                                   写入 address
 if (&address == expectedValue) {
   &address = swapValue;
        return true;
   }
    return false;
}

1) CAS的应用

1. 实现原子类

比如,多线程针对一个 count 变量进行 ++。在 java 标准库中,已经提供了一组原子类。

多线程进阶篇_第6张图片

基于CAS又能衍生出一套"无锁编程",进一步提高代码运行效率。

这里面提供了 自增/自减/自增任意值/自减任意值,这些操作,就可以基于 CAS 无锁编程的方式来实现。

上述的原子类,就是基于 CAS 来实现的。

//伪代码实现
class AtomicInteger {
    private int value;
    public int getAndIncrement() {
        int oldValue = value;
        while ( CAS(value, oldValue, oldValue+1) != true) {
            oldValue = value;
       }
        return oldValue;
   }
}

假设两个线程同时调用 getAndIncrement ,同时假设 value 是 0.

多线程进阶篇_第7张图片

在 CAS 中比较 value 和 oldValue 是否相等时,其实就是在检查当前 value 的值是不是变了。是不是被别的线程穿插进来做出修改了!!进一步就发现了当前的 ++ 操作不是一气呵成的原子操作了,一旦发现出现其他线程穿插的情况,立即重新读取内存的值准备下一次尝试~~

当两个线程并发的去执行++操作的时候,如果不加任何限制,就意味着,有时候,这俩++是串行的,能计算正确的。有的时候这俩++操作是穿插的,这个时候是会出现问题的。可以通过加锁保证线程安全:通过锁,强制避免出现穿插~~
原子类/CAS保证线程安全:借助CAS来识别当前是否出现“穿插"的情况,如果没穿插,此时直接修改,就是安全的。如果出现穿插了,就重新读取内存中的最新的值,再次尝试修改。

2. 实现自旋锁

基于 CAS 实现更灵活的锁, 获取到更多的控制权.

public class SpinLock {
    private Thread owner = null;     //用owner表示当前线程持有的锁,null为解锁状态。
    public void lock(){
        // 通过 CAS 看当前锁是否被某个线程持有. 
        // 如果这个锁已经被别的线程持有, 那么就自旋等待. 
        // 如果这个锁没有被别的线程持有, 那么就把 owner 设为当前尝试加锁的线程. 
        while(!CAS(this.owner, null, Thread.currentThread())){
        //获取当前线程引用.哪个线程调用lock,这里得到的结果就是哪个线程的引用!
       }
   }
   
    public void unlock (){
        //当该锁已经处于加锁状态,这里就会返回false, cas不会进行实际的交换操作.接下来循环条件成立,继续进入下一轮循环.
        this.owner = null;
   }
}

2) CAS 的 ABA 问题

上面讲到了,CAS 的关键要点,是比较 寄存器1 和 内存 中的值,通过这里的是否相等,来判定内存的值是否发生变化。

如果内存的值发生变化,则存在其他线程进行了修改。如果内存的值没有发生变化,则没有别的线程修改,接下来进行的修改就是安全的。

但是我们要想到一个问题,如果这里的值没变,就一定没有别的线程进行修改吗?

ABA 问题就描述了另一个线程,把变量的值从A->B,又从B->A。此时本线程区分不了,这个值是始终没变,还是出现变化又回来了的情况。

大部分情况下,就算是出现 ABA 问题,也没啥太大影响。但是在一些比较极端情况下,还是会出现问题。

多线程进阶篇_第8张图片

虽然上述操作,概率比较小,也需要去考虑。

ABA 问题,CAS 基本的思路是 ok 的,但是主要是修改操作能够进行反复横跳,就容易让咱们 CAS 的判定失效。


3) 解决方案

我们也有相应的解决办法,可以给上述案例中的账户余额安排一个隔壁邻居 ——— 版本号

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

  • CAS 操作在读取旧值的同时, 也要读取版本号.
  • 真正修改时
    • 如果当前版本号和读到的版本号相同,则修改数据,并把版本号 + 1。
    • 如果当前版本号高于读到的版本号。就操作失败(认为数据已经被修改过了)。

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

5、Callable 接口

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

Callable interface 也是创建线程的一种方式。

多线程进阶篇_第9张图片

如果进行多线程操作,如果你只是关心多线程执行的过程,使用 Runnable 即可。(只关心过程)

如果是关心多线程的计算结果,使用Callable更合适。(比如说通过多线程,计算一个公式,返回结果)

import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;

public class Demo1 {
    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);
        Thread t = new Thread(futureTask);
        t.start();

        Integer result = futureTask.get();
        System.out.println(result);
    }
}

使用 Callable 不能直接作为 Thread 的构造方法参数。而是需要用到 FutureTask 。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

上面 Callable 线程结果啥时候能算出来??这是最关心的一点。使用 futureTask 就可以帮助咱们解决这个问题。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

获取 call 方法的返回结果。get 类似于 join 一样,如果 call 方法没执行完,会阻塞等待。

6、JUC

Juc (java.util.concurrent) 的常见类也是并发编程。

1) ReentrantLock 可重入锁

这个锁,没有 synchronized 那么常用,但是也是一个可选的加锁的组件。

多线程进阶篇_第10张图片

ReentrantLock 具有一些特点,是 synchronized 不具备的功能。

  1. 提供了一个tryLock方法进行加锁。对于lock操作,如果加锁不成功,就会阻塞等待(死等)。对于tryLock,如果加锁失败,直接返回false/也可以设定等待时间。tryLock给加锁操作提供了更多的可操作空间~~
  2. ReentrantLock有两种模式。可以工作在公平锁状态下,也可以工作在非公平锁的状态下。构造方法中通过参数设定的公平/非公平模式
  3. ReentrantLock 也有等待通知机制。搭配Condition 这样的类来完成。这里的等待通知要比 wait notify功能更强。这几个是ReentrantLock的优势~~

synchronized 锁对象是任意对象。ReentrantLock 锁对象就是自己本身。如果你多个线程针对不同的 ReentrantLock 调用 lock 方法,此时是不会产生锁竞争的。


2) 原子类的应用场景

  1. 计数需求

    播放量、点赞量、投币量、转发量、收藏量等……

    同一个视频,有很多人同时播放、点赞、收藏……

  2. 统计效果

    统计出现错误的请求数量。使用原子类,记录出错的请求的数目

3) 信号量 Semaphore

semaphore 是并发编程中的一个重要组件。它可以用来控制同时访问某个资源的线程数量。Semaphore维护了一个许可证集合,线程在访问资源前必须先获取许可证,如果许可证已经全部被占用,则线程必须等待其他线程释放许可证后才能获取许可证并访问资源。

准确来说,Semaphore 是一个计数器(变量),描述了“可用资源的个数”。

描述当前线程,是否“有临界资源可以使用”。(多个线程修改同一个变量,这个变量就可以认为是临界资源)

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

import java.util.concurrent.Semaphore;

// 信号量
public class Demo2 {
    public static void main(String[] args) throws InterruptedException {
        // 构造方法中, 就可以用来指定计数器的初始值.
        Semaphore semaphore = new Semaphore(4);   //申请 4 个资源
        semaphore.acquire();            // 计数器 -1
        System.out.println("执行 P 操作 1");
        
        semaphore.acquire();            // 计数器 -1
        System.out.println("执行 P 操作 2");
        
        semaphore.acquire();            // 计数器 -1
        System.out.println("执行 P 操作 3");
        
        semaphore.acquire(); 			// 计数器 -1
        System.out.println("执行 P 操作 4");        //到此为止所有资源已占用完,如果再申请资源则阻塞等待。
        
        semaphore.release();			// 计数器 +1
        System.out.println("执行 V 操作 1");
        
        semaphore.acquire(); 			// 计数器 -1
        System.out.println("执行 P 操作 5");
    }
}

打印结果:

多线程进阶篇_第11张图片

4) CountDownLatch

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

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

当需要把一个任务拆成多个任务,如何衡量现在是把多个任务都搞定了呢?这时候就需要用到 CountDownLatch.

import java.util.concurrent.CountDownLatch;

public class Demo3 {
    public static void main(String[] args) throws InterruptedException {
        // 构造方法中, 指定创建几个任务.
        CountDownLatch countDownLatch = new CountDownLatch(10);

        for (int i = 0; i < 10; i++) {
            int id = i;
            Thread t = new Thread(() -> {
                System.out.println("线程" + id + "开始工作!");
                try {
                    // 使用 sleep 代指某些耗时操作, 比如下载.
                    Thread.sleep(2000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("线程" + id + "结束工作!");
                // 每个任务执行结束这里, 调用一下方法
                // 把 10 个线程想象成短跑比赛的 10 个运动员. countDown 就是运动员撞线了.
                countDownLatch.countDown();
            });
            t.start();
        }

        // 主线程如何知道上述所有的任务都完成了呢??
        // 难道要在主线程中调用 10 次 join 嘛?
        // 万一要是任务结束, 但是线程不需要结束, join 不就也不行了嘛?
        // 主线程中可以使用 countDownLatch 负责等待任务结束.
        // a => all 等待所有任务结束. 当调用 countDown 次数 < 初始设置的次数, await 就会阻塞.
        countDownLatch.await();
        System.out.println("多个线程的所有任务都执行完毕了!!");
    }
}

7、集合类

原来的集合类, 大部分都不是线程安全的。

Vector,Stack,HashTable,是线程安全的(不建议用),其他的集合类不是线程安全的。

1) 多线程环境使用 ArrayList

  1. 自己使用同步机制 (synchronized 或者 ReentrantLock)

  2. Collections.synchronizedList(new ArrayList);

  3. 使用 CopyOnWriteArrayList

2) 多线程环境使用队列

  1. ArrayBlockingQueue

    基于数组实现的阻塞队列

  2. LinkedBlockingQueue

    基于链表实现的阻塞队列

  3. PriorityBlockingQueue

    基于堆实现的带优先级的阻塞队列

  4. TransferQueue

    最多只包含一个元素的阻塞队列

3) 多线程环境使用哈希表

HashMap 本身不是线程安全的.

在多线程环境下使用哈希表可以使用:

  • Hashtable
  • ConcurrentHashMap

你可能感兴趣的:(JavaEE,java,多线程,锁,CAS)