线程安全问题
一、概述
1、案例分析
卖票案例:模拟3个窗口同时卖1000张电影票
public class UnsafeThreadTest {
public static void main(String[] args) {
// 共享资源:票
Ticket ticket = new Ticket();
// 卖票任务
Runnable task = () -> {
for (int i = 0; i < 100; i++) {
ticket.saleTickets();
}
};
// 多线程操作
new Thread(task, "窗口1").start();
new Thread(task, "窗口2").start();
new Thread(task, "窗口3").start();
}
}
class Ticket {
private int number = 100;
public void saleTickets() {
if (number > 0) {
number--;
System.out.println(Thread.currentThread().getName() +
"卖了一张票,剩余:" + number);
}
}
}
窗口3卖了一张票,剩余:99
窗口2卖了一张票,剩余:99
窗口1卖了一张票,剩余:99
窗口2卖了一张票,剩余:98
窗口3卖了一张票,剩余:97
窗口3卖了一张票,剩余:96
可以看到,三个窗口卖了同一张票,显然出现了线程安全问题。
2、什么是线程安全问题?
线程安全问题是指:多个线程
同时操作
共享资源
而导致的数据不一致或不正确的问题。
- 如果只有读操作,而没有写操作,一般来说是线程安全的。
3、临界区 和 竞态条件
临界区和竞态条件是并发编程中常用的两个概念:
-
临界区(Critical Section)
:一段在 并发执行 和 顺序执行 时有着不同运行结果的代码片段。
-
竞态条件(Race Condition)
:多个线程同时竞争执行临界区的代码,由于执行顺序不同而导致结果无法预测,称之为发生了竞态条件。
要想防止竞态条件的发生,就要确保临界区以原子方式执行:
- 可以通过加锁、使用原子操作、使用线程安全的数据结构等方式
4、JUC并发包
JUC 是 Java Util Concurrent 的缩写,是 Java 中用于并发编程的工具包,提供了一系列线程安全的工具类、数据结构和执行器框架,用于简化多线程编程,并提高并发性能。
JUC 提供了以下几个主要的组件:
-
原子变量类(Atomic Variables):
包括
AtomicInteger
、AtomicLong
、AtomicBoolean
等,用于在多线程环境下操作原子性的变量,通常使用 CAS(Compare and Swap)操作来实现。
-
锁框架(Lock Framework):
包括
Lock
接口、ReentrantLock
、ReentrantReadWriteLock
、StampedLock
等,提供了比传统的 synchronized 关键字更灵活、可扩展的锁机制,支持更多的锁定方式和功能。
-
并发集合(Concurrent Collections):
包括
ConcurrentHashMap
、CopyOnWriteArrayList
、CopyOnWriteArraySet
等,这些集合类提供了线程安全的实现,可以在多线程环境下安全地进行操作。
-
并发工具类(Concurrent Utilities):
包括
CountDownLatch
、CyclicBarrier
、Semaphore
、Exchanger
等,用于协调多个线程的执行顺序、控制并发数量等。
-
线程池框架(Executor Framework):
包括
Executor
、ExecutorService
、ThreadPoolExecutor
、ScheduledExecutorService
等,提供了管理线程生命周期、调度执行任务、控制线程池大小等功能,简化了多线程编程中线程的创建和管理。
JUC 的引入提供了更丰富的并发编程工具和更灵活的线程控制机制,能够更好地满足多线程编程的需求。
5、解决方案
1)悲观锁
-
悲观的含义:
认为在并发访问时会发生冲突,因此在处理数据之前加锁,使得整个数据处理过程中,数据处于锁定状态。
-
悲观锁/互斥锁:
一个线程一旦获取到锁,其他需要锁的线程就会阻塞BLOCKED,等待锁的释放。
-
常用的悲观锁:
synchronized
、Lock
-
悲观锁的优缺点:
优点:简单直观,稳定可靠,并发适用于写操作频繁的场景。
缺点:需要获取和释放锁,性能开销大;加锁粒度控制不当,也会导致性能下降;可能导致死锁。
2)乐观锁
-
乐观的含义:
在并发访问时不会发生冲突,因此不需要加锁。
-
乐观锁:
当多个线程尝试更新同一资源时,乐观锁会进行版本检查或者比较操作,以确定是否发生了冲突。
如果未被更新,则修改成功;如果已被更新,则修改失败,可以 自旋重试 或 不再重试。
-
乐观锁的应用:
原子操作类(基于CAS)
-
乐观锁的优缺点:
优点:不需要加锁,减少了锁的竞争和开销,适用于读操作频繁的场景。
缺点:自旋消耗资源、ABA问题
二、Java内存模型(JMM)
1、什么是JMM
Java内存模型(Java Memory Model,JMM)是一种规范,用于定义在并发编程中,Java程序中各个线程之间如何通过内存进行通信。JMM规定了线程如何与主内存和工作内存进行交互,以及如何确保多线程环境下的内存可见性、原子性和有序性。
JMM通过限制和规范多线程程序中的内存访问,确保了程序的正确性和可预测性。程序员在编写并发程序时,需要遵循JMM的规范,使用同步机制(如锁、volatile、synchronized等)来保证线程间的内存可见性和操作的原子性。
2、内存交互规定
- 所有变量都存储在
主内存
(主内存是所有线程共享的内存区域) - 每个线程创建时,JVM都会为其创建一个
工作内存
(用于存储线程私有的数据,如共享变量副本) - 线程不能直接操作
主内存
中的共享变量,线程对变量的所有操作都是在工作内存
中进行的。
工作内存是线程私有的,不同的线程间无法访问对方的工作内存,线程间的通信/传值必须通过主内存来完成:
Java 内存模型定义了 8 个操作来完成主内存
和工作内存
的交互操作,每个操作都是原子
的
-
Lock
作用于主内存,将一个变量标识为被一个线程独占状态(对应 monitorenter)
-
Read
作用于主内存,把一个变量值从主内存读取到线程的工作内存中,以便后续load使用。
-
Load
作用于工作内存,在 read 之后执行,把 read 得到的值放入工作内存的变量副本中
-
Use
作用于工作内存,把工作内存中的一个变量值传递给执行引擎使用。
-
Assign
作用于工作内存,把一个从执行引擎接收到的值赋给工作内存的变量。
-
Store
作用于工作内存,把工作内存中的一个变量的值传送到主内存中,以便后续write使用。
-
Write
作用于主内存,在 store 之后执行,把 store 得到的值放入主内存的变量中
-
Unclock
作用于主内存,将一个变量从独占状态释放出来,释放后的变量才可以被其他线程锁定(对应 monitorexit)
3、happens-before 原则
happens-before
是 JMM 中的一个重要概念,用于描述在多线程环境下操作之间的顺序性关系。
- 具体来说,如果 操作A “happens-before” 操作B,那么在执行时,操作A 的效果将对 操作B 可见。
1)程序顺序规则
在同一个线程中,前一个操作 happens-before 后一个操作
- 操作1(对变量 x 的写操作)对于 操作2(对变量 x 的读操作)是可见的。
int x = 0;
int y = 0;
x = 1; // 操作1
y = x; // 操作2
2)监视器锁规则
释放监视器锁(synchronized 块或方法的结束) happens-before 获取监视器锁(synchronized 块或方法的开始)
- 操作1(t1线程 释放锁m 之前对 x 的写)对于 操作2(t2线程 获取锁m 之后对 x 的读)是可见的。
public class MonitorLockRuleTest {
static int x;
public static void main(String[] args) {
Object m = new Object();
new Thread(() -> {
synchronized (m) {
x = 10;
}
}, "t1").start();
new Thread(() -> {
synchronized (m) {
System.out.println(x);
}
}, "t2").start();
}
}
3)volatile 变量规则
对一个 volatile 变量的写操作 happens-before 于 对一个 volatile 变量的读操作
- 操作1(t1线程 对 volatile变量x 的写)对于 操作2(t2线程 对 volatile变量x 的读)是可见的。
public class VolatileRuleTest {
volatile static int x;
public static void main(String[] args) {
new Thread(() -> x = 10, "t1").start();
new Thread(() -> System.out.println(x), "t2").start();
}
}
4)线程启动规则
一个线程的启动操作 happens-before 该线程的任何操作。
- 操作1(线程 start 前对变量的写)对于 操作2(线程 start 后对变量的读)是可见的。
public class ThreadStartRuleTest {
static int x;
public static void main(String[] args) {
x = 10;
new Thread(() -> System.out.println(x)).start();
}
}
5)线程终止规则
一个线程的所有操作 happens-before 其他线程检测到该线程已经终止。
- 操作1(线程结束前对变量的写)对于 操作2(其它线程得知它结束后的读)是可见的。
“其它线程得知它结束”包括:
- 其它线程调用
t.isAlive()
判断 或t.join()
等待它结束
public class ThreadTerminationRuleTest {
static int x;
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(() -> x = 10);
t.start();
t.join();
System.out.println(x);
}
}
6)中断规则
对线程 interrupt()
方法的调用 happens-before 其他线程检测到被中断线程发生中断。
- 操作1(线程 t1 打断 t2 前对x的写)对于 操作2(main线程得知 t2 被打断后对x的读)是可见的。
public class InterruptionRuleTest {
static int x;
public static void main(String[] args) {
Thread t2 = new Thread(() -> {
while (true) {
if (Thread.currentThread().isInterrupted()) {
System.out.println(x);
break;
}
}
}, "t2");
t2.start();
new Thread(() -> {
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
x = 10;
t2.interrupt();
}, "t1").start();
while (!t2.isInterrupted()) {
Thread.yield();
}
System.out.println(x);
}
}
7)终结器规则
一个对象的初始化完成(构造函数执行结束)happens-before 该对象的finalize()
方法的开始。
public class FinalizerRuleTest {
int x;
public FinalizerRuleTest() {
this.x = 10;
}
@Override
protected void finalize() {
System.out.println(x); // 10
}
public static void main(String[] args) throws Throwable {
FinalizerRuleTest finalizerRuleTest = new FinalizerRuleTest();
finalizerRuleTest.finalize();
}
}
8)传递性规则
如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C的结论。
public class TransitivityTest {
static boolean flag1 = false;
volatile static boolean flag2 = false;
public static void main(String[] args) throws InterruptedException {
new Thread(() -> {
while (true) {
if (flag1) {
System.out.println("flag1 exit");
break;
}
if (flag2) {
System.out.println("flag2 exit");
break;
}
}
}, "t").start();
TimeUnit.SECONDS.sleep(1);
System.out.println("停止t线程");
flag1 = true;
flag2 = true;
}
}
停止t线程
flag1 exit
三、并发编程三大特性
一个并发程序,必须保证原子性、可见性、有序性,否则就可能导致运行结果不正确。
1、原子性 - 线程切换
原子性:
一个/多个操作在执行过程中不会被线程调度机制打断,要么全部执行,要么都不执行。
操作一旦开始,就一直运行到结束,中间不会有任何上下文切换(context switch)。
原子性问题:
一个操作的多个步骤间发生了线程的上下文切换
如何保证:
1. 悲观锁:synchronized、Lock
2. 乐观锁:版本号、CAS、原子操作类
1)问题示例
/**
* 原子性问题:同时对共享变量加减相同次数,结果不为0
*/
public class AtomicityUnsafeTest {
private static int num = 0;
public static void main(String[] args) {
new Thread(() -> {
for (int i = 0; i < 100000; i++) {
num++;
}
}, "t1").start();
new Thread(() -> {
for (int i = 0; i < 100000; i++) {
num--;
}
}, "t2").start();
while (Thread.activeCount() > 2) {
// main线程、gc线程
}
System.out.println(num); // -909(理论上应该是0)
}
}
2)问题分析
public class AtomicityTest {
private static int num = 0;
private static void add() {
num++;
}
}
$ javap -p -c AtomicityTest.class
........
private static void add();
Code:
0: getstatic #2 // Field num:I
3: iconst_1
4: iadd
5: putstatic #2 // Field num:I
8: return
可以看到,num++
可以分解为3步:
- 从静态区获取num的值
- 执行++操作
- 将修改后的值写回静态区
执行以上3个步骤时,可能会发生线程切换,因此num++
不是一个原子性操作,会发生原子性问题。
3)解决方案
共享变量使用原子操作类
(除此以外,还可以使用加锁等方式,此处仅展示原子操作类的示例)
public class AtomicitySafeTest {
// 原子操作类
private static AtomicInteger num = new AtomicInteger();
public static void main(String[] args) {
new Thread(() -> {
for (int i = 0; i < 100000; i++) {
num.getAndIncrement();
}
}, "t1").start();
new Thread(() -> {
for (int i = 0; i < 100000; i++) {
num.getAndDecrement();
}
}, "t2").start();
while (Thread.activeCount() > 2) {
// main线程、gc线程
}
System.out.println(num); // 0
}
}
2、可见性 - 工作内存
可见性:
多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
可见性问题:
若两个线程在不同的CPU,那么线程1改变了i的值还没刷新到主存,线程2又使用了i,
那么这个i值肯定还是之前的,线程1对变量的修改线程2没看到,这就是可见性问题。
如何保证:
1. volatile关键字
2. synchronized关键字
1)问题示例
/**
* 可见性问题:线程t不知道main线程对共享变量的修改,没有退出循环
*/
public class VisibilityUnsafeTest {
static boolean run = true;
public static void main(String[] args) throws InterruptedException {
new Thread(() -> {
while (true) {
if (!run) break;
}
System.out.println("run发生了变化");
}, "t").start();
TimeUnit.SECONDS.sleep(1);
System.out.println("停止t线程");
run = false;
}
}
2)问题分析
- 初始状态, t 线程刚开始从主内存读取了 run 的值到工作内存。
-
因为 t 线程要频繁从主内存中读取 run 的值,JIT 编译器会将 run 的值缓存至自己工作内存中的高速缓存中,
减少对主存中 run 的访问,提高效率。
- 1 秒之后,main 线程修改了 run 的值,并同步至主存,而 t 线程还是从自己工作内存的缓存中读取 run 的值,还是旧值。
3)解决方案 - volatile
/**
* volatile 保证 可见性
*/
public class VisibilitySafeTest1 {
// volatile修饰
volatile static boolean run = true;
public static void main(String[] args) throws InterruptedException {
new Thread(() -> {
while (true) {
if (!run) break;
}
System.out.println("run发生了变化");
}, "t").start();
TimeUnit.SECONDS.sleep(1);
System.out.println("停止t线程");
run = false;
}
}
4)解决方案 - synchronized
/**
* synchronized 保证 可见性
*/
public class VisibilitySafeTest2 {
static boolean run = true;
public static void main(String[] args) throws InterruptedException {
Object lock = new Object();
new Thread(() -> {
while (true) {
// synchronized同步
synchronized (lock) {
if (!run) break;
}
}
System.out.println("run发生了变化");
}, "t").start();
TimeUnit.SECONDS.sleep(1);
System.out.println("停止t线程");
run = false;
}
}
原理分析:
-
当一个线程进入
synchronized
代码块或方法时,它会获取同步锁,在获取同步锁时,线程会清空
工作内存
中的共享变量值,并从主内存
中重新获取最新的值。 -
当一个线程退出
synchronized
代码块或方法时,它会释放同步锁。在释放同步锁时,线程会将
工作内存
中的共享变量的值刷新到主内存
中,这样其他线程就能够看到最新的值。
因此,使用synchronized
关键字会刷新线程的工作内存,确保共享变量的修改被及时反映到主内存中,同时也会确保从主内存中获取最新的共享变量值。
5)扩展
在示例的死循环中加入 System.out.println()
会发现,线程 t 也能正确看到对 flag 变量的修改了
public class VisibilitySafeTest3 {
static boolean run = true;
public static void main(String[] args) throws InterruptedException {
new Thread(() -> {
while (true) {
System.out.println();
if (!run) break;
}
System.out.println("run发生了变化");
}, "t").start();
TimeUnit.SECONDS.sleep(1);
System.out.println("停止t线程");
run = false;
}
}
看一下System.out.println()
的源码,可以看到使用了synchronized
代码块
/**
* System.out.println()源码
*/
public void println(String x) {
synchronized (this) {
print(x);
newLine();
}
}
当然,不推荐用这种方式保证原子性,这里只是做一个了解。
3、有序性 - 指令重排
有序性:
程序按照代码的先后顺序执行。
有序性问题:
发生指令重排(程序没有按代码顺序执行),从而在多线程的时候出现问题(单线程不会出现问题)
如何保证:
1. volatile关键字(可以禁止指令重排)
2. synchronized关键字(注意,synchronized并不能禁止指令重排)
如果变量操作都在临界区内(synchronized内部),是可以保证有序性的,
但是如果变量逃逸在临界区之外(参考双重校验锁),就又可能发生指令重排,导致有序性问题
代码的执行过程:
源代码 -> 编辑器优化重排 -> 指令并行重排 -> 内存系统重排 -> 代码执行
1)问题示例
/**
* 有序性问题:相同的代码可能会输出不同的结果
*/
public class OrderUnsafeTest {
static int num = 0;
static boolean flag = false;
public static void main(String[] args) {
Thread thread1 = new Thread(() -> {
num = 1;
flag = true;
});
Thread thread2 = new Thread(() -> {
if (flag) {
System.out.println(num + num);
} else {
System.out.println(num);
}
});
thread1.start();
thread2.start();
}
}
可能输出的结果:
- 情况1:线程2 先执行,这时 flag = false,所以进入 else 分支,结果为 1
- 情况2:线程1 先执行 num = 1,还未执行 flag = true,此时线程2 执行,结果为1(和情况1一致)
- 情况3:线程1 执行完 num = 1 和 flag = true,线程2 执行,这回进入 if 分支,结果为 2
- 情况4:线程1 指令重排,先执行 flag = true,还未执行 num = 1,此时线程2 执行,进入 if 分支,结果为 0
可以看到,情况4发生指令重排,导致发生了有序性问题。
2)问题分析 - 指令重排
一般来说,处理器为了提高程序的运行效率,可能会对代码进行优化,它不保证程序中各个语句的执行顺序
和代码顺序
一致,但是它会保证程序在单线程环境
下最终的执行结果
和 代码顺序执行的结果
是一致的。
注意:指令重排不会影响单线程
的执行结果,但是会影响多线程
并发执行的结果正确性。
以下面4个语句为例:
int i = 0; // 语句1
int j = 0; // 语句2
i = i + 8; // 语句3
j = i * i; // 语句4
可能的执行顺序如下:
1-2-3-4
2-1-3-4
1-3-2-4
编译器在进行指令重排时,会考虑数据的依赖性问题。拿上面的例子来说:
- 语句4依赖于语句3,因此语句4一定是在语句3之后执行的。
3)为什么要指令重排
每条计算机指令可以被划分成一个个更小的阶段,如: 取指令 - 指令译码 - 执行指令 - 内存访问 - 数据写回
这 5 个阶段
在不改变程序结果的前提下,这些指令的各个阶段可以通过重排序
和组合
来实现指令级并行
。
这时 CPU 可以在一个时钟周期内,同时运行五条指令的不同阶段,通常被称为五级流水线(Five-stage pipeline)
这虽然不能缩短单条指令的执行时间,但它使得处理器能够在同一时刻执行多条指令,从而提高了指令的处理速度和整体性能。
4)解决方案 - volatile
/**
* volatile 保证 有序性
*/
public class OrderUnsafeTest {
static int num = 0;
// volatile修饰
volatile static boolean flag = false;
public static void main(String[] args) {
Thread thread1 = new Thread(() -> {
num = 1;
flag = true;
});
Thread thread2 = new Thread(() -> {
if (flag) {
System.out.println(num + num);
} else {
System.out.println(num);
}
});
thread1.start();
thread2.start();
}
}
这里volatile
通过内存屏障
来限制指令重排
,从而保证了有序性。
四、volatile
1、volatile的作用
- 保证可见性
volatile
变量的写操作
,会同步到主内存
当中。volatile
变量的读操作
,加载的是主内存
中的最新数据。
- 保证有序性
- 通过
内存屏障
来限制指令重排
- 通过
注意:volatile
并不能保证原子性!
2、内存屏障(Memory Barrier)
内存屏障的分类(参考JSR-133)
屏障类型 | 示例 | 说明 |
---|---|---|
LoadLoad | Load1-LoadLoad-Load2 | 保证 Load1的读操作 在 Load2及其后的读操作 之前执行 |
LoadStore | Load1-LoadStore-Store1 | 保证 Load1的读操作 在 Store1及其后的写操作 之前执行 |
StoreLoad | Store1-LoadLoad-Load1 | 保证 Store1的写操作刷新到主内存后 再执行 Load1及其后的读操作 |
StoreStore | Store1-LoadLoad-Store2 | 保证 Store1的写操作刷新到主内存后 再执行 Store2及其后的写操作 |
在实际执行时,编译器可以省略没必要的屏障点,同时在某些处理器上会做进一步的优化。
- Normal操作 和 Normal操作 之间,是不加屏障的
- Volatile的写操作 之后如果是 Normal操作,也是不加屏障的
- Volatile的读操作 之前如果是 Normal操作,也是不加屏障的
3、volatile 保证 可见性
volatile
变量的写操作
,会同步到主内存
当中。volatile
变量的读操作
,加载的是主内存
中的最新数据。
/**
* volatile 保证 可见性
*/
public class VisibilitySafeTest1 {
// volatile修饰
volatile static boolean run = true;
public static void main(String[] args) throws InterruptedException {
new Thread(() -> {
while (true) {
if (!run) break;
}
System.out.println("run发生了变化");
}, "t").start();
TimeUnit.SECONDS.sleep(1);
System.out.println("停止t线程");
run = false;
}
}
4、volatile 保证 有序性
StoreStore屏障
保证了num = 1
不会重排到flag = true
之后。
/**
* volatile 保证 有序性
*/
public class OrderUnsafeTest {
static int num = 0;
// volatile修饰
volatile static boolean flag = false;
public static void main(String[] args) {
Thread thread1 = new Thread(() -> {
num = 1;
// StoreStore屏障
flag = true;
});
Thread thread2 = new Thread(() -> {
if (flag) {
System.out.println(num + num);
} else {
System.out.println(num);
}
});
thread1.start();
thread2.start();
}
}
5、原子性(无法保证)
无法解决多个线程之间指令交错的问题。如下图所示(t1线程
执行i++
,t2线程
执行i--
)
- 有序性的保证也只是保证了
本线程内
相关代码不被重排序。
6、应用:双重校验锁单例
public class DoubleCheckLockSingleton {
private Singleton() {}
// volatile修饰,禁止指令重排
private volatile static final Singleton uniqueInstance;
public static Singleton getUniqueInstance() {
if (uniqueInstance == null) {
// 没有被实例化才会加锁
synchronized (Singleton.class) {
// 可能有多个线程都进入了第一个if,因此需要这里的if再次判断,保证只实例化1次
if (uniqueInstance == null) {
uniqueInstance = new Singleton();
}
}
}
return uniqueInstance;
}
}
volatile 关键字很重要。 uniqueInstance = new Singleton();
这段代码其实是分为三步执行:
- 为 uniqueInstance 分配内存空间
- 执行构造方法,初始化 uniqueInstance 对象
- 将 uniqueInstance 指向分配的内存地址
由于JVM的指令重排,执行顺序可能变为1-3-2
。多线程环境下,可能会获取到还没有初始化的实例(还未执行2)
。
7、实现:两阶段中止模式
public class TwoParseTerminationTest {
public static void main(String[] args) throws InterruptedException {
TwoParseTermination twoParseTermination = new TwoParseTermination();
twoParseTermination.start();
Thread.sleep(3000);
twoParseTermination.stop();
}
}
class TwoParseTermination {
// 监控线程
Thread monitorThread;
// 中止标记(需要用volatile修饰,保证共享变量在不同线程间的可见性)
private volatile boolean stop = false;
// 启动监控线程
public void start(){
monitorThread = new Thread(()->{
while(true) {
// 判断是否被中止(这里用volatile修饰的stop替代了isInterrupted)
if (stop){
System.out.println("线程结束。。正在料理后事中");
break;
}
try {
Thread.sleep(500);
System.out.println("正在执行监控的功能");
} catch (InterruptedException e) {
}
}
}, "monitor");
monitorThread.start();
}
// 停止监控线程
public void stop(){
stop = true;
monitorThread.interrupt();
}
}
五、悲观锁 - Lock
1、概述
jdk1.5 之后,JUC并发包中新增了 Lock接口 及 相关实现类,用来实现同步功能(保证原子性)
package java.util.concurrent.locks;
public interface Lock {
// 加锁
void lock();
void lockInterruptibly() throws InterruptedException;
// 解锁
boolean tryLock();
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
// 尝试获取锁
void unlock();
Condition newCondition();
}
Lock 提供了与 synchronized 类似的同步功能,但需要在使用时手动获取和释放锁,并且多个线程要使用相同的Lock对象。
-
lock()
方法是一个阻塞式
的加锁方法:如果当前锁没有被其他线程持有,那么它会获取到锁并返回 ;
如果当前锁被其他线程持有,那么调用线程将被阻塞,直到获取到锁为止。
-
tryLock()
方法是一个非阻塞式
的加锁方法:如果当前锁没有被其他线程持有,那么它会立即获取到锁并返回
true
;如果当前锁被其他线程持有,那么它会立即返回
false
,而不会使调用线程进入阻塞状态。
2、使用示例
Lock
是接口,这里我们以它的实现类 ReentrantLock
为例,演示Lock的同步功能。
1)lock加锁 & unlock解锁
lock()
方法会阻塞当前线程,直到获取到锁为止。
public class LockTest {
private static int number = 30;
private static final Lock lock = new ReentrantLock();
public static void main(String[] args) {
Runnable task = () -> {
String currentThread = Thread.currentThread().getName();
for (int i = 0; i < 10; i++) {
try {
lock.lock();
if (number > 0) {
number--;
System.out.println(currentThread + "卖了一张票,剩余:" + number);
}
} catch (Exception e) {
// TODO: handle exception
} finally {
lock.unlock();
}
}
};
// 多线程操作
new Thread(task, "窗口1").start();
new Thread(task, "窗口2").start();
new Thread(task, "窗口3").start();
}
}
2)tryLock获取锁
tryLock()
方法尝试获取锁,获取成功返回true,获取失败返回false
public class TryLockTest {
// 创建锁对象
private final Lock lock = new ReentrantLock();
public static void main(String[] args) {
final TryLockTest test = new TryLockTest();
new Thread(test::tryLock, "t1").start();
new Thread(test::tryLock, "t2").start();
}
public void tryLock() {
String currentThread = Thread.currentThread().getName();
// 尝试获取锁
if (lock.tryLock()) {
try {
System.out.println(currentThread + "获取锁成功");
Thread.sleep(2000);
} catch (Exception e) {
// TODO: handle exception
} finally {
// 释放锁
lock.unlock();
System.out.println(currentThread + "释放了锁");
}
} else {
System.out.println(currentThread + "获取锁失败");
}
}
}
3、synchronized 和 Lock 对比
- synchronized 是内置的Java关键字;Lock 是一个接口。
- synchronized 无法判断获取锁的状态;Lock 可以通过
tryLock
方法知道有没有成功获取锁。 - synchronized 会自动释放锁;Lock 需要通过
unlock
方法手动释放锁,不释放会产生死锁。 - synchronized 没有获取到锁会一直等待下去;Lock 可以通过
tryLock
方法让没有获取到锁的线程中断。 - synchronized 可重入锁,非公平锁,不可中断;Lock 可重入锁,可设置公平锁(构造),可中断(判断)。
- synchronized 不支持读写锁;Lock 支持读写锁,可以提高多个线程进行读操作的效率。
synchronized
适用于简单的同步需求,使用起来方便快捷;而 Lock
适用于复杂的同步需求,提供了更多的灵活性和性能优势。
4、公平锁 & 非公平锁
synchronized 是 非公平锁;Lock 默认是非公平锁,也可以设置为公平锁,如:new ReentrantLock(true)
public class ReentrantLock implements Lock, java.io.Serializable {
// 非公平锁:随机获取锁
public ReentrantLock() {
sync = new NonfairSync();
}
// 公平锁:先来后到
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
}
5、可重入锁
可重入锁:允许同一线程多次获取锁。
- synchronized 和 Lock 都是可重入锁。
- Lock锁的 lock() 和 unlock() 必须成对出现,不然会发生死锁。
public class ReentrantLockDemo {
private static final Lock lock = new ReentrantLock();
public static void main(String[] args) {
// 创建两个线程,分别调用同一个对象的方法
new Thread(ReentrantLockDemo::printNumbers, "t1").start();
new Thread(ReentrantLockDemo::printNumbers, "t2").start();
}
public static void printNumbers() {
lock.lock(); // 获取锁
try {
System.out.println(Thread.currentThread().getName() + ":第一次获取锁");
// 调用另一个需要获取同一把锁的方法
printLetters();
Thread.sleep(100); // 模拟其他操作
} catch (InterruptedException e) {
// TODO: handle exception
} finally {
lock.unlock(); // 释放锁
}
}
public static void printLetters() {
lock.lock(); // 获取锁
try {
System.out.println(Thread.currentThread().getName() + ":重入锁");
} finally {
lock.unlock(); // 释放锁
}
}
}
t1:第一次获取锁
t1:重入锁
t2:第一次获取锁
t2:重入锁
6、自旋锁
1)原子操作类
public class AtomicInteger extends Number implements java.io.Serializable {
public final int getAndIncrement() {
return unsafe.getAndAddInt(this, valueOffset, 1);
}
}
public final class Unsafe {
public final int getAndAddInt(Object var1, long var2, int var4) {
int var5;
// 自旋操作
do {
var5 = this.getIntVolatile(var1, var2);
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
return var5;
}
public final native boolean compareAndSwapInt(Object o, long offset, int expected, int x);
}
2)自定义自旋锁
private class SpinLock {
AtomicReference<Thread> atomicReference = new AtomicReference<>();
public void lock() {
Thread thread = Thread.currentThread();
// 自旋
while (!atomicReference.compareAndSet(null, thread)) {
// do nothing
}
System.out.println(Thread.currentThread().getName() + "上锁");
}
public void unlock() {
Thread thread = Thread.currentThread();
atomicReference.compareAndSet(thread, null);
System.out.println(Thread.currentThread().getName() + "解锁");
}
}
public class SpinLockTest {
private static int number = 30;
private static final SpinLock lock = new SpinLock();
public static void main(String[] args) {
Runnable task = () -> {
String currentThread = Thread.currentThread().getName();
for (int i = 0; i < 10; i++) {
try {
lock.lock();
if (number > 0) {
number--;
System.out.println(currentThread + "卖了一张票,剩余:" + number);
}
} catch (Exception e) {
// TODO: handle exception
} finally {
lock.unlock();
}
}
};
// 多线程操作
new Thread(task, "窗口1").start();
new Thread(task, "窗口2").start();
new Thread(task, "窗口3").start();
}
}
3)自定义可重入自旋锁
为了实现可重入锁,我们需要引入一个计数器,用来记录获取锁的线程数。
public class ReentrantSpinLock {
AtomicReference<Thread> cas = new AtomicReference<>();
private int count; // 重入次数
public void lock() {
Thread thread = Thread.currentThread();
// 如果当前线程已经获取到了锁,计数器+1,然后返回
if (thread == cas.get()) {
count++;
System.out.println("重入次数+1,count=" + count);
return;
}
// 如果没获取到锁,则通过CAS自旋
while (!cas.compareAndSet(null, thread)) {
// do nothing
}
}
public void unlock() {
Thread thread = Thread.currentThread();
if (thread == cas.get()) {
if (count > 0) {
// 如果大于0,表示当前线程多次获取了该锁,释放锁通过计数器-1来模拟
count--;
System.out.println("重入次数-1,count=" + count);
} else {
// 如果count==0,可以将锁释放,这样就能保证获取锁的次数与释放锁的次数是一致的了。
cas.compareAndSet(thread, null);
}
}
}
}
public class ReentrantSpinLockTest {
private static final ReentrantSpinLock lock = new ReentrantSpinLock();
public static void main(String[] args) {
// 创建两个线程,分别调用同一个对象的方法
new Thread(ReentrantSpinLockTest::printNumbers, "t1").start();
new Thread(ReentrantSpinLockTest::printNumbers, "t2").start();
}
public static void printNumbers() {
lock.lock(); // 获取锁
try {
System.out.println(Thread.currentThread().getName() + ":第一次获取锁");
// 调用另一个需要获取同一把锁的方法
printLetters();
Thread.sleep(1000); // 模拟其他操作
} catch (InterruptedException e) {
// TODO: handle exception
} finally {
lock.unlock(); // 释放锁
}
}
public static void printLetters() {
lock.lock(); // 获取锁
try {
System.out.println(Thread.currentThread().getName() + ":重入锁");
} finally {
lock.unlock(); // 释放锁
}
}
}
4)自定义公平自旋锁
public class FairSpinLock {
// 服务号
private AtomicInteger serviceNum = new AtomicInteger(1);
// 排队号
private volatile AtomicInteger queueNum = new AtomicInteger(0);
// 存储每个线程的排队号
private ThreadLocal<Integer> queueNumHolder = new ThreadLocal<>();
public void lock() {
int currentQueueNum = queueNum.incrementAndGet();
queueNumHolder.set(currentQueueNum);
// 排队号=服务号 才能获取锁
while (currentQueueNum != serviceNum.get()) {
// Do nothing
}
System.out.println(Thread.currentThread().getName() + "上锁");
}
public void unlock() {
// 释放锁,从ThreadLocal中获取当前线程的排队号
Integer currentQueueNum = queueNumHolder.get();
// 服务号+1
serviceNum.compareAndSet(currentQueueNum, currentQueueNum + 1);
System.out.println(Thread.currentThread().getName() + "解锁");
}
}
7、死锁
两个 或 两个以上的线程在执行的过程中,因争夺资源产生的一种互相等待的现象叫做死锁。产生死锁后,线程会一直等待,无法执行,因此这种现象是我们不希望出现的,要尽量避免其发生。
1)死锁的演示
package thread.lock;
public class DeadLock {
public static void main(String[] args) {
// 条件1:多个锁对象
Object obj1 = new Object();
Object obj2 = new Object();
// 条件2:多个线程
new Thread(() -> {
while (true) {
// 条件3:锁的嵌套
synchronized (obj1) {
System.out.println("A线程拿到了锁1");
synchronized (obj2) {
System.out.println("A线程拿到了锁2");
}
}
}
}, "A").start();
new Thread(() -> {
// 死循环增大死锁概率
while (true) {
synchronized (obj2) {
System.out.println("B线程拿到了锁2");
synchronized (obj1) {
System.out.println("B线程拿到了锁1");
}
}
}
}, "B").start();
}
}
2)死锁的排查
使用 jps -l
命令定位进程号
$ jps -l
24443 lock.DeadLock
使用 jstack 进程号
命令查看进行信息
3)如何避免死锁
死锁产生的3个条件:
- 有多把锁
- 有多个线程
- 有同步代码块嵌套
死锁的避免:只要打破三个条件中的一个,就无法形成死锁。
六、乐观锁 - CAS
1、什么是 CAS ?
CAS是英文单词Compare And Swap
的缩写,翻译过来就是比较并替换
。
Java中的CAS是通过Unsafe
类中的native
方法实现的
- 无法直接调用,但可以通过一些封装好的类实现,如原子操作类,底层调用的就是
Unsafe
类中的方法。 - 底层实现是通过一条CPU指令
cmpxchg
,用于在内存位置上执行原子比较和交换操作。
public final class Unsafe {
public final native boolean compareAndSwapObject(参数);
public final native boolean compareAndSwapInt(参数);
public final native boolean compareAndSwapLong(参数);
}
2、CAS 的原理
【原理】
CAS有三个操作数:
V 内存值
A 预期值(旧值)
B 要修改的新值
当多个线程尝试使用CAS同时更新一个变量时:
只有当 内存值V == 预期值A 时,才可以将 内存值V修改为 新值B
然后失败的线程不会挂起,而是被告知失败,可以 继续尝试(自旋) 或 不再重试!
【自旋重试】
假设有两个线程:线程A和线程B,同时对内存值进行自增!内存值刚开始是0,旧值也是0。
1. 线程A先进入,此时 内存值0 == 旧值0,所以可以将内存值修改为新值1。
2. 线程A结束后,线程B进入,对内存值进行自增:
但是 内存值1 != 旧值0,所以更新失败
只能 将旧值0 更新为 内存值1,并告知下一次再试试!
3. 线程B自旋重试,此时 内存值1 == 旧值1,所以可以将内存值修改为新值2。
所以,哪怕没有加锁,也能实现线程安全。
【不再重试】
同样的,我们举例有两个线程:线程A和线程B,两个线程都要将内存值更新为10。
1. 线程A先进入,此时 内存值0 == 旧值0,所以可以将内存值修改为新值10。
2. 线程A结束后,线程B进入,要将内存值修改为10
但是 内存值10 != 旧值0,所以更新失败
只能 将旧值0 更新为 内存值10
3. 同时被告知线程B下次不用重试了。(因为我们的目的是将内存值更新为10,显然我们的目的已经完成了)
3、CAS 的使用 - 原子操作类
原子操作类,指的是java.util.concurrent.atomic
包下,一系列以Atomic
开头的包装类。
如AtomicBoolean
,AtomicInteger
,AtomicLong
分别用于Boolean
,Integer
,Long
类型的原子性操作。
public class AtomicDemo {
public static void main(String[] args) {
// 原子操作类 AtomicInteger
AtomicInteger atomicInteger = new AtomicInteger(2020);
// 如果是期望值2020,更新为2021,否则就不更新
System.out.println(atomicInteger.compareAndSet(2020, 2021)); // true
System.out.println(atomicInteger.get()); // 2021
System.out.println(atomicInteger.compareAndSet(2020, 2021)); // false
System.out.println(atomicInteger.get()); // 2021
}
}
Atomic操作的底层实现正是利用的CAS机制。
public class AtomicInteger extends Number implements java.io.Serializable {
public final int getAndIncrement() {
return unsafe.getAndAddInt(this, valueOffset, 1);
}
}
public final class Unsafe {
public final int getAndAddInt(Object var1, long var2, int var4) {
int var5;
// 自旋操作
do {
var5 = this.getIntVolatile(var1, var2);
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
return var5;
}
public final native boolean compareAndSwapInt(Object o, long offset, int expected, int x);
}
4、什么是 ABA问题 ?
ABA问题指的是,在CAS操作期间,共享变量的值从A变为B,然后再从B又变回A,导致进行CAS操作的线程可能会错误地认为共享变量的值没有发生变化,从而CAS操作成功。
- 线程1取出共享变量的值A
- 这时发生线程切换,共享变量的值被其他线程从A改为B,然后又从B改回A。
- 然后线程1进行CAS操作,共享变量的值还是A,Compare成功,CAS成功。
虽然线程1的CAS操作成功了,但是线程1并不知道其他线程对共享变量的操作,这就是ABA问题。
5、ABA问题 - 举例
小明有余额100元,在提款机提取了50元,因为提款机问题,有两个线程,同时把余额从100变为50。
- 线程1(扣款):查询到余额为100,此时切换到其他线程
- 线程2(扣款):查询到余额为100,预期值100,执行扣款,余额变为50
此时,小红给小明汇款50元:
- 线程3(存款):查询到余额为50,执行存款,余额变为100
- 线程1(扣款):切换为线程1,预期值100,此时余额正好也是100,扣款成功,余额变为50
实际余额应该是 100-50+50=100元,但最终变成了 100-50+50-50=50元,这就是ABA问题带来的风险。
public class AbaProblemDemo {
public static void main(String[] args) throws InterruptedException {
// 账户余额100
AtomicInteger account = new AtomicInteger(100);
int startValue = account.get();
System.out.println("账户初始余额:" + startValue);
// 线程1:扣款50(最后执行)
new Thread(() -> {
try {
TimeUnit.MILLISECONDS.sleep(200);
account.compareAndSet(startValue, startValue - 50);
System.out.println(Thread.currentThread().getName() + ":扣款50");
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}, "线程1").start();
// 线程2:扣款50(首先执行)
new Thread(() -> {
account.compareAndSet(startValue, startValue - 50);
System.out.println(Thread.currentThread().getName() + ":扣款50");
}, "线程2").start();
// 线程3:存款50(中间执行)
new Thread(() -> {
try {
TimeUnit.MILLISECONDS.sleep(100);
account.getAndUpdate(v -> v + 50);
System.out.println(Thread.currentThread().getName() + ":存款50");
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}, "线程3").start();
TimeUnit.MILLISECONDS.sleep(500);
System.out.println("账户最终余额:" + account.get());
}
}
账户初始余额:100
线程2:扣款50
线程3:存款50
线程1:扣款50
账户最终余额:50
6、ABA问题 - 解决方案
解决方案:Java中提供了两个原子类,为我们提供了版本号(时间戳)的方法解决了该问题!
- 如果不关心引用变量更改了几次,只单纯的关心是否更改过,可以使用
AtomicMarkableReference
- 如果关心引用变量更改了几次,可以使用
AtomicStampedReference
这里演示一下用AtomicStampedReference
解决ABA问题:
public class AbaProblemSolveDemo {
public static void main(String[] args) throws InterruptedException {
// 账户余额100
AtomicStampedReference<Integer> account = new AtomicStampedReference<>(100, 1);
int expectedValue = account.getReference();
int expectedStamp = account.getStamp();
System.out.println("账户初始余额:" + expectedValue);
// 线程1:扣款50(最后执行)
new Thread(() -> {
try {
TimeUnit.MILLISECONDS.sleep(200);
boolean result = account.compareAndSet(expectedValue, expectedValue - 50, expectedStamp, expectedStamp + 1);
System.out.println(Thread.currentThread().getName() + ":扣款50,执行结果:" + result);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}, "线程1").start();
// 线程2:扣款50(首先执行)
new Thread(() -> {
boolean result = account.compareAndSet(expectedValue, expectedValue - 50, expectedStamp, expectedStamp + 1);
System.out.println(Thread.currentThread().getName() + ":扣款50,执行结果:" + result);
}, "线程2").start();
// 线程3:存款50(中间执行)
new Thread(() -> {
try {
TimeUnit.MILLISECONDS.sleep(100);
account.set(account.getReference() + 50, account.getStamp() + 1);
System.out.println(Thread.currentThread().getName() + ":存款50");
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}, "线程3").start();
TimeUnit.MILLISECONDS.sleep(500);
System.out.println("账户最终余额:" + account.getReference());
}
}
账户初始余额:100
线程2:扣款50,执行结果:true
线程3:存款50
线程1:扣款50,执行结果:false
账户最终余额:100
7、CAS 小结
CAS(Compare and Swap)是一种在并发编程中常用的原子操作,具有一些优点和缺点。
优点:
- 原子性: CAS操作是原子性的,要么成功执行并更新变量的值,要么失败并不执行任何操作。这种原子性可以确保多线程环境下的数据一致性。
- 非阻塞: CAS操作是非阻塞的,它不会使线程进入阻塞状态,而是在操作失败时立即返回,让线程继续执行其他操作。这减少了线程的上下文切换和内核态与用户态的切换,提高了性能。
- 无锁: CAS操作是一种无锁的同步机制,它不需要使用锁来保护共享资源,因此避免了锁的竞争和开销,减少了死锁和线程阻塞的可能性。
- 实时性: CAS操作通常具有较好的实时性,因为它不需要等待其他线程的释放锁或者唤醒。
缺点:
- ABA问题: 在上面已经具体介绍了,并且给出了解决方案。
- 循环时间: CAS操作可能会出现自旋等待的情况,当操作失败时,线程会反复尝试CAS操作,直到操作成功或达到最大重试次数。如果CAS操作的竞争较激烈,循环时间过长会消耗CPU资源。
- 只能保证单个变量的原子性: CAS操作只能保证对单个变量的原子性操作,对于复合操作或多个变量之间的操作,需要额外的手段来保证原子性。
综上所述,CAS操作具有原子性、非阻塞和无锁等优点,但也存在ABA问题、自旋等待的缺点,因此在使用时需要综合考虑其优缺点,并根据具体场景选择合适的同步机制。