并发篇
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 回收。