Java必知必会—锁

1.什么是锁?

在并发环境下,多个线程会对同一资源进行争抢,那么可能会导致数据不一致的问题,为了解决这种问题,很多编程语言都引入了锁机制。
锁.png

那么,Java锁机制是如何设计的呢?

在谈锁之前,我们需要简单了解一些Java虚拟机内存结构的知识。如下图所示,JVM运行时的内存结构主要包含了程序计数器、JVM栈、Native方法栈、堆、方法区。红色的区域是各个线程所私有的,这些区域的数据,不会出现线程竞争的问题,而蓝色区域的数据被所有线程所共享。其中,Java堆中存放的是所有对象,方法区中存放类信息、常量、静态变量等数据。并发环境下,需要锁机制对其限制,确保共享区域内的数据正确性。

素材0.png

2.Java代码中锁的实现

在Java中,每个对象Object都拥有一把锁,这把锁存放在对象头中,锁中记录了当前对象被哪个线程所占用。
下图是对象的结构,其中,对象头存放了一些对象本身的运行时信息,对象头包含两部分,Mark word和Class point,相较于实例数据,对象头属于一些额外的存储开销,所以它被设计的极小(32bit)来提高效率。

Java对象结构.png

  • Class point是一个指针,它指向了当前对象类型所在方法区中的类型数据。
  • Mark Word存储了很多和当前对象运行时状态有关的数据,比如hashcode,锁状态标志,指向锁记录的指针等,如下图所示。其中,最主要的就是四种锁状态,这四种锁又是什么呢?往下看。

素材3.png

3.synchronized关键字

synchronized 关键字解决的是多个线程之间访问资源的同步性,synchronized关键字可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。

另外,在 Java 早期版本中,synchronized 属于 重量级锁,效率低下。

为什么呢?

因为监视器锁(monitor)是依赖于底层的操作系统的 Mutex Lock 来实现的,Java 的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高。

庆幸的是在 Java 6 之后 Java 官方对从 JVM 层面对 synchronized 较大优化,所以现在的 synchronized 锁效率也优化得很不错了。JDK1.6 对锁的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。

所以,你会发现目前的话,不论是各种开源框架还是 JDK 源码都大量使用了 synchronized 关键字。

3.1 synchronized 关键字的底层原理

看下面的例子,num为共享变量,用synchronized标记的同步代码块去更改共享变量不会出问题,那么底层原理是什么呢?

TestSync.java

package com.conghuhu.sync;

/**
 * @author conghuhu
 * @create 2021-10-21 19:04
 */
public class TestSync {
    private int num = 0;
    public void test(){
        for(int i=0; i<800; i++){
            synchronized (this){
                System.out.println("thread:" + Thread.currentThread().getId()+", num:" + num++);
            }
        }
    }
}

main.java

package com.conghuhu;
import com.conghuhu.sync.TestSync;
/**
 * @author conghuhu
 * @create 2021-10-20 18:51
 */
public class main {
    public static void main(String[] args) {
        TestSync testSync = new TestSync();
        Thread t1 = new Thread(new Runnable() {
            @Override
            public void run() {
                testSync.test();
            }
        });
        Thread t2 = new Thread(new Runnable() {
            @Override
            public void run() {
                testSync.test();
            }
        });
        t1.start();
        t2.start();
    }
}

我们通过 JDK 自带的 javap 命令查看 TestSync 类的相关字节码信息,如下图所示:

素材1.png

素材2.png

从上面我们可以看出,synchronized 同步语句块的实现使用的是 monitorentermonitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。

Monitor:管程/监视器

如下图所示,首先,Entry Set中聚集了一些想要进入monitor的线程,处于waiting状态。假设某个线程A经过2进入monitor,那么它被标记为actived状态,激活。当A由于某个原因执行3,暂时让出执行权,那么它将进入Wait Set,状态也被标记为waiting,此时entry set中的线程就有机会进入monitor。
素材4.png
假设某个线程B进入monitor,并顺利执行,那么它可以通过notify的形式来唤醒wait set中的线程A,线程A再次进入monitor ,执行完后,便可以退出。

这就是synchronized的同步机制

3.2 synchronized的性能问题

上文我们知道,synchronized 同步语句块的实现使用的是 monitorentermonitorexit 两个字节码指令,而monitor是依赖于操作系统的mutex lock来实现的。Java线程实际上是对操作系统线程的映射,所以每当挂起或者唤醒一个线程,都要去切换操作系统用户态内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高。

所以,使用synchronized将会对程序的性能产生很严重的影响。

但是,从Java6开始,synchronized进行了优化,引入了偏向锁轻量级锁

3.3 synchronized优化

庆幸的是在 Java 6 之后 Java 官方对从 JVM 层面对 synchronized 较大优化,所以现在的 synchronized 锁效率也优化得很不错了。JDK1.6 对锁的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。

继续看下文四种锁状态,你就知道synchronized是怎么优化的了。

4.锁的四种状态

4.1 无锁

无锁,就是对资源没有锁定,所有线程都能够访问到同一资源。

有两种情况满足无锁:

  • 第一种:某个对象不会出现在多线程的环境下,或者说即使出现了多线程环境下也不会出现竞争的情况。此时无须对这个对象进行任何保护。

  • 第二种:资源会被竞争,但是我们不想对资源进行锁定,还是想通过某些机制来控制多线程。比如说,如果有多个线程想要修改同一个值,通过某种方式限制,只有一个线程能修改成功,其他修改失败的线程将会不断重试,直到修改成功,这就是耳熟能详的CASCAS在操作系统中通过一条指令实现,原理是拿期望的值和原本的一个值作比较,如果相同则更新成新的值,所以它能保证原子性,通过诸如CAS这种方式,我们可以实现无锁编程。

大部分情况下,无锁的效率是很高的。

4.2 偏向锁

假设一个对象A被加锁了,但在实际运行中只有一个线程会获取这个对象锁,我们最理想的方式,不通过线程切换,也不通过CAS来获得锁,因为这两种多多少少会耗费一些资源。我们设想的是,最好对象能够认识这个线程,只要这个对象过来,那么对象直接把锁交出去,我们就认为这个锁偏爱这个线程,也就是偏向锁

偏向锁的实现:

  1. 在对象头的Mark Word中,当锁标志位为01时,去判断倒数第三个bit是否为1,为1当前对象的锁状态就是偏向锁,否则为无锁。
  2. 确认当前状态为偏向锁,于是再去读前23个bit,这个值就是线程ID
  3. 通过线程ID来测试是否指向当前想要获得对象锁的这个线程,如果是,直接执行步骤6。如果不是继续步骤4.
  4. 如果线程ID并未指向当前线程,则通过CAS操作竞争锁。如果竞争成功,则将Mark Word中线程ID设置为当前线程ID,然后执行6;如果竞争失败,执行5。
  5. 如果CAS获取偏向锁失败,则表示有竞争,偏向锁升级为轻量级锁。
  6. 交出锁,可以访问对象的资源

素材3.png

4.3 轻量级锁

素材0.png

当一个线程想要获得某个对象的锁时,假如看到锁标志位为00,那么就知道它是轻量级锁,这时线程会在虚拟机栈中开辟一块被称为Lock Record的空间。
Lock Record中存的是对象头中Mark word的副本以及ownner指针。线程通过CAS去尝试获取锁,一旦获得那么将会赋值该对象头中的Mark word到Lock Record中,并且将Lock Record中的ownner指针指向该对象,另一方面,对象的Mark Word的前30个bit,将会生成一个指针,指向线程虚拟机栈中的Lock Record。
这样就实现了线程和对象锁的绑定,获取了锁的线程就可以执行同步代码块了。如果此时还有其他线程想要获取这个对象,将自旋等待。
自旋,可以理解为轮询,线程不断尝试着去看一下目标对象的锁有没有被释放。如果释放,那么就获取,如果没有释放就进入下一轮循环。这种方式区别于被操作系统挂起阻塞,因为对象的锁很快被释放的话,自旋就不需要进行系统中断和现场恢复,效率更高。
自旋,相当于CPU空转,如果长时间自旋会浪费CPU资源,于是出现了一种叫做适应性自旋的优化。简单来说,就是自旋的时间不再固定了,而是由上一次,在同一个锁上的自旋时间以及锁状态,这两个条件来进行决定。

假如此时有一个线程在自旋等待,又有其他线程同时也来请求资源,自旋等待。一旦自旋等待的线程数>1,那么轻量级锁将升级为重量级锁

4.4 重量级锁

synchronized在早期Java版本中,就是重量级锁,也就是上文提到的moniter对线程进行控制,此时将完全锁定资源,对线程的管控最为严格。
素材4.png

总结

本文主要介绍了几个问题:

  • 什么是锁?
  • 对象头
  • Mark Word
  • synchronized
  • monitor
  • 四种锁状态:无锁、偏向锁、轻量锁

参考资料:寒食君哔站视频

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值