内存模型
1. java 内存模型(JMM)
Java内存模型和Java内存结构没关系
JMM 即 Java Memory Model,它定义了主存(共享内存)、工作内存(线程私有)抽象概念,底层对应着 CPU 寄存器、缓存、硬件内存、 CPU 指令优化等。JMM 体现在以下几个方面:
- 原子性: 保证指令不会受到线程上下文切换的影响。
- 可见性:保证指令不会受 cpu 缓存的影响。
- 有序性:保证指令不会受 cpu 指令并行优化的影响。
简单的说,JMM 定义了一套在多线程读写共享数据时(成员变量、数组)时,对数据的可见性、有序 性、和原子性的规则和保障。
1.1 原子性
问题: 两个线程对初始值为 0 的静态变量一个做自增,一个做自减,各做 5000 次,结果不一定是是 0
1.2 从字节码角度分析:
以上的结果可能是正数、负数、零. 因为 Java 中对静态变量的自增,自减并不是原子操作。这些操作可能被CPU交错执行
当i作为类的静态变量,根据字节码指令得,静态变量i++
是通过执行iadd
在操作数栈上自增的,而局部变量的i++
是执行innc
指令在局部变量表槽上执行的
Java的内存模型,静态变量的变化要在主存和线程内存中数据互换,不要与Java内存结构中的堆栈混淆,共享的变量信息放在主存,线程是在工作内存中
单线程下自增和自减的指令不会交错执行,不会有问题
多线程下自增和自减的指会交错执行,因为操作系统的线程模型都是抢先式多任务系统,线程会轮流拿到CPU的使用权,CPU会以时间片为单位将使用权分别交给两个线程
1.3 解决方法
Java内存模型保证原子性: 使用synchronized(同步关键字)
保证同步代码块内的代码执行的原子性
synchronized( 对象 ) {
要作为原子操作代码
}
用 synchronized 解决并发问题:
static int i = 0;
static Object obj = new Object();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int j = 0; j < 5000; j++) {
synchronized (obj) {
i++;
}
}
});
Thread t2 = new Thread(() -> {
for (int j = 0; j < 5000; j++) {
synchronized (obj) {
i--;
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(i);
}
如何理解呢:你可以把 obj 想象成一个房间,线程 t1,t2 想象成两个人。
当线程 t1 执行到 synchronized(obj) 时就好比 t1 进入了这个房间,并反手锁住了门,在门内执行 count++ 代码。
这时候如果 t2 也运行到了 synchronized(obj) 时,它发现门被锁住了,只能在门外等待。
当 t1 执行完synchronized{} 块内的代码,这时候才会解开门上的锁,从 obj 房间出来。t2 线程这时才可以进入 obj 房间,反锁住门,执行它的 count-- 代码。
注意:上例中 t1 和 t2 线程必须用 synchronized 锁住同一个 obj 对象,如果 t1 锁住的是 m1 对 象,t2 锁住的是 m2 对象,就好比两个人分别进入了两个不同的房间,没法起到同步的效果。
2 可见性
2.1 线程运行时退不出循环现象分析:
例子:
static boolean run = true;
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(()->{
while(run){
// ....
}
});
t.start();
Thread.sleep(1000);
run = false; // 线程t不会如预想的停下来
}
首先 t 线程运行,然后过一秒,主线程设置 run 的值为 false,想让 t 线程停止下来,但是 t 线程并没有停!
说明
: main 线程对 run 变量的修改对于 t 线程不可见,导致了 t 线程无法停止
分析 :
- 初始状态, t 线程刚开始从主内存读取了 run 的值到工作内存。
- 因为 t 线程要频繁从主内存中读取 run 的值,JIT 编译器会将 run 的值缓存至自己工作内存中的高 速缓存中,减少对主存中 run 的访问,提高效率
- 1 秒之后,main 线程修改了 run 的值,并同步至主存,而 t 是从自己工作内存中的高速缓存中读 取这个变量的值,结果永远是旧值
2.2 死循环的解决方法
引入volatile
(易变关键字): 只保证可见性,不保证原子性
它可以用来修饰成员变量和静态成员变量,他可以避免线程从自己的工作缓存中查找变量的值,必须到主存中查找它的最新值,线程操作 volatile 变量每次都是直接到主存读取,保证线程每次看到都是最新结果。
public static volatile boolean run = true; // 保证内存之间的可见性
前面例子体现的实际就是可见性,它保证的是在多个线程之间,一个线程对 volatile 变量的修改对另一 个线程可见,不能保证原子性,仅用在一个写线程,多个读线程的情况
上例从字节码理解是这样的:
getstatic run // 线程 t 获取 run true
getstatic run // 线程 t 获取 run true
getstatic run // 线程 t 获取 run true
getstatic run // 线程 t 获取 run true
putstatic run // 线程 main 修改 run 为 false, 仅此一次
getstatic run // 线程 t 获取 run false
比较一下之前我们将线程安全时举的例子:两个线程一个i++ 一个 i-- ,只能保证看到最新值,不能解 决指令交错
注意:
synchronized 代码块既可以保证代码块的原子性,也同时保证代码块内变量的可见性。但缺点是synchronized是属于重量级操作,性能相对更低
如果在前面示例的死循环中加入 System.out.println()
会发现即使变量不加 volatile 修饰符,线程 t 也 能正确看到对 run 变量的修改了,想一想为什么?(因为println()源码中有synchronized代码块加锁,可以保证原子性与可见性,它是 PrintStream 类的方法
3.有序性
同一线程内,指令重排不会影响到结果的正确性;多线程下
多线程下指令重排的问题
int num = 0;
// volatile 修饰的变量,可以禁用指令重排 volatile boolean ready = false; 可以防止变量之前的代码被重排序
boolean ready = false;
// 线程1 执行此方法
public void actor1(I_Result r) {
if(ready) {
r.r1 = num + num;
}
else {
r.r1 = 1;
}
}
// 线程2 执行此方法
// 发生指令重排可能会只给ready赋值,跳过num的赋值,直接跳到线程1
public void actor2(I_Result r) {
num = 2;
ready = true;
}
发生指令重排可能会只给ready赋值,跳过num的赋值,直接跳到线程1
解决指令重排方法 :
给要禁止指令重排的变量添加volatile
修饰符
单例模式的懒汉式虽然是双重锁检测机制,但是也可能会出现重排序的问题,根据字节码指令可以看到单例对象创建的时候,线程1先将对象引用地址复制给静态变量(指令重排),此时静态不为空,然后线程2进来后没来得及进入同步代码块,if判断条件判断对象不为空,线程2直接返回静态变量,但是线程1还没执行完构造方法,线程2拿到的就是不完整构造的对象
解决方法 : 给静态变量加volatile
修饰符
注意
: volatile 修饰要在jdk1.5以上才管用
volatile 原理
volatile 的底层实现原理是内存屏障,Memory Barrier(Memory Fence)
对 volatile 变量的写指令后会加入写屏障
对 volatile 变量的读指令前会加入读屏障
如何保证可见性?
写屏障(sfence)保证在该屏障之前的,对共享变量的改动,都同步到主存当中
而读屏障(lfence)保证在该屏障之后,对共享变量的读取,加载的是主存中最新数据:
如何保证有序性?
写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后
读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前
注意:
volatile 不能解决指令交错
写屏障仅仅是保证之后的读能够读到最新的结果,但不能保证其它线程的读跑到它前面去
而有序性的保证也只是保证了本线程内相关代码不被重排序
4.CAS与原子类
CAS(即Compare And Swap)是与volatile配合使用的技术,体现一种乐观锁的思想,被称作为无锁并发;保护共享变量无需加锁
CAS作用 : 抢锁的时候,对mark word 进行比较并交换,无锁并发,保证原子性
CAS机制原理:
例子 : 多线程对共享变量执行+1操作
首先,通过开启死循环,从主内存读取共享变量赋值给工作内存中的一个旧值变量,在一条线程内旧值+1操作,得到一个结果变量,然后调用CompareAndSwap方法,该方法会尝试把结果赋值给共享变量,同时会先让旧值和共享变量当前值作比较,因为怕写入结果时候有其他线程把共享变量值改成了新值,所以要拿上次读到的旧值和当前共享变量值作比较,若比较结果一致,就可以成功把结果值写入到共享变量中,若不一致,则说明其他线程把共享变量改了,这一次尝试就失败了,方法失败后返回false,就会从新进入while循环,再读取共享变量的最新值重复刚刚的操作.
所以 CAS 采用的是不断尝试的机制直到成功
获取共享变量时,为了要获取最新结果,保证变量的可见性,所以CAS要和volatile结合使用,适用于竞争不激烈,多核CPU的场景,若竞争激烈可能会频繁重试,效率降低;
CAS线程一直跑,没有上下文切换,所以不会有阻塞,低竞争会比synchronized效率高
4.2 乐观锁和悲观锁
- CAS 是基于乐观锁的思想:最乐观的估计,不怕别的线程来修改共享变量,就算改了也没关系, 我吃亏点再重试呗。
- synchronized 是基于悲观锁的思想:最悲观的估计,得防着其它线程来修改共享变量,我上了锁 你们都别想改,我改完了解开锁,你们才有机会。
- 原子操作类:juc(java.util.concurrent)中提供了原子操作类,可以提供线程安全的操作,例如:AtomicInteger、 AtomicBoolean等,它们底层就是采用 CAS 技术 + volatile 来实现的。
5.synchronized的优化
Java HotSpot 虚拟机中,每个对象都有对象头(其中有两个部分包括 class 指针和 Mark Word)。Mark Word 平时存
储这个对象的 哈希码 、 分代年龄 (新生代晋升到老年代会用到),当给对象加synchronized锁时,这些对象头信息就根据情况被替换为 标记位 (加的是什么锁),根据锁类型不同的不同存储、 线程锁记录指
针 、 重量级锁指针 、 线程ID 等内容
5.1 轻量级锁
如果一个对象虽然有多线程访问,但多线程访问的时间是错开的(线程之间没有竞争),那么可以使用轻
量级锁来优化。
5.5 锁的其他优化
读写分离
读操作不用同步,写操作才用同步