1. Java内存模型概述
① 常见的并发应用场景
- 常见的并发应用场景:
- 多任务处理: 计算机中,CPU的运算速度和它的存储和通信子系统的速度差异很大。为了充分利用CPU资源,让CPU同时处理多个任务。
- 服务器: 服务器可以同时为多个客户端提供服务
② 如何充分利用CPU资源?
- 高速缓存(Cache)
- 为了解决CPU和内存之间的速度差异,避免慢的内存影响快的CPU,在CPU和内存之间加入高速缓存作为缓冲。
- 缓存的读写速度几乎接近CPU的运算速度,可以将运算需要的数据从内存复制到缓存中,运算完成后,将运算结果从缓存同步回内存。
- 在多CPU的计算机系统中,每个CPU都有自己的高速缓存,同时又共享主内存。如果多个CPU的运算任务都涉及到同一块主内存,则很可能导致缓存中的数据不一致。
- 在CPU访问高速缓存时,需要通过一些协议,保证缓存一致性(
Cache Coherency
)。
- 代码的乱序执行
- 为了充分利用CPU中的运算单元,可以将输入的代码乱序执行。
- CPU会在运算结束后将乱序执行的结果重组,保证乱序执行的结果与顺序执行的结果一致。。
③ Java的内存模型(JMM)
- Java内存模型(Java Memory Model,JMM)可以屏蔽各种硬件和操作系统的内存访问差异,使得Java程序在不同平台上都能达到一致的内存访问效果。
- 传统的编程语言,比如C和C++,直接使用硬件和操作系统的内存模型。当程序的运行平台发生变化时,可能需要重新编写或编译代码。
- 在JDK1.5发布以后,Java的内存模型已经成熟和完善起来了。
④ 主内存与工作内存
- Java内存模型:
- 所有的变量都存储在主内存(
Main Memory
)中,变量包括实例字段、静态字段、构成数组对象的元素,但不包括局部变量和方法参数, - 每个线程都有自己的工作内存(
Working Memory
),对应计算机的高速缓存或者寄存器。工作内存中保存了被线程使用到的变量的主内存副本拷贝。 - 线程对变量所有操作都只能在工作内存中进行,不能直接读写主内存。
- 每个线程无法直接访问其他线程的工作内存,因此线程间变量值的传递需要依靠主内存来完成
⑤ 内存间的交互操作
- 如何将变量从主内存拷贝到工作内存,以及如何从工作内存同步回主内存,Java内存模型规定了主内存和工作内存之间的8种基本交互操作。
- lock(锁定): 作用于主内存,
将主内存中的变量标识为一个线程独占状态
。 - unlock(解锁): 作用于主内存,
将主内存中被锁定的变量解锁
。 - read(读取): 作用于主内存,
将主内存中的变量值传递到工作内存
,以便随后的load操作使用。 - laod(载入): 作用于工作内存,
将read操作从主内存中获取到的变量值放入工作内存的变量副本中
。 - use(使用): 作用于工作内存,
将工作内存中的变量值传递给执行引擎
。每当JVM遇到一条使用变量的字节码指令时,执行此操作。 - assign(赋值): 作用于工作内存,
执行引擎将接收到的值赋给工作内存中的变量
。每当JVM遇到一条给变量赋值的字节码指令时,执行此操作。 - store(存储): 作用于工作内存,
将工作内存中的变量值同步回主内存
,以便随后的write操作使用。 - write(写入): 作用于主内存,
将store操作从工作内存中获取到的变量值放入主内存的变量中
。
- 内存间8中基本交互操作的规则:
- 如果要把一个变量从主内存拷贝到工作内存,必须顺序执行
read和load操作
;如果要把一个变量从工作内存同步回主内存,必须顺序执行store和write操作
。两对操作必须成对、按顺序执行,可以不连续执行:read a
、read b
、load b
、load a
。 - 不允许一个线程丢弃它的最近的assign操作,即变量在工作内存中改变了之后必须同步回主内存。
- 不允许一个线程无原因的(即没有发生过任何assign操作)把数据从工作内存同步回主内存,。
- 一个新的变量只能在主内存中诞生,不允许在工作内存中使用一个未被初始化的变量。即对一个变量实施
use、store操作
之前,必须先执行过了assign、load操作
。 - 一个变量在同一时刻只允许一个线程对其执行
lock操作
,lock操作
可以被同一线程执行多次。之后需要执行多次unlock操作
,变量才会被解锁。 - 一个变量事先没被
lock操作
锁住,不允许执行unlock操作
,也不允许unlock
一个被其他线程锁住的变量。 - 对一个变量执行unlock操作前,必须先执行store、write操作把变量同步回主内存中。
- 注意: 这些规则,以及后面的关于
volatile
类型的变量、long和double
类型的变量的规则严谨复杂,都可以通过先行发生原则(happens-before
原则)进行等效判断。
2. 特定类型变量的特殊规则
① volatile类型变量的特殊规则
volatile变量两个特性:
- 定义为
volatile
类型的变量,具有两个特性:
- 保证变量在线程之间的可见性,即一个线程修改变量的值后,其他线程可以立即读取到修改后的值。
- 禁止指令重排序优化,可以保证变量赋值操作的执行顺序与程序代码中的顺序一致,避免指令重排序干扰程序的并发性。
对于volatile变量可见性的误解:
- 误解: 只要将变量定义为
volatile
类型,就可以保证实现多线程并发访问的正确性。
volatile
变量只保证每次使用的值是当前最新的,如果在执行运算期间变量的值发生了更改,就会导致多线程并发访问出错。- 下面的程序,将程序计数器定义为
volatile
类型,并不能保证线程安全。
public class VolatileUnsafe {
private static volatile int i = 0;
public void count() {
i++;
}
public static void main(String[] arg) {
Thread[] threads = new Thread[20];
VolatileUnsafe counter = new VolatileUnsafe();
for (int i = 0; i < 20; i++) {
threads[i] = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 10000; i++) {
counter.count();
}
}
});
threads[i].start();
}
while (Thread.activeCount() > 1) {
Thread.yield();
}
System.out.println("多线程并发执行计数后,i的值为" + counter.i);
}
}
3. 原因: i++
编译出的字节码如下,执行getstatic
指令获取i
的值到栈顶,volatile
保证该值此时是最新的;但在执行iconst_1和iadd
指令时,i
的值可能已经被其他线程所更改,栈顶中的i
值就过期了。此时,putstatic
指令同步回主内存的i
值会偏小。
getstatic
iconst_1
iadd
putstatic
- 当运算结果依赖于变量的当前值时,volatile只保证变量的可见性,仍需要通过加锁保证线程安全。
禁止指令重排序优化
- 指令重排序优化是机器级的优化操作,对于普通变量,仅仅保证存在指令依赖时能得出正确的执行结果。比如执行指令
a + 10
,a * 2
,b - 2
时,可以将b - 2
移动到a + 10
之前,或者a + 10
之后、a * 2
之前。 - 但是,如果存在多线程的并发执行,指令重排序回干扰程序的运行结果:
// 初始化设置
List<String> configs = new ArrayList<>();
// 一定要将initialized定义为volatile
volatile boolean initialized = false;
// 线程A完成配置的初始化,将initialized设置为true
processConfig(config, configs);
initialized=true;
// 线程B循环等待配置初始化,如果初始化完成do something
while (!initialized){
sleep();
}
// 使用线程A初始化好的配置信息,完成某些工作
doSomething();
- 如果不将
initialized
定义为volatile
类型,就可能由于指令冲重排序,导致线程A中的initialized=true
提前执行。 - 这时线程B发现
initialized
发生改变后,使用的配置信息可能并未初始化,导致想要通过配置信息完成的某些任务执行失败。 - 因此必须将
initialized
定义为volatile
类型,避免指令重排序干扰程序的并发执行。
- DCL(双重校验锁)的单例模式:
public class Singleton {
private volatile static Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
- 为
instance
添加volatile
关键字后,instance = new Singleton()
编译后的字节码指令,会在赋值操作之后添加一条lock操作。 - 该
lock操作
相当于一个内存屏障(Memory Barrier
或Memory Fence
),指令重排序时,不能把后面的指令重排序到内存屏障之前的位置。 - 两次null判断,第一次是普通的null判断与懒汉模式一致;第二次是在同步块中进行null判断,避免多线程同时创建实例对象。
volatile的选择
volatile
变量读操作与普通变量几乎没什么区别,写操作可能会慢一点。因为他要插入内存屏障指令保证处理器不发生乱序执行。- 大多数场景下,
volatile
的总开销比锁低。如果volatile
的语义能满足使用场景的需求,就选择使用volatile
。
volatile需要满足的规则
- 个人总结: 为了实现
volatile
变量的在线程之间的可见性,以及禁止指令重排序,use
或者assign
操作volatile
变量时,必须遵循的规则。
- 每次对
volatile
变量执行use操作
时,都需要先执行load操作
,以保证volatile
变量的值是最新的。
线程T对变量V执行的前一个操作是load操作
,线程T才能对变量V执行use操作
;线程T对变量V执行的后一个操作是use操作
,线程T才能对变量V执行load操作
。 - 每次对
volatile
变量执行assign操作
时,都要接着执行store操作,以保证更新的值对其他线程可见。
线程T对变量V执行的前一个操作时assign操作
时,线程T才能对变量V执行store操作
;线程T对变量V执行的后一个操作是store操作
时,线程T才能对变量V执行assign操作
。 - 同一个线程对多个
volatile
变量的use或assign操作
的执行顺序,应该与程序代码中的顺序一致。
线程T对变量V的use或assign操作(统称为动作A),与之关联的是load或store操作(统称为动作B),与动作B关联的是read或write操作(统称为动作C);线程T对变量W执行的use或assign操作(统称为动作M),与之关联的是load或者store操作(统称为动作N),与动作N关联的是read或write操作(统称为动作O)。如果动作A
先于动作M
,则动作C
先于动作O
。
② long或double类型变量的特殊规则
- long和double的非原子协定: 对64 bit的
long
或double
类型的读写操作,允许划分成两次32 bit的操作来执行。即可以不保证64 bit的long
或double
类型变量的read
、load
、store
、write
,这4个操作的原子性。 - 如果不保证
long
或double
类型变量read
、load
、store
、write
操作的原子性,可能会读取到半个变量的数值。 - 商用的JVM保证
long
或double
类型变量read
、load
、store
、write
操作的原子性,因此在编写代码时无需将long
或double
类型变量专门声明为volatile
。
2. JMM的三大特性
- JMM的相关操作和规则都是围绕原子性、可见性、有序性,这三大特性建立的。
① 原子性
- JMM保证了
read
、load
、use
、assign
、store
、write
操作的原子性,对基本数据类型的读写操作是原子性的(除了long
和double
的非原子协定)。 - 注意: 这种原子性是指单个操作的原子性,并非指珍格格操作序列的原子性。
- 例如,下图中的两个线程虽然都对
cnt
变量执行了自增操作,但是最后主内存中cnt
的值仍然为1,并非为2。 - 这也是为什么直接使用
int
变量作为计数器,多线程并发计数会导致最后的结果出错的原因。 - 可以使用循环的CAS操作,
AtomicInteger
作为计数变量,可以实现线程安全的计数器。(循环执行compareAndSet()
或者直接执行incrementAndGet()
)
- 除了使用原子类保证操作的原子性,JMM还提供了
lock
和unlock
操作来满足原子性需求。
lock
和unlock
操作,对应JVM中的monitorenter
和monitorexit
指令。monitorenter
和monitorexit
指令是进入和退出同步块执行的指令,即对应synchronized
关键字。- 因此,在同步块之间的操作也具备原子性,可以使用
synchronize
的关键字保证操作的原子性。
- 总结: 原子性的保证,使用原子类、synchronized关键字。
- 通过
synchronized
关键字实现线程安全的计数器。
public class SysCounter {
private static int i = 0;
private static final int THREAD_COUNT=200;
public synchronized void count() {
i++;
}
public static void main(String[] args) {
....// 调用过程省略
}
}
② 可见性
- 可见性: 一个线程对变量的修改,其他线程可以立即获取到最新的值。
- 可见性的实现: 在JMM中,可见性的实现依靠将变量修改后立即同步回主内存,使用时先从主内存刷新变量值。
- 可见性的三种实现方式:
- volatile: 执行
use
操作前,必须执行load
操作,保证变量的值不过期;执行assign
操作后,必须执行store
操作,保证变量的更改对其他线程可见。 - synchronized:
synchronized
关键对应的unlock
操作,在执行前必须将变量的值同步回主内存。 - final: 被
final
关键字修饰的变量,一旦完成初始化并没有发生this逃逸(其他线程通过this引用访问到初始化了一半的对象),那么其他线程就能看见final
变量的值。
③ 有序性
- 有序性的含义:
- 在本线程内观察, 所有的操作都是有序的,即线程内表现为串行的语义。
- 在一个线程中观察另一个线程,所有的操作都是无序的。即指令重排序现象和工作内存与主内存之间的同步延迟现象。
- 有序性的两种实现方式:
- volatile:
volatile
通过添加内存屏障禁止指令重排序。 - synchronized: 由于synchronize的关键字规定,一个变量在同一时刻只允许一个线程对其进行lock操作,这就使得持有同一个锁的两个同步块只能串行进入。
④ 不要滥用synchronized
synchronized
关键字可以保证原子性、可见性、有序性,看起来很万能。- 但越是万能,对性能的影响越大,不要滥用
synchronized
关键字。