Java 内存模型(Java Memory Model)
- JMM 即 Java Memory Model,它定义了主存、工作内存抽象概念,底层对应着 CPU 寄存器、缓存、硬件内存、CPU 指令优化等
- JMM主要体现在以下几个方面
原子性(多写多读):保证指令不会受到线程上下文切换的影响
可见性(一写多读):保证指令不会受到CPU缓存的影响
有序性:保证指令不会受到CPU指令并行优化的影响
原子性问题—得不到预期值
public class AtomQuestion {
static int counter = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 5000; i++)
// 临界区
{
counter++;
}
}, "t1");
Thread t2 = new Thread(() -> {
for (int i = 0; i < 5000; i++)
// 临界区
{
counter--;
}
}, "t2");
t1.start();
t2.start();
t1.join();
t2.join();
MyTool.printTimeAndThread("计算后的初始化值为:" + counter);
}
}
问题分析:
- 由于counter–与counter++操作在不同的线程中执行且操作的是同一个变量,所以这两个操作为临界区,那么在执行就会发生竞态条件
- 在发生竞态条件时,有没有对其加锁,所以会导致存、取数据时发生被其他线程修改,自己又无法感知的情况
- 这样执行完代码,肯定拿不到预期值0
可见性问题—退不出的循环
public class VisibilityQuestion {
static boolean flag = true;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
while (flag) {
System.out.println();
}
}, "t1");
t1.start();
TimeUnit.SECONDS.sleep(1);
// 线程t不会如预想的停下来
flag = false;
}
}
问题分析:
- .初始状态, t1线程刚开始从主内存读取了 run 的值到工作内存
- 由于 t 线程要频繁从主内存中读取 run 的值,JIT 编译器会将 run 的值缓存至自己工作内存中的高速缓存中,减少对主存中 run 的访问,提高效率
- 1 秒之后,main 线程修改了 run 的值,并同步至主存,而 t 是从自己工作内存中的高速缓存中读取这个变量的值,结果永远是旧值
有序性问题—不可预见的结果
public class OrderlyQuestion {
int num = 0;
boolean ready = false;
// 线程1 执行此方法
public void actor1(Object r) {
if(ready) {
r = num + num;
} else {
r = 1;
}
}
// 线程2 执行此方法
public void actor2(Object r) {
num = 2;
ready = true;
}
}
结果集:
- 线程1 先执行,这时 ready = false,所以进入 else 分支结果为 1
- 线程2 先执行 num = 2,但没来得及执行 ready = true,线程1 执行,还是进入 else 分支,结果为1
- 线程2 执行到 ready = true,线程1 执行,这回进入 if 分支,结果为 4(因为 num 已经执行过了)
- 【不是预期的结果】线程2 执行 ready = true,切换到线程1,进入 if 分支,相加为 0,再切回线程2 执行 num = 2(这种现象叫做指令重排,是JIT编译器在运行时的一些优化)
指令重排
- JVM 会在不影响正确性的前提下,可以调整语句的执行顺序,如下面例子
// 可以重排的例子
int a = 10; // 指令1
int b = 20; // 指令2
System.out.println( a + b );
// 不能重排的例子
int a = 10; // 指令1
int b = a - 5; // 指令2
- 指令重排序优化
- 现代处理器会设计为一个时钟周期完成一条执行时间最长的 CPU 指令。为什么这么做呢?可以想到指令还可以再划分成一个个更小的阶段,例如,每条指令都可以分为: 取指令 - 指令译码 - 执行指令 - 内存访问 - 数据写回 这 5 个阶段
- 在不改变程序结果的前提下,这些指令的各个阶段可以通过重排序和组合来实现指令级并行,这一技术在 80’s 中叶到 90’s 中叶占据了计算架构的重要地位
- 支持流水线的处理器
- 现代 CPU 支持多级指令流水线,例如支持同时执行 取指令 - 指令译码 - 执行指令 - 内存访问 - 数据写回 的处理器,就可以称之为五级指令流水线。这时 CPU 可以在一个时钟周期内,同时运行五条指令的不同阶段(相当于一条执行时间最长的复杂指令),IPC = 1,本质上,流水线技术并不能缩短单条指令的执行时间,但它变相地提高了指令地吞吐率。
解决有序性、可见性问题我们可以使用关键字 【volatile】,原子性的解决方案我们可以使用关键字 volatile+ 原子类的CAS操作(乐观锁机制) 或者使用锁(悲观锁机制)
内存屏障(Memory Fence)
-
保证可见性
写屏障(sfence)保证在该屏障之前的,对共享变量的改动,都同步到主存当中
读屏障(lfence)保证在该屏障之后,对共享变量的读取,加载的是主存中最新数据 -
保证有序性
写屏障(sfence)会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后
读屏障(lfence)会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前 -
不能保证原子性
volatile 原理
volatile 的底层实现原理是内存屏障(Memory Fence)
- 对 volatile 变量的写指令后会加入写屏障
- 对 volatile 变量的读指令前会加入读屏障
如何保证可见性
写屏障(sfence)保证在该屏障之前的,对共享变量的改动,都同步到主存当中
public void actor2(I_Result r) {
num = 2;
ready = true; // ready 是 volatile 赋值带写屏障
// 写屏障
}
读屏障(lfence)保证在该屏障之后,对共享变量的读取,加载的是主存中最新数据
public void actor1(I_Result r) {
// 读屏障
// ready 是 volatile 读取值带读屏障
if(ready) {
r.r1 = num + num;
} else {
r.r1 = 1;
}
}
如何保证有序性
写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后
public void actor2(I_Result r) {
num = 2;
ready = true; // ready 是 volatile 赋值带写屏障
// 写屏障
}
读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前
public void actor1(I_Result r) {
// 读屏障
// ready 是 volatile 读取值带读屏障
if(ready) {
r.r1 = num + num;
} else {
r.r1 = 1;
}
}
如何保证原子性
可以使用原子类中的cas + volatile关键字解决多线程并发问题。
转账问题
- 接口
package com.yanxb.threadnolock;
import java.util.ArrayList;
import java.util.List;
interface Account {
/**
* 获取余额
*/
Integer getBalance();
void withdraw(Integer amount);
/**
* 该方法会启动1000个线程,每个线程扣除10元,若初始值为10000则余额刚好为0
* */
static void demo(Account account) {
List<Thread> threads = new ArrayList<>();
long start = System.nanoTime();
for (int i = 0; i < 1000; i++) {
threads.add(new Thread(() -> {
account.withdraw(10);
}));
}
threads.forEach(Thread::start);
threads.forEach(thread -> {
try {
thread.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
});
long end = System.nanoTime();
System.out.println("余额为:" + account.getBalance() + " 花费的时间为:" + (end - start)/1000000 + "ms");
}
}
- 不安全实现类
package com.yanxb.threadnolock;
public class ThreadUnsafeImpl implements Account {
private Integer balance;
public ThreadUnsafeImpl(Integer balance) {
this.balance = balance;
}
@Override
public Integer getBalance() {
return balance;
}
@Override
public void withdraw(Integer amount) {
balance -= amount;
}
}
- 解决方式一:加锁使用Synchronized关键字
package com.yanxb.threadnolock;
public class ThreadSafeImplBySynchronized implements Account{
private Integer balance;
public ThreadSafeImplBySynchronized(Integer balance) {
this.balance = balance;
}
@Override
public synchronized Integer getBalance() {
return balance;
}
@Override
public synchronized void withdraw(Integer amount) {
balance -= amount;
}
}
解决方式二:使用原子类的cas操作 + volatile
package com.yanxb.threadnolock;
import java.util.concurrent.atomic.AtomicInteger;
public class ThreadSafeByNoLock implements Account{
private volatile AtomicInteger balance;
public ThreadSafeByNoLock(Integer balance) {
this.balance = new AtomicInteger(balance);
}
@Override
public Integer getBalance() {
return balance.get();
}
@Override
public void withdraw(Integer amount) {
// 需要不断尝试,直到成功为止
while (true){
// 获取旧值
int prev = balance.get();
// 设置要转换的值
int next = prev - amount;
/*compareAndSet 正是做这个检查,在 set 前,先比较 prev 与当前值不一致了,next 作废,返回 false 表示失败
比如,别的线程已经做了减法,当前值已经被减成了990那么本线程的这次990就作废了,进入while下次循环重试一致,以next设置为新值,返回true表示成功
*/
if (balance.compareAndSet(prev,next)) {
break;
}
}
}
}
- 测试类
public static void main(String[] args) {
// Account.demo(new ThreadUnsafeImpl(10000));
// Account.demo(new ThreadSafeImplBySynchronized(10000));
Account.demo(new ThreadSafeByNoLock(10000));
}
CAS
概述
- 上述解决转账问题方式二中的compareAndSet操作,它的简称就是 CAS (也有 Compare And Swap 的说法),它是原子操作
- cas是原子操作的原理是底层使用了指令 lock cmpxchg,在X86架构下,无论是单核还是多核都可以保证cas操作都是原理的
- 单核情况下就不解释里,在多核情况下,无论哪个核在执行带有lock指令时,CPU都会让总线锁住,当这个核将这条带有lock指令结束后,在开启总线。这个过程是不会被任务调度器打断的,从而保证了cas操作的原子性。
特点
- 原子类cas + volatile可以实现无锁并发,使用与多核、线程数少的场景
- cas的实现是基于乐观锁思想:其他线程修改了值,重试,没有则操作成功。而synchronized是基于悲观锁的思想:当我修改时,其他线程都不可以对共享变量修改
- cas体现的是无锁并发、无阻塞并发。因为不会使用synchronized,所以不会导致线程进入阻塞,也不会导致上下文切换,从而提高效率。但如果竞争激烈时,重试次数必然会频繁发生,从而导致效率下降。所以在使用时需要权衡利弊
与volatile关系
- 原子类在cas操作时,必须保证共享变量的可见性,所以就需要用volatile关键字修饰。这也是原子类中存储值的变量使用volatile关键字修饰的原因。
- 线程操作 volatile 变量都是直接操作主存,即一个线程对 volatile 变量的修改,对另一个线程可见
- 但volatile 仅仅保证了共享变量的可见性,让其它线程能够看到最新值,并不能解决指令交错问题
原子类详解:https://blog.csdn.net/silence_yb/article/details/124163132