本篇是对
Java
内存模型 (JMM
) 及volatile
关键字的深度记录,从而彻底理解JMM
和volatile
关键字的作用。
# Java 内存区域与内存模型
# Java 内存区域
Java
虚拟机在运行程序时会把自动管理的内存划分为以下几个区域,每个区域都有各自的用途以及创建和销毁的时机,其中蓝色部分代表的是所有线程共享的数据区域,绿色部分代表的是每个线程私有的数据区域。
Method Area(方法区)
:- 方法区又被称为:
Non-Heap
属于线程共享的内存区域,主要用于存储已被虚拟机加载的类信息、常量、静态变量、通过即时编译器编译后的代码等数据,根据JVM
规定,当Method Area(方法区)
无法满足内存分配需求时,将会抛出OutOfMemoryError
错误,注意:在Method Area(方法区)
中存在一个叫运行时常量池(Runtime Constant Pool)
的区域,它的主要作用是存放编译器生成的各种字面量和符号引用,这些内容将在类加载后存放到运行时常量池中以便后续使用。
- 方法区又被称为:
Java Heap(JVM堆)
Java
堆也属于线程共享内存区域,它在虚拟机启动时创建,是Java
虚拟机所管理的内存中最大的一块,主要用于存放对象实例,几乎所有的对象实例都在这里进行内存分配,注意:Java
堆是垃圾收集器管理的主要区域,因此很多时候也被称为GC
堆,如果在堆中没有内存完成实例分配并且堆无法再扩展时,将会抛出OutOfMemoryError
错误。
Program Counter Register(程序计数器)
- 程序计数器属于线程私有数据区域,它是一小块内存空间,主要代表当前线程所执行的字节码行号的指示器,字节码解释器工作时,通过改变这个计数器的值来选区下一条需要执行的字节码指令、分支、循环、跳转、异常处理、线程恢复等基础功能都依赖于这个计数器来完成。
Java Virtual Machine Stacks(虚拟机栈)
- 虚拟机栈属于线程私有数据区域,它与线程同时创建,总数与线程相关联,它代表
Java
方法执行的内存模型,每个方法执行时都会创建一个栈帧来存储方法的变量表、操作数栈、动态链接、返回值、返回地址等信息,方法的调用到结束对应一个栈帧在虚拟机栈中的入栈和出栈过程。
- 虚拟机栈属于线程私有数据区域,它与线程同时创建,总数与线程相关联,它代表
Native Method Stacks(本地方法栈)
- 本地方法栈属于线程私有数据区域,这部分主要与虚拟机用到的的
Native
方法有关,在一般情况下我们不需要关心此区域。
- 本地方法栈属于线程私有数据区域,这部分主要与虚拟机用到的的
# Java 内存模型
Java
内存模型又被称为JMM(Java Memory Model)
,它是一种抽象概念,描述的是一组规则或规范,通过这组规范定义了程序中的各个变量 (其中包括:实例字段、静态字段、数组等) 的访问方式,由于JVM
运行程序的实体是线程,而每个线程创建时JVM
都会为其创建一个工作内存有时候也会被称为栈空间,用于存储线程私有数据,而Java
内存模型中规定了所有变量都要存储在主内存中,主内存是共享内存区域,所有线程都可以进行访问,但线程对变量的操作 (读取赋值等操作) 必须在工作内存中进行才可以,首先会将变量从主内存拷贝到自己的工作内存空间中,然后对变量进行操作,操作完成后再将变量写回主内存,不能直接操作主内存中的变量,工作内存中存储着主内存中的变量副本数据,在前面说过,工作内存是每个线程的私有数据区域,因此不同的线程间无法访问对方的工作内存,线程间的通信必须通过主内存来完成,过程如下图:
注意:
JMM
与Java
内存区域的划分是不同的概念层次,更恰当来说JMM
描述的是一组规则,通过这组规则来控制程序中的各个变量在共享数据区域和私有数据区域的访问方式,JMM
是围绕原子性、有序性、可见性展开的。Java
内存模型与Java
内存区域唯一相似的是都存在共享和私有数据区域,在JMM
中主内存属于共享数据区域,从某个程度上讲应该包括了堆和方法区,而工作内存和线程私有数据区域,从某个程度上讲应该包括程序计数器、虚拟机栈及本地方法栈。在某些地方我们会看到主内存会被描述为堆内存,工作内存被描述为线程栈,实际上它们表达的都是同一个含义,JMM
主内存和工作内存详细介绍如下:
- 主内存:
- 其主要存储的是
Java
实例对象,所有线程创建的实例对象都存放在主内存中,不管该实例对象是成员变量还是局部变量,当然也包括共享的类信息、常量、静态变量,由于是共享数据区域,多条线程对同一个变量进行访问可能会发生线程安全问题。
- 其主要存储的是
- 工作内存:
- 其主要存储当前方法的所有本地变量信息 (包含从主内存中拷贝的变量副本),每个线程只能访问自己的工作内存,即:线程中的本地变量对其它线程是不可见的,就算是两个线程执行同一段代码,它们也会在各自的工作内存中创建属于当前线程的本地变量,当然也包括字节码行号指示器和
Native
相关方法信息,由于工作内存是每个线程的私有数据,线程间无法互相访问,因此存储在工作内存的数据不存在线程安全问题。
- 其主要存储当前方法的所有本地变量信息 (包含从主内存中拷贝的变量副本),每个线程只能访问自己的工作内存,即:线程中的本地变量对其它线程是不可见的,就算是两个线程执行同一段代码,它们也会在各自的工作内存中创建属于当前线程的本地变量,当然也包括字节码行号指示器和
# 主内存与工作内存的数据存储类型
在上面了解了主内存和工作内存后,下面将来看一下它们的数据存储类型及操作方式,根据
Java
虚拟机规范,对于一个实例对象中的成员方法而言,如果方法中包含本地变量是基本数据类型(byte、short、char、int、long、float、double、boolean)
,将会直接存储在工作内存的栈帧结构中,倘若本地变量是引用类型,那么该变量的引用会存储在工作内存的栈帧中,而对象实例将存储在主内存的共享数据区域的堆中。
但是对于实例对象的成员变量,不管它是基本数据类型或引用类型还是包装类型
(Interger、Double)
等,都会被存储到堆中,至于static
静态变量以及类本身相关信息将会存储在主内存中。需要注意:在主内存中的实例对象可以被多线程共享,如果两个线程同时调用同一个对象的同一个方法时两条线程会将要操作的数据拷贝到自己的工作内存中,在执行完成后会写回到主内存从而实现数据修改操作。
# Java 内存与硬件内存
# 硬件内存架构
上图为了方便理解,省去了南北桥并将三级缓存统称为
CPU
缓存,对于目前计算机而言,每个CPU
都存在多个核心,多核指的是在一枚处理器中集成多个完整计算引擎(内核)
,这样就可以支持多任务并行执行,从多线程角度来讲,每个线程都会映射到各个CPU
核心中并行执行。
在处理器内部有一组
CPU
寄存器,它是一个临时存放数据的空间,一般处理器都会从内存取数据到寄存器,然后对其进行处理,但由于内存的处理速度远低于CPU
这就导致处理器在处理指令时往往花费很多时间在等待内存做准备工作,于是在寄存器和主内存间添加了CPU
缓存,CPU
缓存很小但访问速度远比主内存快的多。
如果处理器总是操作主内存中的同一地址数据,就很容易影响处理器执行速度,此时
CPU
缓存就可以把内存的数据暂时存起来,如果寄存器要取内存中的同一个位置的数据,就可直接从CPU
缓存中取出,无需再跑到主内存中去取。
注意:寄存器并不是每次数据都可以从缓存中取到,如果不是同一个内存地址中的数据,那么寄存器还必须得绕过缓存从内存中取数据,这种现象叫做缓存命中率,从缓存中可以取到数据就代表命中,则而反之就是从内存中取就代表没命中,缓存命中率的高低也会影响处理器执行效率,这就是
CPU(处理器)
、缓存、以及主内存的数据交互。
总而言之当一个
CPU
需要访问主内存时,会先读取一部分主内存中的数据到CPU
缓存,如果CPU
缓存中存在需要的数据就会直接从缓存中获取,进而在读取CPU
缓存到寄存器,当CPU
需要写数据到主内存时,同样会先刷新寄存器中的数据到CPU
缓存,然后再把数据刷新到主内存中。
# 线程与硬件处理器
Java
线程的实现是基于一对一的线程模型,所谓的一对一模型就是通过语言级别层面程序去间接调用系统内核的线程模型 (即我们使用Java
线程时), 在Java
虚拟机内部是转而调用当前操作系统的内核线程(Kernel-Level Thread,kLT)
来完成当前任务,它是由操作系统内核(Kernel)
支持的线程,这种线程是由操作系统内核来完成线程切换,内核通过操作调度器进而对线程执行调度,并将线程的任务映射到各个处理器上。
每个内核线程可以被视为内核的一个分身,这也就是操作系统可以同时处理多任务的原因,由于我们编写的多线程程序属于语言层面,程序一般不会直接去调用内核线程,取而代之的是一种轻量级
进程(Light Weight Process)
,也就是所谓的线程,由于每个轻量级进程都会映射到一个内核线程,因此我们可以通过轻量级进程来调用内核线程,进而由操作系统内核将任务映射到各个处理器,这种轻量级进程与内核线程间的一对一关系就被称为一对一的线程模型。
如上图所示,每个线程最终都会映射到
CPU
中进行处理,如果CPU
存在多核那么一个CPU
将可以并行执行多个线程任务。
# 内存模型与内存架构关系
在了解了前面的硬件内存架构和
Java
内存模型及Java
多线程的实现原理后,我们已经意识到多线程的执行最终都会映射到硬件处理器上进行执行,但Java
内存模型和硬件内存架构并不完全一致,对于硬件内存来说只有寄存器、缓存、主内存的概念,并没有工作内存 (私有数据区域) 和主内存 (堆内存) 之分,也就是说Java
内存模型的内存划分对硬件内存并没有任何影响,因为JMM
只是一种抽象概念,是一组规则并不存在,不管是工作内存的数据还是主内存的数据,对于计算机硬件来讲都会存储在计算机主内存中,当然也有可能存储到CPU
缓存或寄存器中,因此总体来讲Java
内存模型和计算机硬件内存架构是一个相互交叉的关系,是一种抽象概念划分与真实物理硬件的交叉,对于Jav
内存区域划分也是同样的道理。
# 指令重排
指令重排是什么?简单来说就是操作系统在执行代码的时候并不一定按照你的代码顺序依次执行。
- 计算机在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序列操作,并分为以下三种方式:
编译器优化重排
:编译器在不改变单线程程序的前提下,可以重新安排指令语句的执行顺序。指令并行重排
:现代处理器采用了指令级并行技术来将多条指令重叠执行,如果不存在数据依赖性 (即:后面的执行语句不依赖于前面执行语句的结果),处理器可以改变语句对应的机器指令的执行顺序。内存系统重排
:由于处理器使用缓存和读写缓冲区,这使得加载和存储操作看上去就像是在乱序执行,因为三级缓存的存在,导致内存与缓存的数据同步存在时间差。
Java
源代码会经历编译器优化重排
➡️指令并行重排
➡️内存系统重排
的过程最终才变成操作系统可执行的指令顺序,另外指令重排序可以保证串行语义一致,但是没有义务保证多线程之间的语义一致,所以在多线程情况下,指令重排就会可能导致一些问题的发生。- 编译器和处理器的指令重排处理方式是不同的,对于编译器,通过禁止特定类型的编译器重排序的方式来禁止重排序。对于处理器,通过插入内存屏障
(Memory Barrier又被称为内存栅栏或Memory Fence)
的方式来禁止特定类型的处理器重排序,指令并行重排和内存系统重排都属于处理器级别的指令重排序。Memory Barrier(内存屏障)
:它是一种CPU
指令,用来禁止处理器指令发生重排序 (像屏障一样),从而保障指令执行的有序性,另外为了达到屏障效果,它也会使处理器写入、读取值之前将主内存的值写入高速缓存并清空无效队列,从而保证变量的可见性。
package top.rem.rain.synchronized_example; | |
/** | |
* @Author: LightRain | |
* @Description: 指令重排示例代码 | |
* @DateTime: 2023-12-22 00:21 | |
* @Version:1.0 | |
**/ | |
/** | |
* 指令重排测试 | |
*/ | |
public class CommandDisorder { | |
/** | |
* 当使用 volatile 关键词修饰变量时,则不会出现指令重排现象 | |
*/ | |
private static /*volatile*/ int a = 0, b = 0, c = 0, d = 0; | |
/** | |
* 测试方式:一次开启两个线程,同时修改变量 | |
*/ | |
public static void main(String[] args) throws InterruptedException { | |
int i = 0; | |
while (true) { | |
i++; | |
a = b = c = d = 0; | |
Thread t1 = new Thread(() -> { | |
// 指令重排,会先执行这行代码,导致 c = 0, d = 0 | |
a = 1; | |
c = b; | |
}); | |
Thread t2 = new Thread(() -> { | |
// 指令重排,会先执行这行代码,导致 c = 0, d = 0 | |
b = 1; | |
d = a; | |
}); | |
t1.start(); | |
t2.start(); | |
t1.join(); | |
t2.join(); | |
if (c == 0 && d == 0) { | |
System.err.printf("第%s次出现指令重排%n", i); | |
break; | |
} else { | |
System.out.println(i); | |
} | |
} | |
/* | |
执行结果: | |
1 | |
2 | |
3 | |
4 | |
... | |
56145 | |
56146 | |
56147 | |
第 56148 次出现指令重排 | |
*/ | |
} | |
} |
上述代码就是用于测试指令重排会在什么时候发生,指令重排会在多少次后进行重排操作都是不确定的,在真实业务中想要避免指令重排请加上
volatile
关键字,来保证变量在线程间的可见性。
# JMM(Java Memory Model)
# JMM 到底是什么?
Java
是最早尝试提供内存模型的编程语言,由于早期内存模型存在一些缺陷 (例如非常容易削弱编译器的优化),自Java 5
开始,Java
开始使用新的内存模型 《JSR-133:Java Memory Model and Thread Specification》。一般来讲,编程语言也可以直接复用操作系统层面的内存模型,不过不同的操作系统内存模型是不同的。如果直接复用操作系统层面的内存模型,就可能会导致同样一套代码换了一个操作系统就无法执行了。Java
语言是跨平台的,它需要自己提供一套内存模型以屏蔽系统差异。
这只是
JMM
存在的其中一个原因,实际上对于Java
来说,你可以把JMM
看做是Java
定义的并发编程相关的一组规范,除了抽象了线程和主内存之间的关系之外,其还规定了从Java
源代码到CPU
可执行命令的这个转化过程要遵守哪些和并发相关的原则和规范,其主要目的是为了简化多线程编程,增强程序的可移植性。
为什么需要遵守并发相关的原则和规范,这是因为并发编程下像
CPU
多级缓存和指令重排这类设计可能会导致程序运行出现一些问题。比如指令重排就可能会让多线程程序的执行出现问题,为此JMM
抽象了happens-before
原则来解决指令重排问题。
JMM
简单来说就是定义了一些规范来解决这些问题,开发者可以利用这些规则更方便地开发多线程程序,对于Java
开发者来说不需要深度了解底层原理也可以开发出并发安全的程序,使用并发相关的关键字如:volatile
、synchronized
、Lock
等,即可实现并发安全。
# JMM 存在的必要性
在明白了
Java
内存区域和硬件内存架构及Java
多线程实现原理与Java
内存模型的具体关系后,来看Java
内存模型存在的必要性,由于JVM
运行程序的实体是线程,而每个线程创建时JVM
都会为其创建一个工作内存,用于存储线程私有数据,线程与主内存中的变量操作必须通过工作内存间接完成,主要过程是将变量从主内存拷贝到每个线程各自的工作内存中,然后对变量进行操作,操作完成后再将变量写回主内存。
如果两个线程同时对一个主内存中的实例对象变量进行操作就有可能诱发线程安全问题,请看下图,主内存中存在一个共享变量
x
, 但现在有两个线程分别是A
线程和B
线程对该变量进行赋值操作 (如:x = 100
),A
和B
每个线程各自的工作内存中都存在共享变量副本x
,假如现在A
线程想要修改x
值为150
,而B
线程却要读取x
的值,那么B
线程读取到的值是A
线程更新后的数据还是更新前的数据呢?这谁也不知道,有可能B
线程读取到更新后的也有可能读取到更新前的数据,这是因为工作内存是每个线程私有数据区域,而A
线程操作变量x
时首先将变量从主内存拷贝一份到A
线程的工作内存中,然后对其变量进行赋值操作等,等操作完成后再将变量x
写回到主内存,对于B
线程也是类似的,这样就有可能造成主内存与工作内存数据存在一致性问题。
假设
A
线程修改完成后正在将数据写回主内存中,而B
线程此时正在读取主内存,即:将x = 100
拷贝到自己的工作内存中,这样B
线程读取到的值就是x=100
, 但如果A
线程已将x = 150
写回到主内存后,B
线程才开始读取的话那么此时B
线程读取到的就是x = 150
, 但到底是哪种情况会发生谁都不会知道,这就是所谓的线程安全问题。
为了解决类似上述的问题
JVM
定义了一组规则,通过这组规则来决定一个线程对共享变量的写入何时对另一个线程可见,这组规则被称为Java
内存模型(JMM)
,JMM
是围绕着程序执行的原子性、有序性、可见性展开的。
# JMM 的三种特性
# 原子性
原子性是指一个操作是不可中断的,即使是在多线程环境下,一个操作一旦开始就不会被其它线程所影响,如:对一个静态变量 (
private static int i = 0
) 两条线程同时对它进行赋值操作,线程A
赋值为100
,线程B
赋值为200
,不管线程如何运行最终i
的值要么是100
要么是200
,线程A
和线程B
之间的操作是没有任何干扰的,这就是原子性操作不可被中断的特点。
但是对于
32
位系统来讲long
类型和double
类型数据它们读写并非原子性的,对于基本类型数据:byte
、short
、int
、float
、boolean
、char
的读写是原子操作,也就是说两条线程同时对long
类型或double
类型的数据进行读写是会存在互相干扰的,因为对于32
位虚拟机来说,每次原子读写是32
位,而long
和double
则是64
位的存储单元。
这样就会导致一个线程在写时,操作完前
32
位的原子操作后,轮到B
线程读取时,恰好只读到了后32
位的数据,这样可能会读取到一个既非原值又不是线程修改值的变量,它可能就是半个变量的数值,即64
位数据呗两个线程分成了两次读取。也不必太过担心至少目前的商用虚拟机中几乎把64
位数据读写操作作为原子操作来执行。
# 可见性
可见性是指当一个线程对共享变量进行了修改,那么另外的线程都是立即可以看到修改后的最新值,在
Java
中可以使用synchronized
、volatile
关键字、以及各种Lock
实现可见性,如果我们将变量声明为volatile
就代表我们将告诉JVM
这个变量是共享数据且不稳定的,每次使用它都到主内存中进行读取。
# 有序性
有序性是指对于单线程的执行代码,我们总是认为代码的执行是按照顺序依次执行的,毕竟对于单线程而言确实如此,但对于多线程环境则可能会出现乱序现象,因为程序编译成机器指令后可能会出现指令重排现象,重排后的指令与原指令的顺序未必一致,要明白在
Java
程序中,倘若在本线程内,所有操作都视为有序行为,如果是多线程环境下,一个线程中观察另外一个线程,所有操作都是无序的,前半句是代表单线程内保证串行语义执行的一致性,后半句代表指令重排现象和工作内存与主内存同步延迟现象。
# happens-before
# happens-before 什么是
关于
happens-before
概念最早诞生于1978
年由Leslie Lamport
发表的《Time,Clocks and the Ordering of Events in a Distributed System》论文中,在这篇论文中Leslie Lamport
提出了逻辑时钟的概念,这也成了第一个逻辑时钟算法。在分布式环境中,通过一系列规则来定义逻辑时钟的变化,从而能通过逻辑时钟来对分布式系统中的事件的先后顺序进行判断,逻辑时钟并不度量时间本身,仅区分事件发生的前后顺序,其本质就是定义了一种happens-before
关系,简单了解一下即可。
JSR 133
引入了happens-before
这个概念来描述两个操作之间的内存可见性,为什么需要happens-beffore
原则?happens-before
原则的诞生是为了程序员和编译器、处理器之间的平衡,程序员追求的是易于理解和编程的强内存模型,遵守规则编码即可。编译器和处理器追求的是较少约束的弱内存模型,让它们尽己所能地去优化性能,让性能最大化。
happens-before
原则设计思想非常简单:- 1️⃣为了对编译器和处理器的约束尽可能少,只要不改变程序的执行结果 (单线程程序和正确执行的多线程程序),编译器和处理器怎么进行重排序优化都行。
- 2️⃣对于会改变程序执行结果的重排序,
JMM
要求编译器和处理器必须禁止这种重排序。
- 下面的图来自于《Java 并发编程的艺术》中的一张
JMM
设计思想示意图。 happens-before
原则的定义:- 如果一个操作
happens-before
另一个操作 (描述为第一个操作和第二个操作之间满足happens-before
关系,那么我们就可以说第一个操作对于第二个操作一定是可见的),那么第一个操作的执行结果将对第二个操作可见,并且第一个操作的执行顺序排在第二个操作之前。 - 两个操作之间存在
happens-before
关系,并不意味着Java
平台的具体实现必须要按照happens-before
关系指定的顺序来执行。 - 如果重排序之后的执行结果,与按
happens-before
关系来执行的结果一直,那么JMM
也允许这样的重排序。
int userNum = getUserNum(); // 1
int teacherNum = getTeacherNum(); // 2
int totalNum = userNum + teacherNum; // 3
// 1 happens-before 2
// 2 happens-before 3
// 1 happens-before 3
/*
虽然 1 happens-before 2,但对 1 和 2 进行重排序不会影响代码的执行结果,所以 JMM 是允许编译器和处理器执行这种重排序的。但 1 和 2 必须是在 3 执行之前,也就是说 1,2 happens-before 3
*/
- 如果一个操作
happens-before
原则表达的意义其实并不是一个操作发生在另外一个操作的前面,虽然这从程序员的角度上来讲也并无大碍。更准确地来说,它更想表达的意义是前一个操作的结果对于后一个操作是可见的,无论这两个操作是否在同一个线程里。
# happens-before 和 JMM 的关系
下图来自于《Java 并发编程的艺术》中的一张图就可以非常好的解释清楚。
# happens-before 原则
在程序开发中,仅靠
synchronized
和volatile
关键字来保证原子性、可见性及有序性,那么编写并发程序可能会显得十分麻烦,幸运的是在Java
内存模型中,提供了happens-before
原则来辅助保证程序执行的原子性、可见性和有序性的问题,它是判断数据是否存在竞争、线程是否安全的依据。
happens-before
原则分为以下八种:程序顺序原则
:一个线程内,按照代码顺序,书写在前面的操作,happens-before
于书写在后面的操作,即:在一个线程内保证语义串行性,也就是按照代码顺序执行。锁规则
:解锁(unlock)
操作必然发生在后续的同一个锁的加锁(lock)
之前,就是说如果对于一个锁解锁后再加锁,那么加锁的动作必须在解锁动作之后 (同一个锁)。volatile规则
:volatile
变量的写,先发生于读,这保证了volatile
变量的可见性,简单理解就是volatile
变量在每次被线程访问时,都强迫从主内存中读取该变量的值,而当该变量发生变化时,又会强迫将最新的值刷新到主内存,在任何时刻不同的线程总是能看到该变量的最新值。线程启动规则
:线程的start()
方法先于它的每一个动作,即如果线程A
在执行线程B
的start()
方法之前修改了关系变量的值,那么当线程B
执行start()
方法时,线程A
对共享变量的修改对线程B
可见。传递性
:A
优先于B
,B
优先于C
,那么A
必然先于C
。线程终止规则
:线程的所有操作先于线程的终结,Thread.join()
方法的作用是等待当前执行的线程终止,假设在线程B
终止之前,修改了共享变量,线程A
从线程B
的join()
方法成功返回后,线程B
对共享变量的修改将对线程A
可见。线程中断规则
:对线程interrupt()
方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()
方法检测线程是否中断。对象终结规则
:对象的构造函数执行,结束先于finalize()
方法
- 需要注意的是:如果两个操作不满足上述任意一个
happens-before
规则,那么这两个操作就没有顺序的保障,JVM
可以对这两个操作进行重排序。 - 在上述中的八条原则无需手动添加任何同步手段使用关键字
synchronized
或volatile
即可达到效果。
# 关于 volatile 关键字
# volatile 内存语义
volatile
关键字在并发编程中很常见,但也容易被滥用,现在我们将来深入了解一下volatile
关键字的语义,volatile
是Java
虚拟机提供的轻量级同步机制,主要有如下两个作用:- 禁止指令重排序优化。
- 保证被
volatile
修饰的共享变量对所有线程总是可见的,也就是当一个线程修改了一个被volatile
关键字所修饰的共享变量的值,新值总是可以被其它线程所看到。
- 当写一个
volatile
变量时,JMM
会把该线程对应的本地内存中的共享变量值立即刷新回主内存中。 - 当读一个
volatile
变量时,JMM
会把该线程对应的本地内存设置为无效,重新回到主内存中读取最新共享变量。
# volatile 可见性
关于
volatile
的可见性作用,我们必须意识到被volatile
关键字所修饰的变量对所有线程总是立即可见的,对volatile
变量的所有写操作总是能立刻反应到其它线程中,但是对于volatile
变量运算操作在多线程环境下并不保证安全性,代码如下:
package top.rem.rain.synchronized_example; | |
/** | |
* @Author: LightRain | |
* @Description: volition 的可见性代码示例 | |
* @DateTime: 2023-12-23 16:38 | |
* @Version:1.0 | |
**/ | |
public class VolatileVisibility implements Runnable { | |
private static volatile int i = 0; | |
public static void increase() { | |
i++; | |
} | |
public static void main(String[] args) throws InterruptedException { | |
Thread t1 = new Thread(new VolatileVisibility()); | |
Thread t2 = new Thread(new VolatileVisibility()); | |
t1.start(); | |
t2.start(); | |
t1.join(); | |
t2.join(); | |
System.out.println("i = " + i); | |
/* | |
执行结果:i = 1140057 | |
*/ | |
} | |
@Override | |
public void run() { | |
for (int j = 0; j < 1000000; j++) { | |
increase(); | |
} | |
} | |
} |
如上述代码所示,共享变量
i
的任何改变都会立马反应到其它线程中,但是如果此时存在多条线程同时调用increase()
方法的话,此时就会出现线程安全问题,毕竟i++
操作并不是原子性,该操作是先读取值然后写回一个新值,相当于在原来的基础上+1
分为两步来完成。
如果第二个线程在第一个线程读取旧值和写回新值期间读取
i
的域值,那么第二个线程就会与第一个线程一起看到同一个值,并执行相同值的+1
操作,这也就造成了线程安全问题。
因此对于
increase()
方法必须使用synchronized
来修饰,以确保证线程安全,需要注意的一点是一旦使用synchronized
修饰方法后,由于synchronized
本身就具备与volatile
相同特性 (可见性),因此在这种情况下就可以省去volatile
关键字了。
package top.rem.rain.synchronized_example; | |
/** | |
* @Author: LightRain | |
* @Description: volition 的可见性代码示例 | |
* @DateTime: 2023-12-23 16:38 | |
* @Version:1.0 | |
**/ | |
public class VolatileVisibility implements Runnable { | |
private static int i = 0; | |
public synchronized static void increase() { | |
i++; | |
} | |
public static void main(String[] args) throws InterruptedException { | |
Thread t1 = new Thread(new VolatileVisibility()); | |
Thread t2 = new Thread(new VolatileVisibility()); | |
t1.start(); | |
t2.start(); | |
t1.join(); | |
t2.join(); | |
System.out.println("i = " + i); | |
/* | |
执行结果:i = 2000000 | |
*/ | |
} | |
@Override | |
public void run() { | |
for (int j = 0; j < 1000000; j++) { | |
increase(); | |
} | |
} | |
} |
下方代码使用
volatile
修饰变量达到线程安全目的,代码如下:
package top.rem.rain.synchronized_example; | |
/** | |
* @Author: LightRain | |
* @Description: | |
* @DateTime: 2023-12-23 20:49 | |
* @Version:1.0 | |
**/ | |
public class VolatileSafe { | |
public static volatile boolean flag = true; | |
public static void main(String[] args) throws InterruptedException { | |
new Thread(new Runnable() { | |
@Override | |
public void run() { | |
while (flag) { | |
} | |
System.out.println(Thread.currentThread().getName() + "线程停止,死循环被打开"); | |
} | |
}).start(); | |
new Thread(new Runnable() { | |
@Override | |
public void run() { | |
try { | |
Thread.sleep(2000); | |
} catch (InterruptedException e) { | |
e.printStackTrace(); | |
} | |
flag = false; | |
System.out.println(Thread.currentThread().getName() + "修改 flag 为" + flag); | |
} | |
}).start(); | |
Thread.sleep(Integer.MAX_VALUE); | |
/* | |
使用 volatile 的执行结果: | |
Thread-0 线程停止,死循环被打开 | |
Thread-1 修改 flag 为 false | |
-------------------------------- | |
不使用 volatile 的执行结果: | |
Thread-1 修改 flag 为 false | |
*/ | |
} | |
} |
在上述代码中使用
volatile
和不使用volatile
关键字的结果显然不同,这也就表明了使用volatile
关键字来修饰变量确实可以让其它线程所看到修改的结果,因此可以通过使用volatile
关键字来达到线程安全目的。
那么
JMM
是如何实现让volatile
变量对其它线程立即可见的呢?当写一个volatile
变量时JMM
会把该线程对应的工作内存中的共享变量值刷新到主内存中,当读取一个volatile
变量时,JMM
会把该线程对应的工作内存置为无效,那么该线程将只能从主内存中重新读取共享变量,volatile
变量正是通过这种写/读
方式实现对其它线程可见 (但其内存语义实现则是通过内存屏障)。
# volatile 禁止重排
volatile
关键字另一个作用就是禁止指令重排优化,从而避免多线程环境下程序出现乱序执行的现象,关于指令重排优化在前面提到过,这里主要简单说明一下volatile
是如何实现禁止指令重排优化的。
内存屏障 (Memory Barrier) 又被称为内存栅栏,它是
CPU
指令,它的作用有两个,一是保证特定操作的执行顺序,二是保证某些变量的内存可见性 (volatile
的内存可见性就是利用该特性实现的)。
由于编译器和处理器都能执行指令重排优化,如果在指令间插入一条
Memory Barrier
则会告诉编译器和CPU
,不管什么指令都不能和这条Memory Barrier
指令重排序,也就是说通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化。
Memory Barrier
的另一个作用是强制刷出各种CPU
缓存数据,因此任何CPU
上的线程都能读取到这些数据的最新版本,总之volatile
变量正是通过内存屏障实现其在内存中的可见性和禁止重排优化。
package top.rem.rain.synchronized_example; | |
/** | |
* @Author: LightRain | |
* @Description: 双重检查锁 | |
* @DateTime: 2023-12-23 23:29 | |
* @Version:1.0 | |
**/ | |
public class DoubleCheckLock { | |
private static DoubleCheckLock instance; | |
private DoubleCheckLock() { | |
} | |
public static DoubleCheckLock getInstance() { | |
// 第一次检测 | |
if (instance == null) { | |
// 同步 | |
synchronized (DoubleCheckLock.class) { | |
if (instance == null) { | |
// 多线程环境下可能会出现问题的地方 | |
instance = new DoubleCheckLock(); | |
} | |
} | |
} | |
return instance; | |
} | |
} |
上述代码是一个经典的单例双重检查锁的代码,这段代码在单线程环境下并没什么问题,但如果是在多线程情况下就可能出现线程安全问题,原因在于某一个线程执行到第一次检测,读取到
instance
不为null
时,instance
的引用对象可能没有完成初始化,因为instance = new DoubleCheckLock()
可以分为以下三步完成 (伪代码)。
memory = allocate(); // 第一步:分配对象内存空间 | |
instance(memory); // 第二步:初始化对象 | |
instance = memory; // 第三步:设置 instance 指向刚分配的内存地址,此时 instance != null |
由于第二步和第三步之间可能会重排序,重排序后如下:
memory = allocate(); // 第一步:分配对象内存空间 | |
instance = memory; // 第三步:设置 instance 指向刚分配的内存地址,此时 instance != null | |
instance(memory); // 第二步:初始化对象 |
由于第二步和第三步不存在数据依赖关系,而且无论重排前还是重排后程序的执行结果在单线程中并没有任何改变,因此这种重排优化是允许的。但是指令重排只会保证串行语义执行的一致性 (单线程),但并不会关心多线程间的语义一致性,所以当一条线程访问
instance
不为null
时,由于instance
实例未必初始化完成,也就造成了线程安全问题,此时我们就可以使用volatile
关键字来禁止instance
变量被执行指令重排优化。
// 使用 volatile 来禁止指令重排优化 | |
private volatile static DoubleCheckLock instance; |
最后我们应该清除知道
JMM
就是一组规则,这组规则意在解决在并发编程可能出现的线程安全问题,并提供了内置解决方案(happens-before原则)
及其外部可以使用的同步手段:synchronized
、volatile
等,确保了程序执行在多线程中的应有的原子性、可见性和有序性。