JUC
四万字长文解析 juc,涵盖线程、内存模型、锁、线程池、原子类、同步器、并发容器、并发编程模式、并发编程应用等。
版本:
- jdk: 11
- spring boot: 2.7.0
JUC 是 java.util.concurrent 包的缩写,是 java 提供的用来并发编程的工具包。juc 提供了多种用于多线程编程金额并发控制的接口和类。juc 主要包括以下五大类组件:
- 锁: juc 包中提供了多种锁的实现,如 ReentrantLock、ReentrantReadWriteLock、StampedLock 等。这些锁实现提供了更加灵活的同步机制,能够满足多种业务场景。
- 线程池: juc 包中提供了关于线程池操作的实现,如 ThreadPoolExecutor 等。可以用来自定义创建适合多种业务场景的线程池。
- 原子类: juc 包中提供的具有原子性操作的类,如 AtomicInteger、AtomicLong、AtomicRefrence 等。可以在无锁的情况下进行线程安全的原子性操作。
- 同步器: juc 包中提供一些具有线程同步功能的类,如 Semaphore、CountDownLatch、CyclicBarrier 等。可以协调多线程间的执行顺序和访问控制等。
- 并发容器: juc 包中提供的一些线程安全的容器,如 ConcurrentHashMap、BlockingQueue、CopyOnWriteArrayList 等。可以在多线程下安全使用。
1 线程
1.1 并发与并行
- 并发是指多个事件在同一时刻交替发生;并行是指多个事件在同一时刻独立发生。
- 并发是指同一个目标上的多个事件;并行是指多个目标上的多个事件。
- 并发是指同一个处理器同时处理多个任务;并行是指多个处理器同时处理多个任务。
1.2 进程与线程
1.2.1 OS 层面
1.2.1.1 进程
- 进程用来加载指令、管理内存、管理 IO 等资源。程序是由指令组成的,用来操作数据。进程的作用则是加载指令、管理数据所需要的内存、管理相关 IO 网络等资源。
- 进程可以视为程序的一个实例,打开一个程序则相当于启动一个进程。大部分程序可以同时运行多个实例,如 idea、vscode 等(打开一个窗口则相当于启动一个进程),少数程序则只能启动一个实例,如监控程序等。
1.2.1.2 线程
- 线程是进程的基本执行单元,进程的任务必须交给线程去执行。
- 一个线程就是一条指令流,将其中的指令按照一定顺序交给 CPU 去执行。
- 线程是 CPU 调用的基本单元。
二者区别与联系:
- 从其职责来看,进程是资源分配的基本单元,线程是执行的基本单元,同一个进程的多个之间共享资源。各个进程运行在其专用的且受保护的内存中。
- 每个进程拥有一到多个线程。进程启动(程序启动)时会默认开启一个线程,这个线程被称为主线程或 UI 线程。
- 进程间通信较为复杂:
- 同一台计算机间的进程通信称为 IPC(Inter process communication)。
- 不同计算机之间的进程通信需要借助网络,并遵守相关协议(如 HTTP 协议)。
- 线程间通信较为简单:
- 线程基于进程,而线程共享进程的内存。
1.2.2 java 层面
1.2.2.1 进程
- java 程序是运行在 jvm 上的,每启动一个 java 程序(此时会启动一个 jvm 实例)就会启动一个进程。
1.2.2.2 线程
- jvm 进程中可以创建多个线程,映射到 java 程序中每一个线程对应一个 Thread 类实例。
- java 中有一种特殊的线程叫做守护线程,只要其它非守护线程运行结束,此时,即使守护线程未执行完毕,也会强制结束。GC 线程和 Tomcat 中的 Acceptor、Poller 线程都为守护线程。
1.3 线程状态
1.3.1 OS 层面
操作系统层面定义的线程有以下五个状态:
- 初始状态:指在编程语言层面创建了线程对象,还未与操作系统线程关联。
- 可运行状态:即就绪状态。指操作系统层面的线程已被创建,即初始化状态的线程对象已关联到操作系统系统。此时线程已准备就绪,可被 CPU 调度器调度。注:只有处于可运行状态的线程才可能会被 CPU 调度。
- 运行状态:指可运行状态的线程获得 CPU 时间片,开始运行。当 CPU 时间片用完之后,运行状态的线程会进入可运行状态,等待下一次获得 CPU 时间片。状态切换之间会导致上下文切换。
- 阻塞状态:指运行状态的线程若调用了阻塞 API(如 BIO 读写文件等)会使线程进入阻塞状态,切会发生线程上下文切换。等阻塞操作结束(如 BIO 读写文件操作结束),会由操作系统将阻塞线程唤醒,重新进入可运行状态。
- 终止状态:指线程已执行完毕,生命周期结束。
1.3.2 java 层面
Java 语言层面定义的线程有以下六种状态:
- NEW:即线程对象被创建,但还未调用 start() 方法。
- RUNNABLE:即可运行状态,当线程对象调用 start() 方法后线程将进入可运行状态(或就绪状态)。java 层面的 RUNNABLE 状态对应 CPU 层面的可运行状态、运行状态、阻塞状态。
- BLOCKED、WAITING、TIMED_WAITING:这三种状态整体上都为阻塞状态。
- TERMINATED:终止状态,即线程代码执行完毕,线程运行结束。
各状态见的连线表示线程状态转换,请移步 3.3.7。
1.4 线程创建
Java 中定义了 Thread 类来表示线程,其内部枚举类 Thread.State 维护了线程的六种状态。线程的创建共有以下三种方式。
1.4.1 继承 Thread 类
Thread 类为 Java API 中用来描述线程的,其类图如上所示。其用法如下:
// 创建线程对象
Thread thread = new Thread() {
@Override
public void run() {
log.info("one")
}
}
// 调用其 start() 方法启动线程
thread.start();
1.4.2 实现 Runnable 接口
Runnable 接口是 juc 包提供的任务接口,其定义了 run() 方法,用来运行具体任务。通过其创建的任务可交由 Thread 线程对象去执行。其用法如下:
// 创建任务对象(通过函数式接口)
Runnable task = () -> {
log.info("two");
};
// 交由线程去执行
Thread thread = new Thread(task);
thread.start();
1.4.3 实现 Callable 接口
Callable< V> 是 juc 包提供的有返回值的任务接口,由其创建的任务可交由 Thread 线程去执行。其执行接口可通过 FutureTask< V> 来接收,其中范型 V 表示任务执行的结果类型。其用法如下:
// 创建任务对象(通过函数式接口创建) 任务执行结果为 String 类型
Callable<String> task = () -> {
return "three";
};
// 创建执行结果接收对象
FutureTask<String> future = new FutureTask<>(task);
// 将任务交由线程去执行
Thread thread = new Thread(future);
thread.start();
// 通过 FutureTask<V> 实例的 get() 获取任务执行结果
log.info("{}", future.get());
1.5 线程运行
1.5.1 线程启动
- 线程启动时 jvm 会为其分配栈内存。
- 栈内存为当前线程调用方法时所需要的空间提供内存。
- 每个栈由多个栈帧组成,每个栈帧对应着每一次的方法调用。
- 每个栈内只能有一个活动栈帧,对应着该线程当前执行的方法。
- 线程启动时 jvm 为其分配 TLAB 内存。
- TLAB 即线程本地分配缓冲,为该线程创建的对象分配所需空间。其作用是解决并发下内存分配竞争问题。
1.5.2 线程上下文切换
线程上下文切换是指由于某些原因导致 CPU 不在执行当前线程,而去执行另外的线程。造成线程上下文切换的原因有以下几种:
- 线程当前 CPU 时间片用完。
- 有优先级较高的线程需要执行。
- 发生 GC。
- 线程自己调用了 sleep、yield、join、wait、park、synchronized、lock 等。
发生线程上下文切换时,需要保存当前线程的状态,并恢复另一个要执行的线程的状态,对应到 java 中的概念则为程序计数器和虚拟机栈中的每个栈帧的信息。程序计数器的作用记住下一条要执行的 jvm 命令的地址,栈帧则表示了当前线程所执行的方法涉及到的局部变量、操作数栈、返回地址等。
注:频繁发生上下文切换会影响程序性能。
1.6 常见方法
2 内存模型
2.1 CPU 缓存
2.1.1 缓存结构
由于 CPU 的计算速度非常快,而内存的访问速度相对而言较慢,若 CPU 每次都从内存读取数据会造成 CPU 等待,降低 CPU 利用率,所以,在 CPU 与内存之间引入了高速缓存,来减少内存访问次数,从而提高整体性能。
CPU 缓存及其物理实现如上图所示,缓存分为三级分别是:一级缓存、二级缓存、三级缓存,且其访问速度依次递减,其大小依次递增,L1、L2 为各 CPU 私有,L3为各 CPU 共享。
下图为 CPU 访问各存储介质所需时间(时钟周期):
存储介质 | 所需时钟周期 |
---|---|
寄存器 | 1 cycle (4 GHz 的 CPU 约为 0.25ns) |
一级缓存 | 3 ~ 4 cycle |
二级缓存 | 10 ~ 20 cycle |
三级缓存 | 40 ~ 45 cycle |
内存 | 120 ~ 240 cycle |
CPU 缓存的最小读写单元为缓存行(Cache Line),且其从内存往缓存读取数据是一小块一小块读取的,每一块对应一个缓存行,缓存行大小一般为 64 byte。CPU 读取数据时会按照一级缓存、二级缓存、三级缓存、内存顺序依次读取,即从高级缓存到低级缓存。缓存行结构如下:
- flag: 标志位,用来表示当前缓存行是否有效。当缓存行数据被更新或替换时,标志位会被清除,表示该缓存行已失效。缓存行失效意味着 CPU 下次读取数据时需要从更低级的介质中读取。
- tag: 标记,用来表示该缓存行数据来自于哪个主存地址。当 CPU 进行数据读取时会按照该标记判断缓存是否命中。
- data: 数据区域,用来存储从主存中读取到的数据块。
2.1.2 读写策略
上图为 CPU 缓存数据的读写策略,即缓存数据读写原则。当缓存行中的数据更新后,同时需要将该缓存行标记为脏数据,表示该缓存行数据已被修改,当该缓存行下次被使用时,若为脏,则需要将缓存行中数据写入低级存储中。即这里的 “脏” 并非表示该缓存中数据被抛弃,而是说该缓存行中数据可能已被修改需要同步到低级存储中。简单来说就是脏表示缓存行中数据已被修改,非脏表示未被修改。
- 读取:
- 命中缓存:直接返回缓存行中数据。
- 未命中缓存:先分配一个缓存行(用来存储从低级存储中读取到的数据),然后判断该缓存行是否为脏(因为缓存空间是有限且极小的,因此会被重复使用),若为脏则将其中的数据同步到低级存储中,若不为脏,则将目标数据从低级存储读取到并写入该缓存行,并将其标记为非脏(因为数据是读取过来的,并没有被修改),最后将数据返回。
- 写入:
- 命中缓存:将数据更新到缓存行,并将其标记为脏。
- 未命中缓存:先分配一个缓存行(用来存储要写入的数据),然后判断该缓存行是否为脏数据(因为缓存空间是有限且极小的,因此会被重复使用),若为脏则将其中的数据同步到低级存储中,若不为脏,则将要写入的数据在低级存储中对应的就数据读取到缓存行(目的是建立低级存储与缓存行的对应关系),接着将要写入的数据更新到缓存行,并将其标记为脏。
从上面的读写原则可以看出,CPU 在对缓存进行读写时采取 “就近” 原则,即读的时候从离它最近(物理最近)的那个缓存开始,若读到了就返回,没读到就读下一个;写亦是,只写到离它最近的缓存,而不是一直写到内存。
2.1.3 缓存一致性
CPU 与内存之间引入高速缓存后会造成缓存一致性问题,因为原则上多个 CPU 共享一份数据,但每个 CPU 在更新数据时只更新到缓存中,这就会造成其它 CPU 看不到更新后的数据,即每个 CPU 对应缓存中的数据不一致。针对该问题,有以下几种解决方案。
-
总线嗅探:
即通过 CPU 总线来传播读写请求,各 CPU 通过监听总线上的请求来对自己缓存中各缓存行的状态作出修改。
当某个 CPU 执行一个写操作时,会在总线上广播一个写请求,其它 CPU 通过总线嗅探器监听到该写请求,然后将请求中的地址(要更新的数据在内存中的地址)与自己各缓存行中的数据地址(tag)进行映射,若映射到了某个缓存行,则将其 标记为无效,表示该数据已过期,下次读取该数据时需要从主存或其它级别的缓存中重新加载。
当某个 CPU 执行一个读操作时,会在总线上广播一个读请求,其它 CPU 通过总线嗅探器监听到该读请求,若能映射到某个缓存行,则判该缓存行数据是否为脏,若为脏则说明该数据已被该 CPU 修改,则总线嗅探器负责将该缓存行中数据更新到主存或其它级别缓存中。
-
事务串行化:
即将对同一共享数据的读写操作串行化,也就是把对同一共享数据的并发操作串行化,以保证数据的有效性。其串行是以各个 CPU 为单位。
-
MESI:
事务串行化时,每当有 CPU 修改数据,都需要在总线上广播给其它 CPU,但并不是所有 CPU 都用到这个数据,这样就会浪费资源。于是就引入了 MESI 来解决这个问题。
MESI 是一种缓存一致性协议,用于解决多核 CPU 的缓存一致性问题。MESI 代表四种状态,分别是:修改、独占、共享、无效。CPU 中所有缓存行的状态都用 MESI 协议标记。
- 修改(Modified): 当某个 CPU 将某个缓存行数据修改后,该缓存行状态将设置为 Modified,同时其它 CPU 缓存中对应缓存行将设置为 Invalid。Modified 状态对应读写策略中的 “脏”。
- 独占(Exclusive): 当某个 CPU 独自缓存某块数据时,该缓存行将设置为 Exclusive,当缓存行数据被该 CPU 修改后,将变成 Modified。
- 共享(Shared): 当多个 CPU 同时缓存某块数据时,该缓存行将设置为 Shared,此时,这些 CPU 可同时读取缓存行数据,但不能修改。
- 无效(Invalid): 当某个 CPU 的某个缓存行数据被其它 CPU 修改后,该缓存行将设置为 Invalid。此时,该 CPU 读取该缓存行数据时需要从主存重新加载。
注:不同 CPU 具体实现中用了不同的协议,但都大同小异。
2.1.4 内存屏障
内存屏障是一种 CPU 指令,用来解决特定条件下指令重排和内存可见性问题。内存屏障分为读屏障和写屏障。
- 读屏障(Read Barrier): 保证在该屏障指令之后对共享变量的读取,都从主存中加载,同时,不会将读屏障指令之后的指令重排序到读屏障指令前。
- 写屏障(Write Barrier): 保证在该屏障指令之前对共享变量的修改,都同步到主存当中,同时,不会将写屏障指令之前的代码重排序到写屏障指令后。
2.2 Java 内存模型
2.2.1 内存模型定义
为了提高 CPU 性能,引入了多级缓存,从而产生了缓存一致性问题,接着通过缓存一致性协议解决了缓存一致性问题。Java 为了解决多平台运行问题,当然也要解决缓存一致性问题,于是进一步抽象得到了 java 语言层面的内存模型。
JMM 即 Java Memory Model,它定义了主存、工作内存抽象概念,底层对应着 CPU 寄存器、高速缓存、内存、CPU 指令优化等。java 内存模型分为主内存和工作内存,且规定了所有变量都必须存储在主存中,工作内存为线程私有,即每个线程都有自己的工作内存,工作内存中存储的数据都是该线程工作是所需变脸在主存中的拷贝(即把自己需要的数据复制一份放到工作内存中)。线程对变量的所有操作都必须在工作内存中操作,而不能直接操作主存。不同线程之间无法访问对方工作内存中的变量,且线程间的变量传递都是在工作内存和主存间进行的。
JMM 具有原子性、可见性、有序性三大特性,同时,其针对主存和工作内存之间的交互还定义了八大基本原子操作和八大操作原则。(其目的是为了保证并发编程下数据的准确性)。
2.2.2 三大特性
- 原子性:指一个操作要么全部执行完成,要么不执行。原子性问题是由线程上下文切换引起的,线程上下文切换的本质是 CPU 时间片的轮转造成的。
- 可见性:指当多个线程同时访问同一个变量时,其中一个线程将变量修改后,其它线程能够立即看到修改后的值。可见性问题是由线程工作内存引起的(因为工作内存是线程私有的),本质是由 CPU 多级缓存造成的。
- 有序性:指程序执行的先后顺序要符合代码的先后顺序。有序性问题是由处理器指令优化和重排造成。比如 load > add > save 可能被优化为 load > save > add。这就是有可能存在的有序性问题。
2.2.3 八大基本原子操作
- read(读取): 作用于主存,将主存变量从主存传输到工作内存中。
- load(载入): 作用于工作内存,将 read 操作读取到的值放入工作内存的变量副本中。
- store(存储): 作用于工作内存,将工作内存中的变量传输到主存中。
- write(写入): 作用于主存,将 store 操作传输过来的值放入主存内的变量中。
- use(使用): 作用于工作内存,将工作内存中的变量传递给执行引擎。当虚拟机遇到一个需要使用到变量的字节码指令时就会执行此操作。
- assign(赋值): 作用于工作内存,将从执行引擎接收到的变量值赋值给工作内存中的变量。当虚拟机遇到一个变量赋值指令时就会执行此操作。
- lock(锁定): 作用于主存,将一个变量标记为线程独占状态。
- unlock(解锁): 作用于主存,将一个处于线程独占状态的变量释放出来。
2.2.4 八大操作规则
- 不允许 read和load、store和write 操作之一单独出现。即不允许一个变量从主存读取了但工作内存不接受,或工作内存回写了某个变量但主存不接受的情况出现。
- 不允许一个线程丢弃它最近的 assign 操作。即变量在工作内存中被修改后必须同步到主存中。
- 不允许一个线程在没有发生 assign 操作时同步某个变量到主存中。即变量从工作内存同步到主存只能发生在 assign 操作之后。
- 不允许在工作内存中直接使用一个未被初始化(load 或 assign)的变量。即在对一个变量进行 use、store 操作之前必须先执行 load、assign 操作。再简单点就是一个新的变量必须诞生于主存。
- 对某个变量执行 lock 操作时,会清空当前线程工作内存中该变量的值,在执行引擎需要使用这个变量前,需要重新执行 load 或 assign 操作。
- 同一个变量同时只能被一个线程执行 lock 操作,但同一个线程可对同一个变量执行多次 lock 操作,同时,执行 n 次 lock 需要执行 n 次 unlock 才能解锁。
- 不允许对一个没有被 lock 的变量执行 unlock 操作,同时也不允许对其它线程 lock 的变量执行 unlock 操作。
- 对一个变量执行 unlock 前,需要将变量值同步到主存中(即执行 store、write 操作)。
3 锁
3.1 锁定义
3.1.1 悲观锁与乐观锁
乐观锁与悲观锁是一种广义上的概念,体现了看待线程的不同角度。在 java 和数据库中都有对此概念的实际应用。
-
乐观锁:
乐观锁认为自己在使用数据时不会有其它线程来修改数据,所以在使用数据时不会加锁,只有在更新数据时才会判断该数据是否已被修改,若未被修改则成功写入,若该数据已被其它线程修改,则根据不同的实现方式执行不同的操作(如报错或重试)。
乐观锁适合读操作多的场景,不加锁可使读操作的性能大幅提升。乐观锁在 java 中是通过无锁编程来实现的,最常采用的是 CAS 算法,如原子类等。
-
悲观锁:
悲观锁认为自己在使用数据时一定会有其它线程来修改数据,所以在使用数据时会先加锁,确保在使用过程中数据不会被其它线程修改。
悲观锁适合写操作多的场景,加锁可保证写操作时的数据准确性。悲观锁在 java 中的具体表现为 synchronized 关键字和 Lock 接口的部分实现类等。
3.1.2 自旋锁与适应性自旋锁
-
自旋锁:
阻塞或唤醒一个线程需要进行上下文切换,上下文切换会消耗处理器时间。如果同步代码块的逻辑较简单,则上下文切换所花费的时间可能比同步代码块的执行时间还要长。即,在很多场景下,同步资源锁定时间很短,为了这一小段时间而让线程阻塞或唤醒可能会让整个操作得不偿失,在这种情况下可以让获取锁失败的线程不阻塞(即不放弃 CPU 时间片),一直尝试获取锁,这就是自旋锁。
自旋本身是有缺点的,它不能代替阻塞。自旋的目的是避免线程切换带来的开销,但它会占用 CPU 时间。若自旋时间很短,则其效果很好;若其自旋时间很长,那线程就会一直占用 CPU 资源。所以自旋必须要有时间限制(默认是 10 次,可通过参数 XX:PreBlockSpin 参数更改),如果超过自旋次数,那么线程就应该挂起,释放 CPU 时间片。
-
适应性自旋锁:
jdk-1.6 中默认开启了自旋锁,并引入了适应性自旋锁。适应性自旋锁的自旋次数将不再固定,而是由前一次在同一个锁上的自旋时间和该锁的拥有者的状态决定的。若前一次某个线程通过自旋成功获取到锁,且该线程正在运行中,那么虚拟机就认为这次通过自旋也很有可能会获取到锁,进而它会允许这次自旋时间比前一次稍长点。若一个锁,通过自旋很少成功获取到,那么在以后尝试获取该锁的过程中将可能省略掉自旋这个过程,直接阻塞线程,避免消耗 CPU 资源。
自旋锁在 java 中的具体表现为 TicketLock、CLHLock、MCSLock 类和 synchronized 关键字等。
3.1.3 公平锁与非公平锁
-
公平锁:
公平锁是指竞争锁的多个线程按照竞争顺序来获取锁。线程直接进入队列中排队,队列中的第一个线程将会获得锁。
公平锁的优点是等待锁的线程不会饿死;缺点是整体吞吐率相对非公平锁较低,除队列中第一个线程外其它线程都会阻塞,CPU 唤醒线程的开销要比非公平大。
-
非公平锁:
非公平锁是指线程竞争锁时会先插队尝试获取锁,若未获取到才会进入等待队列。假如某个线程竞争某个锁时该锁刚好被释放,那么该线程将不用阻塞直接获取到锁,所以非公平锁会出现后竞争但先获取到锁的场景。
非公平锁的优点是可以降低 CPU 唤醒线程带来的开销,整体吞吐率较高,因为线程有几率不阻塞直接获取到锁;缺点是处于等待队列的线程可能很久才能获取到锁甚至饿死。
公平锁和非公平锁在 java 中的具体表现为 ReentrantLock 等。
3.1.4 共享锁与排它锁
共享锁和排它锁(独享锁)同样是一种概念。
-
共享锁:
共享锁是指该锁可以被多个线程同时持有。若某个线程对某个数据加上共享锁后,其它线程就只能对该数据加共享锁了,不能加独享锁。获得共享锁的线程只能读取数据,不能修改数据。
-
独享锁:
独享锁也称排它锁,是指锁同一时刻只能被一个线程持有。若某个线程对某个数据加上独享锁后,其它线程就不能对该数据加任何锁了。获得独享锁的线程既可以读取数据也可以修改数据。
独享锁在 java 中的具体表现为 synchronized 关键字和 Lock 接口的部分实现类(如 ReentrantReadWriteLock)等。
3.1.5 可重入锁与不可重入锁
-
可重入锁:
可重入锁是指同一个线程在外层方法获取到锁,在进入到该线程的内层方法后会自动获取到锁(前提是同一个锁),不会因为之前已经获取过但没释放而阻塞。
可重入锁的一个优点是可以一定程度上避免死锁。可重入锁在 java 中的表现为 synchronized 关键字和 Lock 的部分实现类(如 ReentrantLock)等。
-
不可重入锁
不可重入锁是指不管是否同一个锁还是同一个线程亦或是方法内外层,获取后只能先释放再获取。
不可重入锁在 java 中的表现为 NoReentrantLock 等。
3.1.6 锁升级
锁升级是指在多线程竞争锁时对象锁状态的变化过程,升级流程:无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁 。
-
无锁:
无锁是指对同步资源没有锁定,所有线程都可并发修改同步资源,但同时只有一个线程可以成功。
无锁的特点是修改操作在循环内进行,线程会不断尝试修改同步资源,若没有冲突则成功修改并退出(这里的冲突是线程工作内存中该变量的值与主存中该变量的值是否相等);若有冲突则说明有多个线程在并发修改,此时,只有一个线程会成功,其它失败的线程将会不断重试直至成功。
无锁一般使用 CAS 算法实现。无锁无法全面代替有锁,但无锁在某些场景下性能是非常高的。
-
偏向锁:
偏向锁是指同步代码块一直被同一个线程执行,那么该线程会自动获取到锁,以此来降低获取锁的代价。
在大多数情况下锁总是由同一线程多次获得,很少会出现竞争的情况,所以就出现了偏向锁。
偏向锁在 jdk-1.6 及以后版本中是默认开启的,可通过参数 -XX:UseBiasedlocking=false 来关闭,关闭后程序会默认进入轻量级锁。
-
轻量级锁:
轻量级锁是指当锁为偏向锁时,有其它线程来尝试获取锁,此时锁会升级为轻量级锁。其它线程会通过自旋的方式等待,不会阻塞,从而提高性能。
-
重量级锁:
当锁是轻量级锁时,若当前只有一个线程在等待锁,那么该线程将会通过自旋的方式等待;但当自旋超过一定次数或当有第三个线程来竞争锁时,此时锁会升级为重量级锁。锁升级为重量级锁后,所有等待锁的线程都会进入阻塞状态。
3.2 锁实现
java 中锁的实现可分为锁关键字和锁 API 两种。
3.2.1 锁关键字
- synchronized:
- synchronized 是 java 提供的一个并发控制关键字,其可以解决原子性问题、可见性问题、有序性问题。
- 主要有两种用法,即同步方法可同步代码块,即其可以修饰方法也可以修饰代码块。被修饰的方法或代码块在同一时刻只能被一个线程。
- 其实现原理为 Monitor 和 CAS 算法,Monitor 底层基于 C++ 的 ObjectMonitor。
- volatile:
- volatile 是 java 提供的一个并发控制关键字,被比喻为轻量级的 synchronized,其可以解决可见性问题、有序问题。
- 只有一种用法,即作用于变量,它是一个变量修饰符。
- 其实现原理依赖于内存屏障和 jvm lock 指令。
3.2.2 锁 API
- Lock:
- Lock 是 juc 包下的关于线程同步相关的接口,其定义了加锁、解锁等行为。同时 juc 也提供了很多实现类,如可重入锁、公平/非公平锁、共享/独享锁等。
- 其需要显式的加解锁,比 synchronized 有着更广泛的锁操作。
- 其实现原理依赖于 AQS,AQS 底层依赖于 CAS 算法。
3.3 锁原理
3.3.1 volatile 原理
volatile 底层原理是使用了内存屏障。对被 volatile 修饰的变量的写指令后加入了写屏障,对被 volatile 修饰的变量的读指令前加入了读屏障。
-
volatile 与可见性:
被 volatile 修饰的变量在读写指令前后加入了读写屏障,读写屏障可以保证共享变量在多线程见的可见性。
读屏障的作用是在该屏障之后对变量的读取都从主存中加载,这样可以保证共享该变量的线程每次读取到的都是主存中的最新值。
写屏障的作用是在该屏障之前对变量的修改都会同步到主存中,这样可以保证共享该变量的线程每次更新值后都会立刻同步到主存中。
在 jvm 层面,修改变量时,jvm 会向处理器发送一条 lock 前缀的指令,这条指令的作用是将修改后的值同步到内存而不是只写到缓存,同时由于 CPU 缓存一致性协议,其它 CPU 会监听到总线上的消息,然后将自己缓存中对应变量的状态置为无效。
-
volatile 与有序性:
被 volatile 修饰的变量在读写指令前后加入了读写屏障,读写屏障可以保证对共享变量操作时代码的有序性。可以简单理解为读写屏障禁止了指令重排。
读屏障可以保证在指令重排时,不会将读屏障之后的代码重排到读屏障之前。
写屏障可以保证在 指令重排时,不会将写屏障之前的代码重排到写屏障之后。
-
volatile 与原子性:
被 volatile 修饰的变量在读写指令前后虽然加入了读写屏障,但其并不能保证其原子性。
因为虽然读写屏障禁止了指令重排,但写屏障并不能阻止读指令跑到写屏障前面执行;同样的读屏障也不能保证写指令跑到读屏障后面。换言之,读写屏障并不能阻止读写指令的交错执行。
3.3.2 CAS 原理
CAS 全称是 Compare And Swap,即比较与替换,是一种无锁算法。在不阻塞线程的情况下可以实现多线程间的变量同步。juc 包中的原子类即用 CAS 实现,以及 AQS 中锁状态的改变也是使用 CAS 实现。
CAS 算法设计到三个操作数:
- V:要读写的内存值,即该变量在内存中的值。
- A:进行比较的值,即期望值(当前线程读取到的该变量的值)。
- B:要写入的值,即当前线程对该变量的修改结果。
CAS 算法的原理是,比较 A 与 V 的值是否相等,若相等则将 V 替换成 B,若不相等则继续重复比较替换。换言之,其会比较当前线程读取到的该变量的值愈该变量在内存中的值是否相等(因为在该线程读取了该变量在内存中的值后,该值可能会被其它线程修改,所以要比较),若二者相等,则将该变量在内存中的值(旧值 V)用修改后的值(新值 B)替换掉,且整个比较替换过程是在循环中进行的,若替换成功则会退出循环。比较和替换是一个原子操作。
CAS 存在的三大问题:
-
ABA 问题:
即当当前线程读取到该变量的值为 A 后,该变量在内存中的值被其它线程由 A 修改为 B,再被修改成 A,这样当当前线程进行 CAS 操作时虽然能成功,但实际上该变量的值是被修改过的。解决思路是给变量加上版本好,每次修改时都将版本加 1,这样 ABA 过程将变成 1A -> 2B -> 3A。
jdk 1.5 中增加了 AtomicStampedReference 类来解决 ABA 问题,具体使用 compareAndAset(),其会先检查变量的当前引用和当前版本与预期引用和预期版本是否相等,若相等则将引用值和版本值用给定的更新值替换。
-
循环时间长开销大问题:
CAS 若长时间不成功会一直循环,会长时间占用 CPU 资源。
-
只能保证一个共享变量的原子操作问题:
CAS 只能保证一个共享变量的操作问题,对多变量的操作是不支持的。
jdk 1.5 中增加了 AtomicReference 类来保证引用对象的原子操作,可以将多个共享变量放在一个对象中来保证原子操作。
CAS 算法在 java 中具体实现是由 Unsafe 类提供的。在执行 cas 函数时会向 CPU 发送一条 lock 前缀的指令,然后 CPU 会锁住总线,待当前核执行完指令后才会开启总线,且此过程不会被线程的打断机制打断。
juc 中 CAS 的常用方式是 CAS 结合 while、do while 语句来使用,如下所示:
// while 即只有当 CAS 成功后才返回
while (true) {
// todo
if (compareAndSet(expect, update)) {
return;
}
}
// do while
do {
// todo
if (compareAndSet(expect, update)) {
return;
}
} while (true);
注:需要用 CAS 操作的变量必须被 volatile 修饰。
3.3.3 synchronized 原理
3.3.3.1 java 对象头
-
对象头结构:
- 普通对象:对象头占 64 位(8 个字节)
- Mark Word:占 32 位(4 个字节),用来存储哈希码、分代年龄、锁状态、GC 等信息。
- Klass Word:占 32 位(4 个字节),用来存储对象的类类型信息(指针指向类对象信息)。
- 数组对象:对象头占 96 位(12 个字节)
- array length:占 32 位(4 个字节),用来存储数组长度。
- 普通对象:对象头占 64 位(8 个字节)
-
32 位虚拟机下 Mark Word:
- hashcode:占 25 位,用来存储对象的哈希码(只有调用了对象的 hashCode() 方法才会产生哈希码,否则不占用空间)。
- age:占 4 位,用来存储对象的分代年龄。
- biased_lock:占 1 位,表示偏向锁状态。0:未使用偏向锁;1:使用偏向锁。
- thread:占 23 位,用来存储在偏向锁状态下所偏向的线程 id(操作系统层面的线程 id)。
- epoch:占 2 位,表示在偏向锁状态下的偏向时间戳。
- ptr_tolock_record:占 30 位,用来存储在轻量级锁状态下对象在栈中的 Lock Record(锁记录)指针。
- ptr_to_heavyweight_monitor:占 30 位,用来存储在重量级锁状态下对象的 Monitor(管程或监视器)指针。
- 锁状态:无锁或偏向锁状体下占 3 位,其它占 2 位,用来表示对象当前锁状态。001:无锁;101:偏向锁;00:轻量级锁;10:重量级锁;11:GC 标记。
-
64 位虚拟机下 Mark Word:
和 32 位虚拟机下的 Mark Word 一样。
3.3.3.2 Monitor 原理
Monitor,即监视器或管程,它是一个术语,指的是进程同步。Monitor 在 jvm 中由 C++ 的 ObjectMonitor 实现,ObjectMonitor 通过 enter、exit、wait、notify、notifyAll 等方法来实现加锁、解锁、等待、唤醒等。
在 java 中,每个对象都可以关联一个 Monitor 对象,当使用 synchronized 关键字给对象上锁后(重量级锁),该对象对象头中的 Mark Word 部分就会被设置为指向 Monitor 对象的指针。Monitor 结构如下:
- EntryList:阻塞队列,当多个线程同时竞争当前对象锁时,竞争失败的线程就会进入该队列阻塞。其数据结构为双向链表。
- Owner:当前持有对象锁的线程,即运行中的线程。当线程成功竞争到锁时,Owner 表示锁的持有者。
- WaitSet:等待队列,当持有锁的线程因运行条件不满足,调用 wait()、wait(long n) 时,就会进入该队列进行等待,当其被其它线程通过 notify()、notifyAll() 唤醒时,才会进入 EntryList 集合再次参与竞争锁。
- Count:计数器,也可理解为锁的重入次数。synchronized 为重入锁,当线程首次获得锁时,count++,当其在内层方法再次获得锁(即重入)时,count 再加 1,释放锁时 count–,直至为 0 时,Owner 置为 null,锁释放,同时唤醒 EntryList 队列中的阻塞线程。
3.3.3.3 synchronized 字节码
测试代码:
public class SynchronizedTest {
public static final Object lock = new Object();
public static int counter = 0;
public static void main(String[] args) {
synchronized (lock) {
counter++;
}
}
}
其主要字节码如下:
Code:
stack=2, locals=3, args_size=1
0: getstatic #2 // 加载 lock 对象引用
3: dup // 将引用复制一份
4: astore_1 // 将引用存储
5: monitorenter // 加锁 对应 ObjectMonitor 中的 enter 方法
6: getstatic #3 // 6 ~ 14 为临界区代码对应的字节码指令
9: iconst_1
10: iadd
11: putstatic #3
14: aload_1
15: monitorexit // 解锁 对应 ObjectMonitor 中的 exit 方法
16: goto 24
19: astore_2
20: aload_1
21: monitorexit // 解锁 对应 ObjectMonitor 中的 exit 方法
22: aload_2
23: athrow
24: return
Exception table: // 异常表
from to target type
6 16 19 any
19 22 19 any
- 在字节码文件中,monitorenter 为加锁指令,monitorexit 为解锁指令,monitorenter 与 monitorexit 之间的指令(即 6 ~ 14)为临界区代码。
- 执行 monitorenter 指令时会将 lock 对象对象头的 Mark Wrod 置为 Monitor 对象的指针。
- 执行 monitorexit 指令时会将 lock 对象对象头的 Mark Wrod 重置,并唤醒 EntryList 队列中的阻塞线程。
- 从 Exception table 可知,当执行临界区代码出现异常时(6 ~ 16),会跳到 19 行开始执行,19 行后的指令意为解锁。同时,执行 19 ~ 22 行指令出现异常时,也会跳到 19 行开始执行。换言之,临界区代码出现异常会导致正常解锁指令得不到执行(正常解锁指令为 15 行),所以,为了保证锁被释放,若出现异常则会执行第二次解锁指令(19 ~ 22);若 19 ~ 22 指令间又出现异常,则会重复执行,直到锁释放。
3.3.3.4 无锁原理
什么?你说无锁原理,无锁有什么原理,无锁就是无锁,无锁就是 Mark Word 后三位为 001。
实现原理是 CAS 和 volatile。首先 CAS 只是一种算法,即比较和替换,如果相等则替换,否则就失败;其次,并发情况下必然是多个线程同时访问共享变量,又因为 CPU 多级缓存的存在,导致共享变量会出现可见性问题(共享变量可见性问题本质上是多核 CPU 的私有缓存造成的),于是就需要保证共享变量的可见性,这时候 volatile 就派上用场了;最后,再结合 while (true) 之类的死循环就实现了无锁。所以从本质上来说,CAS、volatile、while (true) 是无锁的三大件。换言之,CAS 操作必须借助 volatile 来获取到变量最新值以达到比较替换的效果。
3.3.3.5 偏向锁原理
-
偏向锁状态:
-
偏向锁默认是开启的,当同步代码块一直被同一个线程执行时,会使用偏向锁。该锁对象对象头中 Mark Word 中的 biased_lock 将被置为 1,即 Mark Word 后三位为 101 时,表示当前处于偏向锁状态,此时 thread 为锁偏向线程的线程 ID。
// 偏向锁时锁对象对象头将长这个样子 thread: 54 epoch:2 unused: 1 age: 4 biased_lock: 1 01
-
偏向锁开启后,默认是有延迟的,不会在程序启动后立即生效,可通过参数 -XX:BiasedLockingStartupDelay=0 来设置延迟时间,0 表示无延迟。
-
若未开启偏向锁,则当只有一个线程访问同步代码块时锁状态为轻量级锁,当多个线程竞争时锁会膨胀为重量级锁。
-
-
偏向锁撤销:
-
调用 hashCode() 方法会撤销偏向锁:
当锁状态为偏向锁时,调用锁对象的 hashCode() 方法会撤销偏向锁,锁升级为轻量级锁。
// 无锁时 Mark Word 长这样 共使用 38 位 unused: 25 hashcode: 31 unused: 1 age: 4 biased_lock: 0 01 // 偏向锁时 Mark Word 长这样 共使用 61 位 thread: 54 epoch: 2 unused: 1 age: 4 biased_lock: 1 01 // 偏向锁时 共占用 61 位 此时若再存储 hashCode 61 + 31 > 64 放不下了 所以会撤销偏向锁 升级为轻量级锁 // 轻量级锁时 Mark Word 长这样 其中 ptr_to_lock_recor 表示 Lock Record 锁记录在栈中的指针 ptr_to_lock_record: 62 00
-
第二个线程竞争锁时会撤销偏向锁:
当锁状态为偏向锁时,第二个线程来竞争锁,会撤销偏向锁,锁升级为轻量级锁。此时第二个线程将通过自旋方式来尝试获取锁,并不会阻塞。
-
调用 wait()、notify() 方法会撤销偏向锁:
当锁状态为偏向锁时,若调用锁对象的 wait() 方法,再从线程中调用锁对象的 notify() 方法,会撤销偏向锁,锁升级为重量级锁。
// 重量级锁时 Mark Word 长这样 其中 ptr_to_heavyweight_monitor 表示 Monitor 对象的指针 ptr_to_heavyweight_monitor: 62 10
-
-
批量重偏向:
当锁状态为偏向锁时,多个线程访问同步代码块,但没有竞争,这时,Mark Word 中的 thread 会从上一个使用锁对象的线程 id 替换为当前将要使用锁的线程 id,当这种替换超过 20 次后,jvm 会将剩余锁对象的线程 id 偏向至当前线程。
1、如有 30 个锁对象 都放置该集合 list 中 2、线程 1 先使用这 30 个对象(此时无其它线程竞争) 3、线程 1 使用完后 这 30 个锁对象都将偏向线程 1 4、线程 1 结束后线程 2 使用这 30 个锁对象 5、线程 2 使用时 其中前 19 个锁对象对象头中的 thread 将被替换为线程 2 的 id 6、从第 20 个开始 jvm 会批量将剩余锁对象对象头中的 thread 主动替换为线程 2 的 id
-
批量撤销:
当某个类型的锁对象的偏向锁撤销次数超过 40 次后,jvm 会将该类型对应的剩余锁对象的偏向锁撤销,使其升级为轻量级锁,同时,新建的该类型的锁对象也会禁止偏向,直接从轻量级锁开始。
3.3.3.6 轻量级锁原理
当锁状态为偏向锁时,若有第二个线程来竞争锁,此时锁将升级为轻量级锁。第二个线程将通过 CAS 的方式来尝试获取锁,此时第二个线程是不阻塞的。若第二个线程通过 CAS 能获取到锁,则锁升级为轻量级锁;CAS 尝试一定次数后仍获取不到锁,则锁将升级为重量级锁。
第二个线程获取锁时(假设第二个线程的线程名为 Thread-1),线程与锁对象的结构如下:
-
首先,其会在线程栈上创建一个 Lock Record(锁记录),锁记录用来存储锁对象相关信息。
lock_object reference:表示锁对象的引用。
ptr_to_lock_record:表示锁记录地址。
-
其次,其会用 CAS 的方式替换 Lock Record 中 ptr_to_lock_record 与 Lock Object 对象头中的 Mark Word,若替换成功则表示加锁成功,此时锁为轻量级锁。若替换失败,则有以下两种情况:
- 若是其它线程已经通过 CAS 抢到了锁,此时会发生锁膨胀,即锁升级为重量级锁。
- 若是线程自己执行了 synchronized 锁重入,那么会在栈上再添加一条 Lock Record,作为重入计数。
若 CAS 替换成功,则线程与锁对象结构如下:
若出现锁重入,则线程与锁对象结构如下:
轻量级锁解锁时,若 Lock Record 的值不为 null,则会使用 CAS 尝试将 Mark Word 与 ptr_to_lock_record 恢复。若成功,则解锁成功;若失败,则说明锁已升级为重量级锁,此时将进入重量级锁解锁流程。
3.3.3.7 重量级锁原理
当锁为轻量级锁时有其它线程来竞争锁,或锁升级为轻量级锁过程中 CAS 失败,此时锁会升级为重量级锁。重量级锁时线程和锁对象的结构如下:
- 其会先给锁对象申请 Monitor 对象,并让锁对象的 Mark Word 部分指向 Monitor 对象(轻量级锁时 Mark Word 的值为 Lock Record 地址)。
- 然后线程自己进入 Monitor 的 EntryList 集合中阻塞。
- 重量级锁解锁时,会将 Monitor 中的 Owner 置为 null,然后唤醒 EntryList 集合中阻塞的线程。
3.3.4 wait & notify 原理
wait()、wait(long n)、notify()、notifyAll() 都属于 Object 对象方法,调用这些方法的前提是先获取到当前对象锁。其需配合 synchronized 使用,即底层依赖于 Monitor 实现。其含义如下:
- wait():让当前持有该对象锁的线程进入 WaitSet 集合等待。
- wait(long n):让当前持有该对象锁的线程进入 WaitSet 集合等待 n 毫秒。等待时间结束会自动唤醒,或等待 n 毫秒期间被其它线程调用该对象锁的 notify()、notifyAll() 方法唤醒。
- notify():在 WaitSet 集合中,唤醒一个等待在该对象锁上的线程。
- notifyAll():在 WaitSet 集合中,将等待在该对象锁上的线程全唤醒(结果是只能有一个线程竞争到锁)。
wait、notify 特点:
- 锁的当前持有者线程,即 Owner 线程,在运行条件不满足时(如需要等待其它线程执行结束、或等待某个执行时机),调用 wait() 方法,会让 Owner 线程释放锁,然后线程进入 WaitSet 集合等待,且会唤醒 EntryList 集合中的阻塞线程来重新竞争锁,成为下一个 Owner 线程。
- WaitSet 集合和 EntryList 集合中的线程都不会占用 CPU 时间。
- EntryList 集合中的线程会在 Owner 线程释放锁时唤醒。
- WaitSet 集合中的线程会在 Owner 线程调用 notify()、notifyAll() 方法时被唤醒,且被唤醒的线程不会立刻获取到锁,而是需要进入 EntryList 集合中重新参与竞争。
sleep(long n) 与 wait(long n):
- sleep 是 Thread 类的静态方法,而 wait 是 Object 的对象方法。
- sleep 不需要 synchronized 配合使用,而 wait 需要强制 synchronized 配合使用。
- sleep 时不会释放锁,但 wait 时会释放对象锁。
- 二者对应线程的状态都为 TIMED_WAITING
3.3.5 park & unpark 原理
park 和 unpark 是 LockSupport 类中的方法,先 park 再 unpark。其底层依赖于 Unsafe 类实现,实际上调用的是 Unsafe 类的 park 和 unpark 方法。其含义如下:
- park():暂停当前线程。
- unpark(Thread t):恢复被暂停线程的运行,入参为被暂停的线程对象。
wait & notify 与 park & unpark:
- wait & notify 必须与 synchronized 关键字配合使用,而 park & unpark 不必。
- notifyAll 会唤醒全部等待的线程,而 park & unpark 是以线程为单位,即可以精准阻塞和唤醒某个线程。
- 必须先 wait 再 notify,但可以不 park 直接 unpark。
原理:
每一个线程都有一个属于自己的 Parker 对象,Parker 由 counter、cond、mutex 三部分组成,分别表示计数器、条件变量、锁。
其中 counter 有 0 和 1 两种状态。
-
park():
若 counter 为 0,则当前线程等待在条件变量 cond 上。
若 counter 为 1,则当前线程继续运行,并将 counter 置为 0.
-
unpark():
若当前线程正在等待,则唤醒等待在条件变量上的线程,并将 counter 置为 1。
若当前线程正在运行,则将 counter 置为 1。
-
先 unpark() 再 park():
先将 counter 置为 1,然后 park(),此时 counter 为 1 线程会继续运行,并将 counter 置为 0。
3.3.6 join 原理
join 是 Thread 对象方法,其效果是在 A 线程中调用 B 线程的 join 方法,此时,A 线程会等待 B 线程运行结束才继续运行。
其实现原理是 A 线程轮询检查 B 线程是否存活,若 B 存活则 A 进入 B 线程对象对应的 Monitor 的 WaitSet 集合中等待;若 B 已运行结束则 A 继续运行。等价于以下代码:
Thread b = new Thread(() -> {
// 业务代码
});
Thread a = new Thread(() -> {
// 业务代码
// 等待 b 线程运行结束
// 若 b 未结束 则 a 会进入 b 对象对应的 Monitor 的 WaitSet 集合中等待
// 若 b 结束 则 a 被唤醒 继续执行
synchronized(b) {
while(b.isAlive()) {
b.wait(0);
}
}
// 业务代码
});
3.3.7 线程状态转换
假设有线程 Thread t,其六种状态间的转换如下:
- 1 NEW -> RUNNABLE
- 调用 t.start() 方法后,由 NEW -> RUNNABLE。
- 2 RUNNABLE <–> BLOCKED
- 若 t 线程执行 synchronized(obj) 竞争锁失败时,则由 RUNNABLE -> BLOCKED。
- 持有 obj 锁的线程执行完毕释放锁后,或唤醒阻塞在该对象锁上的所有线程,此时若 t 线程竞争到锁,则由 BLOCKED -> RUNNABLE,竞争失败的线程仍然是 BLOCKED。
- 3 WAITING -> BLOCKED
- 若等待在对象锁 obj 上的线程 t 被其它线程调用 obj.notify()、obj.notifyAll()、唤醒或 t.interrupt() 打断时,t 线程将重新参与竞争锁,若竞争失败,则由 WAITING -> BLOCKED。
- 4 RUNNABLE <–> WAITING
- 若 t 线程在获得 obj 对象锁后调用 obj.wait(),则由 RUNNABLE -> WAITING。
- 若 t 线程被其它线程调用 obj.notify()、onj.notifyAll() 唤醒或 t.interrupt() 打断时:
- t 竞争到锁,则由 WAITING -> RUNNABLE。
- t 未竞争到锁,则由 WAITING -> BLOCKED。
- 5 RUNNABLE <–> WAITING
- 若当前线程调用 t.join(),则当前线程由 RUNNABLE -> WAITING。
- 若 t 运行结束或其它线程调用当前线程的 interrupt() 方法,则当前线程由 WAITING -> RUNNABLE。
- 6 RUNNABLE <–> WAITING
- 若 t 线程调用 LockSupport.park(),则由 RUNNABLE -> WAITING。
- 若其它线程调用 LockSupport.unpark(t),或 t.interrupt(),则 t 由 WAITING -> RUNNABLE。
- 7 RUNNABLE <–> TIMED_WAITING
- 若 t 线程在获得 obj 对象锁后调用 obj.wait(long n),则 t 由 RUNNABLE -> TIMED_WAITING。
- 若 t 线程等待时间超过了 n 毫秒,或 t 被其它线程调用 obj.notify()、obj.notifyAll() 唤醒,或 t.interrupt() 打断时:
- 若 t 竞争到锁,则 t 由 TIMED_WAITING -> RUNNABLE。
- 若 t 未竞争到锁,则 t 由 TIMED_WAITING -> BLOCKED。
- 8 RUNNABLE <–> TIMED_WAITING
- 若当前线程调用 t.join(long n),则当前线程由 RUNNABLE -> TIMED_WAITING。
- 若 t 运行结束后,或当前线程等待时间超过了 n 毫秒,或其它线程调用了当前线程的 interrupt() 方法,则当前线程由 TIMED_WAITING -> RUNNABLE。
- 9 RUNNABLE <–> TIMED_WAITING
- 若 t 线程调用 LockSupport.parkNanos(long n)、LockSupport.parkUntil(long millis),则 t 由 RUNNABLE -> TIMED_WAITING。
- 若其它线程调用 LockSupport.unpark(t),或 t.interrupt(),或 t 等时间超过了 n 毫秒,则 t 由 TIMED_WAITING -> RUNNABLE。
- 10 RUNNABLE <–> TIMED_WAITING
- 若 t 线程调用 Thread.sleep(long n),则 t 由 RUNNABLE -> TIMED_WAITING。
- 若 t 等待时间超多了 n 毫秒,或其它线程调用 t.interrupt(),则 t 由 TIMED_WAITING -> RUNNABLE。
- 11 RUNNABLE -> TERMINATED
- 若 t 线程运行结束(即执行完了所有代码),则 t 由 RUNNABLE -> TERMINATED。
3.3.8 AQS 原理
3.3.8.1 AQS 应用
AQS 即 AbstractQueuedSynchronizer 抽象队列同步器。它是 juc 包中提供的实现锁或同步器的基础组件。
java 中有两大类锁,一种是内置的关键字锁,如 synchronized、volatile 等;另一种是 Lock 接口的各种实现类锁,如 ReentrantLock、ReentrantReadWriteLock 等。其中前者 synchronized 关键字的实现依赖于 Monitor 类,而后者的实现则依赖于 AQS。
- ReentrantLock: 可重入独占锁, 分为阻塞式和非阻塞式两种情况,同时又实现了公平和非公平两种情况。
- ReentrantReadWriteLock: 可重入读写锁,分为读锁和写锁两种。其中读锁为共享非阻塞锁,写锁为独占阻塞锁。同时又实现了公平和非公平两种情况。读写、写读、写写互斥。
- Semaphore: 信号量,用来限制能同时访问共享资源的线程数上限。
- CountDownLatch: 计数器,用来进行线程同步协作。
AQS 提供了独占模式和共享模式两种锁的获取,同时又实现了阻塞式锁和非阻塞式锁。
3.3.8.2 AQS 结构
AQS 在功能上类似于 Monitor 却又更加灵活于 Monitor。同时其在结构上也与 Monitor 较为相似。
-
state:
state 用来表示同步状态或锁状态。其是 AQS 维护的一个用 volatile 修饰的 int 变量。类似于 Monitor 中的 count。
AQS 本质上则是多个线程在并发情况下通过感知和修改 state 的值来达到加锁和解锁的目的。所以其用 volatile 修饰,以保证在多线程间可见;同时在修改时使用 CAS 算法,一定程度上可提升性能。
state 是抽象的,其在不同锁的实现中代表着不同的含义。如:
- ReentrantLock(可重入独占锁):
- 0:表示当前锁未被占有。
- n(n >= 1):表示当前锁已被占有,同时表示重入次数。
- ReentrantReadWriteLock(读写锁):
- 高 16 位表示读锁状态(共享锁):
- 0:表示读锁未被占有。
- n(n >= 1):表示占有或共享读锁的线程个数。
- 低 16 位表示写锁状态(独占锁):
- 0:表示写锁未被占有。
- n(n >= 1):表示写锁已被占有,同时表示重入次数。
- 高 16 位表示读锁状态(共享锁):
- Semaphore(信号量):
- 表示信号量的个数。
- CountDownLatch(计数器):
- 表示计数器的值。
- ReentrantLock(可重入独占锁):
-
CLH 队列:
CLH 即由 Craig、Landin、Hagersten 三人同时发明,所以叫 CLH。它是个单向链表,AQS 中的 CLH 是其变体的双向链表且 FIFO (先进先出)。类似于 Monitor 中的 EntryList 集合。
CLH 在 AQS 中用来存放竞争锁失败而阻塞的线程。其节点由 AQS 内部类 Node 表示,Node 类内部持有了当前被阻塞的线程对象,同时持有了当前节点的钱去节点 prev 和后记节点 next,还维护了节点状态。
其中 AQS 中维护了 CLH 队列的头节点 head 和尾节点 tail,且二者用 volatile 修饰,同时修改头节点和尾节点时用 CAS 算法。
优点:
- 先进先出,保证了公平性(这个公平并不影响非公平锁的实现)。
- 自旋无锁,使用 CAS 的来保证节点操作的原子性。
- 快速不阻塞。
-
ConditionObject:
ConditionObject 表示条件变量,其功能类似于 Monitor 中的 wait()、notify() 的作用对象。由内部的 await()、signal() 方法实现等待和唤醒操作。
同时其维护了一个以 Node 对象为为节点的单向链表,用来表示正在等待的线程。类似于 Monitor 中的 WaitSet 集合。其中 firstWaiter 表示头节点,lastWaiter 表示尾节点,修改头尾节点时用 CAS 算法。
与 WaitSet 不同的是,AQS 的等待可以等待在多个条件变量上(每个 ConditionObject 都拥有一个单向链表),而 synchronized 锁的等待只能等待在同一个锁对象上。
AQS 是通过 LockSupport 的 park、unpark 函数来实现线程的阻塞和唤醒的。
3.3.8.3 AQS 功能
AQS 作为 juc 中最基本、最重要且最底层的组件,其已经实现了大部分相关功能的实现。如:
- 锁状态相关:
- getState()、setState(int newState):获取、设置同步状态。
- compareAndSetState(int expect, int update):使用 CAS 机制设置同步状态。
- isHeldExclusively():判断当前线程释放持有锁。
- 独占式模式相关:
- 阻塞式锁:获取、释放锁,获取可打断的独占锁。
- 非阻塞式锁:获取、释放锁需要由子类实现。
- 共享模式相关:
- 阻塞式锁:获取、释放锁,获取可打断的共享锁。
- 非阻塞式锁:获取、释放锁需要由子类实现。
同时,AQS 又实现了时间相同的功能,如在指定时间内获取锁等。而对于 AQS 的使用,我们只需要继承它,然后实现其需要由子类实现的四个抽象方法即可。在抽象方法的具体实现中可通过调用 AQS 已实现的相关功能来实现自定义同步器的具体功能。
注:AQS 已经实现了阻塞锁获取和释放的相关功能,而未实现非阻塞锁获取和释放的相关功能,是因为阻塞锁的获取和释放需要操作 CLH 队列 和 等待队列,其操作过程较为复杂且通用。
3.3.8.4 AQS 节点
Node 类是 AQS 中定义的内部类,表示 CLH 队列和等待队列中的节点。当线程竞争锁失败或调用条件变量的 await() 方法时会被封装成 Node 对象入然后入队。其主要属性如下:
- waitStatus: 表示当前节点的状态,其状态值在下文中有说明。
- prev: 在 CLH 队列中表示当前节点的前驱节点。
- next: 在 CLH 队列中表示当前节点的后继节点。
- thread: 表示与当前节点关联的线程对象。
- nextWaiter: 在等待队列中表示当前节点的后继节点。
waitStatus 值及说明如下:
- 1: CANCELLED,表示当前节点已取消。当节点内线程被打断或 time 超时时,会更改为此状态。
- -1: SIGNAL,表示当前节点有责任或义务唤醒其后继节点。当当前节点的后继节点不为 null 时,会更改为此状态。
- -2: CONDITION,表示当前节点在等待队列中等待。当某个正在运行的线程调用条件变量对象(ConditionObject)的 await() 方法后,将被封装成 Node 对象入等待队列,且状态为 -2。
- -3: PROPAGATE,共享模式下的状态,表示当前节点不仅会唤醒后继节点,还有可能唤醒后继节点的后继节点。
- 0,新节点入队时的默认状态,即 CLH 队列的最后一个节点(尾节点 tail)的状态总是为 0。
此外,Node 类内部还维护了两个 Node 属性,用来表示节点的共享和独占状态,其分别是:
- SHARE:值为 Node 对象,表示当前节点为共享状态。若当前锁为读锁,则在读锁未释放的情况下会继续唤醒节点状态为 SHARED 的后继节点,即表示读锁可共享。若后继节点为 EXCLUSIVE,则不会唤醒,即表示读写互斥。
- EXCLUSIVE:值为 null,表示当前节点为独占状体啊。若当前锁为写锁,且后继节点状态为 EXCLUSIVE,则不会唤醒后继节点,即表示写写互斥。
注:在 CLH 队列中,第一个节点(头节点 head)不关联任何线程,即其 thread 属性永远为 null,头节点只是作为哨兵节点存在。
3.3.8.5 AQS 条件变量
synchronized 锁配合锁对象(Object 对象)的 wait、notify 类函数实现了线程间的同步协作。AQS 也实现了类似的功能。AQS 中的内部类 ConditionObject(条件变量)提供了 await、signal 类函数,其作用类似于 Object 的 wait、notify 类函数;ConditionObject 类中维护的单向链表,类似于 synchronized 锁对象对应 Monitor 对象中的 WaitSet 集合,都充当等待队列的角色。
二者之间的区别是:
- 在 cynchronized 关键字上,每次使用其时只能关联一个 Object 锁对象,同时每个锁对象只能关联一个 Monitor 对象,每个 Monitor 对象内只有一个 WaitSet 集合,即 synchronized 锁只能关联一个等待队列。
- 在 AQS 实现的锁上,每个 AQS 锁对象都可以为其创建多个 ConditionObject 条件变量对象,而每个条件变量对象都对应一个单向链表,即使用 AQS 实现的锁可以关联多个等待队列,支持多个条件变量。
3.4 锁源码
3.4.1 Lock
Lock 接口是 juc 包中提供的定义了锁相关行为的接口,其结合 AQS 实现了比 synchronized 隐士锁更加灵活、扩展性更强。其定义的主要函数如下:
- lock(): 获取锁,若竞争失败线程将阻塞,直到获取成功。
- lockInterruptibly(): 获取可打断锁,即若竞争锁失败线程将阻塞,阻塞期间可被其它线程打断。
- tryLock(): 尝试获取锁,若竞争成功则返回 true,若失败则立即返回 false。
- tryLock(long time, TimeUnit unit): 在指定时间内尝试获取锁,若竞争失败则线程会被阻塞,直到成功获取到锁、或等待时间超时、或被其它线程打断。
- unlock(): 解锁。
- newCondition(): 创建一个与当前锁对象关联的条件变量。
Lock 接口主要实现类有:
- ReentrantLock: 可重入独占锁。分为阻塞式和非阻塞式,同时又实现了公平和非公平。
- ReentrantReadWriteLock: 可重入读写锁。分为读锁、写锁两种,同时又实现了公平和非公平。其中读写、写读、写写互斥。
- ReadLock: 读锁,读锁为共享非阻塞锁。
- WriteLock: 写锁,写锁为独占阻塞锁。
3.4.2 ReentrantLock
ReentrantLock 是 java 中最常用的一种锁实现。其是独占的、可重入的、阻塞或非阻塞的、公平或非公平的。
ReentrantLock 类图如上图所示,其实现了 Lock 接口(Lock 接口见 3.4.1),Lock 接口中定义了阻塞式锁和非阻塞式锁的获取函数及锁释放函数。ReentrantLock 类定义了内部类 Sync 同步器,继承自 AQS,实现了 AQS 中的 tryRelease、isHeldExclusively 函数。同时又为 Sync 同步器扩展了两个子类 FairSync、NonfairSync,分别表示公平锁、非公平锁的实现,它们都实现了 AQS 的 tryAcquire 函数。
ReentrantLock 初始化时会初始化 Sync 实例,默认会初始化 NonfairSync,即 ReentrnatLock 默认为非公平锁。
3.4.2.1 阻塞锁
3.4.2.1.1 获取锁
-
ReentrantLock#lock()
阻塞锁的获取从 ReentrantLock 的 lock() 方法开始,lock() 方法内部直接调用了 AQS 的 acquire() 方法。大致流程如下:
- 1、首先会根据 Sync 同步器的具体实例(即公平还是非公平)调用 Sync 的 tryAcquire() 尝试一次锁的获取,该方法返回一个 boolean 值,true 表示获取成功,否则表示失败。若获取成功则返回;若失败则走第 2 步。
- 2、第 1 步失败后,会进入 AQS 的 acquireQueued() 函数,该函数内为阻塞获取锁的逻辑。该函数的入参调用了 AQS 的 addWaiter() 函数,addWaiter 函数会将当前线程封装成一个 Node 节点,链到队列队尾并返回节点。该函数返回一个 boolen 结果,true 表示当前线程在阻塞过程中被打断过,false 表示在阻塞过程中没有被打断过。在当前线程获取到锁后会返回。
- 3、若第 2 步返回 true,则调用当前线程的 interrupte() 函数,将当前线程自己打断一次,然后返回。
-
ReentrantLock#Sync#tryAcquire()
// 公平实现 protected final boolean tryAcquire(int acquires) { final Thread current = Thread.currentThread(); int c = getState(); if (c == 0) { if (!hasQueuedPredecessors() && compareAndSetState(0, acquires)) { setExclusiveOwnerThread(current); return true; } } else if (current == getExclusiveOwnerThread()) { int nextc = c + acquires; if (nextc < 0) throw new Error("Maximum lock count exceeded"); setState(nextc); return true; } return false; } // 非公平实现 final boolean nonfairTryAcquire(int acquires) { final Thread current = Thread.currentThread(); int c = getState(); if (c == 0) { if (compareAndSetState(0, acquires)) { setExclusiveOwnerThread(current); return true; } } else if (current == getExclusiveOwnerThread()) { int nextc = c + acquires; if (nextc < 0) // overflow throw new Error("Maximum lock count exceeded"); setState(nextc); return true; } return false; }
上图所示为 ReentrantLock 的内部类 Sync 的两个子类实现的 AQS 类中定义的抽象方法 tryAcquire() 的流程。tryAcquire() 意为尝试获取锁,这里的尝试可理解为不会阻塞线程。该函数返回一个 boolean,true 表示获取锁成功,false 表示失败。
左侧为公平锁的同步器 FairSync 的实现,其大致流程如下:
- 1、调用 Thread 类的 currentThread() 函数获取到当前线程对象。
- 2、调用 AQS 的 getState() 函数获取到当前锁的状态。getState() 函数会返回 0 或 大于 0 的正整数,0 表示锁未被占有,大于 0 的正整数表示锁已被占有和重入的次数。
- 3、然后判断 state 是否等于 0。若等于 0 则走第 4 步,若不等于 0 则走第 7 步。
- 4、调用 AQS 的 hasQueuedPredecessor() 函数判断阻塞队列(CLH)中是否有前驱节点。其判断逻辑是遍历 CLH 链表,若链表不为空且链表中存在节点的 thread 属性不为 null 且 waitStatus 属性 <= 0 的节点即为有前驱节点,则返回 true,走第 10 步;若没有则返回 false 走第 5 步。
- 5、调用 AQS 的 comporeAndSetState() 函数尝试将 state 的值修改为 1。若成功则返回 true 走第 6 步,否则返回 false 走第 10 步。
- 6、调用 AQS 的 setExclusiveIOwnerThread() 将锁的占有者设置为当前线程。然后走第 9 步。
- 7、调用 AQS 的 getExclusiveOwnerThread() 函数获取锁当前的占有者线程,并与第 1 步获取到的当前线程对象比较二者是否为同一线程。若是则走第 8 步,否则走第 10 步。
- 8、将 state++,并调用 AQS 的 setState() 函数更新 state 的值。然后走第 9 步。
- 9、返回 true,表示获取锁成功。
- 10、返回 false,表示获取锁失败。
右侧为非公平锁的同步器 NonfairSync 的实现,其与公平同步器的实现流程大致一样,唯一区别是在第 3 步判断 state 等于 0 后会直接略过第 4 步走第 5 步。简言之,公平锁会判断阻塞队列中是否有正在等待的线程,而非公平锁则不会判断,上来就抢。
-
AQS#acquireQueued()
final boolean acquireQueued(final Node node, int arg) { boolean interrupted = false; try { for (;;) { final Node p = node.predecessor(); if (p == head && tryAcquire(arg)) { setHead(node); p.next = null; // help GC return interrupted; } if (shouldParkAfterFailedAcquire(p, node)) interrupted |= parkAndCheckInterrupt(); } } catch (Throwable t) { cancelAcquire(node); if (interrupted) selfInterrupt(); throw t; } }
如图所示,其为 AQS 实现的阻塞锁的获取逻辑。即当线程获取锁失败需要通过阻塞来等待锁的释放,然后重新获取。该函数返回一个 boolean 值,true 表示该线程在阻塞等待锁的过程中被其它线程打断过,false 表示该线程在阻塞等待锁的过程中没有被其它线程打断过。
实际上在调用该函数前,会先调用 AQS 的 addWaiter() 函数,此函数的作用是将当前线程封装成 Node 实例,并设置 waitStatus 的值为 0,然后将其添加到阻塞队列(CLH)的队尾。且封装的 Node 实例将作为 acquireQueued() 函数的入参。
acquireQueued() 函数大致流程如下:
- 1、声明一个 boolean 变量 interrupted 且赋初始值为 false。其即是该函数的返回值。
- 2、调用 AQS 内部类 Node 的 predecessor() 函数获取当前线程节点的前驱节点 predecessor。
- 3、判断 predecessor 是否为头节点 head。若是则走第 4 步,若不是则走第 7 步。
- 4、调用一次 ReentrantLock 内部类 Sync 的子类所实现的 AQS 的 tryAcquire() 函数尝试获取一次锁。若成功则走第 5 步,否则重走第 2 步。
- 5、调用 AQS 的 setHead() 函数重新设置头节点(若走到这一步则说明阻塞队列中有两个节点,一个头节点即哨兵节点,另一个则是当前线程节点,此时当前线程获取锁成功,则需要把头节点替换成当前线程节点同时将其 thread 属性置为 null)。
- 6、将 predecessor 节点的 next 属性置为 null(若走到这一步则说明 predecessor 节点为头节点,此时需要将头节点从链表中断开,以方便其被垃圾回收)。然后走第几步。
- 7、调用 AQS 的 shoulParkAfterFailedAcquire() 函数判断当前线程在获取锁失败后是否应该被阻塞。正常情况下当某个线程获取锁失败后都应该被阻塞,但阻塞队列中某些线程可能被取消对锁的获取(即 Node 实例的 waitStatus 属性值为 1)。即若此时队列中除了头节点和当前节点外的所有节点都被取消,则表示当前线程不需要阻塞,该函数返回 true 且重走第 2 步。若需要等待则返回 false 且走第 8 步。由此可见,在 ReentrantLock 的阻塞锁实现中,若当前只有一个线程获取到锁在运行(没有竞争),则第二个线程竞争锁时不会被阻塞,而是一直在循环尝试获取,只有当第三个线程来竞争时,二、三线程才会被阻塞。
- 8、调用 LockSupport 的 park() 函数将当前线程阻塞。被阻塞的线程将一直等待在这里,知道被其前继节点对应的线程在获取到锁后释放锁时调用 LockSupport 的 unpark() 唤醒后,才会继续向下执行。
- 9、调用 Thread 的 interrupted() 函数获取当前线程的是否被打断过状态。若执行到此处说明该线程已经被其它线程唤醒了,但该线程在阻塞期间(即第 8 步)可能会被其它线程打断(被打断后打断标记会被置为 true,且线程不会因被打断而被唤醒或退出队列,而是会继续待在队列中阻塞),所以要将打断标记记录下来,并赋值给第 1 步声明的变量 interrupted。然后重走第 2 步再次尝试获取锁。
- 10、返回 interrupted,表示获取锁成功。
3.4.2.1.2 释放锁
-
ReentrantLock#unlock()
Lock 锁的释放都从 unlock() 函数开始,所以 ReentrantLock 也是。而 ReentrantLock 实现的 unlock() 函数则是在内部直接调用了 AQS 的 release() 函数。其大致流程如下:
- 1、调用 ReentrantLock 内部类 Sync 实现的 AQS 定义的 tryRelease() 抽象函数。该函数意为尝试释放锁,其返回一个 boolean 值,true 表示锁已释放则走第 5 步,但若返回 false 则并不意味着锁未释放成功,若返回 false 则说明此时锁发生了重入,且这一次的释放并没有完全释放(锁重入时 state 表示重入次数,此时只是将 state–,若 state-- 后为 0,则这里会返回 true),返回 false 时则第 6 步。
- 2、判断头节点是否不为 null。若为 null 则表示阻塞队列为空不需要唤醒操作走第 5 步,若不为 null 则走第 3 步。
- 3、判断头节点的 waitStatus 是否不等于 0。若不为 0 则说明存在后继节点,需要将其唤醒,则走第 4 步;若等于 0 则说明没有后继节点了,则走第 5 步。waitStatus 为 -1 时,说明当前节点有责任唤醒其后继节点。
- 4、调用 AQS 的 unparkSuccessor() 函数唤醒后继节点。其逻辑是遍历链表,调用 LockSupport 的 unpark() 函数唤醒离当前节点最近的那个没有被取消(waitStatus > 0)的节点所对应的线程。
- 5、返回 true,表示锁释放成功。
- 6、返回 false,表示锁还未完全释放。
-
ReentrantLock#Sync#tryRelease()
protected final boolean tryRelease(int releases) { int c = getState() - releases; if (Thread.currentThread() != getExclusiveOwnerThread()) throw new IllegalMonitorStateException(); boolean free = false; if (c == 0) { free = true; setExclusiveOwnerThread(null); } setState(c); return free; }
如图所示,其为 ReentrantLock 内部类 Sync 实现的 AQS 定义的 tryRelease() 函数,其意为尝试释放锁。该函数返回一个 boolean 值,true 表示锁已释放,false 表示锁未释放。返回 false 是说明此时发生了锁重入,且在此时释放结束后重入次数并没有减少到 0。在未获得锁的情况下调用该函数时会抛出一场。其大致流程如下:
- 1、调用 AQS 的 getState() 函数获取到锁当前状态,并减去函数入参 releases(releases 一般为 1)然后将结果赋值给变量 c。
- 2、判断当前线程是否为该锁的持有者线程。若是则走第 3 步,若不是则走第 9 步。
- 3、声明 boolean 类型变量 free,且赋初值 false。该变量表示 state 是否为 0,也可理解为重入锁的释放状态,若重入锁完全释放则为 true,否则为 false。
- 4、判断 c 是否为 0。若是则走第 5 步,若不是则走第 7 步。
- 5、将 free 置为 true。
- 6、调用 AQS 的 setExclusiveOwnerThread() 函数将该锁的持有者线程置为 null。
- 7、调用 AQS 的 setState() 函数更新 state 值。
- 8、返回 free,若 free 为 true 则表示锁已释放,否则表示锁未完全释放。
- 9、抛出异常。
3.4.2.2 非阻塞锁
3.4.2.2.1 获取锁
-
ReentrantLock#tryLock()
其内部实际上调用的是 ReentrantLock#Sync#tryAcquire() 函数的非公平实现,即其逻辑同 3.4.2.1.1 阻塞锁获取的 ReentrantLock#Sync#tryAcquire() 函数。
3.4.2.2.2 释放锁
-
ReentrantLock#unlock()
其逻辑同 3.4.2.1.2 阻塞锁释放的 ReentrantLock#unlock() 函数。
3.4.3 ReentrantReadWriteLock
ReentrantReadWriteLock 是 juc 包提供的读写锁。其中读锁是共享的,写锁是独占的。此锁适用于读多写少的场景,用读锁来控制读操作,写锁来控制写操作,较重量级锁而言会在一定程度上提高并发量。同时,读写、写读、写写是互斥的。读写锁的特点如下:
- 读锁:
- 共享锁
- 公平或非公平,默认为非公平。
- 重入时不支持锁升级,即在持有读锁的情况下再去尝试获取写锁会导致永久阻塞。
- 写锁:
- 独占锁
- 公平或非公平,默认为非公平。
- 重入时支持锁降级,即在持有写锁的情况下可获取到读锁。
如上图所示为 ReentrantReadWriteLock 的类图。首先,其实现了 ReadWriteLock 接口,且实现了该接口定义的 readLock()、writeLock() 函数,其作用分别是获取读锁对象和获取写锁对象。其次,其定义了内部类 Sync 继承自 AQS,实现了 AQS 定义的 tryAcquire()、tryRelease()、tryAcquireShared()、tryReleaseShared()、isHeldExclusively() 函数,同时 Sync 又定义了 readerShouldBlock()、writerShouldBlock() 抽象函数。然后,又定义了 FairSync、NonFairSync 两个扩展自 Sync 的内部类,用来实现公平锁和非公平锁的功能,分别实现了 Sync 定义的 readerShouldBlock()、writerShouldBlock() 函数。最后,又定义了内部类 ReadLock、WriteLock 来作为读锁和写锁的实现,且二者都实现了 Lock 接口,同时,二者又聚合了 Sync 实例,以此来实现锁相关功能。
ReentrantReadWriteLock 在 state 的实现上,使用 state 的高 16 位来记录读锁,0 表示读锁未被持有,> 0 表示读锁被多个线程同时持有;使用 state 的低 16 位来记录写锁,0 表示写锁未被持有,> 0 表示写锁已被占有同时表示重入次数。
3.4.3.1 ReadLock
3.4.3.1.1 获取锁
-
ReadLock#lock()
如上图所示,为读锁加锁的流程。读锁加锁使用 ReadLock 的 lock() 函数即可。根据读写锁的特点,在写锁未被持有、或写锁已被占有但写锁持有者为当前线程时可成功获取到读锁。其大致流程如下:
- 1、lock() 函数内部直接调用了 AQS 的 acquireShared() 函数。
- 2、acquireShared() 函数先调用 Sync 实现的 tryAcquireShared() 函数尝试获取读锁。
- 3、tryAcquireShared() 函数为尝试获取读锁,只会尝试一次。其返回 -1、0、大于 0 的正整数,其中 -1 表示获取失败,后两种情况表示获取成功。若获取失败则走第 4 步,若成功则走第 5 步。
- 4、调用 AQS 的 doAcquireShared() 函数通过阻塞的方式获取锁。若执行到此处说明写锁已被持有,根据读写、写读互斥,古当前线程得阻塞等待。直到被唤醒才可继续参与锁的竞争。
- 5、成功获取到锁。
-
ReentrantReadWriteLock#Sync#tryAcquireShared()
protected final int tryAcquireShared(int unused) { Thread current = Thread.currentThread(); int c = getState(); if (exclusiveCount(c) != 0 && getExclusiveOwnerThread() != current) return -1; int r = sharedCount(c); if (!readerShouldBlock() && r < MAX_COUNT && compareAndSetState(c, c + SHARED_UNIT)) { // ... return 1; } return fullTryAcquireShared(current); }
如上图所示,为 ReentrantReadWriteLock 的内部类 Sync 对 AQS 定义的 tryAcquireShared() 抽象函数的具体实现。具体功能为尝试一次读锁。其返回值共分为以下三种情况:
- -1:获取锁失败。测试失败是因为写锁已被持有。
- 0:获取锁成功。
- 大于 0 的正整数:获取锁成功,且数值表示要唤醒的后继节点的数量(该状态一般出现在 Semaphore 信号量中)。读锁情况下返回 1。
其大致流程如下:
- 1、先获取当前线程对象。
- 2、调用 AQS 的 getState() 函数获取锁状态信息,赋值给变量 c。
- 3、调用内部函数 exclusiveCount() 以 c 为参数获取独占锁的计数。然后判断其是否不等于 0。若不等于 0 则走第 4步,否则走第 6 步。
- 4、判断当前线程是否不是锁的持有者。若执行到此处说明写锁已被持有。若不是则走第 5 步,否则走第 6 步。
- 5、返回 -1,表示获取读锁失败。
- 6、若执行到此处说明写锁未被占有,则当前线程有机会竞争到读锁;或写锁已被占有且持有者为当前线程,根据重入支持锁降级原则,当前线程有机会获取到读锁的。调用 Sync 的子类实现的 readerShouldLock() 函数判断当前获取读锁的线程是否不应该被阻塞。readerShouldLock() 的实现分为公平和非公平两种:公平的实现是判断阻塞队列中是否存在后继节点,若存在则应阻塞;非公平实现是判断阻塞队列中第二个节点是否为独占状态,若是则应该阻塞,若不是(则说明为共享状态)则不应该阻塞(这判断第二个节点是因为第一节点为头节点,而头节点只是哨兵节点)。若不应该阻塞则走第 7 步,否则走第几步。
- 7、调用内部函数 sharedCount() 以 c 为参数获取共享锁的计数。然后判断其是否小于共享锁共享数量的最大值(65535)。若小于则走第 8 步,否则走第几步。
- 8、调用 AQS 的 compareAndSetState() 尝试将锁状态 state 的读锁部分 + 1。若成功则走第 9 步,否则走第 10 步。
- 9、返回 1,表示获取读锁成功。
- 10、调用 fullTryAcquireShared() 函数。其功能类似于 tryAcquireShared(),使用 for(;😉 不断尝试获取锁,执行过程中无阻塞。若失败则返回 -1,若成功则返回 1。
-
AQS#doAcquireShared()
private void doAcquireShared(int arg) { final Node node = addWaiter(Node.SHARED); boolean interrupted = false; try { for (;;) { final Node p = node.predecessor(); if (p == head) { int r = tryAcquireShared(arg); if (r >= 0) { setHeadAndPropagate(node, r); p.next = null; // help GC return; } } if (shouldParkAfterFailedAcquire(p, node)) interrupted |= parkAndCheckInterrupt(); } } catch (Throwable t) { cancelAcquire(node); throw t; } finally { if (interrupted) selfInterrupt(); } }
如图所示,为 AQS 的 doAcquireShared() 函数的具体实现。每个调用 tryAcquireShared() 函数获取读锁失败的线程都会进入该方法,在该方法内会再次调用 tryAcquireShared() 尝试获取一次,若失败则阻塞;否则则获取到锁且会持续唤醒队列中等待的线程(因为读锁是可共享的,若某个线程成功获取到读锁,则意味着队列中因读锁而等待的线程也有机会获取到读锁)。其大致流程如下:
- 1、调用 AQS 的 addWaiter() 方法将当前线程封装成 Node 节点,并设置其状态为 SHARED,然后将其链到队尾。
- 2、声明一个 boolean 型的 interrupted 变量,赋初值为 false。其表示线程在阻塞期间是否有被打断过。
- 3、获取当前节点的前驱节点记为 processor。
- 4、判断 processor 是否为头节点。若是则走第 5 步,否则走第 11 步。
- 5、调用 Sync 的 tryAcquireShared() 函数再尝试获取一次读锁。若 >= 0 则表示获取成功则走第 6 步,否则走第 3 步进入下一轮循环。
- 6、若执行到此处,说明已成功获取到读锁。调用 AQS 的 setHeadAndPropagate() 函数将当前节点设置为头节点(节点的 thread 属性会置为 null),并持续性唤醒后继节点。(setHeadAndPropagate() 函数内部调用了 AQS 的 doReleaseShared() 函数,在读锁释放时,对该函数有分析)。
- 7、将 processor 节点的 next 属性置为 null,帮助 GC。换言之将其从链表中断开。
- 8、判断 interrupted。若为 true 则走第 9 步,否则直接返回。
- 10、调用 Thread 的 interrupte() 函数将线程自身打断一次。
- 11、调用 AQS 的 shouldParkAfterFailedAcquire() 判断当前线程在获取锁失败后是否应该阻塞。其判断逻辑是判断其前继节点节点状态(waitStatus)是否为 -1,若是则应该阻塞(-1 表示当前节点有责任和义务唤醒下一个节点),否则不应该阻塞并将前继继节点状态置为 -1。若应该阻塞则走第 12 步,否则走第 3 步进入下一轮循环。
- 12、调用 LockSupport 的 park() 函数将当前线程阻塞。直到其它线程调用 LockSupport 的 unpark() 函数将其唤醒时,该线程才会继续执行。
- 13、当其它线程调用 LockSupport 的 unpark() 函数将其唤醒后,调用 Thread 的 interrupted() 函数将该线程的打断标记赋值给 interrupted。然后走第 3 步进入下一轮循环,重新参与锁的竞争。
3.4.3.1.2 释放锁
-
ReadLock#unlock()
如上图所示,为读锁释放过程。读锁释放使用 ReadLock 的 unlock() 函数即可。该函数内部直接调用了 AQS 实现的 releaseShared() 函数,其大致流程如下:
- 1、调用内部类 Sync 实现的 AQS 定义的 tryReleaseShared() 抽象方法。该方法意为尝试释放共享锁,其返回一个 boolean 结果,true 表示读锁已完全释放;false 表示当前线程释放读锁,但其它持有读锁的线程还未释放。若返回 true 则走第 2 步,否则走第 4 步。
- 2、若执行到此处则说明读锁已完全释放,则调用 AQS 的 doReleaseShared() 方法唤醒后继节点。
- 3、返回 true,流程结束。
- 4、返回 false,流程结束。
-
ReentrantReadWriteLock#Sync#tryReleaseShared()
protected final boolean tryReleaseShared(int unused) { // ... for (;;) { int c = getState(); int nextc = c - SHARED_UNIT; if (compareAndSetState(c, nextc)) // Releasing the read lock has no effect on readers, // but it may allow waiting writers to proceed if // both read and write locks are now free. return nextc == 0; } }
如上图所示,为内部类 Sync 实现的 AQS 定义的 tryReleaseShared() 抽象方法。该方法意为尝试释放共享锁,其返回一个 boolean 结果,true 表示读锁已完全释放;false 表示当前线程释放读锁,但其它持有读锁的线程还未释放。其实现原理是通过循环不断尝试修改 state 的的读锁状态值,若成功则返回。其大致流程如下:
- 1、调用 AQS 的 getState() 函数获取锁状态 state 的值。
- 2、state 值减一,赋值给 nextc,表示读锁共享线程数减一。
- 3、调用 AQS 的 compareAndSetState() 函数尝试更新 state 的值。若成功则走第 4 步,否则走第 1 步进入下一轮循环。
- 4、返回 nextc == 0。若相等则表示读锁已完全释放,否则只表示当前线程释放读锁。
-
AQS#doReleaseShared()
private void doReleaseShared() { for (;;) { Node h = head; if (h != null && h != tail) { int ws = h.waitStatus; if (ws == Node.SIGNAL) { if (!h.compareAndSetWaitStatus(Node.SIGNAL, 0)) continue; // loop to recheck cases unparkSuccessor(h); } else if (ws == 0 && !h.compareAndSetWaitStatus(0, Node.PROPAGATE)) continue; // loop on failed CAS } if (h == head) // loop if head changed break; } }
如上图所示,为 AQS 实现的 doReleaseShared() 的大概流程。该方法的作用是持续性唤醒阻塞队列中等待锁的线程。其逻辑是在死循环内,从第二个节点开始(第一个节点为头节点,头节点是哨兵节点,不关联线程),唤醒对应线程,让其去参与参与锁的竞争,直到队列中只有头节点时 break 循环返回。其大致流程如下:
- 1、将头节点 head 赋值给 临时变量 h。
- 2、判断 head 是否为 null,头尾节点是否相等。若为 true 则走第 3 步,否则走第 6 步。
- 3、判断 h 节点的状态 waitStatus 是否为 -1。因为只有当节点 waitStatus 属性为 -1 时节点才有责任唤醒其后继节点。若等于则走第 4 步,否则走第 7 步。
- 4、调用 AQS 内部类 Node 的 compareAndSetWaitStatus() 方法尝试修改 h 节点的 waitStatus 值为 0。这里修改的目的是可能会有其它被唤醒的线程也在执行这块代码,所以将该节点的 waitStatus 更新为 0,以防止重复唤醒。若更新成功则走第 5 步,否则走第 1 步进入下一轮循环。
- 5、调用 AQS 的 unparkSuccessor() 函数唤醒后继节点。其逻辑是遍历链表,调用 LockSupport 的 unpark() 函数唤醒离当前节点最近的那个没有被取消(waitStatus > 0)的节点所对应的线程。
- 6、判断 h 是否等于 head。若相等则说明队列中已无等待线程则直接 break 循环并返回,若不相等则说明队列中还有待唤醒的线程则走第 1 步进入下一轮循环。
- 7、判断 h 节点的 waitStatus 是否等于 0。若等于 0 则走第 8 步,否则走第 6 步。
- 8、调用 AQS 内部类 Node 的 compareAndSetWaitStatus() 方法尝试修改 h 节点的 waitStatus 值为 -3。若成功则走第 1 步进入下一轮循环,否则走第 6 步。
3.4.3.2 WriteLock
3.4.3.2.1 获取锁
-
WriteLock#lock()
如上图所示,为 WriteLock 写锁上锁流程。写锁上锁使用 WriteLock 的 lock() 方法即可。该方法内部直接调用了 AQS 实现的 acquire() 方法。其大致流程如下:
- 1、调用内部类 Sync 实现的 AQS 定义的 tryAcquire() 抽象方法。其作用是尝试获取一次写锁。该方法返回一个 boolean 值,true 表示获取写锁成功,false 表示获取写锁失败。若返回 true 则走第 2 步,否则走第 3 步。
- 2、获取写锁成功,流程结束。
- 3、调用 AQS 实现的 addWaiter() 方法将当前线程封装成一个 Node 节点并添加到队列尾部。
- 4、调用 AQS 实现的 acquireQueued() 方法。该方法内部为阻塞获取锁的逻辑。其返回一个 boolean 值,若为 true 则表示当前线程在阻塞等待锁期间被打断过,若为 false 则表示未被打断过。若返回 true 则走第 5 步,否则走第 2 步。
- 5、调用线程 interrupte() 方法将当前线程打断一次。然后走第 2 步。
-
ReentrantReadWriteLock#Sync#tryAcquire()
protected final boolean tryAcquire(int acquires) { Thread current = Thread.currentThread(); int c = getState(); int w = exclusiveCount(c); if (c != 0) { // (Note: if c != 0 and w == 0 then shared count != 0) if (w == 0 || current != getExclusiveOwnerThread()) return false; if (w + exclusiveCount(acquires) > MAX_COUNT) throw new Error("Maximum lock count exceeded"); // Reentrant acquire setState(c + acquires); return true; } if (writerShouldBlock() || !compareAndSetState(c, c + acquires)) return false; setExclusiveOwnerThread(current); return true; }
如上图所示,为内部类 Sync 实现的 AQS 定义的 tryAcquire() 抽象方法的具体流程。其作用是尝试获取一次写锁。返回一个 boolean 值,true 表示获取成功,false 表示获取失败。其大致流程如下:
- 1、调用 Thread 的 currentThread() 获取当前线程对象。
- 2、调用 AQS 的 getState() 获取锁状态。
- 3、判读锁状态 state 是否不为 0。若不为 0 则走第 4 步,否则走第 9 步。
- 4、判断锁状态中的独占状态是否为 0,即判断 state 的低 16 位是否为 0。若为 0 则说明读锁已被获取,根据重入不支持升级规则,此时走第 5 步;若不为 0 则说明此时写锁已被获取,则判断是否有重入可能,走第 6 步。
- 5、返回 false,表示获取锁失败。
- 6、判断当前线程是否为锁的持有者。若不是则走第 5 步,若是则走第 7 步。
- 7、调用 AQS 的 setState() 方法更新 state 状态。若执行到此处说明锁重入成功。
- 8、返回 true,表示获取写锁成功。
- 9、调用 Sync 定义的子类实现的 writeShouldBlock() 方法。其作用是判断当前线程是否应该阻塞,其返回一个 boolean 值,true 表示应该阻塞,false 表示不应该阻塞。分为公平实现和非公平实现,其中公平实现逻辑为判断队列中是否有前驱节点,非公平实现为直接返回一个 false 表示永远不阻塞。若返回 true 则走第 5 步,否则走第 10 步。
- 10、调用 AQS 的 compareAndSetState() 方法尝试更新 state 的值。若执行到此处说明当前线程有机会被获取到写锁。若更新成功则走第 11 步,失败则走第 5 步。
- 11、调用 AQS 的 setExclusiveOwnerThread() 将锁的持有者设置为当前线程。然后走第 8 步。
-
AQS#acquireQueued()
见 3.4.2.1.1 章节中对该方法的流程分析。
3.4.3.2.2 释放锁
-
WriteLock#unlock()
如上图所示,为写锁解锁流程,写锁解锁使用 WriteLock 的 unlock() 方法即可。unlock() 内部直接调用了 AQS 实现的 release() 方法。其大致流程如下:
- 1、调用内部类 Sync 实现的 AQS 定义的 tryRelease() 抽象方法。其作用是尝试释放写锁,返回一个 boolean 值,true 表示写锁完全释放,false 表示只释放一次,还未完全释放(重入情况)。若返回 true 则走第 2 步,若返回 false 则走第 6 步。
- 2、判断头节点 head 是否不为 null。若不为 null 则走第 3 步,若为 null 则走第 5 步。
- 3、判断头节点的 waitStatus 是否不为 0。若不为 0 则走第 4 步,否则走第 5 步。为什么要判断这个状态,因为只有非零状态的节点才有责任唤醒后续节点。
- 4、调用 AQS 的 unparkSuccessor() 函数唤醒后继节点。其逻辑是遍历链表,调用 LockSupport 的 unpark() 函数唤醒离当前节点最近的那个没有被取消(waitStatus > 0)的节点所对应的线程。
- 5、锁释放,返回 true。
- 6、锁未完全释放,返回 false。
-
ReentrantReadWriteLock#Sync#tryRelease()
protected final boolean tryRelease(int releases) { if (!isHeldExclusively()) throw new IllegalMonitorStateException(); int nextc = getState() - releases; boolean free = exclusiveCount(nextc) == 0; if (free) setExclusiveOwnerThread(null); setState(nextc); return free; }
如上图所示,为内部类 Sync 实现的 AQS 定义的 tryRelease() 抽象方法的具体流程。其作用是尝试释放写锁,返回一个 boolean 值,true 表示写锁完全释放,false 表示只释放一次,还未完全释放(重入情况)。其大致流程如下:
- 1、判断当前线程是否为锁的持有者。若不是则直接抛异常流程结束(必须先获取锁才能释放锁),若是则走第 2 步。
- 2、调用 AQS 的 getState() 方法获取锁状态并减 1,然后赋值给临时变量 c。
- 3、调用 exclusiveCount() 方法以 c 为参数获取独占锁(写锁)数量并判断其是否等于 0,将判断结果赋值给临时变量 free。
- 4、判断 free。若为真则走第 5 步,否则走第 6 步。
- 5、调用 AQS 的 setExclusiveOwnerThread(null) 将锁持有者置为 null,表示写锁已释放。
- 6、调用 AQS 的 setState() 方法将 state 更新为 c。
- 7、返回 free。
3.4.4 StampedLock
3.4.4.1 锁原理
StampedLock 是 jdk 8 引入读写锁,其目的是为了进一步提升 ReentrantReadWriteLock 读写锁中读的性能。当然实际上这两者之间在实现上并没有联系,只是有着相同的功能,同时前者在读锁的实现上比后者有着更加出色的性能。二者间的主要区别是:
- StampedLock 使用时必须结合 戳。
- StampedLock 提供了乐观读,所以其在读的性能上要优于 ReentrantReadWriteLock。
StampedLock 在设计原理上类似于 AQS。首先,内部维护了一个被 volatile 修饰的长整型变量 state 用来表示锁状态;其次,使用一个 CLH 双向链表来承担阻塞队列的作用;最后,在读写锁的实现上,定义了两个内部类 ReadLockView、WriteLockView 来分别作为读写锁的实现,且其都实现了 Lock 接口。当然最重要的武器还是 CAS。
StampedLock 虽然提升了读的性能,但也有不足之处:
- 不支持锁重入。
- 不支持条件变量。
3.4.4.2 锁示例
public class StampedLockTest {
// 创建锁对象
private final StampedLock lock = new StampedLock();
// 读数据
// 先用乐观读 若读取到的数据失效(读期间数据被其它线程修改)则乐观读升级为读锁
public Object read() {
// 获取乐观读的 戳
long stamp = lock.tryOptimisticRead();
// todo 读取数据
// 验戳
if (lock.validate(stamp)) {
// 若验证通过则说明读取到的数据未被其它线程修改 返回数据
return data;
}
// 若验戳失败 则说明读取期间数据已被其它线程修改 则乐观读升级为读锁 并重新读取数据
stamp = lock.readLock();
try {
// todo 重新读取数据
return data;
} finally {
lock.unlockRead(stamp); // 释放读锁
}
}
// 写数据
public void write(Object data) {
long stamp = lock.writeLock();
try {
// todo 写数据
} finally {
lock.unlockWrite(stamp);
}
}
}
4 并发编程工具
juc 包中除了提供锁相关实现外,还提供了大量的并发编程工具,包括线程池、原子类、同步器、并发容器。
4.1 线程池
juc 包中提供了关于线程池操作的实现,如 ThreadPoolExecutor、ScheduledThreadPoolExecutor 等。可以用来自定义创建适合多种业务场景的线程池。
在需要多线程处理工作任务的业务场景中,频繁的创建和销毁线程是非常消耗资源的,所以就有了线程池。顾名思义,线程池是存放线程的池子,池子里的线程可以不停歇的处理任务,当无任务可处理时线程可销毁可存活。这样就避免了频繁创建和销毁线程带来的资源消耗问题。
4.1.1 结构与状态
4.1.1.1 结构
如上图所示,线程池是经典的 生产者-消费者 设计模式。用一个阻塞队列(Blocking Queue)来存放要处理的任务。主线程或其它线程(生产者)负责产生要处理的任务并放入阻塞队列。线程池(Thread Pool)中维护了一定的工作线程,负责消费阻塞队列中的任务。
4.1.1.2 状态
线程池的具体实现中(ThreadPoolExecutor)用一个 AtomicInteger 变量来存储线程池状态等信息。其中高 3 位来表示线程池的状态,低 29 位来表示线程池中工作线程的数量。这样就可以在一次 CAS 操作中同时更新线程池状态和工作线程数量信息。
4.1.1.3 类图
如上图所示,为 juc 提供的线程池相关类的类关系图。
-
Executor: 顶层接口,主要定义了 execute() 方法。其作用是向线程池中提交任务或让线程池执行任务。
void execute(Runnable command);
-
ExecutorService: 继承自 Executor 接口。增加了操作线程池的方法定义,如 shutdown()、shutdownNow()、isShutdown() 等。同时还增加了更多提交任务或线程池执行任务的方法定义,如 submit()、invokeAll()、invokeAny() 等。方法详细作用将在下节进行描述。
-
ScheduledExecutorService: 继承自 ExecutorService 接口。为任务调度线程池接口,增加了任务调度相关的方法,如 schedule()、scheduleAtFixedRate()、scheduleWithFixedDelay() 等。
-
AbstractExecutorService: Executor 接口的抽象实现,其实现了线程池接口中定义的大部分共用功能。
-
ThreadPoolExecutor: 线程池的具体实现,维护了线程池状态、工作线程、任务队列等,提供了适用于不同业务场景的线程池的构造方法,实现了线程池工作的具体流程。
-
ScheduledThreadPoolExecutor: 任务调度线程池的具体实现,扩展自 ThreadPoolExecutor 实现,在其基础上实现了任务调用线程池的相关功能。
4.1.2 核心属性与工作原理
4.1.2.1 核心属性
// 工作线程集合 创建的工作线程会被维护在该集合
// Worker 为内部类 对 Thread 类进行了扩展
private final Set<Worker> workers = new HashSet<>();
// 任务队列/阻塞队列 提交到线程池的任务会被缓存至该队列
private final BlockingQueue<Runnable> workQueue;
// 核心线程数
private volatile int corePoolSize;
// 最大线程数
private volatile int maximumPoolSize;
// 救急线程存活时间(corePoolSize - maximumPoolSize = 救急线程池数)
private volatile long keepAliveTime;
// 拒绝策略
// 拒绝策略是指任务队列已满后提交到线程池的任务的处理方式
private volatile RejectedExecutionHandler handler;
4.1.2.2 工作原理
如上图所示,为 ThreadPoolExecutor 线程池工作流程。其流程分析大致如下:
- 1、线程池接受任务或其它线程向线程池提交任务。
- 2、判断工作线程数是否小于核心线程数。若小于则走第 3 步,否则走第 10 步。
- 3、创建核心工作线程,并将该线程加入工作线程集合,然后该线程开始执行当前任务。
- 4、当工作线程将任务执行完毕后,走第 5 步。
- 5、判断任务队列是否不为空。若不为空则走第 6 步,否则走第 7 步。
- 6、工作线程获取任务队列中的任务并执行。然后走第 4 步。
- 7、判断工作线程空闲 时间是否大于线程存活时间。若大于则走第 8 步,否则救急线程继续待命(注:在此期间核心线程一直处于待命状态,并没有结束。)
- 8、救急线程结束。
- 9、流程结束。
- 10、判断任务队列大小是否小于任务队列容量。若是则将任务加入任务队列等待被执行然后走第 9 步,否则走第 11 步。
- 11、判断工作线程数是否小于最大线程数。若是则创建救急工作线程,并将该线程加入工作线程集合,然后该线程开始执行当前任务,然后走第 4 步;否则走第 12 步。
- 12、执行拒绝策略,然后走第 9 步。
简言之,当工作线程数还未达到核心线程数时,对于新来的任务将创建新的线程执行;当工作线程数达到核心线程数时,新来的任务将进入任务队列;当任务队列中任务数达到队列容量时,对于新来的任务将采取拒绝策略;工作线程会持续性执行队列中的任务,即某个线程执行完当前任务后会从队列中获取任务继续执行;当任务队列中无任务时工作线程会继续等待任务,若等待时间超过线程存活时间(keepAliveTime),此时救急线程将结束,核心线程将继续等待。
RejectedExecutionHandler 是 juc 提供拒绝策略接口,其采用了策略模式,在提供了几种策略之外还支持自定义策略,可在线程池构造时指定策略。juc 提供的策略实现以及其它常用的拒绝策略有以下几种:
- AbortPolicy:丢弃任务,即当队列满后,会丢弃新来的任务,然后抛出 RejectedExecutionException 异常。
- CallerRunsPolicy:任务提交者自己执行任务,即当队列满后,新来的任务将由任务提交线程自己执行。
- DiscardPolicy:丢弃任务,不会抛出异常。
- DiscardOldestPolicy:丢弃老任务,即丢弃最先进入队列的任务。
- 死等:即任务提交线程死等,直至任务提交成功。
- 超时等待:即任务提交线程在指定时间内等待,若超时则放弃提交。
- 抛出异常:即提交失败后抛出异常。
4.1.3 构造器与方法
4.1.3.1 构造器
ThreadPoolExecutor 提供多种不同类型线程池的构造器,而构造器的参数值直接决定了线程池的类型或工作原理。构造器全部入参如下:
- corePoolSize:核心线程数。
- maximumPoolSize:最大线程数。
- keepAliveTime:普通线程存活时间。maximumPoolSize - corePoolSize = 普通线程数。
- unit:普通线程存活时间的时间单位。
- workQueue:阻塞队列,用来存方法任务。
- threadFactory:线程工厂,用来在创建线程时进行一些自定义操作,如设置线程名等。
- handler:拒绝策略,当队列中任务数达到队列大小时进行的自定义操作。
ThreadPoolExecutor 线程池提供的构造器如下:
-
newFixedThreadPool: 固定线程池。
特点:
- 核心线程数 == 最大线程数,故无存活时间;
- 无界队列,可存放任意数量任务。
适用:任务量已知,相对耗时的任务。
public static ExecutorService newFixedThreadPool(int nThreads) { return new ThreadPoolExecutor(nThreads, nThreads, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>()); }
-
newCachedThreadPool: 缓存线程池。
特点:
- 核心线程数为 0,救急线程数为 Integer.MAX_VALUE,即工作线程全为救急线程;
- 救急线程存活时间为 60s,即无任务时工作线程等待 60s 后会结束。
- 任务队列采用 SynchronousQueue,其特点是只有当有消费线程来获取任务时生产线程才能成功提交任务到队列。
- 工作线程数随任务量增长,没有上限,任务执行结束 60s 后释放线程。
适用:任务密集,耗时较短的任务。
public static ExecutorService newCachedThreadPool() { return new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS, new SynchronousQueue<Runnable>()); }
-
newSingleThreadPool: 单例线程池。
特点:
- 工作线程数始终为 1 且不能修改数量。
- 若任务执行期间出现异常,则线程池会重新创建一个线程补上,即始终保持池中有一个线程在工作。
- 当提交任务数大于 1 时,其它任务会入队,所有任务执行完毕后唯一线程也不会被释放。
适用:任务执行存在先后顺序。
public static ExecutorService newSingleThreadExecutor() { return new FinalizableDelegatedExecutorService (new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>())); }
-
newScheduledThreadPool: 任务调度线程池。
特点:
- 线程数固定,任务数多于线程数时,任务入队。
使用:任务需延迟或反复执行。
public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) { return new ScheduledThreadPoolExecutor(corePoolSize); }
4.1.3.2 方法
线程池常用方法如下:
/** 任务 执行、提交 相关 **/
// 执行任务或提交任务
void execute(Runnable comman);
// 提交任务 带返回值
<T> Future<T> submit(Callable<T> task);
// 批量提交任务 带返回值 待提交的任务都执行完毕后才会返回
<T> List<Future<T>> invokeAll(Collection<? extends Callable<T>> tasks) throws InterruptedException;
// 批量提交任务 带返回值 带超时 若超时则只返回已执行完毕的任务结果集
<T> List<Future<T>> invokeAll(Collection<? extends Callable<T>> tasks, long timeout, TimeUnit unit);
// 批量提交任务 返回最先执行完的任务结果(此时其它任务取消)
<T> T invokeAny(Collection<? extends Callable<T>> tasks) throws InterruptedException, ExecutionException;
// 批量提交任务 带超时 返回最先执行完的任务结果(此时其它任务取消)
<T> T invokeAny(Collection<? extends Callable<T>> tasks) throws InterruptedException, ExecutionException, TimeoutException;
/** 线程池关闭相关 **/
// 关闭线程池 状态变为 SHUTDOWN 调用后将不再接受新任务,但正在执行的和队列中的任务都将正常执行完毕
void shutdown();
// 关闭线程池 状态变为 STOP 调用后不再接受新任务、打断正在执行的任务、抛弃队列中的任务 并返回队列中剩余任务集
List<Runnable> shutdownNow();
/** 其它辅助方法 **/
// 判断线程池是否处于 SHUTDOWN 状态 实际上只要线程池不处于 RUNNING 状态则都返回 true
boolean isShutdown();
// 主线程在指定时间内等待线程池工作结束 若指定时间内所有任务都执行完毕则返回 true 若超过指定时间则返回 false
// 主线程可根据等待结果做一些额外操作
boolean awaitTermination(long timeout, TimeUnit unit) throws InterruptedException;
4.1.4 任务调度线程池
4.1.4.1 任务调度线程池说明
在任务调度线程池加入 jdk 以前,可以使用 java.util.Timer 类来实现定时任务。其优点是简单易用;缺点是所有任务都由一个线程来执行,即任务串行执行,前一个任务执行延迟或异常都将影响后一个任务的执行。
任务调度线程池的优点是任务由固定数量的线程执行;任务可串行、可并行执行;任务执行延迟不会影响后续任务执行;任务异常则会创建新的线程补上;可灵活指定延迟时间、间隔时间。
任务调度线程池构造器:
public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) {
return new ScheduledThreadPoolExecutor(corePoolSize);
}
4.1.4.2 任务调度线程池常用方法
任务调度常用方法:
// 一次性延迟任务 及启动后 delay 时间后执行一次
public ScheduledFuture<?> schedule(Runnable command,
long delay, TimeUnit unit);
// 一次性延迟任务 带返回值
public <V> ScheduledFuture<V> schedule(Callable<V> callable,
long delay, TimeUnit unit);
// 延迟定时任务
// command: 任务
// initinalDelay: 初始延迟 即启动后将在 initinalDelay 时间后开始执行定时任务
// period: 间隔时间(会受到任务执行时间的影响 如 period 为 1s, 任务执行需 2s,则实际两次任务执行间隔为 0s)
// unit: initinalDelay 和 period 的单位
public ScheduledFuture<?> scheduleAtFixedRate(Runnable command,
long initialDelay,
long period,
TimeUnit unit);
// 延迟定时任务
// 与 scheduleAtFixedRate 的区别是 delay 不受任务执行所需时间的影响
// delay 从上一次执行结束算起 到下一次执行开始
public ScheduledFuture<?> scheduleWithFixedDelay(Runnable command,
long initialDelay,
long delay,
TimeUnit unit);
4.1.5 异常处理
4.1.5.1 异常带来的问题
线程池中若不处理异常则会消耗性能。即当池中某个线程执行任务时出现异常,若不处理则线程中断,可能需要创建一个新的线程补上;若处理了异常则当前线程会去执行下一个任务。
4.1.5.2 异常处理
- 主动捕获处理。即 try catch。
- Future< Object> 接收。正常情况下 Future< T> 是用来接收任务执行结果的,但当任务出现异常时,future.get() 拿到的是异常信息。
4.1.6 Fork & Join
4.1.6.1 简介
Fork/Join 是 jdk 1.7 加入的一种线程池实现,它采用一种分治思想,适用于可进行任务拆分的业务场景。
任务拆分是指将一个大任务拆分为多个计算规则相同的小任务,直至不能拆分,然后对每个小任务进行计算,最后对结果进行合并。分治思想常用于跟递归相关的一些算法上,如归并排序、斐波那契数列等。
ForkJoinPool 会将多个小任务的执行交给多个线程去执行,但是任务的拆分需要自己实现。且其默认会创建和 cpu 核心数相同的线程数。
4.1.6.2 使用
ForkJoinPool 为 fork/join 线程池实现,提交给该线程池的任务对象需要实现 RecursiveTask< T> 或 RecursiveAction 类,其中前者有返回值(范型 T 代表返回值类型),后者没有返回值;同时得实现其 compute() 抽象方法,作为任务执行内容。
下述例子对 m ~ n 之间的整数求和,即可用 fork/join 解决。
// 任务类
public class SumTask extends RecursiveTask<Integer> {
private int start;
private int end;
public SumTask(int start, int end) {
this.start = start;
this.end = end;
}
@Override
protected Integer compute() {
// 以下两个判断可理解为递归结束条件
if (start == end) {
return start;
}
if (end - start == 1) {
return start + end;
}
// 分治
int mid = (start + end) / 2;
SumTask left = new SumTask(start, mid);
SumTask right = new SumTask(mid + 1, end);
left.fork();
right.fork();
// 结果求和
return left.join() + right.join();
}
}
// test
ForkJoinPool pool = new ForkJoinPool();
pool.invoke(new SumTask(1, 10));
4.2 原子类
juc 包中提供的具有原子性操作的类,如 AtomicInteger、AtomicLong、AtomicRefrence 等。可以在无锁的情况下进行线程安全的原子性操作。
原子类的实现原理是 CAS 和 volatile。首先 CAS 只是一种算法,即比较和替换,如果相等则替换,否则就失败;其次,并发情况下必然是多个线程同时访问共享变量,又因为 CPU 多级缓存的存在,导致共享变量会出现可见性问题(共享变量可见性问题本质上是多核 CPU 的私有缓存造成的),于是就需要保证共享变量的可见性,这时候 volatile 就派上用场了;最后,再结合 while (true) 之类的死循环就实现了无锁。所以从本质上来说,CAS、volatile、while (true) 是无锁的三大件。换言之,CAS 操作必须借助 volatile 来获取到变量最新值以达到比较替换的效果。
同时,juc 中所有 CAS 算法的实现,都是底层类 Unsafe 提供的。常用原子类如下:
- 原子整数:
- AtomicBoolean:对应 boolean 类型变量。(包装类型 Boolean 需要用 AtomicReference)
- AtomicInteger:对应 int 类型变量。
- AtomicLong:对应 long 类型变量。
- 原子引用:
- AtomicReference:对应引用类型变量。
- AtomicStampedReference:引用类型变量的版本号版本,即带版本号的引用类型原子类。可解决 ABA 问题。
- AtomicMarkableReference:引用类型变脸的标记版本,即带标记的引用类型原子类。和 AtomicStampedReference 的区别是前者只记录其是否被修改过,而后者记录了修改的版本。
- 原子数组:
- AtomicIntegerArray:对应 int[] 数组。
- AtomicLongArray:对应 long[] 数组。
- AtomicReferenceArray:对应 Object[] 数组。
- 字段更新器:
- AtomicIntegerFieldUpdater:对应 int 类型字段属性。(属性必须用 volatile 修饰)
- AtomicLongFieldUpdater:对应 long 类型字段属性。
- AtomicReferenceFieldUpdater:对应引用类型字段属性。
- 原子累加器:
4.2.1 原子整数
juc 提供的原子整数类有 AtomicBoolean、AtomicInteger、AtomicLong。以 AtomicInteger 为例:
// 创建 AtomicInteger 对象 初始值赋 0
AtomicInteger atomicInteger = new AtomicInteger(0);
// 获取
System.out.println(atomicInteger.get()); // 0
// 先获取并自增 1 相当于 i++
System.out.println(atomicInteger.getAndIncrement()); // 0
// 先自增 1 并获取 相当于 ++i
System.out.println(atomicInteger.incrementAndGet()); // 2
// 先获取并自减 1 相当于 i--
System.out.println(atomicInteger.getAndDecrement()); // 2
// 先自减 1 并获取 相当于 --i
System.out.println(atomicInteger.decrementAndGet()); // 0
// 先获取并自增指定数
System.out.println(atomicInteger.getAndAdd(2)); // 0
// 先自增指定数并获取
System.out.println(atomicInteger.addAndGet(2)); // 4
// 先获取并修改
System.out.println(atomicInteger.getAndUpdate(i -> i - 2)); // 4
// 先修改并获取
System.out.println(atomicInteger.updateAndGet(i -> i - 2)); // 0
// 先获取并计算
// 其中第一个参数(2)为 第二个参数(lambda)中的 x, 第二个参数(lambda)中的 p 为当前值
System.out.println(atomicInteger.getAndAccumulate(2, (p, x) -> p + x)); // 0
// 先计算并获取
System.out.println(atomicInteger.accumulateAndGet(2, (p, x) -> p + x)); // 4
// cas
System.out.println(atomicInteger.compareAndSet(0, 1)); // false
4.2.2 原子引用
juc 提供的原子引用类有 AtomicReference、AtomicStampedReference、AtomicMarkableReference。
-
AtomicReference
// 创建 AtomicReference 对象 并赋初值 "a" 初值默认为 null AtomicReference<String> atomicReference = new AtomicReference<>("A"); // 获取 System.out.println(atomicReference.get()); // A // 设置 atomicReference.set("B"); System.out.println(atomicReference.get()); // B
-
AtomicStampedReference
// 创建原子对象 第一个参数为引用对象 第二个参数为版本号 AtomicStampedReference<String> atomicStampedReference = new AtomicStampedReference<>("A", 0); // 获取引用值 System.out.println(atomicStampedReference.getReference()); // A // 获取版本号 System.out.println(atomicStampedReference.getStamp()); // 0 // cas 替换 // 参数一:期望引用值;参数二:修改引用值;参数三:期望版本号;参数四:修改版本号(期望版本号 + 1) atomicStampedReference.compareAndSet("A", "B", 0, 0 + 1)
-
AtomicMarkableReference
// 创建原子对象 第一个参数为引用值 第二个参数为初始标记值 AtomicMarkableReference<String> atomicMarkableReference = new AtomicMarkableReference<>("A", false); // 获取引用值 System.out.println(atomicMarkableReference.getReference()); // A // cas 替换 // 参数一:期望引用值;参数二:修改引用值;参数三:期望标记值;参数四:修改标记值 atomicMarkableReference.compareAndSet("A", "B", false, true)
4.2.3 原子数组
juc 提供的原子数组有 AtomicIntegerArray、AtomicLongArray、AtomicReferenceArray。以 AtomicIntegerArray 为例:
// 创建原子对象 构造参数为数组
AtomicIntegerArray atomicIntegerArray = new AtomicIntegerArray(new int[10]);
// 给指定索引设置值
atomicIntegerArray.set(0, 0);
// 获取指定索引元素
System.out.println(atomicIntegerArray.get(0)); // 0
// cas 替换
// 参数一:要替换的元素索引值;参数二:指定元素的预期值;参数三:指定元素的修改值
atomicIntegerArray.compareAndSet(0, 0, 1);
4.2.4 原子字段更新器
juc 提供的原子字段更新器有 AtomicIntegerFieldUpdater、AtomicLongFieldUpdater、AtomicReferenceFieldUpdater。以 AtomicIntegerFieldUpdater 为例:
// 测试实体类
public class TestVo {
private volatile int age; // 注:原子字段更新器操作的字段必须用 volatile 修饰
private volatile String name;
}
// 测试对象
TestVo testVo = new TestVo();
// 创建原子字段更新器 构造器第一个参数为要更新字段所在类的类类型 第二个参数为要更新字段名
AtomicIntegerFieldUpdater<TestVo> fieldUpdater = AtomicIntegerFieldUpdater.newUpdater(TestVo.class, "age");
// 获取字段值
System.out.println(fieldUpdater.get(testVo));
// 修改字段值
fieldUpdater.set(testVo, 1);
// cas 替换
// 参数一:对象;参数二:字段预期值;参数三:字段更新值
fieldUpdater.compareAndSet(testVo, 1, 2);
4.2.5 原子累加器
原子累加器,即 LongAdder,它亦是大哥李的杰作(这人实在太牛逼了!)。它的作用同 AtomicLong,但比 AtomicLong 要更快!更快!更快!。其核心属性如下:
// 累加单元数组 懒惰初始化
transient volatile Cell[] cells;
// 当没有竞争时 在该变量上累加 有竞争时在累加单元上累加 最终结果是 base + cells 数组里的值
transient volatile long base;
// cells 锁状态 0: 未被占有; 1: 被占有 当初始化 cells 或扩容 或创建某个具体的元素时需加锁 防止重复操作
transient volatile int cellsBusy;
LongAdder 在设计上,采用了有点类似于分治的思想。其内部维护了一个 Cell[] 数组,且数组大小最大等于 CPU 核数,即每个 CPU 都会对应一个 cell 元素,多线程情况下按照 CPU 分组在其对应的 cell 对象里进行累加,获取结果再将数组里的值进行求和即可。简言之,增加了多个累加单元,减少了线程 cas 失败时的重试次数,从而提高性能。
其 Cell 类上添加了 @jdk.internal.vm.annotation.Contended 注解,这和 伪共享 有关,而伪共享则和 CPU 高速缓存的缓存行有关。
CPU 缓存行是以缓存行为最小单位进行缓存数据的,一个缓存行对应内存中一块数据(注意是一块,不是一个),缓存行大小一般为 64 byte,也就是说,CPU 每次会从内存中获取总大小小于 64 byte 的一堆数据放入一个缓存行。缓存行失效的条件时只要缓存行那某个数据被修改,那这个缓存行就判定为失效。
cells 是个数组,数组中的元素在内存中是连续存放的。一个 Cell 元素占 24 byte(数组对象对象头占 16 byte,value 占 8 byte),CPU 在缓存 cells 数组时可能会将两个相邻的 cell 元素缓存在同一个缓存行里( (24 + 24) < 64)。这种情况下,就会有以下结果:
- 假设 thread-1 要在 cell[0] 上累加,thread-2 要在 cell[1] 上累加。
- 那么 thread-1 就会将 cell[0] 加载到当前 CPU 缓存行里(同时也可能将 cell[1] 也放到同一个缓存行里)。
- thread-2 就会将 cell[1] 加载到当前 CPU 缓存行里(同时也可能将 cell[0] 也放到同一个缓存行里)。
那么问题就来了,thread-1 更新 cell[0] 时会导致所有 CPU 里缓存了 cell[0] 和 cell[1] 的缓存行都失效;同理 thread-2 更新 cell[1] 时也会让所有 CPU 里缓存了 cell[0] 和 cell[1] 的缓存行都失效。简言之,对于当前正在执行 thread-1 代码的 CPU 来说,虽然我缓存了 cell[0],但它失效了,thread-2 同理。
对于 伪共享 的解决方法是:每次缓存时我只从内存中加载我需要的那部分数据,然后放到缓存行,即缓存行里只有 cell[0] 或 cell[1]。而 @jdk.internal.vm.annotation.Contended 注解,就是解决这个问题的。其原理是在被此注解作用的类的对象或属性前后各增加 128 byte 大小的 padding(参考前端 css 代码中的 padding),换言之,莫挨老子。这样就防止多个数据被加载至统一缓存行。从而解决伪共享问题。
4.2.6 Unsafe
Unsafe 即 sun.misc.Unsafe 类,是 jdk 提供的最底层的操作内存、线程的类。名为不安全的,实际是指该类不适合编程人员直接操作,因为是直接操作内存等,会造成不可预估的错误。同时该类对象不能直接创建,只能通过反射创建。以下以自定义实现 AtomicInteger 原子类为例,展示 Unsafe 用法。
public class CustomAtomicInteger {
// Unsafe 实例
private static final Unsafe unsafe;
// value 属性在其所在类中的偏移量
private static final long offset;
// value 用来存放 int 值(cas 操作的共享变量必须配合 volatile 使用)
private volatile int value;
static {
try {
// 通过反射获取 Unsafe 类中 theUnsafe 属性(Unsafe 是个单例类 持有了自身实例 但只能反射获取)
Field field = Unsafe.class.getDeclaredField("theUnsafe");
// 修改其访问权限
field.setAccessible(true);
// 获取属性值
unsafe = (Unsafe) field.get(null);
// 获取指定类中指定属性的偏移量
offset = unsafe.objectFieldOffset(CustomAtomicInteger.class.getDeclaredField("value"));
} catch (NoSuchFieldException | IllegalAccessException e) {
throw new RunTimeException(e);
}
}
public CustomAtomicInteger(int value) {
this.value = value;
}
public boolean compareAndSet(int expect, int update) {
while (true) {
if (unsafe.compareAndSwapInt(this, offset, expect, update)) {
return true;
}
}
}
public int get() {
return value;
}
public void set(int newValue) {
this.value = value;
}
}
4.3 同步器
juc 包中提供一些具有线程同步功能的类,如 Semaphore、CountDownLatch、CyclicBarrier 等。可以协调多线程间的执行顺序和访问控制等。
4.3.1 Semaphore
Semaphore 即信号量,它可以用来限制并发访问同步资源的线程数。可以应用于连接池最大连接数等场景。使用实例如下:
@Slf4j
public class SemaphoreTest {
public static void main(String[] args) {
// 构造参数表示并发线程数 即同时只能有最多三个线程访问并发资源
Semaphore semaphore = new Semaphore(3);
for (int i = 0; i < 10; i++) {
new Thread(() -> {
try {
semaphore.acquire();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
log.info("start...");
try {
Thread.sleep(2000L);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
log.info("end...");
semaphore.release();
}).start();
}
}
}
21:57:47.274 [Thread-2] INFO org.xgllhz.juc.sync.SemaphoreTest - start...
21:57:47.274 [Thread-0] INFO org.xgllhz.juc.sync.SemaphoreTest - start...
21:57:47.274 [Thread-1] INFO org.xgllhz.juc.sync.SemaphoreTest - start...
21:57:49.280 [Thread-1] INFO org.xgllhz.juc.sync.SemaphoreTest - end...
21:57:49.280 [Thread-2] INFO org.xgllhz.juc.sync.SemaphoreTest - end...
21:57:49.280 [Thread-0] INFO org.xgllhz.juc.sync.SemaphoreTest - end...
21:57:49.281 [Thread-5] INFO org.xgllhz.juc.sync.SemaphoreTest - start...
21:57:49.281 [Thread-3] INFO org.xgllhz.juc.sync.SemaphoreTest - start...
21:57:49.281 [Thread-6] INFO org.xgllhz.juc.sync.SemaphoreTest - start...
21:57:51.284 [Thread-5] INFO org.xgllhz.juc.sync.SemaphoreTest - end...
21:57:51.286 [Thread-4] INFO org.xgllhz.juc.sync.SemaphoreTest - start...
21:57:51.287 [Thread-6] INFO org.xgllhz.juc.sync.SemaphoreTest - end...
21:57:51.289 [Thread-3] INFO org.xgllhz.juc.sync.SemaphoreTest - end...
21:57:53.289 [Thread-4] INFO org.xgllhz.juc.sync.SemaphoreTest - end...
Semaphore 在实现上依然依赖于 AQS。其构造入参可理解为共享锁的最大上限(实则为锁状态 state 的初值),调用 acquire() 方法可理解为获取共享锁,只有当共享锁持有数量小于共享锁上限时才能获取到,获取成功后 state++;调用 release() 方法可理解为释放共享锁,释放成功后 state–,并唤醒阻塞线程。
Semaphore 在实现上亦有公平和非公平两种。公平意味着当共享资源占有线程数达到上限后,后来者会直接进入阻塞队列排队等待;非公平意味着当共享资源占用线程数达到上限后,后来者会先尝试竞争一次,若竞争失败才会进入阻塞队列中排队等待。且其默认为非公平实现。
semaphore.acquire() 核心源码如下:
public void acquire() throws InterruptedException {
sync.acquireSharedInterruptibly(1);
}
public final void acquireSharedInterruptibly(int arg)
throws InterruptedException {
if (Thread.interrupted())
throw new InterruptedException();
// 调用 tryAcquireShared 方法尝试获取一次
// 其返回值 < 0 则表示获取失败;> 0 则表示获取成功,同时表示剩余上限
if (tryAcquireShared(arg) < 0)
// 若失败后则进入阻塞队列等待
doAcquireSharedInterruptibly(arg);
}
// 非公平实现 由 Semaphore 内部类 Sync 的子类 NonfairSync 实现
protected int tryAcquireShared(int acquires) {
return nonfairTryAcquireShared(acquires);
}
final int nonfairTryAcquireShared(int acquires) {
for (;;) {
int available = getState();
int remaining = available - acquires;
if (remaining < 0 ||
compareAndSetState(available, remaining))
return remaining;
}
}
// AQS 中实现 这种套路在 ReentrantLock 和 ReentrantReadWriteLock 中已分析过多次 可参考
private void doAcquireSharedInterruptibly(int arg)
throws InterruptedException {
final Node node = addWaiter(Node.SHARED);
try {
for (;;) {
final Node p = node.predecessor();
if (p == head) {
int r = tryAcquireShared(arg);
if (r >= 0) {
setHeadAndPropagate(node, r);
p.next = null; // help GC
return;
}
}
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
throw new InterruptedException();
}
} catch (Throwable t) {
cancelAcquire(node);
throw t;
}
}
4.3.2 CountDownLatch
CountDownLatch 即计数器,可用来实现线程间同步写作,如等待其它线程执行结束。其构造参数接收一个整数值 n,表示主线程将等待 n 个线程执行完成后才继续运行。这 n 个线程中的每个线程在运行结束前需要将计数器值减一。可应用于多线程执行结果汇总、多线程远程调用(微服务调用)等场景。其使用示例如下:
@Slf4j
public class CountDownLatchTest {
public static void main(String[] args) throws InterruptedException {
// 构造入参为 3 表示主线程需要等待三个线程执行结束
CountDownLatch latch = new CountDownLatch(3);
ExecutorService threadPool = Executors.newFixedThreadPool(3);
threadPool.execute(() -> {
log.info("start...");
try {
Thread.sleep(1000L);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
log.info("end...");
latch.countDown(); //线程内容运行完后计数器减一
});
threadPool.execute(() -> {
log.info("start...");
try {
Thread.sleep(2000L);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
log.info("end...");
latch.countDown(); //线程内容运行完后计数器减一
});
threadPool.execute(() -> {
log.info("start...");
try {
Thread.sleep(1500L);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
log.info("end...");
latch.countDown(); //线程内容运行完后计数器减一
});
log.info("start...");
latch.await(); // 主线程等待其它三个线程执行完成
log.info("end...");
threadPool.shutdown();
}
}
CountDownLatch 的实现依然依赖于 AQS,核心方法为 countDown() 和 await(),为 AQS 家族中最简单的一个,请自行阅读源码。
4.3.3 CyclicBarrier
CyclicBarrier 即循环栅栏,可用来进行线程协作,当计数器达到指定值后才开始运行。其构造器入参接收两个参数,第一个表示计数器预值,第二个参数表示当计数器达到预值后要执行的操作。子线程执行完后会将计数器加一,直到计数器达到预值后才开始执行第二个参数对应的操作。其使用示例如下:
@Slf4j
public class CyclicBarrierTest {
public static void main(String[] args) throws BrokenBarrierException, InterruptedException {
// 2 表示当计数器达到 2 时开始执行第二个参数对应的内容
CyclicBarrier barrier = new CyclicBarrier(2, () -> {
log.info("thread1 thread2 started...");
});
new Thread(() -> {
log.info("start...");
try {
Thread.sleep(1000L);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
log.info("end...");
try {
barrier.await(); // 线程内容执行完后将计数器加一
} catch (InterruptedException | BrokenBarrierException e) {
throw new RuntimeException(e);
}
}).start();
new Thread(() -> {
log.info("start...");
try {
Thread.sleep(2000L);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
log.info("end...");
try {
barrier.await();
} catch (InterruptedException | BrokenBarrierException e) {
throw new RuntimeException(e);
}
}).start();
}
}
CyclicBarrier 在实现上依赖于 ReentrantLock。其与 CountDownLatch 的区别是:
- CountDownLatch 为自减型,CyclicBarrier 为自增型。
- 对于主线程来说,CountDownLatch 是一次性的(即计数器减为 0 后不能重复使用,需要重新创建),而 CyclicBarrier 可重复利用(即计数器加为预值后,会自动重置为 0)。
4.4 并发容器
juc 包中提供的一些线程安全的容器,如 ConcurrentHashMap、BlockingQueue、CopyOnWriteArrayList 等。可以在多线程下安全使用。
4.4.1 分类
如上图所示,为 jdk 提供的主要的线程安全集合类实现。共分为三大类,分别是旧版本遗留的实现、Collections 类中通过装饰器修饰的实现、juc 包中提供的实现。
-
旧版本遗留的:
这类安全容器是 jdk 旧版本中的实现,如 Vector、HashTable。其实现原理是 synchronized 关键字修饰相关方法。
-
Collections 装饰的:
这类安全容器由 Collections 类通过诸如 synchronizedLsit() 此类方法提供,其实现涉及到 List、Set、Map。其实现原理是装饰器模式。具体为定义内部类(如 SynchronizedLsit< E>),通过 synchronized 关键修饰对外提供的方法。
-
juc 包提供的:
这类安全容器由 juc 包提供,大致可分为 blocking 系列、concurrent 系列、copy on write 系列,如 ArrayBlockingQueue、ConcurrentHashMap、CopyOnWriteArrayList 等。
- blocking 系列:为阻塞容器,大部分基于 Lock 锁实现。
- concurrent 系列:其实现原理为 cas 优化与 Lock 锁的结合,并发度高。缺点是存在若一致性,即对于读取、遍历、元素数量等操作的结果可能与实际值存在一定偏差,这主要是由高并发下,读写操作同时进行造成的数据误差。
- copy on write 系列:读写分离,适用于读多写少的场景,其实现原理为写入时拷贝与 Lock 锁的结合。
4.4.2 原理
主要说明 LinkedBlockingQueue、ConcurrentHashMap、ConcurrentLinkedQueue、CopyOnWriteArrayList。
4.4.2.1 LinkedBlockingQueue
LinkedBlockingQueue 是线程安全的链表实现,其在线程安全方面的设计是使用两把锁(ReentrantLock)和 Dummy 节点。一把锁头节点,解决 take 时的安全问题;一把锁尾节点,解决 put 时的安全问题。Dummy 节点亦可理解为占位节点,take 锁永远锁住的是 Dummy 节点,Dummy 节点的存在保证了当链表中只有一个节点时 take 线程和 put 线程不会竞争(因为 take 锁锁住的是 dummy 节点,put 锁锁住的是唯一的元素节点)。两把锁的优点是保证了入队和出队没有竞争,在一定程度上提高了并发度。
LinkedBlockingQueue 与 ArrayBlockingQueue 的区别是:
- Linked 底层数据结构是链表,Array 底层数据结构是数组。
- Linked 支持有届,Array 强制有届。
- Linked 懒惰初始化,Array 初始化时会创建 Node 数组。
- Linked 入队时才创建 Node 对象,Array 会提前创建。
- Linked 两把锁,Array 一把锁。
4.4.2.2 ConcurrentHashMap
ConcurrentHashMap 在 jdk 1.7 和 1.8 中的实现并不相同,其各自结构如上图所示。
-
结构:
- 1.7: 采用 Segment 数组 + HashEntry 数组 + 链表 的结构。
其中 Segment 扩展自 ReentrantLock,即其本质上为一个锁对象数组,且默认长度为 16,初始化后不可更改,其作用是解决并发安全问题,即分段锁。
HashEntry 数组类似于 HashMap 中的 table 数组,是真正用来存放键值对的,当发生哈希冲突时数组元素会从 Entry 变为 Entry 链表,且其最小长度为 2。
Segment 数组中的每个元素都对应一个 HashEntry 数组,所有 HashEntry 数组中的键值对即为 ConcurrentHashMap 集合中的值。 - 1.8: 采用 数组 + 链表 + 红黑树 的结构。
其中 Node 数组即为 hash table(哈希桶),当发生哈希冲突时数组元素变为 Node 链表结构,当链表长度大于 8 时,Node 链表结构会转化为 TreeBin 红黑树结构。结构上和 1.8 中 HashMap 的结构区别不大。
- 1.7: 采用 Segment 数组 + HashEntry 数组 + 链表 的结构。
-
并发设计
- 1.7: 采用 ReentrantLock 分段锁的设计来保证并发安全。初始化时会创建长度为 16 的 Segment 数组,Segment 本质上也是锁,每个 Segment 元素对应一批数据集,也就意味着一个 Segment 元素保证一批数据的安全。当要操作这批数据时,需要先获得这批数据对应的 Segment 锁。
- 1.8: 采用 CAS + synchronized 来保证并发安全。没有竞争时使用 CAS 算法操作数据;存在竞争时,以链表头节点或红黑树根节点为锁对象,使用 synchronized 关键字来修饰。即当多个线程需要同时操作某一个链表或红黑树时,才会加锁。
为什么使用 synchronized 而不是 Lock?这是因为锁对象已经明确为链表头节点或树的根节点,锁粒度已降低,Lock 锁的多条件变量优势已不在,且 synchronized 比 Lock 锁更节省空间。同时,synchronzied 经过优化后,已从原来的重量级锁变为锁升级过程。
-
构造函数:
-
1.7:
public ConcurrentHashMap(int initialCapacity, float loadFactor, int concurrencyLevel) {...}
- initialCapacity:map 初始容量,实际容量为大于或等于该入参的最小 2 的 N 次幂,默认初始容量为 16。
- loadFactor:负载因子,扩容时使用。当 map 中实际键值对个数大于 负载因子 * 容量 时,会进行扩容。
- concurrencyLevel:并发等级,即 Segment 数组大小,即分段锁个数。默认为 16。
注:除了 Segment 数组,其余数组皆为懒惰初始化。
-
1.8:
同 1.7。不同点在于 1.8 中的 concurrencyLevel 仅做判断使用,保证了 initialCapacity 最小值为 concurrencyLevel。
注:所有数组皆为懒惰初始化。
-
-
put:
- 1.7: put 时会有两次定位操作,一是通过 key 的 hash 定位其对应的 segment,二是通过 hash 定位当前 key 在 HashEntry 数组中的位置。在定位 segment 时若发现其对应 segment 还为创建则通过 cas 的方式创建 segment 对象(懒惰思想)。定位到具体 segment 后会调用 segment 的 put 方法。首先会尝试获取锁,采用自旋 + 锁膨胀的思想,当自旋 64 次仍未获取到锁时会直接阻塞状态,直到被唤醒,同时在自旋期间若发现当前 key 在 HashEntry 数组中对应的节点还为创建则当前线程会帮忙创建该节点。成功获取到锁后再进行新增/更新操作,若有必要则执行扩容流程。最后释放锁。
- 1.8: 若未发生哈希碰撞,则其 put 流程较 HashMap 中的 put 流程多个 cas 过程,即若未发生哈希碰撞则 put 中的所有操作懂通过 cas 来完成。若发生哈希碰撞则会使用 synchronized 锁住链表头节点或红黑树的根节点(即锁住整个链表或整棵树),然后进行新增/更新操作。然后执行链表转红黑树操作,最后 size++ 并扩容(扩容时若其它线程正在扩容则当前线程会协助扩容,Node 数组中通过节点状态来判断当前节点是否已完成扩容,即 ForwardingNode 节点表示该节点所在数据已被处理)。
-
get:
- 1.7: get 时并未加锁,而是使用 UNSAFE 的 getObjectvolatile 保证了可见性。若 get 和扩容并发发生则遵循 get 先发生则从旧表获取,get 后发生则从新表获取 的规则。
- 1.8: get 流程和 HashMap get 流程类似,若获取到的节点为 ForwardingNode 则说明此时正在进行扩容且当前 key 对应旧表中的节点已被处理,此时会去新表中获取。
-
size:
- 1.7: size 计算首先采用类似乐观锁的机制,先在不加锁的前提下去统计元素个数,并尝试三次,若前后数量一致则认为统计结果没问题,若不一致,则会给 Segment 数组中每个元素都上锁,然后挨个儿统计其对应 HashEntry 数组,最后再解锁。该过程是在获取 size 时进行的。
- 1.8: 采用 LongAdder 的设计思想,若没有竞争则在 baseCount 上进行计数,若存在竞争则创建累加单元进行计数。当获取 size 时会在 baseCount 的基础上统计各累加单元的计数。该过程是在 put 和 size 时进行的。
4.4.2.3 ConcurrentLinkedQueue
ConcurrentLinkedQueue 在设计上与 LinkedBlockingQueue 相同,不同点在于锁的使用上,前者使用 cas 来实现,后者使用 ReentrantLock 来实现。Tomcat 中的 Acceptor(生产者)与 Poller(消费者)即采用了 ConcurrentLinkedQueue 队列实现。
4.4.2.4 CopyOnWriteArrayList
CopyOnWriteArrayList 在设计上采用了写入时拷贝的思想,即在增删改操作时会将底层数组拷贝一份,在拷贝出来的数组上进行增删改操作。优点是读写分离,写入时不影响并发读。写入时拷贝数组较耗时,故其适合读多写少的场景。在 jdk 1.8 版本中使用 ReentrantLock 锁,而在 jdk 11 版本中优化为 synchronized。
缺点是在 get 时存在弱一致性。即当读取操作和写入操作同时发生,且二者操作的目标为同一个元素(如读取和更新同一个元素)时,读取操作获取到的结果可能时写入操作前的旧值,这便是弱一致性。根据 CAP 理论,弱一致性不可避免。数据库的 MVCC 机制即是弱一致性的表现。
5 并发编程模式
并发编程模式是指在 java 并发编程中一些常用的编程设计套路,这些套路可以帮助我们设计出更合理、更优雅的程序。大致有以下几种:保护性暂停、犹豫、顺序控制、生产者/消费者、工作线程、两阶段终止、单例、享元等。
5.1 同步模式之保护性暂停
5.1.1 定义
保护性暂停,即一个线程等待另一个线程的执行结果,或一个线程的执行结果需要传递到另一个线程中。其特点如下:
- 一个线程的执行结果需要传递到另一个线程中,则需要一个中介来承载结果。
- 若传递动作持续发生,则可使用消息队列,即为生产者/消费者模式。
- jdk 中的 join、Future 则是此模式的典型实现。
5.1.2 实现
// 结果媒介
public class Response {
private final Object lock = new Object(); // 锁对象 使用其 wait/notify 来实现等待和唤醒
private Object result; // 执行结果
// 获取结果
public Object get() {
synchronized (lock) { // 获取锁
while (result == null) { // 当结果为 null 时阻塞等待 当被唤醒时继续判断
try {
lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
return result; // 被唤醒后若结果不为 null 则返回结果
}
}
// 提交结果
public void complete(Object result) {
synchronized (lock) { // 获取锁
this.result = result;
lock.notifyAll(); // 唤醒等待在该锁对象上的所有线程
}
}
}
// 测试
Response response = new Response(); // 创建媒介对象
// 创建需要执行结果的线程并启动
new Thread(() -> {
Object result = response.get();
log.info("result = {}", result);
}).start();
// 创建产生执行结果的线程并启动 假设耗时 2s
new Thread(() -> {
log.info("start...");
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
response.complete("zed");
log.info("end...");
}).start();
// 测试结果为:等待线程将在两秒后成功获取到结果
5.1.3 超时版本
// 超时版本与普通版本唯一区别在于获取方式的实现不同
// 入参表示当等待线程等待时间超过 millis 毫秒后不管执行线程是否执行结束都会返回
// 因为执行线程唤醒方式为 notifyAll() 即唤醒所有等待在该锁对象上的线程 唤醒后这些线程将再次竞争锁
// 竞争失败的会再次阻塞等待 此时就需要重新计算上一次已等待的时间 即 当前时间 - 开始等待时间
public Object get(long millis) {
synchronized (lock) { // 获取锁
long start = System.currentTimeMillis(); // 开始等待时间
long waited = 0; // 已等待时间
while (result == null) {
long remain = millis - waited; // 剩余等待时间
if (remain <= 0) { // 等待超时时返回
break;
}
try {
lock.wait(remain);
} catch (InterrutedException e) {
e.printStackTrace();
}
waited = System.currentTimeMillis() - start; // 剩余等待时间
}
return result;
}
}
5.1.4 多任务版本
多任务版本可理解为多个线程同时等待各自对应线程的执行结果。以邮递员派送邮件给用户为例。
// 邮箱
public class Mailbox {
private Integer id; // 门牌号
private Object mail; // 邮件
public Mailbox(Integer id) {
this.id = id;
}
// 用户收取邮件(超时版本 即在指定时间未获取到邮件将放弃)
public Object getMail(long timeout) {
synchronized (this) {
long start = System.currentTimeMillis();
long waited = 0;
while (mail == null) {
long remain = timeout - waited;
if (remain <= 0) {
break;
}
try {
wait(remain);
} catch (InterruptedException e) {
e.printStackTrace();
}
waited = System.currentTimeMillis() - start;
}
// 收取完邮件后邮箱置空
Object result = mail;
mail = null;
return result;
}
}
// 邮递员派送邮件(即将邮件放入邮箱)
public void postMail(Object mail) {
synchronized (this) {
this.mail = mail;
notifyAll();
}
}
public Integer getId() {
return id;
}
}
// 小区邮局(即类似于楼下楼道内的邮箱架)
public class Postoffice {
// 整栋楼那所有住户的邮箱
private static final Map<Integer, MailBox> boxes = new HashTable<>();
private static Integer id = 1; // 初始门牌号
// 创建邮箱(即住户的邮箱由邮局管理)
public static Mailbox createMailbox() {
Mailbox box = new Mailbox(generateId());
boxes.put(box.getId(), box);
return box;
}
// 获取邮箱 即定位邮箱
public static Mailbox getMailbox(Integer id) {
return boxes.get(id);
}
public static Set<Integer> getIds() {
return boxes.keySet();
}
private static Integer generateId() {
return id++;
}
}
// 用户 实现收取邮件的动作
public class People extends Thread {
@Override
public void run() {
Mailbox box = Postoffice.createMailbox();
log.info("{} start get mail...", box.getId());
Object mail = box.getMail(2000);
System.out.println(mail);
log.info("{} end get mail...", box.getId());
}
}
// 邮递员 实现派送邮件的动作
public class Postman extends Thread {
private Integer id; // 此次派送的目标住户(假设每次只送一户)
private Object mail; // 邮件
public Postman(Integer id, Object mail) {
this.id = id;
this.mail = mail;
}
@Override
public void run() {
Mailbox box = Postoffice.getMailbox(id);
log.info("start post mail to {}", id);
box.postMail(mail);
log.info("end post mail to {}", id);
}
}
// test 三户用户收取邮件 由三个邮递员派送
for (int i = 0; i< 3; i++) {
new People().start();
}
Thread.sleep(1000);
for (Integer id : Postoffice.getIds()) {
new Postman(id, "content " + id).start();
}
// console
10:25:39.475 [Thread-1] INFO org.xgllhz.juc.pattern.guarded_suspension.multi_task.People - 3 start get mail...
10:25:39.475 [Thread-0] INFO org.xgllhz.juc.pattern.guarded_suspension.multi_task.People - 2 start get mail...
10:25:39.475 [Thread-2] INFO org.xgllhz.juc.pattern.guarded_suspension.multi_task.People - 1 start get mail...
10:25:40.503 [Thread-5] INFO org.xgllhz.juc.pattern.guarded_suspension.multi_task.Postman - start post mail to 1
10:25:40.503 [Thread-3] INFO org.xgllhz.juc.pattern.guarded_suspension.multi_task.Postman - start post mail to 3
content 1
content 3
10:25:40.503 [Thread-5] INFO org.xgllhz.juc.pattern.guarded_suspension.multi_task.Postman - end post mail to 1
10:25:40.504 [Thread-1] INFO org.xgllhz.juc.pattern.guarded_suspension.multi_task.People - 3 end get mail
10:25:40.503 [Thread-4] INFO org.xgllhz.juc.pattern.guarded_suspension.multi_task.Postman - start post mail to 2
10:25:40.503 [Thread-3] INFO org.xgllhz.juc.pattern.guarded_suspension.multi_task.Postman - end post mail to 3
content 2
10:25:40.503 [Thread-2] INFO org.xgllhz.juc.pattern.guarded_suspension.multi_task.People - 1 end get mail
10:25:40.504 [Thread-0] INFO org.xgllhz.juc.pattern.guarded_suspension.multi_task.People - 2 end get mail
10:25:40.504 [Thread-4] INFO org.xgllhz.juc.pattern.guarded_suspension.multi_task.Postman - end post mail to 2
5.2 同步模式之犹豫
5.2.1 定义
犹豫模式,即若一个线程发现另一个线程获取当前线程已经完成了某件事,则本线程将无需再做。如系统监控任务/线程,系统监控任务只需启动一个实例即可,若已启动则后续线程将无需再启动。同时,还可用于线程安全的单例设计模式中。
5.2.2 实现
// 当多次点击打开监控页面时 实际只会启动一个监控实例/线程
public class Monitor {
private volatile boolean isStart;
// 此处使用 DCL(双重检查锁)
public void start() {
if (isStart) {
return;
}
synchronized (this) {
if (isStart) {
return;
}
isStart = true;
// todo 启动监控线程
}
}
}
5.3 同步模式之顺序控制
5.3.1 定义
顺序控制,即某个操作先执行,某个操作后执行。如先输出 1 在输出二。还有交替执行(交替输出)。
5.3.2 实现
5.3.2.1 顺序控制
5.3.2.1.1 wait/notify 版本
private static final Object lock = new Object(); // wait/notify 的锁对象
private static boolean first = false; // 线程 1是否执行状态
public static void test() {
new Thread(() -> {
log.info("thread 2 start");
synchronized (lock) { // 线程二获得锁
// 若线程一未执行则等待 因为会存在虚假唤醒
// 即有多个等待在该锁对象上的线程 线程二被唤醒后未获取到锁 则需要重新等待阻塞 所以需要用 while (true) 死循环
while (!first) {
try {
lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
log.info("thread 2");
log.info("thread 2 end");
}
}).start();
new Thread(() -> {
log.info("thread 1 start");
synchronized (lock) { // 获取锁
log.info("thread 1");
first = true; // 修改状态
lock.notifyAll(); // 唤醒等待在该锁对象上的所有线程
}
log.info("thread 1 end");
}).start();
}
12:14:54.125 [Thread-0] INFO org.xgllhz.juc.pattern.order_control.OrderTest - thread 2 start
12:14:54.125 [Thread-1] INFO org.xgllhz.juc.pattern.order_control.OrderTest - thread 1 start
12:14:54.127 [Thread-1] INFO org.xgllhz.juc.pattern.order_control.OrderTest - thread 1
12:14:54.127 [Thread-1] INFO org.xgllhz.juc.pattern.order_control.OrderTest - thread 1 end
12:14:54.127 [Thread-0] INFO org.xgllhz.juc.pattern.order_control.OrderTest - thread 2
12:14:54.127 [Thread-0] INFO org.xgllhz.juc.pattern.order_control.OrderTest - thread 2 end
缺点:使用不便,代码繁琐;存在虚假唤醒,增加开销;wait-notify 有着严格的先后执行次序。
5.3.2.1.2 park/unpark 版本
Thread t2 = new Thread(() -> {
log.info("thread 2 start");
LockSupport.park();
log.info("thread 2");
log.info("thread 2 end");
});
Thread t1 = new Thread(() -> {
log.info("thread 1 start");
log.info("thread 1");
LockSupport.unpark(t2);
log.info("thread 1 end");
});
t2.start();
t1.start();
unpark 可指定唤醒某个线程,且不需要锁对象的支持。
5.3.2.2 交替控制
线程 1 输出 a,线程 2 输出 b,线程 3 输出 c,共循环 5 次(即交替五次),最终结果为 abcabcabcabcabc。
5.3.2.2.1 wait/notify 版本
public class Node {
private int flag;
private int loopNumber;
public Node(int flag, int loopNumber) {
this.flag = flag;
this.loopNumber = loopNumber;
}
// current: 当前标志 next: 下次标志 content: 当前输出内容
public void test(int current, int next, String content) {
for (int i = 0; i < loopNumber; i++) {
synchronized (this) {
while (flag != current) {
try {
this.wait();
} catch (InterrupedException e) {
e.printStackTrace();
}
}
System.out.print(content);
flag = next;
this.notifyAll();
}
}
}
}
Node node = new Node(1, 5);
new Thread(() -> {
node.test(1, 2, "a");
}).start();
new Thread(() -> {
node.test(2, 3, "b");
}).start();
new Thread(() -> {
node.test(3, 1, "c");
}).start();
5.3.2.2.2 lock condition 版本
public class LockNode extends ReentrantLock {
private int loopNumber;
public LockNode(int loopNumber) {
this.loopNumber = loopNumber;
}
// 开始方式 唤醒等待在第一个条件变量上的线程
public void start(Condition first) {
this.lock();
try {
System.out.println("start");
first.signal();
} finally {
this.unlock();
}
}
public void test(Condition current, Condition next, String content) {
for (int i = 0; i < loopNumber; i++) {
this.lock();
try {
current.await(); // 第一次执行到此处时先阻塞等待 等待被唤醒
System.out.print(content);
next.signal(); // 唤醒等待在下一个条件变量上的线程
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
this.unlock();
}
}
}
}
LockNode lock = new LockNode(5);
Condition a = lock.newCondition();
Condition b = lock.newCondition();
Condition c = lock.newCondition();
new Thread(() -> {
lock.test(a, b, "a");
}).start();
new Thread(() -> {
lock.test(b, c, "b");
}).start();
new Thread(() -> {
lock.test(c, a, "c");
}).start();
5.3.2.2.3 park/unpark 版本
public class Node {
private int loopNumber; // 循环次数
private Thread[] thread; // 线程数组
public Node(int loopNumber) {
this.loopNumber = loopNumber;
}
public void test(String content) {
for (int i = 0; i < loopNumber; i++) {
LockSupport.park(); // 当前线程第一次进入时先阻塞 等待被唤醒
System.out.print(content); // 被唤醒后打印内容
LockSupport.unpark(nextThread()); // 唤醒其后续线程
}
}
// 启动
public void start() { // 启动所有线程
for (Thread thread : threads) {
thread.start();
}
LockSupport.unpark(threads[0]); // 唤醒第一个线程
}
// 获取当前线程的下一个线程
public Thread nextThread() {
Thread currentThread = Thread.currentThread();
int index = 0; // 当前线程所在下标
for (int i = 0; i < threads.length; i++) {
if (currentThread == threads[i]) {
index = i;
break;
}
}
if (index < threads.length - 1) {
return threads[++index]; // 若未越界 则返回当前位置的下一个线程对象
} else {
return threads[0]; // 若越界 则说明本轮以打印完成(abc) 然后从头开始
}
}
public void setThreads(Thread... threads) {
this.threads = threads;
}
}
Node node = new Node(5);
Thread t1 = new Thread(() -> { node.test("a") });
Thread t2 = new Thread(() -> { node.test("b") });
Thread t3 = new Thread(() -> { node.test("c") });
node.setThreads(t1, t2, t3);
node.start();
5.4 异步模式之生产/消费者
5.4.1 定义
生产者/消费者模式,以队列为媒介,生产者只负责生产消息并入队,消费者事负责出队并消费,二者互不影响。其特点:
- 生产者线程与消费者线程是多对多的关系,而保护性暂停中二者是一对一的关系。
- 消息队列可以平衡生产者和消费者线程资源,即队列已满时生产者线程阻塞,队列为空消费者线程阻塞。
- jdk 中的各种阻塞队列即采用这种模式。
5.4.2 实现
消息队列一般有单锁实现、双条件变量实现、双锁实现、分段锁实现等。
5.4.2.1 单锁实现
public MessageQueue<E> {
private Queue<E> queue;
private int capacity;
public MessageQueue(int capacity) {
this.queue = new LinkedList<>();
this.capacity = capacity;
}
public E take() {
synchronized (queue) {
while (queue.isEmpty()) { // 当队列为空时 阻塞消费者线程
try {
queue.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
E e = queue.poll(); // 取出一个元素后唤醒生产者线程(实际上阻塞在该队列上的所有线程都会被唤醒)
queue.notifyAll();
return e;
}
}
public void put(E e) {
synchronized (queue) {
while (queue.size() == capacity) { // 当队列已满时 阻塞生产者线程
try {
queue.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
queue.add(e); // 添加一个元素后唤醒消费者线程(实际上阻塞在该队列上的所有线程都会被唤醒)
queue.notifyAll();
}
}
}
5.4.2.2 双条件变量实现
public class MessageQueue<E> {
private final ReentrantLock lock = new ReentrantLock();
// 读写条件变量
private final Condition notEmpty = lock.newCondition(); // 队列为空时作为阻塞或唤醒条件
private final Condition notFull = lock.newCondition(); // 队列满时作为阻塞或唤醒条件
private Queue<E> queue;
private int capacity;
public MessageQueue(int capacity) {
this.queue = new LinkedList<>();
this.capacity = capacity;
}
// 获取元素
public E take() {
lock.lock(); // 上锁
try {
while (queue.isEmpty()) { // 队列为空时 消费者线程等待在读锁条件上
try {
notEmpty.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
E e = queue.poll(); // 移除一个元素后 唤醒阻塞在写锁条件上的生产者线程
notFull.signal();
return e;
} finally {
lock.unlock(); // 解锁
}
}
// 生产元素
public void put(E e) {
lock.lock(); // 上锁
try {
while (queue.size() == capacity) { // 队列已满时 生产者线程阻塞等待在写锁条件上
try {
notFull.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
queue.add(e); // 添加一个元素后唤醒阻塞在读锁条件上的消费者线程
notEmpty.signal();
} finally {
putLock.unlock(); // 解锁
}
}
}
5.5 异步模式之工作线程
5.5.1 定义
工作线程,即让有限的工作线程来轮流处理无限多的任务。线程池使其典型实现,同时特体现了享元设计模式。其优点是节省频繁创建和销毁线程带来的开销,便于管理线程等等。
线程池使用不当会带来饥饿现象,尤其在固定大小线程池中。饥饿现象是指当由多个环节组成的工作,任务以每个环节为单位提交到线程池中,线程池中的线程都被靠前环节的任务占用,以至于靠后环节的任务不能及时被执行。如在餐厅中,若把点餐、做菜、上菜看成一个流程的话,那么点餐、做菜、上菜则可以是这个流程中的三个环节,餐厅工作人员比作线程池中的工作线程。会出现饥饿现象的情况是每个工作人员负责每一批客人的点餐、做菜、上菜工作,当客人批次数大于工作人员后,后来的客人将会没人处理。
对于线程池饥饿现象,正确的做法是按照任务类型创建不同的线程池,在避免饥饿现象的同时还能提高处理效率。如线程池 a 专门负责点餐,线程池 b 专门负责做菜,线程池 c 专门负责上菜。
5.5.2 实现
自定义线程池实现。主要包括自定义消息队列、工作线程、工作流程、拒绝策略等。
// 自定义拒绝策略接口 即当线程池中任务队列满了后 新添加的任务的处理方式 一般有以下几种
// 死等 queue.put(task);
// 超时等待 queue.put(task, 5, TimeUnit.SECONDS);
// 放弃任务 do nothing
// 抛出异常 throw new RuntimeException("队列已满");
// 提交线程自己执行 task.run();
@FunctionInterface
public interface RejectPolicy<T> {
void reject(BlockingQueue<T> queue, T task);
}
// 自定义阻塞队列 消息队列的双锁实现
public class BlockingQueue<E> {
// 读写锁
private final ReentrantLock takeLock = new ReentrantLock();
private final ReentrantLock putLock = new ReentrantLock();
// 读写锁的条件变量
private final Condition notEmpty = takeLock.newCondition();
private final Condition notFull = putLock.newCondition();
private final Queue<E> queue;
private final int capacity;
public BlockingQueue(int capacity) {
this.capacity = capacity;
this.queue = new LinkedList<>();
}
// 唤醒等待在读锁条件变量上的线程
private void signalNotEmpty() {
takeLock.lock();
try {
notEmpty.signal();
} finally {
takeLock.unlock();
}
}
// 唤醒等待在写锁条件变上的线程
private void signalNotFull() {
putLock.lock();
try {
notFull.signal();
} finally {
putLock.unlock();
}
}
// 阻塞获取元素
public E take() {
E element;
takeLock.lock();
try {
while (queue.isEmpty()) { // 队列为空时 消费者线程阻塞等待
log.info("queue is empty");
try {
notEmpty.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
element = queue.poll();
log.info("take");
} finally {
takeLock.unlock();
}
signalNotFull(); // 获取元素后唤醒等待在写锁条件变量上的生产者线程
return element;
}
// 超时阻塞获取元素
public E take(long timeout, TimeUnit timeUnit) {
E element;
takeLock.lock();
try {
long nanos = timeUnit.toNanos(timeout);
while (queue.isEmpty()) { // 队列为空 且 已阻塞时间仍在超时时间内时 消费者线程阻塞等待
log.info("queue is empty");
try {
if (nanos <= 0) {
return null;
}
nanos = notEmpty.awaitNanos(nanos);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
element = queue.poll();
log.info("take");
} finally {
takeLock.unlock();
}
signalNotFull(); // 获取元素后唤醒等待在写锁条件变量上的生产者线程
return element;
}
// 阻塞添加元素
public void put(E element) {
putLock.lock();
try {
while (queue.size() == capacity) { // 当队列已满时 生产者线程阻塞等待
log.info("queue is full");
try {
notFull.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
queue.add(element);
log.info("put");
} finally {
putLock.unlock();
}
signalNotEmpty(); // 添加成功后唤醒等待则读锁条件变量上的消费者线程
}
// 超时阻塞添加元素
public void put(E element, long timeout, TimeUnit timeUnit) {
putLock.lock();
try {
long nanos = timeUnit.toNanos(timeout);
while (queue.size() == capacity) { // 队列已满 且 已阻塞时间仍在超时时间内时 生产者线程阻塞等待
log.info("queue is full");
try {
if (nanos <= 0) {
return;
}
nanos = notFull.awaitNanos(nanos);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
queue.add(element);
log.info("put");
} finally {
putLock.unlock();
}
signalNotEmpty(); // 添加成功后唤醒等待则读锁条件变量上的消费者线程
}
// 尝试阻塞添加元素
public void tryPut(RejectPolicy<E> rejectPolicy, E element) {
putLock.lock();
try {
if (queue.size() < capacity) { // 若队列未满 则添加元素
queue.add(element);
log.info("try put");
} else { // 若队列已满 则执行拒绝策略
log.info("execute reject-policy");
rejectPolicy.reject(this, element);
}
} finally {
putLock.unlock();
}
signalNotEmpty(); // 成功添加元素后 唤醒等待在读锁条件变量上的消费者线程
}
}
// 自定义线程池
public class ThreadPoolExecutor {
private final Set<Worker> workers = new HashSet<>(); // 工作线程集合
private final BlockingQueue<Runnable> taskQueue; // 任务队列
private final RejectPolicy<Runnable> rejectPolicy; // 拒绝策略
private final int coreSize; // 核心线程数
private final int capacity; // 任务队列容量
private final long timeout; // 获取或添加超时时间
private final TimeUnit timeUnit; // 超时时间单位
public ThreadPoolExecutor(RejectPolicy<Runnable> rejectPolicy, int coreSize, int capacity,
int timeout, TimeUnit timeUnit) {
this.rejectPolicy = rejectPolicy;
this.coreSize = coreSize;
this.capacity = capacity;
this.timeout = timeout;
this.timeUnit = timeUnit;
this.taskQueue = new BlockingQueue<>(capacity);
}
// 工作线程类
class Worker extends Thread {
private Runnable task; // 任务对象
public Worker(Runnable task) {
this.task = task;
}
// 工作线程工作原理
@Override
public void run() {
// 持续性执行任务队列中的任务 直至任务队列为空
while (task != null || (task = taskQueue.take(timeout, timeUnit)) != null) {
log.info("working...");
try {
task.run();
} catch (Exception e) {
e.printStackTrace();
} finally {
task = null;
}
}
synchronized (workers) { // 任务队列为空时 溢出工作线程 即释放线程资源
log.info("remove worker");
workers.remove(this);
}
}
}
// 执行任务/提交任务
public void execute(Runnable task) {
synchronized (workers) {
if (workers.size() < coreSize) { // 当工作线程数小于核心线程数时 创建新的工作线程来执行任务
Worker worker = new Worker(task);
workers.add(worker);
worker.start();
} else { // 否则将任务添加到任务队列
taskQueue.tryPut(rejectPolicy, task);
}
}
}
}
// 创建线程池实例
ThreadPoolExecutor executor = new ThreadPoolExecutor(((taskQueue, task) -> {
task.run();
}), 3, 5, 2, TimeUnit.SECONDS);
for (int i = 0; i < 10; i++) {
int index = i;
executor.execute(() -> {
try {
Thread.sleep(1000L); // 假设每个任务耗时 1s
} catch (InterruptedException e) {
e.printStackTrace();
}
log.info("{}", index);
});
}
22:26:33.454 [Thread-0] INFO org.xgllhz.juc.pattern.work_thread.ThreadPool - working...
22:26:33.454 [main] INFO org.xgllhz.juc.pattern.work_thread.ThreadPool - try put
22:26:33.454 [Thread-1] INFO org.xgllhz.juc.pattern.work_thread.ThreadPool - working...
22:26:33.456 [main] INFO org.xgllhz.juc.pattern.work_thread.ThreadPool - try put
22:26:33.456 [main] INFO org.xgllhz.juc.pattern.work_thread.ThreadPool - try put
22:26:33.456 [main] INFO org.xgllhz.juc.pattern.work_thread.ThreadPool - try put
22:26:33.456 [main] INFO org.xgllhz.juc.pattern.work_thread.ThreadPool - execute reject-policy
22:26:34.464 [main] INFO org.xgllhz.juc.pattern.work_thread.ThreadPool - 6
22:26:34.464 [Thread-1] INFO org.xgllhz.juc.pattern.work_thread.ThreadPool - 1
22:26:34.467 [main] INFO org.xgllhz.juc.pattern.work_thread.ThreadPool - execute reject-policy
22:26:34.467 [Thread-1] INFO org.xgllhz.juc.pattern.work_thread.ThreadPool - take
22:26:34.462 [Thread-0] INFO org.xgllhz.juc.pattern.work_thread.ThreadPool - 0
22:26:34.467 [Thread-0] INFO org.xgllhz.juc.pattern.work_thread.ThreadPool - take
22:26:35.468 [main] INFO org.xgllhz.juc.pattern.work_thread.ThreadPool - 7
22:26:35.470 [Thread-1] INFO org.xgllhz.juc.pattern.work_thread.ThreadPool - working...
22:26:35.470 [Thread-0] INFO org.xgllhz.juc.pattern.work_thread.ThreadPool - working...
22:26:36.473 [Thread-1] INFO org.xgllhz.juc.pattern.work_thread.ThreadPool - 2
22:26:36.474 [Thread-1] INFO org.xgllhz.juc.pattern.work_thread.ThreadPool - take
22:26:36.474 [Thread-1] INFO org.xgllhz.juc.pattern.work_thread.ThreadPool - working...
22:26:36.473 [Thread-0] INFO org.xgllhz.juc.pattern.work_thread.ThreadPool - 3
22:26:36.474 [Thread-0] INFO org.xgllhz.juc.pattern.work_thread.ThreadPool - take
22:26:36.474 [Thread-0] INFO org.xgllhz.juc.pattern.work_thread.ThreadPool - working...
22:26:37.477 [Thread-1] INFO org.xgllhz.juc.pattern.work_thread.ThreadPool - 4
22:26:37.478 [Thread-1] INFO org.xgllhz.juc.pattern.work_thread.ThreadPool - queue is empty
22:26:37.478 [Thread-0] INFO org.xgllhz.juc.pattern.work_thread.ThreadPool - 5
22:26:37.479 [Thread-0] INFO org.xgllhz.juc.pattern.work_thread.ThreadPool - queue is empty
22:26:39.484 [Thread-1] INFO org.xgllhz.juc.pattern.work_thread.ThreadPool - queue is empty
22:26:39.484 [Thread-0] INFO org.xgllhz.juc.pattern.work_thread.ThreadPool - queue is empty
22:26:39.484 [Thread-1] INFO org.xgllhz.juc.pattern.work_thread.ThreadPool - remove worker
22:26:39.485 [Thread-0] INFO org.xgllhz.juc.pattern.work_thread.ThreadPool - remove worker
5.6 终结模式之两阶段终止
5.6.1 定义
两阶段终止,即在一个线程内停止另一个线程。
关于停止线程,有两种不友好方式,即:
- stop():该方法会真正杀死线程,但若该线程锁住了共享资源,则在杀死线程后,锁不能被释放,也意味着该共享资源再也不能再被其它线程使用。
- System.exit():该方法会停止线程,但同时会停止整个进程。
5.6.2 实现
5.6.2.1 interrupt
public class InterruptedStop {
private Thread thread;
public void start() {
thread = new Thread(() -> {
while (true) {
Thread currentThread = Thread.currentThread();
if (currentThread.isInterrupted()) { // 当线程被打断时 结束线程执行内容
log.info("料理后事");
break;
}
try {
Thread.sleep(1000L);
log.info("业务一");
} catch (InterruptedException e) { // 当线程在 sleep、wait 期间被打断时捕捉异常并设置打断标记
currentThread.interrupt();
}
log.info("业务二");
}
}, "monitor-thread");
thread.start();
}
public void stop() { // 利用打断方式停止线程
thread.interrupt();
}
}
InterruptedStop thread = new InterruptedStop();
thread.start();
Thread.sleep(3500L);
thread.stop();
23:17:44.672 [monitor-thread] INFO org.xgllhz.juc.pattern.two_phase_termination.InterruptedTest - 业务一
23:17:44.676 [monitor-thread] INFO org.xgllhz.juc.pattern.two_phase_termination.InterruptedTest - 业务二
23:17:45.676 [monitor-thread] INFO org.xgllhz.juc.pattern.two_phase_termination.InterruptedTest - 业务一
23:17:45.677 [monitor-thread] INFO org.xgllhz.juc.pattern.two_phase_termination.InterruptedTest - 业务二
23:17:46.680 [monitor-thread] INFO org.xgllhz.juc.pattern.two_phase_termination.InterruptedTest - 业务一
23:17:46.680 [monitor-thread] INFO org.xgllhz.juc.pattern.two_phase_termination.InterruptedTest - 业务二
23:17:47.169 [monitor-thread] INFO org.xgllhz.juc.pattern.two_phase_termination.InterruptedTest - 业务二
23:17:47.170 [monitor-thread] INFO org.xgllhz.juc.pattern.two_phase_termination.InterruptedTest - 料理后事
5.6.2.2 volatile
public class InterruptedStop {
private Thread thread;
private volatile boolean isStop = false; // 利用 volatile 变量的可见性
public void start() {
thread = new Thread(() -> {
while (true) {
if (isStop) { // 为 true 时 结束线程执行内容
log.info("料理后事");
break;
}
try {
Thread.sleep(1000L);
log.info("业务一");
} catch (InterruptedException e) {
e.printStackTrace();
}
log.info("业务二");
}
}, "monitor-thread");
thread.start();
}
public void stop() {
isStop = true;
thread.interrupt(); // 打断线程 目的是让线程退出 sleep、wait 状态
}
}
5.7 线程安全之单例
即设计模式之单例模式,详见 [深入了解单例设计模式](人世间子 (xgllhz.top))。
5.8 享元模式
即设计模式之享元模式,详见 [深入了解享元设计模式](人世间子 (xgllhz.top))。
享元模式的使用非常常见,如 jdk 中的包装类的缓存(如 Long 对 -128 ~ 127 之间的 Long 对象的缓存)、线程池、各种连接池等都属于享元模式的应用。
在使用享元模式设计连接池时需要考虑一下问题:
- 连接的动态增长与收缩。
- 连接的存活检测。
- 链接超时处理。
- 分布式 hash 等等。
6 并发编程应用
并发编程应用是指 java 并发编程经常涉及到的应用场景,或者说在这些场景下使用并发编程更加合理或可靠。并发编程应用场景大致有以下几种:效率、限制、互斥、同步和异步、缓存、分治、统筹、定时。
6.1 效率
此效率可有两种理解,一是任务的处理效率,而是 CPU 的工作效率。
- 当任务是非线性时,可以使用多线程来提高任务的处理效率。
- 当机器是多核 CPU 时,使用多线程可以充分利用 CPU 资源,以提高工作效率。
6.2 限制
6.2.1 定义
限制一般是指限制对资源的使用。如限制对 CPU 的使用、限制对共享资源的使用、单位时间内限流等等场景。
6.2.2 实现
6.2.2.1 限制对 CPU 的使用
限制对 CPU 的使用是指当当前条件不满足线程执行时,通过 yield、sleep、wait 等方式使线程让出对 CPU 的使用权,以提高 CPU 的利用率。
-
sleep 实现
while (条件不满足) { try { Thread.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); } } // todo
-
wait 实现
synchronized (锁对象) { while (条件不满足) { try { 锁对象.wait(); } catch (InterruptedException e) { e.printStackTrace(); } } // todo }
-
lock 条件变量实现
lock.lock(); try { while (条件不满足) { try { 条件变量.await(); } catch (InterruptedException e) { e.printStackTrace(); } } // todo } finally { lock.unlock(); }
6.2.2.2 限制对共享资源的使用
限制对共享资源的使用一般是指当某种共享资源是有限的时,可以通过并发编程的方式限制对其的使用。如使用信号量 Semaphore 限制对连接池的使用等等。有关 Semaphore 的介绍可见 4.3.1 章节。
public class ConnectionPool {
private final Connection[] connections; // 连接数组
private final AtomicIntegerArray states; // 连接状态 0: 空闲; 1: 繁忙
private final Semaphore semaphore; // 信号量 限制连接数
private final int poolSize; // 连接池大小
public ConnectionPool(int poolSize) {
this.connections = new Connection[poolSize]; // 初始化连接对象数组
this.states = new AtomicIntegerArray(new int[poolSize]); // 初始化连接状态
this.semaphore = new Semaphore(poolSize); // 初始化信号量 信号量大小和连接池大小保持一致
this.poolSize = poolSize;
for (int i = 0; i < poolSize; i++) {
connections[i] = new MockConnection(String.valueOf(i));
}
}
// 自定义实现连接对象
static class MockConnection implements Connection {
private String name;
public MockConnection(String name) {
this.name = name;
}
// ...
}
// 获取连接
public Connection getConnection() {
try {
semaphore.acquire(); // 若信号量不足 则阻塞
} catch (InterruptedException e) {
e.printStackTrace();
}
for (int i = 0; i < poolSize; i++) {
if (states.get(i) == 0) { // 若当前连接状态空闲 则尝试修改连接状态为繁忙
if (states.compareAndSet(i, 0, 1)) {
return connections[i];
}
}
}
}
// 释放连接
public void releaseConnection(Connection connection) {
for (int i = 0; i < poolSize; i++) {
if (connections[i] == connection) {
states.set(i, 0); // 修改该连接对应状态
semaphore.release(); // 释放信号量
break;
}
}
}
}
6.2.2.3 单位时间内限流
单位时间内限流是指在高并发场景下由于系统性能、程序性能或外部环境影响,使得程序在单位时间内只能接受一定数量的请求,以此来保证程序不瘫痪。可以使用 guava 实现的 RateLimiter 来实现。
@RestController
@RequestMapping("/test")
public class TestController {
private RateLimiter limiter = RateLimiter.create(1000);
@PostMapping("/test")
public String test() {
limiter.acquire();
return "ok";
}
}
6.3 互斥
6.3.1 定义
互斥是指同一时刻只能有一个线程访问共享资源。可以通过悲观互斥和乐观重试两种方式实现。
6.3.2 实现
-
悲观互斥
悲观互斥即悲观锁,具体实现是基于各种重量级锁,如 synchronized、lock 系列锁等。
-
乐观重试
乐观重试即乐观锁,具体实现是基于 while true + cas 算法。
while (true) { if (cas()) { break; } }
6.4 同步和异步
6.4.1 定义
线程间的同步或异步可以基于线程执行结果来理解,若一个线程需要立刻得到另一个线程的执行结果则可称之为同步关系,反之则可称之为异步。线程间同步或异步可通过 join、Future、生产者/消费者模式、线程池等实现。
6.4.2 实现
6.4.2.1 需要等待执行结果
可以用同步实现,也可以用异步实现。
-
join(同步)
Object result; Thread t1 = new Thread(() -> { // 提交结果到 result }); t1.start(); t1.join(); // 当前线程等待 t1 执行结束
其优点是使用方便。缺点是需要提供外部共享变量来承载结果,不符合面向对象封装的思想;不能配合线程池使用。
-
Future(同步)
FutureTask<Object> task = new FutureTask<>(() -> { return "ok"; }); new Thread(task, "t1").start(); task.get(); // 获取结果 // 配合线程池使用 ExecutorService threadPool = Executors.newFixedThreadPool(1); Future<Object> result = threadPool.submit(() -> { return "ok"; }); result.get(); // 获取结果
其优点是规避了 join 的缺点(手动滑稽)。缺点:阻塞的,即调用 get() 方法会阻塞当前线程。
-
自定义实现(同步)
即并发编程模式中的保护性暂停模式,即 5.1 章节。
-
CompletableFuture(异步)
ExecutorService computePool = Executors.newFixedThreadPool(1); // 计算线程池 ExecutorService resultPool = Executors.newFixedThreadPool(1); // 接受结果线程池 CompletableFuture.supplyAsync(() -> { return "ok"; }, computePool).thenAcceptAsync((result) -> { // result 为执行结果 }, resultPool);
其优点是:
- 可以以线程池的方式异步接受执行结果。
- 按照不同任务细化线程池指责。
- 以任务为中心,而不是以线程为中心。
- 非阻塞:即获取结果过程中不会阻塞当前线程。其以类似观察者的模式获取任务执行结果。
-
生产者/消费者模式(异步)
即生产者负责生产结果,消费者负责处理结果,二者以阻塞队列为媒介,互不干扰。
6.4.2.2 不需要等待执行结果
此时最好用异步来实现。
-
普通线程
如普通 IO 操作。
-
线程池
线程池…说了好多了。往里塞就完事儿了。
6.5 缓存
6.5.1 定义
并发编程与缓存实际上是指缓存和数据库一起使用(双写)时的数据一致性问题。即通过并发编程来解决 缓存 + 数据库 下的数据一致性问题。有关数据一致性问题,详见 [走进科学之《redis 的秘密》第十节](人世间子 (xgllhz.top))。
数据一致性问题是基于 CAP(Cache Aside Pattern)模式出现的,该模式是 缓存 + 数据库 双写背景下对数据读写的常用模式。具体为:
- 读取时:先读缓存,若缓存没有则读取数据库,然后放入缓存并返回结果。
- 更新时:先更新数据库,然后删除缓存。
在高并发场景下,该模式就会出现数据一致性问题。一般会出现两种问题,即:
- 初级数据一致性问题:先更新数据库,然后删除缓存。若更新数据库成功,但删除缓存失败。就会造成缓存与数据库数据不一致。对于该问题可通过 先删除缓存,再更新数据库 来解决。
- 高级数据一致性问题:先删除缓存,后更新数据库。若第一个请求删除了缓存,还没来得及更新数据库时,另一个读请求未命中缓存,然后从数据库中获取到数据(旧数据)并放入缓存,最后第一个请求更新了数据库。就会造成数据不一致问题。很明显这是高并发场景下由数据访问竞争引起的,那么就可以用并发编程来解决。
数据一致性问题,一般情况下可以通过操作串行化来处理,即将数据的读写请求入队,然后按入队顺序处理即可。但该方案对系统吞吐量影响较大,所以一般不建议使用。
6.5.2 实现
// 模拟数据库操作
public class BaseMapper {
Object selectById(Serializable id) {
return new Object();
}
List<Object> selectList() {
return new ArrayList<>();
}
void updateById(Serializable id, Object data) {
return;
}
}
public class Cache {
private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
private final ReentrantReadWriteLock.ReadLock readLock = lock.readLock(); // 读锁
private final ReentrantReadWriteLock.WriteLock writeLock = lock.writeLock(); // 写锁
private final Map<String, Object> map; // 缓存集合
private final BaseMapper baseMapper;
public Cache() {
this.map = new HashMap<>();
this.baseMapper = new BaseMapper();
}
// 获取数据
public Object get(String key) {
Object result;
readLock.lock(); // 读取时加读锁 防止其它线程修改缓存
try {
if ((result = map.get(key)) != null) {
return result;
}
} finally {
readLock.unlock();
}
// 缓存为空 则差数据库并添加缓存
writeLock.lock(); // 加写锁 防止与其它线程操作并行 以影响结果
try {
if ((result = map.get(key)) != null) { // 在检查缓存 类似于 DCl
return result;
}
result = baseMapper.selectById(key);
map.put(key, result);
return result;
} finally {
writeLock.unlock();
}
}
// 更新数据
public void update(String key, Object data) {
writeLock.lock(); // 加写锁 防止其它线程读取或更改
try {
map.remove(key);
baseMapper.updateById(key, data);
} finally {
writeLock.unlock();
}
}
}
以上为缓存简易实现,只体现了读写锁在数据一致性问题中的应用,实际缓存实现中还应考虑一下问题:
- 以上设计只适合读多写少的场景,若写操作较频繁,则性能较低。
- 缓存容量问题。
- 缓存过期问题。
- 分布式环境。
- 较 Lock 锁而言,使用 cas 更好。
6.6 分治
6.6.1 定义
分治即利用分治思想处理任务。适合用分治思想处理的任务一般都具有可大化小,小任务逻辑相同的特点。一般可通过多线程来处理小任务,然后使用线程安全的容器对执行结果进行统计来实现;第二种方式是使用 ForkJoinPool 线程池来解决,该线程池采用分治思想设计,适合处理可拆分的任务。
6.6.2 实现
见 4.1.6.2 章节。
6.7 统筹
使用并发编程相关解决统筹学问题,一般可使用 join、wait/notify、LockSupport 及锁相关 api 来相互配合结合。
6.8 定时
使用并发编程相关解决定时类问题,一般可使用任务调用线程池。任务调用线程池的优点是任务可串行执行、并行执行、延迟执行等等。
完结撒花!