并发编程面试题(补充,高频实战面试)

AQS 的理解

第一 : AQS 是AbstractQueuedSynchronizer的简称,抽象同步队列,是并发编程比较核心的组件,是多线程
       同步器,是JUC包下多个组件的底层实现,比如 Lock 闭锁 信号量等

第二 ; 本质讲,AQS提供两种锁机制,一排他,二共享锁
    排他 : 多线程竞争同一个共享资源时,同一时刻只允许一个线程访问该共享资源,最后只有一个线程获得锁
	资源,比如 ReetrantLock重入锁就是用到AQS的排他功能;
	共享锁 : 也称读锁,同一时刻允许多线程同时获得锁资源,比如闭锁信号量都是用AQS的共享锁

第三 : AQS 底层的关键是一个state 和一个双向队列以及CAS算法实现;
	每个节点代表一个线程,节点有两个属性,一是状态state,二是线程thread,前者表示该线程是否拿到锁
        或等待队列有多少线程正在等待,线程属性用来记录该节点对应的线程;
	双向链表存储等待队列的节点,节点都有一个前驱和后继;当一个线程想获取锁时,会建一个节点插入到等待
	队列尾部,需要通过CAS保证原子性插入操作,失败的话,说明有其它线程插入成功,进行重新尝试;当一个
	线程释放锁,它会修改状态并唤醒等待队列中的后继节点,也是CAS操作,因为可能存在多线程同时竞争唤醒
        同一个节点的情况
    

Lock 和 synchronized的区别

第一方面,功能角度,两者都是java中解决线程安全问题的工具

第二方面,特性来看
    a.sync是java的关键字,Lock是JUC包的接口,API,这个接口有很多实现类,其中包括ReentrantLock重入锁
    b.sync可以通过两种方式控制锁粒度
    // 修饰方法
    public synchronized void mehhod(){
    
    }

	Object lock = new Object();
	// 修饰代码块
    public void method(){
    
    
        synchronized(lock){
    
    }
    }
	如上,一种是修饰方法,一种是修饰代码块,另外锁对象是静态对象或类对象,锁是全局锁,整个类;锁的是普通
	实例对象,那么锁的范围就是该实例的生命周期;
	lock锁粒度通过它里面提供的lock()unlock()方法决定,包裹在两方法间的代码能保证线程安全;
	Lock lock = new ReentrantLock();
    public void method(){
    
    
        lock.lock();  // 竞争锁
        // 代码
        lock.unlock(); // 释放锁
    }
	c. lock比sync灵活度更高,Lock可以决定什么时候加锁,什么时候释放锁,两个方法即可,还提供了非阻塞
        的竞争锁方法tryLock(),返回值真假来告诉当前线程是否已经有其它线程正在使用锁
        sync由于是关键字,无法实现非阻塞竞争锁的方法,锁的释放是被动的,必须同步代码块执行完或者异常
        才会释放
    d. lock提供公平锁和非公平锁机制,sync只有非公平锁实现

第三方面,性能来看,差别不大,实现有一些区别,sync引入锁升级优化,而lock用自旋锁方式实现性能优化

image.png
:::success
备注 : 上表第六行有一个错别字, Lock 是可中断类型的锁;
:::

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

第一种 : 线程池内部,工作线程执行run方法,,run方法正常结束,任务执行结束;等run方法返回,可以统计任务
    	的完成数量;

第二种 : 如果在线程池外部去获得内部任务的执行状态,
    	方法一 : isTerminated()方法,可判断线程池运行状态,循环判断,一旦状态是Terminated意味任务
                执行完.前提是程序主动调用线程池shutdown()方法,因此实用性灵活性不够;
    	方法二 ; 在线程池中,有一个 submit()方法,它提供了一个 Future 的返回值,我们通
                过 Future.get()方法来获得任务的执行结果,当线程池中的任务没执行完之前,
                future.get()方法会一直阻塞,直到任务执行结束。因此,只要 future.get()
                方法正常返回,也就意味着传入到线程池中的任务已经执行完成了!
    	方法三 : 可以引入一个 CountDownLatch 计数器,它可以通过初始化指定一个计数器
                进行倒计时,其中有两个方法分别是 await()阻塞线程,以及 countDown()
                进行倒计时,一旦倒计时归零,所以被阻塞在 await()方法的线程都会被释放。
综上 : 不管是线程池内部还是外部,要想知道线程是否执行结束,我们必须要获取线程执行结束后的状态,而线程
    本身没有返回值,所以只能通过阻塞-唤醒的方式来实现,future.get 和 CountDownLatch 都是这样原理。

image.png

阻塞队列的有界和无界

1.阻塞队列,是一种特殊队列,是在普通队列基础上提供两个附加功能:
	a. 当队列为空的时候,获取队列中元素的消费者线程会被阻塞,同时唤醒生产者线程。
	b. 当队列满了的时候,向队列中添加元素的生产者线程被阻塞,同时唤醒消费者线程。
2.其中,阻塞队列中能够容纳的元素个数,通常情况下是有界的,比如我们实例化一
	个 ArrayBlockingList,可以在构造方法中传入一个整形的数字,表示这个基于数
	组的阻塞队列中能够容纳的元素个数。这种就是有界队列。
3. 而无界队列,就是没有设置固定大小的队列,不过它并不是像我们理解的那种元素
	没有任何限制,而是它的元素存储量很大,像 LinkedBlockingQueue,它的默认
	队列长度是 Integer.Max_Value,所以我们感知不到它的长度限制。
4. 无界队列存在比较大的潜在风险,如果在并发量较大的情况下,线程池中可以几乎
	无限制的添加任务,容易导致内存溢出的问题!

ConcurrentHashMap 底层

1. ConcurrentHashMap 的整体架构
     ConcurrentHashMap 在 JDK1.8 中的存储结构,它是由数组、单向链表、红黑树组成。
     当我们初始化一个ConcurrentHashMap实例时,默认会初始化一个长度为16的数组。由于 ConcurrentHashMap 
    它的核心仍然是 hash 表,所以必然会存在 hash 冲突问题。
	ConcurrentHashMap 采用链式寻址法来解决 hash 冲突。
	当 hash 冲突比较多的时候,会造成链表长度较长,这种情况会使得
	ConcurrentHashMap 中数据元素的查询复杂度变成 O( n )。因此在 JDK1.8 中,引入了
	红黑树的机制。
    当数组长度大于 64 并且链表长度大于等于 8 的时候,单项链表就会转换为红黑树。
    另外,随着 ConcurrentHashMap 的动态扩容,一旦链表长度小于 8,红黑树会退化
    成单向链表。
2. ConcurrentHashMap 的基本功能
    ConcurrentHashMap 本质上是一个 HashMap,因此功能和 HashMap 一样,但是
    ConcurrentHashMapHashMap 的基础上,提供了并发安全的实现。
    并发安全的主要实现是通过对指定的 Node 节点加锁,来保证数据更新的安全性
3. ConcurrentHashMap 在性能方面的优化
    如果在并发性能和数据安全性之间做好平衡,在很多地方都有类似的设计,比如 cpu
    的三级缓存、mysql 的 buffer_pool、Synchronized 的锁升级等等。
    ConcurrentHashMap 也做了类似的优化,主要体现在以下几个方面:
     在 JDK1.8 中,ConcurrentHashMap 锁的粒度是数组中的某一个节点,而在
    	JDK1.7,锁定的是 Segment,锁的范围要更大,因此性能上会更低。
     引入红黑树,降低了数据查询的时间复杂度,红黑树的时间复杂度是 O( logn )。
     (如图所示),当数组长度不够时,ConcurrentHashMap 需要对数组进行扩
        容,在扩容的实现上,ConcurrentHashMap 引入了多线程并发扩容的机制,
        简单来说就是多个线程对原始数组进行分片后,每个线程负责一个分片的数据
        迁移,从而提升了扩容过程中数据迁移的效率。
     ConcurrentHashMap 中有一个 size()方法来获取总的元素个数,而在多线程
        并发场景中,在保证原子性的前提下来实现元素个数的累加,性能是非常低的。
        ConcurrentHashMap 在这个方面的优化主要体现在两个点:
         当线程竞争不激烈时,直接采用 CAS 来实现元素个数的原子递增。
   	     如果线程竞争激烈,使用一个数组来维护元素个数,如果要增加总的元素
            个数,则直接从数组中随机选择一个,再通过 CAS 实现原子递增。它的核
            心思想是引入了数组来实现对并发更新的负载

CAS 机制

第一 : CAS 是 JavaUnsafe 类里面的方法,它的全称是 CompareAndSwap,比较并交换的意思。
第二 :     它的主要功能是能够保证在多线程环境下,对于共享变量的修改的原子性。
第三 : 我来举个例子,比如说有这样一个场景,有一个成员变量 state,默认值是 0,定义了一个方法 doSomething(),这个方法的逻辑是,判断 state 是否为 0 ,如果为0,就修改成 1。这个逻辑看起来没有任何问题,但是在多线程环境下,会存在原子性的问题,因为这里是一个典型的,Read - Write 的操作。
		一般情况下,我们会在 doSomething()这个方法上加同步锁来解决原子性问题。
    	但是,加同步锁,会带来性能上的损耗,所以,对于这类场景,我们就可以使用 CAS机制来进行优化
    用unsafe类的compareAndSwapInt()方法,四个参数对象实例,state内存地址偏移量,预期值0,修改后值1
    如果内存值和预期一致,就直接修改内存地址state值为1,否则返回失败,过程是原子,不存在线程安全问题
第四: CAS是一个 native 方法,实际上它最终还是会面临同样的问题,就是先从内存地址中读取 state 的值,然后去比较,最后再修改。
	可能存在原子性问题,因此CAS底层,在多核CPU环境下,增加一个Lock指令对缓存或总线加锁,从而保证比较并替换这两个指令的原子性;

综上 : CAS主要用在并发场景,比如
    1. 第一个是 J.U.C 里面 Atomic 的原子实现,比如 AtomicIntegerAtomicLong2. 第二个是实现多线程对共享资源竞争的互斥性质,比如在 AQS、ConcurrentHashMapConcurrentLinkedQueue 等都有用到。

死锁原因和避免

第一 : 死锁主要是多线程在持有自己资源的同时去申请对方的锁和资源,从而导致相互等待死锁情况
    	死锁,简单来说就是两个或者两个以上的线程在执行的过程中,争夺同一个共享资源造成的相互等待的现象。
    	如果没外部干预,线程会一直阻塞无法执行;

第二 : 原因有 互斥条件,同一个锁和共享资源只能同时被一个线程持有
    	不剥夺条件,除非自己主动释放,否则别人无法剥夺他的线程和锁持有权
    	请求持有,在申请同时不释放自己的资源和锁
    	循环等待;

第三 :出现死锁只能通过人工干预,比如重启服务,或杀掉某一个线程

第四 ; 避免的思路
    	a. 对于“请求和保持”这个条件,我们可以一次性申请所有的资源
        b. 对于“不可抢占”这个条件,占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源
        c. 对于“循环等待”这个条件,可以靠按序申请资源来预防。所谓按序申请,是指资
            源是有线性顺序的,申请的时候可以先申请资源序号小的,再申请资源序号大的,
            这样线性化后自然就不存在循环了。    

wait和notify这个为什么要在synchronized代码块

第一,wait 和 notify 用来实现多线程之间的协调,wait 表示让线程进入到阻塞状态,notify 表示让阻塞的线程唤醒。
第二, wait 和 notify 必然是成对出现的,如果一个线程被 wait()方法阻塞,那么必然需要另外一个线程通过 notify()方法来唤醒这个被阻塞的线程,从而实现多线程之间的通信。
第三,在多线程里面,要实现多个线程之间的通信,除了管道流以外,只能通过
        共享变量的方法来实现,也就是线程 t1 修改共享变量 s,线程 t2 获取修改后的共
        享变量 s,从而完成数据通信。
        但是多线程本身具有并行执行的特性,也就是在同一时刻,多个线程可以同时执行。在
        这种情况下,线程 t2 在访问共享变量 s 之前,必须要知道线程 t1 已经修改过了共享变
        量 s,否则就需要等待。
        同时,线程 t1 修改过了共享变量 S 之后,还需要通知在等待中的线程 t2。
        所以要在这种特性下要去实现线程之间的通信,就必须要有一个竞争条件控制线程在什
        么条件下等待,什么条件下唤醒
第四,Synchronized 同步关键字就可以实现这样一个互斥条件,也就是在通过共享变
        量来实现多个线程通信的场景里面,参与通信的线程必须要竞争到这个共享变量的
        锁资源,才有资格对共享变量做修改,修改完成后就释放锁,那么其他的线程就可
        以再次来竞争同一个共享变量的锁来获取修改后的数据,从而完成线程之前的通信。
第五, 所以这也是为什么 wait/notify 需要放在 Synchronized 同步代码块中的原因,有
		了 Synchronized 同步锁,就可以实现对多个通信线程之间的互斥,实现条件等待和条件唤醒。
第六,另外,为了避免 wait/notify 的错误使用,jdk 强制要求把 wait/notify 写在同步代码块里面,否则会抛出 IllegalMonitorStateException
第七. 最后,基于 wait/notify 的特性,非常适合实现生产者消费者的模型,比如说用wait/notify 来实现连接池就绪前的等待与就绪后的唤醒。

线程安全问题

线程安全问题的具体表现在三个方面,原子性、有序性、可见性

原子性呢,是指当一个线程执行一系列程序指令操作的时候,它应该是不可中断的,因为一旦出现中断,站在多线程的视角来看,这一系列的程序指令会出现前后执行结果不
一致的问题。
这个和数据库里面的原子性是一样的,就是一段程序只能由一个线程完整的执行完成,而不能存在多个线程干扰。
CPU 的上下文切换,是导致原子性问题的核心,而 JVM 里面提供了
Synchronized 关键字来解决原子性问题。

可见性,就是说在多线程环境下,由于读和写是发生在不同的线程里面,有可能出现某
个线程对共享变量的修改,对其他线程不是实时可见的。
导致可见性问题的原因有很多,比如 CPU 的高速缓存、CPU 的指令重排序、编译器的
指令重排序

有序性,指的是程序编写的指令顺序和最终 CPU 运行的指令顺序可能出现不一致的现
象,这种现象也可以称为指令重排序,所以有序性也会导致可见性问题。可见性和有序性可以通过volatile解决

综上 :  导致有序性、原子性、可见性问题的本质,是计算机工程师为了最大化提升
CPU 利用率导致的。比如为了提升 CPU 利用率,设计了三级缓存、设计了 StoreBuffer、
设计了缓存行这种预读机制、在操作系统里面,设计了线程模型、在编译器里面,设计
了编译器的深度优化机制。   

守护线程

守护线程就是一种后台服务线程,他和我们在 Java 里面创建的用户线程是一模一样的。

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

AQS为啥用双向链表

双向链表提供了双向指针,可以在任何一个节点方便向前或向后进行遍历,这种对于有反向遍历需求的场景来说非常有用。

 双向链表可以在任意节点位置实现数据的插入和删除,并且这些操作的时间复杂度都是 O(1),不受链表长度的影响。这对于需要频繁对链表进行增删操作的场景非常有用。
第二个方面,说一下 AQS 采用双向链表的原因
    
 存储在双向链表中的线程,有可能这个线程出现异常不再需要竞争锁,所以需要把
这些异常节点从链表中删除,而删除操作需要找到这个节点的前驱结点,如果不采
用双向链表,就必须要从头节点开始遍历,时间复杂度就变成了 O(n)

新加入到链表中的线程,在进入到阻塞状态之前,需要判断前驱节点的状态,只有
前驱节点是 Sign 状态的时候才会让当前线程阻塞,所以这里也会涉及到前驱节点
的查找,采用双向链表能够更好的提升查找效率

线程在加入到链表中后,会通过自旋的方式去尝试竞争锁来提升性能,在自旋竞争
锁的时候为了保证锁竞争的公平性,需要先判断当前线程所在节点的前驱节点是否
是头节点。这个判断也需要获取当前节点的前驱节点,同样采用双向链表能提高查
找效率

总而言之,采用单向链表不支持双向遍历,而 AQS 中存在很多需要双向遍历的场景来提升线程阻塞和唤醒的效率

volatile 关键字

1. 可以保证在多线程环境下共享变量的可见性。
2. 通过增加内存屏障防止多个指令之间的重排序

可见性,是指当某一个线程对共享变量的修改,其他线程可以立刻看到修改之后的值。
其实这个可见性问题,我认为本质上是由几个方面造成的。
    a.CPU 层面的高速缓存,在 CPU 里面设计了三级缓存去解决 CPU 运算效
    率和内存 IO 效率问题,但是带来的就是缓存的一致性问题,而在多线程并行执行
    的情况下,缓存一致性就会导致可见性问题;
	所以,对于增加了 volatile 关键字修饰的共享变量,JVM 虚拟机会自动增加一个#Lock
	汇编指令,这个指令会根据 CPU 型号自动添加总线锁或/缓存锁
        总线锁是锁定了 CPU 的前端总线,从而导致在同一时刻只能有一个线程去和
		内存通信,这样就避免了多线程并发造成的可见性。
        缓存锁是对总线锁的优化,因为总线锁导致了 CPU 的使用效率大幅度下降,
        所以缓存锁只针对 CPU 三级缓存中的目标数据加锁,缓存锁是使用 MESI 缓
        存一致性来实现的。

指令重排序,所谓重排序,就是指令的编写顺序和执行顺序不一致,在多线程环境
下导致可见性问题。指令重排序本质上是一种性能优化的手段
        CPU 层面,针对 MESI 协议的更进一步优化去提升 CPU 的利用率,引入了
        StoreBuffer 机制,而这一种优化机制会导致 CPU 的乱序执行。当然为了避免
        这样的问题,CPU 提供了内存屏障指令,上层应用可以在合适的地方插入内存
        屏障来避免 CPU 指令重排序问题。
         编译器的优化,编译器在编译的过程中,在不改变单线程语义和程序正确性的
        前提下,对指令进行合理的重排序优化来提升性能。
所以,如果对共享变量增加了 volatile 关键字,那么在编译器层面,就不会去触发编译
器优化,同时再 JVM 里面,会插入内存屏障指令来避免重排序问题。

 除了 volatile 以外,从 JDK5 开始,JMM 就使用了一种 Happens-Before 模型
去描述多线程之间的内存可见性问题。
如果两个操作之间具备 Happens-Before 关系,那么意味着这两个操作具备可见性关
系,不需要再额外去考虑增加 volatile 关键字来提供可见性保障

Happens-Before 的理解

:::success
首先,Happens-Before 是一种可见性模型,也就是说,在多线程环境下。原本因为指令重排序的存在会导致数据的可见性问题,也就是 A 线程修改某个共享变量对 B 线程不可见。
因此,JMM 通过 Happens-Before 关系向开发人员提供跨越线程的内存可见性保证。
如果一个操作的执行结果对另外一个操作可见,那么这两个操作之间必然存在Happens-Before 管理。

其次,Happens-Before 关系只是描述结果的可见性,并不表示指令执行的先后顺序,也就是说,只要不对结果产生影响,仍然允许指令的重排序。

最后,在 JMM 中存在很多的 Happens-Before 规则。

  •  程序顺序规则,一个线程中的每个操作,happens-before 这个线程中的任意后续操作,可以简单认为是 as-if-serial也就是不管怎么重排序,单线程的程序的执行结果不能改变
  •  传递性规则,也就是 A Happens-Before B,B Happens-Before C。推导出 A Happens-Before C。
  •  volatile 变量规则,对一个 volatile 修饰的变量的写一定 happens-before 于任意后续对这个 volatile 变量的读操作
  •  监视器锁规则,一个线程对于一个锁的释放锁操作,一定 happens-before 与后续线程对这个锁的加锁操作
  •  线程启动规则,如果线程 A 执行操作 ThreadB.start(),那么线程 A 的ThreadB.start()之前的操作 happens-before 线程 B 中的任意操作。
  •  join 规则,如果线程 A 执行操作 ThreadB.join()并成功返回,那么线程 B 中的任意操作 happens-before 于线程 A 从 ThreadB.join()操作成功的返回
    :::

ThreadLocal 的实现原理

1. ThreadLocal 是一种线程隔离机制,它提供了多线程环境下对于共享变量访问的安全性。
    
2. 在多线程访问共享变量的场景中,一般的解决办法是对共享变量加锁,从而保证在同一时刻只有一个线程能够对共享变
量进行更新,并且基于 Happens-Before 规则里面的监视器锁规则,又保证了数据修改后对其他线程的可见性。
    
 3. 但是加锁会带来性能的下降,所以 ThreadLocal 用了一种空间换时间的设计思想,
也就是说在每个线程里面,都有一个容器来存储共享变量的副本,然后每个线程只
对自己的变量副本来做更新操作,这样既解决了线程安全问题,又避免了多线程竞争加锁的开销  

4. ThreadLocal 的具体实现原理是,在 Thread 类里面有一个成员变量ThreadLocalMap,它专门来存储当前线程的共享
    变量副本,后续这个线程对于共享变量的操作,都是从这个 ThreadLocalMap 里面进行变更,不会影响全局共享变量的值。

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

1. (如图)阻塞队列(BlockingQueue)是在队列的基础上增加了两个附加操作,
 在队列为空的时候,获取元素的线程会等待队列变为非空。
 当队列满时,存储元素的线程会等待队列可用

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

简述一下伪共享的概念以及如何避免

首先,计算机工程师为了提高 CPU 的利用率,平衡 CPU 和内存之间的速度差异,在CPU 里面设计了三级缓存。
    
CPU 在向内存发起 IO 操作的时候,一次性会读取 64 个字节的数据作为一个缓存行,缓存到 CPU 的高速缓存里面。
在 Java 中一个 long 类型是 8 个字节,意味着一个缓存行可以存储 8long 类型的变量
    
    这个设计是基于空间局部性原理来实现的,也就是说,如果一个存储器的位置被引用,
那么将来它附近的位置也会被引用。
所以缓存行的设计对于 CPU 来说,可以有效的减少和内存的交互次数,从而避免了 CPU
的 IO 等待,以提升 CPU 的利用率。
正是因为这种缓存行的设计,导致如果多个线程修改同一个缓存行里面的多个独立变量
的时候,基于缓存一致性协议,就会无意中影响了彼此的性能,这就是伪共享的问题。
(如图)像这样一种情况,CPU0 上运行的线程想要更新变量 X、CPU1 上的线程想要
更新变量 Y,而 X/Y/Z 都在同一个缓存行里面。
每个线程都需要去竞争缓存行的所有权对变量做更新,基于缓存一致性协议。
一旦运行在某个 CPU 上的线程获得了所有权并执行了修改,就会导致其他 CPU 中的缓存行失效。
这就是伪共享问题的原理。

    因为伪共享会问题导致缓存锁的竞争,所以在并发场景中的程序执行效率一定会收到较大的影响。
这个问题的解决办法有两个:
1. 使用对齐填充,因为一个缓存行大小是 64 个字节,如果读取的目标数据小于 64个字节,可以增加一些无意义的成员变量来填充。
2.Java8 里面,提供了@Contented 注解,它也是通过缓存行填充来解决伪共享
问题的,被@Contented 注解声明的类或者字段,会被加载到独立的缓存行上

image.png

可重入,什么是可重入锁? 它用来解决什么问题

简单来说,就是在运行的某个函数或者代码,因为抢占资源或者中断等原因导致函数或
者代码的运行中断,
等待中断程序执行结束后,重新进入到这个函数或者代码中运行,并且运行结果不会受
到影响,那么这个函数或者代码就是可重入的。

可重入锁,简单来说就是一个线程如果抢占到了互斥锁资源,在锁释放之前再
去竞争同一把锁的时候,不需要等待,只需要记录重入次数。
在多线程并发编程里面,绝大部分锁都是可重入的,比如 SynchronizedReentrantLock 等,但是也有不支持重入的锁,比如 JDK8 里面提供的读写锁
StampedLock。

锁的可重入性,主要解决的问题是避免线程死锁的问题。
因为一个已经获得同步锁 X 的线程,在释放锁 X 之前再去竞争锁 X 的时候,相当于会
出现自己要等待自己释放锁,这很显然是无法成立的。

ReentrantLock 的实现原理

首先,ReentrantLock 是一种可重入的排它锁,主要用来解决多线程对共享资源竞争的问题;

其次,它有几个核心特性:
1. 它支持可重入,也就是获得锁的线程在释放锁之前再次去竞争同一把锁的时候,不需要加锁就可以直接访问。
2. 它支持公平和非公平特性
3. 它提供了阻塞竞争锁和非阻塞竞争锁的两种方法,分别是 lock()tryLock()4. 锁的竞争,ReentrantLock 是通过互斥变量,使用 CAS 机制来实现的。
5. 没有竞争到锁的线程,使用了 AbstractQueuedSynchronizer 这样一个队列同步
器来存储,底层是通过双向链表来实现的。当锁被释放之后,会从 AQS 队列里面
的头部唤醒下一个等待锁的线程。
6. 公平和非公平的特性,主要是体现在竞争锁的时候,是否需要判断 AQS 队列存在等待中的线程。
7. 最后,关于锁的重入特性,在 AQS 里面有一个成员变量来保存当前获得锁的线程,
当同一个线程下次再来竞争锁的时候,就不会去走锁竞争的逻辑,而是直接增加重入次数。

image.png

线程池的理解

动态扩容和缩容思想,线程复用思想,线程回收方法

首先,线程池本质上是一种池化技术,而池化技术是一种资源复用的思想,比较常见的
有连接池、内存池、对象池。

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

中断一个正在运行的线程

Java Thread 里面提供了一个 stop 方法可以强行终止,但是这种方式是不安全
的,因为有可能线程的任务还没有,导致出现运行结果不正确的问题

 要想安全的中断一个正在运行的线程,只能在线程内部埋下一个钩子,外部程序通过这
个钩子来触发线程的中断命令。
因此,在 Java Thread 里面提供了一个 interrupt()方法,这个方法配合
isInterrupted()方法使用,就可以实现安全的中断机制。
    这种实现方法并不是强制中断,而是告诉正在运行的线程,你可以停止了,不过是否要
中断,取决于正在运行的线程,所以它能够保证线程运行结果的安全性

锁升级

 jdk1.6 版本中,synchronized 增加了锁升级的机制,来平衡数据安全性和性能。
简单来说,就是线程去访问 synchronized 同步代码块的时候,synchronized 根据
     线程竞争情况,会先尝试在不加重量级锁的情况下去保证线程安全性。所以引入了偏向
锁和轻量级锁的机制。
     
偏向锁,就是直接把当前锁偏向于某个线程,简单来说就是通过 CAS 修改偏向锁标记,
这种锁适合同一个线程多次去申请同一个锁资源并且没有其他线程竞争的场景。
     
轻量级锁也可以称为自旋锁,基于自适应自旋的机制,通过多次自旋重试去竞争锁。自
旋锁优点在于它避免避免了用户态到内核态的切换带来的性能开销。

Synchronized 引入了锁升级的机制之后,如果有线程去竞争锁:
首先,synchronized 会尝试使用偏向锁的方式去竞争锁资源,如果能够竞争到偏
向锁,表示加锁成功直接返回。如果竞争锁失败,说明当前锁已经偏向了其他线程。
需要将锁升级到轻量级锁,在轻量级锁状态下,竞争锁的线程根据自适应自旋次数
去尝试抢占锁资源,如果在轻量级锁状态下还是没有竞争到锁,
就只能升级到重量级锁,在重量级锁状态下,没有竞争到锁的线程就会被阻塞,线
程状态是 Blocked。
处于锁等待状态的线程需要等待获得锁的线程来触发唤醒。

总的来说, Synchronized 的锁升级的设计思想,在我看来本质上是一种性能和安全性
的平衡,也就是如何在不加锁的情况下能够保证线程安全性

ReentrantLock 是如何实现锁公平和非公平性的

公平,指的是竞争锁资源的线程,严格按照请求顺序来分配锁。
非公平,表示竞争锁资源的线程,允许插队来抢占锁资源。
ReentrantLock 默认采用了非公平锁的策略来实现锁的竞争逻辑

其次,ReentrantLock 内部使用了 AQS 来实现锁资源的竞争,
没有竞争到锁资源的线程,会加入到 AQS 的同步队列里面,这个队列是一个 FIFO 的双向链表。

在这样的一个背景下,公平锁的实现方式就是,线程在竞争锁资源的时候判断 AQS 同步队列里面有没有等待的线程。
如果有,就加入到队列的尾部等待。
而非公平锁的实现方式,就是不管队列里面有没有线程等待,它都会先去尝试抢占锁资源,如果抢不到,再加入到
AQS 同步队列等待;

ReentrantLockSynchronized 默认都是非公平锁的策略,之所以要这么设计,我认为还是考虑到了性能这个方面的原因。
因为一个竞争锁的线程如果按照公平的策略去阻塞等待,同时 AQS 再把等待队列里面的线程唤醒,这里会涉及到内核态
的切换,对性能的影响比较大。
如果是非公平策略,当前线程正好在上一个线程释放锁的临界点抢占到了锁,就意味着这个线程不需要切换到内核态,
虽然对原本应该要被唤醒的线程不公平,但是提升了锁竞争的性能。

CompletableFuture 的理解

CompletableFuture 是 JDK1.8 里面引入的一个基于事件驱动的异步回调类。
简单来说,就是当使用异步线程去执行一个任务的时候,我们希望在任务结束以后触发一个后续的动作。
而 CompletableFuture 就可以实现这个功能;

比如一个批量支付的业务逻辑里,涉及订单查询,支付,发送邮件通知,可以直接使用 CompletableFuture,
把查询订单的逻辑放在一个异步线程池里面去处理。然后基于 CompletableFuture 的事件回调机制的特性,
    可以配置查询订单结束后自动触发支付,支付结束后自动触发邮件通知。
从而极大的提升这个这个业务场景的处理性能!

CompletableFuture 提供了 5 种不同的方式,把多个异步任务组成一个具有先后关系
的处理链,然后基于事件驱动任务链的执行

第一种,thenCombine,把两个任务组合在一起,当两个任务都执行结束以后触发事件回调。

第二种,thenCompose,把两个任务组合在一起,这两个任务串行执行,
也就是第一个任务执行完以后自动触发执行第二个任务。

第三种,thenAccept(如图),第一个任务执行结束后触发第二个任务,
并且第一个任务的执行结果作为第二个任务的参数,这个方法是纯粹接受上一个任务的
结果,不返回新的计算值。

第四种,thenApply,和 thenAccept 一样,但是它有返回值。
第五种,thenRun,就是第一个任务执行完成后触发执行一个实现了Runnable 接口的任务。


最后,我认为,CompletableFuture 弥补了原本 Future 的不足,使得程序可以在非阻塞的状态下完成
    异步的回调机制。

Thread 和 Runnable 的区别

1. Thread 是一个类,Runnable 是接口,因为在 Java 语言里面的继承特性,接口可
以支持多继承,而类只能单一继承

2. Runnable 表示一个线程的顶级接口,Thread 类其实也是实现了 Runnable 接口,我们在使用的时候都需要实现 run 方法

3. 站在面向对象的思想来说,Runnable 相当于一个任务,而 Thread 才是真正处理
的线程,所以我们只需要用 Runnable 去定义一个具体的任务,然后交给 Thread
去处理就可以了,这样达到了松耦合的设计目的。

4. Runnable 接口定义了线程执行任务的标准方法 run
所以,基于这四个点的原因,所以在实际应用中,建议实现 Runnable 接口实现线程的
任务定义,然后使用 Thread 的 start 方法
去启动启动线程并执行 Runnable 这个任务。

ConcurrentHashMap 的 size()方法是线程安全的吗

ConcurrentHashMapsize()方法是非线程安全的。
也就是说,当有线程调用 put 方法在添加元素的时候,其他线程在调用 size()方法获取
的元素个数和实际存储元素个数是不一致的。
原因是 size()方法是一个非同步方法,put()方法和 size()方法并没有实现同步锁。

put()方法的实现逻辑是:在 hash 表上添加或者修改某个元素,然后再对总的元素个数进行累加。
其中,线程的安全性仅仅局限在 hash 表数组粒度的锁同步,避免同一个节点出现数据
竞争带来线程安全问题。
数组元素个数的累加方式用到了两个方案:
 当线程竞争不激烈的时候,直接用 cas 的方式对一个 long 类型的变量做原子递增。
 当线程竞争比较激烈的时候,使用一个 CounterCell 数组,用分而治之的思想减少
多线程竞争,从而实现元素个数的原子累加
size()方法的逻辑就是遍历 CounterCell 数组中的每个 value 值进行累加,再加上
baseCount,汇总得到一个结果。
所以很明显,size()方法得到的数据和真实数据必然是不一致的。
因此从 size()方法本身来看,它的整个计算过程是线程安全的,因为这里用到了 CAS
的方式解决了并发更新问题。
但是站在 ConcurrentHashMap 全局角度来看,put()方法和 size()方法之间的数据是
不一致的,因此也就不是线程安全的。

之所以不像 HashTable 那样,直接在方法级别加同步锁。在我看来有两个考虑点。
1. 直接在 size()方法加锁,就会造成数据写入的并发冲突,对性能造成影响,
当然有些朋友会说可以加读写锁,但是同样会造成 put 方法锁的范围扩大,性能影响极大!
2. ConcurrentHashMap 并发集合中,对于 size()数量的一致性需求并不大,并发集
合更多的是去保证数据存储的安全性。
以上就是我对这个问题的理解

wait 和 sleep 是否会触发锁的释放以及 CPU 资源的释放

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

DCL 单例模式设计为什么需要 volatile 修饰实例对象

 我所理解的 DCL 问题,是在基于双重检查锁设计下的单例模式中,存在不完整对象的问题
而这个不完整对象的本质,是因为指令重排序导致的。

     当我们使用 new 方法时构建一个实例对象的时候,因为 new 这个操作并不是原子的。
所以这段代码最终会被编译成 3 条指令(如图)。
 为对象分配内存空间
 初始化对象
 把实例对象赋值给 instance 引用

  由于这是三个指令并不是原子的(如图)。
按照重排序规则,在不影响单线程执行结果的情况下,两个不存在依赖关系的指令允许
重排序,也就是不一定会按照代码编写顺序来执行。  
     会导致其他线程可能拿到一个不完整的对象,也就是这个 instance
已经分配了引用实例,但是这个实例的初始化指令还没执行。

解决办法就是可以在 instance 这个变量上增加一个 volatile 关键字修饰,
volatile 底层使用了内存屏障机制来避免指令重排序

image.png
image.png

线程池的线程回收

首先,线程池里面分为核心线程和非核心线程。

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

如果一个线程两次调用 start(),会出现什么问题

Java 里面,一个线程只能调用一次 start()方法,第二次调用会抛出
IllegalThreadStateException。
    
一个线程本身是具备一个生命周期的。
在 Java 里面,线程的生命周期包括 6 种状态。
 NEW,线程被创建还没有调用 start 启动
 RUNNABLE,在这个状态下的线程有可能是正在运行,也可能是在就绪队列里面
等待操作系统进行调度分配 CPU 资源。
 BLOCKED,线程处于锁等待状态
 WAITING,表示线程处于条件等待状态,当触发条件后唤醒,比如 wait/notify。
 TIMED_WAIT,和 WAITING 状态相同,只是它多了一个超时条件触发
 TERMINATED,表示线程执行结束
当我们第一次调用 start()方法的时候,线程的状态可能处于终止或者非 NEW 状态下的
其他状态。
再调用一次 start(),相当于让这个正在运行的线程重新运行,不管从线程的安全性角度,
还是从线程本身的执行逻辑,都是不合理的。
因此为了避免这个问题,在线程运行的时候会先判断当前线程的运行状态

Java 官方提供了哪几种线程池,分别有什么特点?

下面我分别说一下每一种线程池以及它的特点。

 newCachedThreadPool, 是一种可以缓存的线程池,它可以用来处理大量短期的突发流量。
它的特点有三个,最大线程数是 Integer.MaxValue,线程存活时间是 60 秒,
阻塞队列用的是 SynchronousQueue,这是一种不存才任何元素的阻塞队列,
也就是每提交一个任务给到线程池,都会分配一个工作线程来处理,由于最大线程数没有限制。
所以它可以处理大量的任务,另外每个工作线程又可以存活 60s,使得这些工作线程可
以缓存起来应对更多任务的处理。

  newFixedThreadPool,是一种固定线程数量的线程池。
它的特点是核心线程和最大线程数量都是一个固定的值
如果任务比较多工作线程处理不过来,就会加入到阻塞队列里面等待。  

 newSingleThreadExecutor,只有一个工作线程的线程池。
并且线程数量无法动态更改,因此可以保证所有的任务都按照 FIFO 的方式顺序执行

 newScheduledThreadPool,具有延迟执行功能的线程池
可以用它来实现定时调度

 newWorkStealingPool,Java8 里面新加入的一个线程池
它内部会构建一个 ForkJoinPool,利用工作窃取的算法并行处理请求

这些线程都是通过工具类 Executors 来构建的,
线程池的最终实现类是 ThreadPoolExecutor

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

线程池里面采用了生产者消费者的模式,来实现线程复用。
生产者消费者模型,其实就是通过一个中间容器来解耦生产者和消费者的任务处理过程。
生产者不断生产任务保存到容器,消费者不断从容器中消费任务。
在线程池里面,因为需要保证工作线程的重复使用,
并且这些线程应该是有任务的时候执行,没任务的时候等待并释放 CPU 资源。
因此(如图),它使用了阻塞队列来实现这样一个需求。
提交任务到线程池里面的线程称为生产者线程,它不断往线程池里面传递任务。
这些任务会保存到线程池的阻塞队列里面。
然后线程池里面的工作线程不断从阻塞队列获取任务去执行。
基于阻塞队列的特性,使得阻塞队列中如果没有任务的时候,这些工作线程就会阻塞等待。
直到又有新的任务进来,这些工作线程再次被唤醒,从而达到线程复用

image.png
:::success
生产者消费者模型的底层实现
不管是基于 wait/notify,还是基于 condition.await/signal
:::

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

首先,阻塞队列本身是符合 FIFO 特性的队列,也就是存储进去的元素符合先进先出的规则。

其次,在阻塞队列里面,使用了 condition 条件等待来维护了两个等待队列(如图),
一个是队列为空的时候存储被阻塞的消费者
 另一个是队列满了的时候存储被阻塞的生产者,并且存储在等待队列里面的线程,都符合 FIFO 的特性 

最后,对于阻塞队列的消费过程,有两种情况。
 第一种,就是阻塞队列里面已经包含了很多任务,这个时候启动多个消费者去消费的时候,
它的有序性保证是通过加锁来实现的,也就是每个消费者线程去阻塞队列获取任务的时候,必须要先获得排他锁。
 第二种,如果有多个消费者线程因为阻塞队列中没有任务而阻塞,这个时候这些线程是按照 FIFO 的顺序存储到
condition 条件等待队列中的。 当阻塞队列中开始有任务要处理的时候,这些被阻塞的消费者线程
会严格按照 FIFO 的顺序来唤醒,从而保证了消费的顺序型。

当任务数超过线程池的核心线程数时,如何让它不进入队列,而是直接启用最大线程数

当我们提交一个任务到线程池的时候,它的工作原理分为四步。
 第一步,预热核心线程
 第二步,把任务添加到阻塞队列
 第三步,如果添加到阻塞队列失败,则创建非核心线程增加处理效率
 第四步,如果非核心线程数达到了阈值,就触发拒绝策略
所以,如果希望这个任务不进入队列,那么只需要去影响第二步的执行逻辑就行了。
Java 中线程池提供的构造方法里面,有一个参数可以修改阻塞队列的类型。
其中,就有一个阻塞队列叫 SynchronousQueue(如图), 这个队列不能存储任何元素。
它的特性是,每生产一个任务,就必须要指派一个消费者来处理,否则就会阻塞生产者。
    基于这个特性,只要把线程池的阻塞队列替换成 SynchronousQueue。
就能够避免任务进入到阻塞队列,而是直接启动最大线程数去处理这个任务

image.png

扫描二维码关注公众号,回复: 15327818 查看本文章

SimpleDateFormat 是线程安全的吗

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

并行和并发有什么区别

并行,是指在多核 CPU 架构下,同一时刻同时可以执行多个线程的能力。
在单核 CPU 架构中,同一时刻只能运行一个线程。
在 44 线程的 CPU 架构中,同一时刻可以运行 4 个线程,那这 4 个线程就是并行执
行的。
并发,是指在同一时刻 CPU 能够处理的任务数量,也可以理解成 CPU 的并发能力。
在单核 CPU 架构中,操作系统通过 CPU 时间片机制提升 CPU 的并发能力
在多核 CPU 架构中,基于任务的并行执行能力以及 CPU 时间片切换的能力来提升 CPU
的并发能力
  所以,总的来说,并发是一个宏观概念,它指的是 CPU 能够承载的压力大小,
并行是一个微观概念,它描述 CPU 同时执行多个任务的能力。  

ConcurrentHashMap 中 key 不允许为 null

简单来说,就是为了避免在多线程环境下出现歧义问题。
所谓歧义问题,就是如果key或者value为null,当我们通过get(key)获取对应的value
的时候,如果返回的结果是 null
我们没办法判断,它是 put(k,v)的时候,value 本身为 null 值,还是这个 key 本身就不存在。

image.png

ThreadLocal 会出现内存泄漏

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

猜你喜欢

转载自blog.csdn.net/Kaka_csdn14/article/details/130998602