JUC(二)-- 并发编程
目录
一、线程状态
二、AQS
1. 概述
2. AQS的工作流程
三、ReentrantLock
1. 可重入
2. 可打断
3. 公平锁
4. 条件变量
四、Java内存模型(JMM)
1. volatile
1.1 可见性
1.2 指令重排序
1.2.1 原理
1.2.2 问题
1.3 原理
2. CAS
2.1 CAS与volatile
2.2 CAS的特点
3. 线程池
3.1 线程池状态
3.2 构造方法
五、ConcurrentHashMap
1. get方法原理
2. put方法原理
3. JDK8之后的ConcurrentHashMap
4. JDK7之前的ConcurrentHashMap
一、线程状态
NEW:线程被创建,但还没有启动;RUNNABLE:线程已经启动,可能正在执行,也可能是等待操作系统调度执行(RUNNABLE 并不意味着线程正在运行,它可能正在等待操作系统调度,或者被挂起等待 CPU 时间片);BLOCKED:线程因某些同步操作(如获取锁)被阻塞,无法继续执行,直到获取到所需的锁;WAITING(无限等待):线程在等待其他线程的通知或者执行完某些操作;TIMED_WAITING(有限等待):线程会在指定时间内等待,然后恢复;TERMINATED:线程执行完毕,或者因为异常而终止,进入终止状态。

上图中有10中线程状态转换的情况。




情况5






二、AQS
1. 概述
AQS全称是 AbstractQueuedSynchronizer,是阻塞式锁和相关的同步器工具的框架。它通过一个状态变量state和一个FIFO等待队列来管理线程之间的竞争。
特点:
- 用 state 属性来表示资源的状态(分独占模式和共享模式),子类需要定义如何维护这个状态,控制如何获取锁和释放锁。 - 独占模式是只有一个线程能够访问资源,而共享模式可以允许多个线程访问资源
 
- 提供了基于 FIFO 的等待队列,类似于 Monitor 的 EntryList
- 条件变量来实现等待、唤醒机制,支持多个条件变量,类似于 Monitor 的 WaitSet
2. AQS的工作流程
- 线程尝试获取锁:调用tryAcquire()。
- 如果成功:直接进行临界区。
- 如果失败:进入等待队列,使用park()方法挂起线程。
- 当锁释放的时:唤醒队列中的下一个等待线程。
三、ReentrantLock
ReentrantLock具备如下特点:可中断、可以设置超时时间、可以设置为公平锁、支持多个条件变量、可重入。

内部核心类:
public class ReentrantLock implements Lock {private final Sync sync;abstract static class Sync extends AbstractQueuedSynchronizer {}static final class NonfairSync extends Sync {}static final class FairSync extends Sync {}
}
ReentrantLock的底层由一个内部类Sync来控制,Sync继承于AQS,通过CAS原子操作控制加锁与释放。
1. 可重入
可重入是指同一个线程如果首次获得了这把锁,那么因为它是这把锁的拥有者,因此有权利再次获取这把锁 如果是不可重入锁,那么第二次获得锁时,自己也会被锁挡住。
例如下面这段代码:

结果如下,可以发现方法都执行了,并没有因为第二次加锁而无法进入。

2. 可打断
public void lockInterruptibly() throws InterruptedException {sync.acquireInterruptibly(1);
}
acquireInterruptibly() 是 AQS 提供的中断式获取;
当一个新的线程到来,尝试tryAcquire();如果失败进入等待队列;如果等待过程中线程被打断,则会抛出InterruptedException并退出等待队列。
3. 公平锁


4. 条件变量

四、Java内存模型(JMM)
JMM体现在以下几个方面:
- 原子性-保证指令不会受到线程上下文切换的影响
- 可见性-保证指令不会受cpu缓存的影响
- 有序性-保证指令不会受到cpu指令并行优化的影响
1. volatile
它可以用来修饰成员变量和静态成员变量,可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取它的值,线程操作volatile变量都是直接操作主存。
1.1 可见性
看下面这段代码,虽然main线程吧run设置成了false,但是t线程还是不会停止,这是为什么?因为main 线程对 run 变量的修改对于 t 线程不可见,导致了 t 线程无法停止。

底层状态如下:
1. 初始状态, t 线程刚开始从主内存读取了 run 的值到工作内存。

2. 因为 t 线程要频繁从主内存中读取 run 的值,JIT 编译器会将 run 的值缓存至自己工作内存中的高速缓存中, 减少对主存中 run 的访问,提高效率。

3. 1 秒之后,main 线程修改了 run 的值,并同步至主存,而 t 是从自己工作内存中的高速缓存中读取这个变量 的值,结果永远是旧值。

对于上面的情况,可以使用volatile关键字来解决,它保证了变量的可见性。
1.2 指令重排序
JVM会在不影响正确性的前提下,调整指令的执行顺序。这种特性称之为『指令重排』,但是多线程下『指令重排』可能会影响正确性。

1.2.1 原理
现代处理器会设计为一个时钟周期完成一条执行时间最长的 CPU 指令。为什么这么做呢?可以想到指令 还可以再划分成一个个更小的阶段,例如,每条指令都可以分为: 取指令 - 指令译码 - 执行指令 - 内存访问 - 数据 写回 这 5 个阶段。

在不改变程序结果的前提下,这些指令的各个阶段可以通过重排序和组合来实现指令级并行。
现代 CPU 支持多级指令流水线,例如支持同时执行 取指令 - 指令译码 - 执行指令 - 内存访问 - 数据写回 的处理 器,就可以称之为五级指令流水线。这时 CPU 可以在一个时钟周期内,同时运行五条指令的不同阶段(相当于一 条执行时间最长的复杂指令),IPC = 1,本质上,流水线技术并不能缩短单条指令的执行时间,但它变相地提高了指令的吞吐率。

1.2.2 问题
看下面这段代码:
int num = 0;
boolean ready = false;
//线程1执行此方法
public void actor1(I_Result r) {if(ready) }r.r1 = num + num;}else {r.r1 = 1;}
}//线程2执行此方法
public void actor2(I_Result r) {num = 2;ready = true;
}
有以下几种情况:
情况1:线程1 先执行,这时 ready = false,所以进入 else 分支结果为 1
情况2:线程2 先执行 num = 2,但没来得及执行 ready = true,线程1 执行,还是进入 else 分支,结果为1
情况3:线程2 执行到 ready = true,线程1 执行,这回进入 if 分支,结果为 4(因为 num 已经执行过了)。
但是结果还有可能为0,这是为什么呢?
因为JVM在程序执行的时候进行了指令的重排序,即让ready = true比num = 2先执行了。因此结果就可能为0。
可以在ready上加一个volatile关键字,就可以进行指令的重排序。(因为ready = true在下面,它可以禁止上面的指令进行重排序)。
1.3 原理
volatile的底层实现原理是内存屏障。
- 对volatile变量的写指令后会加入写屏障
- 对volatile变量的读指令前会加入读屏障
对于可见性,写屏障保证在该屏障之前的,对共享变量的改动,都同步到主存当中。

读屏障(lfence)保证在该屏障之后,对共享变量的读取,加载的是主存中最新数据。

对于有序性,也是同理。
写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后。
读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前
2. CAS
来看下面这段代码,这段代码的意思就是多个线程对同一个变量进行减法操作,下面这个代码片段是减余额的操作,balance的get和compareAndSet方法都是可以保证原子性的,不会出现线程并发问题。

保证原子性其中的关键就是compareAndSet这个方法,简称为CAS。上面的代码可以由下图来进行概括。
首先,线程1拿到旧值prev = 100,减去10,剩下的余额next为90。但是此时,另一个线程2已经完成了该原子操作并将余额修改为了90,此时线程1将自己的90进行覆盖显然是不合适的。CAS(比较并修改)这个方法在修改数据之前,会将prev与当前值进行比较(之前获得的值与当前值进行比较),如果不一样,则直接返回false,表示此次操作修改失败;如果一样,则返回true,此次操作修改成功。

2.1 CAS与volatile
获取共享变量的时候,为了保证该变量的可见性,需要使用 volatile 修饰。
它可以用来修饰成员变量和静态成员变量,他可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取 它的值,线程操作 volatile 变量都是直接操作主存。即一个线程对 volatile 变量的修改,对另一个线程可见。
因为volatile仅仅只保证了变量的可见性,不保证原子性。
所以CAS 必须借助 volatile 才能读取到共享变量的最新值来实现【比较并交换】的效果
2.2 CAS的特点
CAS可以实现无锁并发,适用于线程数少、多核CPU的场景。
- CAS 是基于乐观锁的思想:最乐观的估计,不怕别的线程来修改共享变量,就算改了也没关系,我吃亏点再重试呗。
- synchronized 是基于悲观锁的思想:最悲观的估计,得防着其它线程来修改共享变量,我上了锁你们都别想 改,我改完了解开锁,你们才有机会。
为什么无锁效率高?
无锁情况下,即使重试失败,线程始终在高速运行,没有停歇,而 synchronized 会让线程在没有获得锁的时候,发生上下文切换,进入阻塞。
但是注意,如果线程数过多,竞争激烈,对于共享对象的重试必然频繁发生,反而效率可能还不如加锁的效率高。
3. 线程池
3.1 线程池状态
ThreadPoolExecutor是线程池类。ThreadPoolExecutor使用 int 的高 3 位来表示线程池状态,低 29 位表示线程数量。

从数字上比较,TERMINATED > TIDYING > STOP > SHUTDOWN > RUNNING。
为什么要把状态和数量放到一个变量里呢?
因为这些信息存储在一个原子变量中,目的是将线程池状态与线程个数合二为一,这样就可以用一次 cas 原子操作 进行赋值。
3.2 构造方法

线程池执行任务的流程如下:
- 线程池中刚开始没有线程,当一个任务提交给线程池后,线程池会创建一个新线程来执行任务。
- 当线程数达到 corePoolSize 并没有线程空闲,这时再加入任务,新加的任务会被加入workQueue 队列排 队,直到有空闲的线程。
- 如果队列选择了有界队列,那么任务超过了队列大小时,会创建 maximumPoolSize - corePoolSize 数目的线 程来救急。
- 如果线程到达 最大线程数 仍然有新任务这时会执行拒绝策略。
- 当高峰过去后,超过corePoolSize 的救急线程如果一段时间没有任务做,需要结束节省资源,这个时间由 keepAliveTime 和 unit 来控制。
拒绝策略有以下四种:
- 直接抛出异常
- 把该任务交给提交任务的线程来执行
- 直接抛弃该任务
- 抛弃队列中最早的任务
五、ConcurrentHashMap
1. get方法原理
get方法的核心理念就是无锁化。
1. 计算与定位:首先计算 key 的哈希值,并定位到哈希表中的特定桶;
2.分情况处理:根据桶中第一个节点(头节点)的不同状态,进行相应处理:
- 头节点即匹配:如果头节点的哈希值和 key 都匹配,则直接返回其对应的值。
- 特殊节点(哈希值 < 0):这表示该桶正处于特殊状态。例如,哈希值为 MOVED(-1)表示该桶正在扩容,数据可能已迁移到新表(nextTable),这时会调用ForwardingNode的find方法去新表中查找。哈希值为-2则表示该桶下是红黑树,会调用TreeBin的find方法在树中搜索。
- 链表遍历:如果是一个普通的链表(头节点哈希值 >= 0),则顺序遍历链表直到找到匹配的节点。
3. 无锁的关键:get操作不需要加锁,主要得益于 Node节点中的 val和 next字段都被 volatile关键字修饰
2. put方法原理
put方法的核心是结合 CAS(Compare-And-Swap) 和 细粒度锁 来平衡线程安全与并发性能,其基本流程是一个自旋循环:
1. 前置检查:检查 key 和 value 是否为 null,ConcurrentHashMap 不允许 null 的键或值。
2. 分情况处理:
- 表未初始化:如果哈希表数组尚未创建,则首先初始化表(initTable),通过 CAS 操作确保只有一个线程能完成初始化;
- 目标桶为空:如果计算出的桶位置为空,则直接使用 CAS 操作尝试将新节点放入该桶。若 CAS 成功,则插入完成;若失败(说明其他线程已抢先插入),则进入下一轮循环重试。这是无锁添加。
- 桶正在扩容:如果检测到桶的头节点是 ForwardingNode(其hash为MOVED),则当前线程不会阻塞,而是会参与协助数据迁移(helpTransfer),共同完成扩容操作。
- 处理哈希冲突:当桶不为空且不在扩容时,说明发生了哈希冲突。此时,synchronized关键字会锁住这个桶的头节点。在同步代码块内部,会再次检查头节点是否发生变化(双重检查),然后根据当前是链表还是红黑树进行节点的插入或更新。操作完成后释放锁。
3. 后续操作:插入成功后,会检查是否需要将链表转换为红黑树(当链表长度达到阈值且数组容量足够大时),并增加元素计数,这可能会触发扩容。
3. JDK8之后的ConcurrentHashMap
JDK8之后集合底层使用数组(Node) +( 链表 Node | 红黑树 TreeNode ),以下数组简称(table),链表简称(bin)。
- 初始化,使用 cas 来保证并发安全,懒惰初始化 table。
- 树化,当 table.length < 64 时,先尝试扩容,超过 64 时,并且 bin.length > 8 时,会将链表树化,树化过程 会用 synchronized 锁住链表头。
- put,如果该 bin 尚未创建,只需要使用 cas 创建 bin;如果已经有了,锁住链表头进行后续 put 操作,元素 添加至 bin 的尾部。
- get,无锁操作仅需要保证可见性,扩容过程中 get 操作拿到的是 ForwardingNode (说明正在扩容)它会让 get 操作在新 table 进行搜索
- 扩容,扩容时以 bin 为单位进行,需要对 bin 进行 synchronized,但这时妙的是其它竞争线程也不是无事可 做,它们会帮助把其它 bin 进行扩容,扩容时平均只有 1/6 的节点会把复制到新 table 中。
4. JDK7之前的ConcurrentHashMap
它维护了一个 segment 数组,每个 segment 对应一把锁
- 优点:如果多个线程访问不同的 segment,实际是没有冲突的,这与 jdk8 中是类似的
- 缺点:Segments 数组默认大小为16,这个容量初始化指定后就不能改变了,并且不是懒惰初始化
segment其实是分段锁,每一个锁对应着一个哈希表,如图所示:

并发运行的时候,不同的线程来了,如果访问的是不同的segment,访问的每个哈希表就是不一样的,每个哈希表里又是数组+链表的结构。
