2492 字
12 分钟
Concurrency

并发#

Java 并发机制基础#

提到 Java 的编译过程,我们都会想到 .java 文件到 .class 字节码文件再到汇编指令到 CPU 内执行,而 Java 的并发机制正是依赖了 JVM 的实现和 CPU 的指令,让我们从 volatile 和 synchronized 这两个关键字来配合理解一下并发的原理。

volatile#

都说 volatile 是轻量的 synchronized,是因为它在多线程处理中过程中保证了共享变量的可见性,即一个线程在修改一个共享变量时,另一个线程可以读到这个变量值。所以在 volatile 使用恰当的情况下不会引起线程上下文的切换和调度,相比于 synchronized 成本更低。

Java 语言规范 第三版》对 volatile 的定义:Java 编程语言允许线程访问共享变量,为了确保共享变量能别准确和一致的更新,线程应确保能通过排他锁单独获得这个变量。

让我们聊一下它的底层原理:Java 在对使用了 volatile 的变量进行写操作的时候,JVM 会向处理器发一条 Lock 前缀的指令,将这个变量所存在的缓冲行1的数据写回到系统内存。虽然被写回到内存,但是其他处理器缓存已经读取的值还是旧的,这里就得用得到缓存一致性协议了,每个处理器会嗅探总线上传播的数据检查自己的数据是否时最新的,当发现自己的缓存行地址被修改的时候,就会将当前缓存行状态设置为无效并重新从内存中读取到处理器缓存中来。

synchronized#

synchronized 作为多线程并发中的元老级角色也被称作重量级锁,虽然在日后的优化中可能已经没有那么“重”了。了解锁我们得先知道什么是锁。在 Java 中每个对象都可以是一个锁,具体有以下三种锁:

  • 对于普通同步方法,锁的是当前的实例对象。
  • 对于静态同步方法,锁的是当前类的 Class 对象。
  • 对于同步方法块,锁的是 Synchronized 括号里的配置对象。

当一个线程试图访问同步代码块的时候,他必须先获得锁,退出和异常的时候必须释放锁。而具体的得到和释放锁的过程需要依赖 monitorenter 和 monitorexit 两个指令配合完成,JVM 会保证每个 monitorenter 必须有对应的 monitorexit ,它们在编译时会插入到代码块的开始和异常或者结束位置,任何对象都有一个 monitor 与之对应,并且一个 monitor 在被持有后将处于锁定状态,所以线程对锁的获取和释放就是对 monitor 的所有权的获取和释放。

在我们就近的 JDK 版本里,为了解决对锁的获得和释放待来的性能损耗,引入了“偏向锁”和“轻量级锁”。这就得提到锁的状态了,锁一共有四种状态,级别由低到高分别是:无状态锁、偏向锁、轻量级锁、重量级锁。这几个状态会随着竞争情况而升级,并且锁只能升级不能降级,这种做法是为了提高获得锁和释放锁的效率,至于如果做到的我们得先了解这几种锁的状态才能展开。

偏向锁#

在大多数情况下,锁并不会存在多线程的竞争,而且总是由同一线程多次获得,所以就引入偏向锁的概念。当一个线程访问同步块并获取锁的时候,会在对当头和栈帧2的锁记录里存储偏向锁的线程ID,以后该线程在进入和退出同步块的时候不需要进行 CAS3 操作来加锁和解锁,只要测试对象头中是否是存储着当前线程的偏向锁即可。偏向锁提供了一种竞争出现才会释放的锁机制,当竞争出现的时候,首先会停止偏向锁的线程,然后检测持有偏向锁的线程是否活着,如果不活动则将对象头设置成无锁状态,如果活着则拥有偏向锁的栈会被执行,最后唤醒暂停的线程。

轻量级锁#

当一个线程获取轻量级锁时,JVM 会先在对象头中存储锁记录的指针,然后使用 CAS 指令尝试将对象的锁记录指针替换为指向当前线程的指针。如果 CAS 成功,表示当前线程获取了锁,可以继续执行,否则说明有竞争发生。在有竞争的情况下,如果其他线程也尝试获取同一个对象的轻量级锁,JVM 会将锁升级为重量级锁,当锁处于这个状态下,其他线程获取锁就会处于阻塞状态,一直等到只有锁的线程释放锁再唤醒这些阻塞线程进行新一轮争夺锁之战,因为锁不可降级的特性在,那么在释放锁时可以直接将锁的状态改为未锁定状态,无需进行额外的处理。这样就减少了释放锁的开销,提高了效率。

总结#

优点缺点使用场景
偏向锁加锁和解锁不需要额外消耗锁竞争会带来额外的锁撤销的消耗适用于一个线程访问同步块场景
轻量级锁竞争线程不会阻塞,提高了程序的响应速度始终得不到锁的线程会自旋消耗 CPU追求响应时间,执行速度快
重量级锁线程不会自旋,不会消耗 CPU线程阻塞,相应时间慢追求吞吐量

原子操作4#

处理器能保证从系统内存中读取和写入一个字节是原子的,而复杂的内存操作需要搭配处理器提供的总线锁定和缓存锁来保证其原子性。Java 通过循环CAS来实现原子操作,从 JDK1.5 开始,并发包中就出现了 AtomicBoolean、AtomicInteger 等原子类将当前值加一减一。不过用 CAS 实现原子操作也是存在问题的,ABA 问题、循环时间开销大和只能保证一个变量的原子操作等。

总结#

Java 大部分的容器和框架都是依赖 volatile 和原子操作,这对展开并发编程很有帮助。

Java 内存模型#

在并发编程中有两个重要的问题:线程之间如何通讯及线程之间如何同步。Java 的并发采用的是共享内存模型,即线程之间共享程序的公共状态,通过写-读内存中的公共状态进行隐式通讯。

内存模型的抽象结构#

在 Java 中,所有的实例域、静态域和数组元素都存在堆内存中,堆内存在线程间共享,而局部变量、方法定义参数和异常处理器参数不会在线程之间共享,也不会有可见性问题。Java 线程之间的通讯由 Java 内存模型(JMM)控制,由 JMM 决定一个线程对共享变量的写入何时对另一个线程可见,从抽象的角度来讲,线程之间的共享变量存储在主内存,而每个线程都有本地内存,本地内存存放的共享变量的副本,示意图如下所示:

Java 内存模型抽象结构图

从图中来看,如果线程A和线程B之间通讯需要线程A先将本地内存A中更新的变量刷新到主内存去,然后线程B到住内存去读取线程A之前已经更新的共享变量。所以这个步骤的实质就是线程A向线程B发送消息,而且这个通信过程必须经过主内存。

指令重排#

在执行程序的过程中,为了提高性能,编译器和处理器常常会对指令进行重新排序,排序方式有以下三种:

  1. 编译器优化的重新排序:编译器在不改变单线程程序语意的情况下可以进行语句的执行顺序。
  2. 指令级并行的重新排序:根据指令集并行技术(ILP)来将多条指令重叠执行,如果不存在数据依赖,处理器可以改变语句对机器指令的执行顺序。
  3. 内存系统的重新排序:由于处理器使用缓存和读写缓冲区,使得加载和存储操作看上去可能是乱序执行。

因为这些重排序可能造成多线程程序出现内存可见性的问题,JMM 的的处理器重排规则会要求 Java 编译器在生成指令序列的时候插入特定类型的内存屏障(Memory Barriers,也被称为 Memeory Fence),JMM 属于语言级别的内存模型,它确保在不同编译器和不同处理器平台上通过禁止重排序来提供一致的内存可见性保证。

Footnotes#

  1. 缓冲行(cache line)缓存中可以分配的最小单位。

  2. 栈帧(Stack Frame)支持虚拟机进行方法调用和方法执行的数据结构,在当前线程中,每执行一个方法就会往栈中插入一个栈帧。

  3. CAS(compare and swap)判断内存某个位置的值是否为预期值,如果是则更改为新的值,这个过程是原子操作。

  4. 原子操作(atomic operations)不可中断的一个或者一系列指令。

Concurrency
https://songbaicheng.cc.cd/posts/concurrency/
作者
宋柏成
发布于
2026-06-05
许可协议
CC BY-NC-SA 4.0