Java的那些 “锁” 事 - 概念篇

“锁”的开胃菜

Java提供了丰富的锁来供我们使用,我们可以按照这些锁的特性和使用场景将这些锁进行分类。

当然本篇文章都是给出一些概念,所以文章文字有点多,大家耐心看完,相信会有收获。

首先我们简单介绍部分常见的锁分类,然后我们再分类进行详细一点的描述。

  • 乐观锁、悲观锁

    乐观锁和悲观锁其实是按照是否锁住同步资源来区分的。

    乐观锁: “乐观的”认为在当前情况下不会发生冲突,通常不进行加锁,而是利用CAS(CompareAndSwap)的方式进行操作,在这过程中是原子的,也就是说这些操作要么全部成功,要么全部失败。并且我们还常常让CAS与循环重试一起使用。

    悲观锁 : “悲观的”认为每次操作都会有冲突,所以每次都对同步资源进行加锁,只有获得锁的对象才能访问同步资源。

  • 自旋锁、适应性自旋锁

    自旋锁和适应性自旋锁是在对同步资源加锁,并且获取锁失败的前提下,不对当前线程进行阻塞而是不断循环尝试获取锁的一类锁。

  • 公平锁、非公平锁

    公平锁与非公平锁是按照线程等待锁时是否按照先来后到进行排队区分(也就是多个线程同时等待一个锁释放时,是否按照先来的优先得到锁进行区分)

    公平锁 : 有一个等待队列,后来的由队尾进入,处于队首的先获得锁。

    非公平锁 :当多个线程等待锁时,锁释放时随机分配给等待该锁的任意一个线程。

  • 可重入锁、非可重入锁

    可重入锁与非可重入锁的区别在于,是否可以在一个线程内部的不同流程里多次获取该锁。

    可重入锁 : 在一个线程内的不同流程可多次获取该锁。

    不可重入锁:在一个线程内的不同流程不可再次获取该锁。

  • 共享锁、排他锁

    共享锁与排他锁的区别在于,是否允许多个线程共享一把锁。

    共享锁 : 允许多个同性质的操作线程共享同一把锁。

    排他锁 : 只允许一个线程持有锁,其它锁只能等待使用线程释放锁才能持有该锁。

  • 这里针对Synchronized锁划分了四个状态 :无锁偏向锁轻量级锁、重量级锁,从左到右也是一个锁升级的过程。稍后我们会详细的讲一下。

“锁”的正菜

乐观锁与悲观锁

悲观锁

对于悲观锁上面我们也给出了简单的介绍,但是这里我们还是再说一遍(哈哈~):悲观锁其实就是线程在每次操作时都认为这次操作会产生冲突,从而对同步资源进行加锁。像Java中Synchronized、lock类的实现类都算是悲观锁。对于这一块大家可以自己去学习Java中的知识,这里就没有必要铺开来讲了。

乐观锁

乐观锁是一种无锁的状态,在每次操作数据时都“乐观的”认为不会发生冲突,所以并没有对资源进行加锁处理。一般情况下,乐观锁都是基于CAS算法+循环尝试的,所以我们有必要在这里对CAS进行详细的介绍。

CAS(CompareAndSwap,大家通过这个英文应该也能知道个大概,比较和交换)

它的大概流程是 将持有的旧值与当前内存中的值进行比较,如果相同就认为没有其它线程修改了数据,立即将内存中的数据更新为我们持有的新值,否则就失败。(这里我们需要注意的是,这里的比较和交换是一个原子操作,也就是说失败就是全部操作失败,成功即全部成功)。

在没有竞争的情况下CAS的效率是非常高效的,但是它同时也存在着一些问题:

  1. ABA问题 :CAS操作数据成功的前提是内存中的数据没有被改变,但是由于我们进行比较时只是比较了内存中值是否符合预期,这里就有可能出现一种情况,一个线程将原来是数据A 改变成了B 但是在我们比较前,一个线程又将B改回了A,这样对于我们这个线程来说,值是没有改变的,但是实际上改变了。

    解决方法:设置一个类似版本号的标识,每次改变数据都改变该标识符,所以我们可以通过对比标识以及值来进行判断,数据是否被改变过。

  2. 长时间操作不成功导致开销增加 :由于会配合循环尝试使用,所以如果长时间操作不成功,可能会一直自旋,白白浪费CPU资源。

    解决方法 : 设置超时限制,超时后可以进行锁升级或者直接操作失败结束自旋。

  3. 粒度小,只能保证一个共享变量的原子性。

    解决方法 :JDK1.5之后引入了原子引用类,保证了引用对象之间的原子性,将多个共享变量放入一个对象中,对对象进行原子操作。

自旋锁与适应性自旋锁

我们知道通常情况下,非自旋锁是在获取锁失败后线程会被休眠,而线程的休眠和唤醒是需要陷入到内核态进行线程调度的,这个开销是比较大的。所以在一些特殊情况下,我们会使用自旋锁来提高性能。特殊情况是指 已获取锁的线程A会很快释放锁,占用时间很短,那么我们就没有必要将这个期间因为获取锁失败的线程B进行休眠,而是让B循环的去获取锁,由于前面说的已获取锁的A线程会很快释放锁,那么B也就会在少数的循环后获得锁,这样就极大的减少了因为线程切换导致的不必要的开销了。

当然自旋锁也是在特定的使用场景下效率是非常好的,但是在这个过程中由于自旋是一直占有CPU的,如果遇到已获得锁的线程执行任务时间过长,而导致自旋时间过长,在这个空白期内CPU的使用率是很低的,所以性能就大大降低了,这也是它的一个缺点。为了避免这个问题,我们会设置一个自旋的上限(默认次数是10次),当达到这个上限后,我们就必须得挂起线程了,因为再一直这样白白的占用CPU是一件非常不好的事。

由于上面自旋的一些问题,在JDK6时引入了适应性自旋锁。

适应性自旋锁 就是指它可以自己根据具体情况去设置自旋的上限,它可能通过上次获取该锁的情况以及拥有锁的对象的锁状态来设置上限,如果过于糟糕,那么也可能直接跳过自旋阶段,直接将线程挂起。

这里涉及到的锁状态,我们在后面会讲,其实在对象的创建过程中我们就会对对象头进行设置,这里就包括了锁状态的设置。想要了解的可以去看看对象的创建过程。

公平锁与非公平锁

对于公平锁和非公平锁,其实概念就比较简单,也就是一个先来后到的问题。公平锁就是在获得锁时是按照等待时间最长的(也就是最先等待的)分配锁。非公平锁就是谁运气好谁就能拿到锁,无关等待的时间。ReentrantLock 就能实现公平锁和非公平锁,具体可以自行了解。

公平锁

优点 : 避免线程因等待过程中一直分配不到锁而饿死

缺点 : 吞吐率小于非公平锁,在等待队列中除了第一个线程可以获得锁之外,其它锁都被阻塞,那么开销就会比非公平锁大(为什么会大呢?这是因为公平锁如果等待队列非空,那么它就会被加入队列尾,并且阻塞,这里就涉及到了线程的调度,但是非公平锁就不一样了,它可能是当一个锁刚好过来刚好锁释放,并且成功获得锁了,就不必阻塞了,也就避免了线程的调度)

非公平锁

优点 :吞吐率大,开销在一定情况下小于公平锁。

缺点 : 存在线程饿死现象,或者等待时间过长问题。

重入锁与非重入锁

重入锁与非重入锁的区别在于是否能在一个线程的内部多个获得同一个锁。比较抽像这里给出一个代码大家就能懂了应该!

//可重入锁
//Java中 synchronized 与 ReentrantLock都算是可重入锁
//伪代码
class LockTest(){
  synchronized(this){
    ...
    Systeam.out.println("第一层锁输出")
    synchronized(this){
        Systeam.out.println("第二层锁输出")
    }  
  }
}


//输出
  第一层输出
  第二层输出

如果是不可重入锁,那么像上面这样使用就会出现死锁。

为什么会出现死锁以及为什么可重入不会出现死锁?

先说说为什么会出现死锁:如果不是可重入锁,那么当在第一层获得锁之后,我们在第二层获取锁时就会出现一个问题 由于第一层已经持有了锁,而第二层在第一层内部并且未完成所以不会释放锁,第二层又需要第一层释放锁才能进入执行,否则一直阻塞,那么这里就会出现循环相互等待,锁又是不可剥夺的以及互斥的,并且第一层满足了占有等待条件,已经满足了死锁出现的四大条件,那么就会出现死锁现象。

再来说说为什么可重入锁不会出现死锁:其实这是因为在锁的内部会维护一个state 的值,初始值为0,当一个线程获取到锁之后state = 1,当存在线程申请锁的时候,会判断当前申请锁的线程是否是已经持有锁的这个线程,如果是 state + 1,否则申请锁的线程挂起。

独享锁(排他锁)与共享锁

独享锁

独享锁也可以被称为排他锁,按照名字也能理解它的大概的功能情况,也就是说当一个线程对同步资源进行了加锁后,只允许该线程对该资源进行读写操作,不允许其它线程再对该资源进行加锁和访问。

共享锁

共享锁,按照名字来理解就是说它锁住的资源是可以被共享的,也就是说当一个同步资源被加了共享锁后,其它的线程也能对该资源进行加共享锁(这里需要注意的是,当一个线程对该资源加了共享锁之后,我们只能对该资源加共享锁,而不能加独享锁)

Synchronlized的锁状态升级过程

在这之前,我们需要来了解一些东西以便我们后面对状态升级的理解。

对象头

这个在我们对象的创建过程中是有讲过的,也就是在初始化零值后我们进行的操作配置对象头。而对象头中的数据分为两个部分:一部分是运行时数据,这个包含了该对象的Hashcode、分代年龄、锁状态等信息,并且它存储的信息会根据锁状态的改变而改变。另一部分是类型指针也就是指向类元数据的指针。

(图片来自网络)

请添加图片描述

Monitor

每一个Java对象都天生只带一个monitor对象,就比如说 一个实例对象会有一个monitor来保护它的实例数据,而一个类对象也会有一个monitor来保护它的类数据,在我们没有加锁时,我们不需要获取该对象的monitor就能访问这些数据,但是当对该数据进行加锁后,那么想要访问它就必须的获取该对象的monitor的持有权,并且该持有权只能被赋予一次,也就是在同一时间只能有一个线程被赋予了该权力。那么在monitor的字段中会有一个Onwer来唯一标识该线程。

我们知道synchronized的本质原理是获取对象监视器monitor的持有权。Monitor是依赖于底层的操作系统的Mutex Lock(互斥锁)来实现的线程同步。所以在JDK 6 之前Synchronized的效率是非常低的,因为它需要系统进行调度和改变状态。但是在JDK6的时候引入了偏向锁和轻量级锁这两种锁状态。

所以目前来说锁的状态有四种 : 无锁、偏向锁、轻量级锁、重量级锁。

锁状态的升级是按照 无锁 → 偏向锁 → 轻量级锁 → 重量级锁 这个顺序进行的,并且锁只能升级不能降级。

无锁

无锁状态也就是不对资源进行加锁,所有线程都可以访问,但是只能有一个线程操作成功,其它的线程会不断尝试知道成功。

偏向锁

对资源进行加锁,但是一直只有一个线程访问该资源,那么该线程可以自动获得锁,这样就避免了频繁获取锁的开销。

当锁状态为偏向锁时,在对象的对象头中它的锁状态标志位会是01,它存储的内容就会包括:偏向线程ID、偏向时间戳、是否为偏向锁(1)、对象分代年龄。

那么在线程每次获取锁时确认为偏向锁时,我们只需要对偏向线程ID进行一个CAS的原子操作,这样就大大降低了在多线程竞争中需要花费的开销。

当发生多线程竞争偏向锁时,持有偏向锁的线程在完成任务后才会释放掉该偏向锁(不然线程不会主动释放锁),偏向锁的撤销需要在全局安全点(该时间点无字节码运行)进行,先暂停持有锁的线程,然后根据是否处于加锁状态,选择将偏向锁恢复到无锁状态,或者是升级到轻量级锁(改变标志位 为 00)。

轻量级锁

当多个线程竞争偏向锁时,偏向锁就会升级为轻量级锁状态,该状态是通过不断自旋申请锁的,不会进行阻塞,从而提高性能。

在轻量级锁状态下,JVM会在该线程下的栈帧中开辟一块被称为锁记录的空间用来存储从对象头复制来的运行时数据(Mark Word),然后CAS操作对象,将对象头中的运行时数据更新为指向锁记录的指针,然后更新锁记录中的Owner字段指向 Mark Word。这样实际上就是将Owner字段指向了该线程,标识该线程获得了该锁。

当线程将要进入该同步区时,会查看对象头中的运行时数据是否指向该线程的栈帧,如果是则继续执行,否则失败自旋。

当出现自旋超时现象,或者多个线程在自旋时,轻量级锁将会进入到重量级锁的状态。

重量级锁

进入到重量级锁状态,锁标志位将改为10,Mark Word 指向互斥量(重量级锁),所有申请锁且未获得锁的线程进入阻塞状态。

实力有限,如有错误,敬请指出,谢谢!

参考

不可不说的Java “锁” 事

来自国外的技术帖子

Java中的偏向锁,轻量级锁, 重量级锁解析

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值