2.并发线程与多线程

2.1 JUC和锁

2.1.1 什么是AQS

AQS 是多线程同步器,它是 J.U.C 包中多个组件的底层实现,如 Lock、CountDownLatch、Semaphore等都用到了 AQS. 从本质上来说,AQS 提供了两种锁机制,分别是排他锁,和 共享锁。
排它锁,就是存在多线程竞争同一共享资源时,同一时刻只允许一个线程访问该共享资源,也就是多个线程中只能有一个线程获得锁资源,比如 Lock 中的 ReentrantLock 重入锁实现就是用到了 AQS 中的排它锁功能。
共享锁也称为读锁,就是在同一时刻允许多个线程同时获得锁资源,比如 CountDownLatch 和Semaphore 都是用到了 AQS 中的共享锁功能。

2.1.2 如何理解AQS的实现原理

AQS 它是 J.U.C 这个包里面非常核心的一个抽象类,它为多线程访问共享资源提供了一个队列同步器。
在 J.U.C 这个包里面,很多组件都依赖 AQS 实现线程的同步和唤醒,比如 Lock、Semaphore、CountDownLatch 等等。
(如图),AQS 内部由两个核心部分组成:
 一个 volatile 修饰的 state 变量 ,作为竟态条件
 用双向链表结构维护的 FIFO 线程等待队列
它的具体工作原理是,多个线程通过对这个 state 共享变量进行修改来实现静态条件,竞争失败的线程加入 FIFO 队列并且阻塞,抢占到竞态资源的线程释放之后,后续的线程按照 FIFO 顺序实现有序唤
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-jU5cLpaM-1721614050844)(https://i-blog.csdnimg.cn/direct/4a63c3b3dad54b9cb6e29371eba21637.png)]
AQS 里面提供了两种资源共享方式,
一种是独占资源,同一个时刻只能有一个线程获得竞态资源。比如ReentrantLock 就是使用这种方式实现排他锁
另一种是共享资源,同一个时刻,多个线程可以同时获得静态资源。CountDownLatch或者Semaphore就是使用共享资源的方式,实现同时唤醒多个线程。

2.1.3 AQS为什么要使用双向链表

第一个方面,双向链表的优势:
 双向链表提供了双向指针,可以在任何一个节点方便向前或向后进行遍历,这种对于有反向遍历需求的场景来说非常有用。
 双向链表可以在任意节点位置实现数据的插入和删除,并且这些操作的时间复杂度都是 O(1),不受链表长度的影响。这对于需要频繁对链表进行增删操作的场景非常有用。
第二个方面,说一下 AQS 采用双向链表的原因
 存储在双向链表中的线程,有可能这个线程出现异常不再需要竞争锁,所以需要把这些异常节点从链表中删除,而删除操作需要找到这个节点的前驱节点,如果不采用双向链表,就必须从头节点开始遍历,时间复杂度就变成了 O(n)。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-VPlmXQAy-1721614050846)(https://i-blog.csdnimg.cn/direct/bc63119247f94c6ead76cdb9fb912078.png)]
新加入链表中的线程,在进入到阻塞状态之前,需要判断前驱节点的状态,只有前驱节点是 Sign状态的时候才会让当前线程阻塞,所以这里也会涉及前驱节点的查找,采用双向链表能够更好地提升查找效率
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-EMRYnpRW-1721614050847)(https://i-blog.csdnimg.cn/direct/40664544e01240cc927e591f045022d9.png)]
线程在加入链表中后,会通过自旋的方式去尝试竞争锁来提升性能,在自旋竞争锁的时候为了保证锁竞争的公平性,需要先判断当前线程所在节点的前驱节点是否是头节点。这个判断也需要获取当前节点的前驱节点,同样采用双向链表能提高查找效率。
采用单向链表不支持双向遍历,而 AQS 中存在很多需要双向遍历的场景来提升线程阻塞和唤醒的效率。

2.1.4 什么是CAS

CAS 是 Java 中 Unsafe 类里面的方法,它的全称是 CompareAndSwap,比较并交换的意思。它的主要功能是能够保证在多线程环境下,对于共享变量的修改的原子性。
我来举个例子,比如说有这样一个场景(如图),有一个成员变量 state,默认值是 0,定义了一个方法 doSomething(),这个方法的逻辑是,判断 state 是否为 0 ,如果为 0,就修改成 1。
这个逻辑看起来没有任何问题,但是在多线程环境下,会存在原子性的问题,因为这里是一个典型的,Read - Write 的操作。
一般情况下,我们会在 doSomething()这个方法上加同步锁来解决原子性问题。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ZxA862YS-1721614050848)(https://i-blog.csdnimg.cn/direct/3c78de2d17d54219850f54baa9bf5999.png)]
但是,加同步锁,会带来性能上的损耗,所以,对于这类场景,我们就可以使用 CAS 机制来进行优化,这个是优化之后的代码(如图)
在 doSomething()方法中,我们调用了 unsafe 类中的compareAndSwapInt()方法来达到同样的目的,这个方法有四个参数,
分别是:当前对象实例、成员变量 state 在内存地址中的偏移量、预期值 0、期望更改之后的值 1。
CAS 机制会比较 state 内存地址偏移量对应的值和传入的预期值 0 是否相等,如果相等,就直接修改内存地址中 state 的值为 1. 否则,返回 false,表示修改失败,而这个过程是原子的,不会存在线程安全问题。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-K7BBlMEy-1721614050849)(https://i-blog.csdnimg.cn/direct/fc49d7480dcc4e26885f456543dd8f08.png)]
CompareAndSwap 是一个 native 方法,实际上它最终还是会面临同样的问题,就是先从内存地址中读取 state 的值,然后去比较,最后再修改。
这个过程不管是在什么层面上实现,都会存在原子性问题。
所以呢,CompareAndSwap 的底层实现中,在多核 CPU 环境下,会增加一个 Lock 指令对缓存或者总线加锁,从而保证比较并替换这两个指令的原子性。
CAS 主要用在并发场景中,比较典型的使用场景有两个。

  1. 第一个是 J.U.C 里面 Atomic 的原子实现,比如 AtomicInteger,AtomicLong。
  2. 第二个是实现多线程对共享资源竞争的互斥性质,比如在 AQS、ConcurrentHashMap、ConcurrentLinkedQueue 等都有用到。

2.1.5 什么是乐观锁,什么是悲观锁

悲观锁: 总是假设最坏的情况 , 每次去拿数据的时候都认为别人会修改 , 所以每次在拿 数据的时候都会上锁 , 这样别人想拿这个数据就会阻塞直到它拿到锁。传统的关系型数据库里边就用到了很多这种锁机制 , 比如行锁 , 表锁等 , 读锁 , 写锁等 , 都是在做操作之前先上锁。再比如 Java里面的同步原语 synchronized 关键字的实现也是悲观锁。
乐观锁 :顾名思义 , 就是很乐观 , 每次去拿数据的时候都认为别人不会修改 ,所以不会上锁 ,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据 ,可以使用版本号等机制。乐观锁适用于多读的应用类型 ,这样可以提高吞吐量 ,像数据库提供的类似于 write_condition 机制 ,其实都是提供的乐观锁。在 Java 中 java.util.concurrent.atomic 包下面的原子变量类就是使用了乐观锁的一种实现方式 CAS 实现的。
乐观锁的实现方式
1、使用版本标识来确定读到的数据与提交时的数据是否一致。提交后修改版本标 识 ,不一致时可以采取丢弃和再次尝试的策略。
2、java 中 CAS ,当多个线程尝试使用 CAS 同时更新同 一 个变量时 ,只有其中一个线程能更新变量的值 ,而其他线程 都失败 ,失败的线程 并不会被挂起 ,而是被告知这次竞争中失败 ,并可 以再次尝试 。 CAS 操作中包 含三个操 作数 — — 需要读写的内存位 置( V )、 进行比较 的预 期原值( A )和拟 写入的新值(B) 。如果 内存 位置 V 的值 与预 期原值 A 相匹配,那么处理器会 自动将该位置值更新为新值 B 。 否则处 理器 不做 任何 操作 。
CAS 缺点
1、ABA 问题:
比如说一个线程 one 从内存位置 V 中取出 A ,这时候另一个线程 two 也从内存中 取出 A ,并且 two 进行了一 些操作变成了 B ,然后 two 又将 V 位置的数据变成 A , 这时候线程 one进行 CAS 操作发现内存中仍然是 A ,然后 one 操作成功 。尽管线 程 one 的 CAS 操作成功,但可能存在潜藏的问题。从 Java1. 5 开始 JDK 的 atomic 包里提供了 一 个类AtomicStamped Reference 来解决 ABA 问题。
2、循环时间长开销大:
对于资源竞争严重( 线程冲突严重) 的情况 , CAS 自旋的概率会比较大 , 从而浪费更 多的 CPU资源 ,效率低于 synchronized。
3、只能保证一个共享变量的原子操作:
当对一个共享变量执行操作时 ,我们可以使用循环 CAS 的方式来保证原子操作 ,但是对多个共享变量操作时 ,循环 CAS 就无法保证操作的原子性 , 这个时候就可以用锁。

2.1.6 什么条件下会产生死锁,如何避免死锁

(如图),死锁,简单来说就是两个或者两个以上的线程在执行的过程中,争夺同一个共享资源造成的相互等待的现象。
如果没有外部干预,线程会一直阻塞无法往下执行,这些一直处于相互等待资源的线程就称为死锁线程。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-y3ZwFY11-1721614050849)(https://i-blog.csdnimg.cn/direct/3b585450a37f4763a34db2af657baf65.png)]
导致死锁的条件有四个,也就是这四个条件同时满足就会产生死锁。
 互斥条件,共享资源 X 和 Y 只能被一个线程占用;
 请求和保持条件,线程 T1 已经取得共享资源 X,在等待共享资源 Y 的时候,不释放共享资源X;
 不可抢占条件,其他线程不能强行抢占线程 T1 占有的资源;
 循环等待条件,线程 T1 等待线程 T2 占有的资源,线程 T2 等待线程 T1 占有的资源,就是循环等待。
导致死锁之后,只能通过人工干预来解决,比如重启服务,或者杀掉某个线程。
所以,只能在写代码的时候,去规避可能出现的死锁问题
按照死锁发生的四个条件,只需要破坏其中的任何一个,就可以解决,但是,互斥条件是没办法破坏的,因为这是互斥锁的基本约束,其他三方条件都有办法来破坏:
 对于“请求和保持”这个条件,我们可以一次性申请所有的资源,这样就不存在等待了。
 对于“不可抢占”这个条件,占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源,这样不可抢占这个条件就破坏掉了。
 对于“循环等待”这个条件,可以靠按序申请资源来预防。所谓按序申请,是指资源是有线性顺序的,申请的时候可以先申请资源序号小的,再申请资源序号大的,这样线性化后自然就不存在循环了。

2.1.7 synchronized和Lock的区别是什么

下面我从 4 个方面来回答

  1. 从功能角度来看,Lock 和 Synchronized 都是 Java 中用来解决线程安全问题的工具。
  2. 从特性来看,
    a. Synchronized 是 Java 中的同步关键字,Lock 是 J.U.C 包中提供的口,这个接口有很多实现类,其中就包括 ReentrantLock 重入锁
    b. Synchronized 可以通过两种方式来控制锁的粒度,(如图)[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Cy4XWovy-1721614050850)(https://i-blog.csdnimg.cn/direct/1fc603d5d004474c8cd8b6b3d8919904.png)]
    一种是把 synchronized 关键字修饰在方法层面,
    另一种是修饰在代码块上,并且我们可以通过 Synchronized 加锁对象的生命周期来控制锁的作用范围,比如锁对象是静态对象或者类对象,那么这个锁就是全局锁。
    如果锁对象是普通实例对象,那这个锁的范围取决于这个实例的生命周期。
    Lock 锁的粒度是通过它里面提供的 lock()和 unlock()方法决定的(贴图),包裹在这两个方法之间的代码能够保证线程安全性。而锁的作用域取决于 Lock 实例的生命周期。
    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-GlH95bTF-1721614050851)(https://i-blog.csdnimg.cn/direct/8658fc00b6844e1b873af76d14e3bed6.png)]
    c. Lock 比 Synchronized 的灵活性更高,Lock 可以自主决定什么时候加锁,什么时候释放锁,只需要调用 lock()和 unlock()这两个方法就行,同时 Lock 还提供了非阻塞的竞争锁方法tryLock()方法,这个方法通过返回 true/false 来告诉当前线程是否已经有其他线程正在使用锁。
    Synchronized 由于是关键字,所以它无法实现非阻塞竞争锁的方法,另外,Synchronized 锁的释放是被动的,就是当 Synchronized 同步代码块执行完以后或者代码出现异常时才会释放。
    d. Lock 提供了公平锁和非公平锁的机制,公平锁是指线程竞争锁资源时,如果已经有其他线程正在排队等待锁释放,那么当前竞争锁资源的线程无法插队。而非公平锁,就是不管是否有线程在排队等待锁,它都会尝试去竞争一次锁。 Synchronized 只提供了一种非公平锁的实现。
  3. 从性能方面来看,Synchronized 和 Lock 在性能方面相差不大,在实现上会有一些区别,Synchronized 引入了偏向锁、轻量级锁、重量级锁以及锁升级的方式来优化加锁的性能,而 Lock中则用到了自旋锁的方式来实现性能优化。

2.1.8 什么是可重入锁,他的作用是什么

可重入是多线程并发编程里面一个比较重要的概念,
简单来说,就是在运行的某个函数或者代码,因为抢占资源或者中断等原因导致函数或者代码的运行中断,等待中断程序执行结束后,重新进入到这个函数或者代码中运行,并且运行结果不会受到影响,那么这个函数或者代码就是可重入的。
(如图) 而可重入锁,简单来说就是一个线程如果抢占到了互斥锁资源,在锁释放之前再去竞争同一把锁的时候,不需要等待,只需要记录重入次数。
在多线程并发编程里面,绝大部分锁都是可重入的,比如 Synchronized、ReentrantLock 等,但是也有不支持重入的锁,比如 JDK8 里面提供的读写锁 StampedLock。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-MAFTkrxX-1721614050852)(https://i-blog.csdnimg.cn/direct/ef189f9815f7468abee160cf863ee320.png)]

2.1.9 ReentrantLock的实现原理

首先,ReentrantLock 是一种可重入的排他锁,主要用来解决多线程对共享资源竞争的问题。
它的核心特性有几个:

  1. 它支持可重入,也就是获得锁的线程在释放锁之前再次去竞争同一把锁的时候,不需要加锁就可以直接访问。
  2. 它支持公平和非公平特性
  3. 它提供了阻塞竞争锁和非阻塞竞争锁的两种方法,分别是 lock()和 tryLock()。
    然后,ReentrantLock 的底层实现有几个非常关键的技术。
    a. 锁的竞争,ReentrantLock 是通过互斥变量,使用 CAS 机制来实现的。
    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-CRltLyRr-1721614050853)(https://i-blog.csdnimg.cn/direct/e4fc9d8d35e847cdacd79aeb4da1c70e.png)] 没有竞争到锁的线程,使用了 AbstractQueuedSynchronizer 这样一个队列同步器来存储,底层
    是通过双向链表来实现的。当锁被释放之后,会从 AQS 队列里面的头部唤醒下一个等待锁的线程。
    b. 公平和非公平的特性,主要是体现在竞争锁的时候,是否需要判断 AQS 队列存在等待中的线程。
    c. 关于锁的重入特性,在 AQS 里面有一个成员变量来保存当前获得锁的线程,当同一个线程下次再来竞争锁的时候,就不会去走锁竞争的逻辑,而是直接增加重入次数。

2.1.10 ReentrantLock如何实现的公平锁和非公平锁

reentantLock 是基于 aqs 实现的一个可重入锁。它的实现主要有 2 种不同方式,一种是公平锁、一种是非公平锁。
他通过 aqs 中的 state 字段来维护是否获得锁或者重入了几次,并且这个字段需要用 volatile 来修饰,保证线程之间的可见性。
并且去抢占这个标记必须是安全的,所以会通过 CAS 来保证抢占过程的安全性。
如果是不同的线程,通过 cas 判断是否能拿到锁,如果是同一个线程重复抢占锁,那么 state+1,这样就实现了可重入。
如果拿到锁的线程,去执行相关业务代码,如果拿不到锁,会加入一个等待队列,是一个双向链表,这个队列也属于 aqs 维护,加入队列后 park,这个时候线程就等待拿到锁的线程释放锁去唤醒。当拿锁的线程 unlock 后,会去唤醒等待队列的第一个等待的线程。自旋去获取抢占锁。
公平锁与非公平锁,他们的区别在哪?
他们的大致流程都是一样的,他们的区别主要在于去获取锁的时候,公平锁会去判断是不是等待队列的第一个线程或者没有线程在等待,如果是,就能获取锁;如果有线程在等待,必须按照队列的顺序来获取锁。新的线程必须排在等待队列后面。这也是公平的由来,任何线程都必须按照等待队列的顺序来获取锁
而非公平,只要锁释放,没有加入等待队列的线程能提前通过 cas 去抢占锁,可能拿到锁的时间节点比等待队列的线程更早。

2.1.11 说说你对行锁,间隙锁,临键锁的理解

行锁、临键锁、间隙锁,都是 Mysql 里面 InnoDB 引擎下解决事务隔离性的一系列排他锁。
下面请允许我分别介绍一下这三种锁。
行锁,也称为记录锁。
当我们针对主键或者唯一索引加锁的时候,Mysql 默认会对查询的这一行数据加行锁,避免其他事务对这一行数据进行修改。
间隙锁,顾名思义,就是锁定一个索引区间。
在普通索引或者唯一索引列上,由于索引是基于 B+树的结构存储,所以默认会存在一个索引区间。(如图),而间隙锁,就是某个事物对索引列加锁的时候,默认锁定对应索引的左右开区间范围。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-QR10QzWo-1721614050854)(https://i-blog.csdnimg.cn/direct/b857c33624c045a6b92795fe908a160c.png)]
在基于索引列的范围查询,无论是否是唯一索引,都会自动触发间隙锁。
比如基于 between 的范围查询,就会产生一个左右开区间的间隙锁。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-pHlq3wOJ-1721614050855)(https://i-blog.csdnimg.cn/direct/ea977fab562a437ca41e9585bf2833e5.png)]
最后一个是临键锁,(如图)它相当于行锁+间隙锁的组合,也就是它的锁定范围既包含了索引记录,也包含了索引区间, 它会锁定一个左开右闭区间的数据范围。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-nOC3p4LJ-1721614050856)(https://i-blog.csdnimg.cn/direct/41c29dab38b4440bbf37555539d1898e.png)]
(如图)假设我们使用非唯一索引列进行查询的时候,默认会加一个临键锁,锁定一个左开右闭区间的范围。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-i4pJbQNz-1721614050857)(https://i-blog.csdnimg.cn/direct/932f2c1814fd447baabe2e3997b293c3.png)]
所以总的来说,行锁、临键锁、间隙锁只是表示锁定数据的范围,最终目的是解决幻读的问题。
而临键锁相当于行锁+间隙锁,因此当我们使用非唯一索引进行精准匹配的时候,会默认如临键锁,因为它需要锁定匹配的这一行数据,还需要锁定这一行数据对应的左开右闭区间。
因此在实际应用中,尽可能使用唯一索引或者主键索引进行查询,避免大面积的锁定造成性能影响。

2.1.13 阻塞队列被异步消费,怎么保持顺序

首先,阻塞队列本身是符合 FIFO 特性的队列,也就是存储进去的元素符合先进先出的规则。
其次,在阻塞队列里面,使用了 condition 条件等待来维护了两个等待队列(如图),一个是队列为空的时候存储被阻塞的消费者另一个是队列满了的时候存储被阻塞的生产者
并且存储在等待队列里面的线程,都符合 FIFO 的特性。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-xEj0R9LV-1721614050858)(https://i-blog.csdnimg.cn/direct/32ad90d92db64633a76551eb46d3881d.png)]
最后,对于阻塞队列的消费过程,有两种情况。
 第一种,就是阻塞队列里面已经包含了很多任务,这个时候启动多个消费者去消费的时候,它的有序性保证是通过加锁来实现的,也就是每个消费者线程去阻塞队列获取任务的时候 必须先获得排他锁。
 第二种,如果有多个消费者线程因为阻塞队列中没有任务而阻塞,这个时候这些线程是按照 FIFO的顺序存储到condition 条件等待队列中的。 当阻塞队列中开始有任务要处理的时候,这些被阻塞的消费者线程会严格按照 FIFO 的顺序来唤醒,从而保证了消费的顺序性。

2.1.14 基于数组的阻塞队列ArrayBlockingQueue的实现原理

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-IM49jNnu-1721614050858)(https://i-blog.csdnimg.cn/direct/ebcce84f275548f19751773c9761de8c.png)]

  1. (如图)阻塞队列(BlockingQueue)是在队列的基础上增加了两个附加操作,
     在队列为空的时候,获取元素的线程会等待队列变为非空。
     当队列满时,存储元素的线程会等待队列可用。
  2. 由于阻塞队列的特性,可以非常容易实现生产者消费者模型,也就是生产者只需要关心数据的生产,消费者只需要关注数据的消费,所以如果队列满了,生产者就等待,同样,队列空了,消费者也需要等待。
  3. 要实现这样的一个阻塞队列,需要用到两个关键的技术,队列元素的存储,以及线程阻塞和唤醒。
  4. 而 ArrayBlockingQueue 是基于数组结构的阻塞队列,也就是队列元素是存储在一个数组结构里面,并且由于数组有长度限制,为了达到循环生产和循环消费的目的,ArrayBlockingQueue 用到了循环数组。
  5. 而线程的阻塞和唤醒,用到了 J.U.C 包里面的 ReentrantLock 和 Condition。 Condition 相当于wait/notify 在 JUC 包里面的实现。

2.2多线程与线程池

2.2.1 Thread和Runnable的区别是什么

  1. Thread 是一个类,Runnable 是接口,因为在 Java 语言里面的继承特性,接口可以支持多继承,而类只能单一继承。
    所以如果在已经存在继承关系的类里面要实现线程的话,只能实现 Runnable 接口。
  2. Runnable 表示一个线程的顶级接口,Thread 类其实也是实现了 Runnable 接口。
  3. 站在面向对象的思想来说,Runnable 相当于一个任务,而 Thread 才是真正处理的线程,所以我们只需要用 Runnable 去定义一个具体的任务,然后交给 Thread 去处理就可以了,这样达到了松耦合的设计目的。
  4. Runnable 接口定义了线程执行任务的标准方法 run, 所以在实际应用中,建议实现 Runnable 接口实现线程的任务定义,然后使用 Thread 的 start 方法 ·· 1去启动线程并执行 Runnable 这个任务。

2.2.2 什么是守护线程,它有什么特点

简单来说,守护线程就是一种后台服务线程,他和我们在 Java 里面创建的用户线程是一模一的。
守护线程和用户线程的区别有几个点,这几个点也是守护线程本身的特性:

  1. 在线程创建方面,对于守护线程,我们需要主动调用 setDaemon()并且设置成 true。
  2. 我们知道,一个 Java 进程中,只要有任何一个用户线程还在运行,那么这个 java 进程就不会结束,否则,这个程序才会终止。
    注意,Java 进程的终止与否,只和用户线程有关。如果当前还有守护线程正在运行,也不会阻止 Java程序的终止。
    因此,守护线程的生命周期依赖于用户线程。
    举个例子,JVM 垃圾回收线程就是一个典型的守护线程,它存在的意义是不断地处理用户线程运行过程中产生的内存垃圾。
    一旦用户线程全部结束了,那垃圾回收线程也就没有存在的意义了。
    由于守护线程的特性,所以它适合用在一些后台的通用服务场景里面。
    但是守护线程不能用在线程池或者一些 IO 任务的场景里面,因为一旦 JVM 退出之后,守护线程也会直接退出。就可能导致任务没有执行完或者资源没有正确释放的问题。

2.2.3 Blocked 和waiting两种线程状态有什么区别

BLOCKED 和 WAITING 都是属于线程的阻塞等待状态。
BLOCKED 状态是指线程在等待监视器锁的时候的阻塞状态。
(如图)也就是在多个线程去竞争 Synchronized 同步锁的时候,没有竞争到锁资源的线程,会被阻塞等待,这个时候线程状态就是 BLOCKED。
在线程的整个生命周期里面,只有 Synchronized 同步锁等待才会存在这个状态。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-61f8sVfz-1721614050859)(https://i-blog.csdnimg.cn/direct/f81e3b8a89b34e1295b05db6d6cc5058.png)]
WAITING 状态,表示线程的等待状态,在这种状态下,线程需要等待某个线程的特定操作才会被唤醒。
我们可以使用 Object.wait()、Object.join()、LockSupport.park()这些方法使得线程进入到 WAITING 状态,在这个状态下,必须等待特定的方法来唤醒,比如 Object.notify 方法可以唤醒 Object.wait()方法阻塞的线程, LockSupport.unpark()可以唤醒 LockSupport.park()方法阻塞的线程。
所以,在我看来,BLOCKED 和 WAITING 两个状态最大的区别有两个:
 BLOCKED 是竞争失败后被动触发的状态,WAITING 是人为的主动触发的状态
 BLCKED 的唤醒是自动触发的,而 WAITING 状态是必须通过特定的方法来主动唤醒

2.2.4 为什么启动线程池不能直接调用run()方法,调用两次start会有什么后果

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Nrr6yald-1721614050860)(https://i-blog.csdnimg.cn/direct/d73c3d7ee9b84d2aabccc312f38bc763.png)]

2.2.5 谈谈你对线程的五中状态流转原理的理解

1、 新建状态( New ):当线程对象对创建后,即进入了新建状态,如:Thread t = new
MyThread() ;
2、 就 绪 状 态( Runnable ) : 当调用线程对象的 start()方法( t.start(); ) , 线程 即进
入就绪状态 。处于就绪状态的线程 ,只是说明此线程已经做好了准备 ,随时等 待 CPU 调度执行,并不是说执行了 t. start()此线程立即就会执行;
3、 运 行 状 态( Running ) : 当 CPU 开始调度处于就绪状态的线程时 ,此时线程 才得以真正执行 , 即进入到运行状态。注: 就 绪状态是进入到运行状态的唯一入口,也就是说 , 线程要想进入运行状态执行 , 首先必须处于就绪状态中;
4、 阻塞状态( Blocked) :处于运行状态中的线程由于某种原因,暂时放弃对 CPU 的 使用权 ,
停止执行 ,此时进入阻塞状态 ,直到其进入到就绪状态 , 才 有机会再次被 CPU 调用以进入到运
行状态。
根据阻塞产生的原因不同 , 阻塞状态又可以分为三种 :
1、等待阻塞 :运行状态中的线程执行 wait()方法 ,使本线程进入等待阻塞状态;
2、 同步阻塞:线程在获取 synchronized 同步锁失败(因为锁被其他线程所占用) , 它会进入同步阻塞状态;
3、其他阻塞 :通过调用线程的 sleep()或 join()或发出了 I/O 请求时 ,线程会进入阻塞 状态。
当 sleep()状态超时、join()等待线程终止或者超时,或者 I/O 处理完毕时 , 线程重新转入就绪状
态。
5、 死亡状态( Dead) :线程执行完了或者因异常退出了 run()方法 ,该线程结束 生命周期。

2.2.6 谈谈你对线程池的理解

首先,线程池本质上是一种池化技术,而池化技术是一种资源复用的思想,比较常见的有连接池、内存池、对象池。
而线程池里面复用的是线程资源,它的核心设计目标,我认为有两个:

  1. 减少线程的频繁创建和销毁带来的性能开销,因为线程创建会涉及 CPU 上下文切换、内存分配等工作。
  2. 线程池本身会有参数来控制线程创建的数量,这样就可以避免无休止地创建线程带来的资源利用率过高的问题,起到了资源保护的作用。
    其次,我简单说一下线程池里面的线程复用技术。因为线程本身并不是一个受控的技术,也就是说线程的生命周期是由任务运行的状态决定的,无法人为控制。
    所以为了实现线程的复用,线程池里面用到了阻塞队列,简单来说就是线程池里面的工作线程处于一直运行状态,它会从阻塞队列中去获取待执行的任务,一旦队列空了,那这个工作线程就会被阻塞,直到下次有新的任务进来。
    也就是说,工作线程是根据任务的情况实现阻塞和唤醒,从而达到线程复用的目的。
    最后,线程池里面的资源限制,是通过几个关键参数来控制的,分别是核心线程数、最大线程数。
    核心线程数表示默认长期存在的工作线程,而最大线程数是根据任务的情况动态创建的线程,主要是提高阻塞队列中任务的处理效率。
    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-KPEKLeyU-1721614050861)(https://i-blog.csdnimg.cn/direct/8a21b8697d90496cb2f7daa66a09fd49.png)]

我看你在项目中有用到线程池,那你能说下线程池的执行原理

首先多线程,是我们在开发中提升并发能力的一个常用的方案,但是我们一般不会自己去手动创建线程,
因为这样线程不易于管理,每个人都去创建线程,可能会出现无限的线程创建,导致响应速度反而越来越慢。
所以,我们一般会用线程池来创建线程,线程池的核心思想其实是线程复用,我希望用固定的线程数,来执行固定的任务。
我们 juc 包下的 Executors 其实封装了很多线程池的实现方式,比如 newFixedThreadPool
newCachedThreadPool 等等

3.面试官,那你说下线程池要传哪些参数?

在讲到线程池的原理的时候,我们必须知道线程池的 7 个参数
1.核心线程数 默认会开启的线程数量
2.最大线程数 任务过多,我会额外开启的最大线程
3.线程等待时间 没有任务的时候,这个线程过多久会回收(如果能回收的话)
4.等待单位
5.阻塞队列 放任务的队列
6.线程的创建工厂
7.拒绝策略 在某些情况下,我会丢失任务 如果有拒绝策略 ,会进入拒绝策略
这些参数都会在流程中能使用到,每个参数都有每个参数的作用。

4.面试官,你刚才说线程池的流程会用到这些参数,那分别在哪里用到?

参数的使用,要先捋下线程池的执行流程。
线程池去执行任务最终是通过 exectue 方法去执行,传的任务是实现了 Runnable 接口的对象并实现run 方法。实现流程如下
1.先会判断已经开启的线程数是否小于核心线程,如果是,会创建一个 worker 对象,这个 worker 是实现了 runnable 接口的,并且 worker 对象持有一个 Thread 对象。通过启动这个 Thread 去开启线程。 这是核心线程与线程工厂的使用场景,核心线程来判断我是否需要创建新的 thread,线程工厂决定我怎么去创建线程。
2.如果线程池已创建的线程数大于等于核心线程数,会把任务给到阻塞队列,已经开启的线程会自动去阻塞队列获取任务。
阻塞队列就是用来存储我来不及执行的任务的。
3.如果我已经开启了核心线程数的线程,并且阻塞队列放不下了,那么我们会去创建额外的线程来帮助执行,但是总线程数不能超过最大线程。
最大线程是用来限制我线程池能开启的最大线程
4.如果阻塞队列满 了,并且也开启了最大线程的线程数,将不再接收任务,会进入拒绝策略。拒绝策略可以自己实现,实现 RejectedExecutionHandler 接口即可。
现在我们知道了 5 个参数的作用,还有 2 个参数在细节里面,就是过期时间与过期单位。
这 2 个参数 是当阻塞队列没有任务的时候,我要去回收多余的线程或者回收所有的线程的时候,一个超时等待时间,也就是阻塞队列的 poll 的时间。

5.面试官:我看你提到了核心线程数 那这个核心线程跟最大线程有什么区别?

其实 7 大参数里面的核心线程数与最大核心线程数,其实就是一个线程的上限判断。核心线程,是来一个任务我就会开启一个线程,直到达到核心线程数为止。
最大线程是我的阻塞队列满了的时候,我需要开启线程来帮助处理任务。但是他们的开启回收逻辑是一模一样的。
默认会保证核心线程数的线程在阻塞等待,不被回收,那么至于哪些回收,哪些等待,就看线程哪个执行到判断现有线程数是否大于核心线程数的逻辑。

2.2.7Java有哪些实现线程池的的方式

JDK 中幕刃提供了 5 种不同线程池的创建方式,
下面我分别说一下每一种线程池以及它的特点。
newCachedThreadPool, 是一种可以缓存的线程池,它可以用来处理大量短期的突发流量。
它的特点有三个,最大线程数是 Integer.MaxValue,线程存活时间是 60 秒,
阻塞队列用的是 SynchronousQueue,这是一种不存在任何元素的阻塞队列,也就是每提交一个任务给到线程池,都会分配一个工作线程来处理,由于最大线程数没有限制。
所以它可以处理大量的任务,另外每个工作线程又可以存活 60s,使得这些工作线程可以缓存起来
应对更多任务的处理。
newFixedThreadPool,是一种固定线程数量的线程池。
它的特点是核心线程和最大线程数量都是一个固定的值,如果任务比较多工作线程处理不过来,就会加入阻塞队列里面等待。
 newSingleThreadExecutor,只有一个工作线程的线程池。并且线程数量无法动态更改,因此可以保证所有的任务都按照 FIFO 的方式顺序执行。
 newScheduledThreadPool,具有延迟执行功能的线程池,可以用它来实现定时调度
 newWorkStealingPool,Java8 里面新加入的一个线程池,它内部会构建一个 ForkJoinPool,利用工作窃取的算法并行处理请求。
这些线程都是通过工具类 Executors 来构建的,线程池的最终实现类是 ThreadPoolExecutor

2.2.8 线程池是如何回收线程的

线程池里面分为核心线程和非核心线程。
核心线程是常驻在线程池里面的工作线程,它有两种方式初始化。
 向线程池里面添加任务的时候,被动初始化
 主动调用 prestartAllCoreThreads 方法
当线程池里面的队列满了的情况下,为了增加线程池的任务处理能力,线程池会增加非核心线程。
核心线程和非核心线程的数量,是在构造线程池的时候设置的,也可以动态进行更改。
由于非核心线程是为了解决任务过多的时候临时增加的,所以当任务处理完成后,工作线程处于空闲状态的时候,就需要回收。
因为所有工作线程都是从阻塞队列中去获取要执行的任务,所以只要在一定时间内,阻塞队列没有任何可以处理的任务,那这个线程就可以结束了。
这个功能是通过阻塞队列里面的 poll 方法来完成的。这个方法提供了超时时间和超时时间单位这两个参数,当超过指定时间没有获取到任务的时候,poll 方法返回 null,从而终止当前线程完成线程回收。
默认情况下,线程池只会回收非核心线程,如果希望核心线程也要回收,可以设置 allowCoreThreadTimeOut 这个属性为 true,一般情况下我们不会去回收核心线程。
因为线程池本身就是实现线程的复用,而且这些核心线程在没有任务要处理的时候是处于阻塞状态
并没有占用 CPU 资源

2.2.9 线程池是如何实现线程复用的

线程池里面采用了生产者消费者的模式,来实现线程复用。
生产者消费者模型,其实就是通过一个中间容器来解耦生产者和消费者的任务处理过程。
生产者不断生产任务保存到容器,消费者不断从容器中消费任务。
在线程池里面,因为需要保证工作线程的重复使用,并且这些线程应该是有任务的时候执行,没任务的时候等待并释放 CPU 资源。
因此(如图),它使用了阻塞队列来实现这样一个需求。
提交任务到线程池里面的线程称为生产者线程,它不断往线程池里面传递任务。这些任务会保存到线程池的阻塞队列里面。
然后线程池里面的工作线程不断从阻塞队列获取任务去执行。
基于阻塞队列的特性,使得阻塞队列中如果没有任务的时候,这些工作线程就会阻塞等待。
直到又有新的任务进来,这些工作线程再次被唤醒。从而达到线程复用的目的
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-xceui4lb-1721614050862)(https://i-blog.csdnimg.cn/direct/e9ea740d0d514fc891eba57e299b2d93.png)]

2.2.10 线程池如何知道一个线程的任务已经执行完成

  1. 在线程池内部,当我们把一个任务丢给线程池去执行,线程池会调度工作线程来执行这个任务的run 方法,run 方法正常结束,也就意味着任务完成了。
    所以线程池中的工作线程是通过同步调用任务的 run()方法并且等待 run 方法返回后,再去统计任务的完成数量。
  2. 如果想在线程池外部去获得线程池内部任务的执行状态,有几种方法可以实现。
    a. 线程池提供了一个 isTerminated()方法,可以判断线程池的运行状态,我们可以循环判断isTerminated()方法的返回结果来了解线程池的运行状态,一旦线程池的运行状态是Terminated,意味着线程池中的所有任务都已经执行完了。想要通过这个方法获取状态的前提是,程序中主动调用了线程池的 shutdown()方法。在实际业务中,一般不会主动去关闭线程池,因此这个方法在实用性和灵活性方面都不是很好。
    b. 在线程池中,有一个 submit()方法,它提供了一个 Future 的返回值,我们通过 Future.get()方法来获得任务的执行结果,当线程池中的任务没执行完之前,future.get()方法会一直阻塞,直到任务执行结束。因此,只要 future.get()方法正常返回,也就意味着传入到线程池中的任务已经执行完成了!
    c. 可以引入一个 CountDownLatch 计数器,它可以通过初始化指定一个计数器进行倒计时,其中有两种方法分别是 await()阻塞线程,以及 countDown()进行倒计时,一旦倒计时归零,所有被阻塞在 await()方法的线程都会被释放。
    基于这样的原理,我们可以定义一个 CountDownLatch 对象并且计数器为 1,接着在线程池代码块后面调用 await()方法阻塞主线程,然后,当传入到线程池中的任务执行完成后,调用countDown()方法表示任务执行结束。
    最后,计数器归零 0,唤醒阻塞在 await()方法的线程。
    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-A3nfGab5-1721614050863)(https://i-blog.csdnimg.cn/direct/d8d049d6b3aa4ae38faef00f18a471f8.png)]
  3. 基于这个问题,我简单总结一下,不管是线程池内部还是外部,要想知道线程是否执行结束,我们必须获取线程执行结束后的状态,而线程本身没有返回值,所以只能通过阻塞-唤醒的方式来实现,future.get 和 CountDownLatch 都是这样一个原理

2.2.11 当线程数超过线程池的核心线程数时如何让任务不进入队列

Java 中线程池提供的构造方法里面,有一个参数可以修改阻塞队列的类型。
其中,就有一个阻塞队列叫 SynchronousQueue(如图), 这个队列不能存储任何元素。
它的特性是,每生产一个任务,就必须指派一个消费者来处理,否则就会阻塞生产者。
基于这个特性,只要把线程池的阻塞队列替换成 SynchronousQueue。
就能够避免任务进入到阻塞队列,而是直接启动最大线程数去处理这个任务。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-vlanderJ-1721614050864)(https://i-blog.csdnimg.cn/direct/c239afef5e5a4cfc979164033c30837d.png)]

2.2.12 什么是伪共享,如何避免

首先,计算机工程师为了提高 CPU 的利用率,平衡 CPU 和内存之间的速度差异,在 CPU 里面设计了三级缓存。
CPU 在向内存发起 IO 操作的时候,一次性会读取 64 个字节的数据作为一个缓存行,缓存到 CPU 的高速缓存里面。
在 Java 中一个 long 类型是 8 个字节,意味着一个缓存行可以存储 8 个 long 类型的变量。
这个设计是基于空间局部性原理来实现的,也就是说,如果一个存储器的位置被引用,那么将来它附近的位置也会被引用。
所以缓存行的设计对于 CPU 来说,可以有效地减少和内存的交互次数,从而避免了 CPU 的 IO 等待,以提升 CPU 的利用率。
正是因为这种缓存行的设计,导致如果多个线程修改同一个缓存行里面的多个独立变量的时候,基于缓存一致性协议,就会无意中影响了彼此的性能,这就是伪共享的问题。
(如图)像这样一种情况,CPU0 上运行的线程想要更新变量 X、CPU1 上的线程想要更新变量 Y,而X/Y/Z 都在同一个缓存行里面。
每个线程都需要去竞争缓存行的所有权对变量做更新,基于缓存一致性协议。
一旦运行在某个 CPU 上的线程获得了所有权并执行了修改,就会导致其他 CPU 中的缓存行失效。
这就是伪共享问题的原理。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-aMN0yp4l-1721614050865)(https://i-blog.csdnimg.cn/direct/9e081a10334648c1a13e6b2451e7880e.png)]因为伪共享问题导致缓存锁的竞争,所以在并发场景中的程序执行效率一定会受到较大的影响。
这个问题的解决办法有两个:

  1. 使用对齐填充,因为一个缓存行大小是 64 个字节,如果读取的目标数据小于 64 个字节,可以增加一些无意义的成员变量来填充。
  2. 在 Java8 里面,提供了@Contented 注解,它也是通过缓存行填充来解决伪共享问题的,被
    @Contented 注解声明的类或者字段,会被加载到独立的缓存行上。

2.2.13 wait和notify为什么要写在synchronized代码块中

wait 和 notify 用来实现多线程之间的协调,wait 表示让线程进入阻塞状态,notify 表示让阻塞的
线程唤醒。

  1. wait 和 notify 必然是成对出现的,如果一个线程被 wait()方法阻塞,那么必然需要另外一个线程,通过 notify()方法来唤醒这个被阻塞的线程,从而实现多线程之间的通信。
    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-y9KUGpkb-1721614050865)(https://i-blog.csdnimg.cn/direct/0a1524f20453479f9ab755709394e6b2.png)]2. (如图)在多线程里面,要实现多个线程之间的通信,除了管道流以外,只能通过共享变量的方法来实现,也就是线程 t1 修改共享变量 s,线程 t2 获取修改后的共享变量 s,从而完成数据通信。
    但是多线程本身具有并行执行的特性,也就是在同一时刻,多个线程可以同时执行。在这种情况下,线程 t2 在访问共享变量 s 之前,必须知道线程 t1 已经修改过了共享变量 s,否则就需要等待。
    同时,线程 t1 修改过了共享变量 S 之后,还需要通知在等待中的线程 t2。
    所以要在这种特性下要去实现线程之间的通信,就必须有一个竞争条件控制线程在什么条件下等待,什么条件下唤醒。
    3,而 Synchronized 同步关键字就可以实现这样一个互斥条件,也就是在通过共享变量来实现多个线,程通信的场景里面,参与通信的线程必须竞争到这个共享变量的锁资源,才有资格对共享变量做修改,修改完成后就释放锁,那么其他的线程就可以再次来竞争同一个共享变量的锁来获取修改后的数据,从而完成线程之前的通信。
  2. 所以这也是为什么 wait/notify 需要放在 Synchronized 同步代码块中的原因,有了 Synchronized
    同步锁,就可以实现对多个通信线程之间的互斥,实现条件等待和条件唤醒。
  3. 另外,为了避免 wait/notify 的错误使用,jdk 强制要求把 wait/notify 写在同步代码块里面,否则
    会抛出 IllegalMonitorStateException
  4. 最后,基于 wait/notify 的特性,非常适合实现生产者消费者的模型,比如说用 wait/notify 来实
    现连接池就绪前的等待与就绪后的唤醒。

2.2.14 wait和sleep是否会触发锁的释放和cpu资源的释放

Object.wait()方法,会释放锁资源以及 CPU 资源。
Thread.sleep()方法,不会释放锁资源,但是会释放 CPU 资源。
首先,wait()方法是让一个线程进入到阻塞状态,而这个方法必须写在一个 Synchronized 同步代码块里面。
因为 wait/notify 是基于共享内存来实现线程通信的工具,这个通信涉及条件的竞争,所以在调用这两个方法之前必须竞争锁资源。
当线程调用 wait 方法的时候,表示当前线程的工作处理完了,意味着让其他竞争同一个共享资源的线程有机会去执行。
但前提是其他线程需要竞争到锁资源,所以 wait 方法必须释放锁,否则就会导致死锁的问题。
然后,Thread.sleep()方法,只是让一个线程单纯进入睡眠状态,这个方法并没有强制要求加
synchronized 同步锁。
而且从它的功能和语义来说,也没有这个必要。
当然,如果是在一个 Synchronized 同步代码块里面调用这个 Thread.sleep,也并不会触发锁的释放。
最后,凡是让线程进入阻塞状态的方法,操作系统都会重新调度实现 CPU 时间片切换,这样设计的目的是提升 CPU 的利用率。

2.2.15 volatite关键字的作用,以及实现原理

volatile 关键字有两个作用。

  1. 可以保证在多线程环境下共享变量的可见性。
  2. 通过增加内存屏障防止多个指令之间的重排序。
    我理解的可见性,是指当某一个线程对共享变量的修改,其他线程可以立刻看到修改之后的值。
    其实这个可见性问题,我认为本质上是由几个方面造成的。
  3. (如图)CPU 层面的高速缓存,在 CPU 里面设计了三级缓存去解决 CPU 运算效率和内存 IO 效率问题,但是带来的就是缓存的一致性问题,而在多线程并行执行的情况下,缓存一致性就会导致可见性问题。[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-FIchDS4V-1721614050866)(https://i-blog.csdnimg.cn/direct/25c412750c634c208381369fdc6f9b95.png)]
    所以,对于增加了 volatile 关键字修饰的共享变量,JVM 虚拟机会自动增加一个#Lock 汇编指令,这个指令会根据 CPU 型号自动添加锁
  4. 指令重排序,所谓重排序,就是指令的编写顺序和执行顺序不一致,在多线程环境下导致可见性问题。指令重排序本质上是一种性能优化的手段,它来自几个方面。
     CPU 层面,针对 MESI 协议的更进一步优化去提升 CPU 的利用率,引入了 StoreBuffer 机制,
    而这一种优化机制会导致 CPU 的乱序执行。为了避免这样的问题,CPU 提供了内存屏障指令,上层应用可以在合适的地方插入内存屏障来避免 CPU 指令重排序问题。
     编译器的优化,编译器在编译的过程中,在不改变单线程语义和程序正确性的前提下,对指令
    进行合理的重排序优化来提升性能。
    所以,如果对共享变量增加了 volatile 关键字,那么在编译器层面,就不会去触发编译器优化,同时在JVM 里面,会插入内存屏障指令来避免重排序问题。
    当然,除了 volatile 以外,从 JDK5 开始,JMM 就使用了一种 Happens-Before 模型去描述多线程之间的内存可见性问题。
    如果两个操作之间具备 Happens-Before 关系,那么意味着这两个操作具备可见性关系,不需要再额外去考虑增加 volatile 关键字来提供可见性保障。

2.2.16 说说你对CompletableFuture的理解

CompletableFuture 是 Java 8 中引入的一个组件,它提供了一种简洁而强大的方式来处理异步任务和处理异步任务的结果。
使用 CompletableFuture 可以让我们将一个耗时的任务提交给线程池进行异步执行,然后可以继续执行其他的任务,等到异步任务执行结束后会触发一个回调方法,我们可以在回调方法中处理异步任务的执行结果。
CompletableFuture 提供了一些便捷的方法,例如 thenApply、thenAccept、thenRun 等,
可以让我们以链式的方式处理异步任务的结果,从而更加灵活地编写异步代码。

2.2.17 谈谈你对ThreadLocal实现原理的理解

  1. ThreadLocal 是一种线程隔离机制,它提供了多线程环境下对于共享变量访问的安全性。
  2. 在多线程访问共享变量的场景中(出现下面第一个图),一般的解决办法是对共享变量加锁(出现下面第二个图),从而保证在同一时刻只有一个线程能够对共享变量进行更新,并且基于Happens-Before 规则里面的监视器锁规则,又保证了数据修改后对其他线程的可见性。
    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-6nhnnsao-1721614050867)(https://i-blog.csdnimg.cn/direct/853e5006abed46d3a09d7a1bec8ab26a.png)]
    3. 但是加锁会带来性能的下降,所以 ThreadLocal 用了一种空间换时间的设计思想,也就是说在每个线程里面,都有一个容器来存储共享变量的副本,然后每个线程只对自己的变量副本来做更新操作,这样既解决了线程安全问题,又避免了多线程竞争加锁的开销。
    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Ee1wrXNA-1721614050868)(https://i-blog.csdnimg.cn/direct/5ceac484c4d446e09421881a4442e68f.png)]
  3. ThreadLocal 的具体实现原理是,在 Thread 类里面有一个成员变量 ThreadLocalMap,它专门来存储当前线程的共享变量副本,后续这个线程对于共享变量的操作,都是从这个ThreadLocalMap里面进行变更,不会影响全局共享变量的值。

2.3 线程安全

2.3.1谈谈你对线程安全的理解

线程安全问题的具体表现在三个方面,原子性、有序性、可见性。
原子性呢,是指当一个线程执行一系列程序指令操作的时候,它应该是不可中断的,因为一旦出现中断,
站在多线程的视角来看,这一系列的程序指令会出现前后执行结果不一致的问题。
这个和数据库里面的原子性是一样的,就是一段程序只能由一个线程完整地执行完成,而不能存在多个线程干扰。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-vDcP9bm7-1721614050869)(https://i-blog.csdnimg.cn/direct/11f3e8e044074130aedc7b26bbcb79c4.png)]

(如图)CPU 的上下文切换,是导致原子性问题的核心,而 JVM 里面提供了 Synchronized 关键字来解决原子性问题。
可见性,就是说在多线程环境下,由于读和写是发生在不同的线程里面,有可能出现某个线程对共享变量的修改,对其他线程不是实时可见的。
导致可见性问题的原因有很多,比如 CPU 的高速缓存、CPU 的指令重排序、编译器的指令重排序。
有序性,指的是程序编写的指令顺序和最终 CPU 运行的指令顺序可能出现不一致的现象,这种现象也可以称为指令重排序,所以有序性也会导致可见性问题。
可见性和有序性可以通过 JVM 里面提供了一个 Volatile 关键字来解决。
在我看来,导致有序性、原子性、可见性问题的本质,是计算机工程师为了最大化提升 CPU 利用率导致的。比如为了提升 CPU 利用率,设计了三级缓存、设计了 StoreBuffer、设计了缓存行这种预读机制、在操作系统里面,设计了线程模型、在编译器里面,设计了编译器的深度优化机制。

2.3.2 java保证线程安全的方式有哪些

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-xODWcJYU-1721614050869)(https://i-blog.csdnimg.cn/direct/10f2413a71aa42e285c8ee06660142d0.png)]

2.3.3 如何安全中断一个正在运行的线程

从理论上来说,要在 Java 层面去中断一个正在运行的线程,只能像类似于 Linux 里面的 kill 命令结
束进程的方式一样,强制终止。
Java Thread 的 API 里面虽然提供了一个 stop() 方法,可以强行终止线程,但是这种方式是不安全的,因为有可能线程的任务还没有完成,突然中断会导致出现运行结果不正确的问题。
要想安全地中断一个正在运行的线程,只能在线程内部埋下一个钩子,外部程序 通过这个钩子来触发线程的中断命令。
因此,在 Java Thread 里面提供了一个 interrupt() 方法,这个方法要配合 isInterrupted()方法来
使用,就可以实现安全地中断线程运行。
这种实现方法并不是强制中断,而是告诉正在运行的线程,你可以停止了。何时实际中断,取决于正在运行的线程,所以,它能够保证线程运行结果的安全性。

2.3.4 SimpleDateFormat是线程安全的吗

SimpleDateFormat 不是线程安全的,
SimpleDateFormat 类内部有一个 Calendar 对象引用,
它用来储存和这个 SimpleDateFormat 相关的日期信息。
当我们把 SimpleDateFormat 作为多个线程的共享资源来使用的时候。
意味着多个线程会共享 SimpleDateFormat 里面的 Calendar 引用,
多个线程对于同一个 Calendar 的操作,会出现数据误读现象导致一些不可预料的错误。
在实际应用中,我认为有 4 种方法可以解决这个问题。
 第一种,把 SimpleDateFormat 定义成局部变量,每个线程调用的时候都创建一个新的实例。
 第二种,使用 ThreadLocal 工具,把 SimpleDateFormat 变成线程私有的
 第三种,加同步锁,在同一时刻只允许一个线程操作 SimpleDateFormat
 第四种,在 Java8 里面引入了一些线程安全的日期 API,比如 LocalDateTimer、
DateTimeFormatter 等。

2.3.5 并发线程中,ThreadLocal会照成内存泄漏吗

我认为,不恰当地使用 ThreadLocal,会造成内存泄漏问题。
主要原因是,线程的私有变量 ThreadLocalMap 里面的 key 是一个弱引用。
弱引用的特性,就是不管是否存在直接引用关系,当成员 ThreadLocal 没用其他的强引用关系的时候,这个对象会被 GC 回收掉。
从而导致 key 可能变成 null,造成这块内存永远无法访问,出现内存泄漏的问题。
规避内存泄漏的方法有两个:
 通过扩大成员变量 ThreadLoca 的作用域,避免被 GC 回收
 每次使用完 ThreadLocal 以后,调用 remove 方法移除对应的数据
第一种方法虽然不会造成 key 为 null 的现象,但是如果后续线程不再继续访问这个 key。
也会导致这个内存一直占用不释放,最后造成内存溢出的问题。
所以我认为最好是在使用完以后调用 remove 方法移除

  • 17
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值