跳到主要内容

并发篇

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 流程

  1. 判断是 runnable 否为 null
  2. 获取 ctl 进而获取正在工作的线程数,判断是否小于核心线程数
  3. 创建核心线程,执行任务
  4. 如果添加核心线程数失败,重新获取 ctl
  5. 获取线程池状态,如果线程池正在运行,把任务加到阻塞队列
  6. 否则,尝试创建最大线程数(救急线程),成功返回 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 认为只有某个线程才会执行同步代码。

  1. 在 Mark Word 会直接记录当前线程 ID,只要线程来执行代码了,会比较线程 ID 是否相等,相等就直接获取锁,执行代码。
  2. 如果不相等,就会使用 CAS 来修改 Mark word 中当前线程 ID,如果 CAS 成功,就获取锁,执行同步代码。
  3. 如果 CAS 失败,存在竞争环境,就会升级到轻量级锁。

轻量级锁:

  1. 当前线程会在栈帧下创建 Lock Record 锁记录,锁记录会把 Mark Word 的信息拷贝进去,且有多个 Owner 指针指向加锁对象。
  2. 线程执行到同步代码时,则用 CAS 试图将 Mark Word 的指向到线程栈帧的 Lock Record,假设 CAS 修改成功,则获取得到轻量级锁。
  3. 假设修改失败,则自旋(重试),自旋一定次数后,则升级为重量级锁。

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

  1. 调用 lock 时,CAS 尝试获取锁,获取成功就可以执行
  2. CAS 获取锁失败,则调用 AQS 的 acquire 方法
  3. 调用子类的 tryAcquire 方法
  4. 判断当前的 state 是否等于 0,等于 0 说明没有线程持有锁,尝试获取锁
  5. 如果 CAS 成功,执行同步代码
  6. 如果 CAS 获取锁失败,判断当前线程是否持有锁,如果持有,更新 state 的值,获取锁(可重入)
  7. 如果 CAS 失败,且持有锁的不是当前线程,回到 tryAcquire 入队列
  8. 加入队列后,会判断前驱节点是不是头节点,如果是头节点又会用 CAS 尝试获取锁
  9. 如果没有获取锁,就会将前驱节点状态设置为 Signal
  10. 最后调用 LockSurpport.park() 方法将线程挂起

解锁流程:

  1. 调用 unlock()时,会调用 AQS 的 release 方法,release 方法又会调用子类的 tryRelease 方法
  2. tryRelease 会把 state 减一,如果减到 0,说明当前线程把锁释放了
  3. 随后从 队尾 往前找节点,状态要 <0 ,并且离头节点最近的老二节点唤醒,LockSupport.unpark
  4. 唤醒之后,被唤醒的线程尝试 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 指令对缓存或者总线去加锁==,来保证比较并替换的两个操作的原子性。

  1. JUC 中:Atomic 原子类实现
  2. 实现多线程对共享资源竞争的互斥性质:AQS,ConcurrentHashMap 等

缺点:

  1. 作为自旋锁的实现,会消耗大量 CPU 资源。并发高时性能不如加锁。
  2. ABA 问题(AtomicStampedReference 加版本号解决)。
  3. 只能保证一个共享变量的原子操作,多个变量的原子操作需要使用锁或者 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 的理解

作用实现资源对象线程间隔离,资线程内共享。

  1. ThreadLocal 可以实现 资源对象 的线程隔离,让每个线程各用各的 资源对象,避免争用引发线程安全问题
  2. 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 回收。