【Java基础】深入理解Java中的锁

Java中锁的常见面试问题:
  1. 了解的锁有哪些?
  2. 自旋锁与互斥锁对比?
  3. Synchronized底层原理?
  4. CAS及其缺点?
  5. 设计一个可重入锁?
  6. 乐观锁和悲观锁?都有哪些应用?

Java中锁的分类方式:

  • 从线程是否需要对资源加锁可以分为 悲观锁 和 乐观锁
  • 从资源已被锁定,线程是否阻塞可以分为 自旋锁
  • 从多个线程并发访问资源,也就是 Synchronized 可以分为 无锁、偏向锁、 轻量级锁 和 重量级锁
  • 从锁的公平性进行区分,可以分为公平锁 和 非公平锁
  • 从根据锁是否重复获取可以分为 可重入锁 和 不可重入锁
  • 从那个多个线程能否获取同一把锁分为 共享锁 和 排他锁

1.公平锁 / 非公平锁

(1)公平锁
公平锁是指多个线程按照申请锁的顺序来获取锁,类似排队打饭,先来后到。
(2)非公平锁
非公平锁是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁。有可能,会造成优先级反转或者饥饿现象。
  • 对于Java ReentrantLock而言,通过构造函数指定该锁是否是公平锁,默认是非公平锁。非公平锁的优点在于吞吐量比公平锁大。
  • 对于Synchronized而言,也是一种非公平锁。由于其并不像ReentrantLock是通过AQS的来实现线程调度,所以并没有任何办法使其变成公平锁。
关于两者的区别:
  • 公平锁:Threads acquire a fair lock in the order which they requested it.
公平锁,就是很公平,在并发环境中,每个线程在获取锁时会先查看此锁维护的等待队列,如果为空,或者当前线程是等待队列的第一个,就占有锁,否则就会加入到等待队列中,以后按照FIFO的规则从队列中取到自己。
  • 非公平锁:a nonfair lock permits barging: treads requesting a lock can jump ahead of the queue of waiting threads if the lock happens to be available when it is requested.
非公平锁,毕竟粗鲁,上来就直接尝试占有锁,如果尝试失败,就再采用类似公平锁的那种方式;

2.可重入锁 / 不可重入锁

(1)可重入锁
广义上的可重入锁指的是可重复可递归调用的锁,在外层使用锁之后,在内层仍然可以使用,并且不发生死锁(前提得是同一个对象或者class),这样的锁就叫做可重入锁。ReentrantLocksynchronized都是可重入锁
synchronized void setA() throws Exception{
   Thread.sleep(1000);
   setB();
}
synchronized void setB() throws Exception{
   Thread.sleep(1000);
}
上面的代码就是一个可重入锁的一个特点,如果不是可重入锁的话,setB可能不会被当前线程执行,可能造成死锁。
(2)不可重入锁
不可重入锁,与可重入锁相反,不可递归调用,递归调用就发生死锁。看到一个经典的讲解,使用自旋锁来模拟一个不可重入锁,代码如下
import java.util.concurrent.atomic.AtomicReference;

public class UnreentrantLock {

   private AtomicReference<Thread> owner = new AtomicReference<Thread>();

   public void lock() {
       Thread current = Thread.currentThread();
       //这句是很经典的“自旋”语法,AtomicInteger中也有
       for (;;) {
           if (!owner.compareAndSet(null, current)) {
               return;
           }
       }
   }

   public void unlock() {
       Thread current = Thread.currentThread();
       owner.compareAndSet(current, null);
   }
}
代码也比较简单,使用原子引用来存放线程,同一线程两次调用lock()方法,如果不执行unlock()释放锁的话,第二次调用自旋的时候就会产生死锁,这个锁就不是可重入的,而实际上同一个线程不必每次都去释放锁再来获取锁,这样的调度切换是很耗资源的。
(3)把它变成一个可重入锁:
import java.util.concurrent.atomic.AtomicReference;

public class UnreentrantLock {

   private AtomicReference<Thread> owner = new AtomicReference<Thread>();
   private int state = 0;

   public void lock() {
       Thread current = Thread.currentThread();
       if (current == owner.get()) {
           state++;
           return;
       }
       //这句是很经典的“自旋”式语法,AtomicInteger中也有
       for (;;) {
           if (!owner.compareAndSet(null, current)) {
               return;
           }
       }
   }

   public void unlock() {
       Thread current = Thread.currentThread();
       if (current == owner.get()) {
           if (state != 0) {
               state--;
           } else {
               owner.compareAndSet(current, null);
           }
       }
   }
}
在执行每次操作之前,判断当前锁持有者是否是当前对象,采用state计数,不用每次去释放锁。
(4)ReentrantLock中可重入锁实现
这里看非公平锁的锁获取方法:
final boolean nonfairTryAcquire(int acquires) {
   final Thread current = Thread.currentThread();
   int c = getState();
   if (c == 0) {
       if (compareAndSetState(0, acquires)) {
           setExclusiveOwnerThread(current);
           return true;
       }
   }
   //就是这里
   else if (current == getExclusiveOwnerThread()) {
       int nextc = c + acquires;
       if (nextc < 0) // overflow
           throw new Error("Maximum lock count exceeded");
       setState(nextc);
       return true;
   }
   return false;
}
在AQS中维护了一个private volatile int state来计数重入次数,避免了频繁的持有释放操作,这样既提升了效率,又避免了死锁。

3.独享锁 / 共享锁

独享锁和共享锁在你去读C.U.T包下的ReeReentrantLock和ReentrantReadWriteLock你就会发现,它俩一个是独享一个是共享锁。
(1)独享锁:
该锁每一次只能被一个线程所持有。
(2)共享锁:
该锁可被多个线程共有,典型的就是ReentrantReadWriteLock里的读锁,它的读锁是可以被共享的,但是它的写锁确每次只能被独占。
另外读锁的共享可保证并发读是非常高效的,但是读写和写写,写读都是互斥的。
独享锁与共享锁也是通过AQS来实现的,通过实现不同的方法,来实现独享或者共享。
对于Synchronized而言,当然是独享锁。

4.互斥锁 / 读写锁

(1)互斥锁
在访问共享资源之前对进行加锁操作,在访问完成之后进行解锁操作。加锁后,任何其他试图再次加锁的线程会被阻塞,直到当前进程解锁。
如果解锁时有一个以上的线程阻塞,那么所有该锁上的线程都被编程就绪状态, 第一个变为就绪状态的线程又执行加锁操作,那么其他的线程又会进入等待。在这种方式下,只有一个线程能够访问被互斥锁保护的资源
(2)读写锁
读写锁既是互斥锁,又是共享锁,read模式是共享,write是互斥(排它锁)的。
读写锁有三种状态:读加锁状态、写加锁状态和不加锁状态
读写锁在Java中的具体实现就是 ReadWriteLock
一次只有一个线程可以占有写模式的读写锁,但是多个线程可以同时占有读模式的读写锁。
只有一个线程可以占有写状态的锁,但可以有多个线程同时占有读状态锁,这也是它可以实现高并发的原因。当其处于写状态锁下,任何想要尝试获得锁的线程都会被阻塞,直到写状态锁被释放;如果是处于读状态锁下,允许其它线程获得它的读状态锁,但是不允许获得它的写状态锁,直到所有线程的读状态锁被释放;为了避免想要尝试写操作的线程一直得不到写状态锁,当读写锁感知到有线程想要获得写状态锁时,便会阻塞其后所有想要获得读状态锁的线程。所以读写锁非常适合资源的读操作远多于写操作的情况。

5.乐观锁 / 悲观锁

乐观锁和悲观锁都是用于解决并发场景下的数据竞争问题,但是却是两种完全不同的思想。它们的使用非常广泛,也不局限于某种编程语言或数据库。

5.1 悲观锁

总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁(共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程)。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。JavasynchronizedReentrantLock等独占锁就是悲观锁思想的实现。

  • 悲观锁的实现方式
悲观锁的实现方式也就是加锁,加锁既可以在代码层面(比如Java中的synchronized关键字),也可以在数据库层面(比如MySQL中的排他锁)。

5.2 乐观锁

总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号机制和CAS算法实现。乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库提供的类似于write_condition机制,其实都是提供的乐观锁。在Javajava.util.concurrent.atomic包下面的原子变量类就是使用了乐观锁的一种实现方式CAS实现的

5.2.1 乐观锁的实现方式

乐观锁的实现方式主要有两种,一种是CAS(Compare and Swap,比较并交换)机制,一种是版本号机制。
  • CAS机制
CAS操作包括了三个操作数,分别是需要读取的内存位置(V)、进行比较的预期值(A)和拟写入的新值(B),操作逻辑是,如果内存位置V的值等于预期值A,则将该位置更新为新值B,否则不进行操作。另外,许多CAS操作都是自旋的,意思就是,如果操作不成功,就会一直重试,直到操作成功为止。
  • 版本号机制
版本号机制的基本思路,是在数据中增加一个version字段用来表示该数据的版本号,每当数据被修改版本号就会加1。当某个线程查询数据的时候,会将该数据的版本号一起读取出来,之后在该线程需要更新该数据的时候,就将之前读取的版本号与当前版本号进行比较,如果一致,则执行操作,如果不一致,则放弃操作。

5.2.2 乐观锁是否会加锁

乐观锁本身是不加锁的,只有在更新的时候才会去判断数据是否被其他线程更新了,比如AtomicInteger便是一个例子。但是有时候乐观锁可能会与加锁操作合作,比如MySQL在执行更新数据操作的时候会加上排他锁。因此可以理解为乐观锁本身是不加锁的,只有在更新数据的时候才有可能会加锁。

5.3乐观锁与悲观锁的优缺点和使用场景

乐观锁和悲观锁并没有优劣之分,它们有各自适合的场景。
  • 功能限制
乐观锁与悲观锁相比,适用的场景受到了更多的限制,无论是CAS机制还是版本号机制。比如,CAS机制只能保证单个变量操作的原子性,当涉及到多个变量的时候,CAS机制是无能为力的,而synchronized却可以通过对整个代码块进行加锁处理;再比如,版本号机制如果在查询数据的时候是针对表1,而更新数据的时候是针对表2,也很难通过简单的版本号来实现乐观锁。
  • 竞争激烈程度
在竞争不激烈(出现并发冲突的概率比较小)的场景中,乐观锁更有优势。因为悲观锁会锁住代码块或数据,其他的线程无法同时访问,必须等待上一个线程释放锁才能进入操作,会影响并发的响应速度。另外,加锁和释放锁都需要消耗额外的系统资源,也会影响并发的处理速度。在竞争激烈(出现并发冲突的概率较大)的场景中,悲观锁则更有优势。因为乐观锁在执行更新的时候,可能会因为数据被反复修改而更新失败,进而不断重试,造成CPU资源的浪费。

6.分段锁 / 死锁 / 活锁 / 闭锁 / 无锁

6.1 分段锁

分段锁其实是一种锁的设计,并不是具体的一种锁,对于ConcurrentHashMap而言,其并发的实现就是通过分段锁的形式来实现高效的并发操作。
并发容器类的加锁机制是基于粒度更小的分段锁,分段锁也是提升多并发程序性能的重要手段之一。
在并发程序中,串行操作是会降低可伸缩性,并且上下文切换也会减低性能。在锁上发生竞争时将通水导致这两种问题,使用独占锁时保护受限资源的时候,基本上是采用串行方式—-每次只能有一个线程能访问它。所以对于可伸缩性来说最大的威胁就是独占锁。
我们一般有三种方式降低锁的竞争程度
1、减少锁的持有时间
2、降低锁的请求频率
3、使用带有协调机制的独占锁,这些机制允许更高的并发性。
在某些情况下我们可以将锁分解技术进一步扩展为一组独立对象上的锁进行分解,这成为分段锁。
其实说的简单一点就是
容器里有多把锁,每一把锁用于锁容器其中一部分数据,那么当多线程访问容器里不同数据段的数据时,线程间就不会存在锁竞争,从而可以有效的提高并发访问效率,这就是ConcurrentHashMap所使用的锁分段技术,首先将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问。
比如:在ConcurrentHashMap中使用了一个包含16个锁的数组,每个锁保护所有散列桶的1/16,其中第N个散列桶由第(N mod 16)个锁来保护。假设使用合理的散列算法使关键字能够均匀的分部,那么这大约能使对锁的请求减少到越来的1/16。也正是这项技术使得ConcurrentHashMap支持多达16个并发的写入线程。

6.2 死锁

死锁是指两个或两个以上的进程在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力作用,他们都将无法推进下去。这是一个严重的问题,因为死锁会让你的程序挂起无法完成任务,死锁的发生必须满足一下4个条件:
  • 互斥条件:一个资源每次只能被一个进程使用。
  • 请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放。
  • 不剥夺条件:进程已获得的资源,在未使用完之前,不能强行剥夺。
  • 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。
避免死锁最简单的方法就是阻止循环等待条件,将系统中所有的资源设置标志位、排序,规定所有的进程申请资源必须以一定的顺序做操作来避免死锁。

6.3 活锁

LiveLock是一种形式活跃性问题,该问题尽管不会阻塞线程,但也不能继续执行,因为线程将不断重复执行相同的操作,而且总会失败。活锁通常发送在处理事务消息的应用程序中:如果不能成功地处理某个消息,那么消息处理机制将回滚整个事务,并将它重新放到队列的开头:如果不能成功地处理某个消息,那么消息处理机制将回滚整个事务,并将它重新放到队列的开头。如果消息处理器在处理某种特定类型的消息时存在错误并导致它失败,那么每当这个消息从队列中取出并传递到存在错误的处理器时,都会发生事务回滚。由于这条消息又被放回到队列开头,因此处理器将被反复调用,并返回相同的结果。

6.4 闭锁

闭锁是一种同步工具类,可以延迟线程的进度直到其到达终止状态。闭锁的作用相当于一扇门:在闭锁到达结束状态之前,这扇门一直是关闭的,并且没有任何线程能通过,当到达结束状态时,这扇门会打开允许所有的线程通过。当闭锁到达结束状态后,将不会再改变状态,因此这扇门将永远保持打开状态。闭锁可以用来确保某些活动指导其他活动都完成后才继续执行。CountDownLatch就是一种灵活的闭锁实现

6.5 无锁

要保证现场安全,并不是一定就要进行同步,两者没有因果关系。同步只是保证共享数据争用时的正确性的手段,如果一个方法本来就不涉及共享数据,那它自然就无须任何同步措施去保证正确性,因此会有一些代码天生就是线程安全的。
  1. 无状态编程。无状态代码有一些共同的特征:不依赖于存储在对上的数据和公用的系统资源、用到的状态量都由参数中传入、不调用非无状态的方法等。可以参考Servlet。
  2. 线程本地存储。可以参考ThreadLocal
  3. volatile
  4. CAS
  5. 协程:在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换。

7.偏向锁 / 轻量级锁 / 重量级锁

锁的状态
  • 1.无锁状态
  • 2.偏向锁状态
  • 3.轻量级锁状态
  • 4.重量级锁状态
锁的状态是通过对象监视器在对象头中的字段来表明的。四种状态会随着竞争的情况逐渐升级,而且是不可逆的过程,即不可降级。这四种状态都不是Java语言中的锁,而是Jvm为了提高锁的获取与释放效率而做的优化(使用synchronized时)。

7.1 无锁

无锁状态,无锁即没有对资源进行锁定,所有的线程都可以对同一个资源进行访问,但是只有一个线程能够成功修改资源。

无锁的特点就是在循环内进行修改操作,线程会不断的尝试修改共享资源,直到能够成功修改资源并退出,在此过程中没有出现冲突的发生,这很像我们在之前文章中介绍的 CAS 实现,CAS 的原理和应用就是无锁的实现。无锁无法全面代替有锁,但无锁在某些场合下的性能是非常高的。

7.2 偏向锁

偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁。降低获取锁的代价。
HotSpot 的作者经过研究发现,大多数情况下,锁不仅不存在多线程竞争,还存在锁由同一线程多次获得的情况,偏向锁就是在这种情况下出现的,它的出现是为了解决只有在一个线程执行同步时提高性能。

可以从对象头的分配中看到,偏向锁要比无锁多了线程ID 和 epoch,下面我们就来描述一下偏向锁的获取过程
偏向锁获取过程
  1. 首先线程访问同步代码块,会通过检查对象头 Mark Word 的锁标志位判断目前锁的状态,如果是 01,说明就是无锁或者偏向锁,然后再根据是否偏向锁 的标示判断是无锁还是偏向锁,如果是无锁情况下,执行下一步
  2. 线程使用 CAS 操作来尝试对对象加锁,如果使用 CAS 替换 ThreadID 成功,就说明是第一次上锁,那么当前线程就会获得对象的偏向锁,此时会在对象头的 Mark Word 中记录当前线程 ID 和获取锁的时间 epoch 等信息,然后执行同步代码块。
全局安全点(Safe Point):全局安全点的理解会涉及到 C 语言底层的一些知识,这里简单理解 SafePoint 是 Java 代码中的一个线程可能暂停执行的位置。
等到下一次线程在进入和退出同步代码块时就不需要进行 CAS 操作进行加锁和解锁,只需要简单判断一下对象头的 Mark Word 中是否存储着指向当前线程的线程ID,判断的标志当然是根据锁的标志位来判断的。如果用流程图来表示的话就是下面这样

关闭偏向锁
偏向锁在Java 6 和Java 7 里是默认启用的。由于偏向锁是为了在只有一个线程执行同步块时提高性能,如果你确定应用程序里所有的锁通常情况下处于竞争状态,可以通过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false,那么程序默认会进入轻量级锁状态。
关于 epoch
偏向锁的对象头中有一个被称为 epoch 的值,它作为偏差有效性的时间戳。

7.3 轻量级

轻量级锁是指当锁是偏向锁的时候,被另一个线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,提高性能。
轻量级锁加锁过程
  1. 紧接着上一步,如果 CAS 操作替换 ThreadID 没有获取成功,执行下一步;
  2. 如果使用 CAS 操作替换 ThreadID 失败(这时候就切换到另外一个线程的角度)说明该资源已被同步访问过,这时候就会执行锁的撤销操作,撤销偏向锁,然后等原持有偏向锁的线程到达全局安全点(SafePoint)时,会暂停原持有偏向锁的线程,然后会检查原持有偏向锁的状态,如果已经退出同步,就会唤醒持有偏向锁的线程,执行下一步;
  3. 检查对象头中的 Mark Word 记录的是否是当前线程 ID,如果是,执行同步代码,如果不是,执行偏向锁获取流程 的第2步。
如果用流程表示的话就是下面这样(已经包含偏向锁的获取)

7.4 重量级锁

重量级锁是指当锁为轻量级锁的时候,另一个线程虽然是自旋,但自旋不会一直持续下去,当自旋一定次数的时候,还没有获取到锁,就会进入阻塞,该锁膨胀为重量级锁。重量级锁会让其他申请的线程进入阻塞,性能降低。
重量级锁的获取流程
1. 接着上面偏向锁的获取过程,由偏向锁升级为轻量级锁,执行下一步
2 . 会在原持有偏向锁的线程的栈中分配锁记录,将对象头中的 Mark Word 拷贝到原持有偏向锁线程的记录中,然后原持有偏向锁的线程获得轻量级锁,然后唤醒原持有偏向锁的线程,从安全点处继续执行,执行完毕后,执行下一步,当前线程执行第4步
3. 执行完毕后,开始轻量级解锁操作,解锁需要判断两个条件
如果上面两个判断条件都符合的话,就进行锁释放,如果其中一个条件不 符合,就会释放锁,并唤起等待的线程,进行新一轮的锁竞争。
拷贝在当前线程锁记录的 Mark Word 信息是否与对象头中的 Mark Word 一致。
判断对象头中的 Mark Word 中锁记录指针是否指向当前栈中记录的指针
4. 在当前线程的栈中分配锁记录,拷贝对象头中的 MarkWord 到当前线程的锁记录中,执行 CAS 加锁操作,会把对象头 Mark Word 中锁记录指针指向当前线程锁记录,如果成功,获取轻量级锁,执行同步代码,然后执行第3步,如果不成功,执行下一步
5. 当前线程没有使用 CAS 成功获取锁,就会自旋一会儿,再次尝试获取,如果在多次自旋到达上限后还没有获取到锁,那么轻量级锁就会升级为 重量级锁
如果用流程图表示是这样的:

7.5 总结

  • synchronized的偏向锁、轻量级锁以及重量级锁是通过Java对象头实现的。
  • Java对象的内存布局分为:对象头、实例数据和对齐填充,而对象头又可以分为”Mark Word”和类型指针klass。”Mark Word”是关键,默认情况下,其存储对象的HashCode、分代年龄和锁标记位
首先来看一下”Mark Word”的内容
锁状态
存储内容
标志位
无锁
对象的hashCode、对象分代年龄、是否是偏向锁(0)
01
轻量级
指向栈中锁记录的指针
00
重量级
指向互斥量(重量级锁)的指针
10
GC标记
(空)
11
偏向锁
偏向线程ID、偏向时间戳、对象分代年龄、是否是偏向锁(1)
01
注意到这里的无锁和偏向锁在”Mark Word”的倒数第三bit中分别采用0和1标记。
  • 偏向锁是JDK6中引入的一项锁优化,它的目的是消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能。
  • 偏向锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁没有被其他的线程获取,则持有偏向锁的线程将永远不需要同步。大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。
  • 当锁对象第一次被线程获取的时候,线程使用CAS操作把这个锁的线程ID记录再对象Mark Word之中,同时置偏向标志位1。以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需要简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得了锁。
  • 如果线程使用CAS操作时失败则表示该锁对象上存在竞争并且这个时候另外一个线程获得偏向锁的所有权。当到达全局安全点(safepoint,这个时间点上没有正在执行的字节码)时获得偏向锁的线程被挂起,膨胀为轻量级锁(涉及Monitor Record,Lock Record相关操作,这里不展开),同时被撤销偏向锁的线程继续往下执行同步代码。
  • 当有另外一个线程去尝试获取这个锁时,偏向模式就宣告结束。
  • 线程在执行同步块之前,JVM会先在当前线程的栈帧中创建用于存储锁记录(Lock Record)的空间,并将对象头中的Mard Word复制到锁记录中,官方称为Displaced Mark Word。然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。如果自旋失败则锁会膨胀成重量级锁。如果自旋成功则依然处于轻量级锁的状态。
  • 轻量级锁的解锁过程也是通过CAS操作来进行的,如果对象的Mark Word仍然指向线程的锁记录,那就用CAS操作把对象当前的Mark Word和线程中赋值的Displaced Mark Word替换回来,如果替换成功,整个同步过程就完成了,如果替换失败,就说明有其他线程尝试过获取该锁,那就要在释放锁的同时,唤醒被挂起的线程。
  • 轻量级锁提升程序同步性能的依据是:对于绝大部分的锁,在整个同步周期内都是不存在竞争的(区别于偏向锁)。这是一个经验数据。如果没有竞争,轻量级锁使用CAS操作避免了使用互斥量的开销,但如果存在锁竞争,除了互斥量的开销外,还额外发生了CAS操作,因此在有竞争的情况下,轻量级锁比传统的重量级锁更慢。
整个synchronized锁流程如下:
  1. 检测Mark Word里面是不是当前线程的ID,如果是,表示当前线程处于偏向锁
  2. 如果不是,则使用CAS将当前线程的ID替换Mard Word,如果成功则表示当前线程获得偏向锁,置偏向标志位1
  3. 如果失败,则说明发生竞争,撤销偏向锁,进而升级为轻量级锁。
  4. 当前线程使用CAS将对象头的Mark Word替换为锁记录指针,如果成功,当前线程获得锁
  5. 如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。
  6. 如果自旋成功则依然处于轻量级状态。
  7. 如果自旋失败,则升级为重量级锁。

8.自旋锁

我们知道CAS算法是乐观锁的一种实现方式,CAS算法中又涉及到自旋锁,所以这里给大家讲一下什么是自旋锁。

8.1 CAS算法

CAS是英文单词Compare and Swap(比较并交换),是一种有名的无锁算法。无锁编程,即不使用锁的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量的同步,所以也叫非阻塞同步(Non-blocking Synchronization)。CAS算法涉及到三个操作数
  • 1.需要读写的内存值 V
  • 2.进行比较的值 A
  • 3.拟写入的新值 B
更新一个变量的时候,只有当变量的预期值A和内存地址V当中的实际值相同时,才会将内存地址V对应的值修改为B,否则不会执行任何操作。一般情况下是一个自旋操作,即不断的重试。

CAS的缺点

CAS的缺点主要有ABA问题、高竞争下的开销问题和本身的功能限制。
  • ABA问题
所谓的ABA问题,指的就是一个线程在操作数据的时候,有别的线程对数据进行了一系列操作,但是在该线程重新读取该数据的时候,被修改过的数据却和该线程一开始读取的数据一致,该线程不会知道该数据已经被修改过了,然后CAS操作就被判断是成功了。
ABA问题在一些场景下可能不会造成什么危害,但是在某些场景中却可能会造成隐患。比如CAS操作的是栈顶的数据,栈顶的数据虽然经过两次(或多次)变化后又恢复了原值,但是栈却可能是发生了变化,栈中数据的变化就可能会引发一些问题。
对于ABA问题,比较有效的方案是引入版本号。只要内存中的值发生变化,版本号就加1,在进行CAS操作的时候不仅比较内存中的值,也比较版本号,只有当二者都没有变化的时候,CAS操作才能执行成功。Java中的AtomicStampedReference类便是适用版本号来解决ABA问题的。
  • 高竞争下的开销问题
在并发冲突的概率较大的高竞争场景下,如果CAS操作一直失败,就会一直重试,造成CPU开销大的问题。针对这个问题,一个简单的思路是引入退出机制,如果重试次数超过一定阈值,就强制失败退出。当然了,最好是避免在高竞争的场景下使用乐观锁。
  • 自身的功能限制
CAS的功能是比较受限的,比如CAS只能保证单个变量(或者说单个内存值)操作的原子性。这就意味着原子性不一定能保证线程安全,当涉及到多个变量(或者说多个内存值),CAS也是无能为力。除此之外,CAS的实现需要硬件层面处理器的支持,在Java中普通的用户是无法直接使用的,只有借助atomic包下的原子类才能使用,灵活性有限。

8.2 自旋锁(spinlock)

自旋锁(spinlock):是指当一个线程在获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待,然后不断的判断锁是否能够被成功获取,直到获取到锁才会退出循环
  • 这样的好处是减少线程上下文切换的消耗,缺点是循环会消耗CPU。

它是为实现保护共享资源而提出一种锁机制。其实,自旋锁与互斥锁比较类似,它们都是为了解决对某项资源的互斥使用。无论是互斥锁,还是自旋锁,在任何时刻,最多只能有一个保持者,也就说,在任何时刻最多只能有一个执行单元获得锁。但是两者在调度机制上略有不同。对于互斥锁,如果资源已经被占用,资源申请者只能进入睡眠状态。但是自旋锁不会引起调用者睡眠,如果自旋锁已经被别的执行单元保持,调用者就一直循环在那里看是否该自旋锁的保持者已经释放了锁,”自旋”一词就是因此而得名。

8.2.1 Java如何实现自旋锁?

下面是个简单的例子:
public class SpinLock {
   private AtomicReference<Thread> cas = new AtomicReference<Thread>();
   public void lock() {
       Thread current = Thread.currentThread();
       // 利用CAS
       while (!cas.compareAndSet(null, current)) {
           // DO nothing
       }
   }
   public void unlock() {
       Thread current = Thread.currentThread();
       cas.compareAndSet(current, null);
   }
}
lock()方法利用的CAS,当第一个线程A获取锁的时候,能够成功获取到,不会进入while循环,如果此时线程A没有释放锁,另一个线程B又来获取锁,此时由于不满足CAS,所以就会进入while循环,不断判断是否满足CAS,直到A线程调用unlock方法释放了该锁。

8.2.2 自旋锁存在的问题:

1、如果某个线程持有锁的时间过长,就会导致其它等待获取锁的线程进入循环等待,消耗CPU。使用不当会造成CPU使用率极高。
2、上面Java实现的自旋锁不是公平的,即无法满足等待时间最长的线程优先获取锁。不公平的锁就会存在“线程饥饿”问题。

8.2.3 自旋锁的优点:

1、自旋锁不会使线程状态发生切换,一直处于用户态,即线程一直都是active的;不会使线程进入阻塞状态,减少了不必要的上下文切换,执行速度快
2、非自旋锁在获取不到锁的时候会进入阻塞状态,从而进入内核态,当获取到锁的时候需要从内核态恢复,需要线程上下文切换。(线程被阻塞后便进入内核(Linux)调度状态,这个会导致系统在用户态与内核态之间来回切换,严重影响锁的性能)

8.2.4 可重入的自旋锁和不可重入的自旋锁:

文章开始的时候的那段代码,仔细分析一下就可以看出,它是不支持重入的,即当一个线程第一次已经获取到了该锁,在锁释放之前又一次重新获取该锁,第二次就不能成功获取到。由于不满足CAS,所以第二次获取会进入while循环等待,而如果是可重入锁,第二次也是应该能够成功获取到的。
而且,即使第二次能够成功获取,那么当第一次释放锁的时候,第二次获取到的锁也会被释放,而这是不合理的。
为了实现可重入锁,我们需要引入一个计数器,用来记录获取锁的线程数。
public class ReentrantSpinLock {
   private AtomicReference<Thread> cas = new AtomicReference<Thread>();
   private int count;
   public void lock() {
       Thread current = Thread.currentThread();
       if (current == cas.get()) { // 如果当前线程已经获取到了锁,线程数增加一,然后返回
           count++;
           return;
       }
       // 如果没获取到锁,则通过CAS自旋
       while (!cas.compareAndSet(null, current)) {
           // DO nothing
       }
   }
   public void unlock() {
       Thread cur = Thread.currentThread();
       if (cur == cas.get()) {
           if (count > 0) {// 如果大于0,表示当前线程多次获取了该锁,释放锁通过count减一来模拟
               count--;
           } else {// 如果count==0,可以将锁释放,这样就能保证获取锁的次数与释放锁的次数是一致的了。
               cas.compareAndSet(cur, null);
           }
       }
   }
}

8.2.5 自旋锁与互斥锁:

  1. 自旋锁与互斥锁都是为了实现保护资源共享的机制。
  2. 无论是自旋锁还是互斥锁,在任意时刻,都最多只能有一个保持者。
  3. 获取互斥锁的线程,如果锁已经被占用,则该线程将进入睡眠状态;获取自旋锁的线程则不会睡眠,而是一直循环等待锁释放。

8.2.6 自旋锁总结:

  1. 自旋锁:线程获取锁的时候,如果锁被其他线程持有,则当前线程将循环等待,直到获取到锁。
  2. 自旋锁等待期间,线程的状态不会改变,线程一直是用户态并且是活动的(active)。
  3. 自旋锁如果持有锁的时间太长,则会导致其它等待获取锁的线程耗尽CPU。
  4. 自旋锁本身无法保证公平性,同时也无法保证可重入性。
  5. 基于自旋锁,可以实现具备公平性和可重入性质的锁。
#高频知识点汇总##Java##学习路径##阿里巴巴##腾讯##百度##字节跳动#
全部评论
🎉恭喜牛友成功参与 【创作激励计划】高频知识点汇总专场,并通过审核! ------------------- 创作激励计划5大主题专场等你来写,最高可领取500元京东卡和500元实物奖品! 👉快来参加吧:https://www.nowcoder.com/discuss/804743
点赞 回复 分享
发布于 2021-11-24 20:38

相关推荐

10-15 16:27
门头沟学院 C++
LeoMoon:建议问一下是不是你给他付钱😅😅
点赞 评论 收藏
分享
object3:开始给部分🌸孝子上人生第一课了
点赞 评论 收藏
分享
评论
8
44
分享
牛客网
牛客企业服务