JUC《JUC学习》重点

Lock框架和Tools类

image

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时,才会存在。且可以有多个,因为可以有多个条件。

image

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():不断循环,首先获取当前节点的前驱节点,如果前驱节点是头节点并且自己能够获取资源(这个时候表示前驱节点的锁不用了,把前驱干掉,自己当头结点),代表该当前节点能够占有锁,设置头节点为当前节点,返回。否则,调用shouldParkAfterFailedAcquireparkAndCheckInterrupt方法。
  • 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: 并发集合

image

Atomic: 原子类

Executors: 线程池

img

基于www.pdai.tech相关资料整理

ConcurrentHashMap

以下参照:

JDK1.7

Put:

  • 先定位到相应的 Segment ,然后再进行 put 操作。
  • 尝试自旋获取锁。
  • 如果重试的次数达到了 MAX_SCAN_RETRIES 则改为阻塞锁获取,保证能获取成功。

Get:

  • 首先,根据 key 计算出 hash 值定位到具体的 Segment ,
  • 再根据 hash 值获取定位 HashEntry 对象,并对 HashEntry 对象进行链表遍历,找到对应元素。

由于 HashEntry 涉及到的共享变量都使用 volatile 修饰,volatile 可以保证内存可见性,所以每次获取时都是最新值。

JDK1.8

Put:

  1. 根据 key 计算出 hash 值;
  2. 判断是否需要进行初始化;
  3. 定位到 Node,拿到首节点 f,判断首节点 f:
    • 如果为 null ,则通过 CAS 的方式尝试添加;
    • 如果为 f.hash = MOVED = -1 ,说明其他线程在扩容,参与一起扩容;
    • 如果都不满足 ,synchronized 锁住 f 节点,判断是链表还是红黑树,遍历插入;
  4. 当在链表长度达到 8 的时候,数组扩容或者将链表转换为红黑树。

Get:

  1. 根据 key 计算出 hash 值,判断数组是否为空;
  2. 如果是首节点,就直接返回;
  3. 如果是红黑树结构,就从红黑树里面查询;
  4. 如果是链表结构,循环遍历判断。

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方法,对方法进行加同步锁。


转载请注明来源