大厂面试题-Java并发编程基础篇(三)

目录

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

二、请谈谈AQS是怎么回事儿

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

四、CompletableFuture的理解

五、线程状态,BLOCKED和WAITING有什么区别

六、Thread和Runnable的区别

七、AQS为什么要使用双向链表?

八、ConcurrentHashMap的size()方法是线程安全的吗?为什么?

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

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


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

守护线程,它是一种专门为用户线程提供服务的线程,它的生命周期依赖于用户线程。

只有JVM中仍然还存在用户线程正在运行的情况下,守护线程才会有存在的意义。

,一旦JVM进程结束,那守护线程也会随之结束。

也就是说,守护线程不会阻止JVM的退出。但是用户线程会!

守护线程和用户线程的创建方式是完全相同的,我们只需要调用用户线程里面的setDaemon方法并且设置成true,就表示这个线程是守护线程。

为守护线程拥有自己结束自己生命的特性,所以它适合用在一些后台的通用服务场景里面比如JVM面的垃圾回收线程,就是典型的使用场景。

个场景的特殊之处在于,当JVM进程技术的时候,内存回收线程存在的意义也就不存在了

所以不能因为正在进行垃圾回收导致JVM进程无法技术的问题。

但是守护线程不能用在线程池或者一些IO任务的场景里面,因为一旦JVM退出之后,守护线程也会直接退出。

就会可能导致任务没有执行完或者资源没有正确释放的问题。

二、请谈谈AQS是怎么回事儿

这个问题从几个方面来回答

AQS它是J.U.C这个包里面非常核心的一个抽象类,它为多线程访问共享资源提供了一个队列同步器。

在J.U.C这个包里面,很多组件都依赖AQS实现线程的同步和唤醒,比如LockSemaphore、CountDownLatch等等。

(如图),AQS内部由两个核心部分组成:

    1、一个volatile修饰的state变量,作为一个竞态条件

    2、用双向链表结构维护的FIFO线程等待队列

它的具体工作原理是,多个线程通过对这个state共享变量进行修改来实现竞态条件,竞争失败的线程加入到FIFO队列并且阻塞,抢占到竞态资源的线程释放之后,后续的线程按照FIFO顺序实现有序唤醒。

AQS里面提供了两种资源共享方式,一种是独占资源,同一个时刻只能有一个线程获得竞态资源。比如ReentrantLock就是使用这种方式实现排他锁

另一种是共享资源,同一个时刻,多个线程可以同时获得竞态资源。CountDownLatch或者Semaphore就是使用共享资源的方式,实现同时唤醒多个线程。

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

解释一下个公平和非公平的概念

公平指的是竞争锁资源的线程,严格按照请求顺序来分配锁。

非公平示竞争锁资源的线程,允许插队来抢占锁资源。

ReentrantLock默认采用了非公平锁的策略来实现锁的竞争逻辑。

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

在这样的一个背景,公平锁的实现方式就是,线程在竞争锁资源的时候判断AQS同步队列里面有没有等待的线程。

如果有,就加入到队列的尾部等待。

而非公平锁的实现方式,就是不管队列里面有没有线程等待,它都会先去尝试抢占锁资源,如果抢不到,再加入到AQS同步队列等待。

ReentrantLockSynchronized默认都是非公平锁策略,之所以要这么设计,还是考虑到了性能这个方面的原因。

因为一个竞争锁的线程如果按照公平的策略去阻塞等待,同时AQS再把等待队列里面的线程唤醒,这里会涉及到内核态的切换,对性能的影响比较大。

如果是非公平策略,当前线程正好在上一个线程释放锁的临界点抢占到了锁,就意味着这个线程不需要切换到内核态,虽然对原本应该要被唤醒的线程不公平,但是提升了锁竞争的性能。

四、CompletableFuture的理解

CompletableFutureJDK1.8里面引入的一个基于事件驱动的异步回调类。

简单来说,就是当使用异步线程去执行一个任务的时候,我们希望在任务结束以后触发一个后续的动作而CompletableFuture就可以实现这个功能。

(如图),举个简单的例子,比如在一个批量支付的业务逻辑里面,涉及到查询订单、支付、发送邮件通知这三个逻辑。

这三个逻辑是按照顺序同步去实现的,也就是先查询到订单以后,再针对这个订单发起支付,支付成功以后再发送邮件通知

而这种设计方式导致这个方法的执行性能比较慢。

所以,这里可以直接使用CompletableFuture,(如图),也就是说把查询订单的逻辑放在一个异步线程池里面去处理。

然后基于CompletableFuture的事件回调机制的特性,可以配置查询订单结束后自动触发付,支付结束后自动触发邮件通知。

而极大的提升这个这个业务场景的处理性能!

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

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

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

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

    第四种,thenApply(如图),和thenAccept一样,但是它有返回值。

    第五种,thenRun(如图),就是第一个任务执行完成后触发执行一个实现了Runnable接口的任务。

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

五、线程状态,BLOCKED和WAITING有什么区别

BLOCKED和WAITING都是属于线程的阻塞等待状态。

BLOCKED状态是指线程在等待监视器锁的时候的阻塞状态。

(如图)也就是在多个线程去竞争Synchronized同步锁的时候,没有竞争到锁资源的线程,会被阻塞等待,这个时候线程状态就是BLOCKED。

在线程的整个生命周期里面,只有Synchronized同步锁等待才会存在这个状态。

WAITING状态,表示线程的等待状态,在这种状态下,线程需要等待某个线程的特定操作才会被唤醒。们可以使用Object.wait()、Object.join()、LockSupport.park()这些方法

使得线程进入到WAITING状态,在这个状态下,必须要等待特定的方法来唤醒,比如Object.notify方法可以唤醒Object.wait()方法阻塞的线程LockSupport.unpark()可以唤醒LockSupport.park()方法阻塞的线程。

所以BLOCKED和WAITING两个状态最大的区别有两个:

  1. BLOCKED是锁竞争失败后被被动触发的状态,WAITING是人为的主动触发的状态
  2. BLCKED的唤醒时自动触发的,而WAITING状态是必须要通过特定的方法来主动唤醒

六、ThreadRunnable的区别

Thread和Runnable接口的区别有四个

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

所以如果在已经存在继承关系的类里面要实现线程的话,只能实现Runnable接口。

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

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

4.接口表示一种规范或者标准,而实现类表示对这个规范或者标准的实现,所以站在线程的角度来说,Thread才是真正意义上的线程实现。

Runnable表示线程要执行的任务,因此在线程池里面,提交一个任务传递的类型是Runnable

总的来说,Thread只是实现了Runnable接口并做了扩展,所以这两者没什么可比性。

七、AQS为什么要使用双向链表

首先,双向链表的特点是它有两个指针一个指针指向前置节点,一个指针指向后继节点。

所以,双向链表可以支持常量O(1)时间复杂度的情况下找到前驱结点,基于这样的点。

双向链表在插入和删除操作的时候,要比单向链表简单、高效。

因此,从双向链表的特性来看,AQS使用双向链表有三个方面的考虑

第一个方面没有竞争到锁的线程加入到阻塞队列,并且阻塞等待的前提是,当前线程所在节点的前置节点是正常状态,

这样设计是为了避免链表中存在异常线程导致无法唤醒后续线程的问题。

(如图)所以线程阻塞之前需要判断前置节点的状态,如果没有指针指向前置节点,就需要从head节点开始遍历,性能非常低。

第二个方面在Lock接口里面有一个,lockInterruptibly()方法,这个方法表示处于锁阻塞的线程允许被中断。

也就是说,没有竞争到锁的线程加入到同步队列等待以后,是允许外部线程通过interrupt()方法触发唤醒并中断的。

这个时候,被中断的线程的状态会修改成CANCELLED。

(如图)被标记为CANCELLED状态的线程是不需要去竞争锁的,但是它仍然存在于双向链表里面。

意味着在后续的锁竞争中,需要把这个节点从链表里面移除,否则会导致锁阻塞的线程无法被正常唤醒。

在这种况下,如果是单向链表,就需要从Head节点开始往下逐个遍历,找到并移除异常状态的节点。

同样效率也比较低,还会导致锁唤醒的操作和遍历操作之间的竞争。

第三个方面为了避免线程阻塞和唤醒的开销,所以刚加入到链表的线程,首先会通过自旋的方式尝试去竞争锁。

但是实际上按照公平锁的设计,只有头节点的下一个节点才有必要去竞争锁,后续的节点竞争锁的意义不大。

否则,就会造成羊群效应,也就是大量的线程在阻塞之前尝试去竞争锁带来比较大的性能开销。

所以,(图)为了避免这个问题,加入到链表中的节点在尝试竞争锁之前,需要判断前置节点是不是头节点,如果不是头节点,就没必要再去触发锁竞争的动作。

所以这会涉及到前置节点的查找,如果是单向链表,那么这个功能的实现会非常复杂。

八、ConcurrentHashMapsize()方法是线程安全的吗?为么?

ConcurrentHashMapsize()方法是非线程安全的。

也就是说,当有线程调用put方法在添加元素的时候,其他线程在调用size()方法获取元素个数实际存储元素个数是不一致的。

原因是size()方法是一个非同步方法,put()方法和size()方法并没有实现同步锁。

put()方法的实现逻辑:在hash表上添加或者修改某个元素,然后再对总的元素个数进行累加。

其中,线程的安全性仅仅局限在hash表数组粒度的锁同步,避免同一个节点出现数据竞争带来线程安全问题。

(如图)数组元素个数的累加方式用到了两个方案:

  1. 当线程竞争不激烈的时候,直接用cas的方式对一个long类型的变量做原子递增。
  2. 当线程竞争比较激烈的时候,使用一个CounterCell数组,用分而治之的思想减少多线竞争,从而实现元素个数的原子累加。

size()方法的逻辑就是遍历CounterCell数组中的每个value值进行累加,再加上baseCount汇总得到一个结果。

以很明显,size()方法得到的数据和真实数据必然是不一致的。

因此从size()方法本身来,它的整个计算过程是线程安全的,因为这里用到了CAS的方式解决了并发更新问题。

但是站在ConcurrentHashMap全局角度来看,put()方法和size()方法之间的数据是不一致的,因此也就不是线程安全的。

之所以不像HashTable那样,直接在方法级别加同步锁。有两个考虑点

  1. 直接在size()方法加锁,就会造成数据写入的并发冲突,对性能造成影响,当然有些朋友会说可以加读写锁,但是同样会造成put方法锁的范围扩大,性能影响极大!
  2. ConcurrentHashMap并发集合中,对于size()数量的一致性需求并不大,并发集合更多的是去保证数据存储的安全性。

九、waitsleep是否会触发锁的释放以及CPU资源的释放?

Object.wait()方法,会释放锁资源以及CPU资源。

Thread.sleep()方法,不会释放锁资源,但是会释放CPU资源。

首先,wait()方法是让一个线程进入到阻塞状态,而这个方法必须要写在一个Synchronized同步代码块里面。

因为wait/notify是基于共享内存来实现线程通信的工具,这个通信涉及到条件的竞争,以在调用这两个方法之前必须要竞争锁资源。

当线程调用wait方法的时候,表示当前线程的工作处理完了,意味着让其他竞争同一个共享资源的线程有机会去执行。

但前提是其他线程需要竞争到锁资源,所以wait方法必须要释放锁,否则就会导致死锁的问题。

,Thread.sleep()方法,只是让一个线程单纯进入睡眠状态,这个方法并没有强制要求加synchronized同步锁。

从它的功能和语义来说,也没有这个必要。

当然,果是在一个Synchronized同步代码块里面调用这个Thread.sleep,也并不触发锁的释放。

最后,凡是让线程进入阻塞状态的方法,操作系统都会重新调度实现CPU时间片切换,样设计的目的是提升CPU的利用率。

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

DCL问题,是在基于双重检查锁设计下的单例模式中,存在不完整对象的问题。

而这个不完整对象的本质,是因为指令重排序导致的。

当我们使用instance=new DCLExample()构建一个实例对象的时候,因为new这个操作并不是原子的。

所以这段代码最终会被编译成3条指令(如图)。

    1、为对象分配内存空间

    2、初始化对象

    3、把实例对象赋值给instance引用

由于这是三个指令并不是原子的(如图)。

按照重排序规则,在不影响单线程执行结果的情况下,两个不存在依赖关系的指令允许序,也就是不一定会按照代码编写顺序来执行。

这样一来,(如图) 会导致其他线程可能拿到一个不完整的对象,也就是这个instance已经分配了引用实例,但是这个实例的初始化指令还没执行。

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值