【JAVA核心知识】19:JAVA中的各种锁


Java提供了丰富的锁,每种锁都有各自的特性,合理的利用锁能显著提高效率。要根据使用场景选择合适的锁需要了解Java各种的锁的分类与定义:

乐观锁与悲观锁

乐观锁

乐观锁持有较为乐观的思想,即认为遭遇并发的的几率较低。读数据时都认为不会有人修改数据,不加锁直接读,写数据时则会在进行更新时判断数据是否已经被修改了(即自己更新的旧值与实际的数据值相比较,一样则代表没有人修改过),没有修改过则更新,修改过则放弃更新。
java中的乐观锁基本都是通过CAS操作实现的,CAS即Compare and swap,是一种原子级的更新操作,比较当前值和预测值是否一致,一致则更新赋值为新值,否则放弃更新。

悲观锁

悲观锁持有一直较为悲观的思想,即认为遭遇并发的几率很高,无论自己是读还是写,都会有人会抢着写,因此在读写之前都先进行加锁,独占数据进行操作。别人只能等待自己操作完数据释放锁之后再进行操作。

举个例子

仓库新到一批物资,仓库管理员之一的张三需要核对新入库的物资,乐观锁的话张三认为自己在核对期间不会有其他仓库管理员进行物资出入库,核对完毕之后,对比仓库现有物资和自己作为核对基准的旧物资信息是否一致,一致的话就进行物资入库并更新仓库物料信息,不一致的话就放弃这次核对的数据。 悲观锁的话张三会直接关闭仓库,不允许其他仓库管理员查看和更改物资信息,知道自己完成此次新物资入库的数据更新,再打开仓库允许其他人进入仓库。

独占锁与共享锁

独占锁

独占锁模式下,每次只能有一个线程持有锁。独占锁是一种悲观策略,较坏的情况如果某个只读线程获取到锁,那么其他只读线程也只能等待,限制了并发性,因为读操作并不会影响数据一致性。典型的是ReentrantLock。

共享锁

共享锁放宽了加锁限制,允许多个只读线程同时获取锁,并发访问资源。只读线程持有锁的情况下,新的只读线程可直接获得锁。共享锁不适用于写线程,因为写线程会影响数据一致性。

ReadWriteLock读写锁

java提供了读写锁,在读的地方使用读锁(共享锁),在写的地方使用写锁(独占锁)。读之间不互斥,读写与写写之间互斥。在没有写的情形下,读是无阻塞的,一定程度上提高了同时存在读写场景的并发性。
java提供了读写锁的接口java.util.concurrent.locks.ReadWriteLock,可以实现这个接口完成自己的读写锁。也可以使用已有的实现ReentrantReadWriteLock.

自旋锁

在介绍自旋这个概念之前首先说一下内核态和用户态的概念。简单的说运行操作系统的程序(如操作硬件)需要在内核态(Kernel Mode)进行,用户态(User Mode)则用来运行用户程序。而阻塞等待动作即需要在内核态完成。
一个线程,线程阻塞从运行状态进入阻塞状态需要从用户态进入内核态,线程唤醒从阻塞状态进入运行状态则需要从内核态恢复到用户态。而线程状态之前的切换需要的成本非常高,需要的时间也很长。
在多个线程竞争同一个锁的场景下,一个线程获取锁之后,其它线程得不到锁就需要进入阻塞状态进行一个用户态到内核态的转变,而当锁资源释放时又需要唤醒线程进行一个内核态到用户态的转变。但是如果持有锁的线程能很快的释放锁,就可能会出现线程等待锁释放的代价要比线程经历两次状态切换的代价更低。
自旋的操作就被提出了:如果持有锁的线程能在很短的时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞挂起状态,他们只需要等一等(自旋),等待有锁的线程释放锁之后即可立即获取锁,这样就避免了线程状态切换的消耗

自旋的优点

自旋锁能尽可能的减少线程的阻塞,这在锁竞争不激烈,占用锁的代码块执行时间非常短的场景下通过自旋避免线程的状态切换,会对性能有很大的提升。

自旋的缺点

如果锁竞争很激烈或者占用锁的代码块执行的时间较长,就不适用于自旋了。此时自旋就会有明显的弊端。自旋的操作就是不断的循环取获取锁,这个过程就需要占用CPU的。锁竞争激烈与占用锁的代码块执行的时间较长都意味着获取锁的时间会很长,这个时间内自旋线程会一直占用CPU做无用功,造成CPU的浪费。等待时间越长意味着自旋所付出的代价越大于线程阻塞所付出的代价,从而影响到性能。

自适应自旋锁

自适应自旋锁是JDK1.6引入的对于synchronized的一个优化,自旋次数不再固定,而是由前一次在同一锁上的自旋时间以及锁的拥有着状态来决定,基本认为一个上下文切换的时间是最佳的时间,同时JVM还对当前CPU的负荷情况做了较多的优化,如果平均负载小于CPUs(CPUs即CPU的数目,平均负载小于CPUs就是说平均下来CPU没有充分利用,有CPU处于空闲状态)则一直自旋,如果有超过CPUs/2的线程正在自旋,则后来线程直接阻塞,如果自旋的过程中Owner发生了变化(意味着持有锁的线程变化了)则延迟自旋时间或进入阻塞(毕竟别人刚拿到锁),如果CPU处于节电模式则停止自旋。自旋时间的最坏情况是CPU的存储延迟(CPUA修改一个数据,CPUB得到这个消息的时间差)。
JDK1.6时可以通过-XX:+UseSpinning设置启用自旋,通过-XX:PreBlockSpin设置固定的自旋次数(默认为10),但是JDK1.7开始这两个参数都被移除,自旋的设置由JVM自己判定。

公平锁与非公平锁

公平锁

公平锁在加锁之前会检查是否已有在等待的线程,如果有则优先已等待的线程。换一种方式表述就是线程排排队,先到先得,每一个尝试获得锁的线程都会被放到等待队列中,当锁资源释放后,最先等待的线程先拿到锁。

非公平锁

非公平锁不考虑先来后到的问题,线程直接去尝试获取锁,而不会管之前是否已经有线程处于等待状态了。获取失败则进入等待队列等待。
注意:非公平锁并不是说在锁释放之后就把所有的等待线程全部唤醒,然后共同争抢这个锁,公平非公平区别是在锁释放的一瞬间如果有新的线程申请锁,锁会不会忽视等待队列的线程而直接将锁交给新线程。

举个例子

银行窗口,小A在办业务,后面还有已经取了号的小B和小C在休息区等待,此时小A办完业务,小D也恰好来办业务。这时有两种情形:第一个情形小B先从休息区走到窗口(拿到锁),这种情况下公平锁非公平锁没有区别,均是小D看窗口有人在办业务,就去取号排队了。第二种情形是小D先从门口走到窗口,此时如果业务员是公平锁的话就会告诉小D有先来的还在等着,你要去取号排队,而如果业务员是非公平锁的话就会直接给小D办业务,小B走到窗口发现已经有人在办业务了,只能继续回到休息区等待,当然小B依然处于等待队列的首位。

为什么非公平锁不是唤醒全部等待线程进行争抢呢?

在上面自旋篇已简单介绍了线程从阻塞状态到运行状态,从运行状态进入阻塞状态都需要进行用户态和内核态之间的切换。而线程状态之前的切换需要的成本非常高,需要的时间也很长。
假设有N个线程在等待队列,如果锁释放后唤醒全部等待线程进行争抢,那么就意味这要有N个线程进行内核态到用户态的转变,然而最后只会有一个线程获得锁,剩下的N-1个线程依然要等待,那么这N-1个线程又需要一次用户态到内核态的转变。这样的话整个场次下来有N-1次内核态到用户态,N-1次用户态到内核态的切换都是无效的,很影响执行效率,浪费资源。

为什么非公平锁比公平锁拥有更高的性能呢

这个问题如果在百度上查,回答一般是公平锁要维护队列,但是其实非公平锁也是会维护队列或集合的,只是非公平锁无需严格遵守队列顺序,因此高效的本质还是在于非公平锁的闯入机制,也就是一个新的线程获取锁时会直接尝试获取锁,失败才进入队列,而不是直接进入队列等待。这样就可以充分利用锁释放后,队列首部的线程重新尝试获取锁这一段时间。
公平锁模式下锁释放后需要唤醒队列首部的线程,而从阻塞到运行需要完成内核态到用户态的切换,这是一个很耗时的过程,那么在切换状态的期间就会处于无线程实际运行的情况,大大的浪费了资源。而在这个过程中允许闯入线程获取锁就能很好的利用这一段空白时间。

可重入锁

可重入锁,又可以称为递归锁,指的是一个线程在持有锁的情况下,可以再次获取锁而不受影响。ReentrantLock和synchronized都是可重入锁。

分段锁

分段锁是一种概念,即将资源拆分成数断,将对整个资源的锁定改为对一段资源的锁定,提高并发性能。JDK1.8之前的ConcurrentHashMap是典型的分段锁实现。JDK1.8开始ConcurrentHashMap舍弃了分段锁,改用用自旋+CAS+sync关键字来实现同步。

无锁&偏向锁&轻量级锁&重量级锁

synchronized 是通过对象内部的一个叫做监视器锁(monitor)来实现的,重量级锁加锁的本质就是在竞争monitor对象。如果一个线程竞争monitor失败,锁逻辑会让这个线程进入阻塞状态。而操作系统实现线程的运行与阻塞就需要进行用户态与核心态的转换,这个成本非常高,需要相对较长的时间。这就是为什么synchronized 效率低的原因。因此,这种依赖于操作系统Mutex Lock进行线程状态切换所实现的锁我们称之为“重量级锁”。
但是JDK1.6开始对synchronized进行了大量的优化,尤其是引入了偏向锁&轻量级锁的概念,使得synchronized性能有了很大的提升。感兴趣的话可以看一下这篇20:synchronized实现原理与锁膨胀:无锁or偏向锁-轻量级锁-重量级锁,看完就懂

锁的优化途径

减少锁的持有时间

只在需要线程安全的部分加锁,尽可能少的减少加锁代码块,使锁可以更快的释放

锁粗化

上面提到锁优化的手段有减少锁的持有时间,但是如果一个线程短时间内会不停的获取锁,释放锁,那么可以对锁适当的进行粗化。减少线程切换所造成的消耗。

减小锁粒度

将大对象拆成数个小对象,将对大对象的锁定改为对数个小对象的锁定。增加并行度,降低锁竞争。

锁分离

根据功能对锁进行分离,分成可共享的读锁和互斥的写锁,这样即保证了线程安全又提升了性能。典型的就是ReadWriteLock啦。也可以扩展一下,比如一个队列,可以分离成一个头部锁和尾部锁,这样也是锁分离啦,比如LinkedBlockingQueue。

锁消除

锁消除是编译器的事。如果程序编写不规范,发现对象不可能会共享,编译器就会在编译期间消除这个锁。

PS:
【JAVA核心知识】系列导航 [持续更新中…]
上篇导航:18:线程本地变量-ThreadLocal
下篇预告:20:synchronized实现原理与锁膨胀:无锁or偏向锁-轻量级锁-重量级锁,看完就懂
欢迎关注…

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

yue_hu

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值