java基础之-----锁
概述
在开发过程中,会有很多地方用到锁,比如多线程修改一个对象时,为了防止多个线程同时修改,会采用加锁的机制,还有数据库在多个线程修改同一条记录时,也会有读锁,写锁等,所有这些都为了解决一个问题,在并发情况修改同一个值的时候,如何可以保证这个值不出问题。举个简单的例子说明:比如淘宝上某款商品库存只剩下一个了,这时有两个人同时下单购买,后台会启动两个线程处理,如果没有加锁,两个线程同时发现还有一个库存,可以卖,但实际只有一个商品了,就会造成库存超卖。
锁的分类
现实生活中,不同的人看问题往往不同,有的人遇到问题非常悲观,觉得天都要塌下来了,但有些人却非常乐观,觉得就算天塌下来自然会有高个子顶着(前提是这个乐观的人不是最高的人^_^),锁也是一样,有悲观乐观之分。
悲观锁
悲观锁,从字面意思上就可以明白,就是假设一切的情况都是最糟糕的,拿上面的例子来说,悲观锁就说,如果有多个人一起买,就一定可能会存在库存超卖的情况,而库存超卖是绝对不能容忍的,所以悲观锁就会把库存信息锁起来,保证一次只能一个线程来修改这个信息,当然这是一种严谨的思考方式,但是却牺牲了性能。
乐观锁
乐观锁,和悲观锁相反,认为不会超卖,对应上面的例子,乐观锁是这样做的,先去数据库看看库存是否够,发现有一个库存,之后给这个记录加一个版本号,然后就会卖这个商品,但是当把库存数量减1操作时,会比较数据库的那个版本号是不是自己刚刚的那个版本号,如果是就修改,如果不是就认为这个被别人修改过了,修改失败,从上面的叙述中可以发现,乐观锁也能解决库存超卖的情况,但是如果在高并发时很多失败的情况也是不能接受的。
乐观锁其实基本都是基于CAS(compare and set)实现的,那什么是CAS呢?从英文原文中可以看出就是先比较,如果是满足要求就更新,回头看看上面例子中叙述的是不是先比较,之后更新,但是这里注意一点,这个比较,然后更新看上去是分两步操作的,并不是原子操作,但CAS保证了上面的操作是一个原子操作,如果上面的操作不是原子操作,而是分两步执行的,如果有两个线程同时比较发现满足条件,之后又同时修改了这条记录,就会导致脏数据。
悲观锁和乐观锁的各自的应用场景
因为悲观锁要求严格,所以特别适用于那些高并发的写的场景,但是由于悲观锁的性能差,在有些场景需要做些优化,比如淘宝双十一秒杀的场景,一秒钟同时一个商品一下子来了成千上万单,这个时候如果使用悲观锁,不做任何优化,那恐怕性能绝对达不到要求,关于秒杀场景的优化,参考这篇文章。相反,乐观锁适用于那些读多写少场景,这样才能减少写的时候失败的情况。
常见的乐观锁
java.util.concurrent.atomic.AtomicInteger
java.util.concurrent.atomic.AtomicLong
举个例子:
public class Test { public int count = 0; public void add() { count++; } public int get() { return count; } public static void main(String[] args) throws InterruptedException { Test test = new Test(); ExecutorService executorService = Executors.newCachedThreadPool(); // 设置 CountDownLatch 的计数器为 100,保证在主线程打印累加结果之前,100 个线程已经执行完累加 CountDownLatch cdl = new CountDownLatch(100); for (int i = 0; i < 100; i++) { executorService.execute(() -> { test.add(); // 每一个线程执行完累加操作,都将计数器减 1 cdl.countDown(); }); } // 主线程等待,直到 cdl 的计数器为0 cdl.await(); System.out.println("计数器执行完100次累加后值为:" + test.get()); } }
上面这个例子,没有使用锁,所以结果不会是预期的100(多执行几次,我的电脑不晓得是什么原因执行了10次,才出现了一次异常的情况,而且结果还是99),至于为什么不是预期的100,可以去看一下java的内存模型就可以明白了,面试中也会常常问如果变量a使用volatile关键字修饰,结果是不是100,答案也不一定是100,这个仍然和java的内存模型有关,如果想了解java内存模型,参考这篇文章。如果改成如下的代码:
public class Test1 { public AtomicInteger count = new AtomicInteger(0); public void add() { count.getAndIncrement(); } public int get() { return count.get(); } public static void main(String[] args) throws InterruptedException { Test1 test = new Test1(); ExecutorService executorService = Executors.newCachedThreadPool(); // 设置 CountDownLatch 的计数器为 100,保证在主线程打印累加结果之前,100 个线程已经执行完累加 CountDownLatch cdl = new CountDownLatch(100); for (int i = 0; i < 100; i++) { executorService.execute(() -> { test.add(); // 每一个线程执行完累加操作,都将计数器减 1 cdl.countDown(); }); } // 主线程等待,直到 cdl 的计数器为0 cdl.await(); System.out.println("计数器执行完100次累加后值为:" + test.get()); } }
这样就执行的结果就正常了,为100,如果有兴趣了解上面的乐观锁如何可以保证数据正常,可以参考这篇文章,这篇文章详细写了多线程处理的过程,非常通俗易懂。
常见的悲观锁
synchronized
作用范围
作用在非静态方法上,锁定的是当前对象,作用于静态的方法,锁定的是当前类。
特点
synchronized是非公平锁,那什么是非公平锁呢?所谓公平讲究一个先来后到,所以对于锁来说,就是如果我先申请获取锁,虽然当前没有可用的锁,大家都在等待,但是我在你前面申请的我就要在你前面获取锁,这就是公平锁,反之,就是非公平锁。synchronized是一个重量级锁,那什么是重量级锁呢?下面且看我如何实施ctrl+c和ctrl+v大法来解释。
锁的状态分类
锁的状态总共有四种:无锁状态、偏向锁、轻量级锁和重量级锁,随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁(但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级)。
重量级锁
Synchronized 是通过对象内部的一个叫做监视器锁(monitor)来实现的。但是监视器锁本质又是依赖于底层的操作系统的 Mutex Lock 来实现的。而操作系统实现线程之间的切换这就需要从用户态转换到核心态(什么是用户态,什么是内核态,参考这篇文章),这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么Synchronized 效率低的原因。因此,这种依赖于操作系统 Mutex Lock 所实现的锁我们称之为“重量级锁”。JDK 中对 Synchronized 做的种种优化,其核心都是为了减少这种重量级锁的使用。JDK1.6 以后,为了减少获得锁和释放锁所带来的性能消耗,提高性能,引入了“轻量级锁”和“偏向锁”。
轻量级锁
随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁(但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级)。“轻量级”是相对于使用操作系统互斥量来实现的传统锁而言的。但是,首先需要强调一点的是,轻量级锁并不是用来代替重量级锁的,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用产生的性能消耗。在解释轻量级锁的执行过程之前,先明白一点,轻量级锁所适应的场景是线程交替执行同步块的情况,如果存在同一时间访问同一锁的情况,就会导致轻量级锁膨胀为重量级锁。
偏向锁
Hotspot 的作者经过以往的研究发现大多数情况下锁不仅不存在多线程竞争,而且总是由同一线程多次获得。偏向锁的目的是在某个线程获得锁之后,消除这个线程锁重入(CAS)的开销,看起来让这个线程得到了偏护。引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取及释放依赖多次 CAS 原子指令,而偏向锁只需要在置换ThreadID 的时候依赖一次 CAS 原子指令(由于一旦出现多线程竞争的情况就必须撤销偏向锁,所以偏向锁的撤销操作的性能损耗必须小于节省下来的 CAS 原子指令的性能消耗)。上面说过,轻量级锁是为了在线程交替执行同步块时提高性能,而偏向锁则是在只有一个线程执行同步块时进一步提高性能
偏向锁,轻量级锁,重量级锁总结
以上的所写的东西虽然解释了各种锁的特点和适用场景,但是都是些皮毛,并没有涉及原理的东西,举几个简单的例子,来说明上面叙述的问题。
问题1:重量级锁在用户态和核心态互相切换的时候性能损耗为什么很大?
问题2:CAS操作时如何保证一定是一个原子操作?
问题3:轻量级锁是通过CAS实现的,那底层的实现流程到底是什么样的?
问题4:重量级锁是通过调用操作系统的Mutex Lock 实现的,那这个操作系统的Mutex Lock又是怎么实现的?
这篇文章大致回答了上面的两个问题,但是我看的有点云里雾里,我觉得如果一个技术问题不能通过自己的语言或者使用类比的方式描述出来,其实并没有全部搞懂。
Lock的实现类 Reentrantlock
public class LockTest { Lock lock = new ReentrantLock(false); Condition condition = lock.newCondition(); /**测试lock的使用方法**/ //lock.lock()测试 public void test1(){ try { lock.lock(); System.out.println("====> "+Thread.currentThread().getName()+" ====> test1 start"); Thread.sleep(2000); System.out.println("====> "+Thread.currentThread().getName()+" ====> test1 end"); } catch (Exception e) { e.printStackTrace(); }finally { lock.unlock(); } } //lock.tryLock()测试 public void test2(){ if (lock.tryLock()) { try { System.out.println("====> " + Thread.currentThread().getName() + " ====> test2 start"); Thread.sleep(2000); System.out.println("====> " + Thread.currentThread().getName() + " ====> test2 end"); } catch (Exception e) { e.printStackTrace(); } finally { lock.unlock(); } } else { System.out.println(Thread.currentThread().getName() + " ====>get lock fail"); } } // //lock.lockInterruptibly()测试 public void test3() { try { lock.lockInterruptibly(); System.out.println("====> " + Thread.currentThread().getName() + " ====> test3 start"); Thread.sleep(2000); System.out.println("====> " + Thread.currentThread().getName() + " ====> test3 end"); } catch (Exception e) { e.printStackTrace(); return; } try { System.out.println("====> " + Thread.currentThread().getName() + " ====> 获得锁成功,锁关闭成功"); } catch (Exception e) { e.printStackTrace(); } finally { lock.unlock(); } } /**测试使用condition**/ //两个注意点,await先开始,之后signal再开始,不是notify public void conditionTest(){ new Thread(()->{ try { lock.lock(); System.out.println("====> start await"); condition.await(); Thread.sleep(2000); System.out.println("====> end await"); } catch (InterruptedException e) { e.printStackTrace(); }finally { lock.unlock(); } }).start(); } public void conditionTest1(){ new Thread(()->{ try { lock.lock(); System.out.println("====> start notify,sleep 3s"); Thread.sleep(3000); condition.signal(); System.out.println("====> notify success"); } catch (Exception e) { e.printStackTrace(); }finally { lock.unlock(); } }).start(); } public static void main(String[] args) { LockTest lockTest = new LockTest(); new Thread(()->{ lockTest.test1(); },"thread1").start(); new Thread(()->{ lockTest.test1(); },"thread2").start(); new Thread(()->{ lockTest.test2(); },"thread3").start(); new Thread(()->{ lockTest.test3(); },"thread4").start(); // // Object o = 2; // System.out.println(o.getClass().equals(Long.class)); lockTest.conditionTest(); lockTest.conditionTest1(); } }
分布式锁
上面分析的悲观锁和乐观锁可以应用于并发的时候,但是现在的后台架构往往采用微服务,部署多个节点来提高系统的并发能力和高可用,这时上面的悲观锁和乐观锁就不在适用,这时往往需要使用分布式锁,其实分布式锁的原理和上面分析的悲观锁,乐观锁的原理差不多,在一个节点内部,锁的作用是为了防止多个线程同时访问同一个资源时,出现读写异常,采用加锁的办法,在多个节点时,就是多个节点去同时访问同一资源时,出现读写异常,所以才出现了分布式锁。
分布式锁的实现原理,既然是多个节点之间加锁,那这个锁一定要每个节点都知道才可以,也就是说,需要一个统一的位置来存储(其实服务器端多节点session共享也是这样),目前常用的是redis和zookeeper,先说说redis如何实现,其实就是让加锁的资源生成一个key,存在redis中,当下一个节点要获取锁的时候,他也会生成出一个同样的key,去redis看一下,发现key已经存在,那就处于等待状态,当那个获取到锁的节点执行完了,释放锁的时候就把redis的key删除了,这样别的节点就可以加锁了。再说说zookeeper实现加锁的原理,zookeeper中有一个临时顺序节点的概念,至于这个概念什么意思,参考这篇文章,简单来说就是,如果有10个节点竞争锁,zookeeper就会生成10个文件夹,这10个文件夹是有顺序的,这个顺序就是根据申请获取锁的先后顺序建立的,每个文件夹都会检查自己是不是处于第一的位置,如果这个文件夹处于第一的位置,那他对应的节点就会获取到锁,当这个锁释放的时候,这个临时文件夹也会被删除,那相应的处于第二的文件夹就变成了第一,他就可以获取到锁了。以上的解释可能有点过于简单,有兴趣的可以看看这位大佬的解释。
使用redis实现分布式锁例子
public class RessionTest { /*** *@Description 为了简化演示,这里就是用多线程演示的,多个节点和这个类似 *@Param [args] *@Return void *@Author steve *@Date 2019/12/28 *@Time 11:13 */ public static void main(String[] args) throws InterruptedException{ RedissonClient redissonClient = Redisson.create(); RLock rLock = redissonClient.getLock("mylock"); rLock.lock(); System.out.println("main thread lock"); Thread t = new Thread(()->{ RLock rLock1 = redissonClient.getLock("mylock"); rLock1.lock(); System.out.println("new thread lock"); rLock1.unlock(); System.out.println("new thread unlock"); }); t.start(); t.join(1000); rLock.unlock(); System.out.println("main thread unlock"); t.join(); redissonClient.shutdown(); } }