Java线程锁机制是怎样的?

  1. JAVA的锁就是在对象的Markword中记录一个锁状态。无锁,偏向锁,轻量级锁,重量级锁对应不同的锁状态。
  2. JAVA的锁机制就是根据资源竞争的激烈程度不断进行锁升级的过程。

锁的分类

1. 乐观锁与悲观锁

  • 乐观锁
  1. 对共享数据进行访问时,乐观锁总是认为不会有其他线程修改数据修改数据。
  2. 于是直接执行操作,只是在更新时检查数据是否已经被其他线程修改。
  3. 如果没有被修改,则操作执行成功;否则,添加其他补偿措施。
  4. 常见的补偿措施是不断尝试,直到成功。
  • Java中的非阻塞同步都是采用这种乐观的并发策略,乐观锁在Java中是通过使用无锁编程来实现,最常使用的CAS操作
  • 比如,线程安全的原子类的自增操作,就是通过循环的CAS操作实现的。

乐观锁与悲观锁

  • 悲观锁
  1. 对共享数据进行访问时,悲观锁总是认为一定会有其他线程修改数据。如果不加锁,肯定会出问题。
  2. 因此,悲观锁无论是否出现共享数据的争用,在访问数据时都会先加锁。
  • Java中同步互斥都是采用这种悲观的并发策略synchronized关键字和Lock接口的实现类都是悲观锁。

2. 独占锁和共享锁

  • 独占锁
  1. 又叫排它锁,同一个锁对象,同一时刻只允许一个线程获取到锁。
  2. 如果线程T对数据A加上独占锁后,其他线程不能对该数据再加任何类型的锁(包括独占锁和共享锁),自己可以对数据进行读操作或者写操作。
  3. 独占锁允许线程对数据进行读写操作。
  • Java中的 synchronized关键字、Mutex、ReentrantLock、ReentrantReadWriteLock 中写锁,都是独占锁。

  • 共享锁

  1. 同一个所对象,同一时刻允许多个线程获取到锁。
  2. 线程T对数据A加上共享锁,则其他线程只能对数据A加共享锁,不能加独占锁。
  3. 共享锁只允许对数据进行读操作。
  • java中ReentrantReadWriteLock中读锁是共享锁。
  • ReentrantReadWriteLock读写锁的获取
  1. 同步状态不为0,如果有其他线程获取到读锁或者当前线程不是持有写锁的线程,则获取写锁失败进入阻塞状态;否则,当前线程是持有写锁的线程,直接通过setState()方法增加写状态。
  2. 同步状态为0,直接通过compareAndSetState()方法实现写状态的CAS增加,并将当前线程设置为持有写锁的线程。
  3. 如果有其他线程获取到了写锁,则获取读锁失败进入阻塞状态。
  4. 如果写锁未被获取或者该线程为持有写锁的线程,则获取读锁成功,通过compareAndSetState()方法实现读状态的CAS增加
  • 独占锁和共享锁都是通过AQS实现的,tryAcquire()或者tryAcquireShared()方法支持独占式或者共享式的获取同步状态。

3. 公平锁和非公平锁

  • 公平锁
  1. 当锁被释放,按照阻塞的先后顺序获取锁,即同步队列头节点中的线程将获取锁。
  2. 公平锁可以保证锁的获取按照FIFO原则,但需要进行大量的线程切换,导致吞吐率较低。
  • 非公平锁:
  1. 当锁被释放,所有阻塞的线程都可以争抢获取锁的资格,可能导致先阻塞的线程最后获取锁。
  2. 非公平锁虽然可能造成线程饥饿,但极少进行线程的切换,保证了更大的吞吐量
  • Java中ReentrantLock和ReentrantReadWriteLock支持公平和非公平访问,而synchronized关键字只支持非公平访问。
  • 公平与非公平可以通过构造函数的fair参数进行指定,默认是false,即默认为非公平的获取锁。
  • 公平和非公平都是依靠AQS实现的,公平使用FairSync同步器,非公平使用NoFairSync同步器。
    1
    2
    3
    public ReentrantLock(boolean fair) {
    sync = fair ? new FairSync() : new NonfairSync();
    }

    4. 可重入锁和非可重入锁

可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象或者class),不会因为之前已经获取过还没释放而阻塞。Java中ReentrantLock和synchronized都是可重入锁,可重入锁的一个优点是可一定程度避免死锁。下面用示例代码来进行分析:

  • 可重入锁:
  1. 已经获取锁的线程再次获取该锁而不被锁所阻塞,需要解决线程再次获取锁和锁的最终释放两个问题。
  2. 可重入锁可以一定程度的避免死锁

可重入锁

  • 非可重入锁:
  1. 已经获取锁的线程再次获取该锁,会因为需要等待自身释放锁而被阻塞。
  2. 非可重入锁容易造成当前线程死锁,从而使整个队列中线程永久阻塞。

非可重入锁

  • Java中的synchronized关键字、ReentrantLock锁和ReentrantReadWriteLock锁都支持重进入,其中ReentrantReadWriteLock的读锁是支持重进入的共享锁,写锁是支持重进入的独占锁

5.无锁VS偏向锁VS轻量级锁VS重量级锁

  • synchronized关键字实现同步的基础是每个对象都是一个锁,它依靠对象头存储锁。
  • 无锁、偏向锁、轻量级锁、重量级锁都是专门针对synchronized关键字设计的、级别从低到高的4种状态。
  • 注意: 锁状态只能升级,不能降级。
  • 对象头中的第一个字宽叫做Mark Word,用于存储对象的hashCode、分代年龄、锁等信息。
  • 其中最后2 bit的标志位,用于标记锁的状态。根据标志位的不同,可以有如下几种状态:

锁状态图

无锁
  • 不对资源进行锁定,所有的线程都可以访问并修改同一资源,但同一时刻只有一个线程能修改成功。
  • 无锁的修改操作依靠循环实现: 如果没有争用,修改成功并退出循环;否则,循环尝试修改操作,直到成功。
  • 无锁无法全面代替有锁,但在某些场景下具有非常高的性能。
  • 无锁的经典实现: CAS操作。
偏向锁
  • 出现的原因:
  1. 在无竞争的情况下,同一线程可能多次进入同一个同步块,即多次获取同一个锁。
  2. 如果进入和退出同步块都使用CAS操作来加锁和解锁,则会消耗一定的资源。
  3. 于是通过CAS操作将线程ID存储到Mark Word中,线程再次进入或退出同步块时,直接检查Mark Word中是否存储指向当前线程的偏向锁。 如果存储了,则直接进入或退出同步块。
  • 偏向锁可以在无竞争的情况下,尽量减少不必要的轻量级锁执行路径。轻量级锁的加锁和解锁都需要CAS操作,而偏向锁只有将线程ID存储到Mark Word中时才执行一次CAS操作。

  • 偏向锁的释放:

  1. 当有其他线程竞争偏向锁时,持有偏向锁的线程会释放锁偏向锁。
  2. 释放时,会根据锁对象是否处于锁定状态而恢复到不同的状态。
  3. 如果锁对象处于未锁定状态,撤销偏向后恢复到无锁的状态(0 + 01 );如果锁对象处于锁定状态,撤销偏向后恢复到轻量级锁的状态(00)。
  • 偏向锁在JDK1.6及以后,默认是启用的,即-XX:+UseBiasedLocking。可以通过-XX:-UseBiasedLocking关闭偏向锁。

​偏向锁使用了一种等待竞争出现才会释放锁的机制。所以当其他线程尝试获取偏向锁时,持有偏向锁的线程才会释放锁。但是偏向锁的撤销需要等到全局安全点(就是当前线程没有正在执行的字节码)。它会首先暂停拥有偏向锁的线程,让你后检查持有偏向锁的线程是否活着。如果线程不处于活动状态,直接将对象头设置为无锁状态。如果线程活着,JVM会遍历栈帧中的锁记录,栈帧中的锁记录和对象头要么偏向于其他线程,要么恢复到无锁状态或者标记对象不适合作为偏向锁。
20210428215249

轻量级锁
  • 多个线程竞争同步资源时,没有获取到资源的线程自旋等待锁的释放

  • 加锁过程:

  1. 线程进入同步块时,如果同步对象处于无锁状态(0 + 01),JVM 首先在当前线程的栈帧中开辟一块叫做锁记录(Lock Record)的空间,用于存储同步对象的Mark Word的拷贝。这个拷贝加了一个前缀,叫Displaced Mark Word。
  2. 然后通过CAS操作将同步对象的Mark Word更新为指向Lock Record的指针,并将Lock Record里的owner指针指向同步对象的Mark Word。
  3. 如果这个更新动作成功,则当前线程拥有了该对象的锁,Mark Word中的标志位更新为00,表示对象处于轻量级锁定状态。
  4. 如果更新动作失败,JVM首先会检查同步对象的Mark Word是否指向当前线程的栈帧。如果是,说明当前线程已经持有了该对象的锁,可以直接进入同步块继续执行;否则,说明存在多线程竞争锁。
轻量级锁升级为重量级锁
  1. 若当前只有一个线程在等待,则通过自旋进行等待。自旋超过一定的次数,轻量级锁升级为重量级锁。
  2. 若一个线程持有锁,一个线程自旋等待锁,又有第三个线程想要获取锁,轻量级锁升级为重量级锁
  • 锁的释放:
  1. 通过CAS操作,将Lock Record中的Displaced Mark Word与对象中的Mark Word进行替换。
  2. 替换成功,同步状态完成;替换失败,说明有其他线程尝试获取过该锁,释放锁的同时需要唤醒被挂起的线程
重量级锁
  • 多线程竞争同步资源时,没有获取到资源的线程阻塞等待锁的释放。
  1. 轻量级锁升级为重量级锁,锁的标志位变成10,Mark Word中存储的是指向重量级锁的指针。
  2. 所有等待锁的线程都会进入阻塞状态。
锁升级过程

锁升级的顺序为:

无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁,且锁升级的顺序是不可逆的。

线程第一次获取锁获时锁的状态为偏向锁,如果下次还是这个线程获取锁,则锁的状态不变,否则会升级为CAS轻量级锁;如果还有线程竞争获取锁,如果线程获取到了轻量级锁没啥事了,如果没获取到会自旋,自旋期间获取到了锁没啥事,超过了10次还没获取到锁,锁就升级为重量级的锁,此时如果其他线程没获取到重量级锁,就会被阻塞等待唤起,此时效率就低了。

20210429133357

升级图解

20210426225812

什么是锁降级?

一个线程执行写操作,先获取写锁,再获取读锁,完成写操作后先释放写锁,接下来的程序里可能要依赖写操作后的变量值,待程序全部执行完后再释放读锁。先释放了写锁,只剩下了读锁,称之为“锁降级”。

为什么需要锁降级?

一句话:为了保证数据可见性。假设线程A修改了数据,释放了写锁,这个时候线程T获得了写锁,修改了数据,然后也释放了写锁,线程A读取数据的时候,读到的是线程T修改的,并不是线程A自己修改的,那么在使用修改后的数据时,就会忽略线程A之前的修改结果。因此通过锁降级来保证数据每次修改后的可见性。

6. 自旋锁与自适应自旋锁

  • 自旋锁:
  1. 阻塞或唤醒一个线程都需要从用户态切换到内核态去完成,会对性能造成很大影响。
  2. 有时一个线程持有锁的时间很短,如果在很短的时间内让后续获取锁的线程都进入阻塞态,这是很不值得。
  3. 可以让后续线程持有CPU时间等待一会,这个等待需要执行忙循环(自旋) 来实现。
  4. 自旋等待的时间由自旋次数来衡量,默认为10,可以使用-XX:PreBlockSpin来进行设置。
  5. 如果在自旋等待中,持有锁的线程释放该锁,当前线程可以不必阻塞直接获取同步资源。
  6. 如果超过自旋次数仍未获取成功,则使用传统的方法将其阻塞。
  • 自旋锁的实现原理: 循环的CAS操作
  • 自旋锁的缺点:
  1. 自旋锁虽然避免了线程的切换开销,但是会占用CPU时间。
  2. 如果每个等待获取锁的线程总是自旋规定的次数,却又没有等到锁的释放,这样就白白浪费了CPU时间。
  • 自旋锁在JDK1.4.2中引入,默认是关闭的;在JDK1.6中变成默认开启,并为了解决自旋锁中浪费CPU资源的问题,而引入了自适应自旋锁。
  • 自适应自旋锁:
  1. 自适应意味着自旋的次数不再固定,而是根据上一次在同一个锁自旋的次数锁的拥有者的状态来决定
  2. 如果在同一个锁对象上自旋刚刚成功获取过锁,并持有锁的线程处于运行状态,则可以认为这一次自旋也很可能成功,允许它自旋更长的时间。
  3. 如果在一个锁上,自旋很少成功,则下一次可以省略自旋过程,直接阻塞线程,避免浪费处理器资源。

一些问题

轻量级锁一定比重量级锁快吗?

在回答这个问题之前,我们先来了解一下:什么是轻量级锁?什么是重量级锁?

锁概念

轻量级锁是 JDK 1.6 新增的概念,是相对于传统的重量级锁而已的一种状态,在 JDK 1.5 时,synchronized 是需要通过操作系统自身的互斥量(mutex lock)来实现,然而这种实现方式需要通过用户态与和核心态的切换来实现,但这个切换的过程会带来很大的性能开销,所以在 JDK 1.6 就引入了轻量级锁来避免此问题的发生。

轻量级锁执行过程

再讲轻量级锁执行过程之前,要先从虚拟机的对象头开始说起,HotSpot 的对象头(Object Header)分为两部分:

  1. Mark Word 区域,用于存储对象自身的运行时数据,如哈希码(HashCode)、GC 分带年龄等;

  2. 用于存储指向方法区对象类型数据的指针(如果是数组对象的话,还有一个存储数组长度的额外信息)。

Mark Word 在 32 位系统中,有 32bit 空间,其中:

  • 25bit 用来存储 HashCode;

  • 4bit 用来存储对象的分带年龄;

  • 2bit 用来存储锁标志位,01=可偏向锁、00=轻量级锁、10=重量级锁;

  • 1bit 固定为 0。

再说会轻量级锁的执行过程,在代码进入同步块的时候,如果此对象没有被线程所占用,虚拟机会先将此线程的栈帧拷贝一份存储在当前对象的 Lock Record (锁记录) 区域中。

然后虚拟机再使用 CAS (Compare and Swap, 比较并交换) 将本线程的 Mark Word 更新为指向对象 Lock Record 区域的指针,如果更新成功,则表示这个线程拥有了该对象,轻量级锁添加成功,如果更新失败,虚拟机会先检查对象 Mark Word 是否指向了当前线程的线帧,如果是则表明此线程已经拥有了此锁,如果不是,则表明该锁已经被其他线程占用了。如果有两条以上的线程在争抢死锁,那么锁就会膨胀为重量锁,Mark Word 中存储的就是指向重量级锁的互斥量指针,后面等待锁的线程也会进入阻塞状态。

从以上的过程,我们可以看出轻量级锁可以理解为是通过 CAS 实现的,理想的情况下是整个同步周期内不存在锁竞争,那么轻量锁可以有效的提高程序的同步性能,然而,如果情况相反,轻量级锁不但要承担 CAS 的开销还要承担互斥量的开销,这种情况下轻量级锁就会比重量级锁更慢,这就是我们本文的答案。

总结

轻量级锁不是在任何情况下都比重量级锁快的,要看同步块执行期间有没有多个线程抢占资源的情况,如果有,那么轻量级线程要承担 CAS + 互斥量锁的性能消耗,就会比重量锁执行的更慢。

打开偏向锁是否效率一定会提升?为什么?

偏向锁(不太需要竞争的,一般一个线程)
未必会提高,尤其是当你知道一定会有大量线程去竞争的时候。
打开偏向锁偏向锁还有一个锁撤销的过程(把ID撕下来)。

为什么要延迟4s?

偏向锁默认是在JVM启动4s后再初始化偏向锁,可用如下参数修改启动时间,设为0则表示立即启用。之所以这么设计是因为JVM启动的时候,如果立即启动偏向,有可能会因为线程竞争太激烈导致产生太多安全点挂起。
-XX:BiasedLockingStartupDelay=0

参考

Java高并发之锁总结、常见的面试问题
Java并发编程五 同步之ReentrantLock与Condition

评论