专业的JAVA编程教程与资源

网站首页 > java教程 正文

一文了解 Java并发编程:从 volatile 关键字到 Java 中的锁

temp10 2025-05-15 20:57:18 java教程 1 ℃ 0 评论

#java多线程编程难点在哪里# 线程安全问题是一大难点,多个线程同时访问共享资源时容易出错。今天我们来聊聊并发编程。

多线程开发是个大难题,不管有多少年的经验,都依旧会各种踩坑。这里我想总结下自己对Java多线程开发中的锁机制的理解和运用,希望帮助大家在平时工作中都能轻松应付各种并发编程

一文了解 Java并发编程:从 volatile 关键字到 Java 中的锁

本文会聊到 volatile、synchronized、ReentrantLock、StampedLock,代码也是想到哪里就手搓一下试试,保证新鲜可运行,最后也总结了一下各种锁的使用场景。欢迎留言一起探讨……


如果大家刷面试八股文的时候,肯定会看到这么一个问题,如何保证多线程情况下的变量可见性、内存的一致性?

答案一般都是用 volatile,然后会解释一下 volatile关键字的核心作用就是保证变量的可见性,当一个线程修改volatile修饰的变量,其他的线程就能够立即看到。

大家都知道线程会由自己的本地缓存,常规的变量都是存放在线程缓存里面的,而加上了volatile修饰的变量,它的读写操作就变成了直接从内存中读取,这就保证了在多线程环境中,变量的最新值,对所有线程可见。

当然更进一步的一些回答会提到 volatile 还可以防止指令重排。

那么指令重排是什么呢?

Java的编译器和处理器会为了代码运行的性能,进而自己重新排列指令的执行顺序。但是在多线程的环境下,指令重排可能出现一些意想不到的情况。

指令重排不是必然会发生,特别是在jdk8以后,java对编译器和处理器都进行了优化,也优化了更安全的内存模型,这就让指令重排出现的概率降低了一点,但是并不能完全杜绝。让我们来看个例子:

按照代码的顺序执行的话,x、y是不可能同时为0的,26行的代码是无法进入的。因为无论是线程 one和two按顺序运行,还是它们交叉运行,都会至少有一个为1:

  • 假如1先执行:a=1, x=b(0),然后线程2才执行:b=1, y=a(1), x=0,y=1
  • 就算是线程2先执行完:b=1, y=a(0),再执行线程1:a=1, x=b(1) , x=1,y=0
  • 当然由于线程调度管理机制不同,有可能出现两线程交替执行的情况:a=1, b=1, x=b(1), y=a(1) → x=1,y=1

那么指令重排的话会出现怎么样的情况呢?最有可能的指令重排就是线程里面的代码语句执行顺序改变,比如 one 线程的 a=1 和 x=b的顺序调换了,因为他们之间没有依赖,单线程情况下先执行哪一条都不会影响结果。

但是指令重排后,我们的范例代码(多线程互相影响)中就有可能出现 x=0,y=0。

运行结果如下:

JDK21下运行了三万多次才出现重排的情况,虽然概率低,但是还是会出现的。

那么我们给变量加上 volatile 后,这种指令重排就不会发生了:

其他代码不变,再次运行代码,这次循环就永远不会结束了。

那么为什么volatile 可以确保指令顺序不改变了呢?这就涉及到一个内存屏障的概念。

内存屏障是一种同步机制,用于保证操作的顺序。比如某个时间点发生的内存操作,一定是发生在它之前的所有同类型操作都完成之后才会执行,或者是它之后的会影响到它的操作不会跑到前面执行。

内存屏障由Java编译器和处理器自己进行判断,自行插入。比如是用synchronize、volatile或者Lock接口,都会被隐式的插入内存屏障。

在Java中,内存屏障有四种:

  • LoadLoad屏障:确保读操作的顺序
  • StoreStore屏障:确保写操作的顺序
  • LoadStore屏障:读写操作的顺序,这个比较拗口,大概的意思是读写的操作即使指令不相关,但是也会确保按照代码编写的顺序执行指令,不会进行重排。
  • StoreLoad屏障:确保写读的顺序,和上面差不多。

有点拗口是吧,还是用我们之前那段代码来解释下:

线程 one 里面的 a = 1 是个写操作,x = b是个复合操作,其中有读 b 的操作。线程 two 同理。

  • 假如线程 one 先执行,一定可以确保线程 two 的 y = a 在线程 one 的 a = 1 之后执行。
  • 反过来如果线程 two 先执行,一定可以确保线程 one 的 x = b 在线程 two 的 b = 1 之后执行。

如果要回答为什么 volatile 可以确保指令不会重排,可以这样回答:

1. 通过内存屏障实现防止指令重排的作用。它会在写操作后插入 StoreStore、SoreLoad 屏障,在读操作之前插入 LoadLoad 、LoadStore屏障。

2. 通过Happens-Before 规则确保 volatile 变量的全局读写顺序不会被重排。

volatile的局限

volatile 并不是万能,它只是确保了变量的可见性和防止指令重排,但是它不是同步锁,无法确保操作的原子性!

用最简单的自增操作来尝试一下:

这里尝试的次数要大一点,因为多线程中产生资源竞争是有概率的。

出现这种情况,就是老生常谈的:自增操作不是原子操作,虽然加了volatile 修饰变量,当它实际上是分为3步进行,先是从内存读取、然后+1、最后才写回内存。多线程的情况下,这三步可能会交叉进行(顺序不变),最终导致结果出现异常。

当然这种情况最好解决了,我们可以用原子类 AtomicInteger、AtomicLong 等等。

或者我们可以给数值操作抽取一个方法,然后给这个方法用synchronized加锁

synchronized 可以进行代码范围锁定,在 synchrnized 范围内的指令是绝对不会重排的,并且它引入了锁竞争,同时锁定了读和写,可以确保操作的原子性。而它的用法很简洁,一个关键字就可以锁变量、锁方法、锁代码块。

但是说到synchronized,像我们这些老古董的Java程序员,第一反应是用 synchronized 会导致程序性能下降,因为这个锁很重巴拉巴拉的。

那么事实是这样的吗?

synchronized 会隐式插入内存屏障,禁止指令重排,它也会将代码块变成串行执行,确实会对性能有所影响,特别在在多核 CPU 的运行环境。

但是我们不能一刀切的否定掉 synchronized,特别是如果有人用 synchronized 是重量级的锁来否定的时候,我们要小心回答

因为JDK1.6 之后,synchronized有了很大的改变,它的锁已经变成可以升级的锁, JVM会根据使用的情况,自主决定用什么量级的锁。

  • 在大部分时间只有单线程访问的时候,会使用偏向锁。偏向锁和线程ID关联,在无资源竞争和低资源竞争的时候性能接近无锁状态。
  • 如果是超过一个线程同时竞争资源的时候,就会进行锁升级,升级为轻量锁。轻量锁的状态下,每次线程去获取锁的时候,如果失败,会继续自旋尝试获取锁,而不是进入阻塞状态(自旋其实就类似一个while循环),这样的好处就是可减少线程上下文切换的时间消耗。
  • 如果线程竞争激烈,自旋失败次数过多的时候,JVM会将 synchronized 的锁升级为重量级锁!重量锁的状态下,未抢到锁的线程会直接进入阻塞状态。

什么时候可以用 synchronized?一般是在低竞争的时候,还有就是不会长时间持有锁的场景。这个两个场景应该覆盖了大部分的工作任务了……

在高并发、高竞争的场景下,我们就要考虑使用 ReentrantLock 了

ReentrantLock 是更完善的锁,要求显示获取锁和释放锁,让锁的控制更灵活,还支持等待队列、中断锁和公平锁

什么是公平锁

默认的锁是非公平的,也就允许插队,只有有操作,就马上去尝试获取锁,这样的操作吞吐量高,但是可能会引发线程饥饿,也就是某些线程一直无法获取到锁,

使用公平锁之后,就一定会按请求顺序分配锁,虽然性能比较低,但是能避免线程被饿死……

什么是中断锁

ReentrantLock在获取锁的时候,可以被其他线程中断,比如下面这种用法。

什么是等待队列

ReentrantLock可以通过Condition创建多个等待队列,可以用来实现更精细化的线程唤醒。在复杂的线程协作中,通过显式地唤醒消费者线程,可以确保线程协作中的交互。

对比一下ReentrantLock 和 synchronized 。

既然说到了锁,就不得不提一下 StampedLock。

StampedLock 是Java8开始提供的一个特殊用途的读写锁,对标的目标是比 ReentrantReadWriteLock更高效。它提供乐观锁,特别适用读多写少的场景。

什么是乐观锁

乐观锁是一种策略,它假设多线程并发读操作的时候不会发生冲突,因此可不用在读操作的时候加锁,只是在写操作的时候才检查数据是否被修改,进而选择不一样的处理方式样的策略可以减少线程切换,提高并发的性能。

对比下 StampedLock 和 ReentrantLock

特性

StampedLock

ReentrantReadWriteLock

乐观读

支持(无锁读取)

不支持


可重入性

不支持(同一个线程重复获取锁需要手动避免死锁)

支持

锁升级


支持(tryConvertToWriteLock)

不支持(会导致死锁)

条件变量(Condition)

不支持

支持

性能

更高(适合读多写少)

较低



最后我们来看下如何选择用哪种锁

这些常见的场景都有一种比价适应的锁机制,但是这里给出的并不是唯一的标准,在使用中还是希望大家谨慎选择!因为并发的环境真的很复杂,每一次使用都会有新的体会,所以没有人敢说自己精通多线程编程……

场景

synchronized

ReentrantLock

StampedLock

低竞争

一般

一般(开销大)

高竞争

一般

一般

读多写少

差(全锁)

一般(读锁竞争)

极优(乐观读)



感谢你这么有耐心读到这里,还请点个赞加关注

Tags:

本文暂时没有评论,来添加一个吧(●'◡'●)

欢迎 发表评论:

最近发表
标签列表