一 JAVA 多线程锁介绍
1 悲观锁
定义:悲观锁指对数据被外界修改持保守态度,认为数据很容易就会被其他线程修改(很悲观),所以在数据被处理前先对数据进行加锁,并在整个数据处理过程中,使数据处于锁定状态。
悲观锁的实现:开发中常见的悲观锁实现往往依靠数据库提供的锁机制,即在数据库中,在对数据记录操作前给记录加排它锁。如果获取锁失败,则说明数据正在被其他线程修改,当前线程则等待或者抛出异常。如果获取锁成功,则对记录进行操作,然后提交事务后释放排它锁。
实例:Java 中的 synchronized 关键字就是一种悲观锁,一个线程在操作时,其他的线程必须等待,直到锁被释放才可进入方法进行执行,保证了线程和数据的安全性,同一时间,只能有一条线程进入执行。
我们用一段熟悉的代码进行悲观锁的展示。
public class Student {
private String name;
public synchronized String getName() {
return name;
}
public synchronized void setName(String name) {
this.name = name;
}
}
代码分析 :假设有 3 条线程,如下图,线程 3 正在操作 Student 类,此时线程 1 和线程 2 必须要等待线程 3 执行完毕方可进入,这就是悲观锁。
2 乐观锁
定义:乐观锁是相对悲观锁来说的,它认为数据在一般情况下不会造成冲突,所以在访问记录前不会加排它锁,而是在进行数据提交更新的时候,才会正式对数据冲突与否进行检测。
乐观锁的实现:依旧拿数据库的锁进行比较介绍,乐观锁并不会使用数据库提供的锁机制, 一般在表中添加 version 字段或者使用业务状态来实现。 乐观锁直到提交时才锁定,所以不会产生任何死锁。
Java 中的乐观锁:我们之前所学习的 CAS 原理即是乐观锁技术,当多个线程尝试使用 CAS 同时更新同一个变量时,只有其中一个线程能更新变量的值,而其它线程都失败,失败的线程并不会被挂起,而是被告知这次竞争中失败,并可以再次尝试。
Tips:我们这里所说的对于乐观锁,当多个线程尝试使用 CAS 同时更新同一个变量时,只有其中一个线程能更新变量的值,而其它线程都失败。注意失败两字,失败意味着有操作,而悲观锁是等待,意味着不能同时操作。
3 悲观锁机制存在的问题
- 在多线程竞争下,加锁、释放锁会导致比较多的上下文切换和调度延时,引起性能问题;
- 一个线程持有锁会导致其它所有需要此锁的线程挂起;
- 如果一个优先级高的线程等待一个优先级低的线程释放锁会导致优先级倒置,引起性能风险。
对比于悲观锁的这些问题,另一个更加有效的锁就是乐观锁。其实乐观锁就是:每次不加锁而是假设没有并发冲突而去完成某项操作,如果因为并发冲突失败就重试,直到成功为止。
4 公平锁与非公平锁
分类:根据线程获取锁的抢占机制,锁可以分为公平锁和非公平锁。
公平锁:表示线程获取锁的顺序是按照线程请求锁的时间早晚来决定的,也就是最早请求锁的线程将最早获取到锁。
非公平锁:非公平锁则在运行时闯入,不遵循先到先执行的规则。
ReentrantLock:ReentrantLock 提供了公平和非公平锁的实现。我们本节只做介绍,后续章节会对 ReentrantLock 进行深入的讲解。
5 独占锁与共享锁
分类:根据锁只能被单个线程持有还是能被多个线程共同持有,锁可以分为独占锁和共享锁。
独占锁:保证任何时候都只有一个线程能得到锁,ReentrantLock 就是以独占锁方式实现的。
共享锁:则可以同时由多个线程持有,例如 ReadWriteLock 读写锁,它允许一个资源可以被多线程同时进行读操作。
独占锁是一种悲观锁,由于每次访问资源都先加上互斥锁,这限制了并发性,因为读操作并不会影响数据的一致性,而独占锁只允许在同一时间由一个线程读取数据,其他线程必须等待当前线程释放锁才能进行读取。
共享锁则是一种乐观锁,它放宽了加锁的条件,允许多个线程同时进行读操作。
6 自旋锁
由于 Java 中的线程是与操作系统中的线程相互对应的,所以当一个线程在获取锁(比如独占锁)失败后,会被切换到内核状态而被挂起。
当该线程获取到锁时又需要将其切换到内核状态而唤醒该线程。而从用户状态切换到内核状态的开销是比较大的,在一定程度上会影响并发性能。
自旋锁:自旋锁则是当前线程在获取锁时,如果发现锁已经被其他线程占有,它不马上阻塞自己,在不放弃 CPU 使用权的情况下,多次尝试获取(默认次数是 10,可以使用-XX:PreBlockSpinsh 参数设置该值)。
很有可能在后面几次尝试中其他线程己经释放了锁。如果尝试指定的次数后仍没有获取到锁则当前线程才会被阻塞挂起。由此看来自旋锁是使用 CPU 时间换取线程阻塞与调度的开销,但是很有可能这些 CPU 时间白白浪费了。
二 并发锁之 Lock 接口
1 Lock 接口的介绍
Lock 接口的诞生:在 Java 中锁的实现可以由 synchronized 关键字来完成,但在 Java5 之后,出现了一种新的方式来实现,即 Lock 接口。
诞生的意义:Lock 接口支持那些语义不同(重入、公平等)的锁规则,可以在非阻塞式结构的上下文(包括 hand-over-hand 和锁重排算法)中使用这些规则。主要的实现是 ReentrantLock。对于 ReentrantLock,后续有专门的小节进行讲解。
JDK 1.5 前的 synchronized:在多线程的情况下,当一段代码被 synchronized 修饰之后,同一时刻只能被一个线程访问,其他线程都必须等到该线程释放锁之后才能有机会获取锁访问这段代码。
Lock 接口: 实现提供了比使用 synchronized 方法和语句可获得的更广泛的锁定操作。此实现允许更灵活的结构,可以具有差别很大的属性,可以支持多个相关的 Condition 对象。
Lock 相对于 synchronized 关键字而言更加灵活,你可以自由得选择你想要加锁的地方。当然更高的自由度也带来更多的责任。
使用示例:我们通常会在 try catch 模块中使用 Lock 关键字,在 finally 模块中释放锁。
Lock lock = new ReentrantLock(); //通过子类进行创建,此处以ReentrantLock进行举例
lock.lock(); // 加锁 获取不到锁一直等待直到获取锁
try {
// 临界区
// 对上锁的逻辑进行操作
} finally {
lock.unlock(); // 释放锁 如果不释放其他线程就获取不到锁
}
2 Lock 接口与 synchronized 关键字的区别
- 实现:synchronized 关键字基于 JVM 层面实现,JVM 控制锁的获取和释放。Lock 接口基于 JDK 层面,手动进行锁的获取和释放;
- 使用:synchronized 关键字不用手动释放锁,Lock 接口需要手动释放锁,在 finally 模块中调用 unlock 方法;
- 锁获取超时机制:synchronized 关键字不支持,Lock 接口支持;
- 获取锁中断机制:synchronized 关键字不支持,Lock 接口支持;
- 释放锁的条件:synchronized 关键字在满足占有锁的线程执行完毕,或占有锁的线程异常退出,或占有锁的线程进入 waiting 状态才会释放锁。Lock 接口调用 unlock 方法释放锁;
- 公平性:synchronized 关键字为非公平锁。Lock 接口可以通过入参自行设置锁的公平性。
3 Lock 接口相比 synchronized 关键字的优势
我们通过两个个案例分析来了解 Lock 接口的优势所在。
案例 1 :在使用 synchronized 关键字的情形下,假如占有锁的线程由于要等待 IO 或者其他原因(比如调用 sleep 方法)被阻塞了,但是又没有释放锁,那么其他线程就只能一直等待,别无他法。这会极大影响程序执行效率。
案例 1 分析:该案例体现了 synchronized 的缺陷,当线程被占有时,其他线程会陷入无条件的长期等待。这是非常可怕的,因为系统资源有限,最终可能导致系统崩溃。
案例 1 解决:Lock 接口中的 tryLock (long time, TimeUnit unit) 方法或者响应中断 lockInterruptibly () 方法,能够解决这种长期等待的情况。
案例 2 :我们知道,当多个线程读写文件时,读操作和写操作会发生冲突现象,写操作和写操作也会发生冲突现象,但是读操作和读操作不会发生冲突现象。
但是如果采用 synchronized 关键字实现同步的话,就会导致一个问题,即当多个线程都只是进行读操作时,也只有一个线程可以进行读操作,其他线程只能等待锁的释放而无法进行读操作。
案例 2 分析:该案例体现了 synchronized 的缺陷,悲观锁的缺陷。我们说过,如果只是读操作,没有增删改操作的话,多线程环境下无需加锁。但是这种情况下,如果在同一时间多个线程进行读操作,synchronized 会 block 其他的读操作,这是不合理的。
案例 2 解决:Lock 接口家族也可以解决这种情况,后续我们会对 ReadWriteLock 接口的一个子类 ReentrantReadWriteLock 进行讲解。
总结:Lock 接口实现提供了比使用 synchronized 方法和语句可获得的更广泛的锁定操作,能够解决 synchronized 不能够避免的问题。
4 Lock 接口的常用方法
我们来简单的看下,JDK 中 Lock 接口的源码中所包含的方法:
public interface Lock {
void lock();
void lockInterruptibly() throws InterruptedException;
boolean tryLock();
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
void unlock();
Condition newCondition();
}
方法介绍:
- void lock():获取锁。如果锁不可用,出于线程调度目的,将禁用当前线程,并且在获得锁之前,该线程将一直处于休眠状态;
- void lockInterruptibly():如果当前线程未被中断,则获取锁;
- boolean tryLock():仅在调用时锁为空闲状态才获取该锁。如果锁可用,则获取锁,并立即返回值 true。如果锁不可用,则此方法将立即返回值 false;
- boolean tryLock(long time, TimeUnit unit):如果锁在给定的等待时间内空闲,并且当前线程未被中断,则获取锁;
- void unlock():释放锁。在等待条件前,锁必须由当前线程保持。调用 Condition.await () 将在等待前以原子方式释放锁,并在等待返回前重新获取锁;
- Condition newCondition():返回绑定到此 Lock 实例的新 Condition 实例。
Tips:对 Lock 接口方法的使用,我们必须基于子类进行 Lock 的创建来展示,由于目前我们还未接触 Lock 接口的实现子类,此处只做方法的介绍。后续对 ReentrantLock 进行讲解时,会进行深入讲解。
三 乐观锁与悲观锁
1 乐观锁与悲观锁的概念
悲观锁:总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样其他线程想拿这个数据就会阻塞直到它拿到锁(共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程)。
乐观锁:总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号机制和 CAS 算法实现。
乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库提供的类似于 write_condition 机制,其实都是提供的乐观锁。
2 乐观锁与悲观锁的使用场景
简单的来说 CAS 适用于写比较少的情况下(多读场景,冲突一般较少),synchronized 适用于写比较多的情况下(多写场景,冲突一般较多)。
- 对于资源竞争较少(线程冲突较轻)的情况,使用 synchronized 同步锁进行线程阻塞和唤醒切换以及用户态内核态间的切换操作额外浪费消耗 CPU 资源;而 CAS 基于硬件实现,不需要进入内核,不需要切换线程,操作自旋几率较少,因此可以获得更高的性能;
- 对于资源竞争严重(线程冲突严重)的情况,CAS 自旋的概率会比较大,从而浪费更多的 CPU 资源,效率低于 synchronized。
总结:乐观锁适用于写比较少的情况下(多读场景),即冲突真的很少发生的时候,这样可以省去了锁的开销,加大了系统的整个吞吐量。
但如果是多写的情况,一般会经常产生冲突,这就会导致上层应用会不断地进行 retry,这样反倒是降低了性能,所以一般多写的场景下用悲观锁就比较合适。
3 乐观锁的缺点
ABA 问题:我们之前也对此进行过介绍。
如果一个变量 V 初次读取的时候是 A 值,并且在准备赋值的时候检查到它仍然是 A 值,那我们就能说明它的值没有被其他线程修改过了吗?
很明显是不能的,因为在这段时间它的值可能被改为其他值,然后又改回 A,那 CAS 操作就会误认为它从来没有被修改过。这个问题被称为 CAS 操作的 “ABA” 问题。
循环时间长开销大:在特定场景下会有效率问题。
自旋 CAS(也就是不成功就一直循环执行直到成功)如果长时间不成功,会给 CPU 带来非常大的执行开销。
总结:我们这里主要关注 ABA 问题。循环时间长开销大的问题,在特定场景下很难避免的,因为所有的操作都需要在合适自己的场景下才能发挥出自己特有的优势。
4 ABA 问题解决之版本号机制
讲解 CAS 原理时,对于解决办法进行了简要的介绍,仅仅是一笔带过。这里进行较详细的阐释。其实 ABA 问题的解决,我们通常通过如下方式进行解决:版本号机制。我们一起来看下版本号机制:
版本号机制:一般是在数据中加上一个数据版本号 version 字段,表示数据被修改的次数,当数据被修改时,version 值会加 1。当线程 A 要更新数据值时,在读取数据的同时也会读取 version 值,在提交更新时,若刚才读取到的 version 值为当前数据中的 version 值相等时才更新,否则重试更新操作,直到更新成功。
场景示例:假设商店类 Shop 中有一个 version 字段,当前值为 1 ;而当前商品数量为 50。
- 店员 A 此时将其读出( version=1 ),并将商品数量扣除 10,更新为 50 - 10 = 40;
- 在店员 A 操作的过程中,店员 B 也读入此信息( version=1 ),并将商品数量扣除 20,更新为 50 - 20 = 30;
- 店员 A 完成了修改工作,将数据版本号加 1( version=2 ),商品数量为 40,提交更新,此时由于提交数据版本大于记录当前版本,数据被更新,数据记录 version 更新为 2 ;
- 店员 B 完成了操作,也将版本号加 1( version=2 ),试图更新商品数量为 30。但此时比对数据记录版本时发现,店员 B 提交的数据版本号为 2 ,数据记录当前版本也为 2 ,不满足 “ 提交版本必须大于记录当前版本才能执行更新 “ 的乐观锁策略,因此,店员 B 的提交被驳回;
- 店员 B 再次重新获取数据,version = 2,商品数量 40。在这个基础上继续执行自己扣除 20 的操作,商品数量更新为 40 - 20 = 20;
- 店员 B 将版本号加 1 ,version = 3,将之前的记录 version 2 更新为 3 ,将之前的数量 40 更新 为 20。
从如上描述来看,所有的操作都不会出现脏数据,关键在于版本号的控制。
Tips:Java 对于乐观锁的使用进行了良好的封装,我们可以直接使用并发编程包来进行乐观锁的使用。本节接下来所使用的 Atomic 操作即为封装好的操作。
之所以还要对 CAS 原理以及 ABA 问题进行深入的分析,主要是为了让学习者了解底层的原理,以便更好地在不同的场景下选择使用锁的类型。
5 Atomic 操作实现乐观锁
为了更好地理解悲观锁与乐观锁,我们通过设置一个简单的示例场景来进行分析。并且我们采用悲观锁 synchronized 和乐观锁 Atomic 操作进行分别实现。
Atomic 操作类,指的是 java.util.concurrent.atomic 包下,一系列以 Atomic 开头的包装类。例如 AtomicBoolean,AtomicInteger,AtomicLong。它们分别用于 Boolean,Integer,Long 类型的原子性操作。
Atomic 操作的底层实现正是利用的 CAS 机制,而 CAS 机制即乐观锁。
场景设计:
- 创建两个线程,创建方式可自选;
- 定义一个全局共享的 static int 变量 count,初始值为 0;
- 两个线程同时操作 count,每次操作 count 加 1;
- 每个线程做 100 次 count 的增加操作。
结果预期:最终 count 的值应该为 200。
悲观锁 synchronized 实现:
public class DemoTest extends Thread {
private static int count = 0; //定义count = 0
public static void main(String[] args) {
for (int i = 0; i < 2; i++) { //通过for循环创建两个线程
new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(10);
} catch (Exception e) {
e.printStackTrace();
}
//每个线程让count自增100次
for (int i = 0; i < 100; i++) {
synchronized (DemoTest.class) {
count++;
}
}
}
}).start();
}
try {
Thread.sleep(2000);
} catch (Exception e) {
e.printStackTrace();
}
System.out.println(count);
}
}
结果验证:
200
乐观锁 Atomic 操作实现:
import java.util.concurrent.atomic.AtomicInteger;
public class DemoTest extends Thread {
//Atomic 操作,引入AtomicInteger。这是实现乐观锁的关键所在。
private static AtomicInteger count = new AtomicInteger(0);
public static void main(String[] args) {
for (int i = 0; i < 2; i++) {
new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(10);
} catch (Exception e) {
e.printStackTrace();
}
//每个线程让count自增100次
for (int i = 0; i < 100; i++) {
count.incrementAndGet();
}
}
}).start();
}
try {
Thread.sleep(2000);
} catch (Exception e) {
e.printStackTrace();
}
System.out.println(count);
}
}
结果验证:
200
代码解读:
此处主要关注两个点,第一个是 count 的创建,是通过 AtomicInteger 进行的实例化,这是使用 Atomic 的操作的入口,也是使用 CAS 乐观锁的一个标志。
第二个是需要关注 count 的增加 1 调用是 AtomicInteger 中 的 incrementAndGet 方法,该方法是原子性操作,遵循 CAS 原理。
四 AQS 原理
1 什么是 AQS
定义:AbstarctQueuedSynchronizer 简称 AQS,是一个用于构建锁和同步容器的框架。
事实上 concurrent 包内许多类都是基于 AQS 构建的,例如 ReentrantLock,ReentrantReadWriteLock,FutureTask 等。AQS 解决了在实现同步容器时大量的细节问题。
AQS 使用一个 FIFO 队列表示排队等待锁的线程,队列头结点称作 “哨兵节点” 或者 “哑结点”,它不与任何线程关联。其他的节点与等待线程关联,每个阶段维护一个等待状态 waitStatus。
2 AQS 提供的两种功能
从使用层面来说,AQS 的锁功能分为两种:独占锁和共享锁。
独占锁:每次只能有一个线程持有锁,比如前面给大家演示的 ReentrantLock 就是以独占方式实现的互斥锁;
共享锁:允许多个线程同时获取锁,并发访问共享资源,比如 ReentrantReadWriteLock。
3 AQS 的内部实现
AQS 的实现依赖内部的同步队列,也就是 FIFO 的双向队列,如果当前线程竞争锁失败,那么 AQS 会把当前线程以及等待状态信息构造成一个 Node 加入到同步队列中,同时再阻塞该线程。当获取锁的线程释放锁以后,会从队列中唤醒一个阻塞的节点 (线程)。
如下图所示,一个节点表示一个线程,它保存着线程的引用(thread)、状态(waitStatus)、前驱节点(prev)、后继节点(next),其实就是个双端双向链表,其数据结构如下:
Tips:AQS 队列内部维护的是一个 FIFO 的双向链表,这种结构的特点是每个数据结构都有两个指针,分别指向直接的后继节点和直接前驱节点。所以双向链表可以从任意一个节点开始,很方便的访问前驱和后继。每个 Node 其实是由线程封装,当线程争抢锁失败后会封装成 Node 加入到 ASQ 队列中去。
4 添加线程对于 AQS 队列的变化
当出现锁竞争以及释放锁的时候,AQS 同步队列中的节点会发生变化,首先看一下添加线程的场景。
这里会涉及到两个变化:
- 队列操作的变化:新的线程封装成 Node 节点追加到同步队列中,设置 prev 节点以及修改当前节点的前置节点的 next 节点指向自己;
- tail 指向变化:通过同步器将 tail 重新指向新的尾部节点。
5 释放锁移除节点对于 AQS 队列的变化
第一个 head 节点表示获取锁成功的节点,当头结点在释放同步状态时,会唤醒后继节点,如果后继节点获得锁成功,会把自己设置为头结点,节点的变化过程如下:
这个过程也是涉及到两个变化:
head 节点指向:修改 head 节点指向下一个获得锁的节点;
新的获得锁的节点:如图所示,第二个节点被 head 指向了,此时将 prev 的指针指向 null,因为它自己本身就是第一个首节点,所以 pre 指向 null。
6 AQS 与 ReentrantLock 的联系
ReentrantLock 实现:ReentrantLock 是根据 AQS 实现的独占锁,提供了两个构造方法如下:
public ReentrantLock() {
sync = new NonfairSync();
}
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
ReentrantLock 有三个内部类:Sync,NonfairSync,FairSync,继承关系如下:
总结:我们可以看到,这三个内部类都是基于 AQS 进行的实现,由此可见,ReentrantLock 是基于 AQS 进行的实现。
ReentrantLock 提供两种类型的锁:公平锁,非公平锁。分别对应 FairSync,NonfairSync。默认实现是 NonFairSync。
五 ReentrantLock 使用
1 ReentrantLock 介绍
reentrant [ri:'entrənt] 可重入
知识点重现:
Synchronized是一个:非公平,悲观,独享,互斥,可重入的重量级锁。
ReentrantLock是一个:默认非公平但可实现公平的,悲观,独享,互斥,可重入的重量级锁。
ReentrantLock 在 Java 中也是一个基础的锁,ReentrantLock 实现 Lock 接口提供一系列的基础函数,开发人员可以灵活的使用函数满足各种复杂多变应用场景。
定义:ReentrantLock 是一个可重入且独占式的锁,它具有与使用 synchronized 监视器锁相同的基本行为和语义,但与 synchronized 关键字相比,它更灵活、更强大,增加了轮询、超时、中断等高级功能。
ReentrantLock,顾名思义,它是支持可重入锁的锁,是一种递归无阻塞的同步机制。除此之外,该锁还支持获取锁时的公平和非公平选择。
公平性:ReentrantLock 的内部类 Sync 继承了 AQS,分为公平锁 FairSync 和非公平锁 NonfairSync。
如果在绝对时间上,先对锁进行获取的请求一定先被满足,那么这个锁是公平的,反之,是不公平的。公平锁的获取,也就是等待时间最长的线程最优先获取锁,也可以说锁获取是顺序的。
ReentrantLock 的公平与否,可以通过它的构造函数来决定。
2 ReentrantLock 基本方法 lock 与 unlock 的使用
我们使用一个之前涉及到的 synchronized 的场景,通过 lock 接口进行实现。
场景回顾:
- 创建两个线程,创建方式可自选;
- 定义一个全局共享的 static int 变量 count,初始值为 0;
- 两个线程同时操作 count,每次操作 count 加 1;
- 每个线程做 100 次 count 的增加操作。
结果预期:获取到的结果为 200。之前我们使用了 synchronized 关键字和乐观锁 Amotic 操作进行了实现,那么此处我们进行 ReentrantLock 的实现方式。
实现步骤:
- step 1 :创建 ReentrantLock 实例,以便于调用 lock 方法和 unlock 方法;
- step 2:在 synchronized 的同步代码块处,将 synchronized 实现替换为 lock 实现。
实例:
import java.util.concurrent.locks.ReentrantLock;
public class DemoTest {
private static int count = 0; //定义count = 0
private static ReentrantLock lock = new ReentrantLock();//创建 lock 实例
public static void main(String[] args) {
for (int i = 0; i < 2; i++) { //通过for循环创建两个线程
new Thread(new Runnable() {
@Override
public void run() {
//每个线程让count自增100次
for (int i = 0; i < 100; i++) {
try {
lock.lock(); //调用 lock 方法
count++;
} finally {
lock.unlock(); //调用unlock方法释放锁
}
}
}
}).start();
}
try {
Thread.sleep(2000);
} catch (Exception e) {
e.printStackTrace();
}
System.out.println(count);
}
}
代码分析:
我们通过 try finally 模块,替代了之前的 synchronized 代码块,顺利的实现了多线程下的并发。
3 tryLock 方法
我们之前进行过介绍,Lock 接口包含了两种 tryLock 方法,一种无参数,一种带参数。
- boolean tryLock():仅在调用时锁为空闲状态才获取该锁。如果锁可用,则获取锁,并立即返回值 true。如果锁不可用,则此方法将立即返回值 false;
- boolean tryLock(long time, TimeUnit unit):如果锁在给定的等待时间内空闲,并且当前线程未被中断,则获取锁;
为了了解两种方法的使用,我们先来设置一个简单的使用场景。
场景设置:
- 创建两个线程,创建方式自选;
- 两个线程同时执行代码逻辑;
- 代码逻辑使用 boolean tryLock () 方法,如果获取到锁,执行打印当前线程名称,并沉睡 5000 毫秒;如果未获取锁,则打印 timeout,并处理异常信息;
- 观察结果并进行分析;
- 修改代码,使用 boolean tryLock (long time, TimeUnit unit) 方法,设置时间为 4000 毫秒;
- 观察结果并进行分析;
- 再次修改代码,使用 boolean tryLock (long time, TimeUnit unit) 方法,设置时间为 6000 毫秒;
- 观察结果并进行分析。
实例:使用 boolean tryLock () 方法
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class DemoTest implements Runnable {
private static Lock locks = new ReentrantLock();
@Override
public void run() {
try {
if (locks.tryLock()) { //尝试获取锁,获取成功则进入执行,不成功则执行finally模块
System.out.println(Thread.currentThread().getName() + "-->");
Thread.sleep(5000);
} else {
System.out.println(Thread.currentThread().getName() + " time out ");
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
try {
locks.unlock();
} catch (Exception e) {
System.out.println(Thread.currentThread().getName() + "未获取到锁,释放锁抛出异常");
}
}
}
public static void main(String[] args) throws InterruptedException {
DemoTest test = new DemoTest();
Thread t1 = new Thread(test);
Thread t2 = new Thread(test);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("over");
}
}
结果验证:
Thread-1-->
Thread-0 time out
Thread-0 未获取到锁,释放锁抛出异常
over
结果分析:从打印的结果来看, Thread-1 获取了锁权限,而 Thread-0 没有获取锁权限,这就是 tryLock,没有获取到锁资源则放弃执行,直接调用 finally。
实例:使用 boolean tryLock (4000 ms) 方法
将 if 判断进行修改如下:
if(locks.tryLock(4000,TimeUnit.MILLISECONDS)){ //尝试获取锁,获取成功则进入执行,不成功则执行finally模块
System.out.println(Thread.currentThread().getName()+"-->");
Thread.sleep(5000);
}
结果验证:
Thread-1-->
Thread-0 time out
Thread-0 未获取到锁,释放锁抛出异常
over
结果分析:tryLock 方法,虽然等待 4000 毫秒,但是这段时间不足以等待 Thread-1 释放资源锁,所以还是超时。 我们换成 6000 毫秒试试。
实例:使用 boolean tryLock (6000 ms) 方法
将 if 判断进行修改如下:
if(locks.tryLock(6000,TimeUnit.MILLISECONDS)){ //尝试获取锁,获取成功则进入执行,不成功则执行finally模块
System.out.println(Thread.currentThread().getName()+"-->");
Thread.sleep(5000);
}
结果验证:
Thread-1-->
Thread-0-->
over
结果分析:tryLock 方法,等待 6000 毫秒,Thread-1 先进入执行,5000 毫秒后 Thread-0 进入执行,都能够有机会获取锁。
总结:以上就是 tryLock 方法的使用,可以指定最长的获取锁的时间,如果获取则执行,未获取则放弃执行。
4 公平锁与非公平锁
分类:根据线程获取锁的抢占机制,锁可以分为公平锁和非公平锁。
公平锁:表示线程获取锁的顺序是按照线程请求锁的时间早晚来决定的,也就是最早请求锁的线程将最早获取到锁。
非公平锁:非公平锁则在运行时闯入,不遵循先到先执行的规则。
ReentrantLock:ReentrantLock 提供了公平和非公平锁的实现。
ReentrantLock 实例:
//公平锁
ReentrantLock pairLock = new ReentrantLock(true);
//非公平锁
ReentrantLock pairLock1 = new ReentrantLock(false);
//如果构造函数不传递参数,则默认是非公平锁。
ReentrantLock pairLock2 = new ReentrantLock();
场景介绍:通过模拟一个场景假设,来了解公平锁与非公平锁。
- 假设线程 A 已经持有了锁,这时候线程 B 请求该锁将会被挂起;
- 当线程 A 释放锁后,假如当前有线程 C 也需要获取该锁,如果采用非公平锁方式,则根据线程调度策略,线程 B 和线程 C 两者之一可能获取锁,这时候不需要任何其他干涉;
- 而如果使用公平锁则需要把 C 挂起,让 B 获取当前锁,因为 B 先到所以先执行。
Tips:在没有公平性需求的前提下尽量使用非公平锁,因为公平锁会带来性能开销。
5 lockInterruptibly 方法
lockInterruptibly () 方法:能够中断等待获取锁的线程。当两个线程同时通过 lock.lockInterruptibly () 获取某个锁时,假若此时线程 A 获取到了锁,而线程 B 只有等待,那么对线程 B 调用 threadB.interrupt () 方法能够中断线程 B 的等待过程。
场景设计:
- 创建两个线程,创建方式可自选实现;
- 第一个线程先调用 start 方法,沉睡 20 毫秒后调用第二个线程的 start 方法,确保第一个线程先获取锁,第二个线程进入等待;
- 最后调用第二个线程的 interrupt 方法,终止线程;
- run 方法的逻辑为打印 0,1,2,3,4,每打印一个数字前,先沉睡 1000 毫秒;
- 观察结果,看是否第二个线程被终止。
实例:
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class DemoTest {
private Lock lock = new ReentrantLock();
public void doBussiness() {
String name = Thread.currentThread().getName();
try {
System.out.println(name + " 开始获取锁");
lock.lockInterruptibly(); //调用lockInterruptibly方法,表示可中断等待
System.out.println(name + " 得到锁,开工干活");
for (int i = 0; i < 5; i++) {
Thread.sleep(1000);
System.out.println(name + " : " + i);
}
} catch (InterruptedException e) {
System.out.println(name + " 被中断");
} finally {
try {
lock.unlock();
System.out.println(name + " 释放锁");
} catch (Exception e) {
System.out.println(name + " : 没有得到锁的线程运行结束");
}
}
}
public static void main(String[] args) throws InterruptedException {
final DemoTest lockTest = new DemoTest();
Thread t0 = new Thread(new Runnable() {
public void run() {
lockTest.doBussiness();
}
});
Thread t1 = new Thread(new Runnable() {
public void run() {
lockTest.doBussiness();
}
});
t0.start();
Thread.sleep(20);
t1.start();
t1.interrupt();
}
}
结果验证:可以看到,thread -1 被中断了。
Thread-0 开始获取锁
Thread-0 得到锁,开工干活
Thread-1 开始获取锁
Thread-1 被中断
Thread-1 : 没有得到锁的线程运行结束
Thread-0 : 0
Thread-0 : 1
Thread-0 : 2
Thread-0 : 3
Thread-0 : 4
Thread-0 释放锁
6 ReentrantLock 其他方法介绍
对 ReentrantLock 来说,方法很多样,如下介绍 ReentrantLock 其他的方法,有兴趣的同学可以自行的尝试使用。
- getHoldCount():当前线程调用 lock () 方法的次数;
- getQueueLength():当前正在等待获取 Lock 锁的线程的估计数;
- getWaitQueueLength(Condition condition):当前正在等待状态的线程的估计数,需要传入 Condition 对象;
- hasWaiters(Condition condition):查询是否有线程正在等待与 Lock 锁有关的 Condition 条件;
- hasQueuedThread(Thread thread):查询指定的线程是否正在等待获取 Lock 锁;
- hasQueuedThreads():查询是否有线程正在等待获取此锁定;
- isFair():判断当前 Lock 锁是不是公平锁;
- isHeldByCurrentThread():查询当前线程是否保持此锁定;
- isLocked():查询此锁定是否由任意线程保持。
六 锁的可重入性验证
1 什么是锁的可重入性
定义:可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象或者 class),不会因为之前已经获取过还没释放而阻塞。
Java 中 ReentrantLock 和 synchronized 都是可重入锁,可重入锁的一个优点是可一定程度避免死锁。
可重入锁原理:可重入锁的原理是在锁内部维护一个线程标示,用来标示该锁目前被哪个线程占用,然后关联一个计数器。一开始计数器值为 0,说明该锁没有被任何线程占用。当一个线程获取了该锁时,计数器的值会变成 1,这时其他线程再来获取该锁时会发现锁的所有者不是自己而被阻塞挂起。
但是当获取了该锁的线程再次获取锁时发现锁拥有者是自己,就会把计数器值加+1, 当释放锁后计数器值-1。当计数器值为 0 时,锁里面的线程标示被重置为 null,这时候被阻塞的线程会被唤醒来竞争获取该锁。
2 可重入锁与非可重入锁
Java 中 ReentrantLock 和 synchronized 都是可重入锁,可重入锁的一个优点是可一定程度避免死锁。
为了解释可重入锁与非可重入性锁的区别与联系,我们拿可重入锁 ReentrantLock 和 非重入锁 NonReentrantLock 进行简单的分析对比。
相同点: ReentrantLock 和 NonReentrantLock 都继承父类 AQS,其父类 AQS 中维护了一个同步状态 status 来计数重入次数,status 初始值为 0。
不同点:当线程尝试获取锁时,可重入锁先尝试获取并更新 status 值,如果 status == 0 表示没有其他线程在执行同步代码,则把 status 置为 1,当前线程开始执行。
如果 status != 0,则判断当前线程是否是获取到这个锁的线程,如果是的话执行 status+1,且当前线程可以再次获取锁。
而非可重入锁是直接去获取并尝试更新当前 status 的值,如果 status != 0 的话会导致其获取锁失败,当前线程阻塞,导致死锁发生。
3 什么情况下使用可重入锁
我们先来看看如下代码:同步方法 helloB 方法调用了同步方法 helloA。
public class DemoTest{
public synchronized void helloA(){
System.out.println("helloA");
}
public synchronized void helloB(){
System.out.println("helloB");
helloA();
}
}
在如上代码中,调用 helloB 方法前会先获取内置锁,然后打印输出。之后调用 helloA 方法,在调用前会先去获取内置锁,如果内置锁不是可重入的,那么调用线程将会一直被阻塞。
因此,对于同步方法内部调用另外一个同步方法的情况下,一定要使用可重入锁,不然会导致死锁的发生。
4 synchronized 验证锁的可重入性
为了更好的理解 synchronized 验证锁的可重入性,我们来设计一个简单的场景。
场景设计:
- 创建一个类,该类中有两个方法,helloA 方法和 helloB 方法;
- 将两个方法内部的逻辑进行 synchronized 同步;
- helloA 方法内部调用 helloB 方法,营造可重入锁的场景;
- main 方法创建线程,调用 helloA 方法;
- 观察结果,看是否可以成功进行调用。
实例:
public class DemoTest {
public static void main(String[] args) {
new Thread(new SynchronizedTest()).start();
}
}
class SynchronizedTest implements Runnable {
private final Object obj = new Object();
public void helloA() { //方法1,调用方法2
synchronized (obj) {
System.out.println(Thread.currentThread().getName() + " helloA()");
helloB();
}
}
public void helloB() {
synchronized (obj) {
System.out.println(Thread.currentThread().getName() + " helloB()");
}
}
@Override
public void run() {
helloA(); //调用helloA方法
}
}
结果验证:
Thread-0 helloA()
Thread-0 helloB()
结果解析:如果同一线程,锁不可重入的话,helloB 需要等待 helloA 释放 obj 锁,如此一来,helloB 无法进行锁的获取,最终造成无限等待,无法正常执行。此处说明了 synchronized 关键字的可重入性,因此能够正常进行两个方法的执行。
5 ReentrantLock 验证锁的可重入性
相同的场景,对代码进行如下改造,将 synchronized 同步代码块修改成 lock 接口同步,我们看代码实例如下:
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class DemoTest {
public static void main(String[] args) {
new Thread(new SynchronizedTest()).start();
}
}
class SynchronizedTest implements Runnable {
private final Lock lock = new ReentrantLock();
public void helloA() { //方法1,调用方法2
lock.lock();
try {
System.out.println(Thread.currentThread().getName() + " helloA()");
helloB();
} finally {
lock.unlock();
}
}
public void helloB() {
lock.lock();
try {
System.out.println(Thread.currentThread().getName() + " helloB()");
} finally {
lock.unlock();
}
}
@Override
public void run() {
helloA();
}
}
结果验证:
Thread-0 helloA()
Thread-0 helloB()
结果解析:ReentrantLock 一样是可重入锁,试验成功。
七 读写锁 ReentrantReadWriteLock
1 ReentrantReadWriteLock 介绍
JDK 提供了 ReentrantReadWriteLock 读写锁,使用它可以加快效率,在某些不需要操作实例变量的方法中,完全可以使用读写锁 ReemtrantReadWriteLock 来提升该方法的运行速度。
定义:读写锁表示有两个锁,一个是读操作相关的锁,也称为共享锁;另一个是写操作相关的锁,也叫排他锁。
定义解读:也就是多个读锁之间不互斥,读锁与写锁互斥、写锁与写锁互斥。在没有线程 Thread 进行写入操作时,进行读取操作的多个 Thread 都可以获取读锁,而进行写入操作的 Thread 只有在获取写锁后才能进行写入操作。即多个 Thread 可以同时进行读取操作,但是同一时刻只允许一个 Thread 进行写入操作。
2 ReentrantReadWriteLock 的类结构
ReentrantReadWriteLock 是接口 ReadWriteLock 的子类实现,通过 JDK 的代码可以看出这一实现关系。
public class ReentrantReadWriteLock
implements ReadWriteLock, java.io.Serializable{}
我们再来看下接口 ReadWriteLock,该接口只定义了两个方法:
public interface ReadWriteLock {
Lock readLock();
Lock writeLock();
}
通过调用相应方法获取读锁或写锁,可以如同使用 Lock 接口一样使用。
3 ReentrantReadWriteLock 的特点
性质 1 :可重入性。
ReentrantReadWriteLock 与 ReentrantLock 以及 synchronized 一样,都是可重入性锁,这里不会再多加赘述所得可重入性质,之前已经做过详细的讲解。
性质 2 :读写分离。
我们知道,对于一个数据,不管是几个线程同时读都不会出现任何问题,但是写就不一样了,几个线程对同一个数据进行更改就可能会出现数据不一致的问题,因此想出了一个方法就是对数据加锁,这时候出现了一个问题:
线程写数据的时候加锁是为了确保数据的准确性,但是线程读数据的时候再加锁就会大大降低效率,这时候怎么办呢?那就对写数据和读数据分开,加上两把不同的锁,不仅保证了正确性,还能提高效率。
性质 3 :可以锁降级,写锁降级为读锁。
线程获取写入锁后可以获取读取锁,然后释放写入锁,这样就从写入锁变成了读取锁,从而实现锁降级的特性。
性质 4 :不可锁升级。
线程获取读锁是不能直接升级为写入锁的。需要释放所有读取锁,才可获取写锁。
4 ReentrantReadWriteLock 读锁共享
我们之前说过,ReentrantReadWriteLock 之所以优秀,是因为读锁与写锁是分离的,当所有的线程都为读操作时,不会造成线程之间的互相阻塞,提升了效率,那么接下来,我们通过代码实例进行学习。
场景设计:
- 创建三个线程,线程名称分别为 t1,t2,t3,线程实现方式自行选择;
- 三个线程同时运行获取读锁,读锁成功后打印线程名和获取结果,并沉睡 2000 毫秒,便于观察其他线程是否可共享读锁;
- finally 模块中释放锁并打印线程名和释放结果;
- 运行程序,观察结果。
结果预期:三条线程能同时获取锁,因为读锁共享。
实例:
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class DemoTest {
private ReentrantReadWriteLock lock = new ReentrantReadWriteLock();// 读写锁
private int i;
public String readI() {
try {
lock.readLock().lock();// 占用读锁
System.out.println("threadName -> " + Thread.currentThread().getName() + " 占用读锁,i->" + i);
Thread.sleep(2000);
} catch (InterruptedException e) {
} finally {
System.out.println("threadName -> " + Thread.currentThread().getName() + " 释放读锁,i->" + i);
lock.readLock().unlock();// 释放读锁
}
return i + "";
}
public static void main(String[] args) {
final DemoTest demo1 = new DemoTest();
Runnable runnable = new Runnable() {
@Override
public void run() {
demo1.readI();
}
};
new Thread(runnable, "t1").start();
new Thread(runnable, "t2").start();
new Thread(runnable, "t3").start();
}
}
结果验证:
threadName -> t1 占用读锁,i->0
threadName -> t2 占用读锁,i->0
threadName -> t3 占用读锁,i->0
threadName -> t1 释放读锁,i->0
threadName -> t3 释放读锁,i->0
threadName -> t2 释放读锁,i->0
结果分析:从结果来看,t1,t2,t3 均在同一时间获取了锁,证明了读锁共享的性质。
5 ReentrantReadWriteLock 读写互斥
当共享变量有写操作时,必须要对资源进行加锁,此时如果一个线程正在进行读操作,那么写操作的线程需要等待。同理,如果一个线程正在写操作,读操作的线程需要等待。
场景设计:细节操作不详细阐述,看示例代码即可。
- 创建两个线程,线程名称分别为 t1,t2;
- 线程 t1 进行读操作,获取到读锁之后,沉睡 5000 毫秒;
- 线程 t2 进行写操作;
- 开启 t1,1000 毫秒后开启 t2 线程;
- 运行程序,观察结果。
结果预期:线程 t1 获取了读锁,在沉睡的 5000 毫秒中,线程 t2 只能等待,不能获取到锁,因为读写互斥。
实例:
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class DemoTest {
private ReentrantReadWriteLock lock = new ReentrantReadWriteLock();// 读写锁
private int i;
public String readI() {
try {
lock.readLock().lock();// 占用读锁
System.out.println("threadName -> " + Thread.currentThread().getName() + " 占用读锁,i->" + i);
Thread.sleep(5000);
} catch (InterruptedException e) {
} finally {
System.out.println("threadName -> " + Thread.currentThread().getName() + " 释放读锁,i->" + i);
lock.readLock().unlock();// 释放读锁
}
return i + "";
}
public void addI() {
try {
lock.writeLock().lock();// 占用写锁
System.out.println("threadName -> " + Thread.currentThread().getName() + " 占用写锁,i->" + i);
i++;
} finally {
System.out.println("threadName -> " + Thread.currentThread().getName() + " 释放写锁,i->" + i);
lock.writeLock().unlock();// 释放写锁
}
}
public static void main(String[] args) throws InterruptedException {
final DemoTest demo1 = new DemoTest();
new Thread(new Runnable() {
@Override
public void run() {
demo1.readI();
}
}, "t1").start();
Thread.sleep(1000);
new Thread(new Runnable() {
@Override
public void run() {
demo1.addI();
}
}, "t2").start();
}
}
结果验证:
threadName -> t1 占用读锁,i->0
threadName -> t1 释放读锁,i->0
threadName -> t2 占用写锁,i->0
threadName -> t2 释放写锁,i->1
结果解析:验证成功,在线程 t1 沉睡的过程中,写锁 t2 线程无法获取锁,因为锁已经被读操作 t1 线程占据了。
八 锁机制之 Condition 接口
1 Condition 接口简介
任意一个 Java 对象,都拥有一组监视器方法(定义在 java.lang.Object 上),主要包括 wait ()、wait (long timeout)、notify () 以及 notifyAll () 方法。这些方法与 synchronized 同步关键字配合,可以实现等待 / 通知模式。
定义:Condition 接口也提供了类似 Object 的监视器方法,与 Lock 配合可以实现等待 / 通知模式。Condition 可以看做是 Obejct 类的 wait ()、notify ()、notifyAll () 方法的替代品,与 Lock 配合使用。
2 Condition 接口定义
我们看到,从 JDK 的源码中可以获悉,Condition 接口包含了如下的方法,对于其中常用的方法,我们在后续的内容中会有比较详细的讲解。
public interface Condition {
void await() throws InterruptedException;
long awaitNanos(long nanosTimeout) throws InterruptedException;
boolean await(long time, TimeUnit unit) throws InterruptedException;
boolean awaitUntil(Date deadline) throws InterruptedException;
void signal();
void signalAll();
}
3 Condition 方法与 Object 方法的联系与区别
联系 1:都有一组类似的方法.
- Object 对象监视器: Object.wait()、Object.wait(long timeout)、Object.notify()、Object.notifyAll()。
- Condition 对象: Condition.await()、Condition.awaitNanos(long nanosTimeout)、Condition.signal()、Condition.signalAll()。
联系 2:都需要和锁进行关联。
- Object 对象监视器: 需要进入 synchronized 语句块(进入对象监视器)才能调用对象监视器的方法。
- Condition 对象: 需要和一个 Lock 绑定。
区别:
- Condition 拓展的语义方法,如 awaitUninterruptibly () 等待时忽略中断方法;
- 在使用方法时,Object 对象监视器是进入 synchronized 语句块(进入对象监视器)后调用 Object.wait ()。而 Condition 对象需要和一个 Lock 绑定,并显示的调用 lock () 获取锁,然后调用 Condition.await ();
- 从等待队列数量看,Object 对象监视器是 1 个。而 Condition 对象是多个。可以通过多次调用 lock.newCondition () 返回多个等待队列。
4 Condition 对象的创建
Condition 对象是由 Lock 对象创建出来的 (Lock.newCondition),换句话说,Condition 是依赖 Lock 对象的。那么我们来看看如果创建 Condition 对象。
此处仅提供示例代码,后续我们在进行方法讲解时,会有全部的代码示例,但在学习使用方法之前,我们必须先学会如何创建。
Lock lock = new ReentrantLock();
Condition condition1 = lock.newCondition();
Condition condition2 = lock.newCondition();
5 Condition 方法介绍
等待机制方法简介:
- void await() throws InterruptedException:当前线程进入等待状态,直到被其它线程的唤醒继续执行或被中断;
- void awaitUninterruptibly():当前线程进入等待状态,直到被其它线程被唤醒;
- long awaitNanos(long nanosTimeout) throws InterruptedException:当前线程进入等待状态,直到被其他线程唤醒或被中断,或者指定的等待时间结束;nanosTimeout 为超时时间,返回值 = 超时时间 - 实际消耗时间;
- boolean await(long time, TimeUnit unit) throws InterruptedException:当前线程进入等待状态,直到被其他线程唤醒或被中断,或者指定的等待时间结束;与上个方法区别:可以自己设置时间单位,未超时被唤醒返回 true,超时则返回 false;
- boolean awaitUntil(Date deadline) throws InterruptedException:当前线程等待状态,直到被其他线程唤醒或被中断,或者指定的截止时间结束,截止时间结束前被唤醒,返回 true,否则返回 false。
通知机制方法简介:
- void signal():唤醒一个线程;
- void signalAll():唤醒所有线程。
6 ReentrantLock 与 Condition 实现生产者与消费者
非常熟悉的场景设计,这是我们在讲解生产者与消费者模型时使用的案例设计,那么此处有细微的修改如下,请学习者进行比照学习,印象更加深刻。
场景修改:
- 创建一个工厂类 ProductFactory,该类包含两个方法,produce 生产方法和 consume 消费方法(未改变);
- 对于 produce 方法,当没有库存或者库存达到 10 时,停止生产。为了更便于观察结果,每生产一个产品,sleep 3000 毫秒(5000 变 3000,调用地址也改变了,具体看代码);
- 对于 consume 方法,只要有库存就进行消费。为了更便于观察结果,每消费一个产品,sleep 5000 毫秒(sleep 调用地址改变了,具体看代码);
- 库存使用 LinkedList 进行实现,此时 LinkedList 即共享数据内存(未改变);
- 创建一个 Producer 生产者类,用于调用 ProductFactory 的 produce 方法。生产过程中,要对每个产品从 0 开始进行编号 (新增 sleep 3000ms);
- 创建一个 Consumer 消费者类,用于调用 ProductFactory 的 consume 方法 (新增 sleep 5000ms);
- 创建一个测试类,main 函数中创建 2 个生产者和 3 个消费者,运行程序进行结果观察(未改变)。
实例:
public class DemoTest {
public static void main(String[] args) {
ProductFactory productFactory = new ProductFactory();
new Thread(new Producer(productFactory),"1号生产者"). start();
new Thread(new Producer(productFactory),"2号生产者"). start();
new Thread(new Consumer(productFactory),"1号消费者"). start();
new Thread(new Consumer(productFactory),"2号消费者"). start();
new Thread(new Consumer(productFactory),"3号消费者"). start();
}
}
class ProductFactory {
private LinkedList<String> products; //根据需求定义库存,用 LinkedList 实现
private int capacity = 10; // 根据需求:定义最大库存 10
private Lock lock = new ReentrantLock(false);
private Condition p = lock.newCondition();
private Condition c = lock.newCondition();
public ProductFactory() {
products = new LinkedList<String>();
}
// 根据需求:produce 方法创建
public void produce(String product) {
try {
lock.lock();
while (capacity == products.size()) { //根据需求:如果达到 10 库存,停止生产
try {
System.out.println("警告:线程("+Thread.currentThread().getName() + ")准备生产产品,但产品池已满");
p.await(); // 库存达到 10 ,生产线程进入 wait 状态
} catch (InterruptedException e) {
e.printStackTrace();
}
}
products.add(product); //如果没有到 10 库存,进行产品添加
System.out.println("线程("+Thread.currentThread().getName() + ")生产了一件产品:" + product+";当前剩余商品"+products.size()+"个");
c.signalAll(); //生产了产品,通知消费者线程从 wait 状态唤醒,进行消费
} finally {
lock.unlock();
}
}
// 根据需求:consume 方法创建
public String consume() {
try {
lock.lock();
while (products.size()==0) { //根据需求:没有库存消费者进入wait状态
try {
System.out.println("警告:线程("+Thread.currentThread().getName() + ")准备消费产品,但当前没有产品");
c.await(); //库存为 0 ,无法消费,进入 wait ,等待生产者线程唤醒
} catch (InterruptedException e) {
e.printStackTrace();
}
}
String product = products.remove(0) ; //如果有库存则消费,并移除消费掉的产品
System.out.println("线程("+Thread.currentThread().getName() + ")消费了一件产品:" + product+";当前剩余商品"+products.size()+"个");
p.signalAll();// 通知生产者继续生产
return product;
} finally {
lock.unlock();
}
}
}
class Producer implements Runnable {
private ProductFactory productFactory; //关联工厂类,调用 produce 方法
public Producer(ProductFactory productFactory) {
this.productFactory = productFactory;
}
public void run() {
int i = 0 ; // 根据需求,对产品进行编号
while (true) {
productFactory.produce(String.valueOf(i)); //根据需求 ,调用 productFactory 的 produce 方法
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
i++;
}
}
}
class Consumer implements Runnable {
private ProductFactory productFactory;
public Consumer(ProductFactory productFactory) {
this.productFactory = productFactory;
}
public void run() {
while (true) {
productFactory.consume();
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
九 多线程售票案例
1 售票机制模型
售票机制模型是源于现实生活中的售票场景,从开始的单窗口售票到多窗口售票,从开始的人工统计票数到后续的系统智能在线售票。多并发编程能够实现这一售票场景,多窗口售票情况下保证线程的安全性和票数的正确性。
如上图所示,有两个售票窗口进行售票,有一个窗口处理退票,这既是现实生活中一个简单的售票机制。
2 售票机制实现
场景设计:
- 创建一个工厂类 TicketCenter,该类包含两个方法,saleRollback 退票方法和 sale 售票方法;
- 定义一个车票总数等于 10 ,为了方便观察结果,设置为 10。学习者也可自行选择数量;
- 对于 saleRollback 方法,当发生退票时,通知售票窗口继续售卖车票;
- 对 saleRollback 进行特别设置,每隔 5000 毫秒退回一张车票;
- 对于 sale 方法,只要有车票就进行售卖。为了更便于观察结果,每卖出一张车票,sleep 2000 毫秒;
- 创建一个测试类,main 函数中创建 2 个售票窗口和 1 个退票窗口,运行程序进行结果观察。
- 修改 saleRollback 退票时间,每隔 25 秒退回一张车票;
- 再次运行程序并观察结果。
实现要求:本实验要求使用 ReentrantLock 与 Condition 接口实现同步机制。
实例:
public class DemoTest {
public static void main(String[] args) {
TicketCenter ticketCenter = new TicketCenter();
new Thread(new saleRollback(ticketCenter),"退票窗口"). start();
new Thread(new Consumer(ticketCenter),"1号售票窗口"). start();
new Thread(new Consumer(ticketCenter),"2号售票窗口"). start();
}
}
class TicketCenter {
private int capacity = 10; // 根据需求:定义10涨车票
private Lock lock = new ReentrantLock(false);
private Condition saleLock = lock.newCondition();
// 根据需求:saleRollback 方法创建,为退票使用
public void saleRollback() {
try {
lock.lock();
capacity++;
System.out.println("线程("+Thread.currentThread().getName() + ")发生退票。" + "当前剩余票数"+capacity+"个");
saleLock.signalAll(); //发生退票,通知售票窗口进行售票
} finally {
lock.unlock();
}
}
// 根据需求:sale 方法创建
public void sale() {
try {
lock.lock();
while (capacity==0) { //没有票的情况下,停止售票
try {
System.out.println("警告:线程("+Thread.currentThread().getName() + ")准备售票,但当前没有剩余车票");
saleLock.await(); //剩余票数为 0 ,无法售卖,进入 wait
} catch (InterruptedException e) {
e.printStackTrace();
}
}
capacity-- ; //如果有票,则售卖 -1
System.out.println("线程("+Thread.currentThread().getName() + ")售出一张票。" + "当前剩余票数"+capacity+"个");
} finally {
lock.unlock();
}
}
}
class saleRollback implements Runnable {
private TicketCenter TicketCenter; //关联工厂类,调用 saleRollback 方法
public saleRollback(TicketCenter TicketCenter) {
this.TicketCenter = TicketCenter;
}
public void run() {
while (true) {
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
TicketCenter.saleRollback(); //根据需求 ,调用 TicketCenter 的 saleRollback 方法
}
}
}
class Consumer implements Runnable {
private TicketCenter TicketCenter;
public Consumer(TicketCenter TicketCenter) {
this.TicketCenter = TicketCenter;
}
public void run() {
while (true) {
TicketCenter.sale(); //调用sale 方法
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
结果验证:
线程(1号售票窗口)售出一张票。当前剩余票数9个
线程(2号售票窗口)售出一张票。当前剩余票数8个
线程(2号售票窗口)售出一张票。当前剩余票数7个
线程(1号售票窗口)售出一张票。当前剩余票数6个
线程(1号售票窗口)售出一张票。当前剩余票数5个
线程(2号售票窗口)售出一张票。当前剩余票数4个
线程(退票窗口)发生退票。当前剩余票数5个
线程(1号售票窗口)售出一张票。当前剩余票数4个
线程(2号售票窗口)售出一张票。当前剩余票数3个
线程(2号售票窗口)售出一张票。当前剩余票数2个
线程(1号售票窗口)售出一张票。当前剩余票数1个
线程(退票窗口)发生退票。当前剩余票数2个
线程(1号售票窗口)售出一张票。当前剩余票数1个
线程(2号售票窗口)售出一张票。当前剩余票数0个
警告:线程(1号售票窗口)准备售票,但当前没有剩余车票
警告:线程(2号售票窗口)准备售票,但当前没有剩余车票
线程(退票窗口)发生退票。当前剩余票数1个
线程(1号售票窗口)售出一张票。当前剩余票数0个
警告:线程(2号售票窗口)准备售票,但当前没有剩余车票
警告:线程(1号售票窗口)准备售票,但当前没有剩余车票
结果分析:从结果来看,我们正确的完成了售票和退票的机制,并且使用了 ReentrantLock 与 Condition 接口。
代码片段分析 1:看售票方法代码。
public void sale() {
try {
lock.lock();
while (capacity==0) { //没有票的情况下,停止售票
try {
System.out.println("警告:线程("+Thread.currentThread().getName() + ")准备售票,但当前没有剩余车票");
saleLock.await(); //剩余票数为 0 ,无法售卖,进入 wait
} catch (InterruptedException e) {
e.printStackTrace();
}
}
capacity-- ; //如果有票,则售卖 -1
System.out.println("线程("+Thread.currentThread().getName() + ")售出一张票。" + "当前剩余票数"+capacity+"个");
} finally {
lock.unlock();
}
}
主要来看方法中仅仅使用了 await 方法,因为退票是场景触发的,售票窗口无需唤醒退票窗口,因为真实的场景下,可能没有退票的发生,所以无需唤醒。这与生产者与消费者模式存在着比较明显的区别。
代码片段分析 2:看退票方法代码。
public void saleRollback() {
try {
lock.lock();
capacity++;
System.out.println("线程("+Thread.currentThread().getName() + ")发生退票。" + "当前剩余票数"+capacity+"个");
saleLock.signalAll(); //发生退票,通知售票窗口进行售票
} finally {
lock.unlock();
}
}
退票方法只有 signalAll 方法,通知售票窗口进行售票,无需调用 await 方法,因为只要有退票的发生,就能够继续售票,没有库存上限的定义,这也是与生产者与消费者模式的一个主要区别。
总结:售票机制与生产者 - 消费者模式存在着细微的区别,需要学习者通过代码的实现慢慢体会。由于售票方法只需要进入 await 状态,退票方法需要唤醒售票的 await 状态,因此只需要创建一个售票窗口的 Condition 对象。