悲观锁
悲观锁:总是假设最坏的情况,每一次去拿数据都默认别人会修改,所以每次拿数据都会上锁,这样就会导致有其他人想要拿数据就会阻塞直到获取到这把锁。
悲观锁表示当前线程对数据操作时,认为一定会有其他线程去更改数据,所以在获取资源时,就直接上锁,让其他线程去阻塞。只有当自己操作完成,更新完数据时,才释放锁的资源,让其他线程去操作。
- synchronized关键字的实现是悲观锁。
悲观锁机制存在的问题:
- 1、多线程竞争下,加锁、解锁都会导致比较多的上下文切换和调度延时,引起性能问题。
- 2、一个线程池有锁会导致其他需要此锁的线程阻塞。
- 3、数据量大时,独占锁回导致效率低下。
- 4、如果一个优先级高的线程等待一个优先级低的线程释放锁会导致优先级导致,引起性能问题。
由于每一次拿到资源都会上锁的机制,所以适用于锁的竞争激烈的情况,因为如果不上锁使用乐观锁实现同步,那么就会浪费大量CPU的资源。所以当写操作远大于读操作时,悲观锁的实现使更好的选择。
悲观锁的实现之一 Synchronized:
被Synchronized修饰的代码块,一旦获得对应的锁,那么就直接上锁,不让其他线程来获取。Synchronized代码块可以直接实现并发编程的三大特性。
- 原子性,Synchronized代码块中的代码必须全部执行完,才会释放锁,让其他线程获取锁。
- 有序性,有序性是一个变量在同一个时刻只允许一个线程对其进行加锁。这就说明了持有一个锁的两个同步块只能串行进行。
- 可见性,synchronized主要对变量读写,或者代码块的执行进行加锁,在未释放锁之前,其它现场无法操作synchronized修饰的变量或者代码块。并且,在释放锁之前会讲修改的变量值写到内存中,其它线程进来时读取到的就是新的值了。
悲观锁优点:
- 由于每一次线程访问资源时都加上了锁,所以不必担心其他线程对数据的更新。这对于多线程冲突的环境下,CPU利用率非常高。
悲观锁的缺点:
- 1、因为每一次访问资源都进行了上锁,所以当普通读的线程要进行访问资源时,也会阻塞,这就导致资源的利用率不高。
- 2、每一次都要获取锁和释放锁之后,另外的线程才可以进行操作, 再获取锁和释放锁。这就会导致线程的切换速度比较慢。
乐观锁
乐观锁:每次那数据都默认认为别人不会修改,所以不会上锁,在更新的时候判断再次期间有没有人修改过这个数据。乐观锁适用于多读的场景,这样可以提高吞吐量。
乐观锁表示当线程对数据进行操作时,认为不会有其他线程来修改数据,所以先不上锁,对数据操作之后,检查此时的数据是否和最开始线程进入时的数据一致,如果一致,那么就可以认为没有其他线程对数据进行修改,现在可以对数据进行更新;如果不一致,那么就再读数据,再操作再检查数据,直到可以更新数据为止。
由于不上锁的机制,适合于锁的竞争并不激烈的情况,所以当对数据的读操作原源大于写操作时,乐观锁是较好的选择
乐观锁的实现方式:
1、CAS(Compare And Swap)
CAS操作包含有三个操作数:
- 需要读写的内存位置(V);
- 进行比较的预期值(A);
- 将写入的新值(B);
CAS操作时有三个参数是必要的(目标地址,期望值,修改值),首先访问目标地址,对比目标地址的当前值是否和期望值一致,如果一致,那么就交换 修改值和目标地址的值。最后在准备刷新数据到内存时,查看当前目标地址的值和期望值是否一致,如果一致,证明没有其他线程修改数据,那么就刷新到主内存中;如果不一致,那么就说明已经有其他线程修改目标地址的值了,那么就再执行CAS操作,直到没有其他线程修改为止。
Java对CAS的支持:
- jdk1.5之后新增java.util.concurrent.atomic包下的AtomicXXX类都是建立在CAS的基础之上,在性能上都会有很大的提升。
- 例如:由于
i++
包含三个独立操作,是非原子操作,因此AtomicInteger对i++
操作进行了封装 - -getAndIncrement()
。
我们通过java.util.concurrent.atomic 的任意一个子类去查看,我们都会发现CompareAndSet方法中的第二个参数,也就是期望值,都是用volatile修饰的。如此以来就可以保证对数据修改的可见性,而CAS执行结束之前会判断当前的数据是否有人修改过,没修改则刷新,修改则重新执行CAS,那么就说明共享变量同一时刻只有一个线程来操作它,所以可以保证有序性,由于concurrent.atomic包下类的操作优势原子操作,所以CAS的实现类的方法都可以看作是一种原子操作。
练习:
- value1 没有进行任何线程安全的保护。
- value2 使用乐观锁 CAS。
- value3 使用悲观锁 synchronized。
- 运行1000个线程,同时对value1,2,3进行自增操作,看下最终的结果。
public class TestDemo {
private static int value1 = 0; //线程不安全
private static AtomicInteger value2 = new AtomicInteger(0);//CAS
private static int value3 = 0; //synchronized
private static synchronized void increaseValue3() {
value3++;
}
public static void main(String[] args) {
for (int i = 0; i < 1000; i++) {
new Thread() {
@Override
public void run() {
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
value1++;
value2.getAndIncrement();
increaseValue3();
}
}.start();
}
//查看活跃线程数目
//活跃数目要大于2,主要原因是idea工具的原因,会有一个monitor线程用于监控
while (Thread.activeCount() > 2) {
Thread.yield();
}
try {
TimeUnit.SECONDS.sleep(10);
System.out.println("线程不安全:" + value1);
System.out.println("乐观锁:" + value2);
System.out.println("悲观锁:" + value3);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
- 运行结果:
线程不安全:950
乐观锁:1000
悲观锁:1000
AtomicInteger
源码分析:
// setup to use Unsafe.compareAndSwapInt for updates
private static final Unsafe unsafe = Unsafe.getUnsafe();
private volatile int value;
/**
* Atomically increments by one the current value.
*
* @return the previous value
*/
public final int getAndIncrement() {
return unsafe.getAndAddInt(this, valueOffset, 1);
}
unsafe
是用来帮助Java访问操作系统底层资源的类,如分配释放、释放内存。volatile int value
保证可见性和有序性。AtomicInteger
中,volatile
和CAS
一起保证线程安全性。
CAS引发的问题:
- 1、ABA问题。当前线程准备操作数据时,当前的数据是A准备更新数据时检查的当前数据是A,那就证明当前资源没有被更新过吗?不能!因为有可能在更新前有两个线程进行了更新,第一个线程把A更新为B,第二个线程把B又更新为A。所以当前不能保证没有其他线程操作过。这种问题CAS察觉不到,可以通过版本号解决。
- 2、CAS只能保证一个共享变量的原子性。多个共享变量使用CAS操作还能够去保证其原子性吗?不能!Java中引入了
AtomicReference
类用于封装多个共享变量进入同一个原子变量。 - 3、自旋CAS如果长时间不成功,给CPU带来很大的执行开销。
2、版本号机制(Version)
共享数据中增加一个字段version,表示该数据的版本号,如果当前的数据发生改变,版本号加1。
Version叫做版本号机制,当获得资源时先不上锁,并且获得当前数据的版本号,操作结束之后,会对比现在数据的版本号是否和最初获取时一致,如果一致,那么就说明没有其他线程修改,此时将版本号进行修改,刷新到内存中;如果不一致,那么当前的操作就会被驳回。
举个例子:
假设数据库中帐户信息表中有一个 version 字段,当前值为 1 ;而当前帐户余额字段( balance )为 $100 。当需要对账户信息表进行更新的时候,需要首先读取version字段。
操作员 A 此时将其读出( version=1 ),并从其帐户余额中扣除 $50( $100-$50 )。在操作员 A 操作的过程中,操作员B 也读入此用户信息( version=1 ),并从其帐户余额中扣除 $20 ( $100-$20 )。
操作员 A 完成了修改工作,提交更新之前会先看数据库的版本和自己读取到的版本是否一致,一致的话,就会将数据版本号加1( version=2 ),连同帐户扣除后余额( balance=$50 ),提交至数据库更新,此时由于提交数据版本大于数据库记录当前版本,数据被更新,数据库记录 version 更新为 2 。
操作员 B 完成了操作,提交更新之前会先看数据库的版本和自己读取到的版本是否一致,但此时比对数据库记录版本时发现,操作员 B 提交的数据版本号为 2 ,而自己读取到的版本号为1 ,不满足 “ 当前最后更新的version与操作员第一次读取的版本号相等 “ 的乐观锁策略,因此,操作员 B 的提交被驳回。
这样,就避免了操作员 B 用基于 version=1 的旧数据修改的结果覆盖操作员A 的操作结果的可能。
- 相关链接:面试必备之乐观锁与悲观锁
乐观锁的优缺点
乐观锁的优点:
- 由于不会对资源上锁,省去了获取锁和释放锁的过程,所以在获取和修改时非常的快速,增加了CPU的吞吐量。使得线程的运行更加快速。
乐观锁的缺点:
- 1、ABA问题,当前线程准备操作数据时,当前的数据是A准备更新数据时检查的当前数据是A,那就证明当前资源没有被更新过么,不能!因为有可能在更新前有两个线程进行了更新,第一个线程把A更新为B,第二个线程把B又更新为A。所以当前不能保证没有其他线程操作过。
- 2、自旋CAS,如果CAS执行失败,那就一直执行,直到成功为止, ,如果当前的线程的执行时间很长,那么这将大大增加CPU的负担。
悲观锁和乐观锁的适用环境
- 悲观锁的使用环境:冲突很严重,写操作远远大于读操作数量时,就要用悲观锁。
- 乐观锁的适用环境:冲突不严重,或者读操作远远大于写操作数量时,就可以使用乐观锁来完成线程安全。
不同角度去考虑:
- 1、悲观锁的使用场景更广泛,乐观锁的使用场景受到了更多的限制,因为CAS只能保证单个变量操作的原子性,当涉及到多个变量时,CAS也是无能为力的,而悲观锁synchronized则可以通过对整个代码块加锁来处理。而版本号机制,如果查询和更新数据分别在不同的数据表,也很难通过简单的版本号去实现;
- 2、考虑竞争激烈程度,当竞争不激烈时,乐观锁更有优势,因为悲观锁会锁住代码块或数据,其他线程无法同时访问,影响并发,而加锁和解锁也需要消耗一定的资源;当竞争激烈时,悲观锁会更有优势,因为乐观锁在执行更新时频繁失败,需要不断重试,浪费CPU资源。