java synchronized关键字锁和锁类型、锁升级过程讲解

概述

synchronized是java的一个关键字,用于对方法或者代码块添加一个同步锁,以实现操作的原子性,保证线程安全性,但是却会带来一些性能上的损耗。

这个关键字添加的是可重入锁,也就是同一个线程获取同一把锁时,只需把计数器加一,释放锁时,把计数器减一。计数器为0时表示释放了锁,事实上都是可重入锁。

public class SyncDemo {

    private int num = 0;

    public void incr(){
        num ++;
    }

    public static void main(String[] args) {
        int threadNum  = 200;
        CountDownLatch latch = new CountDownLatch(threadNum);
        SyncDemo syncDemo = new SyncDemo();
        for (int i = 0;i<threadNum;i++){
            new Thread(()->{
                try {
                    latch.await();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                try {
                    TimeUnit.MILLISECONDS.sleep(1);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                syncDemo.incr();
            }).start();
            latch.countDown();
        }

        try {
            TimeUnit.SECONDS.sleep(5);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(syncDemo.num);
    }
}

上面代码是开启200个线程对共享变量num进行num++,最后输出执行后的结果,正确的结果应该是200,可实际如下:
在这里插入图片描述
得到的结果可能会小于200,这是因为多个线程对num变量的修改不具备原子性决定的,num++,在底层,是分三个原子步骤完成的:
get //从内存中或者变量的值。
update //更新变量的值。
put //把更新后的值刷到内存。

上面上个操作,每个都具备原子性,但是三个在一起就不具备原子性了,出现小于200的情况的原因是,线程1获取了num值为0,但是没有进行更新或者更新了还没刷回内存中时,线程2也通过get步骤获取了num的值0,然后线程1把num进行+1,变成1后刷回内存,然后线程2也进行+1,变成1后刷回,此时理应是2,但是是1。

由于以上问题,使用synchronized关键字就可以给方法或者同步代码块加上一个同步锁,使得该代码块的执行时串行,具备原子性,但是加锁释放锁、串行执行会在一定程度上降低程序的性能。

在这里插入图片描述
在这里插入图片描述
这里就直接说结论,不测试了,有兴趣可以自行测试,以上是synchronized关键字分别加在方法上和代码块上的情况。

  1. 如果加在代码块上,要指定锁,在上面是this,也就是当前对象作为锁,任意对象都能作为锁,只有被同一把锁(同一个对象)锁定的代码块才具有互斥的效果。
  2. synchronized关键字如果加在成员方法上,那么使用的锁就是当前对象。
  3. synchronized关键字如果加在类方法上,那么使用的锁就是该类的Class对象。

再次执行结果:
在这里插入图片描述

但是,由于synchronized是重量级锁,获取锁、释放锁都是比较消耗性能的,但是不使用锁就会导致线程不安全,那样导致的后果也可能不堪设想,这两者之间似乎是没有办法达到既能满足性能也能满足安全性的要求。

自jdk1.6以后,对锁进行了一些优化,引入偏向锁、轻量级锁。自此,synchronized锁的状态有四种:
无锁、偏向锁、轻量级锁、重量级锁。

无锁:就是不加锁。但是线程也会不安全。

偏向锁

在一些情况下,锁不仅仅不存在多线程竞争,而是总是由同一个线程多次获得, 为了让线程获取锁的代价更低就引入了偏向锁的概念。

当一个线程访问加了同步锁的代码块时,会在对象头中存储当前线程的线程idID,后续这个线程进入和退出这段加了同步锁的代码块时,不需要再次加锁和释放锁。而是直接比较对象头里面是否存储了指向当前线程的偏向锁。如果相等表示偏向锁是偏向于当前线程的,就不需要再尝试获得锁了。

偏向锁存在两个状态,由锁对象的对象头的两个变量决定:
可偏向状态:锁对象头的biased_lock=1、且 ThreadId 为空,这个状态表示当前锁还没有被偏向某个线程,可以进行偏向。
已偏向状态:非可偏向状态,表示当前锁已经偏向某个线程了。

偏向锁获取的逻辑:

  1. 如果当前锁处于可偏向状态,则通过 CAS(compare and swap 一种乐观锁操作) 操作,尝试把当前线程的 ID写入锁对象的对象头的ThreadId。
    如果写入成功,表示当前线程已经获得了锁对象的偏向锁,接着执行同步代码块。
    如果写入失败说明有其他线程已经获得了偏向锁,这种情况说明当前锁存在竞争, 需要撤销已获得偏向锁的线程,并且把它持 有的锁升级为轻量级锁。

  2. 如果当前锁处于已偏向状态,需要判断锁对象头线程id与当前线程的id是否相同,如果相同,就不需要再次获得锁,可直接执行同步代码块,如果不相等,说明当前锁偏向于其他线程,需要撤销偏向锁并升级到轻量级锁。

偏向锁的撤销逻辑:偏向锁的撤销并不是把对象恢复到无锁可偏向状态(因为偏向锁并不存在锁释放的概念), 而是在获取偏向锁的过程中,发现 cas 失败也就是存在线程竞争时,直接把被偏向的锁对象升级到被加了轻量级锁的状态。
a. 如果原来获取到了偏向锁的线程执行完了同步代码块,那么这个时候会把对象头设置成可偏向锁状态并且争抢锁的线程可以基于 CAS 重新偏向但前线程。
b. 如果原获得偏向锁的线程的同步代码块还没执行完,处于临界区之内, 这个时候会把原获得偏向锁的线程升级为轻量级锁后继续执行同步代码块。

也就是如果存在多个线程同时竞争锁的话,JVM就会对锁进行升级成轻量级锁。如果同一时间只有一个线程操作锁的话,也就是一个线程获取了偏向锁并且执行完,退出临界区后,另外的一个线程才来获取偏向锁,这是,就是使用的偏向锁,一旦同一时间存在锁竞争,导致其中一个线程获取偏向锁失败,偏向锁就会升级成轻量级锁。

偏向锁就是为这种情况提升性能引入的,在我们的应用开发中,绝大部分情况下一定会存在 2 个以上的线程竞争,那么如果开启偏向锁,反而会提升获取锁的资源消耗(因为锁升级会消耗资源,但是在我们的应用开发中,绝大部分情况都需要进行锁升级)。 所以可以通过 jvm 参数UseBiasedLocking 来设置开启或关闭偏向锁。 关闭了偏向锁,就能直接使用轻量级锁,省去锁升级的过程。

轻量级锁

轻量级锁在加锁过程中,用到了自旋锁,所谓自旋,就是指当有另外一个线程来竞争锁时,这个线程会在原地循环等待,而不是把该线程给阻塞,直到那个获得锁的线程释放锁之后,这个线程就可以马上获得锁的。注意,锁在原地循环的时候,是会消耗 cpu 的,就相当于在执行一个啥也没有的 for 循环。

也就是如果多个线程来获取到锁,但是同一时间只能有一个线程获取到锁,剩下的线程就通过一个循环来不断尝试获取锁,直到获取锁成功才退出循环。因为是通过循环来实现,没有把线程阻塞住,所以会消耗cpu资源。

//伪代码如下,一直获取锁,知道成功。
while(!getLock()){
}

所以,轻量级锁适用于那些同步代码块执行的很快的场景,这样,线程原地等待很短的时间就能够获得锁了。
自旋锁的使用,其实也是有一定的概率背景, 在大部分同步代码块执行的时间都是很短的。所以通过看似无异议的循环反而能提升锁的性能。

自旋消耗cpu资源与重量级锁阻塞线程唤醒线程消耗资源之间的比较,所以如果自旋时间很短的话,使用轻量级锁可以提升并发性能。

但是自旋必须要有一定的条件控制, 否则如果一个线程执行同步代码块的时间很长, 那么这个线程不断的循环反而会消耗 CPU 资源。 默认情况下自旋的次数是 10 次,可以通过 preBlockSpin 来修改,如果一个线程自旋10都没有获取到锁,就把轻量级锁升级成重量级锁。

在 JDK1.6 之后,引入了自适应自旋锁,自适应意味着自旋的次数不是固定不变的,而是根据前一次在同一个锁上自旋的时间以及锁的拥有者的状态来决定。

如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。

重量级锁

当轻量级锁膨胀到重量级锁之后,意味着线程只能被挂起阻塞来等待被唤醒了。

加 了 同 步 代 码 块 以 后 , 在 字 节 码 中 会 看 到 一 个monitorenter 和 monitorexit。
在这里插入图片描述
每一个 JAVA 对象都会与一个监视器 monitor 关联,我们可以把它理解成为一把锁,当一个线程想要执行一段被
synchronized 修饰的同步方法或者代码块时,该线程得先获取到 synchronized 修饰的对象对应的 monitor。monitorenter 表示去获得一个对象监视器。 monitorexit 表示释放 monitor 监视器的所有权,使得其他被阻塞的线程可以尝试去获得这个监视器。

monitor 依赖操作系统的 MutexLock(互斥锁)来实现的, 线程被阻塞后便进入内核(Linux)调度状态,这个会导致系统在用户态与内核态之间来回切换,严重影响锁的性能。

在这里插入图片描述
上图讲解,使用monitorenter 获取到监视器(锁)的线程执行同步代码,获取不到的线程进入阻塞状态线程状态变为 BLOCKED并且进入同步队列,然后monitorexit 释放锁时就会唤醒同步队列的线程来再次竞争锁。

总的来说:
偏向锁是在同一时间只有一个线程进入临界区的情况。
轻量级锁适用在同步代码块执行时间短的情况。
重量级锁就是适合这两个以外的情况。
锁的升级时JVM自发运行的,我们无需操作,只需根据实际场景调整相应参数。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值