并发编程之几种锁的理解

乐观锁和悲观锁是在数据库中引入的名词,然后在并发包里面也引用了类似的思想,这两种锁机制是并发控制主要采用的技术手段

一、乐观锁与悲观锁

乐观锁和悲观锁是在数据库中引入的名词,然后在并发包里面也引用了类似的思想,这两种锁机制是并发控制主要采用的技术手段

悲观锁

指的是对数据被外界修改保持保守态度,认为数据很容易就会被其他线程修改,所以在数据被处理前先对数据进行加锁,并在整个数据处理过程中,使数据保持锁定状态,java中synchronized和ReentrantLock等独占锁就是悲观锁思想的实现。。悲观锁的实现往往依靠数据库提供的锁机制,即在数据库中,在对数据记录操作前给记录加上排它锁。如果获取锁失败,就说明数据正在被其他的线程修改,当前线程则需要等待或者抛出异常。如果获取锁成功,则记录进行操作,然后提交事务后释放排他锁。

乐观锁

是相对悲观锁来说的,它认为数据一般不会造成冲突,所以在访问记录前不会加排它锁,而是在进行数据提交更新时,才会正式对数据冲突与否进行检测,可以使用版本号机制和CAS算法实现。乐观锁适用于多读的应用类型,这样可以提高吞吐量

使用场景

任何离开业务场景来说使用哪种锁好哪种锁不好都是耍流氓,比如乐观锁适用于写比较少的情况下(多读场景),因为多读场景冲突很少发生,这样可以省去了锁的开销,加大了系统的整个吞吐量,而如果是多写的场景下,这种情况下就比较容易发生冲突,就会导致上层应用会不断的进行retry,反而降低了系统的性能,因此**悲观锁适合多写的情况下

1、乐观锁常见实现方式

1.1 版本号机制

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

1.2 CAS算法

compare and swap(比较与交换),是一种有名的无锁算法。无锁编程,即不使用锁的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量的同步,所以也叫非阻塞同步(Non-blocking Synchronization)。CAS算法涉及到三个操作数

  • 需要读写的内存值 V
  • 进行比较的值 A
  • 拟写入的新值 B

当且仅当 V 的值等于 A时,CAS通过原子方式用新值B来更新V的值,否则不会执行任何操作(比较和替换是一个原子操作)。一般情况下是一个自旋操作,即不断的重试

2、乐观锁的缺点

2.1 ABA问题

如果一个变量V初次读取的时候是A值,并且在准备赋值的时候检查到它仍然是A值,那我们就能说明它的值没有被其他线程修改过了吗?很明显是不能的,因为在这段时间它的值可能被改为其他值,然后又改回A,那CAS操作就会误认为它从来没有被修改过。这个问题被称为CAS操作的 "ABA"问题。

JDK 1.5 以后的 AtomicStampedReference 类就提供了此种能力,其中的 compareAndSet 方法就是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。

2.2 循环时间开销大

自旋CAS(也就是不成功就一直循环执行直到成功)如果长时间不成功,会给CPU带来非常大的执行开销。 如果JVM能支持处理器提供的pause指令那么效率会有一定的提升,pause指令有两个作用,第一它可以延迟流水线执行指令(de-pipeline),使CPU不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零。第二它可以避免在退出循环的时候因内存顺序冲突(memory order violation)而引起CPU流水线被清空(CPU pipeline flush),从而提高CPU的执行效率。

2.3 只能保证一个共享变量的原子操作

CAS 只对单个共享变量有效,当操作涉及跨多个共享变量时 CAS 无效。但是从 JDK 1.5开始,提供了AtomicReference类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行 CAS 操作.所以我们可以使用锁或者利用AtomicReference类把多个共享变量合并成一个共享变量来操作。

二、公平锁和非公平锁

根据线程获取锁的抢占机制,锁可以分为公平锁和非公平锁,公平锁表示线程获取锁的顺序是按照线程请求锁的时间早晚来决定的(先来先到,不能插队),而非公平锁则在运行时间闯入,也就是先来不一定先得到锁(可以插队也就是)

实现:ReentrantLock

  • 公平锁:ReentrantLock lock = new ReentrantLock(true)
  • 非公平锁:ReentrantLock lock = new ReentrantLock(false)

注:如果构造函数不传参数,默认是false,也就是非公平锁

举一个例子:

假设A已经持有了锁,这个时候线程B请求锁是将会被挂起。当线程A释放锁后,线程C也来请求获取该锁,如果采用公平锁的方式,那么因为B线程先到的,所以B会先请求到锁,C线程只能等待,而如果是采用非公平锁,那么就不一定是B线程先获取到锁了,可能是C线程插队优先获取到该锁。

三、独占锁和共享锁

根据锁是只能被单个线程持有还是能被多个线程共同持有,锁可以分为独占锁和共享锁。独占锁保证任何时候只能有一个线程能获取到锁,ReentrantLock 就是以独占锁方式实现的。共享锁允许多个线程同时持有,例如ReadWriteLock读写锁,它允许一个资源可以被多线程同时进行读操作

独占锁是一种悲观锁,每次访问资源都先加上互斥锁。共享锁是一种乐观锁,放宽了加锁的条件,允许多个线程同时进行读操作。

四、可重入锁

如果一个线程再次获取它自己已经获取的琐时如果不被阻塞的情况下,我们称这种锁为可重入锁

实际上,synchronized内部锁是可重入锁。可重入锁的原理是在锁内部维护一个标识,用来标识该锁目前被哪个线程占用。当一个线程获取了该锁时,计数器的值会变为1,这个时候其他线程再来获取该锁时会发现锁的所有者不是自己而被阻塞挂起。但是获取了该锁的线程再次获取锁时,发现锁的拥有者是自己,就会把计数值+1,当释放锁后计数器值-1。当计数器变为0时,锁里面的线程标识被重置为null,这个时候被阻塞的线程会被唤醒来竞争获取该锁。

五、自旋锁

Java中的线程跟操作系统中的线程是一一对应的,所以当一个线程获取锁失败后(比如独占锁),会被切换到内核态而被挂起(从用户态切换到内核态)。当线程获取到锁时又从内核态切换到用户态从而唤醒该线程。而从用户态切换到内核态的开销是比较大的,在一定程度上会影响并发性能。自旋锁其实是当前线程在获取锁时,如果发现锁已经被其他线程占有,它不会马上阻塞自己,在不放弃cpu使用权的情况下,多次获取(默认是10,可以使用-XX:PreBlockSpinsh参数设置该值),很有可能在后面几次尝试中其他线程释放了锁。但是如果后面的尝试到了最大的尝试次数后,最终还是要被阻塞挂起,这样就白白尝试了,浪费了CPU时间。由此看来自旋锁是使用CPU时间换取线程阻塞与调度的开销。attachments-2020-10-GuKQGaO75f894404ee7fc.png


  • 发表于 2020-10-16 14:56
  • 阅读 ( 18 )

0 条评论

请先 登录 后评论
NX小编
NX小编

1158 篇文章

作家榜 »

  1. NX小编 1158 文章
  2. 58沈剑 309 文章
  3. 奈学教育 137 文章
  4. 李希沅 | 奈学教育 36 文章
  5. 江帅帅 | 奈学教育 29 文章
  6. 林淮川 | 奈学教育 12 文章
  7. 科技热点 10 文章
  8. 邱鹏超 2 文章