005 - Java锁分类
2022-03-08 09:39:02 1 举报
AI智能生成
Java锁, 你应该要系统的学习与了解。
作者其他创作
大纲/内容
Java中的锁有很多, 可以按照不同的功能、种类进行分类
下面是对Java中一些常用锁的分类, 包括一些基本的概述
图解锁的分类
Java按照是否对资源加锁分为乐观锁和观锁,
乐观锁和悲观锁井不是一种真实存在的锁, 而是一种设计思想,
乐观锁和悲观锁对于理解Java多线程和数据库来说至关重要
说明
悲观锁是就是悲观思想,即认为写多,
认为写多,每次拿数据的时候认为别人会修改,所以每次在读写数据时都会上锁,这样别人想读这个数据就会block,直到拿到锁为止。
它总认为最坏的情况可能会出现,它认为数据很可能会被其他人所修改,
所以悲观锁在持有数据的时候总会把资源或者数据锁住,这样其他线程想要请求这个资源的时候就会阻塞,直到等到悲观锁把资源释放为止。
遇到并发写的可能性高,每次去拿数据时都认为别人会修改,所以每次在读写数据的时候都会上锁,这样别人想读写这个数据就会block,直到拿到锁。
悲观锁是一种悲观思想,
比如行锁,表锁等,
读锁,写锁等
传统的关系型数据库里边就用到了很多这种锁机制,都是在做操作之前先上锁。
悲观锁因为对读写都加锁,所以它的性能比较低
悲观锁,不仅会对写操作加锁,还会对读操作加锁
悲观锁的实现往往依数据库本身的锁功能实现
select * from student where name=\"burt”for update
这条sql语句从Student表中选取name=burt的记录并对其加锁,
那么其他写操作再这个事务提交之前,都不会对这条数据进行操作,起到了独占和排他的作用。
一个典型的悲观锁调用
java中的悲观锁就是Synchronized,
Synchronized
AQS框架下的锁则是,先尝试cas乐观锁去获取锁
获取不到,才会转换为悲观锁,如RetreenLock。
ReentrantLock等独占锁(排他锁)
互斥锁
Java中悲观锁的具体实现
因为不管是否持有资源, 它都会尝试去加锁, 生怕自己心爱的宝贝被别人拿走。
对于现在互联网提倡的三高(高性能、高可用、高井发)来说,悲观锁的实现用的越来越少了
但是一般多读的情况下还是需要使用悲观锁的
因为虽然加锁的性能比较低,但是也阻止了像乐观锁一样,遇到写不一致的情况下一直重试的时间。
使用场景
悲观锁
它总认为资源和数据不会被别人所修改,所以读取不会上锁,
但是在进行写入操作时,会判断当前数据是否被修改过
认为读多写少,遇到并发的可能性低,所以不会上锁,
但是在更新的时候判断一下在此期间别人有没有更新去更新这个数据,
采取在写时先读出当前版本号,然后加锁操作
乐观锁是一种乐观思想,即认为读多写少
乐观锁的思想与悲观锁的思想相反
遇到并发写的可能性低,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,
采取在写时先读出当前版本号,然后加锁操作(比较跟上一次的版本号,如果一样则更新),如果失败则要重复读-比较-写的操作。
在数据表中加上一个version字段来实现的,
表示数据被修改的次数, 当执行写操作并且写入成功后, version=version+1,
当线程A要更新数据时, 在读取数据的同时也会读取version值,
在提交更新时, 若刚才读取到的version值为当前数据库中的version值相等时,才更新, 否则重试更新操作,直到更新成功。
柜员要对一笔金额做修改,为了保证数据的准确性和实效性
锁住某个数据后,再遇到其他需要修改数据的操作,
那么此操作就无法完成金额的修改,
对产品来说是灾难性的一刻,
使用悲观锁
版本号机制能够解决这个问题
使用乐观锁
典型的,比如说成本系统
金额的属性是能够实时变化,
而version表示的是金额每次发生变化的版本,
成本系统中有一个数据表, 表中有两个字段分别是金额和version,
一般的策略, 当金额发生改变时,version采用递增的策略每次都在上一个版本号的基础上+1.
公司收到回款后,需要把这笔钱放在金库中,假如金库中存有100元钱
当男柜员执行回款写入操作前,他会先查看(读)一下金库中还有多少钱,此时读到金库中有100元,可以执行写操作,并把数据库中的钱更新为120元,提交事务,金库中的钱由100->120, version的版本号由0->1。
下面开启事务一
女柜员收到给员工发工资的请求后,需要先执行读请求,查看金库中的钱还有多少,此时的版本号是多少,然后从金库中取出员工的工资进行发放,提交事务,成功后版本+1,此时版本由1→2。
开启事务二
上面两种情况是最乐观的情况,上面的两个事务都是顺序执行的,也就是事务一和事务二互不干扰,
那么事务要并行执行会如何呢?
事务一开启
此时金额改为120,版本号为1,事务还没有提交
事务一开启,男柜员先执行读操作,取出金额和版本号,执行写操作
事务二开启
此时金额改为50,版本号变为1,事务未提交
事务二开启,女柜员先执行读操作,取出金额和版本号,执行写操作
现在提交事务一,金额改为120,版本变为1,提交事务,理想情况下应该变为金额=50,版本号-2,
但是实际上事务二的更新是建立在金额为100和版本号为0的基础上的,所以事务二不会提交成功,应该重新读取金额和版本号,再次进行写操作。
这样, 就避免了女柜员用基于version=0的旧数据修改的结果覆盖男操作员操作结果的可能。
在了解了基本情况和基本信息之后,来看一下这个过程:
图解
以成本系统为案例说明
版本号机制
CAS(Compare-and-Swap, 即比较并替换) 算法
即compare and swap(比较与交换)
是一种有名的无锁算法。
即:不使用锁的情况下,实现多线程之间的变量同步
java中的乐观锁基本都是通过CAS操作实现的
JUC包里提供了很多面向并发编程的类
一些以Atomic为开头的一些原子类都使用CAS作为其实现方式。
使用这些类在多核CPU的机器上会有比较好的性能。
也提供了CAS算法的支持
CAS 操作见多线程-JUC-Atomic(原子操作)
Java从JDK 1.5开始支持
CAS算法是什么?
在没有线程被阻塞的情况下实现变量的同步
非阻塞同步(Non-blocking Synchronization)
如果要保证它们的原子性, 必须进行加锁, 使用Synchronzied或者ReentrantLock
用哪种方式保证它们的原子性呢?
需要读写的内存值V
进行比较的值A
拟写入的新值B
CAS中涉及三个要素
当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则什么都不做。
多线程并行的情况下, 使用AtomicInteger可以保证线程安全性。
incrementAndGet和decrementAndGet都是原子性操作
可以以JUC中的Atomic Integer为例, 看一下在不用锁的情况下是如何保证线程安全的
CAS算法实现
乐观锁的实现方案一般来说有两种
JUC
Atomic Integer
atomic包下面的原子变量类
Java中乐观锁的具体实现
使用了乐观锁的一种实现方式CAS实现的
乐观锁多适用于多读的应用类型, 这样可以提高吞吐量。
任何事情都是有利也有弊,软件行业没有完美的解决方案,只有最优的解决方案,所以乐观锁也有它的弱点和缺陷
如果一个变量第一次读取的值是A, 准备好需要对A进行写操作时, 发现值还是A,那么这种情况下,能认为A的值没有被改变过吗?
可以是由A->B->A的这种情况,但是AtomicInteger却不会这么认为, 它只相信它看到的, 它看到的是什么就是什么。
问题描述
其中的compareAndSet方法:就是首先检查当前引用是否等于预期引用
并且当前标志是否等于预期标志,
如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。
JDK 1.5以后的AtomicStompedReference类就提供了此种能力
DCAS, 是对于每一个V增加一个引用的表示修改次数的标记符,
对于每个V, 如果引用修改了一次, 这个计数器就加1。
然后再这个变量需要update时,就同时检查变量的值和计数器的值。
采用CAS的一个变种DCAS来解决这个问题
如何解决?
ABA问题
乐观锁在进行写操作时,会判断是否能够写入成功,如果写入不成功将触发等待→重试机制,
这种情况是一个自旋锁,简单来说就是适用于短期内获取不到,进行等待重试的锁,
它不适用于长期获取不到锁的情况,
另外,自旋循环对于性能开销比较大。
循环开销大
乐观锁的缺点
CAS,适用于写比较少的情况下(多读场景, 冲突一般较少) ,
synchronized,适用于写比较多的情况下(多写场景,冲突一般较多
Synchronized同步锁进行线程阻塞和唤醒切换以及用户态内核态间的切换操作额外浪费消耗cpu资源;
CAS基于硬件实现, 不需要进入内核,不需要切换线程,操作自旋几率较少,因此可以获得更高的性能。
对于资源竞争较少(线程冲突较轻) 的情况
CAS自旋的概率会比较大, 从而浪费更多的CPU资源, 效率低于synchronized。
对于资源竞争严重(线程冲突严重) 的情况,
CAS与synchronized的使用情景
乐观锁
什么是乐观锁?什么是悲观锁?在Java是怎么体现的?
常见问题
从线程是否需要对资源加锁
由于在多处理器环境中某些资源的有限性, 有时需要互斥访问(mutual exclusion) , 这时候就需要引入锁的概念,
只有获取了锁的线程才能够对资源进行访问, 由于多线程的核心是CPU的时间分片, 所以同一时刻只能有一个线程获取到锁,
没有获取到锁的线程,就一直循环等待,判断该资源是否已经释放锁,这种锁叫做自旋锁,
它不用将线程阻塞起来(NON-BLOCKING)
使用自旋锁
把自己阻塞起来, 等待重新调度请求
使用互斥锁
那么就面临一个问题,那么没有获取到锁的线程应该怎么办?有两种处理方式
提出背景
当一个线程尝试去获取某一把锁时,如果这个锁此时已经被别人获取(占用),
那么此线程就无法获取到这把锁,该线程将会等待,间隔一段时间后会再次尝试获取。
这种采用循环加锁->等待的机制被称为自旋锁(spinlock) .
定义
自旋锁获取示意图
比较简单,
如果持有锁的线程能在短时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞状态,
它们只需要等一等(自旋),等到持有锁的线程释放锁之后即可获取,这样就避免了用户进程和内核切换的消耗。
原理说明
自旋锁,尽可能的减少线程阻塞,这对于锁的竞争不激烈,且占用锁时间非常短的代码块来说,性能能大幅度的提升,
优点
因为自旋的消耗会小于线程阻塞挂起再唤醒的操作的消耗,这些操作会导致线程发生两次上下文切换!
但是如果锁的竞争激烈,或者持有锁的线程需要长时间占用锁执行同步块,这时候就不适合使用自旋锁了
因为自旋锁在获取锁前一直都是占用cpu做无用功, 占着XX不XX, 同时有大量线程在竞争一个锁, 会导致获取锁的时间很长,
线程自旋的消耗大于线程阻塞挂起操作的消耗, 其它需要cpu的线程又不能获取到cpu, 造成cpu的浪费, 所以这种情况下,要关闭自旋锁.
缺点
自旋锁的优缺点
自旋锁的代码实现
这种简单的自旋锁有一个问题:无法保证多线程竞争的公平性。
对于上面的Spinlock Test, 当多个线程想要获取锁时, 谁最先将available设为false谁就能最先获得锁,
这可能会造成某些线程一直都未获取到锁造成线程饥饿。
就像我们下课后蜂拥的跑向食堂,下班后蜂拥地挤向地铁,通常我们会采取排队的方式解决这样的问题
-XX:+UseSpinning开启
-XX:PreBlockSpin=10 为自旋次数;
JDK1.6中
去掉此参数,由jvm控制
JDK1.7后
自旋锁的开启
因为自旋锁避免了操作系统进程调度和线程切换
由于这个原因,操作系统的内核经常使用自旋锁。
通常适用在时间比较短的情况下(操作系统的内核)
但是,如果长时间上锁的话,自旋锁会非常耗费性能,它阻止了其他线程的运行和调度。
线程持有锁的时间越长, 则持有该锁的线程将被OS(Operating System) 调度程序中断的风险越大。
如果发生中断情况, 那么其他线程将保持旋转状态(反复尝试获取锁),
而持有该锁的线程并不打算释放锁,这样导致的是结果是无限期推迟,直到持有锁的线程可以完成并释放它为止,
如果长时间上锁的话,自旋锁会非常耗费性能
自旋锁的目的是为了占着CPU的资源不释放,等到获取到锁立即进行处理。但是如何去选择自旋的执行时间呢?如果自旋执行时间太长,会有大量的线程处于自旋状态占用CPU资源,进而会影响整体系统的性能。因此自旋的周期选的额外重要!
JVM对于自旋周期的选择,jdk1.5这个限度是一定的写死的,在1.6引入了适应性自旋锁,适应性自旋锁意味着自旋的时间不在是固定的了,而是由前一次在同一个锁上的自旋时间以及锁的拥有者的状态来决定,基本认为一个线程上下文切换的时间是最佳的一个时间,同时JVM还针对当前CPU的负荷情况做了较多的优化,如果平均负载小于CPUs则一直自旋,如果有超过(CPUs/2)个线程正在自旋,则后来线程直接阻塞,如果正在自旋的线程发现Owner发生了变化则延迟自旋时间(自旋计数)或进入阻塞,如果CPU处于节电模式则停止自旋,自旋时间的最坏情况是CPU的存储延迟(CPU A存储了一个数据,到CPU B得知这个数据直接的时间差),自旋时会适当放弃线程优先级之间的差异。
线程自旋是需要消耗cup的,说白了就是让cup在做无用功,如果一直获取不到锁,那线程也不能一直占用cup自旋做无用功,所以需要设定一个自旋等待的最大时间。 如果持有锁的线程执行的时间超过自旋等待的最大时间扔没有释放锁,就会导致其它争用锁的线程在最大等待时间内还是获取不到锁,这时争用线程会停止自旋进入阻塞状态。
自旋锁时间阈值(1.6引入了适应性自旋锁)
解决上面这种情况一个很好的方式是给自旋锁设定一个自旋时间,等时间一到立即释放自旋锁。
自旋锁的目的是占着CPU资源不进行释放, 等到获取锁立即进行处理。
设定一个自旋时间,等时间一到立即释放自旋锁
如何去选择自旋时间呢?适应性自旋锁了解下
自旋锁
如果自旋执行时间太长, 会有大量的线程处于自旋状态占用CPU资源, 进而会影响整体系统的性能。
因此,自旋的周期选的额外重要!
意味着自旋时间不是固定的了,而是由前一次在同一个锁上的自旋时间以及锁拥有的状态来决定,
基本认为一个线程上下文切换的时间是最佳的一个时间。
JDK在1.6引入了适应性自旋锁
适应性自旋锁
计算机科学家们使用了各种方式来实现排队自旋锁, 如TicketLock, MCSLock, CLHLock。
接下来我们分别对这几种锁做个大致的介绍。
在计算机科学领域中, TicketLock是一种同步机制或锁定算法,
它是一种自旋锁, 它使用ticket来控制线程执行顺序。
TicketLock是什么?
基于先进先出(FIFO) 队列的机制
基于队列的
底层结构
就像票据队列管理系统一样。
面包店或者服务机构(例如银行)都会使用这种方式来为每个先到达的顾客记录其到达的顺序,而不用每次都进行排队.
通常,这种地点都会有一个分配器(叫号器,挂号器等等都行)
先到的人需要在这个机器上取出自己现在排队的号码
这个号码是按照自增的顺序进行的,旁边还会有一个标牌显示的是正在服务的标志
这通常是代表目前正在服务的队列号,当前的号码完成服务后,标志牌会显示下一个号码可以去服务了
TicketLock实际应用场景
队列票据是线程在队列中的位置
队列票据是你取票号的位置
第一个值是队列ticket(队列票据)
出队票据是现在持有锁的票证的队列位置。
出队票据是你距离叫号的位置
第二个值是出队(票据)
TicketLock中有两个int类型的数值, 开始都是0
当叫号叫到你时,不能有相同的号码同时办业务,必须只有一个人可以去办,办完后,叫号机叫到下一个人,这就叫做原子性
你在办业务的时候不能被其他人所干扰,而且不可能会有两个持有相同号码的人去同时办业务。
然后,下一个人看自己的号是否和叫到的号码保持一致,如果一致的话,那么就轮到你去办业务,否则只能继续等待。
每个办业务的人在办完业务之后,必须丢弃自己的号码,叫号机才能继续叫到下面的人
如果这个人没有丢弃这个号码,那么其他人只能继续等待。
上面流程关键点在于
TicketLock的设计原则
票据排队方案
每次叫号机在叫号的时候,都会判断自己是不是被叫的号,
并且每个人在办完业务时,叫号机根据在当前号码的基础上+1,让队列继续往前走。
下面来实现一下这个票据排队方案
因为获得自己的号码之后,是可以对号码进行更改的,这就造成系统素乱,锁不能及时释放。
这时候就需要有一个能确保每个人按会着自己号码排队办业务的角色,
重新设计一下这个逻辑
这次就不再需要返回值,
办业务时,要将当前的这一个号码缓存起来,
在办完业务后,需要释放缓存的这条票据,
在得知这一点之后,我们重新设计一下这个逻辑
但是上面这个设计是有问题的
它增加了锁的公平性,解决了公平性的问题
TicketLock的优点
Ticket Lock虽然解决了公平性的问题,
但是多处理器系统上, 每个进程/线程占用的处理器都在读写同一个变量queueNum,
每次读写操作都必须在多个处理器缓存之间进行缓存同步,
这会导致繁重的系统总线和内存的流量,大大降低系统整体的性能
为了解决这个问题, MCSLock和CLHLock应运而生。
TicketLock的缺点
TicketLock
CLH的发明人是:Craig,Land in and Hagersten, 用它们各自的字母开头命名。
由来
CLH是一种基于链表的可扩展, 高性能, 公平的自旋锁,
申请线程只能在本地变量上自旋,它会不断轮询前驱的状态,如果发现前驱释放了锁就结束自旋。
是什么?
CLHLock就是基于链表设计的
代码
CLHLock
MCS来自于其发明人名字的首字母:John Mellor-Crummey和Michael Scott.
MCS Spinlock是一种基于链表的可扩展、高性能、公平的自旋锁
申请线程只在本地变量上自旋
直接前驱负责通知其结束自旋,从而极大地减少了不必要的处理器缓存同步的次数,降低了总线和内存的开销。
代码2
代码1
MCSLock
CLH Lock是基于隐式链表, 没有真正的后续节点属性
MCS Lock是显示链表,有一个指向后续节点的属性。
都是基于链表, 不同的是,
将获取锁的线程状态借助节点(node) 保存, 每个线程都有一份独立的节点, 这样就解决了Ticket Lock多处理器缓存同步的问题.
CLHLock和MCSLock的区别
排队自旋锁(Queued Spinlock)
从资源已被锁定,线程是否阻塞
当多个线程同时访问同一个数据时,很容易出现问题。
为了避免这种情况出现,我们要保证线程同步互斥,就是指并发执行的多个线程,在同一时间内只允许一个线程访问共享数据。
Java中可以使用synchronized关键字来取得一个对象的同步锁。
诞生原因
在Java语言中,每一个对象有一把锁。
线程可以使用synchronized关键字来获取对象上的锁。
可以把任意一个非NULL的对象当作锁。
属于独占式的悲观锁,同时属于可重入锁。
监视器锁(monitor)是依赖于底层的操作系统的Mutex Lock来实现的,
而操作系统实现线程之间的切换时,需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高
用户态向内核态切换
重锁
重量级的可重入锁(为什么早期的synchronized效率低的原因?)
1. 作用于方法时,锁住的是对象的实例(this);
方法级别(粗粒度锁)
持有的是当前对象实例的锁
作用于实例方法
2. 当作用于静态方法时,锁住的是Class实例,
持有的是静态对象的锁
又因为Class的相关数据存储在永久带PermGen(jdk1.8则是metaspace),永久代是全局共享的,
因此静态方法锁相当于类的一个全局锁,会锁所有调用该方法的线程;
作用于静态方法
3. synchronized作用于一个对象实例时,锁住的是所有以该对象为锁的代码块。
代码块级别(细粒度锁)。
它有多个队列,当多个线程一起访问某个对象监视器的时候,对象监视器会将这些线程存储在不同的容器中。
作用于代码块
作用范围、作用方式(synchronized关键字可应用在)同步方法和同步代码块的区别是什么?
对象的hashCode
CG年代
锁信息(偏向锁,轻量级锁,重量级锁)
GC标志
指向monitor的指针
(存储对象的HashCode,分代年龄和锁标志位信息。)
Mark Word
指向对象实例的指针
Class Metadata Address
Klass Point(对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。)
EntryList
Owner(会指向持有 Monitor 对象的线程)
WaitSet
Monitor
java对象头(Header)
实例数据
对其填充
对象
无锁
mark Word 中有线程信息 cas 比较
偏向锁
复制了一份mark work 叫 Lock record 也是cas尝试改变指针
轻量级
死循环
自旋
重量级
锁膨胀
线程的中断操作对于正在等待获取的锁对象的synchronized方法或者代码块并不起作用
线程中断与synchronized
在使用notify/notifyAll和wait这3个方法时,必须处于synchronized代码块或者synchronized方法中
notify/notifyAll和wait方法都依赖于monitor
等待唤醒机制与synchronized
ACC_SYNCHRONIZED
JVM可以从方法区中的方法表结构中的 ACC_SYNCHRONIZED 访问标志区分一个方法是否同步方法。
作用于方法(显式同步)
monitorenter
monitorexit
加减
程序计数器 count
为了保证在方法异常完成时 monitorenter 和 monitorexit 指令依然可以正确配对执行,编译器会自动产生一个异常处理器,这个异常处理器声明可处理所有的异常,它的目的就是用来执行 monitorexit 指令
编译时,在代码块的前后加上monitorenter和monitorexit
作用于代码块(隐式同步)
as-if-serial
happens-before
有序性
内存强制刷新
可见性
单一线程持有
原子性
计数器
可重入性
特性保证
1) Wait Set:哪些调用wait方法被阻塞的线程被放置在这里;
2) Contention List:竞争队列,所有请求锁的线程首先被放在这个竞争队列中;
3) Entry List:Contention List中那些有资格成为候选资源的线程被移动到Entry List中;
4) OnDeck:任意时刻,最多只有一个线程正在竞争锁资源,该线程被成为OnDeck;
5) Owner:当前已经获取到所资源的线程被称为Owner;
6) !Owner:当前释放锁的线程。
7)程序计数器 count
Synchronized核心组件,ObjectMontior
Synchronized实现
JVM每次从队列的尾部取出一个数据用于锁竞争候选者(OnDeck),但是并发情况下,ContentionList会被大量的并发线程进行CAS访问,为了降低对尾部元素的竞争,JVM会将一部分线程移动到EntryList中作为候选竞争线程。
Owner线程会在unlock时,将ContentionList中的部分线程迁移到EntryList中,并指定EntryList中的某个线程为OnDeck线程(一般是最先进去的那个线程)。
Owner线程并不直接把锁传递给OnDeck线程,而是把锁竞争的权利交给OnDeck,OnDeck需要重新竞争锁。这样虽然牺牲了一些公平性,但是能极大的提升系统的吞吐量,在JVM中,也把这种选择行为称之为“竞争切换”。
OnDeck线程获取到锁资源后会变为Owner线程,而没有得到锁资源的仍然停留在EntryList中。如果Owner线程被wait方法阻塞,则转移到WaitSet队列中,直到某个时刻通过notify或者notifyAll唤醒,会重新进去EntryList中。
处于ContentionList、EntryList、WaitSet中的线程都处于阻塞状态,该阻塞是由操作系统来完成的(Linux内核下采用pthread_mutex_lock内核函数实现的)。
Synchronized是非公平锁。 Synchronized在线程进入ContentionList时,等待的线程会先尝试自旋获取锁,如果获取不到就进入ContentionList,这明显对于已经进入队列的线程是不公平的,还有一个不公平的事情就是自旋获取锁的线程还可能直接抢占OnDeck线程的锁资源。
每个对象都有个monitor对象,加锁就是在竞争monitor对象,代码块加锁是在前后分别加上monitorenter和monitorexit指令来实现的,方法加锁是通过一个标记位来判断的
synchronized是一个重量级操作,需要调用操作系统相关接口,性能是低效的,有可能给线程加锁消耗的时间比有用操作消耗的时间更多。
Java1.6,synchronized进行了很多的优化,有适应自旋、锁消除、锁粗化、轻量级锁及偏向锁等,效率有了本质上的提高。在之后推出的Java1.7与1.8中,均对该关键字的实现机理做了优化。引入了偏向锁和轻量级锁。都是在对象头中有标记位,不需要经过操作系统加锁。
锁可以从偏向锁升级到轻量级锁,再升级到重量级锁。这种升级过程叫做锁膨胀;
JDK 1.6中默认是开启偏向锁和轻量级锁,可以通过-XX:-UseBiasedLocking来禁用偏向锁。
参考:https://blog.csdn.net/zqz_zqz/article/details/70233767
底层实现
synchronized同步锁
synchronized是悲观锁, 在操作同步之前需要给资源加锁, 这把锁就是对象头里面的,
以Hotspot虚拟机为例, Hopspot对象头主要包括两部分数据:
默认存储对象的HashCode, 分代年龄和锁标志位信息。
这些信息都是与对象自身定义无关的数据, 所以MarkWord被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据。
它会根据对象的状态复用自己的存储空间, 也就是说在运行期间MarkWord里存储的数据会随着锁标志位的变化而变化
MarkWord(标记宇段)
对象指向它的类元数据的指针
虚拟机通过这个指针来确定这个对象是哪个类的实例。
32位虚拟机的MarkWord和classPointer分别占用32bits的字节
64位虚拟机的MarkWord和classPointer占用了64bits的字节
32位和64位虚拟机的MarkWord所占用的字节大小不一样
classPointer(类型指针)
Java对象头是什么呢?
32位虚拟机内存分配
64位虚拟机内存分配
下面我们以32位虚拟机为例, 来看一下其Mark Word的字节具体是如何分配的
也就是无锁的时候
25bit的空间用来存储对象的hashcode,
4bit用于存放分代年龄,
1bit用来存放是否偏向锁的标识位,
2bit用来存放锁标识位为01
对象头开辟
无状态
划分更细
23bit用来存放线程ID
2bit用来存放epoch
开辟25bit的空间
4bit存放分代年龄
1bit存放是否偏向锁标识, 0表示无锁, 1表示偏向锁
2bit用来存放锁的标识位
30bit的空间存放指向栈中锁记录的指针
2bit存放锁的标志位, 其标志位为00
轻量级锁
和轻量级锁一样
30bit的空间用来存放指向重量级锁的指针
2bit存放锁的标识位,为11
重量级锁
30bit的内存空间,却没有占用
2bit空间存放锁标志位为11
GC标记
其中无锁和偏向锁的锁标志位都是01, 只是在前面的1bit区分了这是无锁状态还是偏向锁状态
中文翻译版本:32位虚拟机对象头分配情况
为什么这么分配的内存
age_bits就是说的分代回收的标识, 占用4字节
lock_bits是锁的标志位, 占用2个字节
biased_lock_bits是是否偏向锁的标识, 占用1个字节
如果是32位虚拟机, 就是32-4-2-1=25byte,
如果是64位虚拟机, 64-4-2-1=57byte, 但是会有25字节未使用, 所以64位的hashcode占用31byte
max_hash_bits是针对无锁计算的hashcode占用字节数量,
hash_bits是针对64位虚拟机来说, 如果最大字节数大于31, 则取31, 否则取真实的字节数
cms_bits我觉得应该是不是64位虚拟机就占用0byte, 是64位就占用1byte
epoch_bits就是epoch所占用的字节大小, 2字节
关于为什么这么分配的内存, 我们可以从Open JDK中的markOop.hpp类中的枚举窥出端倪
Java对象头
synchronized用的锁记录是存在Java对象头里的。
JVM基于进入和退出Monitor对象,来实现方法同步和代码块同步。
任何对象都有一个monitor与之关联, 当且一个monitor被持有后, 它将处于锁定状态。
是在编译后插入到同步代码块的开始位置,
根据虚拟机规范的要求, 在执行monitor enter指令时, 首先要去尝试获取对象的锁,
如果这个对象没被锁定, 或者当前线程已经拥有了那个对象的锁, 把锁的计数器加1
monitor enter指令
是插入到方法结束处和异常处
在执行monitor exit指令时,会将锁计数器减1,当计数器被减到0时,锁就释放了,
如果获取对象锁失败了,那当前线程就要阻塞等待,直到对象锁被另一个线程释放为止。
monitor exit指令
代码块同步是使用两个指令实现
Synchronized锁
Synchronized是通过对象内部的一个叫做监视器锁(monitor) 来实现的
监视器锁(monitor)是什么?
本质是依赖于底层的操作系统的Mutex Lock(互斥锁) 来实现的
操作系统实现线程之间的切换,需要从用户态转换到核心态
这个成本非常高, 状态之间的转换需要相对比较长的时间
这种依赖于操作系统Mutex Lock所实现的锁我们称之为重量级锁
为什么Synchronized效率低的原因?
监视器锁(monitor)
Java对象头和Monitor
Java语言专门针对synchronized关键字设置了四种状态
锁的状态总共有四种:无锁状态、偏向锁、轻量级锁和重量级锁。
锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态。
随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁
但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级
锁可以升级,但不能降级
为了减少获得锁和释放锁带来的性能消耗, 引入了偏问锁和轻量级锁
默认是开启偏向锁和轻量级锁的
Java SE 1.6/JDK 1.6
对象空间头分配情况图
锁的分类及其解释
无锁,即没有对资源进行锁定,
所有的线程都可以对同一个资源进行访问,但是只有一个线程能够成功修改资源。
无锁状态
在循环内进行修改操作,线程会不断的尝试修改共享资源,直到能够成功修改资源并退出, 在此过程中没有出现冲突的发生,
类似于CAS实现, CAS的原理和应用,就是无锁的实现。
无锁,无法全面代替有锁,但无锁在某些场合下的性能是非常高的。
无锁的特点
图解无锁的对象头
可以通过-XX:-UseBiasedLocking=false来禁用偏向锁
JDK 1.6提出
HotSpot的作者经过研究发现,
大多数情况下, 锁不仅不存在多线程竞争, 还存在锁由同一线程多次获得的情况,
偏向锁就是在这种情况下出现的,它的出现是为了解决只有在一个线程执行同步时提高性能。
偏向锁是什么?
图解偏向锁的对象头
可以从对象头的分配中看到, 偏向锁要比无锁多了线程ID和epoch,
首先线程访问同步代码块, 会通过检查对象头Mark Word的锁标志位判断目前锁的状态, 如果是01,说明就是无锁或者偏向锁,然后再根据是否偏向损的标示判断是无锁还是偏向锁,如果是无锁情况下,执行下一步
线程使用CAS操作来尝试对对象加锁, 如果使用CAS替换ThreadID成功, 就说明是第一次上锁, 那么当前线程就会获得对象的偏向锁, 此时会在对象头的Mark Word中记录当前线程ID和获取锁的时间epoch等信息, 然后执行同步代码块。
等到下一次线程在进入和退出同步代码块时就不需要进行CAS操作进行加锁和解锁, 只需要简单判断一下对象头的Mark Word中是否存储着指向当前线程的线程ID, 判断的标志当然是根据锁的标志位来判断的。
偏向锁的获取过程
全局安全点的理解会涉及到C语言底层的一些知识,
简单理解SafePaint是Java代码中的一个线程可能暂停执行的位置.
全局安全点(SafePaint)
偏向锁的对象头中有一个被称为epoch的值, 它作为偏差有效性的时间戳。
epoch值
图解偏向锁的获取过程
偏向锁在Java 6和Java 7里是默认启用的。
由于偏向锁是为了在只有一个线程执行同步块时提高性能,
如果确定应用程序里,所有的锁通常情况下处于竞争状态, 可以通过JVM参数关闭偏向锁, 那么程序默认会进入轻量级锁状态。
JVM参数-Xx:-UseBiasedLocking=false
关闭偏向锁
轻量级锁是指当前锁是偏向锁时,资源被另外的线程所访问,那么偏向锁就会升级为轻量级锁,
其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能
轻量级锁是什么?
紧接着上一步, 如果CAS操作替换ThreadID没有获取成功, 执行下一步
如果使用CAS操作替换ThreadID失败(这时候就切换到另外一个线程的角度) 说明该资源已被同步访问过,这时候就会执行锁的撒销操作,撤销偏向锁,然后等原持有偏向锁的线程到达全局安全点{Safe Point) 时, 会暂停原持有偏向锁的线程, 然后会检查原持有偏向锁的状态, 如果已经退出同步,就会唤醒持有偏向锁的线程,执行下一步
检查对象头中的Mark Word记录的是否是当前线程ID, 如果是, 执行同步代码, 如果不是, 执行偏向锁获取流程的第2步.
轻量锁详细获取过程
图解轻量锁详细获取过程
操作系统实现线程之间的切换,需要从用户态转换到核心态,
这个成本非常高, 状态之间的转换需要相对比较长的时间,
重量级锁的获取流程比较复杂
接着上面偏向锁的获取过程,由偏向锁升级为轻量级锁,执行下一步
会在原持有偏向锁的线程的栈中分配锁记录, 将对象头中的Mark Word拷贝到原持有偏向锁线程的记录中,然后原持有偏向锁的线程获得轻量级锁,然后唤醒原持有偏向锁的线程,从安全点处继续执行,执行完毕后,执行下一步,当前线程执行第4步
Mark Word中锁记录指针
执行完毕后,开始轻量级解锁操作,解锁需要判断两个条件判断对象头中的Mark Word中锁记录指针是否指向当前栈中记录的指针
拷贝在当前线程锁记录的Mark Word信息是否与对象头中的Mark Word一致。
如果上面两个判断条件都符合的话,就进行锁释放,如果其中一个条件不符合,就会释放锁,并唤起等待的线程,进行新一轮的锁竞争。
在当前线程的栈中分配锁记录, 拷贝对象头中的Mark Word到当前线程的锁记录中, 执行CAS加锁操作, 会把对象头Mark Word中锁记录指针指向当前线程锁记录, 如果成功, 获取轻量级锁,执行同步代码,然后执行第3步,如果不成功,执行下一步
轻量级锁就会升级为重量级锁
当前线程没有便用CAS成功获取锁, 就会自旋一会儿, 再次尝试获取, 如果在多次自旋到达上限后还没有获取到锁,那么轻量级锁就会升级为重量级锁
重量级锁的获取流程
图解重量锁详细获取过程
锁状态的分类
从多个线程并发访问资源(Synchronized实现细节)
在并发环境中,多个线程需要对同一资源进行访问,同一时刻只能有一个线程能够获取到锁并进行资源访问,那么剩下的这些线程怎么办呢?
这就好比食堂排队打饭的模型
最先到达食堂的人拥有最先买饭的权利,那么剩下的人就需要在第一个人后面排队,即每个人都能够买上饭。
对于正常排队的人来说,没有人插队,每个人都在等待排队打饭的机会,那么这种方式对每个人来说都是公平的,先来后到嘛。
公平锁,每个人都需要排队
这种锁也叫做公平锁。
【理想情况】
如果插队这个人后面没有人制止他这种行为,他就能够顺利买上饭,
如果有人制止,他就也得去队伍后面排队。
在排队过程中,就有个别人想走捷径,插队打饭,
那么假如插队的这个人成功买上饭并且在买饭的过程不管有没有人制止他,他的这种行为对正常排队的人来说都是不公平的,
非公平锁,插队失败
非公平锁,插队成功
这在锁的世界中也叫做非公平锁。
【现实情况】
以饭堂排队打饭举例
即先来先得的FIFO先进先出顺序
表示线程获取锁的顺序是按照线程加锁的顺序来分配的,
锁的分配机制是公平的
通常,先对锁提出获取请求的线程,会先被分配到锁
ReentrantLock在构造函数中,提供了是否公平锁的初始化方式,来定义公平锁。
加锁前,检查是否有排队等待的线程,优先排队等待的线程,先来先得
公平锁(Fair)
一种获取锁的抢占机制,是随机获得锁的
和公平锁不一样的是先来的不一定先得到锁,这个方式可能造成某些线程一直拿不到锁,结果也就是不公平的了。
JVM按随机、就近原则分配锁的机制
Java中的synchronized是非公平锁
ReentrantLock在构造函数中,提供了是否公平锁的初始化方式,默认为非公平锁。
ReentrantLock 默认的lock()方法,采用的是非公平锁。
除非程序有特殊需要,否则最常用非公平锁的分配机制。
加锁时,不考虑排队等待问题,直接尝试获取锁,获取不到自动到队尾等待
默认为非公平锁
非公平锁实际执行的效率要远远超出公平锁
非公平锁性能比公平锁高5~10倍
因为公平锁需要在多核的情况下维护一个队列
性能与执行效率更高
非公平锁(Nonfair)
在Java中, 一般通过ReetrantLock来实现锁的公平性
一般,通过ReetrantLock来实现锁的公平性
ReentrantLock是一把可重入锁, 也是一把互斥锁
它具有与synchronized相同的方法和监视器锁的语义, 但是它比synchronized有更多可扩展的功能
它可以由上次成功锁定但还未解锁的线程拥有。
当只有一个线程尝试加锁时, 该线程调用lock() 方法会立刻返回成功并直接获取锁,
如果当前线程已经拥有这把锁, 这个方法会立刻返回。
可以使用isHeldByCurrentThread和getHoldCount进行检查。
ReentrantLock的可重入性
private ReentrantLock lock = new ReentrantLock(fairness参数)
这个类的构造函数接受可选择的fairness参数
否则,当fairness设置为false时,锁不能保证每个线程的访问顺序,也就是非公平锁。
当fairness设置为true时, 在多线程争夺尝试加锁时,锁倾向于对等待时间最长的线程访问,这也是公平性的一种体现。
ReentrantLock的构造方法
与使用默认设置的程序相比,使用许多线程访问的公平锁的程序可能会显示较低的总体春吐量(即较慢:通常要慢得多).
但是获取锁并保证线程不会饥饿的次数比较小。
无论如何请注意:锁的公平性不能保证线程调度的公平性。
因此,便用公平锁的多线程之一可能会连续多次获得它,而其他活动线程没有进行且当前未持有该锁。这也是互斥性的一种体现。
锁的公平性不能保证线程调度的公平性
如果锁是可以获取的, 那么即使其他线程等待, 它仍然能够返回成功
tryLock方法不支持公平性
推荐使用下面的代码来进行加锁和解锁
ReentrantLock锁通过同一线程最多支持2147483647个递归锁。
尝试超过此限制,会导致锁定方法引发错误。
ReetrantLock基本概述
锁的公平性实例代码
创建了一个ReetrantLock, 并给构造函数传了一个true
也就是说, 把fair参数设置为true之后, 就可以实现一个公平锁了, 是这样吗?
回到示例代码,可以执行一下这段代码,它的输出是顺序获取的,也就是说创建了一个公平锁
ReetrantLock的构造函数
根据JavaDoc的注释可知, 如果是true的话, 那么就会创建一个Reentrant Lock的公平锁, 然后并创建一个FairSync,
其实是一个Sync的内部类,
它的主要作用是同步对象以获取公平锁,
图解FairSync源码
FairSync
是ReentrantLock中的内部类
Sync继承AbstractQueuedSynchronizer类
abstract static class Sync extends AbstractQueuedSynchronizer{..}
Sync
AbstractQueuedSynchronizer就是常说的AQS
它是JUC(java.util concurrent) 中最重要的一个类
独占多和共享锁,一般对应JDK源码的ReentrantLock和ReentrantReadWriteLock源码来介绍独占锁和共享锁
通过它来实现独占锁和共享锁
ReentrantLock
ReentrantReadWriteLock
Semaphore
CountDownLatch
ThreadPoolExecutor
主要有五类:
都可以实现公平锁和非公平锁。
可以看到, 所有实现了AQS的类都位于JUC包下
继承了AQS的类主要有
AQS
ReetrantLock的底层源码分析:它是如何实现锁的公平性的?
ReentrantLock是可以实现锁的公平性的, 那么原理是什么呢?
通过其源码来了解一下ReentrantLock是如何实现锁的公平性的
abstract void lock O;
lock是抽象方法,是需要被子类实现的
跟踪其源码发现, 调用Lock.lock()方法,其实是调用了sync的内部的方法
而sync是最基础的同步控制Lock的类,
即使用AQS状态,代表锁持有的数量。
它继承AbstractQueuedSynchronizer
公平锁FairSync的继承关系
非公平锁的NonFairSync的继承关系
从源码发现,公平锁和非公平锁的实现就是下面这段代码的区别
通过上图中的源代码对比, 可以明显的看出公平锁与非公平锁的lock方法唯一的区别
源码分析公平锁和非公平锁的实现的区别
也是AQS中的方法,
它主要是用来查询是否有任何线程在等待获取锁的时间比当前线程长
每个等待线程都是在一个队列中,此方法就是判断队列中在当前线程获取锁时, 是否有等待锁时间比自己还长的队列,
如果当前线程之前有排队的线程, 返回true, 如果当前线程位于队列的开头或队列为空, 返回false
公平锁在获取同步状态时,多了一个限制条件:hasQueuedPredecessors,
它有公平锁和非公平锁的实现
由继承图可以看到,两个类的继承关系都是相同的
sync
公平锁,就是通过同步队列,来实现多个线程,按照申请锁的顺序,来获取锁,从而实现公平的特性
非公平锁,加锁时,不考虑排队等待问题,直接尝试获取锁,所以存在后,申请却先获得锁的情况。
综上
ReentrantLock如何实现锁公平性? 源码分析原理
与公平性相对的就是非公平性, 通过设置fair参数为true, 便实现了一个公平锁,
与之相对的, 把fair参数设置为false, 是不是就是非公平锁了?用事实证明一下
private ReentrantLock lock = new ReentrantLock(false) ;
看看输出(部分输出)
可以看到,线程的启动并没有按顺序获取,可以看出非公平锁对锁的获取是乱序的,即有一个抢占锁的过程。
也就是说, 把fair参数设置为false便实现了一个非公平锁。
其他代码不变,执行一下看看输出(部分输出)
锁的非公平性
ReetrantLock与锁公平性的实现
从锁的公平性
又称为递归锁
指在同一个线程,在外层方法获取锁时,再进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象或者class)
不会因为之前已经获取过,还没释放,而阻塞
Java中Reentrant Lock和synchronized都是可重入锁
Java中的可重入锁
在一定程度上可以避免死锁。
一个线程持有者
一个计数器
每个锁关联
当计数器为0时,表示该锁没有被任何线程持有,那么任何线程都可以获得该锁而调用方法。
当一个线程请求成功后,JVM会记下持有锁的线程,并将计数器记为1。
此时其它线程请求该锁,则必须等待,而持有锁的线程如果再次请求这个锁,就可以再次拿到锁,同时计数器会递增。
当线程退出一个synchronized方法/块时,计数器会递减。
如果计数器为0,则释放该锁
请说明一下synchronized的可重入怎么实现?
代码来说明一下synchronized的可重入性
在上面这段代码中, 对doSomething和doSomethingElse分别使用了synchronized进行锁定,
doSomething方法中调用了doSomethingElse方法, 因为synchronized是可重入锁,
所以同一个线程在调用doSomething方法时, 也能够进入doSomethingElse方法中。
可重入锁,也叫做递归锁
指的是同一线程 外层函数获得锁之后 ,内层递归函数仍然有获取该锁的代码,但不受影响。
线程得到一个对象锁后再次请求该对象锁,是允许的
实现方法
本文里面讲的是广义上的可重入锁,而不是单指JAVA下的ReentrantLock。
能完成synchronized所能完成的所有工作
可响应中断锁
可轮询锁请求
定时锁
提供了避免多线程死锁的方法
一种可重入锁
void lock()
此锁是否有任意线程占用
isLock()
tryLock():尝试获得锁,仅在调用时锁未被线程占用,获得锁
tryLock()只是\"试图\
该方法和lock()的区别在于
boolean tryLock()
如果锁在给定等待时间内没有被另一个线程保持,则获取该锁。
tryLock(long timeout TimeUnit unit)
void unlock()
条件对象,获取等待通知组件。
该组件和当前的锁绑定,当前线程只有获取了锁,才能调用该组件的await()方法,而调用后,当前线程将缩放锁。
Condition newCondition()
查询当前线程保持此锁的次数,也就是执行此线程执行lock方法的次数。
getHoldCount()
返回正等待获取此锁的线程估计数,
比如启动10个线程,1个线程获得锁,此时返回的是9
getQueueLength()
返回等待与此锁相关的给定条件的线程估计数。
比如10个线程,用同一个condition对象,并且此时这10个线程都执行了condition对象的await方法,那么此时执行此方法返回10
getWaitQueueLength:(Condition condition)
查询是否有线程等待与此锁有关的给定条件(condition),对于指定contidion对象,有多少线程执行了condition.await方法
hasWaiters(Condition condition)
查询给定线程是否等待获取此锁
hasQueuedThread(Thread thread)
是否有线程等待此锁
hasQueuedThreads()
该锁是否公平锁
isFair()
当前线程是否保持锁锁定,线程的执行lock方法的前后分别是false和true
isHeldByCurrentThread()
如果当前线程未被中断,获取锁
lockInterruptibly()
Lock接口的主要方法
通过fair这个boolean型变量设置
在构造函数中设置
这个函数会判断当前获取锁的线程是否是请求队列的首部线程
是通过hasQueuedPredecessors()函数实现的
可实现公平锁
绑定多个条件(Condition)
等待可中断
通过setExclusiveOwnerThread()实现
可重入锁
特点
Reentrantlock默认实现在sync中(sync继承了AQS)
非公平锁NonfairSync(继承sync)
公平锁FairSync(继承sync)
内部类
tryLock:能获得锁就返回true,不能就立即返回false,
lock:能获得锁就返回true,不能的话一直等待获得锁
lock不会抛出异常
lockInterruptibly会抛出异常。
lock和lockInterruptibly:如果两个线程分别执行这两个方法,但此时中断这两个线程
tryLock和lock和lockInterruptibly的区别
Condition类的await方法和Object类的wait方法等效
Condition类的signal方法和Object类的notify方法等效
Condition类的signalAll方法和Object类的notifyAll方法等效
ReentrantLock类可以唤醒指定条件的线程,而object的唤醒是随机的
Condition类和Object类锁方法区别区别
ReentrantLock可重入锁
synchronized是Java中的关键字,是JVM层面的底层啥都帮我们做了,
synchronized是JVM级别的,synchronized是内置的语言实现。
synchronized是关键字,lock是接口
Lock是一个接口,是JDK层面的有丰富的API。
ReentantLock继承接口Lock,并实现了接口中定义的方法
ReentrantLock是API级别的
【关键字与接口】
synchronized会自动释放锁,会被JVM自动解锁
synchronized在发生异常时,会自动释放线程占有的锁,因此不会导致死锁现象发生;
发生异常时,synchronized会自动释放锁,
synchronized隐式获得释放锁
发生异常时,Lock必须手动释放锁。lock需要手动释放锁
而Lock在发生异常时,如果没有主动通过unLock()去释放锁,则很可能造成死锁现象,因此使用Lock时需要在finally块中释放锁。
ReentrantLock通过方法lock()与unlock()来进行加锁与解锁操作,加锁后需要手动进行解锁。
为了避免程序出现异常而无法正常解锁的情况,使用ReentrantLock必须在finally控制块中进行解锁操作。
ReentrantLock显式的获得、释放锁
【释放锁的方式】
synchronized在发生异常时,会自动释放线程占有的锁,不会导致死锁的发生。
而Lock在发生异常时,如果你没有在finally中主动unlock()去释放锁,就很有可能导致死锁。
【是否会导致死锁】
synchronized是不可中断的,使用synchronized时,等待的线程会一直等待下去,不能够响应中断。
synchronized是不可以响应中断的,为处理锁的不可用性提供了更高的灵活性
synchronized不可以
font color=\"#c41230\
lock可以实现等待可中断
Lock可以中断也可以不中断。Lock可以让等待锁的线程响应中断
ReentrantLock可响应中断、可轮回,
【是否可中断】
通过Lock可以知道线程有没有成功获取、拿到锁
通过tryAcquire()去尝试获取锁,获取成功则设置锁状态并返回true,否则返回false。
lock能够知道是否成功获取到锁,
synchronized不能,无法办到。synchronized则不行
【判断是否拿到锁】
synchronized能锁住方法和代码块
Lock只能锁住代码块
【作用范围】
synchronized是非公平锁
lock可以实现公平锁
ReentrantLock可以实现公平锁
ReentrantLock可以控制是否是公平锁。
【公平锁与非公平锁】
synchronized是同步阻塞,使用的是悲观并发策略
lock是同步非阻塞,采用的是乐观并发策略
【底层实现】
当它修饰一个方法或者一个代码块时,能够保证在同一时刻最多只有一个线程执行该代码(互斥的体现)
synchronized
synchronized能做的他都能做
Lock
【原理】
lock可以实现读写锁
Lock可以使用读锁,提高多线程读效率
Lock可以提高多个线程进行读操作的效率,实现读写锁等。
ReentrantLock通过Condition可以绑定多个条件
ReentrantLock相比synchronized的优势是可中断、公平锁、多个锁。
劣势:锁升级不可逆
【其他】
两者的不同点
都是用来协调多线程对共享对象、变量的访问
JDK1.5之前Lock效率高很多
JDK1.6之后效率差不多
在竞争激烈的情况下,lock效率更高
在JAVA环境下 ,ReentrantLock 和synchronized 都是 可重入锁,同一线程可以多次获得同一个锁
都保证了可见性和互斥性
两者的共同点
synchronized和 Lock、ReentrantLock的区别
ReentrantLock与可重入锁(递归锁)
如果synchronized是不可重入锁, 那么在调用doSomethingElse方法时, 必须把doSomething的锁丢掉,
实际上该对象锁已被当前线程所持有, 且无法释放, 所以此时会出现死锁。
不可重入锁会造成死锁
不可重入锁
从锁是否重复获取
共享锁和独占锁
java并发包提供的加锁模式分为独占锁和共享锁。
指的是锁能够被多个线程所拥有
如果某个线程对资源加上共享锁后,则其他线程只能对资源再加共享锁,不能加排它锁,
获得共享锁的线程只能读数据,不能修改数据。
共享锁则允许多个线程同时获取锁,并发访问 共享资源,如:ReadWriteLock。
共享锁则是一种乐观锁,它放宽了加锁策略,允许多个执行读操作的线程同时访问共享资源。
AQS的内部类Node定义了两个常量SHARED和EXCLUSIVE,他们分别标识 AQS队列中等待线程的锁获取模式。
java的并发包中提供了ReadWriteLock,读-写锁。
它允许一个资源可以被多个读操作访问,或者被一个 写操作访问,但两者不能同时进行。
共享锁
又叫做排他锁,
是指锁在同一时刻只能被一个线程拥有,其他线程想要访问资源,就会被阻塞。
独占锁模式下,每次只能有一个线程能持有锁,
ReentrantLock就是以独占方式实现的互斥锁。
JDK中synchronized和JUC中Lock的实现类,就是互斥锁。
独占锁是一种悲观保守的加锁策略,它避免了读/读冲突,
如果某个只读线程获取锁,则其他读线程都只能等待,这种情况下就限制了不必要的并发性,因为读操作并不会影响数据的一致性。
排他锁/独占锁
源码说明
ReadLock和WriteLock, 也就是一个读锁一个写锁, 合在一起叫做读写锁。
再进一步观察可以发现ReadLock和WiteLock是靠内部类Sync实现的锁
Sync是继承于AQS子类的,
AQS是并发的根本
这种结构在其他JUC包的工具类里面也都存在,主要有这么五类
在ReentrantReadWriteLock里面, 读锁和写锁的锁主体都是Sync
读锁的共享锁可保证井发读非常高效,而读写、写读、写写的过程互斥
因为读锁和写锁是分离的, 所以ReentrantReadWriteLock的井发性相比一般的互斥锁有了很大提升
但读锁和写锁的加锁方式不一样,读锁是共享锁,写锁是独享锁。
ReentrantReadWiteLock有两把锁
从多个线程能否获取同一把锁
Hotspot的作者经过以往的研究发现大多数情况下锁不仅不存在多线程竞争,而且总是由同一线程多次获得。
偏向锁的目的是在某个线程获得锁之后,消除这个线程锁重入(CAS)的开销,看起来让这个线程得到了偏护。
引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取及释放依赖多次CAS原子指令,而偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令(由于一旦出现多线程竞争的情况就必须撤销偏向锁,所以偏向锁的撤销操作的性能损耗必须小于节省下来的CAS原子指令的性能消耗)。
上面说过,轻量级锁是为了在线程交替执行同步块时提高性能,而偏向锁则是在只有一个线程执行同步块时进一步提高性能。
如果成功,则说明该线程已经获取了对象的偏向锁
CAS操作:将线程ID保存在对象的Mark Word中
对象锁定时膨胀为轻量级锁
对象未锁定时恢复到未锁定状态
有其他线程获取对象锁时失效
经验依据:在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得
将对象的Mark Word更新为指向Lock Record的指针
如果成功,则该线程已经获取了对象的轻量级锁
如果指向当前线程,则进入同步代码块
如果没有,则膨胀为重量级锁
如果失败,检查对象的Mark Word是否指向当前线程
CAS操作
随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁(但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级)。
“轻量级”是相对于使用操作系统互斥量来实现的传统锁而言的。
但是,首先需要强调一点的是,轻量级锁并不是用来代替重量级锁的,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用产生的性能消耗。
在解释轻量级锁的执行过程之前,先明白一点,轻量级锁所适应的场景是线程交替执行同步块的情况,如果存在同一时间访问同一锁的情况,就会导致轻量级锁膨胀为重量级锁。
锁升级
如果存在同一时间访问同一锁的场合,就会导致轻量级锁膨胀为重量级锁。
经验依据:对绝大部分的锁,在整个同步周期内都不存在竞争
Synchronized是通过对象内部的一个叫做监视器锁(monitor)来实现的。
但是监视器锁本质又是依赖于底层的操作系统的Mutex Lock来实现的。
而操作系统实现线程之间的切换这就需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间
依赖于操作系统Mutex Lock所实现的锁,称之为“重量级锁”。
JDK中对Synchronized做的种种优化,其核心都是为了减少这种重量级锁的使用。
JDK1.6以后,为了减少获得锁和释放锁所带来的性能消耗,提高性能,引入了“轻量级锁”和“偏向锁”。
重量级锁(Mutex Lock)
四种锁的状态
安全失败
对于HashMap而言,最重要的两个方法是get与set方法
如果对整个HashMap加锁,可以得到线程安全的对象,但是加锁粒度太大。
ConcurrentHashMap 和 HashMap 思路是差不多的,但是因为它支持并发操作,所以要复杂一些。
Segment的大小也被称为ConcurrentHashMap的并发度。
高性能的HashMap
减小锁粒度是指缩小锁定对象的范围,从而减小锁冲突的可能性,从而提高系统的并发能力。
将大对象(这个对象可能会被很多线程访问),拆成小对象,大大增加并行度,降低锁竞争。
降低了锁的竞争,偏向锁,轻量级锁成功率才会提高。
减小锁粒度是一种削弱多线程锁竞争的有效手段,
这种技术典型的应用是ConcurrentHashMap类的实现。
最最典型的减小锁粒度的案例就是ConcurrentHashMap。
减小锁粒度
ConcurrentHashMap是学习分段锁的最好实践
ConcurrentHashMap并发
例如ConcurrentHashMap
segment分段锁
注意,也可以用“槽”来代表一个 segment。
Segment 代表”部分“或”一段“的意思,所以很多地方都会将其描述为分段锁。
Segment是一种可重入锁ReentrantLock
继承了reentranLock
尝试获取锁存在并发竞争 自旋 阻塞
整个 ConcurrentHashMap 由一个个 Segment 组成
在ConcurrentHashMap里扮演锁的角色
chm由一个segment数组组成
一个ConcurrentHashMap里包含一个Segment数组
Segment的结构和HashMap类似,是一种数组和链表结构
Segment数组结构(Segment段)
每个segment元素里包含一个HashEntry数组
每个Segment守护一个HashEntry数组里的元素
当对HashEntry数组的数据进行修改时,必须首先获得它对应的Segment锁。
Segment与HashEntry数组
HashEntry
用于存储键值对数据。
每个HashEntry是一个链表结构的元素
每个HashEntry包含一个链表
final k key
volatile V value
final int hash
HashEntry大部分成员变量都为final
HashEntry数组结构
concurrencyLevel:并行级别、并发数、Segment 数,怎么翻译不重要,理解它。
默认是 16,也就是说 ConcurrentHashMap 有 16 个 Segments,
所以理论上,这个时候,最多可以同时支持 16 个线程并发写,只要它们的操作分别分布在不同的 Segment 上。
这个值可以在初始化的时候设置为其他值,但是一旦初始化以后,它是不可以扩容的。
再具体到每个 Segment 内部,其实每个 Segment 很像之前介绍的 HashMap,不过它要保证线程安全,所以处理起来要麻烦些。
并行度(默认16)
图解ConcurrentHashMap结构
Java7 ConcurrentHashMap结构
图解Java7 ConcurrentHashMap结构
1.7ConcurrentHashMap的组成:segment数组+HashEntry数组(数组+链表)
数组+链表+红黑树
Java8 对 ConcurrentHashMap 进行了比较大的改动,Java8 也引入了红黑树。
Java8实现 (引入了红黑树)
再失败就sync保证
cas失败自旋保证成功
CAS+synchronized
node
图解Java8 ConcurrentHashMap结构
1.8 ConcurrentHashMap的组成:数组+链表+红黑树
ConcurrentHashMap底层实现
简单理解就是,ConcurrentHashMap 是一个 Segment 数组
Segment 通过继承 ReentrantLock 来进行加锁,所以每次需要加锁的操作锁住的是一个 segment,
这样只要保证每个 Segment 是线程安全的,也就实现了全局的线程安全。
线程安全(Segment 继承 ReentrantLock 加锁)
ssize-1
SegmentMask
32-sshift
SegmentShift
是大于ConcurrentLevel的最小二次幂
ssize
第一次Hash定位到Segment
定位方法和HashMap中的indexFor()相同
第二次Hash定位到元素所在的链表的头部
通过segment.lock加锁
1.7 通过两次hash确定
通过CAS+synchronized加锁
1.如果没有hash冲突就直接通过CAS插入
2.如果有hash冲突或者CAS操作失败,说明存在并发情况,使用synchronized加锁
3.如果插入成功就调用addCount()方法统计size,并且检查是否需要扩容
1.8通过两次hash确定
基本流程
1.判断是否被其他线程初始化,这里使用了getObjectVolatile()方法2.使用segment[0]的属性来初始化其他槽3.使用while()循环,内部使用CAS操作,尝试初始化槽
1.ensureSegment
2.segment.put()
源码分析
put()
get不需要加锁,因为HashEntry的value值设定为了volatile
如果get()到的是null值,则可能这个key,value对正在put的过程中,如果出现这种情况,那么就通过lock加锁来保证取出的value是完整的
volatile修饰节点指针
get高效 volatile修饰 不需要加锁
get()
先根据ConcurrentLevel构造出Segment数组
Segment数组大小是不大于concurrentLevel的最大的2的指数
每个hashEntry的大小为大于initialCapacity/concurrentLevel的最小二次幂
每个Segment中的HashEntry数组的大小都是大于指定大小的最小二次幂
initialCapacity(每个HashEntry的长度)
loadFactor:扩容因子
concurrencyLevel:并发度,指Segment数组的长度
初始参数
构造函数
在定位到待删除元素的位置以后,程序就将待删除元素前面的那一些元素全部复制一遍,然后再一个一个重新接到链表上去。尾结点指向e的下一个结点。e后面的结点不需要复制,它们可以重用。
因为HashEntry中的next是final,所以只能先把待删除之前的元素复制了再删除
remove
size操作就是遍历了两次Segment,每次记录Segment的modCount值,然后将两次的modCount进行比较,如果相同,则表示期间没有发生过写入操作,就将原先遍历的结果返回,如果不相同,就需要将所有的Segment都锁住,然后一个一个遍历了,
size
resize()
分段锁也并非一种实际的锁,而是一种思想
ConcurrentHashMap内部细分了若干个小的HashMap,称之为段(Segment)。
默认情况下一个ConcurrentHashMap被进一步细分为16个段,既就是锁的并发度。
并不是将整个HashMap加锁,
而是首先根据hashcode得到该表项应该存放在哪个段中,然后对该段加锁,并完成put操作。
如果需要在ConcurrentHashMap中添加一个新的表项
在多线程环境中,如果多个线程同时进行put操作,只要被加入的表项不存放在同一个段中,则线程间可以做到真正的并行。
ConcurrentHashMap分段锁
分段锁与减小锁粒度
最常见的锁分离就是读写锁ReadWriteLock
根据功能进行分离成读锁和写锁,这样读读不互斥,读写互斥,写写互斥,即保证了线程安全,又提高了性能
读写分离思想可以延伸,只要操作互不影响,锁就可以分离。比如LinkedBlockingQueue 从头部取出,从尾部放数据
在读的地方使用读锁,
在写的地方使用写锁
为了提高性能,Java提供了读写锁,灵活控制
如果没有写锁的情况下,读是无阻塞的,在一定程度上提高了程序的执行效率。
读写锁分为读锁和写锁,多个读锁不互斥,读锁与写锁互斥,这是由jvm自己控制的,你只要上好相应的锁即可。
如果你的代码只读数据,可以很多人同时读,但不能同时写,那就上读锁
读锁
如果你的代码修改数据,只能有一个人在写,且不能同时读取,那就上写锁。
写锁
总之,读的时候上读锁,写的时候上写锁!
Java中读写锁有个接口java.util.concurrent.locks.ReadWriteLock,也有具体的实现ReentrantReadWriteLock。
ReadWriteLock读写锁
读写锁与锁分离
通常情况下,为了保证多线程间的有效并发,会要求每个线程持有锁的时间尽量短,即在使用完公共资源后,应该立即释放锁。
但是,凡事都有一个度,如果对同一个锁不停的进行请求、同步和释放,其本身也会消耗系统宝贵的资源,反而不利于性能的优化 。
锁粗化
对于一些代码上要求同步,但实际上并不需要同步的锁进行消除
锁消除是在编译器级别的事情。
在即时编译器时,如果发现不可能被共享的对象,则可以消除这些对象的锁操作,多数是因为程序员编码不规范引起。
锁消除
只用在有线程安全要求的程序上加锁
减少锁持有时间
尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁,
好处:减少线程上下文切换的消耗
缺点:循环会消耗CPU。
锁优化
参考:https://www.jianshu.com/p/39628e1180a9
Java锁分类
0 条评论
回复 删除
下一页