Lock框架和Tools类
Locksupport类
LockSupport的核心函数都是基于Unsafe类中定义的park和unpark函数。
原理:直接调用unsafe类,然后拿到Thread.class对应字段的内存偏移地址。然后直接通过修改内存偏移地址,来修改Thread.class对应字段的值。
- Thread.sleep()和Object.wait()的区别:Thread.sleep()不会释放锁资源,Object.wait()会释放锁资源。
- Object.wait()和Condition.await()的区别:原理基本一致,Condition.await()底层是调用LockSupport.park()来实现阻塞当前线程。
- Thread.sleep()和LockSupport.park()的区别:都是阻塞当前线程的执行,且都不会释放当前线程占有的锁资源。Thread.sleep()没法从外部唤醒,只能自己醒过来;LockSupport.park()方法可以被另一个线程调用LockSupport.unpark()方法唤醒;
- Object.wait()和LockSupport.park()的区别:Object.wait()不带超时的,需要另一个线程执行notify()来唤醒,但不一定继续执行后续内容;LockSupport.park()不带超时的,需要另一个线程执行unpark()来唤醒,一定会继续执行后续内容;
park()/unpark()底层的原理是“二元信号量”,你可以把它相像成只有一个许可证的Semaphore,只不过这个信号量在重复执行unpark()的时候也不会再增加许可证,最多只有一个许可证。
如果在park()之前执行了unpark()会怎样?线程不会被阻塞,直接跳过park(),继续执行后续内容。底层是二元信号量嘛,执行了unpark就会增加上许可证,不过重复执行时只有一个。
LockSupport.park()会释放锁资源吗?不会,它只负责阻塞当前线程,释放锁资源实际上是在Condition的await()方法中实现的。
AQS
AQS是一个用来构建锁和同步器的框架。
可实现:ReentrantLock、Semaphore、CountDownLatch、 CyclicBarrier、ReentrantReadWriteLock
等。
AQS 核心思想
整体逻辑:
- 被请求的共享资源空闲:将当前请求资源的线程设为有效工作线程,将共享资源设置为锁定状态。
- 被请求的共享资源占用:提供一套线程阻塞等待、以及被唤醒时锁分配的机制。用CLH队列锁实现,线程入队。
同步状态管理:
- 用一个int表示同步状态。通过内置的FIFO队列来完成线程排队。
- 用CAS修改该同步状态,以确保原子操作。
AQS 资源共享方式
两种资源共享方式:
- Exclusive(独占):只有一个线程能执行,如ReentrantLock。又可分为公平锁和非公平锁:
- 公平锁:按照线程在队列中的排队顺序,先到者先拿到锁。
- 非公平锁:当线程要获取锁时,无视队列顺序直接去抢锁。
- Share(共享):多个线程可同时执行,如Semaphore/CountDownLatch。Semaphore、CountDownLatCh、 CyclicBarrier、ReadWriteLock 我们都会在后面讲到。
ReentrantReadWriteLock 可以看成是组合式,它允许多个线程同时读,但只允许一个线程写。
AQS底层使用了模板方法模式
在实现自定义同步器时:
- 只需实现共享资源 state 的获取与释放方式即可
- 具体线程等待队列的维护(如获取资源失败入队/唤醒出队等),AQS已经在上层完成实现。
只需重写下面几个AQS提供的模板方法即可:
isHeldExclusively()//该线程是否正在独占资源。只有用到condition才需要去实现它。
tryAcquire(int)//独占方式。尝试获取资源,成功则返回true,失败则返回false。
tryRelease(int)//独占方式。尝试释放资源,成功则返回true,失败则返回false。
tryAcquireShared(int)//共享方式。尝试获取资源。1:负数表示失败;2:0表示成功,但没有剩余可用资源;3:正数表示成功,且有剩余资源。
tryReleaseShared(int)//共享方式。尝试释放资源,成功则返回true,失败则返回false。
AQS数据结构
管程模型:
- 等待队列:底层使用
CLH(Craig,Landin,and Hagersten)队列
。每条请求共享资源的线程,封装成一个CLH锁队列的结点(Node)。双向链表。 - 条件等待队列(Condition queue):单向链表。只有使用Condition时,才会存在。且可以有多个,因为可以有多个条件。
AQS源码分析
Node内部类:
- 封装了线程。作为等待队列和条件等待队列的节点都可以。维护线程当前状态,也可用来组成链表。
CoditionObject内部类:
- addConditionWaiter():新增线程入等待队列,插入到队尾。插入之前会遍历一遍队列,把所有状态不是contidion的节点干掉。
- doSignal():查看first节点,如果是wait状态,则修改为SIGNAL状态,并用Locksupport.unpark()唤醒线程。
- doSigallAll():从头结点开始,遍历所有节点并唤醒。
- awaitUninterruptibly():等待,当前线程在接到信号之前一直处于等待状态,不响应中断。
- await():等待,当前线程在接到信号或被中断之前一直处于等待状态。响应中断。
CondtionObject实现了Condition接口。Condition接口中定义了await、signal方法,用来等待条件、释放条件。
类的属性
包含了头节点head,尾结点tail,状态state、自旋时间spinForTimeoutThreshold,还有AbstractQueuedSynchronizer抽象的属性在内存中的偏移地址,通过该偏移地址,可以获取和设置该属性的值,同时还包括一个静态初始化块,用于加载内存偏移地址。
类的核心方法 - acquire方法
源码如下:
public final void acquire(int arg) {
if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
- 首先调用tryAcquire方法,调用此方法的线程会试图在独占模式下获取对象状态。此方法应该查询是否允许它在独占模式下获取对象状态,如果允许,则获取它。在AbstractQueuedSynchronizer源码中默认会抛出一个异常,即需要子类去重写此方法完成自己的逻辑。之后会进行分析。
- 若tryAcquire失败,则调用addWaiter方法,addWaiter方法完成的功能是将调用此方法的线程封装成为一个结点并放入Sync queue。
- 调用acquireQueued方法,此方法完成的功能是Sync queue中的结点不断尝试获取资源,若成功,则返回true,否则,返回false。
各方法逻辑分析:
- tryAcquire():获取状态。由子类实现。
- addWaiter():往sync queue尾部添加结点。如果sync queue队列还没有初始化,则会使用enq插入队列中。添加的过程中,用
tailOffset
等成员变量,直接比较队尾元素。 - enq():使用无限循环来确保节点的成功插入。期间会判断队列是否为空。
- acquireQueue():不断循环,首先获取当前节点的前驱节点,如果前驱节点是头节点并且自己能够获取资源(这个时候表示前驱节点的锁不用了,把前驱干掉,自己当头结点),代表该当前节点能够占有锁,设置头节点为当前节点,返回。否则,调用
shouldParkAfterFailedAcquire
和parkAndCheckInterrupt
方法。 shouldParkAfterFailedAcquire()
:检查前驱节点状态。是SIGNAL则执行park操作。是CANCELLED状态,则干掉这个节点。是-3,0(无状态),-2(在条件队列中),则将状态改为SIGNAL。(?)- parkAndCheckInterrupt():首先执行park操作,即禁用当前线程,然后返回该线程是否已经被中断。再看final块中的cancelAcquire方法,
- unparkSuccessor():释放某节点后续节点。
acquireQueued方法的整个的逻辑:
- 判断结点的前驱是否为head并且自己是否能成功获取(资源)。
- 若步骤1均满足,则设置自己为head,之后会判断是否finally模块,然后返回。
- 若步骤2不满足,则判断是否需要park当前线程。判断逻辑是:判断结点的前驱结点的状态如果为SIGNAL,则park当前结点。否则,不park。
- 若park了当前线程,之后某个线程对本线程unpark后,并且本线程也获得机会运行。那么,将会继续进行步骤①的判断。
类的核心方法 - release方法
以独占模式释放对象,其源码如下:
public final boolean release(int arg) {
if (tryRelease(arg)) { // 释放成功
// 保存头节点
Node h = head;
if (h != null && h.waitStatus != 0) // 头节点不为空并且头节点状态不为0
unparkSuccessor(h); //释放头节点的后继结点
return true;
}
return false;
}
tryRelease()的默认实现是抛出异常,需要具体的子类实现。
tryRelease成功后,那么如果头节点不为空并且头节点的状态不为0,则释放头节点的后继结点。
后续内容待补充。
清晰的AQS文档
AQS:
Collections: 并发集合
Atomic: 原子类
Executors: 线程池
ConcurrentHashMap
以下参照:
JDK1.7
Put:
- 先定位到相应的 Segment ,然后再进行 put 操作。
- 尝试自旋获取锁。
- 如果重试的次数达到了
MAX_SCAN_RETRIES
则改为阻塞锁获取,保证能获取成功。
Get:
- 首先,根据 key 计算出 hash 值定位到具体的 Segment ,
- 再根据 hash 值获取定位 HashEntry 对象,并对 HashEntry 对象进行链表遍历,找到对应元素。
由于 HashEntry 涉及到的共享变量都使用 volatile 修饰,volatile 可以保证内存可见性,所以每次获取时都是最新值。
JDK1.8
Put:
- 根据 key 计算出 hash 值;
- 判断是否需要进行初始化;
- 定位到 Node,拿到首节点 f,判断首节点 f:
- 如果为 null ,则通过 CAS 的方式尝试添加;
- 如果为
f.hash = MOVED = -1
,说明其他线程在扩容,参与一起扩容; - 如果都不满足 ,synchronized 锁住 f 节点,判断是链表还是红黑树,遍历插入;
- 当在链表长度达到 8 的时候,数组扩容或者将链表转换为红黑树。
Get:
- 根据 key 计算出 hash 值,判断数组是否为空;
- 如果是首节点,就直接返回;
- 如果是红黑树结构,就从红黑树里面查询;
- 如果是链表结构,循环遍历判断。
get 方法不需要加锁。因为 Node 的元素 value 和指针 next 是用 volatile 修饰的,在多线程环境下线程A修改节点的 value 或者新增节点的时候是对线程B可见的。
其他
ConcurrentHashMap 的 get 方法是否要加锁,为什么?★★★
- get 方法不需要加锁。因为 Node 的元素 value 和指针 next 是用 volatile 修饰的,在多线程环境下线程A修改节点的 value 或者新增节点的时候是对线程B可见的。
get 方法不需要加锁与 volatile 修饰的哈希桶数组有关吗?★★★
- 没有关系。哈希桶数组
table
用 volatile 修饰主要是保证在数组扩容的时候保证可见性。
ConcurrentHashMap 不支持 key 或者 value 为 null 的原因?★★★
我们先来说value 为什么不能为 null。因为 ConcurrentHashMap 是用于多线程的 ,如果ConcurrentHashMap.get(key)
得到了 null ,这就无法判断,是映射的value是 null ,还是没有找到对应的key而为 null ,就有了二义性。
而用于单线程状态的 HashMap 却可以用containsKey(key)
去判断到底是否包含了这个 null 。
我们用反证法来推理:
假设 ConcurrentHashMap 允许存放值为 null 的 value,这时有A、B两个线程,线程A调用ConcurrentHashMap.get(key)
方法,返回为 null ,我们不知道这个 null 是没有映射的 null ,还是存的值就是 null 。
假设此时,返回为 null 的真实情况是没有找到对应的 key。那么,我们可以用 ConcurrentHashMap.containsKey(key)
来验证我们的假设是否成立,我们期望的结果是返回 false 。
但是在我们调用 ConcurrentHashMap.get(key)
方法之后,containsKey
方法之前,线程B执行了ConcurrentHashMap.put(key, null)
的操作。那么我们调用containsKey
方法返回的就是 true 了,这就与我们的假设的真实情况不符合了,这就有了二义性。
至于 ConcurrentHashMap 中的 key 为什么也不能为 null 的问题,源码就是这样写的,哈哈。如果面试官不满意,就回答因为作者Doug不喜欢 null ,所以在设计之初就不允许了 null 的 key 存在。想要深入了解的小伙伴,可以看这篇文章这道面试题我真不知道面试官想要的回答是什么
ConcurrentHashMap 的并发度是什么?★★
并发度可以理解为程序运行时能够同时更新 ConccurentHashMap且不产生锁竞争的最大线程数。在JDK1.7中,实际上就是ConcurrentHashMap中的分段锁个数,即Segment[]的数组长度,默认是16,这个值可以在构造函数中设置。
如果自己设置了并发度,ConcurrentHashMap 会使用大于等于该值的最小的2的幂指数作为实际并发度,也就是比如你设置的值是17,那么实际并发度是32。
如果并发度设置的过小,会带来严重的锁竞争问题;如果并发度设置的过大,原本位于同一个Segment内的访问会扩散到不同的Segment中,CPU cache命中率会下降,从而引起程序性能下降。
在JDK1.8中,已经摒弃了Segment的概念,选择了Node数组+链表+红黑树结构,并发度大小依赖于数组的大小。
ConcurrentHashMap 迭代器是强一致性还是弱一致性?★★
与 HashMap 迭代器是强一致性不同,ConcurrentHashMap 迭代器是弱一致性。
ConcurrentHashMap 的迭代器创建后,就会按照哈希表结构遍历每个元素,但在遍历过程中,内部元素可能会发生变化,如果变化发生在已遍历过的部分,迭代器就不会反映出来,而如果变化发生在未遍历过的部分,迭代器就会发现并反映出来,这就是弱一致性。
这样迭代器线程可以使用原来老的数据,而写线程也可以并发的完成改变,更重要的,这保证了多个线程并发执行的连续性和扩展性,是性能提升的关键。想要深入了解的小伙伴,可以看这篇文章:http://ifeve.com/ConcurrentHa...
JDK1.7 与 JDK1.8 中ConcurrentHashMap 的区别?★★★★★
- 数据结构:取消了 Segment 分段锁的数据结构,取而代之的是数组+链表+红黑树的结构。
- 保证线程安全机制:JDK1.7 采用 Segment 的分段锁机制实现线程安全,其中 Segment 继承自 ReentrantLock 。JDK1.8 采用
CAS+synchronized
保证线程安全。 - 锁的粒度:JDK1.7 是对需要进行数据操作的 Segment 加锁,JDK1.8 调整为对每个数组元素加锁(Node)。
- 链表转化为红黑树:定位节点的 hash 算法简化会带来弊端,hash 冲突加剧,因此在链表节点数量大于 8(且数据总量大于等于 64)时,会将链表转化为红黑树进行存储。
- 查询时间复杂度:从 JDK1.7的遍历链表O(n), JDK1.8 变成遍历红黑树O(logN)。
ConcurrentHashMap 和 Hashtable 的效率哪个更高?为什么?★★★★★
ConcurrentHashMap 的效率要高于 Hashtable,因为 Hashtable 给整个哈希表加了一把大锁从而实现线程安全。而ConcurrentHashMap 的锁粒度更低,在 JDK1.7 中采用分段锁实现线程安全,在 JDK1.8 中采用CAS+synchronized
实现线程安全。
具体说一下Hashtable的锁机制 ★★★★★
Hashtable 是使用 synchronized来实现线程安全的,给整个哈希表加了一把大锁,多线程访问时候,只要有一个线程访问或操作该对象,那其他线程只能阻塞等待需要的锁被释放,在竞争激烈的多线程场景中性能就会非常差!
多线程下安全的操作 map还有其他方法吗?★★★
还可以使用Collections.synchronizedMap
方法,对方法进行加同步锁。
转载请注明来源