锁的概念

1,091 阅读14分钟

锁的详情

锁的类型

锁从宏观的角度来分类,分为两种悲观锁和乐观锁

1.乐观锁

乐观锁是一种乐观思想,即认为读多写少,遇到并发写的可能性低,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间是否有其他线程更新这个数据,采取在写的时候先读出当前版本号,然后加锁操作(比较跟上一次的版本号,如果一样则更新),如果失败则要重复读--比较-写的操作。

2.悲观锁

悲观锁就是悲观思想,即认为写多,遇到并发写的可能性高,每次去那数据的时候都认为别人会修改,所以每次在读写数据的时候会上锁,这样别人想读写这个数据就会block直接拿到锁。Java中的悲观锁就是Synchronized,AOS框架下的锁则是先尝试获取乐观锁,获取不到,才会转换为悲观锁。如Retreenlock 悲观锁按照使用性质划分

共享锁

共享锁:也称读锁,事务A对对象T加s锁,其他事务也只能对T加S,多个事务可以同时读,但不能有写操作,直到A释放S锁。

排它锁

排它锁(Exclusivelocks简记为X锁):也称写锁,事务A对对象T加X锁以后,其他事务不能对T加任何锁,只有事务A可以读写对象T直到A释放X锁。

更新锁

更新锁(简记为U锁):用来预定要对此对象施加X锁,它允许其他事务读,但不允许再施加U锁或X锁;当被读取的对象将要被更新时,则升级为X锁,主要是用来防止死锁的。因为使用共享锁时,修改数据的操作分为两步,首先获得一个共享锁,读取数据,然后将共享锁升级为排它锁,然后再执行修改操作。这样如果同时有两个或多个事务同时对一个对象申请了共享锁,在修改数据的时候,这些事务都要将共享锁升级为排它锁。这些事务都不会释放共享锁而是一直等待对方释放,这样就造成了死锁。如果一个数据在修改前直接申请更新锁,在数据修改的时候再升级为排它锁,就可以避免死锁。 悲观锁按照作用范围划分

行锁

锁的作用范围是行级别,数据库能够确定那些行需要锁的情况下使用行锁,如果不知道会影响哪些行的时候就会使用表锁。举个例子,一个用户表user,有主键id和用户生日birthday当你使用update … where id=?这样的语句数据库明确知道会影响哪一行,它就会使用行锁,当你使用update … where birthday=?这样的的语句的时候因为事先不知道会影响哪些行就可能会使用表锁。

表锁

锁的作用范围是整张表。

乐观锁实现方式

版本号(记为version) 就是给数据增加一个版本标识,在数据库上就是表中增加一个version字段,每次更新把这个字段加1,读取数据的时候把version读出来,更新的时候比较version,如果还是开始读取的version就可以更新了,如果现在的version比老的version大,说明有其他事务更新了该数据,并增加了版本号,这时候得到一个无法更新的通知,用户自行根据这个通知来决定怎么处理,比如重新开始一遍。这里的关键是判断version和更新两个动作需要作为一个原子单元执行,否则在你判断可以更新以后正式更新之前有别的事务修改了version,这个时候你再去更新就可能会覆盖前一个事务做的更新,造成第二类丢失更新,所以你可以使用update … where … and version=”old version”这样的语句,根据返回结果是0还是非0来得到通知,如果是0说明更新没有成功,因为version被改了,如果返回非0说明更新成功。 时间戳(timestamp) 和版本号基本一样,只是通过时间戳来判断而已,注意时间戳要使用数据库服务器的时间戳不能是业务系统的时间。 待更新字段 和版本号方式相似,只是不增加额外字段,直接使用有效数据字段做版本控制信息,因为有时候我们可能无法改变旧系统的数据库表结构。假设有个待更新字段叫count,先去读取这个count,更新的时候去比较数据库中count的值是不是我期望的值(即开始读的值),如果是就把我修改的count的值更新到该字段,否则更新失败。java的基本类型的原子类型对象如AtomicInteger就是这种思想。

所有字段 和待更新字段类似,只是使用所有字段做版本控制信息,只有所有字段都没变化才会执行更新。

乐观锁几种方式的区别

新系统设计可以使用version方式和timestamp方式,需要增加字段,应用范围是整条数据,不论那个字段修改都会更新version,也就是说两个事务更新同一条记录的两个不相关字段也是互斥的,不能同步进行。旧系统不能修改数据库表结构的时候使用数据字段作为版本控制信息,不需要新增字段,待更新字段方式只要其他事务修改的字段和当前事务修改的字段没有重叠就可以同步进行,并发性更高。

java线程阻塞的代价

java的线程是映射到操作系统原生线程之上的,如果要阻塞或唤醒一个线程就需要操作系统介入,需要在户态与核心态之间切换,这种切换会消耗大量的系统资源,因为用户态与内核态都有各自专用的内存空间,专用的寄存器等,用户态切换至内核态需要传递给许多变量、参数给内核,内核也需要保护好用户态在切换时的一些寄存器值、变量等,以便内核态调用结束后切换回用户态继续工作。 1.如果线程状态切换是一个高频操作时,这将会消耗很多CPU处理时间; 2.如果对于那些需要同步的简单的代码块,获取锁挂起操作消耗的时间比用户代码执行的时间还要长,这种同步策略显然非常糟糕的。 synchronized会导致争用不到锁的线程进入阻塞状态,所以说它是java语言中一个重量级的同步操纵,被称为重量级锁,为了缓解上述性能问题,JVM从1.5开始,引入了轻量锁与偏向锁,默认启用了自旋锁,他们都属于乐观锁。 Markword markword数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为32bit和64bit,它的最后2bit是锁状态标志位,用来标记当前对象的状态,对象的所处的状态,决定了markword存储的内容,如下表所示:

Java中的锁

1.自旋锁

自旋锁的原理非常简单,如果持有所得线程能在很短时间内释放锁资源,那么那些等待竞争的线程就不需要做内核态和用户态之间的切换进入阻塞挂起状态,他们只需等一等(自旋),等持有锁的线程释放锁后即可获取锁,这样就能避免用户线程和内核的切换的小号。 但是线程自旋是需要消耗CPU的,说白了就是让CPU在做无用功,如果一直获取不到锁,那么这些线程已不能一直占用CPU自旋做无用功,所以需要设定一个自旋等待的最大时间。 如果持有锁的线程执行时间超过自旋最大时间仍没有释放锁,就会导致其他征用锁的线程在最大等待时间内还是获取不到锁,这时征用线程会自动停止自旋进入阻塞状态。

自旋锁的优点

自旋锁尽可能的减少线程的阻塞,这对于锁的竞争不激烈,且占用锁的时间非常短的代码来说性能大幅度的提升,因为自旋的消耗会小于线程阻塞挂起再唤醒的操作的小号,这些操作会导致线程发生两次上下文切换。 但是如果所得竞争激烈,或者持有所得线程需要长时间占用锁执行同步快,这时候就不适合使用自旋锁了,因为自旋锁在获取锁之前一直都是占用CPU做无用功,同时又大量的线程在竞争一个锁,会导致获取锁的时间很长,线程自旋的小号大于阻塞挂起操作的消耗,其他需要CPU的线程又不能获取到CPU,造成CPU的浪费。这种情况下我们要关闭自旋锁。

2.重量级锁Synchronized

在JDK1.8之前都是使用synchronized关键字保证同步的,Synchronized的作用相信大家都已经非常熟悉了; 他可以把任意一个非null的对象当做锁 1.作用于方法时,锁住的是对象的实例(this); 2.当作用于静态方法时,锁住的是Class实例,又因为Class的相关数据储存在永久带permgen(JDK1.8则是metaspace),永久带是全局共享的,因此静态方法锁相当于类的一个全局锁,会锁所有调用该对象方法的线程; 3.Synchronzied作用于一个对象实例时,锁住的是所有以该对象为锁的代码块

偏向锁

偏向锁获取过程: 1.访问Mark Word中偏向锁的标识是否设置成1,锁标志位是否为01,确认为可偏向状态。 2.如果为可偏向状态,则测试线程ID是否志向当前线程,如果是,进入步骤5,否则进入步骤3 3.如果线程ID并未指向当前线程,则通过CAS操作竞争锁,如果竞争成功,则将Mark Word中线程ID设置为当前线程ID,然后执行5;如果竞争失败,执行4. 4.如果CAS获取偏向锁失败,则表示有竞争。当到达全局安全点时获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后又被阻塞在安全点的线程继续往下执行同步代码。(撤销偏向锁的时候会导致stop the word) 5.执行同步代码 偏向锁的适用场景 始终只有一个线程在执行同步块,在它没有执行完释放锁之前,没有其他线程去执行同步块,在锁无竞争的状况下使用,一旦有了竞争就升级为轻量级锁,升级为轻量级锁的时候需要撤销偏向锁,撤销偏向锁的时候会导致stop the word操作;在有锁的竞争时,偏向锁会多很多额外操作,尤其是撤销偏向锁的时候会导致进入安全点,安全点会导致stw,导致性能下降,这种情况下应当禁用;

轻量级锁

轻量级锁是由偏向锁升级来的,偏向锁运行在一个线程进入同步块的情况下,当第二个线程加入锁争用的时候,偏向锁就会升级为轻量级锁; synchronized的执行过程:

  1. 检测Mark Word里面是不是当前线程的ID,如果是,表示当前线程处于偏向锁
  2. 如果不是,则使用CAS将当前线程的ID替换Mard Word,如果成功则表示当前线程获得偏向锁,置偏向标志位1
  3. 如果失败,则说明发生竞争,撤销偏向锁,进而升级为轻量级锁。
  4. 当前线程使用CAS将对象头的Mark Word替换为锁记录指针,如果成功,当前线程获得锁
  5. 如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。
  6. 如果自旋成功则依然处于轻量级状态。
  7. 如果自旋失败,则升级为重量级锁。 上面几种锁都是JVM自己内部实现,当我们执行synchronized同步块的时候jvm会根据启用的锁和当前线程的争用情况,决定如何执行同步操作; 在所有的锁都启用的情况下线程进入临界区时会先去获取偏向锁,如果已经存在偏向锁了,则会尝试获取轻量级锁,启用自旋锁,如果自旋也没有获取到锁,则使用重量级锁,没有获取到锁的线程阻塞挂起,直到持有锁的线程执行完同步块唤醒他们; 偏向锁是在无锁争用的情况下使用的,也就是同步开在当前线程没有执行完之前,没有其它线程会执行该同步块,一旦有了第二个线程的争用,偏向锁就会升级为轻量级锁,如果轻量级锁自旋到达阈值后,没有获取到锁,就会升级为重量级锁; 如果线程争用激烈,那么应该禁用偏向锁

锁的优化

减少锁的时间 不需要同步执行的代码,能不放到同步块里面执行就不要放到同步开里,可以让锁尽快释放。 减少锁的粒度 它的思想是将物理上的一个锁,折成逻辑上的多个锁,增加并行度,从而降低锁竞争。它的思想也是用空间来换时间;

Redis实现分布式共享锁

Redis加锁分类 Redis能用的加锁命令分别是INCR,SETNX,SET 第一种锁SETNX(推荐第二种) set not exist 这种加锁的思路是,如果 key 不存在,将 key 设置为 value 如果 key 已存在,则 SETNX 不做任何动作,并且为了防止出现死锁情况,需要使用expire指令设置过期时间。 当时setnx与expire这两个操作不是原子性的,容易出现问题,所以在redis2.6.12版本后, 可以使用set来获取锁: String result = jedis.set(key, value, "NX", "PX", expireMillis); if (result != null && result.equalsIgnoreCase("OK")) { flag = true; } 如果再获取锁后并且设置了

第二种锁SET 上面两种方法都有一个问题,会发现,都需要设置 key 过期。那么为什么要设置key过期呢?如果请求执行因为某些原因意外退出了,导致创建了锁但是没有删除锁,那么这个锁将一直存在,以至于以后缓存再也得不到更新。于是乎我们需要给锁加一个过期时间以防不测。 但是借助 Expire 来设置就不是原子性操作了。所以还可以通过事务来确保原子性,但是还是有些问题,所以官方就引用了另外一个,使用 SET 命令本身已经从版本 2.6.12 开始包含了设置过期时间的功能。 虽然上面一步已经满足了我们的需求,但是还是要考虑其它问题? 1、 redis发现锁失败了要怎么办?中断请求还是循环请求? 使用循环请求,循环请求去获取锁 2、 循环请求的话,如果有一个获取了锁,其它的在去获取锁的时候,是不是容易发生抢锁的可能? 在循环请求获取锁的时候,加入睡眠功能,等待几毫秒在执行循环 3、锁提前过期后,客户端A还没执行完,然后客户端B获取到了锁,这时候客户端A执行完了,会不会在删锁的时候把B的锁给删掉? 在加锁的时候存入的key是随机的。这样的话,每次在删除key的时候判断下存入的key里的value和自己存的是否一样