目录
一、什么是守护线程,它有什么特点
二、谈谈你对AQS的理解
三、AbstractQueuedSynchronized为什么采用双向链表
四、lock和synchronized区别
五、线程池如何知道一个线程的任务已经执行完成
六、什么叫做阻塞队列的有界和无界
七、ConcurrentHashMap底层具体实现知道吗?实现原理是什么?
ConcurrentHashMap 的整体架构
ConcurrentHashMap的基本功能
ConcurrentHashMap 在性能方面做的优化
八、谈一下CAS机制
九、死锁的发生原因和怎么避免
十、volatile关键字有什么用?它的实现原理是什么?
一、什么是守护线程,它有什么特点
下面用最简单的方式让大家彻底搞懂守护线程。
简单来说,守护线程就是一种后台服务线程,他和我们在Java里面创建的用户线程是一模一样的。
守护线程和用户线程的区别有几个点,这几个点也是守护线程本身的特性:
1.在线程创建方面,对于守护线程,我们需要主动调用setDaemon()并且设置成true。
2.我们知道,一个Java进程中,只要有任何一个用户线程还在运行,那么这个java进程就不会结束,否则,这个程序才会终止。
注意,Java进程的终止与否,只和用户线程有关。如果当前还有守护线程正在运行,也不会阻止Java程序的终止。
因此,守护线程的生命周期依赖于用户线程。
举个例子,JVM垃圾回收线程就是一个典型的守护线程,它存在的意义是不断的处理用户线程运行过程中产生的内存垃圾。
一旦用户线程全部结束了,那垃圾回收线程也就没有存在的意义了。
由于守护线程的特性,所以它它适合用在一些后台的通用服务场景里面。
但是守护线程不能用在线程池或者一些IO任务的场景里面,因为一旦JVM退出之后,守护线程也会直接退出。
就会可能导致任务没有执行完或者资源没有正确释放的问题。
二、谈谈你对AQS的理解
AQS是多线程同步器,它是J.U.C包中多个组件的底层实现,如Lock、CountDownLatch、Semaphore等都用到了AQS.
从本质上来说,AQS提供了两种锁机制,分别是排它锁和共享锁。
排它锁,就是存在多线程竞争同一共享资源时,同一时刻只允许一个线程访问该共享资源,也就是多个线程中只能有一个线程获得锁资源,比如Lock中的ReentrantLock重入锁实现就是用到了AQS中的排它锁功能。
共享锁也称为读锁,就是在同一时刻允许多个线程同时获得锁资源,比如CountDownLatch和Semaphore都是用到了AQS中的共享锁功能。
三、AbstractQueuedSynchronized为什么采用双向链表
下面从两个方面给大家解释一下这个问题:
第一个方面,双向链表的优势:
1、双向链表提供了双向指针,可以在任何一个节点方便向前或向后进行遍历,这种对于有反向遍历需求的场景来说非常有用。
2、双向链表可以在任意节点位置实现数据的插入和删除,并且这些操作的时间复杂度都是O(1),不受链表长度的影响。这对于需要频繁对链表进行增删操作的场景非常有用。
第二个方面,说一下AQS采用双向链表的原因:
1、存储在双向链表中的线程,有可能这个线程出现异常不再需要竞争锁,所以需要把这些异常节点从链表中删除,而删除操作需要找到这个节点的前驱结点,如果不采用双向链表,就必须要从头节点开始遍历,时间复杂度就变成了O(n)。
2、新加入到链表中的线程,在进入到阻塞状态之前,需要判断前驱节点的状态,只有前驱节点是Sign状态的时候才会让当前线程阻塞,所以这里也会涉及到前驱节点的查找,采用双向链表能够更好的提升查找效率。
3、线程在加入到链表中后,会通过自旋的方式去尝试竞争锁来提升性能,在自旋竞争锁的时候为了保证锁竞争的公平性,需要先判断当前线程所在节点的前驱节点是否是头节点。这个判断也需要获取当前节点的前驱节点,同样采用双向链表能提高查找效率。
总而言之,采用单向链表不支持双向遍历,而AQS中存在很多需要双向遍历的场景来,提升线程阻塞和唤醒的效率。
四、lock和synchronized区别
下面从4个方面来回答:
1.从功能角度来看:Lock和Synchronized都是Java中用来解决线程安全问题的工具。
2.从特性来看:
a.Synchronized是Java中的同步关键字,Lock是J.U.C包中提供的接口,这个接口有很多实现类,其中就包括ReentrantLock重入锁。
b.Synchronized可以通过两种方式来控制锁的粒度,如图:
一种是把synchronized关键字修饰在方法层面,另一种是修饰在代码块上,并且我们可以通过Synchronized加锁对象的声明周期来控制锁的作用范围,比如锁对象是静态对象或者类对象,那么这个锁就是全局锁。
如果锁对象是普通实例对象,那这个锁的范围取决于这个实例的声明周期。
Lock锁的粒度是通过它里面提供的lock()和unlock()方法决定的(如图),包裹在这两个方法之间的代码能够保证线程安全性。而锁的作用域取决于Lock实例的生命周期。
c.Lock比Synchronized的灵活性更高,Lock可以自主决定什么时候加锁,什么时候释放锁,只需要调用lock()和unlock()这两个方法就行,同时Lock还提供了非阻塞的竞争锁方法tryLock()方法,这个方法通过返回true/false来告诉当前线程是否已经有其他线程正在使用锁。
Synchronized由于是关键字,所以它无法实现非阻塞竞争锁的方法,另外,Synchronized锁的释放是被动的,就是当Synchronized同步代码块执行完以后或者代码出现异常时才会释放。
d.Lock提供了公平锁和非公平锁的机制,公平锁是指线程竞争锁资源时,如果已经有其他线程正在排队等待锁释放,那么当前竞争锁资源的线程无法插队。而非公平锁,就是不管是否有线程在排队等待锁,它都会尝试去竞争一次锁。Synchronized只提供了一种非公平锁的实现。
3.从性能方面来看,Synchronized和Lock在性能方面相差不大,在实现上会有一些区别,Synchronized引入了偏向锁、轻量级锁、重量级锁以及锁升级的方式来优化加锁的性能,而Lock中则用到了自旋锁的方式来实现性能优化。
五、线程池如何知道一个线程的任务已经执行完成
从两个方面来回答:
在线程池内部,当我们把一个任务丢给线程池去执行,线程池会调度工作线程来执行这个任务的run方法,run方法正常结束,也就意味着任务完成了。
所以线程池中的工作线程是通过同步调用任务的run()方法并且等待run方法返回后, 再去统计任务的完成数量。
1. 如果想在线程池外部去获得线程池内部任务的执行状态 ,有几种方法可以实现。
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()方法的线程。
2. 基于这个问题,简单总结一下,不管是线程池内部还是外部,要想知道线程是否执行结束,我们必须要获取线程执行结束后的状态,而线程本身没有返回值,所以只能通过阻塞-唤醒的方式来实现,future.get和CountDownLatch都是这样一个原理。
六、什么叫做阻塞队列的有界和无界
- 阻塞队列,是一种特殊的队列 ,它在普通队列的基础上提供了两个附加功能:
a. 当队列为空的时候,获取队列中元素的消费者线程会被阻塞 ,同时唤醒生产者 线程。
b. 当队列满了的时候 ,向队列中添加元素的生产者线程被阻塞 ,同时唤醒消费者 线程。
2. 其中,阻塞队列中能够容纳的元素个数,通常情况下是有界的,比如我们实例化一个ArrayBlockingList,可以在构造方法中传入一个整形的数字,表示这个基于数组的阻塞队列中能够容纳的元素个数。这种就是有界队列。
3. 而无界队列,就是没有设置固定大小的队列,不过它并不是像我们理解的那种元素没有任何限制,而是它的元素存储量很大,像LinkedBlockingQueue,它的默认队列长度是Integer.Max_Value,所以我们感知不到它的长度限制。
4. 无界队列存在比较大的潜在风险,如果在并发量较大的情况下,线程池中可以几乎无限制的添加任务,容易导致内存溢出的问题!
七、ConcurrentHashMap底层具体实现知道吗?实现原理是什么?
这个问题从这三个方面来回答:
1. ConcurrentHashMap 的整体架构
2. ConcurrentHashMap 的基本功能
3. ConcurrentHashMap 在性能方面的优化
ConcurrentHashMap 的整体架构
这个是ConcurrentHashMap在JDK1.8中的存储结构,它是由数组、单向链表、红黑树组成。
当我们初始化一个ConcurrentHashMap实例时,默认会初始化一个长度为16的数组。由于ConcurrentHashMap它的核心仍然是hash表,所以必然会存在hash冲突问题。ConcurrentHashMap采用链式寻址法来解决hash冲突。
当hash冲突比较多的时候,会造成链表长度较长,这种情况会使得ConcurrentHashMap中数据元素的查询复杂度变成O(n)。因此在JDK1.8中,引入了红黑树的机制。
当数组长度大于64并且链表长度大于等于8的时候,单项链表就会转换为红黑树。另外,随着ConcurrentHashMap的动态扩容,一旦链表长度小于8,红黑树会退化成单向链表。
ConcurrentHashMap的基本功能
ConcurrentHashMap本质上是一个HashMap,因此功能和HashMap一样,但是ConcurrentHashMap在HashMap的基础上,提供了并发安全的实现。
并发安全的主要实现是通过对指定的Node节点加锁,来保证数据更新的安全性。
ConcurrentHashMap 在性能方面做的优化
如果在并发性能和数据安全性之间做好平衡,在很多地方都有类似的设计,比如cpu的三级缓存、mysql的buffer_pool、Synchronized的锁升级等等。ConcurrentHashMap也做了类似的优化,主要体现在以下几个方面:
1、 在JDK1.8中,ConcurrentHashMap锁的粒度是数组中的某一个节点,而在JDK1.7,锁定的是Segment,锁的范围要更大,因此性能上会更低。
2、引入红黑树,降低了数据查询的时间复杂度,红黑树的时间复杂度是O(logn)。
3、(如图所示),当数组长度不够时,ConcurrentHashMap需要对数组进行扩容,在扩容的实现上,ConcurrentHashMap引入了多线程并发扩容的机制,简单来说就是多个线程对原始数组进行分片后,每个线程负责一个分片的数据迁移,从而提升了扩容过程中数据迁移的效率。
4、ConcurrentHashMap中有一个size()方法来获取总的元素个数,而在多线程并发场景中,在保证原子性的前提下来实现元素个数的累加,性能是非常低的。ConcurrentHashMap在这个方面的优化主要体现在两个点:
a.当线程竞争不激烈时,直接采用CAS来实现元素个数的原子递增。
b.如果线程竞争激烈,使用一个数组来维护元素个数,如果要增加总的元素个数,则直接从数组中随机选择一个,再通过CAS实现原子递增。它的核心思想是引入了数组来实现对并发更新的负载。
八、谈一下CAS机制
CAS是Java中Unsafe类里面的方法,它的全称是CompareAndSwap,比较并交换的意思。它的主要功能是能够保证在多线程环境下,对于共享变量的修改的原子性。
举个例子:比如说有这样一个场景(如图),有一个成员变量state,默认值是0,定义了一个方法doSomething(),这个方法的逻辑是判断state是否为0,如果为0就修改成1。
这个逻辑看起来没有任何问题,但是在多线程环境下,会存在原子性的问题,因为这里是一个典型的,Read-Write的操作。
一般情况下,我们会在doSomething()这个方法上加同步锁来解决原子性问题。
但是,加同步锁,会带来性能上的损耗,所以,对于这类场景,我们就可以使用CAS机制来进行优化,这个是优化之后的代码(如图)
在doSomething()方法中,我们调用了unsafe类中的compareAndSwapInt()方法来达到同样的目的,这个方法有四个参数,分别是:当前对象实例、成员变量state在内存地址中的偏移量、预期值0、期望更改之后的值1。
CAS机制会比较state内存地址偏移量对应的值和传入的预期值0是否相等,如果相等,就直接修改内存地址中state的值为1。否则,返回false,表示修改失败,而这个过程是原子的,不会存在线程安全问题。
CompareAndSwap是一个native方法,实际上它最终还是会面临同样的问题,就是先从内存地址中读取state的值,然后去比较,最后再修改。
这个过程不管是在什么层面上实现,都会存在原子性问题。
所以呢,CompareAndSwap的底层实现中,在多核CPU环境下,会增加一个Lock指令对缓存或者总线加锁,从而保证比较并替换这两个指令的原子性。
CAS主要用在并发场景中,比较典型的使用场景有两个:
1. 第一个是J.U.C里面Atomic的原子实现,比如AtomicInteger,AtomicLong。
2. 第二个是实现多线程对共享资源竞争的互斥性质,比如在AQS、ConcurrentHashMap、ConcurrentLinkedQueue等都有用到。
九、死锁的发生原因和怎么避免
(如图),死锁,简单来说就是两个或者两个以上的线程在执行的过程中,争夺同一个共享资源造成的相互等待的现象。
如果没有外部干预,线程会一直阻塞无法往下执行,这些一直处于相互等待资源的线程就称为死锁线程。
导致死锁的条件有四个,也就是这四个条件同时满足就会产生死锁:
1、 互斥条件,共享资源X和Y只能被一个线程占用;
2、请求和保持条件,线程T1已经取得共享资源X,在等待共享资源Y的时候,不释放共享资源X;
3、不可抢占条件,其他线程不能强行抢占线程T1占有的资源;
4、循环等待条件,线程T1等待线程T2占有的资源,线程T2等待线程T1占有的资源,就是循环等待。
导致死锁之后,只能通过人工干预来解决,比如重启服务,或者杀掉某个线程。
所以,只能在写代码的时候,去规避可能出现的死锁问题。
按照死锁发生的四个条件,只需要破坏其中的任何一个,就可以解决,但是,互斥条件是没办法破坏的,因为这是互斥锁的基本约束,其他三方条件都有办法来破坏:
1、对于“请求和保持”这个条件,我们可以一次性申请所有的资源,这样就不存在等待了。
2、对于“不可抢占”这个条件,占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源,这样不可抢占这个条件就破坏掉了。
3、对于“循环等待”这个条件,可以靠按序申请资源来预防。所谓按序申请,是指资源是有线性顺序的,申请的时候可以先申请资源序号小的,再申请资源序号大的,这样线性化后自然就不存在循环了。
十、volatile关键字有什么用?它的实现原理是什么?
volatile关键字有两个作用:
1. 可以保证在多线程环境下共享变量的可见性。
2. 通过增加内存屏障防止多个指令之间的重排序。
可见性:指当某一个线程对共享变量的修改,其他线程可以立刻看到修改之后的值。其实这个可见性问题,本质上是由几个方面造成的:
3. (如图)CPU层面的高速缓存,在CPU里面设计了三级缓存去解决CPU运算效率和内存IO效率问题,但是带来的就是缓存的一致性问题,而在多线程并行执行的情况下,缓存一致性就会导致可见性问题。
所以,对于增加了volatile关键字修饰的共享变量,JVM虚拟机会自动增加一个#Lock汇编指令,这个指令会根据CPU型号自动添加总线锁或/缓存锁。
简单说一下这两种锁:
a. 总线锁是锁定了CPU的前端总线,从而导致在同一时刻只能有一个线程去和内存通信,这样就避免了多线程并发造成的可见性。
b. 缓存锁是对总线锁的优化,因为总线锁导致了CPU的使用效率大幅度下降,所以缓存锁只针对CPU三级缓存中的目标数据加锁,缓存锁是使用MESI缓存一致性来实现的。
4. 指令重排序,所谓重排序,就是指令的编写顺序和执行顺序不一致,在多线程环境下导致可见性问题。指令重排序本质上是一种性能优化的手段,它来自于几个方面:
a. CPU层面,针对MESI协议的更进一步优化去提升CPU的利用率,引入了StoreBuffer机制,而这一种优化机制会导致CPU的乱序执行。当然为了避免这样的问题,CPU提供了内存屏障指令,上层应用可以在合适的地方插入内存屏障来避免CPU指令重排序问题。
b. 编译器的优化,编译器在编译的过程中,在不改变单线程语义和程序正确性的前提下,对指令进行合理的重排序优化来提升性能。
所以,如果对共享变量增加了volatile关键字,那么在编译器层面,就不会去触发编译器优化,同时再JVM里面,会插入内存屏障指令来避免重排序问题。
当然,除了volatile以外,从JDK5开始,JMM就使用了一种Happens-Before模型去描述多线程之间的内存可见性问题。
如果两个操作之间具备Happens-Before关系,那么意味着这两个操作具备可见性关系,不需要再额外去考虑增加volatile关键字来提供可见性保障。