8分钟搞懂Java中的各种锁

转载请注明出处❤️

作者:测试蔡坨坨

原文链接:caituotuo.top/f9fc66cb.html


前言

你好,我是测试蔡坨坨。

在前几篇Redis相关文章中都说到了锁,同时我们在参加设计评审或者codeReview时也会接触到关于加锁的问题。因此,作为测试人员,还是很有必要搞懂相关的锁机制。

你是否背了很多关于锁的面试题,但还是没有搞懂锁到底有哪些东西,学了很多锁之后,发现越搞越模糊。

不要慌,本篇我们就来聊一聊Java中的各种锁。

什么是锁

说到锁,我们自然而然会想到Synchronized、Lock、Reentrantlock、分布式锁等很多锁的类型。

那么第一个问题,我们要搞清楚锁到底解决什么问题?

很简单,锁要解决的一个问题就是线程安全问题。

所谓线程安全,主要体现在三方面:原子性、可见性和有序性。

  • 原子性:提供互斥访问,同一时刻只能有一个线程对数据进行操作。
  • 可见性:一个线程对主内存的修改可以及时被其他线程看到。
  • 有序性:一个线程观察其他线程的指令执行顺序,由于在JMM中允许编译器和处理器对指令重排序,因此该观察结果一般杂乱无序。

而Synchronized同步关键字是可以解决我们在多线程开发领域中涉及到的线程安全问题。

线程安全问题在实际开发中又是如何体现的呢?

举个简单的栗子,有一个int类型的i=0存在主内存中,有两个线程Thread1和Thread2同时执行一个i++操作,此时这个结果可能等于1,也可能等于2。为什么呢?

因为i++这个指令是非原子指令,i++在Java中是一条指令,但是最终转成底层的汇编指令是三条指令:

  1. 先从内存加载i的值(get)
  2. 对i进行递增(modify)
  3. 把i的值写回到内存中(set)

两个线程同时操作这三条指令时,就有可能两个线程同时拿到i,结果就是i=2。

所以,我们在这种场景中需要加排他锁,也叫同步锁。

这里的同步锁起到什么作用呢?

在没有加锁之前可能出现最终结果等于2的情况,是因为两个线程同时执行,同时拿到i的值,也就是并行操作,而加上同步锁就是让并行变成串行。

同步锁的特点就是多个线程访问共享资源时,在同一时刻只允许一个线程访问这个共享资源,这样就能够解决原子性问题。

功能层面

从功能层面来说,锁在Java并发编程中只有两类:共享锁排它锁

共享锁也叫读锁,读锁的特点是在同一时刻允许多个线程抢占到锁。

排它锁也叫写锁,写锁的特点是在同一时刻只允许一个线程抢占到锁。

性能和线程安全

我们经常听到的乐观/悲观锁、自旋锁、可重入锁、偏向锁、轻量/重量级锁又是什么呢?

这就需要从第二个维度进行拆分,加锁必然存在性能问题,因为加锁使得并行变成串行,并行的效率一定比串行高,加锁会造成阻塞。在软件开发中需要考虑两个点,性能和线程安全。例如:库存扣减,既要保证性能,又要保证线程的并发安全,保证原子性的修改。

在这个层面上来说,我们如何优化,如何权衡性能和线程安全两者之间的关系呢?

  • 锁粒度的优化,把锁的范围缩小,保证锁竞争的范围在目标需求范围内就好了。

  • 无锁化编程(乐观锁)/悲观锁

    乐观锁是没有加锁的,它是通过一个数据的版本来控制多线程并发的数据修改安全性。

    既然说到了乐观锁,不得不提一嘴悲观锁,乐观锁和悲观锁有什么区别呢?

    举个栗子,小明同学去上厕所,厕所里有一万个坑位,悲观锁的场景就是虽然有一万个坑位,但是小明也担心有人会来跟他抢一个坑位,于是小明上去二话不说就加了把锁;乐观锁的场景就是一万个坑位就小明一个人,小明进去之后也不用上锁,也不担心有人会来抢同一个坑位。

  • 偏向锁/轻量级锁(自旋锁)/重量级锁

    加锁的本质实际上是去竞争一个同步状态,如上图所示,有一个文件,两个线程需要去竞争文件的访问资格,如何知道是否有访问资格呢?可以通过一个同步标识,比如int status=0/1(0表示空闲,1表示繁忙),线程1竞争到锁,进入访问时将status修改成1,线程2再进入到锁判断时,只需要去判断当前的status是否等于1即可。

    Synchronized是通过操作系统层面的Mutex机制(mutually exclusive)实现同步状态,通过竞争Mutex机制,实现互斥状态的处理。线程1和2去竞争Mutex的时候,会涉及到内核指令的调用,因为Mutex是操作系统层面提供的一个互斥机制,所以需要通过内核指令去调用这个机制来实现互斥竞争行为。

    这个地方就会涉及到用户态内核态的切换,这个切换会占用CPU资源,消耗性能,因为用户线程要进入阻塞等待,然后切换到内核线程来运行,需要把当前运行的线程执行指令的上下文保存起来,同时要切换到内核线程去执行指令,也就是会涉及到线程的阻塞唤醒以及上下文的保存

    假设线程2竞争到了锁,线程1就会进入阻塞等待,所以加锁会影响性能。

    性能主要体现在三个方面:

    1.竞争同步状态时涉及到上下文切换,也就是从用户态到内核态的切换

    2.线程阻塞和唤醒的切换

    3.并行到串行的改变

    由于1和3无法改变,所以我们重点关注第二点线程的阻塞和唤醒,这个地方的切换是否能够避免,也就是说线程2竞争到锁之后,线程1不去阻塞等待,也就是让线程1在阻塞之前进行重试(重试就是线程1第一次尝试加锁,发现线程2已经获取到锁,这时就进入下一次循环再进行重试)。这种方式也就是所谓的自旋锁,在阻塞等待之前通过一定的自旋尝试去竞争锁资源,也叫做轻量级锁

    咱就是说我们加锁的代码有没有可能压根就不存在竞争场景?有可能。

    我们加锁的目的是保证这段代码的线程安全性,但是有可能在实际开发中这段代码压根就不存在竞争。

    举个栗子,前端页面做了非空校验,理论上传给后端的参数就不会为空,但是也有可能有人直接调用接口传一个空值,所以后端一般都做非空校验,也叫做防御性编程。

    同理,如果一段代码中锁的竞争必要性不存在,但是我们又想保护这段代码,于是就引入了偏向锁。

    所谓偏向锁就是当线程1进入锁的时候,如果当前不存在竞争,那么它就会把这个锁偏向线程1,线程1下次再进入的时候,就不再需要竞争锁。

    简而言之,偏向锁可以认为没有竞争,轻量级锁存在轻微竞争,而重量级锁就是整个的实现。

  • 锁消除/锁膨胀

    在jdk中还引入了锁消除和锁膨胀,这是编译器层面的优化,主要优化加锁的性能。

    锁消除也就是代码本身可能就没有线程安全问题,但是你又加了锁,然后jvm编译的时候发现这个地方加了锁,导致无效竞争,那么它就会把这个锁消除掉。锁膨胀是因为控制的锁粒度太小,导致频繁加锁和释放锁,所以它就会把锁的范围扩大。

  • 读写锁

    读写锁也是一种优化,读操作不会影响数据的准确性,因为它不会修改数据,也就是说读操作不需要加锁,针对读多写少的场景,读写锁可以确保读和读不会互斥,不需要竞争锁,而写和写实现互斥。

  • 公平锁/非公平锁

    公平锁就是每个线程获取锁的顺序是按照线程访问锁的先后顺序获取的;非公平锁就是每个线程获取锁的顺序是随机的,并不会遵循先来后到的规则,所有线程会竞争获取锁。

    默认情况下锁都是非公平的,比如Synchronized(只能为非公平锁)、Reentrantlock(在创建Reentrantlock时可以手动指定成公平锁),因为非公平锁的性能要比公平锁的性能更好,非公平锁意味着减少了锁的等待,减少了线程的阻塞和唤醒。

锁的特性

第三维度从锁的特性来说,又会有重入锁和分布式锁。

  • 重入锁

    锁主要用来控制多线程访问问题,对于同一线程,如果连续两次对同一把锁进行加锁,那么这个线程就会被卡死,在实际开发中,方法之间的调用错综复杂,一不小心就可能在多个不同的方法中反复调用lock(),造成死锁。

    重入锁就是用来解决这个问题的,使得同一线程可以对同一把锁在不释放的前提下,反复加锁不会导致线程卡死,唯一的一点就是需要保证lock()和unlock()的次数相同。

  • 分布式锁

    分布式锁是解决分布式架构下粒度的问题,解决的是进程维度的问题,而Synchronized是解决Java并发里面的线程维度。关于分布式锁更多知识点后面我们单独来讨论。