一道有深度的面试题:本地悲观锁实现计数器需要加 volatile 吗?

故事背景

团队内部前几天讨论了一个面试题,在本地用乐观锁和悲观锁实现计数器需要volatile关键字吗?毫无疑问,使用乐观锁一定是需要的。但使用悲观锁需要呢?

张三:不需要吧,每次不都是一个线程访问变量吗?

李四:还是需要的,加锁只是保证了该变量被一个线程独占,但是不能保证拿到变量最新的值,因为可能上个线程操作后数据还在线程本地内存里,导致本线程读取的数据是脏数据!

张三:嘶,好像有点道理,不过如果加锁的话,这个本地内存的数据什么时候刷到主内存呢?会不会加锁后就直接读取到最新数据了?

李四:诶?问得好,这个得研究研究。

预备知识

Hppens-Before 规则

Java Memory Model(JMM) 里定义了一些跨线程操作的 Happens-Before 关系,并据此来决定线程间一些操作的相对顺序。如果说操作 A “Happens-Before” B,则有两个含义:

  1. 可见性:A 的操作对 B 可见
  2. 顺序性:A 要在 B 之前执行

Happens-before 规则有多条,本文只借助几条来进行解释

  • 程序顺序规则:如果程序中操作 A 在操作 B 之前,那么在线程中操作 A Happens-Before 操作 B
  • 监视器锁规则:监视器上的 unlock 操作 Happens-Before 同一个监视器的 lock 操作
  • volatile 变量规则:写入 volatile 变量 Happens-Before 读取该变量
  • 传递性:如果 hb(A, B)hb(B, C),则 hb(A, C)

volatile 的内存语义

从内存语义的角度来说,volatile的写-读与锁的释放-获取有相同的内存效果。

volatile 写的内存语义:当写一个 volatile 变量时,JMM 会把该线程对应的本地内存中写 volatile 前所有的共享变量刷新到主内存中,并让其他 core 的缓存失效,不管这些变量是否volatile,不仅仅只是 volatile 变量本身。

volatile 读的内存语义:当读一个 volatile 变量时,JMM 会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。

关于 volatile 写的说明:https://jenkov.com/tutorials/java-concurrency/volatile.html

synchronized 实现

synchronized 可见性原理

Synchronized 的 Happens-Before 规则,即监视器锁规则:对同一个监视器的解锁,Happens-Before 于对该监视器的加锁。

在这里插入图片描述

图中每一个箭头连接的两个节点就代表之间的 Happens-Before 关系,红色的为监视器锁规则推导而出:线程A释放锁 Happens-Before 线程B加锁;蓝色的则是通过程序顺序规则和监视器锁规则推测出来 Happens-Before 关系,通过传递性规则进一步推导的 Happens-Before 关系。

根据 Happens-Before 规则的程序顺序规则:如果 A Happens-Before B,则 A 的执行结果对 B 可见,并且 A 的执行顺序先于 B。因此,如果线程 A 修改了计数器的值,对线程 B 是可见的。

synchronized 验证代码

public class TestSynchronizedCounter {
    private static int count = 0;

    public static void main(String[] args) {
        new Thread(() -> {
            for (int i = 0; i < 100000; i++) {
                synchronized (TestSynchronizedCounter.class) {
                    count++;
                }
            }
            System.out.println("thread1 finish, sum = " + count);
        }).start();
        new Thread(() -> {
            for (int i = 0; i < 100000; i++) {
                synchronized (TestSynchronizedCounter.class) {
                    count++;
                }
            }
            System.out.println("thread2 finish, sum = " + count);
        }).start();
    }
}

Reentrantlock 实现

Reentrantlock 可见性原理

ReentrantLock 也可以起到和 Synchronized 关键字同样的效果,在 Lock 接口的注释中有如下描述。这段描述的意思是说所有的 Lock 接口实现必须在内存可见性上具有和内置监视器锁(Synchronized)相同的语义。

Memory Synchronization
All Lock implementations must enforce the same memory synchronization semantics as provided by the built-in monitor lock, as described in The Java Language Specification (17.4 Memory Model) :
A successful lock operation has the same memory synchronization effects as a successful Lock action.
A successful unlock operation has the same memory synchronization effects as a successful Unlock action.

ReentrantLock 通过内部的 Sync 类来完成锁的功能,Sync 类扩展了 AQS,重用 AQS 的各项同步功能。众所周知:Reentrantlock 的 lock 和 unlock 都需要读取并用 CAS 方式修改被 volatile 修饰的变量 state。

需要注意的是,volatile 写操作会把之前的共享变量更新一并发布出去,而不只是 volatile 变量本身。

在这里插入图片描述

假设线程a通过调用lock方法获取到锁,此时线程b也调用了lock方法,因为a尚未释放锁,b只能等待。a在获取锁的过程中会先读state,再写state。当a释放掉锁并唤醒b,b会尝试获取锁,也会先读state,再写state。

根据 Hppens-Before 规则的 volatile 变量规则:写入 volatile 变量 Happens-Before 读取该变量以及 volatile 写的内存语义。可以推测出,线程a在写入state变量之前的任何操作结果对线程b都是可见的。

再次说明:volatile 写操作会把之前的共享变量更新一并发布出去,而不只是 volatile 变量本身。

以公平锁为例,我们看看 ReentrantLock 获取锁 & 释放锁的关键代码:

private volatile int state; // 关键 volatile 变量
protected final int getState() {
    return state;
}

// 获取锁
protected final boolean tryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    int c = getState(); // 重要!!!读 volatile 变量
    ... // 竞争获取锁逻辑,省略   
}

// 释放锁
protected final boolean tryRelease(int releases) {
    boolean free = false;
    ... // 根据状态判断是否成功释放,省略
    setState(c); // 重要!!!写 volatile 变量
    return free;
}

简单来说就是对于每一个进入到锁的临界区域的线程,都会做三件事情:

  • 获取锁,读取 volatile 变量;
  • 执行临界区代码,针对本文是对 count 做自增;
  • 写 volatile 变量 (即发布所有写操作),释放锁。

Reentrantlock 验证代码

public class TestLockCounter {
    private final static ReentrantLock LOCK = new ReentrantLock();
    private static int count = 0;

    public static void main(String[] args) {
        new Thread(() -> {
            for (int i = 0; i < 1000000; i++) {
                try {
                    LOCK.lock();
                    count++;
                } finally {
                    LOCK.unlock();
                }
            }
            System.out.println("thread1 finish, sum = " + count);
        }).start();
        new Thread(() -> {
            for (int i = 0; i < 1000000; i++) {
                try {
                    LOCK.lock();
                    count++;
                } finally {
                    LOCK.unlock();
                }
            }
            System.out.println("thread2 finish, sum = " + count);
        }).start();
    }
}

相关原理

内存屏障

内存屏障,又称内存栅栏,是一个CPU指令,它的作用有两个:

  • 保证特定操作的执行顺序
  • 二是保证某些变量的内存可见性(利用该特性实现volatile的内存可见性)。

Intel硬件提供了一系列的内存屏障,Java内存模型屏蔽了底层硬件平台的差异,由 JVM来为不同的平台生成相应的机器码。 JVM中提供了四类内存屏障指令:

屏障类型指令示例说明
LoadLoadLoad1; LoadLoad; Load2在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕
StoreStoreStore1; StoreStore; Store2在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见
LoadStoreLoad1; LoadStore; Store2在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕
StoreLoadStore1; StoreLoad; Load2在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见

Happens-Before 底层实现原理

Happens-Before 是对 Java 内存模型(JMM)中所规定的可见性的更高级的语言层面的描述,程序员可以用这个原则解决并发环境下两个操作之间的可见性问题,而不需要陷入 Java 内存模型苦涩难懂的定义中。

一个 Happens-Before 规则对应于一个或多个编译器和处理器重排序规则,Happens-Before 与JMM的关系如下图所示:

在这里插入图片描述

volatile 底层实现原理

JVM的实现会在 volatile 读写前后均加上内存屏障,实现了可见性和有序性。如下所示:

LoadLoadBarrier
volatile 读操作
LoadStoreBarrier

StoreStoreBarrier
volatile 写操作
StoreLoadBarrier

总结

本地用悲观锁实现计数器不需要加 volatile ,synchronized 关键字和 Lock 接口都具有 Happens-Before 规则:

  • synchronized 关键字遵循监视器锁规则,从而实现了代码临界区内变量的可见性。
  • ReentrantLock 及其它 Lock 接口实现类借助了 volatile 关键字间接地实现了可见性。

参考资料

  • Java 并发知识:https://lotabout.me/books/Java-Concurrency/Happens-Before/index.html
  • Happens-Before 原则深入解读:https://xie.infoq.cn/article/d0f4d9e812ee03b6a32265686
  • Java Volatile Keyword:https://jenkov.com/tutorials/java-concurrency/volatile.html
  • 关键字: synchronized 详解:https://pdai.tech/md/java/thread/java-thread-x-key-synchronized.html
  • 深度好文 | Java 可重入锁内存可见性分析:https://cloud.tencent.com/developer/article/1142546
  • ReentrantLock 是如何保证内存的可见性的:https://zhuanlan.zhihu.com/p/80929454
  • 35
    点赞
  • 22
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

笼中小夜莺

嘿嘿嘿,请用金钱尽情地蹂躏我吧

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值