龙空技术网

面试官:工作中用过锁么?说说乐观锁和悲观锁的优劣势和使用场景

尚硅谷教育 272

前言:

如今朋友们对“乐观所和悲观锁区别”可能比较关怀,我们都需要分析一些“乐观所和悲观锁区别”的相关知识。那么小编也在网上收集了一些有关“乐观所和悲观锁区别””的相关文章,希望姐妹们能喜欢,姐妹们快快来了解一下吧!

面试官:工作中用过锁么?说说乐观锁和悲观锁的优劣势和使用场景

一、乐观锁

什么是乐观锁

乐观锁是对于数据冲突保持一种乐观态度,操作数据时不会对操作的数据进行加锁(这使得多个任务可以并行的对数据进行操作),只有到数据提交的时候才通过一种机制来验证数据是否存在冲突(一般实现方式是通过加版本号然后进行版本号的对比方式实现);

特点:乐观锁是一种并发类型的锁,其本身不对数据进行加锁而是通过业务实现锁的功能,不对数据进行加锁就意味着允许多个请求同时访问数据,同时也省掉了对数据加锁和解锁的过程,这种方式因为节省了悲观锁加锁的操作,所以可以一定程度的的提高操作的性能,不过在并发非常高的情况下,会导致大量的请求冲突,冲突导致大部分操作无功而返而浪费资源,所以在高并发的场景下,乐观锁的性能却反而不如悲观锁。

版本号机制实现乐观锁

版本号机制实现乐观锁一般是在数据表中加上一个数据版本号 version 字段,表示数据被修改的次数,当数据被修改时,version 值会加一。当线程 A 要更新数据值时,在读取数据的同时也会读取 version 值,在提交更新时,若刚才读取到的 version 值与当前数据库中的 version 值相等时才更新,否则将会重试更新操作,直到更新成功。

这里举一个简单的例子进行说明:

假设数据库中帐户信息表中有一个 version 字段,当前值为 1.0 ;而当前帐户余额字段(balance)为 $10000。

① 操作员 A 此时将其读出(version=1.0),并从其帐户余额中扣除 $8000($10000-$8000)。

② 在操作员 A 操作的过程中,操作员 B 也读入此用户信息(version=1.0),并从其帐户余额中扣除 $5000($10000-$5000)。

③ 操作员 A 完成了修改工作,将数据版本号加一(version=1.1),连同帐户扣除后余额(balance=$8000),提交至数据库更新,此时由于提交数据版本大于数据库记录当前版本,数据被更新,数据库记录 version 更新为1.1 。

④ 操作员 B 试图向数据库提交数据(balance=$5000),但此时比对数据库记录版本时发现,操作员 B 读到的数据版本号为 1.0 ,数据库记录当前版本为 1.1 ,不满足 “ 读取到的 version 值与当前数据库中的 version 值相等 “ 的乐观锁策略,

因此,操作员 B 的提交被驳回。这样就避免了操作员 B 用基于 version=1.0 的旧数据修改的结果覆盖操作员 A 的操作结果的可能。

Java中CAS算法实现乐观锁

CAS:Compare and Swap。比较并交换的意思。CAS操作有3个基本参数:内存地址A,旧值B,新值C。它的作用是将指定内存地址A的内容与所给的旧值B相比,如果相等,则将其内容替换为指令中提供的新值C;如果不等,则更新失败。类似于修改登陆密码的过程。当用户输入的原密码和数据库中存储的原密码相同,才可以将原密码更新为新密码,否则就不能更新。

CAS是解决多线程并发安全问题的一种乐观锁算法。因为它在对共享变量更新之前,会先比较当前值是否与更新前的值一致,如果一致则更新,如果不一致则循环执行(称为自旋锁),直到当前值与更新前的值一致为止,才执行更新。

操作流程:

Unsafe类是CAS的核心类,提供硬件级别的原子操作(目前所有CPU基本都支持硬件级别的CAS操作)。

// 对象、对象的属性地址偏移量、预期值、修改值1

public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);

Unsafe简单demo:

public class UnsafeDemo {

private int number = 0;

public static void main(String[] args) throws NoSuchFieldException, IllegalAccessException {

UnsafeDemo unsafeDemo = new UnsafeDemo();

System.out.println(unsafeDemo.number);// 修改前

unsafeDemo.compareAndSwap(0, 30);

System.out.println(unsafeDemo.number);// 修改后

}

public void compareAndSwap(int oldValue, int newValue){

try {

// 通过反射获取Unsafe类中的theUnsafe对象

Field theUnsafe = Unsafe.class.getDeclaredField("theUnsafe");

theUnsafe.setAccessible(true); // 设置为可见

Unsafe unsafe = (Unsafe) theUnsafe.get(null); // 获取Unsafe对象

// 获取number的偏移量

long offset = unsafe.objectFieldOffset(UnsafeDemo.class.getDeclaredField("number"));

// cas操作

unsafe.compareAndSwapInt(this, offset, oldValue, newValue);

} catch (NoSuchFieldException e) {

e.printStackTrace();

} catch (IllegalAccessException e) {

e.printStackTrace();

}

}

}

乐观锁的缺点

开销大:在并发量比较高的情况下,如果反复尝试更新某个变量,却又一直更新不成功,会给CPU带来较大的压力

ABA问题:当变量从A修改为B再修改回A时,变量值等于期望值A,但是无法判断是否修改,CAS操作在ABA修改后依然成功。

不能保证代码块的原子性:CAS机制所保证的只是一个变量的原子性操作,而不能保证整个代码块的原子性。

二、悲观锁

什么是悲观锁

悲观锁,顾名思义就是总是假设最坏的情况,每次获取数据的时候都认为别人会修改,所以每次在获取数据的时候都会上锁,这样别人想获取这个数据就会阻塞直到它拿到锁后才可以获取(共享资源每次只给一个线程使用,其它线程阻塞,当前线程用完后再把资源转让给其它线程)。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁、表锁、读锁、写锁等,都是在做操作之前先上锁。Java 中 synchronized 和 Lock 等锁就是悲观锁思想的实现。

数据库悲观锁

以MySQL InnoDB为例:

商品t_phone表中有一个字段status,status为1代表商品未售空,status为2代表商品已经售空,那么我们对某个商品下单时必须确保该商品status为1。假设商品的id为1。

如果不采用锁,那么操作方法如下:

//1.查询出商品信息

select status from t_phone where id=1;

//2.根据商品信息生成订单

insert into t_orders (id,phone_id) values (null,1);

//3.修改商品status为2

update t_phone set status=2;

上面这种场景在高并发访问的情况下很可能会出现问题。前面已经提到,只有当phone status为1时才能对该商品下单,上面第一步操作中,查询出来的商品status为1。但是当我们执行第三步Update操作的时候,有可能出现其他人先一步对商品下单把phone status修改为2了,但是我们并不知道数据已经被修改了,这样就可能造成同一个商品被下单2次,使得数据不一致。所以说这种方式是不安全的。

使用悲观锁来实现:

在MySQL数据库中要使用悲观锁就必须关闭MySQL自动提交的属性我们可以使用命令设置MySQL为非autocommit模式:

set autocommit=0;

设置完autocommit后,我们就可以执行我们的正常业务了。具体如下:

//0.开始事务

begin;

//1.查询出商品信息

select status from t_phone where id=1 for update;

//2.根据商品信息生成订单

insert into t_orders (id,phone_id) values (null,1);

//3.修改商品status为2

update t_phone set status=2;

//4.提交事务

commit;

与普通查询不一样的是,我们使用了select…for update的方式,这样就通过数据库实现了悲观锁。此时在t_phone表中,id为1的 那条数据就被我们锁定了,其它的事务必须等本次事务提交之后才能执行。这样我们可以保证当前的数据不会被其它事务修改。

synchronized原理:

方法级的同步是隐式,即无需通过字节码指令来控制的,它实现在方法调用和返回操作之中。JVM可以从方法常量池中的方法表结构(method_info Structure) 中的 ACC_SYNCHRONIZED 访问标志区分一个方法是否同步方法。当方法调用时,调用指令将会 检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先持有monitor(虚拟机规范中用的是管程一词), 然后再执行方法,最后再方法完成(无论是正常完成还是非正常完成)时释放monitor。

代码块的同步是利用monitorenter和monitorexit这两个字节码指令。它们分别位于同步代码块的开始和结束位置。当jvm执行到monitorenter指令时,当前线程试图获取monitor对象的所有权,如果未加锁或者已经被当前线程所持有,就把锁的计数器+1;当执行monitorexit指令时,锁计数器-1;当锁计数器为0时,该锁就被释放了。如果获取monitor对象失败,该线程则会进入阻塞状态,直到其他线程释放锁。

我们先来看这个synchronized作用于同步代码块的代码。

public void run() {

synchronized (this){ //this表示当前对象实例,这里还可以使用Test.class,表示class对象锁

for (int j = 0; j < 10000; j++) {

j++;

}

}

}

查看代码字节码指令如下:

public void run();

descriptor: ()V

flags: ACC_PUBLIC

Code:

stack=2, locals=4, args_size=1

0: aload_0

1: dup

2: astore_1

3: monitorenter //进入同步代码块的指令

4: iconst_0

5: istore_2

6: iload_2

7: sipush 10000

10: if_icmpge 22

13: iinc 2, 1

16: iinc 2, 1

19: goto 6

22: aload_1

23: monitorexit //结束同步代码块的指令

24: goto 32

27: astore_3

28: aload_1

29: monitorexit //遇到异常时执行的指令

30: aload_3

31: athrow

32: return

synchronized作用于同步方法的代码:

public synchronized void run() {

for (int j = 0; j < 10000; j++) {

j++;

}

}

查看代码字节码指令如下:

public synchronized void run();

descriptor: ()V

flags: ACC_PUBLIC, ACC_SYNCHRONIZED

Code:

stack=2, locals=2, args_size=1

0: iconst_0

1: istore_1

2: iload_1

3: sipush 10000

6: if_icmpge 18

9: iinc 1, 1

12: iinc 1, 1

15: goto 2

18: return

LineNumberTable:

line 7: 0

line 8: 9

line 7: 12

line 11: 18

StackMapTable: number_of_entries = 2

frame_type = 252 /* append */

offset_delta = 2

locals = [ int ]

frame_type = 250 /* chop */

offset_delta = 15

字节码中并没有monitorenter,monitorexit,而是多了一个ACC_SYNCHRONIZED标记。方法被ACC_SYNCHRONIZED,线程必须先持有monitor才能进入方法,方法执行完毕,线程释放monitor。

看下面这段儿代码,回答后面的8个问题:

class Phone {

public synchronized void sendSMS() throws Exception {

//TimeUnit.SECONDS.sleep(4);

System.out.println("------sendSMS");

}

public synchronized void sendEmail() throws Exception {

System.out.println("------sendEmail");

}

public void getHello() {

System.out.println("------getHello");

}

}

public class Lock_8 {

public static void main(String[] args) throws Exception {

Phone phone = new Phone();

Phone phone2 = new Phone();

new Thread(() -> {

try {

phone.sendSMS();

} catch (Exception e) {

e.printStackTrace();

}

}, "AA").start();

Thread.sleep(100);

new Thread(() -> {

try {

phone.sendEmail();

//phone.getHello();

//phone2.sendEmail();

} catch (Exception e) {

e.printStackTrace();

}

}, "BB").start();

}

}

多线程的8个问题:

标准访问,先打印短信还是邮件停4秒在短信方法内,先打印短信还是邮件普通的hello方法,是先打短信还是hello现在有两部手机,先打印短信还是邮件两个静态同步方法,1部手机,先打印短信还是邮件两个静态同步方法,2部手机,先打印短信还是邮件1个静态同步方法,1个普通同步方法,1部手机,先打印短信还是邮件1个静态同步方法,1个普通同步方法,2部手机,先打印短信还是邮件

总结:

synchronized实现同步的基础:Java中的每一个对象都可以作为锁。具体表现为以下3种形式:

对于普通同步方法,锁是当前实例对象。对于静态同步方法,锁是当前类的Class对象。对于同步方法块,锁是synchonized括号里配置的对象

当一个线程试图访问同步代码块时,它首先必须得到锁,退出或抛出异常时必须释放锁。

也就是说:

如果一个实例对象的非静态同步方法获取锁后,该实例对象的其他非静态同步方法必须等待获取锁的方法释放锁后才能获取锁;可是不同实例对象的非静态同步方法因为用的是不同对象的锁,所以毋须等待其他实例对象的非静态同步方法释放锁,就可以获取自己的锁。

所有的静态同步方法用的是同一把锁——类对象本身。不管是不是同一个实例对象,只要是一个类的对象,一旦一个静态同步方法获取锁之后,其他对象的静态同步方法,都必须等待该方法释放锁之后,才能获取锁。

而静态同步方法(Class对象锁)与非静态同步方法(实例对象锁)之间是不会有竞态条件的。

Lock原理

Lock的存储结构:一个int类型状态值(用于锁的状态变更),一个双向链表(用于存储等待中的线程)

Lock获取锁的过程:本质上是通过CAS来获取状态值修改,如果当场没获取到,会将该线程放在线程等待链表中。

lock.lock();

// 上锁:上锁的本质 就是将 共享变量 state 由 0 ->1

final void lock() {

// CAS 比较交换,上锁!

if (compareAndSetState(0, 1))

// 设置拥有锁的线程!

setExclusiveOwnerThread(Thread.currentThread());

else

acquire(1);

}

// 设置拥有锁的线程!

protected final void setExclusiveOwnerThread(Thread thread) {

exclusiveOwnerThread = thread;

}

// 如果比较失败!arg = 1

public final void acquire(int arg) {

if (!tryAcquire(arg) &&

acquireQueued(addWaiter(Node.EXCLUSIVE), arg))

selfInterrupt();

}

// acquires = 1

protected final boolean tryAcquire(int acquires) {

return nonfairTryAcquire(acquires);

}

private volatile int state;

final boolean nonfairTryAcquire(int acquires) {

// 获取当前线程

final Thread current = Thread.currentThread();

// 获取资源state

int c = getState();

if (c == 0) {

// 比较交换!state 0 --> 1

if (compareAndSetState(0, acquires)) {

// 设置拥有锁的线程!

setExclusiveOwnerThread(current);

return true;

}

}

// 可重入

else if (current == getExclusiveOwnerThread()) {

// nextc = 当前资源 + 1

int nextc = c + acquires;

if (nextc < 0) // overflow

throw new Error("Maximum lock count exceeded");

// state=nextc

setState(nextc);

return true;

}

return false;

}

final boolean acquireQueued(final Node node, int arg) {

boolean failed = true;

try {

boolean interrupted = false;

for (;;) {

final Node p = node.predecessor();

if (p == head && tryAcquire(arg)) {

setHead(node);

p.next = null; // help GC

failed = false;

return interrupted;

}

if (shouldParkAfterFailedAcquire(p, node) &&

parkAndCheckInterrupt())

interrupted = true;

}

} finally {

if (failed)

cancelAcquire(node);

}

}

Lock释放锁的过程:修改状态值,调整等待链表。

// 解锁:

lock.unlock();

public void unlock() {

sync.release(1);

}

// arg = 1;

public final boolean release(int arg) {

if (tryRelease(arg)) {

Node h = head;

if (h != null && h.waitStatus != 0)

unparkSuccessor(h);

return true;

}

return false;

}

// releases = 1

protected final boolean tryRelease(int releases) {

// 释放锁本质:就是在 state - 1

int c = getState() - releases;

if (Thread.currentThread() != getExclusiveOwnerThread())

throw new IllegalMonitorStateException();

boolean free = false;

if (c == 0) {

free = true;

setExclusiveOwnerThread(null);

}

setState(c);

return free;

}

Lock大量使用CAS+自旋。因此根据CAS特性,Lock建议使用在低锁冲突的情况下。

Lock是一个接口,这里主要有三个实现:ReentrantLock、ReentrantReadWriteLock.ReadLock、ReentrantReadWriteLock.WriteLock。

ReentrantLock的使用:

class Ticket{

private Integer number = 20;

private ReentrantLock lock = new ReentrantLock();

public void sale(){

lock.lock();

if (number <= 0) {

System.out.println("票已售罄!");

lock.unlock();

return;

}

try {

Thread.sleep(200);

number--;

System.out.println(Thread.currentThread().getName() + "买票成功,当前剩余:" + number);

} catch (InterruptedException e) {

e.printStackTrace();

} finally {

lock.unlock();

}

}

}

ReentrantLock和synchronized区别

(1)synchronized是独占锁,加锁和解锁的过程自动进行,易于操作,但不够灵活。ReentrantLock也是独占锁,加锁和解锁的过程需要手动进行,不易操作,但非常灵活。ReentrantLock和synchronized区别

(2)synchronized可重入,因为加锁和解锁自动进行,不必担心最后是否释放锁;ReentrantLock也可重入,但加锁和解锁需要手动进行,且次数需一样,否则其他线程无法获得锁。

(3)synchronized不可响应中断,一个线程获取不到锁就一直等着;ReentrantLock可以响应中断。

三、乐观锁和悲观锁的使用场景

功能限制

乐观锁与悲观锁相比,适用的场景受到了更多的限制,无论是CAS机制还是版本号机制。

比如,CAS机制只能保证单个变量操作的原子性,当涉及到多个变量的时候,CAS机制是无能为力的,而synchronized却可以通过对整个代码块进行加锁处理;再比如,版本号机制如果在查询数据的时候是针对表1,而更新数据的时候是针对表2,也很难通过简单的版本号来实现乐观锁。

竞争激烈程度

在竞争不激烈(出现并发冲突的概率比较小)的场景中,乐观锁更有优势。因为悲观锁会锁住代码块或数据,其他的线程无法同时访问,必须等待上一个线程释放锁才能进入操作,会影响并发的响应速度。另外,加锁和释放锁都需要消耗额外的系统资源,也会影响并发的处理速度。

在竞争激烈(出现并发冲突的概率较大)的场景中,悲观锁则更有优势。因为乐观锁在执行更新的时候,可能会因为数据被反复修改而更新失败,进而不断重试,造成CPU资源的浪费。

乐观锁是否会加锁:

乐观锁本身是不加锁的,只有在更新的时候才会去判断数据是否被其他线程更新了,比如AtomicInteger便是一个例子。但是有时候乐观锁可能会与加锁操作合作,比如MySQL在执行更新数据操作的时候会加上排他锁。因此可以理解为乐观锁本身是不加锁的,只有在更新数据的时候才有可能会加锁。

总结

从上面对两种锁的介绍,我们知道两种锁各有优缺点,我们不可以简单的认为它们哪一种更好 ,像乐观锁适用于写比较少的情况下(多读场景),即实际冲突很少发生的 时候,这样可以省去加锁造成的开销,加大了系统的整个吞吐量。但如果是在多写的情况,一般会经常产生冲突,这就会导致上层应用会不断的进行重试,这样反倒是降低了性能,所以一般在多写的场景下使用悲观锁就比较合适。

标签: #乐观所和悲观锁区别