Java并发编程(7) —— 锁的分类概述

一、乐观锁与悲观锁

乐观锁和悲观锁是在数据库中引入的名词,但是在并发包锁里面也引入了类似的思想。

1. 悲观锁

悲观锁总是假设最坏的情况,认为共享资源每次被访问的时候就会出现问题(如共享数据被修改),所以每次在获取资源操作的时候都会上排它锁,这样其他线程想拿到这个资源就会阻塞直到锁被上一个持有者释放。

也就是说,共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程。

像Java中synchronized关键字和并发包中的ReentrantLock类等独占锁就是悲观锁思想的实现。

悲观锁通常多用于写多比较多的情况下(多写场景),避免频繁失败和重试影响性能。

2. 乐观锁

乐观锁总是假设最好的情况,认为共享资源每次被访问的时候不会出现问题,所以在访问资源时不会加排它锁只是在提交修改的时候去验证对应的资源(也就是数据)是否被其它线程修改了(具体方法可以使用版本号机制或 CAS 算法),因此乐观锁效率较高。

在 Java 中java.util.concurrent.atomic包下面的原子变量类就是使用了乐观锁的一种实现方式 CAS 实现的。

乐观锁通常多于写比较少的情况下(多读场景),避免频繁加锁影响性能,大大提升了系统的吞吐量。

(1)乐观锁的两种实现方式

版本号机制

一般是给数据加上一个数据版本号 version 字段,表示数据被修改的次数。当数据被修改时,version 值会加一。当线程 A 要更新数据值时,在读取数据时会读取 version 值,在提交更新时,若刚才读取到的 version 值为当前数据的 version 值相等时才更新,否则更新失败,需要重新读取数据,重新尝试更新。

CAS操作

CAS 是一个原子操作,底层依赖于一条 CPU 的原子指令,全称是 Compare And Swap(比较与交换)。CAS操作时需要比较当前数据值和预期值是否相等,相等才进行更新。当多个线程同时使用 CAS 操作一个变量时,只有一个线程能更新成功,其余失败线程可以选择放弃更新或更新预期值然后进行重试操作。

sun.misc包下的Unsafe类提供了compareAndSwapObject、compareAndSwapInt、compareAndSwapLong方法来实现的对Object、int、long类型的 CAS 操作。

(2)乐观锁存在的问题

ABA 问题

对于CAS操作如果使用不当则可能会导致ABA问题,即数据初始值为A,线程2读取后,其他线程将其修改为了B然后又修改回了A,然后线程2再更新时发现值仍为A,于是更新成功。就是说在进行CAS操作时是无法确定数据在读取后是否被修改过了。这对于CAS操作本身是无问题的,但是在应用逻辑层面可能会产生问题。如下案例:

Java并发编程(7) —— 锁的分类概述_第1张图片
因此我们在使用CAS操作时需要根据应用逻辑进行正确的并发控制。在关心修改过程的应用中,可以在数据中追加版本号或者时间戳。JDK 1.5 以后的 AtomicStampedReference 类就是用来解决 ABA 问题的,其中的 compareAndSet() 方法就是首先检查当前数据中当前引用是否等于预期引用,当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用加该标志的值设置为给定的更新值,读取时根据约定的分隔符进行拆分。

循环时间长开销大

乐观锁虽然不用加锁,但是在CAS 操作经常会用到自旋操作来进行重试,长时间的重试也会给 CPU 带来较大的开销。

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

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

二、公平锁与非公平锁

根据线程获取锁的抢占机制,锁可以分为公平锁和非公平锁,公平锁表示线程获取锁的顺序是按照线程请求锁的时间早晚来决定的,先到先得。而非公平锁则在运行时闯入,也就是先来不一定先得。synchronized为非公平锁。ReentrantLock提供了公平和非公平锁的实现。

在没有公平性需求的前提下尽量使用非公平锁,因为公平锁会带来性能开销。

三、独占锁与共享锁

根据锁只能被单个线程持有还是能被多个线程共同持有,锁可以分为独占锁和共享锁。

  • 独占锁保证任何时候都只有一个线程能得到锁,synchronized、ReentrantLock 就是独占锁。独占锁是一种悲观锁,由于每次访问资源都先加上互斥锁,只允许在同一时间由一个线程读取数据,其他线程必须等待当前线程释放锁才能进行读取。

  • 共享锁则可以同时由多个线程持有,例如ReadWriteLock 读写锁,它允许一个资源可以被多线程同时进行读操作,只有在写操作时才会进行加锁,阻塞其他所有读写操作。共享锁则是一种乐观锁,它放宽了加锁的条件,允许多个线程同时进行读操作。

四、可中断锁和不可中断锁

  • 可中断锁 :获取锁的过程中可以被中断,不需要一直等到获取锁之后 才能进行其他逻辑处理。ReentrantLock 就属于是可中断锁。

  • 不可中断锁 :一旦线程申请了锁,就只能等到拿到锁以后才能进行其他的逻辑处理。 synchronized 就属于是不可中断锁。

五、可重入锁

当一个线程要获取一个被其他线程持有的独占锁时,该线程会被阻塞,那么当一个线程再次获取它自己已经获取的锁时是否会被阻塞呢?如果不被阻塞,那么我们说该锁是可重入的,也就是只要该线程获取了该锁,那么可以无限次数(严格来说是有限次数)地进入被该锁锁住的任意代码,若不可重入又不释放锁就造成死锁了。JDK 提供的所有现成的 Lock 实现类,包括 synchronized 关键字锁都是可重入的。

可重入锁的原理是在锁内部维护一个线程标示和重入计数器,当获取了该锁的线程再次获取锁时发现锁拥有者是自己就无需再次获取 可直接访问资源,并把计数器值加1,释放锁时计数器值减1,为0时(或者达到限制次数)才真正释放锁并唤醒其它阻塞线程来竞争锁。

六、自旋锁

当前线程在获取锁时,如果发现锁已经被其他线程占有,它不马上阻塞自己,而是在不放弃CPU使用权的情况下多次尝试获取(默认次数是10,可以使用-XX:PreBlockSpinsh参数设置该值),很有可能在后面几次尝试中其他线程已经释放了锁。如果尝试指定的次数后仍没有获取到锁则当前线程才会被阻塞挂起,这就称之为自旋锁。由此看来自旋锁就是使用CPU时间换取线程阻塞与调度的开销。synchronized锁在为轻量级锁时,等待锁的线程就是通过自旋来尝试获取。


参考:

  1. 《Java并发编程之美》
  2. Java Guide
  3. 浅谈 ABA 问题

你可能感兴趣的:(#,Java并发编程,java,并发编程,多线程)