您当前的位置: 首页 >  Java

一一哥Sun

暂无认证

  • 4浏览

    0关注

    622博文

    0收益

  • 0浏览

    0点赞

    0打赏

    0留言

私信
关注
热门博文

Day07_07_分布式教程之Java中的锁机制回顾

一一哥Sun 发布时间:2019-06-06 15:07:10 ,浏览量:4

Java中的锁机制回顾 一. 锁的概念

锁(lock)或互斥(mutex)是一种同步机制,用于在多线程的环境中实现对资源的限制访问,旨在强制实施互斥排他、并发控制的策略.

锁机制通常需要硬件的支持才能有效实施,这种支持通常采取一个或多个原子指令的形式,如"test-and-set","fetch-and-add" 或者"compare-and-swap".这些指令允许单个进程测试锁是否空闲,如果空闲,则通过单个原子操作获取锁.

二. 锁的分类

其实我们真正用到的锁也就那么两三种,只不过依据设计方案和性质对锁进行了大量的划分.

1. 按性质划分

  • 公平锁/非公平锁
  • 乐观锁/悲观锁
  • 独享锁/共享锁
  • 互斥锁/读写锁
  • 可重入锁

2. 按设计方案划分

  • 自旋锁/自适应自旋锁
  • 偏向锁/轻量级锁/重量级锁
  • 锁粗化/锁消除
  • 分段锁
三. 锁分类简介 1. 公平锁/非公平锁

非公平锁:Synchronized,ReentrantLock默认是非公平锁; 公平锁:ReentrantLock可以通过构造方法制定是否为公平锁.

公平锁是指多个线程按照申请锁的顺序来获取锁.

非公平锁是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁,所以有可能会造成优先级反转或者饥饿现象.

对于Java ReentrantLock而言,通过构造函数指定该锁是否是公平锁,默认是非公平锁,非公平锁的优点在于吞吐量比公平锁大.对于Synchronized而言,也是一种非公平锁,由于其并不像ReentrantLock是通过AQS来实现的线程调度,所以并没有任何办法使其变成公平锁.

2. 乐观锁/悲观锁

乐观锁与悲观锁不是指具体的某种类型的锁,而是指看待并发同步的态度.

悲观锁认为存在很多并发更新操,采取加锁操作,如果不加锁一定会有问题.因此对于同一个数据的并发操作,悲观锁采取加锁的形式.悲观的认为,不加锁的并发操作一定会出问题.

乐观锁认为不存在很多的并发更新操作,不需要加锁.在更新数据的时候,会采用尝试更新,不断重试的方式更新数据.乐观的认为,不加锁的并发操作是没有事情的.

从上面的描述我们可以看出,悲观锁适合写操作非常多的场景,乐观锁适合读操作非常多的场景,不加锁会带来大量的性能提升.

悲观锁在Java中的使用,就是利用各种锁;乐观锁在数据库中的实现一般是采用版本号,在Java中可使用CAS实现,属于无锁编程.典型的例子就是原子类,通过CAS自旋实现原子操作的更新.

3. 独享锁/共享锁
  • 独享锁:ReentrantLock、Synchronized;
  • 共享锁:ReadWriteLock;
  • 独享锁/共享锁:ReentrantReadWriteLock,读锁是共享锁,写锁是独享锁.

独享锁是指该锁一次只能被一个线程所持有,共享锁是指该锁可被多个线程所持有.

ReentrantReadWriteLock的读锁是共享锁,写锁是独享锁.共享的读锁的可以保证并发读是高效的,而读写,写读,写写的过程是互斥的.独享锁与共享锁也是通过AQS来实现的,通过实现不同的方法,来实现独享或者共享.

4. 互斥锁/读写锁

互斥锁:ReentrantLock; 读写锁:ReentrantReadWriteLock

上面讲的独享锁/共享锁就是一种广义的说法,互斥锁/读写锁就是具体的实.

低16位代表写锁,高16位代表读锁.

5. 可重入锁

可重入锁又名递归锁,是指同一个线程在外层方法获取锁的时候,在进入内层方法的时候会自动获取锁.说的有点抽象,下面会有一个代码的示例.对于Java ReentrantLock而言,从它的名字就可以看出是一个可重入锁,其名字是Reentrant Lock重新进入锁.对于Synchronized而言,也是一个可重入锁.可重入锁的一个好处是可一定程度避免死锁.

在上面代码段中,执行 testA 方法的时候需要获得当前对象作为监视器的对象锁,但方法中又调用了 testB 的同步方法.

如果锁是具有可重入性的话,那么该线程在调用 testB 时并不需要再次获得当前对象的锁,可以直接进入 testB 方法进行操作.

如果锁是不具有可重入性的话,那么该线程在调用 testB 前会等待当前对象锁的释放,实际上该对象锁已被当前线程所持有,不可能再次获得.

如果锁是不具有可重入性特点的话,那么线程在调用同步方法、以及含有锁的方法时就会产生死锁.

需要注意的是,可重入锁加锁和解锁的次数要相等.

6. 分段锁

分段锁是一种锁的设计,并不是一种具体的锁.ConcuttentHashMap就是通过分段锁来实现高效并发操作的.

7. 自旋锁

自旋锁是指尝试获取锁的线程不会阻塞,而是采用循环的方式尝试获取锁,好处是减少上下文切换,缺点是一直占用CPU资源.适合小并发场景,效率比较高.

8. 偏向锁/轻量级锁/重量级锁

这是jdk1.6中对Synchronized锁做的优化,首先了解下对象头(Mark Word):

运行时JVM内存布局:

Mark Word在不同锁状态下的标志位存储:

从jdk1.6开始为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”.锁共有四种状态,级别从低到高分别是: 无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态.随着竞争情况锁状态逐渐升级,锁可以升级但不能降级.

8.1 偏向锁的获取和撤销:

HotSpot作者经过研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入偏向锁.

线程1检查对象头中的Mark Word中是否存储了线程1,如果没有则CAS操作将Mark Word中的线程ID替换为线程1.此时,锁偏向线程1,后面该线程进入同步块时不需要进行CAS操作,只需要简单的测试一下Mark Word中是否存储指向当前线程的偏向锁,如果成功表明该线程已经获得锁.如果失败,则再需要测试一下Mark Word中偏向锁标识是否设置为1(是否是偏向锁),如果没有设置,则使用CAS竞争锁,如果设置了,则尝试使用CAS将偏向锁指向当前线程.

8.2 偏向锁的竞争结果:

根据持有偏向锁的线程是否存活:

  • 1️⃣.如果不活,偏向锁撤销到无锁状态,再偏向到其他线程;

  • 2️⃣.如果线程仍然活着,则升级到轻量级锁.

偏向锁在Java6和Java7中默认是开启的,但是在应用程序启动几秒后才激活,如果有必要可以关闭延迟:

-XX:BiasedLockingStartupDelay=0

如果确定应用程序中所有的锁通常情况下处于竞争状态,可以通过JVM参数关闭偏向锁:

-XX:-UseBiasedLocking=false #程序默认会进入轻量级锁
-XX:BiasedLockingStartupDelay=0 
-XX:+TraceBiasedLocking

8.3 轻量级锁膨胀:

  • 1️⃣.线程在执行同步块之前,JVM会在当前栈桢中创建用于存储锁记录的空间(Lock record),并将对象头中的Mark Word复制到锁记录中(Displaced Mark Word);

  • 2️⃣.然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针;

  • 3️⃣.如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程尝试使用自旋来获取锁.

8.4 偏向锁、轻量级锁、重量级锁的优缺点

  • 1️⃣.偏向锁是为了避免某个线程反复获得/释放同一把锁时的性能消耗,如果仍然是同一个线程去获得这个锁,尝试偏向锁时会直接进入同步块,不需要再次获得锁.

  • 2.而轻量级锁和自旋锁都是为了避免直接调用操作系统层面的互斥操作,因为挂起线程是一个很耗资源的操作.为了尽量避免使用重量级锁(操作系统层面的互斥),首先会尝试轻量级锁,轻量级锁会尝试使用CAS操作来获得锁,如果轻量级锁获得失败,说明存在竞争.但是也许很快就能获得锁,就会尝试自旋锁,将线程做几个空循环,每次循环时都不断尝试获得锁.如果自旋锁也失败,那么只能升级成重量级锁.

  • 3.可见偏向锁,轻量级锁,自旋锁都是乐观锁.

8.5 逃逸分析

逃逸分析,通俗的说就是当一个对象的指针被多个方法或线程引用时,我们称这个指针发生了逃逸,必须在JIT里完成.

8.6 锁粗化

如果虚拟机探测到有一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展到整个操作序列的外部,这样就只需要加锁一次就够了.

8.7 锁消除

如果你定义的类的方法上有同步锁,但在运行时,却只有一个线程在访问,此时逃逸分析后的机器码,会去掉同步锁运行.

8.8 栈上分配

分析找到未逃逸的变量,将变量类的实例化内存直接在栈里分配(无需进入堆),分配完成后,继续在调用栈内执行,最后线程结束,栈空间被回收,局部变量对象也被回收.

从jdk1.6开始默认开启:

开启: -XX:+DoEscapeAnalysis
关闭: -XX:-DoEscapeAnalysis
四. 锁的粒度 Granularity

在引入锁粒度之前,需要了解关于锁的三个概念:

  • 1️⃣.锁开销lock overhead 锁占用的内存空间、CPU初始化和销毁锁、获取和释放锁的时间.程序使用的锁越多,相应的锁开销越大.

  • 2️⃣.锁竞争lock contention 一个进程或线程试图获取另一个进程或线程持有的锁,就会发生锁竞争.锁粒度越小,发生锁竞争的可能性就越小.

  • 3️⃣.死锁deadlock 至少两个任务中的每一个都等待另一个任务释放锁的情况.

锁粒度是用来衡量锁保护的数据量大小的指标.通常选择粗粒度的锁(锁的数量少,每个锁保护大量的数据),当单进程访问受保护的数据时锁开销小,但是当多个进程同时访问时性能很差,因为增大了锁的竞争.相反,使用细粒度的锁(锁数量多,每个锁保护少量的数据)增加了锁的开销但是减少了锁竞争.例如数据库中,锁的粒度有表锁、页锁、行锁、字段锁、字段的一部分锁.

相关术语:

Critical Section(临界区)、
Mutex/mutual exclusion(互斥体)、 Semaphore/binary semaphore(信号量)
五. Synchronized与Lock的区别 1. 区别汇总

2. 从锁的分类对比
  • 1️⃣.Synchronized: 非公平,悲观,独享,互斥,可重入的重量级锁;

  • 2️⃣.ReentrantLock: 默认非公平但可实现公平的,悲观,独享,互斥,可重入,重量级锁;

  • 3️⃣.ReentrantReadWriteLocK: 默认非公平但可实现公平的,悲观,写独享,读共享,读写,可重入,重量级锁.

3. synchronized的优势

synchronized是在JVM层面上实现的,不但可以通过一些监控工具监控synchronized的锁定,而且在代码执行时出现异常,JVM会自动释放锁定,但是使用Lock则不行,lock是通过代码实现的,要保证锁定一定会被释放,就必须将unLock()放到finally{}中.

4. ReentrantLock的高级操作

4.1 中断等待

ReentrantLock 拥有Synchronized相同的并发性和内存语义,此外还多了 锁投票 机制,定时锁等候和中断锁等候.

线程A和B都要获取对象o的锁定,假设A获取了对象o的锁,B将等待A释放对o的锁定.

如果使用 synchronized,如果A不释放,B将一直等下去,不能被中断;如果使用ReentrantLock,如果A不释放,可以使B在等待了足够长的时间以后中断等待,然后干别的事情.

4.2 ReentrantLock获取锁的三种方式

  • lock():如果获取了锁则立即返回,如果别的线程持有锁,则当前线程一直处于休眠状态,直到获取锁;

  • tryLock():如果获取了锁立即返回true,如果别的线程正持有锁,立即返回false;

  • tryLock(long timeout,TimeUnit unit):如果获取了锁定立即返回true,如果别的线程正持有锁,会等待参数给定的时间,在等待的过程中,如果获取了锁定,就返回true,如果等待超时,返回false;

  • lockInterruptibly:如果获取了锁定立即返回,如果没有获取锁定,当前线程处于休眠状态,直到获取锁定,或者当前线程被别的线程中断.

可实现公平锁

对于ReentrantLock而言,通过构造函数指定该锁是否是公平锁,默认是非公平锁.非公平锁的优点在于吞吐量比公平锁大.

锁绑定多个条件

锁绑定多个条件是指一个ReentrantLock对象可以同时绑定多个Condition对象,而在synchronized中,锁对象的wait()和notify()或notifyAll()方法可以实现一个隐含的条件,如果要和多于一个的条件关联的,就不得不额外地添加一个锁,而ReentrantLock则无须这样做,只需要多次调用newCondition()方法即可.

5. 应用场景对比

在资源竞争不是很激烈的情况下,Synchronized的性能要优于ReetrantLock,但是在资源竞争很激烈的情况下,Synchronized的性能会下降几十倍,但是ReetrantLock的性能能维持常态.

关注
打赏
1665624836
查看更多评论
立即登录/注册

微信扫码登录

0.0420s