Java中常见的锁及其优化

常见锁

作为并发编程的一部分,锁机制是必不可少的,常见的锁有以下几种:
乐观锁、悲观锁、自旋锁、同步锁、递归锁、重量级锁、轻量级锁、偏向锁、分段锁,下面就来一一介绍一下这些锁:

1.乐观锁

乐观锁是一种乐观思想,它主要用在读多写少的场景。它认为别的线程在拿数据的时候只负责拿,并不会对数据进行改变,所以不会上锁。但是它在更新的时候会判断一下在此期间别人有没有对数据进行更新,采取先时先读当前的版本号,然后再进行加锁操作(比较当前的版本和上一次的版本号是否相等,如果一样就更新,如果不一样则重新进行CAS操作);

Java中的乐观锁都是用CAS实现的,CAS是原子性操作,比较当前值跟传入值是否相等,一样则更新,不一样则失败。

问题: CAS(CompareAndSwap)

即“比较和交换”,它是乐观锁的使用机制。
在CAS中有三个操作数,要更新的变量V,预期值E,新值U。当且仅当预期值E和内存值V相等时,将变量V修改为U,如果V和E的值不同,则说明已经有其他线程对V和E进行了更改,此时线程什么都不做。最后CAS返回当前变量V的真实值。需要程序员注意的是,CAS操作会导致著名的"ABA"问题,可以使用版本号机制解决

什么是ABA问题?
举个例子:现在有两个线程T1和T2,他们同时拿到变量A的地址对其进行操作,根据CAS操作机制,线程T1首先将变量A改为变量B,然后再次改成变量A,线程T2接着运行。此时线程T2发现变量A没有发生变化,然后将A变为B,由于是乐观锁,它觉得自己改的一点毛病都没有。其实A已经被T1改过一次又改了回来。

利用版本号机制解决ABA问题

在类的内部加上一个final version字段,每一次修改前都验证修改时的版本号与类中当前版本号是否一致,如果相等CAS修改,不相等则修改失败。
JDK中的AtomicStampedReference和AtomicMarkableReference这两个类就是使用版本号机制来解决AtomicInteger的ABA问题。只不过前者可以杜绝,而后者只是减小问题发生的概率;


2.悲观锁

悲观锁使用在读少写多的地方,它认为所有的线程来拿数据的时候都会修改数据,所以在每次读数据的时候都会加锁,这样其他线程需要拿到数据的时候就必须阻塞等待锁被释放在竞争拿到锁,才可以拿到数据。Java中的synchronized就是悲观锁。


3.自旋锁

自旋锁原理很简单,如果持有锁的线程能在很短的时间内完成操作然后释放锁,那么那些等待竞争锁的线程就不会进入阻塞状态,而是一直处于active状态(自旋),等到持有锁的线程释放锁之后即可获取锁,这样就避免了线程和内核切换的资源消耗。
由于线程自旋需要消耗CPU,如果一直拿不到锁,那么线程就一直会自旋下去,所以无故消耗了CPU的资源,所以需要给线程设置一个自旋的最大时间,当线程自旋超过该最大时间之后还没有获得锁,这时争用线程会停止自旋进入阻塞状态。

JDK 1.6之后引入了适应性自旋锁,其自旋时间由前一次同一个锁上的自旋时间和锁的拥有者的状态来决定,基本认为一个线程上下文的切换时间是最佳时间。

自旋锁的优缺点:
优点: 尽可能减少线程的阻塞,提高系统的性能。
缺点: 对于线程竞争比较激烈,后者持有锁的线程释放锁资源的时间太长,会引起自旋线程一直旋下去,造成阻塞和CPU占用。


4.同步锁

即synchronized,他可以把所有非null的对象当作锁。它属于独占式的悲观锁,也属于可重入锁(即下文的递归锁)。
Synchronized的作用范围
1.作用于方法时锁定的是对象的实例(this);
2.作用于静态方法时锁住的是整个类的实例,因为Class的相关数据存储在永久带(静态方法区)中,永久带又是线程共享的,因此静态发放锁相当于类的全局锁,会锁住所有调用方法的线程。
3.synchronized作用于对实例时,锁住的是以该对象为锁的所有同步代码块。

竞争切换:Owner线程在unlock之后并不会把锁直接让给OnDeck,而是将锁重新被EntryList中的线程竞争获得;

Synchronized的实现过程

在这里插入图片描述

ContentionList : 竞争队列,所有需要获取锁资源的线程一开始都存放在竞争队列里;
EntryList :ContentionList中有资格成为候选资源的线程从ContentionList中转移到EntryList;
OnDeck : 候选者线程,任意时刻只有一个线程可以请求到锁资源,这个线程就是OnDeck;
Owner : 持有锁线程,当前已经获取锁资源可以执行的线程为Owner;
WaitSet :等待队列,调用过wait方法被阻塞的线程被放在等待队列等待被唤醒,唤醒之后重新进入EntryList;

synchronized锁的几大特征
1)synchronized属于不公平锁;Synchronized 在线程进入 ContentionList 时,等待的线程会先
尝试自旋获取锁,如果获取不到就进入 ContentionList,这明显对于已经进入队列的线程是
不公平的,还有一个不公平的事情就是自旋获取锁的线程还可能直接抢占 OnDeck 线程的锁
资源。
2)每个对象都有monitor对象,加锁就是在竞争monitor对象;
3)synchronized是一个重量级锁,,需要调用操作系统相关接口,性能是低效的,有可能给线
程加锁消耗的时间比有用操作消耗的时间更多。
4)Java1.6,synchronized 进行了很多的优化,有适应自旋、锁消除、锁粗化、轻量级锁及偏向
锁等,效率有了本质上的提高。在之后推出的 Java1.7 与 1.8 中,均对该关键字的实现机理做
了优化。引入了偏向锁和轻量级锁。都是在对象头中有标记位,不需要经过操作系统加锁。
5)锁可以从偏向锁升级到轻量级锁,再升级到重量级锁。这种升级过程叫做锁膨胀;


5.ReentrantLock

ReentantLock 继承接口 Lock 并实现了接口中定义的方法,他是一种可重入锁,除了能完
成 synchronized 所能完成的所有工作外,还提供了诸如可响应中断锁、可轮询锁请求、定时锁等
避免多线程死锁的方法。
reentrantlock的常用方法
1)void lock():如果锁处于空闲,那么就会获得锁。
2)boolean trylock():尝试获取锁资源,如果锁不可用并不会导致线程被禁用;
3)void unlock():释放当前线程所持有的锁资源;
4)isFair():判断是否是公平锁;

reentrantloack可以通过构造函数设置锁时候为公平锁:
Lock lock=new ReentrantLock(true);
true为公平锁,false 为不公平锁;

ReentrantLock和Synchronized的区别

  • ReentrantLock通过lock()和unlock()方法来实现加锁和解锁,必须手动执行;Synchronized会被JVM自动解锁;
  • Reentrantock可中断,是个公平锁,一次性可以锁住多个类;

6.可重入锁

可重入锁,也叫做递归锁,指的是同一线程 外层函数获得锁之后 ,内层递归函数仍然有获取该锁的代码,但不受影响。在 JAVA 环境下 ReentrantLock 和 synchronized 都是 可重入锁。

重入锁和不可重入锁的区别(关键在于拿到锁资源之后能不能再次以不同的方式访问临界资源而不会被阻塞):
对于不可重入锁,只判断这个锁有没有被占用,只要被锁上的线程都会等待;
可重入锁,不仅判断有没有被锁上,还会判断是谁锁的,当判断是自己锁住的时候,就可以再次访问临界资源,并把加锁次数加1;


7.公平锁和不公平锁

  • 公平锁(Fair)
    加锁前检查是否有排队等待的线程,优先排队等待的线程,先来先得
  • 非公平锁(Nonfair)
    加锁时不考虑排队等待问题,直接尝试获取锁,获取不到自动到队尾等待
  1. 非公平锁性能比公平锁高 5~10 倍,因为公平锁需要在多核的情况下维护一个队列
  2. Java 中的 synchronized 是非公平锁,ReentrantLock 默认的 lock()方法采用的是非公平锁。

8.读写锁

为了提高性能,Java 提供了读写锁,在读的地方使用读锁,在写的地方使用写锁,灵活控制,如
果没有写锁的情况下,读是无阻塞的,在一定程度上提高了程序的执行效率。读写锁分为读锁和写
锁,多个读锁不互斥,读锁与写锁互斥,这是由 jvm 自己控制的,你只要上好相应的锁即可。

  • 读锁
    如果你的代码只读数据,可以很多人同时读,但不能同时写,那就上读锁
  • 写锁
    如果你的代码修改数据,只能有一个人在写,且不能同时读取,那就上写锁。总之,读的时候上
    读锁,写的时候上写锁!
    Java 中 读 写 锁 有 个 接 口 java.util.concurrent.locks.ReadWriteLock , 也 有 具 体 的 实 现
    ReentrantReadWriteLock。

9.共享锁和独占锁

  • 独占锁
    独占模式下,每次只能有一个线程加锁,Reentrantlock就是以独占的方式实现的互斥锁,独占锁是一种悲观保守的加锁策略,它避免了读/读冲突,如果某个只读线程获取锁,则其他读线程都只能等待,这种情况下就限制了不必要的并发性,因为读操作并不会影响数据的一致性。
  • 共享锁
    -共享锁则允许多个线程同时获取锁,并发访问 共享资源,如:ReadWriteLock。共享锁则是一种乐观锁,它放宽了加锁策略,允许多个执行读操作的线程同时访问共享资源。

10.偏向锁

锁的状态有四种:
无锁状态——>偏向锁——>轻量级锁 ——>重量级锁,若存在锁的竞争问题,按照这个方向可以进行锁升级。

  • 为什么要引进偏向锁?
    因为经过HotSpot的作者大量的研究发现,大多数时候是不存在锁竞争的,常常是一个线程多次获得同一个锁,因此如果每次都要竞争锁会增大很多没有必要付出的代价,为了降低获取锁的代价,才引入的偏向锁。

    轻量级锁的获取和释放依赖于多次的CAS操作,而偏向锁只需要置换ThreadID的时候依赖一次CAS操作,因为偏向锁只有一个线程在运行。偏向锁无法进行自旋,因为一旦有另一个线程竞争锁,偏向锁就会升级为轻量级锁。

  • 偏向锁的目的
    在某个线程获取锁之后,消除这个线程锁重入(CAS)的开销,看起来让这个线程得到了偏袒。


11.轻量级锁

  • 轻量级锁是相对于重量级锁而言的。使用轻量级锁时,不需要申请互斥量,仅仅将Mark Word中的部分字节CAS更新指向线程栈中的Lock Record,如果更新成功,则轻量级锁获取成功,记录锁状态为轻量级锁;否则,说明已经有线程获得了轻量级锁,目前发生了锁竞争(不适合继续使用轻量级锁),接下来膨胀为重量级锁。

轻量级锁适用于线程交替执行的同步块中,如果存在同一时间访问同一把锁的情况,如果锁竞争不激烈那么可以自旋,否则就将轻量级锁升级为重量级锁;

12.重量级锁

  • Synchronized 是通过对象内部的一个叫做监视器锁(monitor)来实现的。但是监视器锁本质又
    是依赖于底层的操作系统的 Mutex Lock 来实现的。而操作系统实现线程之间的切换这就需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么
    Synchronized 效率低的原因。因此,这种依赖于操作系统 Mutex Lock 所实现的锁我们称之为
    “重量级锁”。
  • JDK1.6 以后,为了减少获得锁和释放锁所带来的性能消耗,提高性能,引入了“轻量级锁”和
    “偏向锁”。

13.分段锁

分段锁是一种思想,可以理解为:将一个容器分为若干段,每段都加一个锁,当一个线程访问一段数据时,其他线程也能访问别的数据,保证了数据的完整性,而且有效的提高了高并发访问的效率。CurrentHashMap就实现了这种思想。


锁优化

既然锁有一系列的缺点,比如说自旋锁可能会造成CPU资源浪费,同步锁由于底层有监视器锁所以资源消耗大等等,所以以下几种锁的优化方式:

  • 减少锁持有的时间
    只在需要加锁的方法或变量上加锁;
  • 降低锁粒度
    将大对象(这个对象可能会被很多线程访问),拆成小对象,大大增加并行度,降低锁竞争。
    降低了锁的竞争,偏向锁,轻量级锁成功率才会提高。最最典型的减小锁粒度的案例就是
    ConcurrentHashMap。、
  • 锁分离
    最常见的锁分离就是读写锁 ReadWriteLock,根据功能进行分离成读锁和写锁,这样读读不互
    斥,读写互斥,写写互斥,即保证了线程安全,又提高了性能;
  • 锁粗化
    通常情况下,为了保证多线程间的有效并发,会要求每个线程持有锁的时间尽量短,即在使用完
    公共资源后,应该立即释放锁。但是,凡事都有一个度,如果对同一个锁不停的进行请求、同步
    和释放,其本身也会消耗系统宝贵的资源,反而不利于性能的优化 。
  • 锁消除
    锁消除是在编译器级别的事情。在即时编译器时,如果发现不可能被共享的对象,则可以消除这
    些对象的锁操作,多数是因为程序员编码不规范引起。
  • 0
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值