常见的锁策略和synchronized实现原理

一、常见锁策略

在多线程编程中,对代码上锁是很平常的事,但是对待不同情况我们需要用到不同的锁,所以就对锁进行了一下分类。

1、乐观锁VS悲观锁

  • 乐观锁:乐观锁在处理一段代码时,它会乐观的认为读多写少,也就是并发执行的情况概率很低,代码都是串行执行的,他认为每次去拿数据时,别人都不会对数据进行修改,而只有当写数据时才会正式对数据上锁,它会先去获取一下版本号,看一下版本号有没有发生改变,如果发生改变,就会重复读–比较–写的操作(CAS操作),Java中的乐观锁一般都是通过CAS操作实现的。
  • 悲观锁:悲观锁和乐观锁是完全相反的,它会认为读少写多,并发执行的概率很高,它会认为他在读取数据之后还会有线程对数据做出修改,所以他会在读取数据时就会上锁,这样后面的线程就拿不到锁对象了,从而进入阻塞状态,直到获取到锁,这样做会涉及到线程状态的来回切换,代价较高,性能较低,Java中Synchronized就是一个悲观锁。

2、重量级锁VS轻量级锁

重量级锁和轻量级锁是一对相对的概念,没有太明确的界限,一般轻量级锁的工作量更少,消耗的资源更少,效率更高;重量级锁的工作量更多,消耗的资源更多,效率更低。一般认为乐观锁都是轻量级锁,悲观锁都是重量级锁。因为悲观锁会涉及到用户态和内核的切换过程,消耗更大,而乐观锁使用CAS机制实现,开销更小。

3、读写锁

读写锁会被专门分为一类,是因为它的特殊性,他在上锁时会分成两步,读加锁,写加锁。读加锁和读加锁之间不会互斥,写加锁和写加锁之间会互斥,读加锁和写加锁之间也会互斥

4、自旋锁

在说悲观锁时我们提到,如果两个线程竞争锁,失败的一方会进入阻塞状态,这涉及到了上线文的切换,开销较大,所以就引入了自旋锁;当锁竞争失败的线程会一直尝试获取锁,直到获取到锁

  • 优点:对于锁竞争不是很激烈,占用锁时间较短的代码来说,自旋锁可以大大提高效率
  • 缺点:线程在尝试获取锁的过程中也是会占用CPU资源的,所以如果锁竞争很激烈的话,就会浪费更多资源,还有一点就是如果获取锁的线程一直不释放锁的话,其他线程自旋时间太长也是会造成无畏的浪费,所以就需要规定一个自旋等待的最大时间,过了这个时间,线程就会自动进入阻塞状态。

5、可重入锁

对于下面这段代码来说,在主线程执行到第二句获取到了锁对象之后,执行func函数,而func函数也需要获取锁对象,但是只有执行完函数前面的锁对象才能释放,这在正常情况下就会造成死锁。

public class Test{
    static class A {
        synchronized public void func() {
            System.out.println("hello");
        }
    }

    public static void main(String[] args) {
        A a = new  A();
        synchronized (a){
            a.func();
        }
    }
}

但是因为synchronized是一个可重入锁,就不会出现锁死锁的现象,对于可重入锁来说,在获取锁的时候会维护一个计数器,当一个线程获取到锁之后,计数器就会加1,如果后续同一个线程又第二次获取锁时,由于线程一样,所以就只会让计数器加1,然后正常执行代码

6、公平锁VS非公平锁

我们在说所谓公平和非公平时肯定是需要一个规则来判定,比如根据距离、概率、时间等等条件判断公平还是非公平。这里是按照时间来评判的,就比如自旋锁就是一个典型的非公平锁,就比如线程2在等待线程1结束的时候,突然来了一个线程3获取到了锁,从概率来说都是1/2的概率,但是线程2却早早就等待了,所以这就是一个非公平锁,同样的Synchronized也是非公平锁。因为我们本身的线程调度就是一个随机过程,所以想要实现公平锁就需要做出额外的处理,例如记录每个锁竞争的时间,然后用一个堆来把这些时间储存起来,最后根据时间时间长短来决定哪个线程可以获取到锁。

7、死锁

死锁就是说所有线程都被阻塞的情况,他们中的一个或者多个线程在等待资源的释放,但是由于线程无线阻塞,所以就形成了死锁。

我认为死锁会出现的最主要因素是因为出现环路等待问题,就是P1等待P2、P2等待P3、P3等待P1。资源永远得不到释放,有一个经典的例子就是哲学家吃东西问题,是说在一张圆形的桌子周围坐了五位哲学家,他们每人的左手边都只有一只筷子,所以如果他们每次想要吃东西的时候必须要同时拿到左右手两边的筷子才能吃到东西,而其他人则只能思考人生,然后等待;假设有一种极端情况是五个人同时想吃东西,都拿起了他们左手边的筷子,这时就会出现死锁的情况,每个人都在等待其他人吃完放下筷子,这时就陷入无限循环。要打破这个局面在我们看来只需要一个人懂得谦让,让出一只筷子,就能打破僵局,换到代码层面来说就是只需要一个线程释放这把锁就行,但是机器是只能根据指令做出下一步动作的,不存在主动的动作, 所以想要不出现死锁就需要从代码层面入手解决。

  • 第一个办法就是不嵌套使用锁,这就意味着在同一时刻只能获取到一把锁,如果只是一把锁或者是可重入锁,就不会有环路等待问题,也就不会出现死锁的情况了。
  • 第二个办法就是如果实在要使用嵌套锁的话,对每层的锁做一个编号,这样按顺序执行也就不会存在环路等待问题了。

二、CAS

1、概念

CAS(Compare And Swap/Set)比较并交换,CAS 算法的过程是这样: 它包含 3 个参数CAS(V,E,N)。V 表示要更新的变量(内存值),E 表示预期值(旧的),N 表示新值。当且仅当 V 值等于 E 值时,才会将 V 的值设为 N,如果 V 值和 E 值不同,则说明已经有其他线程做了更新,则当前线程什么都不做。最后,CAS 返回当前 V 的真实值。

CAS 操作是抱着乐观的态度进行的(乐观锁),它总是认为自己可以成功完成操作。当多个线程同时使用 CAS 操作一个变量时,只有一个会胜出,并成功更新,其余均会失败。失败的线程不会被挂起,仅是被告知失败,并且允许再次尝试,当然也允许失败的线程放弃操作。 基于这样的原理,CAS 操作即使没有锁,也可以发现其他线程对当前线程的干扰,并进行恰当的处理。

2、实现

CAS操作是基于硬件层面提供的支持,才能实现的操作。

3、应用

  1. Java中的乐观锁和自旋锁基本都是基于CAS来实现的。
  2. 用于实现原子类,针对int,long类型的数据操作很可能不是原子的,尤其是自增、自减这样的操作,涉及到一个读操作和一次写操作,而这时使用原子类就可以保证这样的操作也具有原子性,在Java库中有AtomicInteger、AtomicLong等等原子类。

4、ABA问题

CAS操作是一个原子操作,但是在前面一般还有一个读取操作,那么在这两个操作之间可能会有别的线程对数据进行修改,ABA问题就是说,假设原始值为A,线程1先读取了A之后其他线程把它改成了B,然后又改回到了A,这时线程1执行CAS操作,发现内存中的值和期望值一样,然后对内存值做出了修改,实际上A的值已经被替换过了,但是CAS却没有发现。尽管从结果上看没有问题,但是过程中却存在问题,例如转账问题。

在这里插入图片描述
假设有一个人要给另一个人转账,但是他在第一次转账时,界面卡住了,于是他就发起了第二次转账,转出了100,这时第一次操作恢复了,然后CAS时发现内存值和预期值不一样了,这时转账就失败了,这是没有问题的。但是如果在第一次操作恢复之前,又有另外一个人转入了100,这时第一次操作再去CAS的时候发现内存中的值和预期值一样,于是就又转出去100。从结果上看,他只想转100出去,但是实际他转出去了200,这就是ABA问题。

要解决ABA问题其实很简单,只需要CAS时能感知到内存中的值到底是变化过的值,还是一直没动就可以了。我们可以加入版本信息或是时间信息,比如在内存中记录每次修改的时间,CAS时不止要判断值是否相同,还要判断时间是否相同,如果都相同才能做出修改。在Java中一把是选择AtomicStam pedReference 之类的时间戳作为版本信息,保证不会出现老的值。

三、Synchronized背后的原理

首先synchronized背后实现的一个最重要的过程是一个自适应的过程,也可以叫做“锁膨胀”或者"锁升级",其次还有一些其他的优化,比如锁消除、锁粗化、锁分离等等。

锁膨胀

锁膨胀的目的是为了在不同场景下使用不同的锁,提高代码运行的效率。我们一下面例子说明,首先假设刚开始多个线程尝试i++操作,这些线程不是同时运行的。

  1. 偏向锁:,第一个线程开始i++操作,就需要加锁,这时是以一个乐观锁的方式去加锁,只是在对象头里通过一个特殊的标记位,标记一下是哪个线程获取到的这个锁,而且这时大概率是没有线程来竞争锁的
  2. 轻量级锁:但是小概率情况下,线程1以偏向锁的状态进行i++操作的过程中,线程2 来尝试竞争锁了,这时线程1会立即获取到锁状态,线程2这时可能会自旋,也可能进入等待状态,反正是不能获取到锁对象了,这就是锁的第一次膨胀。
  3. 重量级锁:接下来,其他的线程接二连三的过来竞争锁了,锁竞争正越来越激烈,这时如果是自旋锁的话,可能自旋的耗损就会大于进入阻塞状态的消耗了,所以这时轻量级锁又再一次膨胀为重量级锁,获取到锁的进程继续工作,其他线程进入内核态,阻塞等待。

锁可以升级也可以降级,当线程活动频繁时可能会升级,但是一旦过了这个时间就会降级,这里就涉及了JVM的实现。锁升级降级不是 JVM 规范约定的. 所以就导致有的 JVM 支持降级, 有的不支持降级。有的 JVM 不支持升降级的理由是 “频繁升降级” 对于性能影响比较大, 所以干脆就破罐子破摔就一直使用重量级锁了,这个理由也是比较看场景的, 只能说有的场景锁降级有利, 有的场景锁降级不利, 这个支持降级或者不支持降级, 都是有理由能支撑的。

锁消除

加锁是需要我们人为判断的,但是有时代码中可以不用加锁,但是却额外加上了,这时编译器和JVM就会判断,这个代码到底涉不涉及线程安全问题,如果不涉及,就会自动把这个锁给去掉。就比如在Java库中有许多本身就线程安全的类,像HashTable、StringBuffer这样的类,他们在内部已经实现了加锁操作,如果外面再加锁的化,就会影响代码的效率。

锁粗化

通常情况下,为了保证多线程间的有效并发,会要求每个线程持有锁的时间尽量短,即在使用完公共资源后,应该立即释放锁。但是,凡事都有一个度,如果对同一个锁不停的进行请求、同步和释放,其本身也会消耗系统宝贵的资源。所以编译器和JVM又有了优化方式,就是增大锁的"粒度",锁的"粒度"意思是锁的影响范围,相当于把多行代码放到了一个锁代码块中,这样,只需要加一次锁就可以完成这些操作了。

四、Synchronized的实现

JVM 会为每个 Java 对象都维护一个关联的 Monitor。Monitor 是一种同步机制,当 Java 对象关联的 Monitor 被一个线程持有之后,它将会进入锁定状态,其他线程无法再次获取该 Monitor,只有在 Monitor 从锁定状态退出之后,才能被其他线程再次锁定

synchronized 关键字是依赖 MonitorEnter 和 MonitorExit 指令来实现的:在 JVM 编译代码的时候,会在 synchronized 代码块的入口处插入 MonitorEnter 指令,在 synchronized 代码块的出口(正常结束或是异常退出的地方)插入 MonitorExit 指令。 MonitorEnter 指令就是用来锁定指定对象的 Monitor,MonitorExit 指令则是用来释放指定对象的 Monitor,从而完成了对对象的加锁和解锁效果,实现了操作的原子性。

在获取 Monitor 的时候,线程的工作内存会被设置为无效,并从主内存中重新加载数据;在释放 Monitor 的时候,线程工作内存中的数据会同步到主内存,这样也就保证了可见性。另外,MonitorEnter、MonitorExit 指令也可以防止重排序。

总结

一般在实际开发中我们是不需要去设计锁的,所以这些概念只是了解就好了,锁的设计还是非常复杂的,像Synchronized这个关键字的实现是在JVM中用C++代码来实现的,一般程序员是接触不到那么底层的。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值