并发篇
Java 线程的状态
新建 New
当一个线程对象被创建,但还未调用 start 方法时处于新建状态。 此时未与操作系统底层线程关联。可运行 Runnable
调用了 start 方法,就会由新建进入可运行。 此时与底层线程关联,由操作系统调度执行。终结 Terminated
线程内代码已经执行完毕,由可运行进入终结。 此时会取消与底层线程关联。阻塞 Blocked
当获取锁失败后,由可运行进入 Monitor 的阻塞队列阻塞,此时不占用 cpu 时间。 当持锁线程释放锁时,会按照一定规则唤醒阻塞队列中的阻塞线程,唤醒后的线程进入可运行 状态。等待 Waiting
当获取锁成功后,但由于条件不满足,调用了 wait() 方法,此时从可运行状态释放锁进入 Monitor 等待集合等待,同样不占用 cpu 时间 当其它持锁线程调用 notify() 或 notifyAll() 方法,会按照一定规则唤醒等待集合中的等待线 程,恢复为可运行状态有时限等待 Timed Waiting
当获取锁成功后,但由于条件不满足,调用了 wait(long) 方法,此时从可运行状态释放锁进 入 Monitor 等待集合进行有时限等待,同样不占用 cpu 时间 当其它持锁线程调用 notify() 或 notifyAll() 方法,会按照一定规则唤醒等待集合中的有时限等 待线程,恢复为可运行状态,并重新去竞争锁 如果等待超时,也会从有时限等待状态恢复为可运行状态,并重新去竞争锁 还有一种情况是调用 sleep(long) 方法也会从可运行状态进入有时限等待状态,但与 Monitor 无关,不需要主动唤醒,超时时间到自然恢复为可运行状态
其它情况(只需了解)
可以用 interrupt() 方法打断等待、有时限等待的线程,让它们恢复为可运行状态
park,unpark 等方法也可以让线程等待和唤醒
线程池
七大参数:
corePoolSize
:核心线程数,池中会保留的核心线程数maximumPoolSize
:最大线程数 = 核心线程数 + 救急线程数keepAliveTime
:救急线程存活时间,如果该时间内没有任务,该线程被释放unit
:救急线程存活时间单位,如秒、毫秒等workQueue
:工作队列,当没有空闲核心线程时,任务会加入到该队列,当队列满时会创建救急线程执行任务threadFactory
:线程工厂,定制创建线程的名字,是否守护线程等等handler
:拒绝策略,当所有线程都在繁忙,workQueue 也满了时,会触发拒绝策略,JDK 提供的拒绝策略有四种:- AbortPolicy:拒绝执行,直接抛出异常。
- CallerRunPolicy:由调用者创建线程执行任务。
- DiscardPolicy:直接丢弃任务。
- DiscardOldestPolicy:丢弃最早进入队列的任务。
如何分配线程数量?
假设我们的 CPU 的线程数为 N,一般的经验做法是:
- CPU 密集型:N+1(CPU 一直全速运行)
- IO 密集型:2N(并不是一直在执行任务,可能会阻塞)
具体开多少还是得压测才能确定下来。
为什么要用阻塞队列而不用非阻塞队列?
线程池是采用生产者-消费者模式设计的,线程池是消费者。
工作线程调用 take()方法获得任务,如果没有元素时会阻塞在 take()方法,释放 CPU 资源。当阻塞队列有任务时才被唤醒对应线程从队列中取出任务执行。
execute 流程
- 判断是 runnable 否为 null
- 获取 ctl 进而获取正在工作的线程数,判断是否小于核心线程数
- 创建核心线程,执行任务
- 如果添加核心线程数失败,重新获取 ctl
- 获取线程池状态,如果线程池正在运行,把任务加到阻塞队列
- 否则,尝试创建最大线程数(救急线程),成功返回 true,失败执行拒绝策略
wait()和 sleep()的区别
共同点:都可以放弃 CPU 的使用权,进入等待状态,都可以被打断 interrupt() 唤醒。
不同点:方法归属、醒来时机、锁的特性 三个方面。
- 方法归属:wait 是 Object 的成员方法,每个对象都有 wait 方法;sleep 是线程 Thread 的静态方法。
- 醒来时机
- sleep(long)和 wait(long)都会在一定时间后醒来。
- wait(long)和 wait()还可以被 notify 唤醒,wait()如果不唤醒就会一直等下去。
- 锁的特性
- wait 方法的调用必须获取对象的锁,而 sleep 不用。
- wait 方法执行后会释放对象锁,允许其他线程抢占锁;而 sleep 如果在 synchronized 代码块中执行,会一直持有锁,不释放。
生产者和消费者模式假死现象
生产者/消费者模型最终达到的目的是平衡生产者和消费者的处理能力,达到这个目的的过程中,并不要求只有一个生产者和一个消费者。可以多个生产者对应多个消费者,可以一个生产者对应一个消费者,可以多个生产者对应一个消费者。
假死就发生在上面三种场景下。理论分析就能说明问题,所以就不写代码了。开一个生产者线程/多个消费者线程、开多个生产者线程/消费者线程、开多个生产者线程/多个消费者线程都可以。假死指的是全部线程都进入了 WAITING 状态,那么程序就不再执行任何业务功能了,整个项目呈现停滞状态。
比方说有生产者 A 和生产者 B,缓冲区由于空了,消费者处于 WAITING。生产者 B 处于 WAITING,生产者 A 被消费者通知生产,生产者 A 生产出来的产品本应该通知消费者,结果通知了生产者 B,生产者 B 被唤醒,发现缓冲区满了,于是继续 WAITING。至此,两个生产者线程处于 WAITING,消费者处于 WAITING,系统假死。
上面的分析可以看出,假死出现的原因是因为 notify 的是同类,所以非单生产者/单消费者的场景,可以采取两种方法解决这个问题:
1、synchronized 用 notifyAll()唤醒所有线程、ReentrantLock 用 signalAll()唤醒所有线程
2、用 ReentrantLock 定义两个 Condition,一个表示生产者的 Condition,一个表示消费者的 Condition,唤醒的时候调用相应的 Condition 的 signal()方法就可以了
死锁
死锁的四个条件:
- 互斥等待:不可破坏
- 请求和保持:一次性申请所有资源
- 不可抢占:申请不到主动释放
- 循环等待:按序申请
synchronized
synchronized 是 Java 的一个关键字,提供一种==互斥锁==,只允许一个线程进入同步代码块,其他线程只能阻塞等待。
加锁对象情况:
- 如果修饰静态方法,就是给当前类的对象加锁。
- 如果修饰的是成员方法,就是给当前类的实例对象加锁。
- 如果修饰的是代码块,给传入的对象实例加锁。
实现原理:编译后的字节码可以发现
- 修饰方法时,会用 ACC_SYNCHRONIZED 关键字来标识
- 修饰代码块时,会 依赖 monitorenter 和 monitorexit 指令
首先讲一下对象的构成:
在内存中的 Java 对象,分为==对象头==、==对象实际数据==和==对齐填充==。(数组还有长度字段)
重点在于对象头,对象头分为 ==mark word== 和 ==class point==。
mark word 主要存储==对象的 hashcode==、==GC 分代年龄==和==锁的相关信息==。
monitor 对象的构成:
每个对象都会有个对应的 ==object monitor== 对象,==monitor== 对象中主要存储三个部分,==owner== 、==wait set==、==entry list==。
==owner==:记录当前获得锁线程。
==wait set==:存放 wait()等待的线程。
==entry list==:存放等待获取锁额线程。
重量级锁:
JDK 1.6 之前是重量级锁,当线程进入同步代码块时,monitor 对象会把 当前线程的 ID 存储,并且设置 mark word 的 monitor 对象地址,把阻塞的线程存储到 entry list 中。
底层实现:依赖操作系统的 mutex 相关指令,所以会有 用户态 和 内核态之间的切换,性能损耗明显。
偏向锁:没有竞争的情况下,JVM 认为只有某个线程才会执行同步代码。
- 在 Mark Word 会直接记录当前线程 ID,只要线程来执行代码了,会比较线程 ID 是否相等,相等就直接获取锁,执行代码。
- 如果不相等,就会使用 CAS 来修改 Mark word 中当前线程 ID,如果 CAS 成功,就获取锁,执行同步代码。
- 如果 CAS 失败,存在竞争环境,就会升级到轻量级锁。
轻量级锁:
- 当前线程会在栈帧下创建 Lock Record 锁记录,锁记录会把 Mark Word 的信息拷贝进去,且有多个 Owner 指针指向加锁对象。
- 线程执行到同步代码时,则用 CAS 试图将 Mark Word 的指向到线程栈帧的 Lock Record,假设 CAS 修改成功,则获取得到轻量级锁。
- 假设修改失败,则自旋(重试),自旋一定次数后,则升级为重量级锁。
Lock 和 synchronized 的区别
主要从 语法、功能、性能 三个层面阐述。
语法层面
- synchronized 是关键字,源码在 JVM 中,C++ 实现
- Lock 是接口,源码在 JDK 中,Java 实现
- 使用 synchronized 时,临界区代码执行完会自动释放锁,而 Lock 要手动释放锁。
功能层面
- 都是悲观锁,互斥、同步、锁重入
- Lock 提供了一些 synchronized 不具备的功能:获取等待状态、==公平锁==、==可打断==、==可超时==、==多条件变量(Condition)==
- Lock 有适合不同场景的实现,如 ReentrantLock、ReentrantReadWriteLock
性能层面
- 在没有竞争时,synchronized 做了很多优化,如偏向锁、轻量级锁,性能不赖
- 在竞争激烈时,Lock 通常性能比较好
公平锁
- 公平锁的公平体现
- 已经处在阻塞队列中的线程始终都是公平的,先进先出
- 公平锁是指未处于阻塞队列的线程来获取锁时,要判断队列是否为空,不为空就必须加入到队列
- 非公平锁是指未处阻塞队列的线程来获取锁时,直接抢占,谁抢到就是谁的
- 公平锁会降低吞吐量,一般不用
条件变量
- ReentrantLock 中的条件变量类似于 synchronized 中的 wait,notify,用在线程获得锁发现条件不满足时,临时加入等待的链表结构
- ReentrantLock 中的条件变量可以有多个,实现更加精细的等待和唤醒控制
sychronized 抛异常可以释放锁,编译后字节码会有异常表,保证异常时正常解锁。
reentrantLock 可以跨方法释放锁吗,可以,但是最好不要,可重入锁是针对线程的,不是针对方法的。
AQS 原理
AQS,全称为 ==AbstractQueueSynchronizer==,==抽象队列同步器==。它是 JDK 提供给我们的一个可以实现==锁==的框架,内部实现的关键就是维护了一个==先进先出的队列==和 ==state 状态变量==。
先进先出队列的载体就是 Node,节点标识当前的状态值,独占还是共享模式,前驱和后续节点。
AQS 定义了模板,具体实现由子类实现。
流程:把需要等待的线程以 Node 的形式存入先进先出的队列上,state 变量表示当前锁的状态。
子类:ReentrantLock、ReentrantReadWriteLock、Semphore、CountDownLatch
ReentrantLock 加锁、解锁流程
以非公平锁为例
加锁流程:lock -> CAS -> acquire -> tryAcquire -> state == 0 CAS -> 当前线程获得锁?-> 加入队列 -> 头节点?-> CAS -> park
- 调用 lock 时,CAS 尝试获取锁,获取成功就可以执行
- CAS 获取锁失败,则调用 AQS 的 acquire 方法
- 调用子类的 tryAcquire 方法
- 判断当前的 state 是否等于 0,等于 0 说明没有线程持有锁,尝试获取锁
- 如果 CAS 成功,执行同步代码
- 如果 CAS 获取锁失败,判断当前线程是否持有锁,如果持有,更新 state 的值,获取锁(可重入)
- 如果 CAS 失败,且持有锁的不是当前线程,回到 tryAcquire 入队列
- 加入队列后,会判断前驱节点是不是头节点,如果是头节点又会用 CAS 尝试获取锁
- 如果没有获取锁,就会将前驱节点状态设置为 Signal
- 最后调用 LockSurpport.park() 方法将线程挂起
解锁流程:
- 调用 unlock()时,会调用 AQS 的 release 方法,release 方法又会调用子类的 tryRelease 方法
- tryRelease 会把 state 减一,如果减到 0,说明当前线程把锁释放了
- 随后从 队尾 往前找节点,状态要 <0 ,并且离头节点最近的老二节点唤醒,LockSupport.unpark
- 唤醒之后,被唤醒的线程尝试 CAS 获取锁,
volatile
volatile 是 Java 语言提供的一个关键字,它可以用来保证变量的可见性和有序性。
并发编程中需要考虑的三个问题:原子性、可见性和有序性。
原子性
起因:多线程下,不同线程的指令发生了交错导致了共享变量的读写顺序混乱。
解决:volatile 只能保证变量的可见性和有序性,不能解决原子性问题。原子性问题需要用悲观锁或乐观锁来解决。
可见性
为了提升 CPU 的利用率,CPU 增加了寄存器和三级高速缓存,L1 和 L2 缓存是 CPU 私有的,如果两个线程同时把一个变量的数据加载到自己的高速缓存中,再分别修改,就引发了缓存一致性问题。为了解决缓存一致性问题,CPU 引入了总线锁和缓存锁。总线锁就是在总线上声明一个 Lock#信号,它可以保证只有当前 CPU 可以访问共享内存,其他的处理器请求会被阻塞,解决缓存不一致的问题。缓存锁是一种优化,如果当前 CPU 访问的数据已经缓存在其他 CPU 的高速缓存中,它就采用缓存一致性协议来保证缓存一致性。一致性协议的过程:当一个 CPU 修改了共享变量的值之后会刷新到主内存中,并通知其他 CPU 失效自己的缓存,每次重新去主内存加载。
起因:对共享变量所做的修改对其他线程不可见。
解决:valatile 底层实现就是会在修改命令前面加上一个 Lock# 信号,基于缓存锁或总线锁来保证结果可见。用 volatile 修饰共享变量,能够防止编译器等优化发生,让一个线程对共享变量的修改对另一个线程可见。
有序性
起因:由于 CPU 指令重排序优化、CPU 缓存优化或编译器优化导致的指令实际执行顺序和编写顺序不一致。
解决:用 volatile 修饰共享变量,会在读、写共享变量时加入不同的屏障,阻止其他读写操作越过屏障,从而达到阻止排序的效果
写屏障:阻止上方其他写操作越过屏障到 volatile 变量的写之下。
读屏障:阻止下方其他读操作越过屏障到 volatile 变量的读之上。
CAS
CAS 是 Java ==Unsafe 类==中的一个方法,全称是 ==compareAndSwap==,比较并交换,它是一个 CPU 原子性的指令,对应到 CPU 指令为 ==lock cmpxchg==,主要作用是==保证多线程环境下对共享变量修改的原子性==。
它的用法是:传入对应对象实例,对应变量的偏移量、预期值、新值,它会去比较内存中变量的值和传入的预期值是否相等,相等则修改为新值。
而这个操作在底层也会出现原子性问题,所以在==多核 CPU 环境下增加 Lock 指令对缓存或者总线去加锁==,来保证比较并替换的两个操作的原子性。
- JUC 中:Atomic 原子类实现
- 实现多线程对共享资源竞争的互斥性质:AQS,ConcurrentHashMap 等
缺点:
- 作为自旋锁的实现,会消耗大量 CPU 资源。并发高时性能不如加锁。
- ABA 问题(AtomicStampedReference 加版本号解决)。
- 只能保证一个共享变量的原子操作,多个变量的原子操作需要使用锁或者 AtomicReference 类。
悲观锁 vs 乐观锁
悲观锁:线程占有了锁,才能去操作共享变量,每次只有一个线程可以获得锁,获取锁失败的线程都阻塞。
线程需要进入阻塞状态,再到唤醒状态。导致上下文切换频繁,影响性能。
实际上,线程在获取 synchronized 和 Lock 锁时,如果锁已被占用,都会做几次重试操作,减少阻塞的机会。
乐观锁:无需加锁,每次只有一个线程可以成功修改共享变量,其他线程不停重试,直到成功。
线程一直运行,不需要阻塞,不会发生线程上下文切换。
需要多核 CPU 支持,线程数不应超过 CPU 核数。
Semaphore
Semaphore 信号量,用来限制能够同时访问共享资源的线程上限。可以使用 Semaphore 限流,但只是单机版,分布式不能使用。
permits 许可数量。
acquire() 申请许可,如果申请不到就会进入 AQS 队列 park 阻塞。
release() 释放许可,唤醒 AQS 队列中的节点。
CountDownLatch vs CyclicBarrier
CountDownLatch 倒计时器:允许一个或多个线程一直等待,直到完成他们的操作。
CyclicBarrier 循环栅栏:当线程达到某状态后,暂停下来等待其他线程,等到所有线程到达后,才继续执行。
CountDownLatch 用完了,就结束了,没法复用。而 CyclicBarrier 不一样,它可以复用。
两者都是线程同步的工具类,但他们的等待的主体不一样:CountDownLatch 一般是主线程/调用线程等待 await(),CyclicBarrier 一般是任务线程等待 await()。
这两个类都是基于 AQS 实现的。
当构建 CountDownLatch 对象时,我们会传参 count,这个参数会赋值给 AQ S 的关键变量 state,当执行 countdown 方法时,就是利用 cas 将 state 值减 1;执行 await()方法,就是判断这个 state 的值是否为 0,不为 0 就会加入到阻塞队列中,将该线程阻塞。当 state 为 0 时,会把队列里阻塞的线程节点都唤醒。
CyclicBarrier 是利用 ReentrantLock 来实现的,他是借助 ReentrantLock + Condition 来实现的。在构建 CyclicBarrier 的时候,传入的 parties 值会赋值给 count 和 parties 变量,每次调用 await() 时,会将 count-1,操作 count 值是直接使用 ReentrantLock 来保证线程安全性,如果 count 不为 0,则添加到 condition 队列中,如果 count 等于 0,就会把 condition 队列全部唤醒,并且将 parties 值重新赋值给 count ,实现复用。
Hashtable vs ConcurrentHashMap
相同点:都是线程安全的 Map 集合。
不同点:
- Hashtable 并发度低,整个 Hashtable 对应一把锁,同一时刻只有一个线程操作它。
- ConcurrentHashMap 并发度高,整个 ConcurrentHashMap 对应多把锁,只要线程访问的是不用锁,就不会冲突。
ConcurrentHashMap 1.7 的实现
- 数据结构:Segment(大数组) + HashEntry(小数组) + 链表,每个 Segment 对应一把锁。
- 并发度:Segment 大小即并发度,同一时刻最多能有多少个线程并发访问。Segment 不能扩容,意味着并发度在 ConcurrentHashMap 创建时就固定了。
- 索引计算:
- 大数组长度是 2^m,key 在大数组内的索引是 key 的二次 hash 值的高 m 位
- 小数组长度是 2^n,key 在小数组内的索引是 key 的二次 hash 值的低 n 位
- 扩容:每个小数组扩容相对独立,小数组在==超过==扩容因子 0.75 时会触发扩容,每次==扩容翻倍==
- Segment[0]原型:首次创建其他小数组时,会以此原型为依据,数组长度,扩容因子都会以原型为准
- 饿汉式初始化
ConcurrentHashMap 1.8 的实现
数据结构: Node 数组 + 链表或红黑树,数组的每个头结点作为锁,如果多个线程访问的头结点不同,则不会冲突。首次生成头节点时如果发生竞争,利用 cas 而非 synchronized,进一步提升性能。
并发度:Node 数组有多大,并发度就有多大,与 1.7 不同,Node 数组可以扩容。
扩容条件:Node 数组满 3/4 时就会扩容(7 是超过)。
扩容单位:以链表为单位从后向前迁移链表,迁移完成的将旧数组头结点替换为 ForwardingNode
扩容时并发 get
- 根据是否为 ForwardingNode 来决定是在新数组找还是旧数组找,不会阻塞
- 如果链表长度超过 1,则需要对节点进行复制(创建新节点),主要是怕节点迁移后 next 指针改变
- 如果链表最后几个元素扩容后索引不变,则节点无需复制
扩容时并发 put
- 如果 put 的线程与扩容线程的链表是同一个,put 线程会阻塞,扩容完才能 put
- 如果 put 的线程操作的链表还未迁移完成,即头节点不是 ForwardingNode,则可以并发执行
- 如果 put 的线程操作的链表已经完成,即头节点是 ForwardingNode,则可以协助扩容(每个线程可以处理 16 个链表的迁移),扩容完才能 put
懒惰初始化,第一次 put 元素时才会初始化
capacity 代表预估的元素个数,capacity / factory 来计算出初始数组大小,需要贴近 2 ^ n。例如 capacity = 16,初始化后会等于 32。
loadFactor 只在计算初始数组大小时被使用,之后扩容固定为 3/4。
超过树化阈值的扩容问题,如果容量已经是 64,直接树化,否则在原来容量基础上做 3 轮扩容。
ThreadLocal 的理解
作用:实现资源对象线程间隔离,资线程内共享。
- ThreadLocal 可以实现 资源对象 的线程隔离,让每个线程各用各的 资源对象,避免争用引发线程安全问题
- ThreadLocal 同时实现了线程内的资源共享
原理:每个线程内都有一个 ThreadLocalMap 类型的成员变量,用来存储资源对象。
- 调用 set 方法,就是以 ThreadLocal 自己作为 key,资源对象作为 value,放入当前线程的 ThreadLocalMap 集合中
- 调用 get 方法,就是以 ThreadLocal 自己作为 key,到当前线程中查找关联的资源值
- 调用 remove 方法,就是以 ThreadLocal 自己作为 key,移除当前线程关联的资源值
特点:
- ==hash 值==: hash 值统一分配,nextHashCoded 是 AtomicInteger 对象,从魔数 0x61c88647(1640531527)自增。
- ==容量和扩容==:初始容量 16,扩容因子 2/3,扩容容量翻倍。
- key 索引冲突后用开放寻址法解决冲突,冲突后会找下一个空闲位置
为什么 ThreadLocalMap 的 key 设计成弱引用?
线程可能长时间运行,如果 key 不再使用,需要在内存不足时释放其占用的内存。
但是 GC 只是让 key 的内存释放,后续还要根据可以是否为 null 来释放值的内存。
内存释放时机
被动 GC 释放 key
- 让 key 的内存释放,关联的 value 不会释放
懒惰被动释放 value
- get key 发现 null key,把自己的 key 放进去,把 value 清空。
- set key 时,会使用==启发式扫描==,清除临近的 null key,启发次数与元素个数,是否发现 null key 有关。
主动 remove 释放 key(推荐)
- 会同时释放 key 和 value,临近的 null key 的 value 也会清除
- 推荐使用,因为一般我们使用 ThreadLocal 都是定义的静态变量(强引用),无法被动的靠 GC 回收。