目录
1. 乐观锁&悲观锁
1.1 乐观锁
1.1.1 乐观锁定义
1.1.2 乐观锁实现(CAS)
1.1.3 CAS应用:AtomicInteger :
1.1.4 CAS缺点(ABA问题)
1.2 悲观锁
1.2.1 悲观锁定义
1.2.2 悲观锁应用
2. 公平锁&非公平锁
3. 读写锁
3.1 读写锁的定义
3.2 读写锁示例
3.3 独占锁
3.4 共享锁
4. 可重入锁
5. 自旋锁&挂起等待锁
1. 乐观锁&悲观锁
1.1 乐观锁
1.1.1 乐观锁定义
乐观锁 认为一般情况下不会出现冲突,所以只会在更新数据的时候才对冲突进行检测,如果发生没有发生冲突直接进行修改,如果发生冲突了不做任何修改,然后把结果返回给用户,让用户自行决定处理。
1.1.2 乐观锁实现(CAS)
CAS(Compare And Swap)比较并替换,CAS 比较并替换的流程是这样的:CAS 中包含了三个操作单位:V(内存值)、A(预期的旧值)、B(新值),比较 V 值和 A 是否相等,如果相等的话则将 V 的值更换成 B,否则就提示用户修改失败,从而实现了 CAS 的机制。
两个线程进行 CAS 操作:
- 内存地址V中,存储着变量10;
- 此时线程1想要把变量的值增加1。对线程1来说,旧的预期值A=10,要修改的新值B=11;
- 在线程1要提交更新之前,另一个线程2抢先一步,把内存地址V中的变量值率先更新成了11。
- 线程1开始提交更新,首先进行A和地址V的实际值比较(Compare),发现A不等于V的实际值(10 != 11),提交失败。
- 线程1重新获取内存地址V的当前值,并重新计算想要修改的新值。此时对线程1来说,A=11,B=12。这个重新尝试的过程被称为自旋;
- 这一次没有其他线程修改V的值,线程1进行比较(Compare),发现 A == V(11 == 11);
- 线程1进行交换(Swap),将V的值修改为B,V=12。
1.1.3 CAS应用:AtomicInteger :
非线程安全代码:
public class CASDemo {
private static int number = 0;
private static final int MAX_COUNT = 100000;
public static void main(String[] args) throws InterruptedException {
// ++
Thread t1 = new Thread(() -> {
for (int i = 0; i < MAX_COUNT; i++) {
number++;
}
});
t1.start();
// --
Thread t2 = new Thread(() -> {
for (int i = 0; i < MAX_COUNT; i++) {
number--;
}
});
t2.start();
t1.join();
t2.join();
System.out.println("最终结果:" + number);
}
}
运行结果:
结果并不是预期的0,是线程不安全的。
线程安全代码(AtomicInteger):
public class CASDemo2 {
private static final int MAX_COUNT = 100000;
private static final AtomicInteger atomicInteger = new AtomicInteger(0);
public static void main(String[] args) throws InterruptedException {
// ++
Thread t1 = new Thread(() -> {
for (int i = 0; i < MAX_COUNT; i++) {
atomicInteger.getAndIncrement();
}
});
t1.start();
// --
Thread t2 = new Thread(() -> {
for (int i = 0; i < MAX_COUNT; i++) {
atomicInteger.getAndDecrement();
}
});
t2.start();
t1.join();
t2.join();
System.out.println("最终结果:" + atomicInteger.get());
}
}
运行结果:
结果为0,证明使用了AtomicInteger是线程安全的。
1.1.4 CAS缺点(ABA问题)
ABA 转账问题,X 给 Y 转账,系统卡顿点击了两次转账按钮,X 原来是 300,正常是转完账(100元) 还剩下200,第⼀次转账成功之后变成了 200,此时 Z 给 X 转了 100 元,余额又变回了 300,第⼆次CAS 判断(300,300,200)成功,于是又扣了X 100 元,X直接亏了100元。
ABA代码演示:
public class ABADemo1 {
private static final AtomicInteger money = new AtomicInteger(300);
public static void main(String[] args) throws InterruptedException {
// 第一次转账点击按钮(-100)
Thread t1 = new Thread(() -> {
// 先得到余额
int oldMoney = money.get();
// 执行耗时 2s
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
money.compareAndSet(oldMoney, oldMoney - 100);
});
t1.start();
// 第二次点击按钮(-100)(不小心点击的,因为第一次点击完没反应,所以又点了一次)
Thread t2 = new Thread(() -> {
int oldMoney = money.get();
money.compareAndSet(oldMoney, oldMoney - 100);
});
t2.start();
// 给账户 +100
Thread t3 = new Thread(() -> {
int oldMoney = money.get();
// 执行耗时 1s
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
money.compareAndSet(oldMoney, oldMoney + 100);
});
t3.start();
t1.join();
t2.join();
t3.join();
System.out.println("最终余额:" + money.get());
}
}
运行结果:
可以看到,这波属实亏大了!
解决方案(AtomicStampedReference):引入了一个版本号,每次操作后让版本号+1,执行的时候判断版本号和值。
public class ABADemo2 {
private static final AtomicStampedReference<Integer> money = new AtomicStampedReference<>(300, 0);
public static void main(String[] args) throws InterruptedException {
// 第一次转账点击按钮(-100)
Thread t1 = new Thread(() -> {
// 先得到余额
int oldMoney = money.getReference();
// 得到版本号
int version = money.getStamp();
// 执行耗时 2s
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
money.compareAndSet(oldMoney, oldMoney - 100, version, version + 1);
});
t1.start();
// 第二次点击按钮(-100)(不小心点击的,因为第一次点击完没反应,所以又点了一次)
Thread t2 = new Thread(() -> {
int oldMoney = money.getReference();
int version = money.getStamp();
money.compareAndSet(oldMoney, oldMoney - 100, version, version + 1);
});
t2.start();
// 给账户 +100
Thread t3 = new Thread(() -> {
int oldMoney = money.getReference();
int version = money.getStamp();
// 执行耗时 1s
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
money.compareAndSet(oldMoney, oldMoney + 100, version, version + 1);
});
t3.start();
t1.join();
t2.join();
t3.join();
System.out.println("最终余额:" + money.getReference());
}
}
运行结果:
成功解决ABA问题。
1.2 悲观锁
1.2.1 悲观锁定义
总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。
1.2.2 悲观锁应用
之前介绍过的 synchronized、Lock 都是悲观锁。
详情请看:Java多线程(三):线程安全问题与解决方法_澄白易的博客-CSDN博客
2. 公平锁&非公平锁
假设三个线程 A, B, C。 A 先尝试获取锁, 获取成功. 然后 B 再尝试获取锁, 获取失败, 阻塞等待; 然后C 也尝试获取锁, C 也获取失败, 也阻塞等待.当线程 A 释放锁的时候, 会发生啥呢?
- 公平锁: 遵守 "先来后到"。B 比 C 先来的,当 A 释放锁的之后, B 就能先于 C 获取到锁;
- 非公平锁: 不遵守 "先来后到"。 B 和 C 都有可能获取到锁。
注意:
- 操作系统内部的线程调度就可以视为是随机的. 如果不做任何额外的限制, 锁就是非公平锁. 如果要想实现公平锁, 就需要依赖额外的数据结构, 来记录线程们的先后顺序;
- 公平锁和非公平锁没有好坏之分, 关键还是看适用场景。
3. 读写锁
3.1 读写锁的定义
读写锁(Readers-Writer Lock)顾名思义就是⼀把锁分为两部分:读锁和写锁,其中读锁允许多个线程同时获得,因为读操作本身是线程安全的,而写锁则是互斥锁,不允许多个线程同时获得(写锁),并且写操作和读操作也是互斥的,总结来说,读写锁的特点是:读读不互斥、读写互斥、写写互斥。
Java 标准库提供了 ReentrantReadWriteLock 类, 实现了读写锁:
- ReentrantReadWriteLock.ReadLock 类表示⼀个读锁. 这个对象提供了 lock / unlock 方法进行加锁解锁;
- ReentrantReadWriteLock.WriteLock 类表示⼀个写锁. 这个对象也提供了 lock / unlock方法进行加锁解锁.
3.2 读写锁示例
public class ReadWriteLockDemo {
public static void main(String[] args) {
// 创建读写锁
final ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock();
// 创建读锁
final ReentrantReadWriteLock.ReadLock readLock = readWriteLock.readLock();
// 创建写锁
final ReentrantReadWriteLock.WriteLock writeLock = readWriteLock.writeLock();
// 线程池
ThreadPoolExecutor executor = new ThreadPoolExecutor(5, 5, 0, TimeUnit.SECONDS,
new LinkedBlockingDeque<>(100));
// 启动新线程执行读任务
executor.submit(() -> {
// 加锁操作
readLock.lock();
try {
System.out.println("执行读锁1:" + LocalDateTime.now());
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
readLock.unlock();
}
});
// 启动新线程执行读任务2
executor.submit(() -> {
readLock.lock();
try {
System.out.println("执行读锁2:" + LocalDateTime.now());
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
readLock.unlock();
}
});
// 启动新线程执行写操作
executor.submit(() -> {
writeLock.lock();
try {
System.out.println("执行写锁1:" + LocalDateTime.now());
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
writeLock.unlock();
}
});
// 启动新线程执行写操作2
executor.submit(() -> {
writeLock.lock();
try {
System.out.println("执行写锁2:" + LocalDateTime.now());
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
writeLock.unlock();
}
});
executor.shutdown();
}
}
运行结果:
可以观察到读写锁的特点:读读不互斥、读写互斥、写写互斥。
3.3 独占锁
独占锁是指任何时候都只有⼀个线程能执行资源操作,synchronized、Lock都是独占锁。
3.4 共享锁
共享锁指定是可以同时被多个线程读取,但只能被一个线程修改。比如 Java 中的 ReentrantReadWriteLock 就是共享锁的实现方式,它允许⼀个线程进行写操作,允许多个线程读操作。
4. 可重入锁
可重入锁的字面意思是“可以重新进入的锁”,即允许同一个线程多次获取同一把锁。比如一个递归函数里有加锁操作,递归过程中这个锁会阻塞自己吗?如果不会,那么这个锁就是可重入锁(因为这个原因可重入锁也叫做递归锁)。
注意 :Java里只要以Reentrant开头命名的锁都是可重入锁,而且JDK提供的所有现成的Lock实现类,包括synchronized关键字锁都是可重入的。
5. 自旋锁&挂起等待锁
想象一下, 去追求一个女神. 当男生向女神表白后, 女神说: 你是个好人, 但是我有男朋友了~~
- 挂起等待锁: 陷入沉沦不能自拔.... 过了很久很久之后, 突然女神发来消息, "咱俩要不试试?" (注意,这个很长的时间间隔里, 女神可能已经换了好几个男票了).
- 自旋锁: 死皮赖脸坚韧不拔. 仍然每天持续的和女神说早安晚安. 一旦女神和上一任分手, 那么就能 立刻抓住机会上位。
自旋锁是指尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁,这样的好处是减少线程上下文切换的消耗,缺点是循环会消耗 CPU。
自旋锁是一种典型的 轻量级锁 的实现方式.
- 优点: 没有放弃 CPU, 不涉及线程阻塞和调度, 一旦锁被释放, 就能第一时间获取到锁.
- 缺点: 如果锁被其他线程持有的时间比较久, 那么就会持续的消耗 CPU 资源. (而挂起等待的时候是不消耗 CPU 的).