后端面试每日一题 聊聊synchronized

protected final boolean tryAcquire(int acquires) {

final Thread current = Thread.currentThread();

int c = getState();

if (c == 0) {

// 公平锁比非公平锁多了一行代码 !hasQueuedPredecessors()

if (!hasQueuedPredecessors() &&

compareAndSetState(0, acquires)) { //尝试获取锁

setExclusiveOwnerThread(current); // 获取成功,标记被抢占

return true;

}

}

else if (current == getExclusiveOwnerThread()) {

int nextc = c + acquires;

if (nextc < 0)

throw new Error(“Maximum lock count exceeded”);

setState(nextc); // set state=state+1

return true;

}

return false;

}

对于此方法来说,公平锁比非公平锁只多一行代码 !hasQueuedPredecessors(),它用来查看队列中是否有比它等待时间更久的线程,如果没有,就尝试一下是否能获取到锁,如果获取成功,则标记为已经被占用。

如果获取锁失败,则调用 addWaiter 方法把线程包装成 Node 对象,同时放入到队列中,但 addWaiter 方法并不会尝试获取锁,acquireQueued 方法才会尝试获取锁,如果获取失败,则此节点会被挂起,源码如下:

/**

  • 队列中的线程尝试获取锁,失败则会被挂起

*/

final boolean acquireQueued(final Node node, int arg) {

boolean failed = true; // 获取锁是否成功的状态标识

try {

boolean interrupted = false; // 线程是否被中断

for (;😉 {

// 获取前一个节点(前驱节点)

final Node p = node.predecessor();

// 当前节点为头节点的下一个节点时,有权尝试获取锁

if (p == head && tryAcquire(arg)) {

setHead(node); // 获取成功,将当前节点设置为 head 节点

p.next = null; // 原 head 节点出队,等待被 GC

failed = false; // 获取成功

return interrupted;

}

// 判断获取锁失败后是否可以挂起

if (shouldParkAfterFailedAcquire(p, node) &&

parkAndCheckInterrupt())

// 线程若被中断,返回 true

interrupted = true;

}

} finally {

if (failed)

cancelAcquire(node);

}

}

该方法会使用 for(;😉 无限循环的方式来尝试获取锁,若获取失败,则调用 shouldParkAfterFailedAcquire 方法,尝试挂起当前线程,源码如下:

/**

  • 判断线程是否可以被挂起

*/

private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {

// 获得前驱节点的状态

int ws = pred.waitStatus;

// 前驱节点的状态为 SIGNAL,当前线程可以被挂起(阻塞)

if (ws == Node.SIGNAL)

return true;

if (ws > 0) {

do {

// 若前驱节点状态为 CANCELLED,那就一直往前找,直到找到一个正常等待的状态为止

node.prev = pred = pred.prev;

} while (pred.waitStatus > 0);

// 并将当前节点排在它后边

pred.next = node;

} else {

// 把前驱节点的状态修改为 SIGNAL

compareAndSetWaitStatus(pred, ws, Node.SIGNAL);

}

return false;

}

线程入列被挂起的前提条件是,前驱节点的状态为 SIGNAL,SIGNAL 状态的含义是后继节点处于等待状态,当前节点释放锁后将会唤醒后继节点。所以在上面这段代码中,会先判断前驱节点的状态,如果为 SIGNAL,则当前线程可以被挂起并返回 true;如果前驱节点的状态 >0,则表示前驱节点取消了,这时候需要一直往前找,直到找到最近一个正常等待的前驱节点,然后把它作为自己的前驱节点;如果前驱节点正常(未取消),则修改前驱节点状态为 SIGNAL。

到这里整个加锁的流程就已经走完了,最后的情况是,没有拿到锁的线程会在队列中被挂起,直到拥有锁的线程释放锁之后,才会去唤醒其他的线程去获取锁资源,整个运行流程如下图所示:

img

unlock 相比于 lock 来说就简单很多了,源码如下:

public void unlock() {

sync.release(1);

}

public final boolean release(int arg) {

// 尝试释放锁

if (tryRelease(arg)) {

// 释放成功

Node h = head;

if (h != null && h.waitStatus != 0)

unparkSuccessor(h);

return true;

}

return false;

}

锁的释放流程为,先调用 tryRelease 方法尝试释放锁,如果释放成功,则查看头结点的状态是否为 SIGNAL,如果是,则唤醒头结点的下个节点关联的线程;如果释放锁失败,则返回 false。

tryRelease 源码如下:

/**

  • 尝试释放当前线程占有的锁

*/

protected final boolean tryRelease(int releases) {

int c = getState() - releases; // 释放锁后的状态,0 表示释放锁成功

// 如果拥有锁的线程不是当前线程的话抛出异常

if (Thread.currentThread() != getExclusiveOwnerThread())

throw new IllegalMonitorStateException();

boolean free = false;

if (c == 0) { // 锁被成功释放

free = true;

setExclusiveOwnerThread(null); // 清空独占线程

}

setState©; // 更新 state 值,0 表示为释放锁成功

return free;

}

在 tryRelease 方法中,会先判断当前的线程是不是占用锁的线程,如果不是的话,则会抛出异常;如果是的话,则先计算锁的状态值 getState() - releases 是否为 0,如果为 0,则表示可以正常的释放锁,然后清空独占的线程,最后会更新锁的状态并返回执行结果。

JDK 1.6 锁优化

  • 自适应自旋锁

JDK 1.5 在升级为 JDK 1.6 时,HotSpot 虚拟机团队在锁的优化上下了很大功夫,比如实现了自适应式自旋锁、锁升级等。

JDK 1.6 引入了自适应式自旋锁意味着自旋的时间不再是固定的时间了,比如在同一个锁对象上,如果通过自旋等待成功获取了锁,那么虚拟机就会认为,它下一次很有可能也会成功 (通过自旋获取到锁),因此允许自旋等待的时间会相对的比较长,而当某个锁通过自旋很少成功获得过锁,那么以后在获取该锁时,可能会直接忽略掉自旋的过程,以避免浪费 CPU 的资源,这就是自适应自旋锁的功能。

  • 锁升级

锁升级其实就是从偏向锁到轻量级锁再到重量级锁升级的过程,这是 JDK 1.6 提供的优化功能,也称之为锁膨胀。

偏向锁是指在无竞争的情况下设置的一种锁状态。偏向锁的意思是它会偏向于第一个获取它的线程,当锁对象第一次被获取到之后,会在此对象头中设置标示为“01”,表示偏向锁的模式,并且在对象头中记录此线程的 ID,这种情况下,如果是持有偏向锁的线程每次在进入的话,不再进行任何同步操作,如 Locking、Unlocking 等,直到另一个线程尝试获取此锁的时候,偏向锁模式才会结束,偏向锁可以提高带有同步但无竞争的程序性能。但如果在多数锁总会被不同的线程访问时,偏向锁模式就比较多余了,此时可以通过 -XX:-UseBiasedLocking 来禁用偏向锁以提高性能。

轻量锁是相对于重量锁而言的,在 JDK 1.6 之前,synchronized 是通过操作系统的互斥量(mutex lock)来实现的,这种实现方式需要在用户态和核心态之间做转换,有很大的性能消耗,这种传统实现锁的方式被称之为重量锁。

而轻量锁是通过比较并交换(CAS,Compare and Swap)来实现的,它对比的是线程和对象的 Mark Word(对象头中的一个区域),如果更新成功则表示当前线程成功拥有此锁;如果失败,虚拟机会先检查对象的 Mark Word 是否指向当前线程的栈帧,如果是,则说明当前线程已经拥有此锁,否则,则说明此锁已经被其他线程占用了。当两个以上的线程争抢此锁时,轻量级锁就膨胀为重量级锁,这就是锁升级的过程,也是 JDK 1.6 锁优化的内容。

悲观锁和乐观锁

悲观锁指的是数据对外界的修改采取保守策略,它认为线程很容易会把数据修改掉,因此在整个数据被修改的过程中都会采取锁定状态,直到一个线程使用完,其他线程才可以继续使用。

我们来看一下悲观锁的实现流程,以 synchronized 为例,代码如下:

public class LockExample {

public static void main(String[] args) {

synchronized (LockExample.class) {

System.out.println(“lock”);

}

}

}

我们使用反编译工具查到的结果如下:

Compiled from “LockExample.java”

public class com.lagou.interview.ext.LockExample {

public com.lagou.interview.ext.LockExample();

Code:

0: aload_0

1: invokespecial #1 // Method java/lang/Object.“”😦)V

4: return

public static void main(java.lang.String[]);

Code:

0: ldc #2 // class com/lagou/interview/ext/LockExample

2: dup

3: astore_1

4: monitorenter // 加锁

5: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;

8: ldc #4 // String lock

10: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V

13: aload_1

14: monitorexit // 释放锁

15: goto 23

18: astore_2

19: aload_1

20: monitorexit

21: aload_2

22: athrow

23: return

Exception table:

from to target type

5 15 18 any

18 21 18 any

}

可以看出被 synchronized 修饰的代码块,在执行之前先使用 monitorenter 指令加锁,然后在执行结束之后再使用 monitorexit 指令释放锁资源,在整个执行期间此代码都是锁定的状态,这就是典型悲观锁的实现流程。

乐观锁和悲观锁的概念恰好相反,乐观锁认为一般情况下数据在修改时不会出现冲突,所以在数据访问之前不会加锁,只是在数据提交更改时,才会对数据进行检测。

Java 中的乐观锁大部分都是通过 CAS(Compare And Swap,比较并交换)操作实现的,CAS 是一个多线程同步的原子指令,CAS 操作包含三个重要的信息,即内存位置、预期原值和新值。如果内存位置的值和预期的原值相等的话,那么就可以把该位置的值更新为新值,否则不做任何修改。

CAS 可能会造成 ABA 的问题,ABA 问题指的是,线程拿到了最初的预期原值 A,然而在将要进行 CAS 的时候,被其他线程抢占了执行权,把此值从 A 变成了 B,然后其他线程又把此值从 B 变成了 A,然而此时的 A 值已经并非原来的 A 值了,但最初的线程并不知道这个情况,在它进行 CAS 的时候,只对比了预期原值为 A 就进行了修改,这就造成了 ABA 的问题。

以警匪剧为例,假如某人把装了 100W 现金的箱子放在了家里,几分钟之后要拿它去赎人,然而在趁他不注意的时候,进来了一个小偷,用空箱子换走了装满钱的箱子,当某人进来之后看到箱子还是一模一样的,他会以为这就是原来的箱子,就拿着它去赎人了,这种情况肯定有问题,因为箱子已经是空的了,这就是 ABA 的问题。

ABA 的常见处理方式是添加版本号,每次修改之后更新版本号,拿上面的例子来说,假如每次移动箱子之后,箱子的位置就会发生变化,而这个变化的位置就相当于“版本号”,当某人进来之后发现箱子的位置发生了变化就知道有人动了手脚,就会放弃原有的计划,这样就解决了 ABA 的问题。

JDK 在 1.5 时提供了 AtomicStampedReference 类也可以解决 ABA 的问题,此类维护了一个“版本号” Stamp,每次在比较时不止比较当前值还比较版本号,这样就解决了 ABA 的问题。

相关源码如下:

public class AtomicStampedReference {

private static class Pair {

final T reference;

final int stamp; // “版本号”

private Pair(T reference, int stamp) {

this.reference = reference;

this.stamp = stamp;

}

static Pair of(T reference, int stamp) {

return new Pair(reference, stamp);

}

}

// 比较并设置

public boolean compareAndSet(V expectedReference,

V newReference,

int expectedStamp, // 原版本号

int newStamp) { // 新版本号

Pair current = pair;

return

expectedReference == current.reference &&

expectedStamp == current.stamp &&

((newReference == current.reference &&

newStamp == current.stamp) ||

casPair(current, Pair.of(newReference, newStamp)));

}

//…省略其他源码

}

可以看出它在修改时会进行原值比较和版本号比较,当比较成功之后会修改值并修改版本号。

小贴士:乐观锁有一个优点,它在提交的时候才进行锁定的,因此不会造成死锁。

可重入锁

可重入锁也叫递归锁,指的是同一个线程,如果外面的函数拥有此锁之后,内层的函数也可以继续获取该锁。在 Java 语言中 ReentrantLock 和 synchronized 都是可重入锁。

下面我们用 synchronized 来演示一下什么是可重入锁,代码如下:

public class LockExample {

public static void main(String[] args) {

reentrantA(); // 可重入锁

自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。

深知大多数Java工程师,想要提升技能,往往是自己摸索成长或者是报班学习,但对于培训机构动则几千的学费,着实压力不小。自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!

因此收集整理了一份《2024年Java开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。img

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上Java开发知识点,真正体系化!

由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!

如果你觉得这些内容对你有帮助,可以扫码获取!!(备注Java获取)

img

最后

2020年在匆匆忙忙慌慌乱乱中就这么度过了,我们迎来了新一年,互联网的发展如此之快,技术日新月异,更新迭代成为了这个时代的代名词,坚持下来的技术体系会越来越健壮,JVM作为如今是跳槽大厂必备的技能,如果你还没掌握,更别提之后更新的新技术了。

更多JVM面试整理:

《互联网大厂面试真题解析、进阶开发核心学习笔记、全套讲解视频、实战项目源码讲义》点击传送门即可获取!
,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!**

如果你觉得这些内容对你有帮助,可以扫码获取!!(备注Java获取)

img

最后

2020年在匆匆忙忙慌慌乱乱中就这么度过了,我们迎来了新一年,互联网的发展如此之快,技术日新月异,更新迭代成为了这个时代的代名词,坚持下来的技术体系会越来越健壮,JVM作为如今是跳槽大厂必备的技能,如果你还没掌握,更别提之后更新的新技术了。

[外链图片转存中…(img-PeZCmB7O-1713584612879)]

更多JVM面试整理:

[外链图片转存中…(img-g5bCkB8c-1713584612879)]

《互联网大厂面试真题解析、进阶开发核心学习笔记、全套讲解视频、实战项目源码讲义》点击传送门即可获取!

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值