详细讲解 —— 多线程进阶(Java EE初阶)(万字长文)

多线程

  • 1 常见的锁策略
  • 2 CAS
      • 2.1 什么是CAS
      • 2.2 CAS 有哪些应用
      • 2.3 CAS 的 ABA 问题
      • 2.4 解决方案
  • 3 Synchronized 原理
      • 3.1 基本特点
      • 3.2 synchronized 加锁工作过程
      • 3.3 其它优化操作
  • 4 JUC(java.util.concurrent) 的常见类
      • 4.1 Callable 接口
      • 4.2 ReentrantLock
      • 4.3 信号量(Semaphore)
      • 4.4 CountDownLatch
  • 5 线程安全的集合类
      • 5.1 多线程环境使用 ArrayList
      • 5.2 多线程环境使用队列
      • 5.3 多线程环境使用哈希表
  • 6 面试题

1 常见的锁策略

1)乐观锁 vs 悲观锁

悲观锁:预期锁冲突的概率很高,做的工作更多,付出的成本更多,更低效。
乐观锁:预期锁冲突的概率很低,做的工作更少,付出的成本更低,更高效。

举例:针对疫情
乐观态度:认为下一次疫情来了能够买到自己想要吃的食物,所以不必做特殊的准备。
悲观态度:认为下一次疫情来了不能买到自己想要吃的食物,这样就要做出准备,去超市购物,买一些自己喜欢的食物,然后把这些食物存放起来。(买食物增大开销,存食物占用空间)

2)读写锁 vs 普通的互斥锁

普通的互斥锁:只能有两个操作,加锁和解锁,只要来两个线程针对同一个对象加锁就会产生互斥。

读写锁:有三个操作,加写锁,加读锁,解如果代码加写锁,就只能进行写操作。如果代码加读锁,就只能进行读操作。
针对两个线程调用同一个读锁,不存在线程互斥关系的。
针对两个线程调用同一个写锁和一个线程调用读锁,一个线程调用写锁。存在线程互斥关系的。

3)重量级锁 vs 轻量级锁

重量级锁:做了更多的的事情,开销更大。
轻量级锁:做的事情更少,开销更小。

重量级锁和轻量级锁和上面的乐观锁和悲观锁有一定的重叠。也可以这么认为,通常情况下,悲观锁一般都是重量级锁,乐观锁一般都是轻量级锁,但是这个事情不绝对。

在使用锁中,如果锁是基于内核的一些功能来实现的(比如调用了操作系统提供的 mutex 接口),此时一般认为这是重量级锁。(操作系统的锁会在内核中做很多事情,比如让线程等待…)
如果锁是纯用户态实现的,此时一般认为这是轻量级锁。(用户态的代码更可控,也更高效)

4)挂起等待锁 vs 自旋锁(Spin Lock)

挂起等待锁:往往就是通过内核的一些机制来实现的,往往比较重。(重量级锁的一种典型实现)

自旋锁:往往就是通过用户态的代码来实现的,往往比较轻。

5)公平锁 vs 非公平锁

公平锁:多个线程在等待一把锁的时候,谁先来的,就是谁先获取到锁(遵循先来后到的原则)

非公平锁:多个线程在等待一把锁的时候,不遵循先来后到的原则(到来的线程不管谁是先来的谁是后到的,都是多个锁抢占式执行)。

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

可重入锁:一个线程,一把锁,连续加锁两次,不会出现死锁。

不可重入锁:一个线程,一把锁,连续加锁两次,会出现死锁。

2 CAS

2.1 什么是CAS

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

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

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

CAS 伪代码

下面写的代码不是原子的,真实的 CAS 是一个原子的硬件指令完成的。这个伪代码只是辅助理解CAS 的工作流程。

//address待比较的内存地址, expectValue预期内存中的值, swapValue希望把内存的值改成这个新的值。
boolean CAS(address, expectValue, swapValue) {
 if (&address == expectedValue) {
   &address = swapValue;
        return true;
   }
    return false;
}

上面的那一段交换逻辑,这就相当于硬件直接实现出来了,通过这一条指令,封装好,直接让我们用了。

CAS 最大的意义,就是让我们写的这种多线程安全的代码,提供了一个新的思路和方向。

2.2 CAS 有哪些应用

1) 实现原子类

标准库中提供了 java.util.concurrent.atomic 包,里面的类都是基于这种方式来实现的。
典型的就是 AtomicInteger 类。其中的 getAndIncrement 相当于 i++ 操作。这个操作是线程安全的。

代码演示:

import java.util.concurrent.atomic.AtomicInteger;

public class TestDome1 {

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

        AtomicInteger num = new AtomicInteger(0);

        Thread thread1 = new Thread(()->{
            for(int i=0; i<50000; i++){
                num.getAndIncrement();
            }
        });

        Thread thread2 = new Thread(()->{
            for(int i=0; i<50000; i++){
                num.getAndIncrement();
            }
        });

        thread1.start();
        thread2.start();

        thread1.join();
        thread2.join();

        System.out.println(num);
    }
}

详细讲解 —— 多线程进阶(Java EE初阶)(万字长文)_第1张图片

上面代码不存在线程安全的问题。
基于CAS实现的++操作,这里面就可以保证既能够线程安全,又能够比 synchronize 高效。synchronize 会涉及到锁的竞争,两个线程要互相等待。
而CAS不涉及到线程阻塞等待。

伪代码实现:

class AtomicInteger {
    private int value;
    public int getAndIncrement() {
    	//这里看起来是一个oldValue的变量,但是在实际上,这个可能是直接用一个寄存器来存储的。
    	//这个操作就相当于把数据从内存读到寄存器中(load)。
        int oldValue = value;
        //判定一下,当前内存的值是不是和刚才寄存器里取到的值一致,如果不一致返回false,否则返回true
        while ( CAS(value, oldValue, oldValue+1) != true) {
            oldValue = value;
       }
        return oldValue;
   }
}

在代码的第六行到第八行之间,按照我们之前的理解,这两个相邻的代码中读到的value的值是一样的。但是我们要考虑多线程的情况,有可能是其他的线程改变了value的值,我们再写多线程代码的时候,要时刻记得任意两个线程之间都可能执行一些其他的代码。

详细讲解 —— 多线程进阶(Java EE初阶)(万字长文)_第2张图片

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;
   }
}

当别的线程持有这个锁的时候,当前线程就会一直while循环,这个反复循环的过程就是 “忙等”。
我们认为这个自旋锁是轻量级锁,是一个乐观锁,我们有一个预期很快就能拿到这个锁(假设锁冲突不激烈),短暂的循环之后自己就能拿到锁。

2.3 CAS 的 ABA 问题

CAS中的关键,是先比较,再交换,其中的比较的是当前值和旧值是不是相同的,把这两个值相同,就视为是中间没有发生过改变的。

但是上面存在一些问题,就是 当前值 和 旧值 相同,可能中间值没有改变过,也可能改变了,但是又变回来了,比如当前值是A,变为B,然后再变回A,这样中间值就像没有变过一样。

这样的漏洞在通常的情况下,没有什么影响,但是在极端情况下也会引起 bug。

举例:使用ATM取钱的例子。
有一位老铁,账户余额是100元,他去ATM上取钱,想要取50元。
当他按下取款操作的时候,机器卡了一下,这位老铁下意识的多按了一下,在正常的情况下使用CAS 只会有其中的一次生效。
但是在异常的情况下,有一位朋友在这期间给这位老铁转账了50,就会出现bug。

详细讲解 —— 多线程进阶(Java EE初阶)(万字长文)_第3张图片

2.4 解决方案

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

1)CSA操作在读取旧值的同时,也要读取版本号。

2)真正修改的时候

  • 如果当前版本号和读到的版本号相同,则修改数据,并把版本号 + 1(这个版本号只能同时向一个方向变动)。
  • 如果当前版本号高于读到的版本号,就操作失败(认为数据已经被修改过了)。

还是上面在ATM上取钱的例子:
详细讲解 —— 多线程进阶(Java EE初阶)(万字长文)_第4张图片

3 Synchronized 原理

3.1 基本特点

结合上面的锁策略,我们就可以总结出,synchronized 具有以下特性(只考虑 JDK 1.8):

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

3.2 synchronized 加锁工作过程

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

  1. 无锁。没有加锁
  2. 偏向锁。如果只有一个线程加锁,就不会真正的加锁,这个时候就是做了一个标记,记录是哪一个对象的锁。
  3. 自旋锁。只有当两个或者两个线程加锁,这个时候就会真正的加锁,由偏向锁升级到自旋锁。
    这里的由偏向锁升级到自旋锁,和懒汉模式有点像,只有在必要的时候,才进行操作,如果不必要,则能省就省。
  4. 重量级锁。如果竞争进一步加剧,就会进入重量级锁状态。

3.3 其它优化操作

1)锁粗化

一段逻辑中如果出现多次加锁解锁,编译器 + JVM 会自动进行锁的粗化。

加锁的粗细和加锁代码涉及的范围有关系,如果加锁代码涉及到的范围越大,就认为锁的越粗,如果加锁代码的范围小就是锁比较细。

锁粗细好坏。
如果锁比较粗,并发性就比较低,加锁的开销小。
如果锁比较细,并发性就比较高,加锁的开销大。

2)锁消除

编译器+JVM 判断锁是否可消除。如果可以,就直接消除。

4 JUC(java.util.concurrent) 的常见类

4.1 Callable 接口

由于使用 Runnable 不太适合让多线程返回一个结果,如果要返回结果就比较麻烦,由此就引入了Callable这个接口。

Callable 这个接口可以从多线程中带回一个值。

代码示例: 创建线程计算 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 TestDome2 {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
    	//实现 Callable 接口中的 call 方法。其中的泛型为要返回的类型
        Callable<Integer> callable = new Callable<Integer>() {
            @Override
            public Integer call() throws Exception {
                int sum = 0;
                for(int i=1; i<=100; i++){
                    sum += i;
                }
                return sum;
            }
        };
        //把 callable 实例,用 FutureTask 封装一下。
        FutureTask<Integer> futureTask = new FutureTask<>(callable);
        //创建线程
        Thread thread = new Thread(futureTask);
        thread.start();
        
		//使用 futureTask.get() 等待 thread  线程结束,并且获取返回的结果,
        int result = futureTask.get();
        System.out.println(result);
    }
}

详细讲解 —— 多线程进阶(Java EE初阶)(万字长文)_第5张图片

4.2 ReentrantLock

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

ReentrantLock 的用法:

  1. lock(): 加锁,如果获取不到锁就死等。
  2. trylock(超时时间): 加锁,如果获取不到锁,等待一定的时间之后就放弃加锁。
  3. unlock(): 解锁。

ReentrantLock 和 synchronize 的区别:

  1. synchronize 是一个关键字(背后的逻辑是 JVM 内部实现的,C++),ReentrantLock 是一个标准库中的类(背后的逻辑是Java代码实现的)。
  2. synchronize 不用手动释放,出了指定的代码块就释放了。ReentrantLock 需要使用 unlock 去手动释放。
  3. 如果竞争失败,synchronize 就会一直阻塞等待,而 ReentrantLock 出了阻塞等待之外,还可以使用 trylock ,如果调用失败,阻塞等待一段时间,然后返回。
  4. synchronize 是一个非公平锁,但是 ReentrantLock 可以指定他是非公平锁还是公平锁,默认是非公平锁,如果传入参数为 布尔类型 true,这个时候就是一个公平锁。
  5. 基于 synchronize 衍生出来的等待机制,是 wait 和 notify ,功能是相对有限的。而基于 ReentrantLock 衍生出来的等待机制,是Condition类,功能要丰富一些。

我们在日常开发中,绝大部分情况下,synchronize 就已经够用了。

4.3 信号量(Semaphore)

这是一个更广义的锁。我们上面讲的锁就是一个 “二元信号量” 。

举例:大学生去图书馆
图书馆中有很多的房间,每一个房间有一定的座位,假设有一个count来记录这个房间的座位数,如果有人进入了这个房间座位就 - 1,如果有人出了这个房间座位就 + 1,这个count就表示信号量,当count 为零的时候就会进入阻塞等待。

代码演示

acquire()表示申请资源, release()表示释放资源。

import java.util.concurrent.Semaphore;

public class TestDome3 {
    public static void main(String[] args) {
        Semaphore semaphore = new Semaphore(4);   //申请4个信号量(资源)

        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                try {
                	semaphore.acquire();  
                    System.out.println("申请资源");
                    semaphore.acquire();
                    System.out.println("申请资源");
                    semaphore.acquire();
                    System.out.println("申请资源");
                    semaphore.acquire();
                    System.out.println("申请资源");
                    semaphore.acquire();   //阻塞等待线程 
                    System.out.println("申请资源");     
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        };
        Thread thread = new Thread(runnable);
        thread.start();
    }
}

4.4 CountDownLatch

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

举例:跑步比赛
有 n 个选手,同时跑步,这个 n 个选手中有跑的快的,有跑的慢的。只有当最后一个人冲到终点,这个跑步比赛才算结束。

import java.util.concurrent.CountDownLatch;

public class TestDome4 {
    public static void main(String[] args) {
        CountDownLatch latch = new CountDownLatch(10);

        for(int i=0; i<10; i++){
            Thread thread = new Thread(()->{
                try {
                    Thread.sleep((long) (Math.random() * 10000));
                    System.out.println(Thread.currentThread().getName() + " 执行完毕");
                    latch.countDown();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            });
            thread.start();
        }
    }
}

详细讲解 —— 多线程进阶(Java EE初阶)(万字长文)_第6张图片

5 线程安全的集合类

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

5.1 多线程环境使用 ArrayList

  1. 自己使用同步机制 (synchronized 或者 ReentrantLock)。
  2. Collections.synchronizedList(new ArrayList)
    synchronizedList 是标准库提供的一个基于 synchronized 进行线程同步的 List。
    synchronizedList 的关键操作上都带有 synchronized
  3. 使用 CopyOnWriteArrayList
    CopyOnWrite容器即写时复制的容器。
    当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy,复制出一个新的容器,然后新的容器里添加元素,添加完元素之后,再将原容器的引用指向新的容器。
    这样做的好处是我们可以对CopyOnWrite容器进行并发的读,而不需要加锁,因为当前容器不会
    添加任何元素。所以CopyOnWrite容器也是一种读写分离的思想,读和写不同的容器。
    优点:在读多写少的场景下, 性能很高, 不需要加锁竞争。
    缺点:占用内存较多,新写的数据不能被第一时间读取到。

5.2 多线程环境使用队列

  1. ArrayBlockingQueue
    基于数组实现的阻塞队列
  2. LinkedBlockingQueue
    基于链表实现的阻塞队列
  3. PriorityBlockingQueue
    基于堆实现的带优先级的阻塞队列
  4. TransferQueue
    最多只包含一个元素的阻塞队列

5.3 多线程环境使用哈希表

HashMap 本身线程不安全。

解决:使用 HashTable (不推荐),使用 ConcurrentHashMap (推荐)。

1)HashTable

HashTable 是通过给关键方法加锁,来确保线程安全的。
这个是针对 this 来加锁的,当有多个线程来访问这个 HashTable 的时候,无论是什么样的操作,无论是什么样的数据,都会出现锁竞争,这样的设计就会导致锁的竞争是非常大的,这个其中的效率就会比较低

2)ConcurrentHashMap

ConcurrentHashMap 是给哈希表中的每一个链表来进行加锁,来确保线程安全的,哈希表是一个数组,数组中的每一个元素是一个链表。
针对哈希表中两个不同链表的元素操作时,没有线程安全问题。
而且哈希表中的链表是很多的,但是链表的长度是很短的,这样锁竞争的概率就变小了。

3)ConcurrentHashMap 的优点

  1. ConcurrentHashMap 让锁加到每一个链表头结点上,减少了锁冲突。
  2. ConcurrentHashMap 只针对写进行加锁操作,读操作没有加锁。
  3. ConcurrentHashMap 中更广泛的使用了 CAS 来提高效率,比如维护 size 操作。
  4. ConcurrentHashMap 中哈希表进行扩容时,化整为零。
    当哈希表要进行扩容时,我们要把旧哈希表中的数据搬到新哈希表中,在 HashTable 中是一次性搬完的,这样就会导致放数据时,变得卡顿。而 ConcurrentHashMap 中每次操作只搬运一点点,分多次搬运,插入数据放到新的哈希表中,查找数据新哈希表和旧哈希表都要查找。直到搬运完成才把旧哈希表销毁。

6 面试题

1) 谈谈 volatile关键字的用法?

volatile 能够保证内存可见性,强制从主内存中读取数据,此时如果有其他线程修改被 volatile 修饰
的变量,可以第一时间读取到最新的值。

2) Java多线程是如何实现数据共享的?

JVM 把内存分成了这几个区域:
方法区,堆区,栈区,程序计数器。
其中堆区这个内存区域是多个线程之间共享的。
只要把某个数据放到堆内存中, 就可以让多个线程都能访问到

3) Java创建线程池的接口是什么?参数 LinkedBlockingQueue 的作用是什么?

创建线程池主要有两种方式:
通过 Executors 工厂类创建,创建方式比较简单,但是定制能力有限。
通过 ThreadPoolExecutor 创建,创建方式比较复杂, 但是定制能力强。

LinkedBlockingQueue 表示线程池的任务队列,用户通过 submit / execute 向这个任务队列中添加任务,再由线程池中的工作线程来执行任务。

4) Java线程共有几种状态?状态之间怎么切换的?

NEW: 安排了工作,还未开始行动,新创建的线程,还没有调用 start 方法时处在这个状态。
RUNNABLE:可工作的,又可以分成正在工作中和即将开始工作,调用 start 方法之后,并正在
CPU 上运行/在即将准备运行 的状态。
BLOCKED:使用 synchronized 的时候,如果锁被其他线程占用,就会阻塞等待,从而进入该状态。
WAITING:调用 wait 方法会进入该状态。
TIMED_WAITING:调用 sleep 方法或者 wait(超时时间) 会进入该状态。
TERMINATED:工作完成了. 当线程 run 方法执行完毕后, 会处于这个状态。

5) 在多线程下,如果对一个数进行叠加,该怎么做?

使用 synchronized / ReentrantLock 加锁
使用 AtomInteger 原子操作。

6) Servlet是否是线程安全的?

Servlet 本身是工作在多线程环境下。
如果在 Servlet 中创建了某个成员变量, 此时如果有多个请求到达服务器, 服务器就会多线程进行作,是可能出现线程不安全的情况的。

7) Thread和Runnable的区别和联系?

Thread 类描述了一个线程。
Runnable 描述了一个任务。
在创建线程的时候需要指定线程完成的任务, 可以直接重写 Thread 的 run 方法, 也可以使用Runnable 来描述这个任务。

8) 多次start一个线程会怎么样

第一次调用 start 可以成功调用。
后续再调用 start 会抛出 java.lang.IllegalThreadStateException 异常。

9) 有synchronized两个方法,两个线程分别同时用这个方法,请问会发生什么?

synchronized 加在非静态方法上,相当于针对当前对象加锁。
如果这两个方法属于同一个实例:线程1 能够获取到锁,并执行方法,线程2 会阻塞等待,直到线程1 执行完毕,释放锁,线程2 获取锁之后才能执行方法内容。
如果这两个方法属于不同实例:两者能并发执行,互不干扰。

10) 进程和线程的区别?

进程是包含线程的,每个进程至少有一个线程存,即主线程。
进程和进程之间不共享内存空间,同一个进程的线程之间共享同一个内存空间。
进程是系统分配资源的最小单位,线程是系统调度的最小单位。

你可能感兴趣的:(Java,EE初阶,java-ee,java,面试,后端,开发语言)