Java 并发实现原理:JDK 源码剖析 - 余春龙
Java 并发实现原理:JDK 源码剖析 - 余春龙
程序员朱永胜元数据
[!abstract] Java 并发实现原理:JDK 源码剖析
- 书名:Java 并发实现原理:JDK 源码剖析
- 作者:余春龙
- 简介:本书全面而系统地剖析了 Java Concurrent 包中的每一个部分,对并发的实现原理进行了深刻的探讨。全书分为 8 章,第 1 章从最基础的多线程知识讲起,理清多线程中容易误解的知识点,探究背后的原理,包括内存重排序、happen-before、内存屏障等;第 2~8 章,从简单到复杂,逐个剖析 Concurrent 包的每个部分,包括原子类、锁、同步工具类、并发容器、线程池、ForkJoinPool、CompletableFuture 共 7 个部分。本书遵循层层递进的逻辑,后一章建立在前一章的知识点基础之上,建议读者由浅入深,逐步深入阅读。本书适合有一定 Java 开发经验的工程师、架构师阅读。通过本书,读者可以对多线程编程形成一个 “ 深刻而直观 “ 的认识,而不是再仅仅停留在概念和理论层面。
- 出版时间:2020-03-01 00:00:00
- ISBN:9787121379727
- 分类:计算机 - 编程设计
- 出版社:电子工业出版社
- PC 地址:https://weread.qq.com/web/reader/6de3271071dbddc06de1a75
高亮划线
第 1 章 多线程基础
📌 一个线程一旦运行起来,就不要去强行打断它,合理的关闭办法是让其运行完(也就是函数执行完毕),干净地释放掉所有资源,然后退出
⏱ 2024-06-10 02:06:07 ^31186368-5-885-946
📌 如果是一个不断循环运行的线程,就需要用到线程间的通信机制,让主线程通知其退出。
⏱ 2024-06-10 02:06:14 ^31186368-5-947-986
📌 当在一个 JVM 进程里面开多个线程时,这些线程被分成两类:守护线程和非守护线程。默认开的都是非守护线程。在 Java 中有一个规定:当所有的非守护线程退出后,整个 JVM 进程就会退出。
⏱ 2024-06-10 02:07:15 ^31186368-5-1599-1687
1.2 InterruptedException()函数与 interrupt()函数
📌 实际上,只有那些声明了会抛出 InterruptedException 的函数才会抛出异常,也就是下面这些常用的函数:[插图]
⏱ 2024-06-10 02:08:44 ^31186368-6-1383-1473
📌 能够被中断的阻塞称为轻量级阻塞,对应的线程状态是 WAITING 或者 TIMED_WAITING;而像 synchronized 这种不能被中断的阻塞称为重量级阻塞,对应的状态是 BLOCKED。如图 1-1 所示的是在调用不同的函数之后,一个线程完整的状态迁移过程。[插图]
⏱ 2024-06-10 02:09:25 ^31186368-6-1769-1937
📌 WAITING 或者 TIMED_WAITING 状态,两者的区别只是前者为无限期阻塞,后者则传入了一个时间参数,阻塞一个有限的时间。如果使用了 synchronized 关键字或者 synchronized 块,则会进入 BLOCKED 状态
⏱ 2024-06-10 02:10:24 ^31186368-6-2371-2484
📌 t.interrupted()的精确含义是 “ 唤醒轻量级阻塞 “,而不是字面意思 “ 中断一个线程 “
⏱ 2024-06-10 02:10:45 ^31186368-6-2642-2688
📌 t.interrupted()相当于给线程发送了一个唤醒的信号,所以如果线程此时恰好处于 WAITING 或者 TIMED_WAITING 状态,就会抛出一个 InterruptedException,并且线程被唤醒
⏱ 2024-06-10 02:11:21 ^31186368-6-2863-2966
1.3 Synchronized 关键字
📌 对于非静态成员函数,锁其实是加在对象 a 上面的;对于静态成员函数,锁是加在 A.class 上面的
⏱ 2024-06-10 02:12:25 ^31186368-7-1181-1227
📌 一个静态成员函数和一个非静态成员函数,都加了 synchronized 关键字,分别被两个线程调用,它们是否互斥?很显然,因为是两把不同的锁,所以不会互斥。
⏱ 2024-06-10 02:12:40 ^31186368-7-1299-1375
📌 synchronized 实现原理答案在 Java 的对象头里。在对象头里,有一块数据叫 Mark Word。在 64 位机器上,Mark Word 是 8 字节(64 位)的,这 64 位中有 2 个重要字段:锁标志位和占用该锁的 thread ID。因为不同版本的 JVM 实现,对象头的数据结构会有各种差异,此处不再进一步讨论
⏱ 2024-06-10 02:14:20 ^31186368-7-2993-3172
1.4 wait()与 notify()
📌 如何阻塞?办法 1:线程自己阻塞自己,也就是生产者、消费者线程各自调用 wait()和 notify()。办法 2:用一个阻塞队列,当取不到或者放不进去数据的时候,入队 / 出队函数本身就是阻塞的。这也就是 BlockingQueue 的实现
⏱ 2024-06-10 02:16:08 ^31186368-8-1380-1558
📌 如何双向通知?办法 1:wait()与 notify()机制。办法 2:Condition 机制。
⏱ 2024-06-10 02:16:00 ^31186368-8-1617-1727
📌 在 wait()的内部,会先释放锁 obj1,然后进入阻塞状态,之后,它被另外一个线程用 notify()唤醒,去重新拿锁!其次,wait()调用完成后,执行后面的业务逻辑代码,然后退出 synchronized 同步块,再次释放锁
⏱ 2024-06-10 02:18:02 ^31186368-8-3221-3332
📌 wait()内部的伪代码如下:[插图] 只有如此,才能避免上面所说的死锁问题
⏱ 2024-06-10 02:18:10 ^31186368-8-3362-3611
1.5 Volatile 关键字
📌 在 32 位的机器上,一个 64 位变量的写入可能被拆分成两个 32 位的写操作来执行
⏱ 2024-06-10 02:20:40 ^31186368-9-1144-1181
📌 内存可见性 “,指的是 “ 写完之后立即对其他线程可见 “,它的反面不是 “ 不可见 “,而是 “ 稍后才能可见 “
⏱ 2024-06-10 02:21:28 ^31186368-9-1742-1790
📌 DCL(Double Checking Locking),
⏱ 2024-06-10 02:21:53 ^31186368-9-2048-2077
📌 volatile 的三重功效:64 位写入的原子性、内存可见性和禁止重排序
⏱ 2024-06-10 02:22:59 ^31186368-9-2706-2741
1.6 JMM 与 Happen-before
📌 多 CPU,每个 CPU 多核,每个核上面可能还有多个硬件线程,对于操作系统来讲,就相当于一个个的逻辑 CPU。每个逻辑 CPU 都有自己的缓存,这些缓存和主内存之间不是完全同步的。对应到 Java 里,就是 JVM 抽象内存模型,如图 1-7 所示。[插图] 图 1-7 JVM 抽象内存模型
⏱ 2024-06-10 02:24:58 ^31186368-10-2077-2455
📌 Store Buffer 的延迟写入是重排序的一种,称为内存重排序(Memory Ordering)。除此之外,还有编译器和 CPU 的指令重排序。下面对重排序做一个分类:(1)编译器重排序。对于没有先后依赖关系的语句,编译器可以重新调整语句的执行顺序。(2)CPU 指令重排序。在指令级别,让没有依赖关系的多条指令并行。(3)CPU 内存重排序。CPU 有自己的缓存,指令的执行顺序和写入主内存的顺序不完全一致。
⏱ 2024-06-10 02:25:27 ^31186368-10-2682-2970
📌 对于多线程程序来说,线程之间的数据依赖性太复杂,编译器和 CPU 没有办法完全理解这种依赖性并据此做出最合理的优化。所以,编译器和 CPU 只能保证每个线程的 as-if-serial 语义。线程之间的数据依赖和相互影响,需要编译器和 CPU 的上层来确定。上层要告知编译器和 CPU 在多线程场景下什么时候可以重排序,什么时候不能重排序。
⏱ 2024-06-10 02:27:22 ^31186368-10-5436-5596
📌 为了明确定义在多线程场景下,什么时候可以重排序,什么时候不能重排序,Java 引入了 JMM(Java Memory Model),也就是 Java 内存模型(单线程场景不用说明,有 as-if-serial 语义保证)。这个模型就是一套规范,对上,是 JVM 和开发者之间的协定;对下,是 JVM 和编译器、CPU 之间的协定。
⏱ 2024-06-10 02:28:06 ^31186368-10-6130-6285
📌 为了描述这个规范,JMM 引入了 happen-before,使用 happen-before 描述两个操作之间的内存可见性。那么,happen-before 是什么呢?如果 A happen-before B,意味着 A 的执行结果必须对 B 可见,也就是保证跨线程的内存可见性。A happen before B 不代表 A 一定在 B 之前执行。因为,对于多线程程序而言,两个操作的执行顺序是不确定的。happen-before 只确保如果 A 在 B 之前执行,则 A 的执行结果必须对 B 可见。定义了内存可见性的约束,也就定义了一系列重排序的约束。
⏱ 2024-06-10 02:29:02 ^31186368-10-6528-6813
📌 对于非 volatile 变量的写入和读取,不在这个承诺之列。通俗来讲,就是 JMM 对编译器和 CPU 来说,volatile 变量不能重排序;非 volatile 变量可以任意重排序。JMM 没有对非 volatile 变量做这个承诺
⏱ 2024-06-10 02:29:46 ^31186368-10-7178-7289
📌 happen-before 还具有传递性,即若 A happen-before B,B happen-before C,则 A happen-before C。
⏱ 2024-06-10 02:29:56 ^31186368-10-7477-7554
📌 Java 中的 volatile 关键字不仅具有内存可见性,还会禁止 volatile 变量写入和非 volatile 变量写入的重排序,但 C++ 中的 volatile 关键字不会禁止这种重排序。
⏱ 2024-06-10 02:31:41 ^31186368-10-9726-9815
1.7 内存屏障
📌 为了禁止编译器重排序和 CPU 重排序,在编译器和 CPU 层面都有对应的指令,也就是内存屏障(Memory Barrier)。这也正是 JMM 和 happen-before 规则的底层实现原理。
⏱ 2024-06-10 07:46:51 ^31186368-11-454-547
📌 从 JDK 8 开始,Java 在 Unsafe 类中提供了三个内存屏障函数,如下所示。[插图]
⏱ 2024-06-10 07:53:46 ^31186368-11-2044-2115
📌 在理论层面,可以把基本的 CPU 内存屏障分成四种:(1)LoadLoad:禁止读和读的重排序。(2)StoreStore:禁止写和写的重排序。(3)LoadStore:禁止读和写的重排序。(4)StoreLoad:禁止写和读的重排序。
⏱ 2024-06-10 07:53:19 ^31186368-11-2322-2554
📌 loadFence=LoadLoad+LoadStorestoreFence=StoreStore+LoadStorefullFence=loadFence+storeFence+StoreLoad
⏱ 2024-06-10 07:54:05 ^31186368-11-2948-3105
📌 具体到 x86 平台上,其实不会有 LoadLoad、LoadStore 和 StoreStore 重排序,只有 StoreLoad 一种重排序(内存屏障),也就是只需要在 volatile 写操作后面加上 StoreLoad 屏障
⏱ 2024-06-10 07:55:41 ^31186368-11-3665-3769
1.8 Final 关键字
📌 final 关键字也有相应的 happen-before 语义:(1)对 final 域的写(构造函数内部),happen-before 于后续对 final 域所在对象的读。(2)对 final 域所在对象的读,happen-before 于后续对 final 域的读
⏱ 2024-06-10 07:59:00 ^31186368-12-1526-1706
1.9 综合应用:无锁编程
📌 一写一读的无锁队列:内存屏障
⏱ 2024-06-10 08:02:22 ^31186368-13-805-819
📌 一写多读的无锁队列:volatile 关键字
⏱ 2024-06-10 08:02:19 ^31186368-13-998-1019
📌 多写多读的无锁队列:CAS
⏱ 2024-06-10 08:02:08 ^31186368-13-1636-1649
📌 无锁栈
⏱ 2024-06-10 08:02:37 ^31186368-13-2024-2027
📌 无锁链表
⏱ 2024-06-10 08:02:44 ^31186368-13-2258-2262
2.1 AtomicInteger 和 AtomicLong
📌 对于悲观锁,作者认为数据发生并发冲突的概率很大,所以读操作之前就上锁。synchronized 关键字,以及后面要讲的 ReentrantLock 都是悲观锁的典型例子
⏱ 2024-06-10 08:50:16 ^31186368-15-1439-1520
📌 Unsafe 类是整个 Concurrent 包的基础,里面所有函数都是 native 的
⏱ 2024-06-10 08:51:34 ^31186368-15-2421-2461
📌 在 Unsafe 中专门有一个函数,把成员变量转化成偏移量,如下所示。[插图]
⏱ 2024-06-10 08:52:24 ^31186368-15-2919-2984
2.3 AtomicStampedReference 和 AtomicMarkableReference
📌 AtomicMarkableReference 与 AtomicStampedReference 原理类似,只是 Pair 里面的版本号是 boolean 类型的,而不是整型的累加变量
⏱ 2024-06-10 08:57:18 ^31186368-17-2305-2390
2.4 AtomicIntegerFieldUpdater、AtomicLongFieldUpdater 和 AtomicReferenceFieldUpdater
📌 要想使用 AtomicIntegerFieldUpdater 修改成员变量,成员变量必须是 volatile 的 int 类型
⏱ 2024-06-10 08:59:57 ^31186368-18-1838-1895
2.6 Striped64 与 LongAdder
📌 ConcurrentHashMap 中的 clear()函数,一边执行清空操作,一边还有线程放入数据,clear()函数调用完毕后再读取,hash map 里面可能还有元素
⏱ 2024-06-10 09:04:07 ^31186368-20-2465-2549
📌 缓存与主内存进行数据交换的基本单位叫 Cache Line(缓存行)。在 64 位 x86 架构中,缓存行是 64 字节,也就是 8 个 Long 型的大小。这也意味着当缓存失效,要刷新到主内存的时候,最少要刷新 64 字节。
⏱ 2024-06-10 09:07:11 ^31186368-20-2908-3008
📌 LongAdder 的 add(x)函数调用的是 casBase(b,b+x),这里调用的是 casBase(b,r),其中,r=function.applyAsLong(b=base,x)。
⏱ 2024-06-10 09:14:39 ^31186368-20-8850-8942
📌 Double.doubleToRawLongBits(Double.longBitsToDouble(b)+x),在读出来的时候,它把 long 类型转换成 double 类型,然后进行累加,累加的结果再转换成 long 类型,通过 CAS 写回去。
⏱ 2024-06-10 09:15:10 ^31186368-20-9628-9747
第 3 章 Lock 与 Condition
📌 在 Concurrent 包中的锁都是 “ 可重入锁 “
⏱ 2024-06-10 09:18:46 ^31186368-21-664-687
📌 “ 可重入锁 “ 是指当一个线程调用 object.lock()拿到锁,进入互斥区后,再次调用 object.lock(),仍然可以拿到该锁。
⏱ 2024-06-10 09:19:08 ^31186368-21-714-780
📌 lock()不能被中断,对应的 lockInterruptibly()可以被中断。
⏱ 2024-06-10 09:20:25 ^31186368-21-2041-2081
📌 Sync 的父类 AbstractQueuedSynchronizer 经常被称作队列同步器(AQS),
⏱ 2024-06-10 09:21:40 ^31186368-21-3233-3282
📌 unpark(Thread t),它实现了一个线程对另外一个线程的 “ 精准唤醒 “。前面讲到的 wait()/notify(),notify 也只是唤醒某一个线程,但无法指定具体唤醒哪个线程
⏱ 2024-06-10 09:23:40 ^31186368-21-4931-5022
📌 线程一旦进入 acquireQueued(..)就会被无限期阻塞,即使有其他线程调用 interrupt()函数也不能将其唤醒,除非有其他线程释放了锁,并且该线程拿到了锁,才会从 accquireQueued(..)返回 ^31186368-21-7742-7849
- 💭 使用 acquireQueued 要注意 - ⏱ 2024-06-10 09:27:44
📌 进入 acquireQueued(..),该线程被阻塞。在该函数返回的一刻,就是拿到锁的那一刻,也就是被唤醒的那一刻,此时会删除队列的第一个元素(head 指针前移 1 个节点)。
⏱ 2024-06-10 09:28:30 ^31186368-21-7908-7994
📌 acquireQueued(..)函数有一个返回值,表示什么意思呢?虽然该函数不会中断响应,但它会记录被阻塞期间有没有其他线程向它发送过中断信号。如果有,则该函数会返回 true;否则,返回 false。
⏱ 2024-06-10 09:29:30 ^31186368-21-8213-8313
📌 release()里面做了两件事:tryRelease(..)函数释放锁;unparkSuccessor(..)函数唤醒队列中的后继者。
⏱ 2024-06-10 09:32:10 ^31186368-21-10053-10121
📌 因为是排他锁,只有已经持有锁的线程才有资格调用 release(..),这意味着没有其他线程与它争抢。所以,在上面的 tryRelease(..)函数中,对 state 值的修改,不需要 CAS 操作,直接减 1 即可。
⏱ 2024-06-10 09:32:28 ^31186368-21-10167-10269
📌 tryLock()实现基于调用非公平锁的 tryAcquire(..),对 state 进行 CAS 操作,如果操作成功就拿到锁;如果操作不成功则直接返回 false,也不阻塞。
⏱ 2024-06-10 09:34:03 ^31186368-21-11566-11649
3.3 Condition
📌 读写锁中的 ReadLock 是不支持 Condition 的,读写锁的写锁和互斥锁都支持 Condition
⏱ 2024-06-10 10:41:45 ^31186368-23-2525-2577
3.4 StampedLock
📌 StampedLock 引入了 “ 乐观读 “ 策略,读的时候不加读锁,读出来发现数据被修改了,再升级为 “ 悲观读 “,相当于降低了 “ 读 “ 的地位,把抢锁的天平往 “ 写 “ 的一方倾斜了一下,避免写线程被饿死
⏱ 2024-06-10 10:51:08 ^31186368-24-1210-1303
📌 整个 acquireWrite(..)函数是两个大的 for 循环,内部实现了非常复杂的自旋策略。在第一个大的 for 循环里面,目的就是把该 Node 加入队列的尾部,一边加入,一边通过 CAS 操作尝试获得锁。如果获得了,整个函数就会返回;如果不能获得锁,会一直自旋,直到加入队列尾部。在第二个大的 for 循环里,也就是该 Node 已经在队列尾部了。这个时候,如果发现自己刚好也在队列头部,说明队列中除了空的 Head 节点,就是当前线程了。此时,再进行新一轮的自旋,直到达到 MAX_HEAD_SPINS 次数,然后进入阻塞。这里有一个关键点要说明:当 release(..)函数被调用之后,会唤醒队列头部的第 1 个元素,此时会执行第二个大的 for 循环里面的逻辑,也就是接着 for 循环里面 park()函数后面的代码往下执行。
⏱ 2024-06-10 11:00:33 ^31186368-24-6324-6701
📌 另外一个不同于 AQS 的阻塞队列的地方是,在每个 WNode 里面有一个 cowait 指针,用于串联起所有的读线程。例如,队列尾部阻塞的是一个读线程 1,现在又来了读线程 2、3,那么会通过 cowait 指针,把 1、2、3 串联起来。1 被唤醒之后,2、3 也随之一起被唤醒,因为读和读之间不互斥。
⏱ 2024-06-10 10:59:45 ^31186368-24-6730-6869
📌 锁的释放操作。和读写锁的实现类似,也是做了两件事情:一是把 state 变量置回原位,二是唤醒阻塞队列中的第一个节点。节点被唤醒之后,会继续执行上面的第二个大的 for 循环,自旋拿锁。如果成功拿到,则出队列;如果拿不到,则再次进入阻塞,等待下一次被唤醒。
⏱ 2024-06-10 11:00:26 ^31186368-24-6913-7037
4.3 CyclicBarrier
📌(1)CyclicBarrier 是可以被重用的。以上一节的应聘场景为例,来了 10 个线程,这 10 个线程互相等待,到齐后一起被唤醒,各自执行接下来的逻辑;然后,这 10 个线程继续互相等待,到齐后再一起被唤醒。每一轮被称为一个 Generation,就是一次同步点。(2)CyclicBarrier 会响应中断。10 个线程没有到齐,如果有线程收到了中断信号,所有阻塞的线程也会被唤醒,就是上面的 breakBarrier()函数。然后 count 被重置为初始值(parties),重新开始。(3)上面的回调函数,barrierAction 只会被第 10 个线程执行 1 次(在唤醒其他 9 个线程之前),而不是 10 个线程每个都执行 1 次。
⏱ 2024-06-10 21:28:35 ^31186368-28-2863-3226
4.5 Phaser
📌 CyclicBarrier 所要同步的线程个数是在构造函数中指定的,之后不能更改,而 Phaser 可以在运行期间动态地调整要同步的线程个数。Phaser 提供了下面这些函数来增加、减少所要同步的线程个数。
⏱ 2024-06-10 21:32:34 ^31186368-30-1773-1875
📌 可以发现,在 Phaser 的内部结构中,每个 Phaser 记录了自己的父节点,但并没有记录自己的子节点列表。所以,每个 Phaser 知道自己的父节点是谁,但父节点并不知道自己有多少个子节点,对父节点的操作,是通过子节点来实现的。
⏱ 2024-06-10 21:32:53 ^31186368-30-2893-3005
5.1 BlockingQueue
📌 ArrayBlockingQueue 是一个用数组实现的环形队列,在构造函数中,会要求传入数组的容量
⏱ 2024-06-10 22:14:50 ^31186368-32-1708-1758
📌 LinkedBlockingQueue 是一种基于单向链表的阻塞队列。因为队头和队尾是 2 个指针分开操作的,所以用了 2 把锁 +2 个条件,同时有 1 个 AtomicInteger 的原子变量记录 count 数。
⏱ 2024-06-10 22:15:58 ^31186368-32-2750-2847
📌 PriorityQueue 是按照元素的优先级从小到大出队列的。正因为如此,PriorityQueue 中的 2 个元素之间需要可以比较大小,并实现 Comparable 接口。
⏱ 2024-06-10 22:17:00 ^31186368-32-4542-4625
📌 如果不指定初始大小,内部会设定一个默认值 11,当元素个数超过这个大小之后,会自动扩容。
⏱ 2024-06-10 22:17:14 ^31186368-32-4891-4934
📌 DelayQueue 即延迟队列,也就是一个按延迟时间从小到大出队的 PriorityQueue。所谓延迟时间,就是 “ 未来将要执行的时间 “-“ 当前时间 “。为此,放入 DelayQueue 中的元素,必须实现 Delayed 接口
⏱ 2024-06-10 22:17:37 ^31186368-32-5831-5939
📌 SynchronousQueue 是一种特殊的 BlockingQueue,它本身没有容量。先调 put(..),线程会阻塞;直到另外一个线程调用了 take(),两个线程才同时解锁,反之亦然。对于多个线程而言,例如 3 个线程,调用 3 次 put(..),3 个线程都会阻塞;直到另外的线程调用 3 次 take(),6 个线程才同时解锁,反之亦然。
⏱ 2024-06-10 22:18:19 ^31186368-32-7733-7896
5.3 CopyOnWrite
📌 CopyOnWrite 指在 “ 写 “ 的时候,不是直接 “ 写 “ 源数据,而是把数据拷贝一份进行修改,再通过悲观锁或者乐观锁的方式写回。那为什么不直接修改,而是要拷贝一份修改呢?这是为了在 “ 读 “ 的时候不加锁。
⏱ 2024-06-13 23:32:39 ^31186368-34-461-559
5.5 ConcurrentHashMap
📌 为了提高并发度,在 JDK7 中,一个 HashMap 被拆分为多个子 HashMap。每一个子 HashMap 称作一个 Segment,多个线程操作多个 Segment 相互独立,如图 5-9 所示。[插图] 图 5-9 JDK 7 中 ConcurrentHashMap 数据结构示意图具体来说,每个 Segment 都继承自 ReentrantLock,Segment 的数量等于锁的数量,这些锁彼此之间相互独立,即所谓的 “ 分段锁 “。
⏱ 2024-06-15 23:37:09 ^31186368-36-710-1167
📌 [插图] 构造函数的第 3 个参数 concurrenyLevel,是 “ 并发度 “,也就是 Segment 数组的大小。这个值一旦在构造函数中设定,之后不能再扩容。为了提升 hash 的计算性能,会保证数组的大小始终是 2 的整数次方。例如设置 concurrentyLevel=9,在构造函数里面会找到比 9 大且距 9 最近的 2 的整数次方,也就是 ssize=16。对应 segmentShift、segmentMask 两个变量,是为了方便计算 hash 使用的。
⏱ 2024-06-15 23:38:12 ^31186368-36-1524-1921
📌 默认的 Segment 数组大小是 16
⏱ 2024-06-15 23:38:21 ^31186368-36-1986-2003
6.6 Executors 工具类
📌 在《阿里巴巴 Java 开发手册》中,明确禁止使用 Executors 创建线程池,并要求开发者直接使用 ThreadPoolExector 或 ScheduledThreadPoolExecutor 进行创建。这样做是为了强制开发者明确线程池的运行策略,使其对线程池的每个配置参数皆做到心中有数,以规避因使用不当而造成资源耗尽的风险。
⏱ 2024-07-04 23:14:34 ^31186368-43-804-964
8.1 CompletableFuture 用法
📌 没有返回值的任务,提交的是 Runnable,返回的是 CompletableFuture<;Void>;;有返回值的任务,提交的是 Supplier,返回的是 CompletableFuture<;String>;。
⏱ 2024-07-05 12:39:15 ^31186368-54-1999-2128
📌,在基本的用法上,CompletableFuture 和 Future 很相似,都可以提交两类任务:一类是无返回值的,另一类是有返回值的。
⏱ 2024-07-05 12:39:00 ^31186368-54-2193-2259
8.2 四种任务原型
📌 四种任务原型的对比 [插图]
⏱ 2024-07-05 12:43:25 ^31186368-55-617-631
读书笔记
1.7 内存屏障
划线评论
📌(1)在 volatile 写操作的前面插入一个 StoreStore 屏障。保证 volatile 写操作不会和之前的写操作重排序。(2)在 volatile 写操作的后面插入一个 StoreLoad 屏障。保证 volatile 写操作不会和之后的读操作重排序。(3)在 volatile 读操作的后面插入一个 LoadLoad 屏障 +LoadStore 屏障。保证 volatile 读操作不会和之后的读操作、写操作重排序。^280435523-7RPX9MyGZ
- 💭 volatile 实现原理
- ⏱ 2024-06-10 07:55:21
1.8 Final 关键字
划线评论
📌 一个对象的构造并不是 “ 原子的 “,当一个线程正在构造对象时,另外一个线程却可以读到未构造好的 “ 一半对象 “ ^280435523-7RPXisn7G
- 💭 对象初始化分三步。第一步:分配内存 第二步:初始化 第三步:更新指针
- ⏱ 2024-06-10 07:57:30
划线评论
📌 如果 i,j 只需要初始化一次,则后续值就不会再变了,还有办法 3,为其加上 final 关键字 ^280435523-7RPXny9aE
- 💭 变量只需要初始化一次的情况下,除了 volatile 和 synchronized 关键字,还有 final 可以
- ⏱ 2024-06-10 07:58:45
划线评论
📌(1)单线程中的每个操作,happen-before 于该线程中任意后续操作。(2)对 volatile 变量的写,happen-before 于后续对这个变量的读。(3)对 synchronized 的解锁,happen-before 于后续对这个锁的加锁。(4)对 final 变量的写,happen-before 于 final 域对象的读,happen-before 于后续对 final 变量的读。四个基本规则再加上 happen-before 的传递性,就构成 JMM 对开发者的整个承诺。在这个承诺以外的部分,程序都可能被重排序,都需要开发者小心地处理内存可见性问题。图 1-9 表示了 volatile 背后的原理。[插图] 图 1-9 从底向上看 volatile 背后的原理 ^280435523-7RPXtjBbe
- 💭 volatile 原理
- ⏱ 2024-06-10 08:00:10
2.1 AtomicInteger 和 AtomicLong
划线评论
📌 对于乐观锁,作者认为数据发生并发冲突的概率比较小,所以读操作之前不上锁。等到写操作的时候,再判断数据在此期间是否被其他线程修改了。如果被其他线程修改了,就把数据重新读出来,重复该过程;如果没有被修改,就写回去。判断数据是否被修改,同时写回新值,这两个操作要合成一个原子操作,也就是 CAS(Compare And Set)。^280435523-7RQ0MbSlq
- 💭 CAS 基本概念
- ⏱ 2024-06-10 08:50:37
2.3 AtomicStampedReference 和 AtomicMarkableReference
划线评论
📌 CAS 都是基于 “ 值 “ 来做比较的。但如果另外一个线程把变量的值从 A 改为 B,再从 B 改回到 A,那么尽管修改过两次,可是在当前线程做 CAS 操作的时候,却会因为值没变而认为数据没有被其他线程修改过,这就是所谓的 ABA 问题。^280435523-7RQ14TLLg
- 💭 ABA 问题
- ⏱ 2024-06-10 08:55:14
划线评论
📌 要解决 ABA 问题,不仅要比较 “ 值 “,还要比较 “ 版本号 “,而这正是 AtomicStamped-Reference 做的事情,其对应的 CAS 函数如下:[插图] ^280435523-7RQ17kaDs
- 💭 ABA 问题的解决方式是加上版本号
- ⏱ 2024-06-10 08:55:50
划线评论
📌 为什么没有 AtomicStampedInteger 或 AtomictStampedLong ^280435523-7RQ1acGLh
- 💭 因为这里要同时比较数据的 “ 值 “ 和 “ 版本号 “,而 Integer 型或者 Long 型的 CAS 没有办法同时比较两个变量,于是只能把值和版本号封装成一个对象,也就是这里面的 Pair 内部类,然后通过对象引用的 CAS 来实现。
- ⏱ 2024-06-10 08:56:32
2.4 AtomicIntegerFieldUpdater、AtomicLongFieldUpdater 和 AtomicReferenceFieldUpdater
划线评论
📌 如果一个类是自己编写的,则可以在编写的时候把成员变量定义为 Atomic 类型。但如果是一个已经有的类,在不能更改其源代码的情况下,要想实现对其成员变量的原子操作,就需要 AtomicIntegerFieldUpdater、AtomicLongFieldUpdater 和 AtomicReferenceFieldUpdater。^280435523-7RQ1hmwUm
- 💭 为什么需要 AtomicXXXFieldUpdater
- ⏱ 2024-06-10 08:58:18
2.6 Striped64 与 LongAdder
划线评论
📌 AtomicLong 内部是一个 volatile long 型变量,由多个线程对这个变量进行 CAS 操作。多个线程同时对一个变量进行 CAS 操作,在高并发的场景下仍不够快,如果再要提高性能,该怎么做呢?把一个变量拆成多份,变为多个变量,有些类似于 ConcurrentHashMap 的分段锁的例子。如图 2-3 所示,把一个 Long 型拆成一个 base 变量外加多个 Cell,每个 Cell 包装了一个 Long 型变量。当多个线程并发累加的时候,如果并发度低,就直接加到 base 变量上;如果并发度高,冲突大,平摊到这些 Cell 上。在最后取值的时候,再把 base 和这些 Cell 求 sum 运算。[插图] ^280435523-7RQ1yzupp
- 💭 LongAdder 原理
- ⏱ 2024-06-10 09:02:32
划线评论
📌 在 sum 求和函数中,并没有对 cells[] 数组加锁。也就是说,一边有线程对其执行求和操作,一边还有线程修改数组里的值,也就是最终一致性,而不是强一致性 ^280435523-7RQ1Je10f
- 💭 LongAddr 只能保证最终一致性,无法保证强一致性
- ⏱ 2024-06-10 09:05:10
划线评论
📌 LongAdder 开篇的注释中,把它和 AtomicLong 的使用场景做了比较。它适合高并发的统计场景,而不适合要对某个 Long 型变量进行严格同步的场景 ^280435523-7RQ1H8TUn
- 💭 LongAdder 和 AtomicLong 使用场景区别
- ⏱ 2024-06-10 09:04:39
划线评论
📌 主内存中有变量 X、Y、Z(假设每个变量都是一个 Long 型),被 CPU1 和 CPU2 分别读入自己的缓存,放在了同一行 Cache Line 里面。当 CPU1 修改了 X 变量,它要失效整行 Cache Line,也就是往总线上发消息,通知 CPU 2 对应的 Cache Line 失效。由于 Cache Line 是数据交换的基本单位,无法只失效 X,要失效就会失效整行的 Cache Line,这会导致 Y、Z 变量的缓存也失效。[插图] 图 2-4 伪共享示意图虽然只修改了 X 变量,本应该只失效 X 变量的缓存,但 Y、Z 变量也随之失效。Y、Z 变量的数据没有修改,本应该很好地被 CPU1 和 CPU2 共享,却没做到,这就是所谓的 “ 伪共享问题 “ ^280435523-7RQ1PGywJ
- 💭 伪共享问题
- ⏱ 2024-06-10 09:06:45
划线评论
📌 问题的原因是,Y、Z 和 X 变量处在了同一行 Cache Line 里面。要解决这个问题,需要用到所谓的 “ 缓存行填充 “,分别在 X、Y、Z 后面加上 7 个无用的 Long 型,填充整个缓存行,让 X、Y、Z 处在三行不同的缓存行中,如图 2-5 所示。[插图] 图 2-5 缓存行填充示意图 ^280435523-7RQ1WIges
- 💭 解决伪共享问题的方法就是缓存行填充,每个变量后面填充 7 个整形变量
- ⏱ 2024-06-10 09:08:29
划线评论
📌 @sun.misc.Contended ^280435523-7RQ20R9tt
- 💭 总结下就是:避免了伪共享问题,自动在变量后面加 7 个 long 变量,保证每个 cache line 只会有一个变量
- ⏱ 2024-06-10 09:09:30
划线评论
📌 下面来看 LongAdder 最核心的累加函数 add(long x),自增、自减操作都是通过调用该函数实现的。[插图] 当一个线程调用 add(x)的时候,首先会尝试使用 casBase 把 x 加到 base 变量上。如果不成功,则再用 a.cas(..)函数尝试把 x 加到 Cell 数组的某个元素上。如果还不成功,最后再调用 longAccumulate(..)函数。^280435523-7RQ2bHy5a
- 💭 LongAddr 的 add 方法原理
- ⏱ 2024-06-10 09:12:10
划线评论
📌 Cell[] 数组的大小始终是 2 的整数次方,在运行中会不断扩容,每次扩容都是增长 2 倍 ^280435523-7RQ25XdDP
- 💭 LongAddr 扩容策略
- ⏱ 2024-06-10 09:10:45
划线评论
📌 LongAdder 只能进行累加操作,并且初始值默认为 0;LongAccumulator 可以自己定义一个二元操作符,并且可以传入一个初始值。^280435523-7RQ2eTClF
- 💭 LongAddr 和 LongAccumulater 区别
- ⏱ 2024-06-10 09:12:58
第 3 章 Lock 与 Condition
划线评论
📌 head 指向双向链表头部,tail 指向双向链表尾部。入队就是把新的 Node 加到 tail 后面,然后对 tail 进行 CAS 操作;出队就是对 head 进行 CAS 操作,把 head 向后移一个位置。[插图] 图 3-2 阻塞队列的示意图初始的时候,head=tail=NULL;然后,在往队列中加入阻塞的线程时,会新建一个空的 Node,让 head 和 tail 都指向这个空 Node;之后,在后面加入被阻塞的线程对象。所以,当 head=tail 的时候,说明队列为空。^280435523-7RQ31xeb1
- 💭 AQS 中的阻塞队列
- ⏱ 2024-06-10 09:24:56
划线评论
📌 线程一旦进入 acquireQueued(..)就会被无限期阻塞,即使有其他线程调用 interrupt()函数也不能将其唤醒,除非有其他线程释放了锁,并且该线程拿到了锁,才会从 accquireQueued(..)返回 ^280435523-7RQ3ejF6W
- 💭 使用 acquireQueued 要注意
- ⏱ 2024-06-10 09:28:05
划线评论
📌 acquireQueued(..)函数才写了一个 for 死循环。唤醒之后,如果发现自己排在队列头部,就去拿锁;如果拿不到锁,则再次自己阻塞自己。不断重复此过程,直到拿到锁。被唤醒之后,通过 Thread.interrupted()来判断是否被中断唤醒。如果是情况 1,会返回 false;如果是情况 2,则返回 true。^280435523-7RQ3syPku
- 💭 简单的 acquireQueued 流程
- ⏱ 2024-06-10 09:31:36
3.2 读写锁
划线评论
📌 当 state=0 时,说明既没有线程持有读锁,也没有线程持有写锁;当 state!=0 时,要么有线程持有读锁,要么有线程持有写锁,两者不能同时成立,因为读和写互斥。这时再进一步通过 sharedCount(state)和 exclusiveCount(state)判断到底是读线程还是写线程持有了该锁。^280435523-7RQ6Ly2q1
- 💭 读写锁,讲 state 拆分成低 16 位和高 16 位两部分,低 16 记录持有写的线程数,高 16 位记录持有读线程数
- ⏱ 2024-06-10 10:22:05
划线评论
📌 [插图] 把上面的代码拆开进行分析,如下:(1)if(c!=0)and w==0,说明当前一定是读线程拿着锁,写锁一定拿不到,返回 false。(2)if(c!=0)and w!=0,说明当前一定是写线程拿着锁,执行 current!=getExclusive-OwnerThread()的判断,发现 ownerThread 不是自己,返回 false。(3)c!=0,w!=0,且 current=getExclusiveOwnerThread(),才会走到 if(w+exclusive-Count(acquires)>;MAX_COUNT)。判断重入次数,重入次数超过最大值,抛出异常。因为是用 state 的低 16 位保存写锁重入次数的,所以 MAX_COUNT 是 216。如果超出这个值,会写到读锁的高 16 位上。为了避免这种情形,这里做了一个检测。当然,一般不可能重入这么多次。(4)if(c=0),说明当前既没有读线程,也没有写线程持有该锁。可以通过 CAS 操作开抢了。[插图] 抢成功后,调用 setExclusiveOwnerThread(current),把 ownerThread 设成自己。^280435523-7RQ7sJklV
- 💭 tryAcquire 实现原理
- ⏱ 2024-06-10 10:32:43
3.4 StampedLock
划线评论
📌 ReentrantLock 采用的是 “ 悲观读 “ 的策略,当第一个读线程拿到锁之后,第二个、第三个读线程还可以拿到锁,使得写线程一直拿不到锁,可能导致写线程 “ 饿死 “。虽然在其公平或非公平的实现中,都尽量避免这种情形,但还有可能发生 ^280435523-7RQ8EnimP
- 💭 ReentrantLock 无法避免 线程 饿死的场景
- ⏱ 2024-06-10 10:50:51
5.1 BlockingQueue
划线评论
📌 在 Concurrent 包中,BlockingQueue 是一个接口,有许多个不同的实现类,如图 5-1 所示。[插图] 图 5-1 BlockingQueue 的各种实现类 ^280435523-7RQRnsffS
- 💭 blockingQueue 的各种实现类
- ⏱ 2024-06-10 22:13:47