Java 多线程部分八股整理

1. 进程和线程的区别

  1. 进程是资源分配的基本单位,线程是调度的基本单位

  2. 可以认为线程就是一个执行流,一个进程中至少有一个线程

  3. 线程在进程的地址空间中工作,线程之间共享进程的内存,但是进程和进程之间内存是独立的

  4. 一般一个进程被杀死,不会影响其他进程;但是一个线程挂了,可能会把所有线程连带进程一起带走~~

2. Java 中创建线程的方法

Thread 类现在不需要手动导入,其包含在 java.lang 中,这个包默认导入

  1. 继承 Thread 类,重写里面的 run 方法

    1. run 方法是线程的执行入口
    2. start 方法是真正去创建线程
  2. 实现 Runnable 接口,重写其 run 接口之后,把类传给 Thread

  3. 实现 Callable 接口,重写其 call 方法之后,通过 FutureTaskget 可以获得线程的执行结果,相比第二种方法解耦性更强(get 获取的结果其实就是 call 方法中返回的内容)

  4. lambda 表达式,最方便

  5. 使用线程池,直接使用 Executor 创建或者使用选项更多的 ThreadPoolExecutor 创建

3. 导致线程不安全的可能原因

  1. CPU 对线程采取的是随即调度,这是导致线程不安全的核心原因
  2. 内存可见性问题。CPU 对内存中的一个变量进行操作,比如 i++,核心分为从内存加载、在 CPU 寄存器中完成操作、写回内存三个阶段,如果在任意两个阶段之间发生了线程的调度,就会导致线程安全问题(两次操作变成了一次操作)
  3. 指令重排序。JVM 和 CPU 指令集会在逻辑不变的情况下,把代码的执行顺序进行调整。但是在多线程、优化策略激进的情况下可能对“逻辑不变”的判断出现错误。

4. synchronized 的代码块是可重入的,怎样实现一把可重入的锁?

主要从两点来实现

  1. 记录当前是那个线程加的锁
  2. 记录当前加锁的次数,并在合适的时候进行释放。思路有些类似于 C++ 的 std::shared_ptr,内部维护了引用计数,当引用计数为零的时候释放资源

5. sleep 和 wait 的区别

  1. sleep 用来让当前线程睡眠一段时间,让出 CPU 资源;wait 是让获取到锁但尚未满足执行条件的线程让出锁资源,防止“占着茅坑不lashi”
  2. wait 需要搭配 synchronized 使用,但是 sleep 不需要
  3. wait 是 Object 的共有方法,而 sleep 是 Thread 的静态方法

6. 单例模式

所谓 “单例模式”,简单来说,就是让一个类、变量等在整个进程中只被创建一份。一般来说,可以通过两种模式实现

6.1 饿汉模式

饿汉模式是在类加载的时候就创建出来。这是在整个 Java 程序运行之前进行的步骤,所以不存在线程安全问题。后续通过引用变量的方式返回创建的实例。这种方式相比懒汉更加简单,但是对于体积大、不一定用的上的的类,可能就不如懒汉模式

6.2 懒汉模式

懒汉模式是在用的时候才创建,不用就不创建。其实我们日常使用的软件、操作系统都是采用的这种方式:播放视频是边放边加载而不是等到加载了整个视频再播放、浏览 PPT 的时候一次只渲染几页…

饿汉模式只涉及读,但是懒汉模式设计创建这种写操作,所以不是线程安全的,需要在判断是否创建并创建前加锁。

但是这种方式,各种试图创建的线程需要先获取锁判断是否创建过实例,即使已经创建过也要获得锁资源后才能返回。这就是完全不必要的开销。

所以懒汉模式最好采取:if - synchronized - if的结构,减少额外的锁竞争

除此之外,为了防止前面提到的内存可见性以及指令重排序造成的线程安全问题,实例的引用变量最好使用 volatile 修饰

7. 生产者消费者模型

7.1 三二一原则

三个关系

  1. 生产者之间是竞争、互斥关系,因为消费场所就能放那么多“商品”,同一时刻也只能有一个生产者在缓冲区生产
  2. 消费者之间是竞争关系,因为消费场所就能提供那么多“商品“,同一时刻也只能有一个消费者在缓冲区消费
  3. 生产者消费者之间是同步和互斥关系,同步是告知另一方已经可以生产或者消费,互斥是因为临界资源在同一时刻只被一个执行流访问才是安全的

两个角色

  1. 生产者,往交易场所中生产数据
  2. 消费者,往交易场所中消费数据

一个交易场所

通常是一块缓冲区,用来存放数据。想要实现多线程之间的通信,就要首先让多线程看到同一份资源,这里的交易场所就是同一分资源的载体

7.2 生产者消费者模型的优点

  1. 支持忙闲不均,削峰填谷。机器的硬件资源是有限的,那种资源满了都容易挂掉。把突然海量产生的数据都丢到缓冲区,允许下游处理机器按照自己的能力处理,不至于被冲挂掉
  2. 支持生产与消费过程解耦

8. 线程池

8.1 ThreadPoolExecutor

一共提供了七个参数,如下所示

new ThreadPoolExecutor(                                                         
      corePoolSize,                               
      maximumPoolSize,                                         
      keepAliveTime,                              
      unit,                                                   
      workQueue,                                            
      threadFactory,                                               
      handler                                                    
  ) 
  • corePoolSize,是在线程池创建时就创建的线程,相当于一个公司的创始老员工,后续都不会被裁掉(大概…至少线程池不会裁掉这些创始就创建的线程)。这里采取的是懒启动,第一次调用 submit 才会创建
  • maximumPoolSize,是线程池中允许的最大线程的数量,就像一个公司不能没完没了招人
  • keepAliveTime,线程允许的最长的空闲时间,就像没有公司养闲人,多少天不干活就把人要辞退
  • unit,时间单位,是枚举类型
  • workQueue,是一个元素为 Runnable 的阻塞队列,用来存放供给线程处理的任务
  • threadFactory,创建线程的工厂函数,可以统一为创建的线程制定线程名称等内容
  • handler,拒绝策略,当线程池不足以处理 submit 的任务时,决定如何报错
    • AbortPolicy,直接摆烂不干了(默认)
    • CallerRunsPolicy,线程池没有更多线程了,那就给我任务的线程去干活吧
    • DiscardPolicy,抛弃最新的任务(丢弃当前提交的任务)
    • DiscardOldestPolicy,抛弃最老的任务

8.3 Executor

上面的好麻烦!!别怕,就像 C++20 出的协程麻烦到天际,到了 C++23 出了简单的创建方法,Java 也为我们提供了简单的创建方式——Executors

Executors 其实就是对 ThreadPoolExecutor 的封装,常用的有两个静态方法

  • newFixedThreadPool,指定线程池线程数目,后续不可改变
  • newCachedThreadPool,没有最大理论上限(或者说最大上限是 Integer.MAX_VALUE),根据 submit 创建即可

9. 锁策略

9.1 乐观锁和悲观锁

我们常使用的互斥锁就是一种悲观锁。悲观锁预先设想锁资源的竞争非常激烈,因此先获取锁资源再进行数据操作,一方面,悲观锁的这种操作一定是线程安全的,另一方面,竞争锁的性能消耗也更大。

乐观锁的假设与悲观锁相反。乐观锁假设锁资源的竞争不激烈,大概率直接申请就可以获得锁资源,所以不直接加锁,而是完成数据更新后再判断数据是否并法冲突,如果冲突了,就返回错误信息,进一步确定如何处置。

synchronized 初始情况下使用乐观锁策略,当竞争逐渐激烈,开始转到悲观锁策略

9.2 重量级锁和轻量级锁

重量级锁指使用操作系统提供的锁接口(例如 Linux 的 pthread_mutex_init 系列),没有竞争到锁资源的线程就进入阻塞休眠。阻塞休眠的过程,其实就涉及到 CPU 上下文切换、内核态回到用户态的过程中的信号处理(这样一来有可能涉及到更多的内核态用户态切换)等等开销,从某种意义上讲,这对线程就是“沧海桑田”般的等待,但是线程把 CPU 资源让了出来,给其他线程使用

轻量级锁就是尽可能不使用操作系统的锁接口,把等待在用户态尽可能完成,不要让线程阻塞休眠。这种策略就是预设大概率马上就能获得锁资源。

自旋锁就是一种常用的轻量级锁。自旋锁的“自旋”其实就是一个“循环”,离开循环的条件就是获得锁资源。一方面,避免了线程的休眠阻塞带来的额外的开销;另一方面,线程也占据着 CPU 资源,这意味着如果判断失误不切换锁策略就会导致性能损失

synchronized 开始是一个轻量级锁,当冲突激烈时,会转到重量级锁

9.3 公平锁和非公平锁

其实公平锁和非公平锁的“公平”,就是指是否遵守先来后到的原则。

比如若干个线程都在阻塞等待,现在锁资源空闲了,谁来获得这份资源呢?公平锁就是通过额外的数据结构,保存线程开始等待的先后顺序,最先开始等待的线程最先获得锁资源。非公平锁就是若干个线程随即选一个获得锁资源。

synchronized 是非公平锁

9.4 可重入锁和不可重入锁

所谓“重入“,就是是否可以二次加锁。Java 中带 synchronized 关键字的锁就是可重入的,但是像 Go 的 sync.Mutex 就是不可重入的。Java 中 Reentrant 开头的锁都是可重入的

9.5 读写锁

读写锁就是为了解决“读多写少”的情况下,为了维护线程安全锁产生的不必要的开销。核心原因就是:读并不会产生线程安全问题,这时加锁就是不必要的开销;但是写于写之间、读与写之间不是线程安全的。

所以我们核心的思想是:读与读之间有读锁,但是读的时候锁不互斥,读写之间才互斥;写于写之间也互斥。

但是语言之间的实现方法不同:比如 C++ 是一个锁 std::shared_mutex,两种加锁方式 std::shared_lock 和 std::unique_lock;Java 是 ReentrantReadWriteLock 中提供了 ReadLock 和 WriteLock 两把锁,分别提供了 lock 和 unlock 方法;Go 类似 Java,提供了 sync.RWMutex,提供了读的 RLock 和 RUnlock,以及写的 Lock 和 UnLock

10. CAS 指令与 ABA 问题

CAS 机制是 CPU 提供的一条指令,所以他是线程安全的。进行的行为就是先比较寄存器的值和内存中的值是否相等,如果相等,就把内存中的值和另一个寄存器中的值交换(其实我们并不关心另一个寄存器中的值,所以也可以认为是一个赋值操作)

ABA 问题就是虽然我们的 CAS 指令虽然是 CPU 提供的一条指令,但是从内存中取值的过程是我们自己的代码进行的,这部分不是线程安全的,考虑下面的例子:

A 去取50块钱,余额100。ATM机用两个线程完成扣款操作。第一个线程取到内存中的值,并确定了旧值和新值,第二个线程也进行到这里,但是阻塞休眠。现在线程一进行 CAS 操作,余额变为50。现在B又给A转了50块钱,余额变为100,这是线程二开始执行,发现余额还是100,便执行扣款操作,余额变为50

但实际余额应该是100!!!扣款执行了两次。

要解决这种问题,其实就是解决这种值恰好重复的问题。我们可以引入一个只增的版本号,只通过 CAS 指令对它进行加操作,这样后续操作就可以根据版本号是否匹配来判断是否有因为 ABA 问题而忽略的变化过程

11. synchronized 的工作流程

总的流程是:无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁

偏向锁

并不是真正的“锁”,而是先把锁对象头部的 Mark Word 更新为自己的线程 ID,表示当前锁对象被哪个线程持有。这样第一个线程就不需要进行真正的加锁操作就可以获得锁资源,但是当其他线程也试图做这个标记时,就会发现标记已经存在了,也就无法通过“偏向锁”的方式完成加锁,接下来就会转向使用轻量级锁。最先做这个标记的线程,会最先获得锁资源

轻量级锁

轻量级锁会试图不使用操作系统提供的互斥锁,而是通过 CAS 操作完成用户级的锁操作(线程会在自己的栈帧中创建锁记录,并试图通过 CAS 操作将对象头的 Mark Word 指向自己的锁记录),当无法完成 CAS 操作时,使用自旋锁空转等待。但是自旋锁也会消耗 CPU 资源,所以不能快速完成 CAS 操作的情况下,就会转向使用重量级锁

重量级锁

就是操作系统提供给我们的互斥锁,虽然最安全可靠,但是开销也最大,因此 synchronized 只有在锁资源竞争最激烈时才会使用重量级锁

12. 为什么有 synchronized 还要使用 juc 中的 lock

(以 java.util.ReentrantLock 为例)

  1. synchronized 中通过代码块来自动加锁解锁,ReentrantLock 通过 lock 和 unLock 手动进行,更加灵活

  2. synchronized 是非公平锁,ReentrantLock 默认也是非公平锁,但是可以在创建时传入 true 表示使用公平锁

  3. synchronized 加锁失败会阻塞等待,但是Reentrantlock提供了 trylock 方法,能够竞争失败直接返回

  4. synchronized 的条件变量使用 Object 的 wait/notify 方法,ReentrantLock 使用 Condition 的 aquire/signal 方法,可以唤醒某个指定的线程,功能要更多一些

  5. synchronized 等待锁资源是死等,但是 ReentrantLock 提供了 lockInterruptibly(),允许响应中断

13. 多线程环境下使用 ArrayList

总的来说分为三种情况:

  1. 自己使用 synchronized 或者其他方式加锁
  2. 使用 Collections.synchronizedList(new ArrayList) 的方式创建一个 ArrayList,但是线程不安全的操作使用了 synchronized 保护
  3. 使用 CopyOnWriteArrayList,思路就是读的时候直接读原数组,写的时候,就在复制的数组中操作,当写完的时候,就直接把原数组的引用改成对新数组的引用,这样就在不加锁的情况下完成了线程安全的 ArrayList 的使用。但是这种方式对于空间要求较大

14. 多线程环境下使用哈希表

我们有三种选择:

  1. HashMap,本身不是线程安全的,只能自己加锁保护,key 允许为 null
  2. HashTable,线程不安全方法使用 synchronized 保护,其他就是 HashMap 的方法,key 不允许为 null
  3. ConcurrentHashMap,多线程环境下的最好选择。采取给每个桶加锁的方式,实现了既保证线程安全,又尽可能降低锁冲突的方式。另外,哈希表的扩容也是一个很大的开销。ConcurrentHashMap 扩容时,每次有线程使用 get/put 时都由这个线程完成一部分往新的哈希表中数据的搬运,查找时两边都查,插入时只插入新的哈希表,这样就把扩容时的时间开销平铺开来。除此之外,利用 CAS 特性,比如 size 就使用 CAS 更新。key 不允许为 null

自 jdk1.8 开始,不再使用分段锁(就是一段哈希表使用一个锁,按段来算),而是采用一个桶一个锁(即按桶来分配锁);除此之外,数据结构也发生了改变,像 C++ 一样在容量达到阈值后使用红黑树作为哈希桶的下层数据结构

15. 死锁

15.1 什么是死锁

若干个线程都因为竞争不到锁资源而阻塞,但是每个被阻塞的线程都因为无法获得另一个已经被阻塞的线程持有的锁资源,形成无法打破的循环,就形成了死锁。

15.2 死锁形成的条件

  1. 锁是互斥的,一个锁在同一时刻内只能被一个线程持有。
  2. 锁是不可抢占的,一个线程持有锁,除非它主动施放锁,别的线程就无法获得锁资源
  3. 请求和保持,线程请求锁资源后,除非获得锁资源,就一直等待
  4. 循环等待,A线程持有 B 请求的资源,B 持有 C 请求的资源,C 请求 A 请求的资源,形成一个无法打破的循环。就像 C++ 的 std::shared_ptr,两个智能指针释放资源的条件都是对方释放对方自己的资源,不过这里不是死锁而是内存泄漏

​ 这四个条件只要不满足一个就可以防止死锁,其中前两个条件是操作系统提供的锁的特点,后两个可以通过我们代码避免

15.3 怎样避免死锁

最常用的操作就是锁排序。

来看一个哲学家就餐问题。现在有五个哲学家,五根筷子,要求只能用两个筷子吃饭。现在看这种情况:一个圆桌子,哲学家围坐,每两位哲学家之间有一个筷子。现在每个哲学家同时获得自己左手边的筷子,接下来都试图获得自己右手边的筷子,但是谁都无法获得,这样就形成了死锁。

通过锁排序怎样解决呢?我们把五个筷子从1-5编号,要求每个哲学家只允许从小到大开始获得筷子,这样就不会发生死锁了。

在代码中,使用锁排序解决这个问题,就只需要把锁当成上面的筷子即可:都按照同一个顺序去获取锁

更多推荐