乐观锁和悲观锁是并发控制的两种不同策略,用于处理多个线程同时访问共享资源的情况。它们的主要区别在于对并发冲突的处理方式。
悲观锁是一种较保守的并发控制策略,它假设在整个事务过程中会发生冲突,因此在访问共享资源之前会先加锁。通过锁定资源,其他线程需要等待锁被释放才能继续访问。悲观锁常用于对共享资源进行长时间占用的场景,如数据库中的表锁和行锁。悲观锁可能会导致性能下降,特别是在高并发情况下,因为它会阻塞其他线程的操作。
乐观锁是一种较乐观的并发控制策略,它假设在整个事务过程中不会发生冲突,因此不会加锁。而是通过在更新共享资源时检查是否有其他线程同时修改该资源。如果没有冲突,则更新成功;如果冲突,则返回用户错误的信息,让用户决定如何去做,需要进行回滚或重新尝试。乐观锁常用于对共享资源进行短时间占用的场景,如线程间的读写操作冲突。乐观锁可以避免锁的开销,提高性能,但在并发冲突较频繁的情况下可能需要频繁的回滚和重试。
注意:Synchronized 初始使用乐观锁策略. 当发现锁竞争比较频繁的时候, 就会自动切换成悲观锁策略.
在初始使用synchronized时,它采用了乐观锁策略。乐观锁的意思是,线程在访问临界区之前,假设没有竞争,并且直接进入临界区执行操作。如果没有发生冲突,那么进程可以快速完成任务。但是,如果发生冲突,即其他线即程正在访问临界区并且获取了锁,当前线程的操作将失败。
当synchronized发现锁竞争很频繁时,就会自动切换到悲观锁策略。即线程在访问临界区之前,假设会发生竞争,并且会先申请锁。如果锁没有被其他线程占用,该线程可以顺利进入临界区执行操作。如果锁已被其他线程占用,当前线程将会被阻塞,直到锁被释放。
通过自动切换策略,synchronized可以根据实际情况调整使用的锁策略,从而在竞争较少时提供较高的并发性能,而在竞争激烈时保证线程安全。
乐观锁的一个重要功能就是要检测出数据是否发生访问冲突. 我们可以引入一个 “版本号” 来解决.
假设我们需要多线程修改 “用户账户余额”.设当前余额为 100. 引入一个版本号 version, 初始值为 1. 并且我们规定 "提交版本必须大于记录当前版本才能执行更新余额
2) 线程 A 操作的过程中并从其帐户余额中扣除 50( 100-50 ),线程 B 从其帐户余额中扣除 20( 100-20 );
3) 线程 A 完成修改工作,将数据版本号加1( version=2 ),连同帐户扣除后余额( balance=50),写回到内存中;
4) 线程 B 完成了操作,也将版本号加1( version=2 )试图向内存中提交数据( balance=80),但此时比对版本发现,操作员 B 提交的数据版本号为 2 ,数据库记录的当前版本也为 2 ,不满足 “提交版本必须大于记录当前版本才能执行更新“ 的乐观锁策略。就认为这次操作失败.
一个线程对于数据的访问, 主要存在两种操作: 读数据 和 写数据.
读写锁就是把读操作和写操作区分对待.
读写锁允许多个线程同时读取共享资源,但只允许一个线程写入共享资源。读写锁可以提高并发性能,使得多个线程可以同时读取数据,而在写操作时保持独占性,保证数据的一致性和完整性。
读写锁由两个部分组成:读锁和写锁。在读锁下,多个线程可以同时获取读锁,读取共享资源没有互斥的限制。而在写锁下,只有一个线程可以获取写锁,其他线程无法获取读锁或写锁,保证了写操作的原子性和独占性。
读写锁的特点如下:
读写锁适用于读多写少的场景,可以有效地提高系统的并发性能。对于读操作比写操作频繁的情况,使用读写锁可以减少线程争抢和等待的时间,提高系统的响应速度。
Java 标准库提供了 ReentrantReadWriteLock 类, 实现了读写
锁.
其中,
注意:Synchronized 不是读写锁.
首先我们要知道:锁的核心特性 “原子性”, 这样的机制追根溯源是 CPU 这样的硬件设备提供的.
注意, synchronized 并不仅仅是对 mutex 进行封装, 在 synchronized 内部还做了很多其他的工作
重量级锁和轻量级锁是Java中用于实现同步的两种不同机制。它们的主要区别在于锁的获取和释放的开销。
重量级锁(Heavyweight Lock):
轻量级锁(Lightweight Lock):
如何理解用户态 vs 内核态: 想象去银行办业务. 在窗口外, 自己做, 这是用户态. 用户态的时间成本是比较可控的. 在窗口内,
工作人员做, 这是内核态. 内核态的时间成本是不太可控的. 如果办业务的时候反复和工作人员沟通, 还需要重新排队, 这时效率是很低的.
总结:重量级锁和轻量级锁是Java中用于实现同步的两种不同机制,主要区别在于锁的获取和释放的开销。重量级锁使用操作系统的互斥量实现,获取和释放需要涉及用户态和内核态之间的切换,适用于多个线程访问一个共享资源且访问时间较长的情况。轻量级锁采用乐观锁策略,使用CAS操作来尝试获取锁,如果竞争激烈则会膨胀为重量级锁,适用于多个线程访问一个共享资源且访问时间较短的情况。
注意:synchronized 开始是一个轻量级锁. 如果锁冲突比较严重, 就会变成重量级锁.
自旋锁和挂起等待锁是多线程编程中常用的两种锁策略,用于解决线程之间的竞争条件。
自旋锁适用于锁竞争激烈但等待锁时间较短的情况。好处是线程不会进入阻塞状态,避免了线程切换的开销,但同时也会占用CPU资源。
挂起等待锁适用于锁竞争不激烈或等待锁时间较长的情况。它可以有效地减少CPU资源的使用,但也引入了线程切换和上下文切换的开销。
理解自旋锁和挂起等待锁 :
想象一下, 去追求一个女神. 当男生向女神表白后, 女神说: 你是个好人, 但是我有男朋友了
自旋锁是一种典型的 轻量级锁 的实现方式.
synchronized 中的轻量级锁策略大概率就是通过自旋锁的方式实现的.
假设三个线程 A, B, C. A 先尝试获取锁, 获取成功. 然后 B 再尝试获取锁, 获取失败, 阻塞等待; 然后C 也尝试获取锁, C 也获取失败, 也阻塞等待.当线程 A 释放锁的时候, 会发生啥呢?
注意:
操作系统内部的线程调度就可以视为是随机的. 如果不做任何额外的限制, 锁就是非公平锁. 如果要想实现公平锁, 就需要依赖额外的数据结构,来记录线程们的先后顺序.
公平锁和非公平锁没有好坏之分, 关键还是看适用场景.
synchronized 是非公平锁.
可重入锁的字面意思是“可以重新进入的锁”,即允许同一个线程多次获取同一把锁。
比如一个递归函数里有加锁操作,递归过程中这个锁会阻塞自己吗?如果不会,那么这个锁就是可重入锁(因为这个原因可重入锁也叫做递归锁)。
Java里只要以Reentrant开头命名的锁都是可重入锁,而且JDK提供的所有现成的Lock实现类,包括synchronized关键字锁都是可重入的。
而 Linux 系统提供的 mutex 是不可重入锁.
CAS(Compare and Swap)是一种并发算法,用于解决多线程环境下的原子性操作问题。它是一种乐观锁的实现方式,通过比较共享变量的当前值与期望值是否相等来确定是否进行更新操作。
CAS操作包含三个参数:共享变量的内存地址、期望值和新值。它的执行步骤如下:
CAS操作是原子性的,它不需要使用锁来保护共享变量,因此减少了锁的开销。同时,CAS操作的执行是非阻塞的,没有线程被挂起,增加了系统的并发性能。
然而,CAS操作也存在一些限制:
为了解决ABA问题,通常使用版本号或标记位来标识共享变量的修改次数。每次修改时都会对版本号进行更新,即使值没有实际变化,也能保证CAS操作的正确性。
标准库中提供了 java.util.concurrent.atomic 包, 里面的类都是基于这种方式来实现的,典型的就是 AtomicInteger 类. 其中的 getAndIncrement 相当于 i++ 操作.
AtomicInteger atomicInteger = new AtomicInteger(0);
// 相当于 i++
atomicInteger.getAndIncrement();
伪代码实现:
class AtomicInteger {
private int value;
public int getAndIncrement() {
int oldValue = value;
while ( CAS(value, oldValue, oldValue+1) != true) {
oldValue = value;
}
return oldValue;
}
}
CAS 操作有三个参数:目标值、期望值和新值。它的作用是比较目标值和期望值是否相等,如果相等,则将目标值设为新值。CAS 操作是原子的,即在执行过程中不会被其他线程干扰。
如果CAS操作返回true,表示成功更新了value的值,否则表示在CAS操作过程中,有其他线程修改了value的值,需要重新获取旧值并再次尝试CAS操作。
在代码中,‘CAS(value, oldValue, oldValue+1)’ 的意思是:将当前的 value 和 oldValue 进行比较,如果相等,则将 value 的值设为 oldValue+1。如果不相等,则循环继续执行直到比较成功。
这段代码的目的是实现线程安全的自增操作,即保证在多线程环境下每次调用 getAndIncrement 方法时 value 的值都会自增,并确保不会发生竞态条件(race condition)问题。
假设两个线程同时调用 getAndIncrement
4) 线程2 接下来第二次执行 CAS, 此时 oldValue 和 value 相同, 于是直接执行赋值操作.
5) 线程1 和 线程2 返回各自的 oldValue 的值即可.
通过形如上述代码就可以实现一个原子类. 不需要使用重量级锁, 就可以高效的完成多线程的自增操作.
自旋锁伪代码:
public class SpinLock {
private Thread owner = null;
public void lock(){
// 通过 CAS 看当前锁是否被某个线程持有.
// 如果这个锁已经被别的线程持有, 那么就自旋等待.
// 如果这个锁没有被别的线程持有, 那么就把 owner 设为当前尝试加锁的线程.
while(!CAS(this.owner, null, Thread.currentThread())){
}
}
public void unlock (){
this.owner = null;
}
}
ABA 的问题:
假设存在两个线程 t1 和 t2. 有一个共享变量 num, 初始值为 A,接下来, 线程 t1 想使用 CAS 把 num 值改成 Z, 那么就需要:
但是, 在 t1 执行这两个操作之间, t2 线程可能把 num 的值从 A 改成了 B, 又从 B 改成了 A
线程 t1 的 CAS 是期望 num 不变就修改. 但是 num 的值已经被 t2 给改了. 只不过又改成 A 了. 这个时候 t1 究竟是否要更新 num 的值为 Z 呢?
到这一步, t1 线程无法区分当前这个变量始终是 A, 还是经历了一个变化过程.
这就好比, 我们买一个手机, 无法判定这个手机是刚出厂的新手机, 还是别人用旧了, 又翻新过的手机。
大部分的情况下, t2 线程这样的一个反复横跳改动, 对于 t1 是否修改 num 是没有影响的. 但是不排除一些特殊情况.
假设 滑稽老哥 有 100 存款. 滑稽想从 ATM 取 50 块钱. 取款机创建了两个线程, 并发的来执行 -50操作.
我们期望一个线程执行 -50 成功, 另一个线程 -50 失败.如果使用 CAS 的方式来完成这个扣款过程就可能出现问题.
正常的过程:
异常的过程
解决方案:
给要修改的值, 引入版本号. 在 CAS 比较数据当前值和旧值的同时, 也要比较版本号是否符合预期.
真正修改的时候:
如果当前版本号和读到的版本号相同, 则修改数据, 并把版本号 + 1.
如果当前版本号高于读到的版本号. 就操作失败(认为数据已经被修改过了).
对比理解上面的转账例子:
假设 滑稽老哥 有 100 存款. 滑稽想从 ATM 取 50 块钱. 取款机创建了两个线程, 并发的来执行 -50操作.我们期望一个线程执行 -50 成功, 另一个线程 -50 失败,为了解决 ABA 问题, 给余额搭配一个版本号, 初始设为 1.
结合上面的锁策略, 我们就可以总结出, Synchronized 具有以下特性(只考虑 JDK 1.8):
JVM 将 synchronized 锁分为 无锁、偏向锁、轻量级锁、重量级锁 状态。会根据情况,进行依次升级。
偏向锁不是真的 “加锁”, 只是给对象头中做一个 “偏向锁的标记”, 记录这个锁属于哪个线程
如果后续没有其他线程来竞争该锁, 那么就不用进行其他同步操作了(避免了加锁解锁的开销),如果后续有其他线程来竞争该锁(刚才已经在锁对象中记录了当前锁属于哪个线程了, 很容易识别当前申请锁的线程是不是之前记录的线程), 那就取消原来的偏向锁状态, 进入一般的轻量级锁状态.
偏向锁本质上相当于 “延迟加锁” . 能不加锁就不加锁, 尽量来避免不必要的加锁开销.
但是该做的标记还是得做的, 否则无法区分何时需要真正加锁.
举个栗子理解偏向锁:
假设男主是一个锁, 女主是一个线程. 如果只有这一个线程来使用这个锁, 那么男主女主即使不领证结婚(避免了高成本操作), 也可以一直幸福的生活下去.
但是女配出现了, 也尝试竞争男主, 此时不管领证结婚这个操作成本多高, 女主也势必要把这个动作完成了, 让女配死心.
自旋操作是一直让 CPU 空转, 比较浪费 CPU 资源,因此此处的自旋不会一直持续进行, 而是达到一定的时间/重试次数, 就不再自旋了,也就是所谓的 “自适应”
那么什么是 "锁消除"呢?
有些应用程序的代码中, 用到了 synchronized, 但其实没有在多线程环境下. (例如 StringBuffer)
StringBuffer sb = new StringBuffer();
sb.append("a");
sb.append("b");
sb.append("c");
sb.append("d");
此时每个 append 的调用都会涉及加锁和解锁. 但如果只是在单线程中执行这个代码, 那么这些加锁解锁操作是没有必要的, 白白浪费了一些资源开销.
锁的粒度: 粗和细
实际开发过程中, 使用细粒度锁, 是期望释放锁的时候其他线程能使用锁,但是实际上可能并没有其他线程来抢占这个锁. 这种情况 JVM 就会自动把锁粗化, 避免频繁申请释放锁.
举个栗子理解锁粗化:
滑稽老哥当了领导, 给下属交代工作任务:
方式一:
方式二:
显然, 方式二是更高效的方案.
可以看到, synchronized 的策略是比价复杂的, 在背后做了很多事情, 目的为了让程序猿哪怕啥都不懂,也不至于写出特别慢的程序.
Callable 是一个 interface . 相当于把线程封装了一个 “返回值”. 方便程序猿借助多线程的方式计算结果.
代码示例: 创建线程计算 1 + 2 + 3 + … + 1000, 不使用 Callable 版本:
static class Result {
public int sum = 0;
public Object lock = new Object();
}
public static void main(String[] args) throws InterruptedException {
Result result = new Result();
Thread t = new Thread() {
@Override
public void run() {
int sum = 0;
for (int i = 1; i <= 1000; i++) {
sum += i;
}
synchronized (result.lock) {
result.sum = sum;
result.lock.notify();
}
}
};
t.start();
synchronized (result.lock) {
while (result.sum == 0) {
result.lock.wait();
}
System.out.println(result.sum);
}
}
下面对这段代码进行分析:
首先,我们在主线程中创建了一个Result对象result,并启动了一个新的线程t。在线程t的run方法中,使用循环将1到1000进行累加,并将结果存储到sum变量中。
然后,使用synchronized关键字将对result.lock对象进行同步处理。在主线程中,首先使用while循环来判断result.sum是否为0,如果为0,则调用result.lock对象的wait()方法,将主线程挂起。当线程t完成累加后,通过synchronized同步块获取到result.lock对象的锁,并将sum的值赋给result.sum,并调用result.lock对象的notify()方法唤醒主线程。主线程被唤醒后,输出result.sum的值。
可以看到, 上述代码需要一个辅助类 Result, 还需要使用一系列的加锁和 wait notify 操作, 代码复杂, 容易出错.
代码示例: 创建线程计算 1 + 2 + 3 + … + 1000, 使用 Callable 版本:
Callable<Integer> callable = new Callable<Integer>() {
@Override
public Integer call() throws Exception {
int sum = 0;
for (int i = 1; i <= 1000; i++) {
sum += i;
}
return sum;
}
};
FutureTask<Integer> futureTask = new FutureTask<>(callable);
Thread t = new Thread(futureTask);
t.start();
int result = futureTask.get();
System.out.println(result);
实现思路:
可以看到, 使用 Callable 和 FutureTask 之后, 代码简化了很多, 也不必手动写线程同步代码了.
理解 Callable:
理解 FutureTask:
FutureTask是RunnableFuture接口的一个实现类,并且实现了Runnable接口。RunnableFuture接口继承了Runnable和Future接口。由于FutureTask实现了Runnable接口,因此它可以被提交给线程池执行,同时又可以获取任务的返回结果。