悲观锁的实现方式
悲观锁的实现方式也就是加锁,加锁既可以在代码层面(比如Java中的synchronized
关键字),也可以在数据库层面(比如MySQL中的排他锁)
乐观锁的问题
CAS虽然很高效,但是它也存在三大问题,这里简单说一下:
- ABA问题。CAS需要在操作值的时候检查内存值是否发生变化,没有发生变化才会更新内存值。但是如果内存值原来是A,后来变成了B,然后又变成了A,那么CAS进行检查时会发现值没有发生变化,但是实际上是有变化的。ABA问题的解决思路就是在变量前面添加版本号,每次变量更新的时候都把版本号加一,这样变化过程就从
A-B-A
变成了1A-2B-3A
。JDK从1.5开始提供了AtomicStampedReference类来解决ABA问题,具体操作封装在compareAndSet()中。compareAndSet()首先检查当前引用和当前标志与预期引用和预期标志是否相等,如果都相等,则以原子方式将引用值和标志的值设置为给定的更新值。 - 循环时间长开销大。CAS操作如果长时间不成功,会导致其一直自旋,给CPU带来非常大的开销。
- 只能保证一个共享变量的原子操作。对一个共享变量执行操作时,CAS能够保证原子操作,但是对多个共享变量操作时,CAS是无法保证操作的原子性的。
Java从1.5开始JDK提供了AtomicReference类来保证引用对象之间的原子性,可以把多个变量放在一个对象里来进行CAS操作。但是还是没有使用synchronized方便
二者的适用场景
乐观锁与悲观锁相比,适用的场景受到了更多的限制,无论是CAS机制还是版本号机制。
- 乐观锁的功能有限,比如,CAS机制只能保证单个变量操作的原子性,当涉及到多个变量的时候,CAS机制是无能为力的,而synchronized却可以通过对整个代码块进行加锁处理;再比如,版本号机制如果在查询数据的时候是针对表1,而更新数据的时候是针对表2,也很难通过简单的版本号来实现乐观锁。
- 竞争激烈程度,在竞争不激烈(出现并发冲突的概率比较小)的场景中,乐观锁更有优势。因为悲观锁会锁住代码块或数据,其他的线程无法同时访问,必须等待上一个线程释放锁才能进入操作,会影响并发的响应速度。另外,加锁和释放锁都需要消耗额外的系统资源,也会影响并发的处理速度。在竞争激烈(出现并发冲突的概率较大)的场景中,悲观锁则更有优势。因为乐观锁在执行更新的时候,可能会因为数据被反复修改而更新失败,进而不断重试,造成CPU资源的浪费
总的来说乐观锁可以当做美图秀秀,悲观锁可以当做PS,简单场景乐观锁OK,大量场景下还是会存在问题
自旋锁和非自旋锁
自旋锁(spinlock),是指尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁,这样的好处是减少线程上下文切换的消耗,缺点是循环会消耗CPU,其实和CAS的操作类似,也是乐观锁的一种实现形式。
自旋锁不会使线程状态发生切换,一直处于用户态,即线程一直都是active的;不会使线程进入阻塞状态,减少了不必要的上下文切换,执行速度快
非自旋锁在获取不到锁的时候会进入阻塞状态,从而进入内核态,当获取到锁的时候需要从内核态恢复,需要线程上下文切换。 (线程被阻塞后便进入内核(Linux)调度状态,这个会导致系统在用户态与内核态之间来回切换,严重影响锁的性能)
package com.nuih.lock; import sun.misc.Unsafe; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; /** * 题目:实现一个自旋锁 * 自旋锁好处:循环比较获取直到成功为止,没有类似wait的阻塞 * * 通过cas操作完成自旋锁,A线程先进来调用myLock方法自己持有锁5秒钟, * B随后进来后发现当前有线程持有锁,不是null,所以只能通过自旋等待,直到A释放锁后B随后抢到 */ public class SpinLockDemo { // 原子引用 AtomicReference<Thread> atomicReference = new AtomicReference<>(); public void myLock(){ Thread thread = Thread.currentThread(); System.out.println(thread.getName() + "\t come in "); while (!atomicReference.compareAndSet(null,thread)){ } } public void myUnLock(){ Thread thread = Thread.currentThread(); atomicReference.compareAndSet(thread,null); System.out.println(thread.getName() + "\t invoked myUnLock"); } public static void main(String[] args) throws InterruptedException { SpinLockDemo spinLockDemo = new SpinLockDemo(); new Thread(() -> { spinLockDemo.myLock(); try { TimeUnit.SECONDS.sleep(5); } catch (InterruptedException e) { e.printStackTrace(); } spinLockDemo.myUnLock(); },"A").start(); TimeUnit.SECONDS.sleep(1); new Thread(() -> { spinLockDemo.myLock(); try { TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); } spinLockDemo.myUnLock(); },"B").start(); } }
比较与适用场合
自旋和非自旋之间存在如下差异且因此在不同的场合使用:
- 自旋锁不会进入内核态,避免了线程上下文切换的开销,而非自旋锁会切换内核态
- 自旋等待不能代替阻塞,且先不说对处理器数量的要求,自旋等待本身虽然避免了线程切换的开销,但它是要占用处理器时间的
如果锁被占用的时间很短,自旋等待的效果就会非常好,反之,如果锁被占用的时间很长,那么自旋的线程只会白白消耗处理器资源,而不会做任何有用的工作,反而会带来性能上的浪费。
如果自旋超过了限定的次数仍然没有成功获得锁,就应当使用传统的方式去挂起线程了。自旋次数的默认值是10次
自适应自旋
在JDK 1.6中引入了自适应的自旋锁。自适应意味着自旋的时间不再固定了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。
- 如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而它将允许自旋等待持续相对更长时间,比如100个循环。
- 如果对于某个锁,自旋很少成功获得过,那在以后要获取这个锁时将可能省略掉自旋过程,以避免浪费处理器资源
不过这也仅仅为局部优化,是一种基于预测的优化
公平锁和非公平锁
公平锁是指多个线程按照申请锁的顺序来获取锁。非公平锁是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁。有可能,会造成优先级反转或者饥饿现象。
- Java ReentrantLock而言,通过构造函数指定该锁是否是公平锁,默认是非公平锁。非公平锁的优点在于吞吐量比公平锁大。
- Synchronized也是一种非公平锁。由于其并不像ReentrantLock是通过AQS的来实现线程调度,所以并没有任何办法使其变成公平锁。
二者的实现方式有差异:
- 公平锁的实现:并发环境中,每个线程在获取锁时会先查看此锁维护的等待队列,如果为空,或者当前线程是等待队列的第一个,就占有锁,否则就会加入到等待队列中,以后按照FIFO的规则从队列中取到自己
- 非公平锁的实现:非公平锁,直接尝试占有锁,如果尝试失败,就再采用类似公平锁的那种方式
吞吐量大的情况下还是选择非公平锁
可重入锁(递归锁)
可重入锁(也叫做递归锁)指的是同一线程外层函数获得锁之后,内层递归函数仍然能获取该锁的代码。同一个线程在外层方法获取锁的时候,在进入内层方法会自动获取锁。也就是说,线程可以进入任何一个它已经拥有的锁所同步着的代码快。可重入锁的最大作用是避免死锁
package com.nuih.lock; import java.util.concurrent.TimeUnit; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; class MyPhone implements Runnable { public synchronized void sendMsg(){ System.out.println(Thread.currentThread().getName() + "\t invoked sendMsg"); sendEmail(); } public synchronized void sendEmail(){ System.out.println(Thread.currentThread().getName() + "\t##### invoked sendEmail"); } Lock lock = new ReentrantLock(); @Override public void run() { get(); } public void get(){ lock.lock(); try{ System.out.println(Thread.currentThread().getName() + "\t invoked get"); set(); }finally { lock.unlock(); } } public void set(){ lock.lock(); try{ System.out.println(Thread.currentThread().getName() + "\t###### invoked set"); }finally { lock.unlock(); } } } /** * 可重入锁(也叫做递归锁) * * 指的是同一线程外层函数获得锁之后,内层递归函数仍然能获取该锁的代码, * 在同一个线程在外层方法获取锁的时候,在进入内层方法会自动获取锁。 * * 也就是说,线程可以进入任何一个它已经拥有的锁所同步着的代码快。 * * t1 invoked sendMsg() t1线程在外层方法获取锁的时候 * t1 ##### invoked sendEmail t1在进入内层方法会自动获取锁 * * t2 invoked sendMsg() * t2 ##### invoked sendEmail */ public class ReentrantLockDemo { public static void main(String[] args) throws InterruptedException { MyPhone myPhone = new MyPhone(); new Thread(() -> { myPhone.sendMsg(); },"t1").start(); new Thread(() -> { myPhone.sendMsg(); },"t2").start(); //Synchronized的可重入实现 TimeUnit.SECONDS.sleep(1); System.out.println("\n\n\n\n"); Thread t3 = new Thread(myPhone,"t3"); //ReentrantLock的可重入实现 t3.start(); Thread t4 = new Thread(myPhone,"t4"); t4.start(); } }
在上面代码段中,执行 sendMsg方法需要获得当前对象作为监视器的对象锁,但方法中又调用了 sendEmail的同步方法。
- 如果锁是具有可重入性的话,那么该线程在调用 sendEmail时并不需要再次获得当前对象的锁,可以直接进入 sendEmail方法进行操作。
- 如果锁是不具有可重入性的话,那么该线程在调用 sendEmail前会等待当前对象锁的释放,实际上该对象锁已被当前线程所持有,不可能再次获得。
如果锁是不具有可重入性特点的话,那么线程在调用同步方法、含有锁的方法时就会产生死锁。
独享锁/共享锁
独享锁是指该锁一次只能被一个线程所持有。共享锁是指该锁可被多个线程所持有。对于Java ReentrantLock和Synchronized而言,其是独享锁。但是对于Lock的另一个实现类ReentrantReadWriteLock,其读锁是共享锁,其写锁是独享锁。读锁的共享锁可保证并发读是非常高效的,读写,写读 ,写写的过程是互斥的。独享锁与共享锁也是通过AQS来实现的,通过实现不同的方法,来实现独享或者共享。
上面讲的独享锁/共享锁是一种广义的说法,互斥锁/读写锁就是具体的实现。互斥锁在Java中的具体实现就是ReentrantLock,读写锁在Java中的具体实现就是ReentrantReadWriteLock
package com.nuih.lock; import com.nuih.map.HashMap; import com.nuih.map.Map; import java.util.concurrent.locks.ReadWriteLock; import java.util.concurrent.locks.ReentrantReadWriteLock; class MyCache{ private volatile Map<String,Object> map = new HashMap<>(); final ReadWriteLock readWriteLock = new ReentrantReadWriteLock(); public void put(String key,Object value){ readWriteLock.writeLock().lock(); try{ System.out.println(Thread.currentThread().getName() + "\t 开始写入:" + key); map.put(key,value); System.out.println(Thread.currentThread().getName() + "\t 写入完成"); }finally { readWriteLock.writeLock().unlock(); } } public void get(String key){ readWriteLock.readLock().lock(); try { System.out.println(Thread.currentThread().getName() + "\t 开始读取"); Object result = map.get(key); System.out.println(Thread.currentThread().getName() + "\t 读取完成:" + result); }finally { readWriteLock.readLock().unlock(); } } }
如果只进行读取
/** * 多个线程同时读一个资源类没有任何问题,所以为了满足并发量,读取共享资源应该可以同时进行 * 如果又一个线程想去写共享资源了,就不应该再有其它线程可以对该资源进行读或写 * 小总结: * 读-读可以共存 * 读-写不能共存 * 写-写不能共存 * * 写操作:原子+独占,中间过程必须一个完整的统一体,中间不许被分割 */ public class ReadWriteLockDemo { public static void main(String[] args) { MyCache myCache = new MyCache(); for(int i=0;i<5;i++){ int finalI = i; new Thread(() -> { myCache.get(finalI+""); },"B"+String.valueOf(i)).start(); } } }
打印结果为,可以看到线程是交替执行的
B0 开始读取 B4 开始读取 B1 开始读取 B3 开始读取 B2 开始读取 B2 读取完成:null B3 读取完成:null B1 读取完成:null B4 读取完成:null B0 读取完成:null
如果只进行写入:
public class ReadWriteLockDemo { public static void main(String[] args) { MyCache myCache = new MyCache(); for(int i=0;i<5;i++){ int finalI = i; new Thread(() -> { myCache.put(finalI+"",finalI); },"A"+String.valueOf(i)).start(); } } }
打印结果为,可以看到线程是挨个执行的。
A1 开始写入:1 A1 写入完成 A0 开始写入:0 A0 写入完成 A2 开始写入:2 A2 写入完成 A3 开始写入:3 A3 写入完成 A4 开始写入:4 A4 写入完成