Java内存模型之JMM
为什么需要JMM
计算机存储结构:从本地磁盘到主存到CPU缓存,也就是从硬盘到内存,到CPU。一般对应的程序的操作就是从数据库查数据到内存然后到CPU进行计算。
CPU和物理主内存的速度不一致,所以设置多级缓存,CPU运行时并不会直接操作内存,当CPU读取数据时,先把内存里边的数据读到缓存,然后再从缓存中读取;当CPU写出数据时,先把数据写到缓存中,然后缓存再写到内存中。
JVM规范中定义了一种Java内存模型 (java Memory Model,简称JMM)来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。
JMM的作用:
-
通过JMM来实现线程和主内存之间的抽象关系。
-
屏蔽各个硬件平台和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。
什么是JMM
JMM(Java内存模型Java Memory Model,简称JMM)本身是一种抽象的概念并不真实存在它仅仅描述的是一组定或规范,通过这组规范定义了程序中(尤其是多线程)各个变量的读写访问方式,并决定一个线程对共享变量的写入何时可用,以及如何变成对另一个线程可见,关键技术点都是围绕多线程的原子性、可见性和有序性展开的。
JMM规范下的三大特性
可见性
可见性是一种即时通知机制,当一个线程修改了某一个共享变量的值,其他线程能够立即知道该变更,
JMM规定了所有的变量都存储在主内存中。
系统主内存共享变量数据修改时被写入的时间是不确定的,多线程并发下很可能出现"脏读",所以每个线程都有自己的工作内存(线程私有),线程自己的工作内存中保存了该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(读取,赋值等)都必需在线程自己的工作内存中进行,而不能够直接读写主内存中的变量。不同线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。
线程读取变量过程:
由于JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(栈空间),工作内存是每个线程的私有数据区域,而Java内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问,但线程对变量的操作(读取賦值等)必须在工作内存中进行。首先要将变量从主内存拷贝到线程自己的工作内存空问,然后对变量进行操作,操作完成后再将变量写回主内存,不能直接操作主内存中的变量,各个线程的工作内存中存储着主内存中的变量副本拷贝,因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成,其简要访问过程如下图:
线程和主内存之间的关系:
- 线程之间的共享变量存储在主内存中(从硬件角度来说就是内存条)
- 每个线程都有一个私有的本地工作内存,木地工作内存中存储了该线程用来读/写共享变量的副本(从硬件角度来说就是CPU的缓存,比如寄存器、L1、L2、L3缓存等)
线程脏读:
原子性
指一个操作是不可打断的,即多线程环境下,操作不能被其他线程干扰
有序性
重排序:
对于一个线程的执行代码而言,我们总是习惯性认为代码的执行总是从上到下,有序执行。但为了提升性能,编译器和处理器通常会对指令序列进行重新排序。Java规范规定JVM线程内部维持顺序化语义,即只要程序的最终结果与它顺序化执行的结果相等,那么指令的执行顺序可以与代码顺序不一致,此过程叫指令的重排序。但是处理器在进行重排序时必须要考虑指令之间的数据依赖性。
重排序的优缺点:
优:JVM能根据处理器特性(CPU多级缓存系统、多核处理器等)适当的对机器指令进行重排序,使机器指令能更符合CPU的执行特性,最大限度的发挥机器性能。
缺: 但是,指令重排可以保证串行语义一致,但没有义务保证多线程间的语义也一致(即可能产生"脏读")。简单说,两行以上不相干的代码在执行的时候有可能先执行的不是第一条,不见得是从上到下顺序执行,执行顺序会被优化。
从源码到最终执行示例图:
单线程环境里面可以保证程序最终执行结果与顺序执行的结果一致。但是,在多线程环境中线程交替执行,由于编译器优化重排的存在,两个线程中使用的变量能否保证一致性是无法确定的,结果无法预测。
happens-before
Java 语言里面,Happens-Before 的语义本质上是一种可见性,A Happens-Before B 意味着 A发生过的事情对B来说是可见的,无论 A事件和B事件是否发生在同一个线程里。
JMM的设计分为两部分:
一部分是面向我们程序员提供的,也就是happens-before规则,它通俗易懂的向我们程序员阐述了一个强内存模型,我们只要理解happens-before规则,就可以编写并发安全的程序了。
另一部分是针对JVM实现的,为了尽可能少的对编译器和处理器做约束从而提高性能,JMM在不影响程序执行结果的前提下对其不做要求,即允许优化重排序。
我们写代码时,只需要关注前者就好了,也就是理解happens before规则即可,其它繁杂的内容有JMM规范结合操作系统给我们搞定,我们只写好代码即可。
8条规则:
- 次序规则:在同一个线程内,按照代码顺序,写在前面的操作先行发生于写在后面的操作。
- 锁定规则: 一个unlock操作先行发生于后面(这里的“后面”是指时间上的先后)对同一个锁的lock操作。
-
volatitle变量规则:对一个volatile变量的写操作先行发生于后面对这个变量的读操作,前面的写对后面的读是可见的,这里的“后面”同样是指时间上的先后。
-
传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C。
-
线程启动规则:Thread对象的start()方法先行发生于此线程的每一个动作。
-
线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生;可以通过Thread.interrupted()检测到是否发生中断,也就是说你要先调用interrupt()方法设置过中断标志位,我才能检测到中断发送。
-
线程终止规则:线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过isAlive()等手段检测线程是否己经终止执行。
-
对象终结规则:一个对象的初始化完成(构造函数执行结東)先行发生于它的finalize()方法的开始。
案例:
问:假设存在线程A和B,线程A先(时间上的先后)调用了setValue(),然后线程B调用了同一个对象的getValue),那么线程B收到的返回值是什么?
答:不一定
原因:
我们就这段简单的代码一次分析happens-before的规则(规则5、6、7、8可以忽略,因为他们和这段代码毫无关系):
- 由于两个方法是由不同的线程调用,不在同一个线程中,所以肯定不满足规则1(次序规则);
- 两个方法都没有使用锁,所以不满足规则2(锁定规则);
- 变量不是用volatile修饰的,所以不满足规则3(volatile变量规则)
- 规则4(传递规则)肯定不满足;
所以我们无法通过happens-before 原则推导出线程A happens-before线程B,虽然可以确认在时间上线程A优先于线程B执行,但就是无法确认线程B获得的结果是什么,所以这段代码不是线程安全的。
那么怎么修复这段代码呢?
修复1:把getter/setter方法都定义为synchronized方法
修复2:把value定义为volatile变量,由于setter方法对value的修改不依赖value的原值,满足volatile关键字使用场景
volatile与JMM
**volatile变量的2大特点:**可见性、有序性
- 可见性
当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值立即刷新回主内存中。
当读一个volavle变量时,JMM会把该线程对应的本地内存设置为无效,重新回到主内存中读取最新共享变量。
所以volatile的写内存语义是写完后立即刷新回主内存并及时发出通知,大家可以去主内存拿最新版,前面的修改对后面所有线程可见。
- 有序性
禁止编译器指令重排
volatile凭什么可以保证可见性和有序性???
内存屏障(也称内存栅栏,屏障指令等,是一类同步屏障指令,是CPU或编译器在对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作),避免代码重排序。内存屏障其实就是一种JVM指令,Java内存模型的重排规则会要求Java编译器在生成JVM指令时插入特定的内存屏障指令,通过这些内存屏障指令,volatile实现了Java内存模型中的可见性和有序性(禁重排),但volatile无法保证原子性。
内存屏障之前的所有写操作都要回写到主内存,
内存屏障之后的所有读操作都能获得内存屏障之前的所有写操作的最新结果(实现了可见性)。
因此重排序时,不允许把内存屏障之后的指令重排序到内存屏障之前。一句话:对一个volatile变量的写,先行发生于任意后续对这个volatile变量的读,也叫写后读。
内存屏障粗分为2种:
读屏障:在读指令之前插入读屏障,让工作内存或CPU高速级存当中的缓存数据失效,重新回到主内存中获取最新数据
写屏障:在写指令之后插入写屏障,强制把写缓冲区的数据刷回到主内存中
内存屏障细分为4种:
happens-before之volatile变量规则:
volatile之可见性案例
- 不使用volatile
import java.util.concurrent.TimeUnit;
public class JUC08 {
static boolean flag=true;
public static void main(String[] args) {
new Thread(()->{
System.out.println(Thread.currentThread().getName()+"\t -----come in");
while(flag){}
System.out.println(Thread.currentThread().getName()+"\t -----flag被设置为false,程序停止");
},"t1").start();
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
flag=false;
System.out.println(Thread.currentThread().getName()+"\t -----修改完成:"+flag);
}
}
线程t1中为何看不到被主线程main修改为false的flag的值?
-
可能主线程修改了flag之后没有将其刷新到主内存,所以1线程看不到。
-
可能主线程将flag刷新到了主内存,但是t1一直读取的是自己工作内存中flag的值,没有去主内存中更新获取flag最新的值。
- 使用volatile
import java.util.concurrent.TimeUnit;
public class JUC08 {
static volatile boolean flag=true;
public static void main(String[] args) {
new Thread(()->{
System.out.println(Thread.currentThread().getName()+"\t -----come in");
while(flag){}
System.out.println(Thread.currentThread().getName()+"\t -----flag被设置为false,程序停止");
},"t1").start();
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
flag=false;
System.out.println(Thread.currentThread().getName()+"\t -----修改完成:"+flag);
}
}
volatile之原子性案例
- volatile不能保证原子性
import java.util.concurrent.TimeUnit;
public class JUC08 {
public static void main(String[] args) {
MyNumber myNumber = new MyNumber();
for (int i = 1; i <= 10; i++) {
new Thread(()->{
for (int j = 1; j <= 1000; j++) {
myNumber.addPlusPlus();
}
},String.valueOf(i)).start();
}
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(myNumber.number);
}
}
class MyNumber{
volatile int number;
public void addPlusPlus(){
number++;
}
}
- 使用synchronized保证原子性
import java.util.concurrent.TimeUnit;
public class JUC08 {
public static void main(String[] args) {
MyNumber myNumber = new MyNumber();
for (int i = 1; i <= 10; i++) {
new Thread(()->{
for (int j = 1; j <= 1000; j++) {
myNumber.addPlusPlus();
}
},String.valueOf(i)).start();
}
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(myNumber.number);
}
}
class MyNumber{
int number;
public synchronized void addPlusPlus(){
number++;
}
}
volatile不能保证原子性的原因:
假设A线程中i=1时,读取到number为5,B线程i=1时,也读取到number为5,A线程对number进行++,A线程的工作内存中number=6,此时B线程刚好也执行完number++,此时B线程的工作内存中number=6,假设B线程正要将number=6写入主内存时,A线程先一步将number=6写入主内存,此时主内存中number=6,B线程感知到主内存中number发生了改变,B线程将会把自己工作内存中的number=6丢掉,下一次读取主内存中的number时会将从主内存中读取最新的值,B线程执行myNumber.addPlusPlus();
下面的代码,而myNumber.addPlusPlus();
下面没有代码,所以B线程进入下一次循环,此时B线程i=2,从主内存中读取最新的number=6,但是B线程i=1时的并没有有效的将number++,所以最终的number一定会小于10000。
详见《深入理解Java虚拟机》12.3.3节
注意:
- volatile变量不适合参与到依赖当前值的运算中,如i=i+1;i++之类的,volatile通常用做保存某个状态的boolean值或int值。
- 由于volatile变量只能保证可见性,所以我们仍然要通过加锁(使用synchronized、 java.util.concurrent中的锁或原子类)来保证原子性。
volatile之禁重排案例
若存在数据依赖关系则禁止重排序===>重排序发生,会导致程序运行结果不同。
数据依赖性:若两个操作访问同一变量,且这两个操作中有一个为写操作,此时两操作间就存在数据依赖性。
编译器和处理器在重排序时,会遵守数据依赖性,不会改变存在依赖关系的两个操作的执行,但不同处理器和不同线程之间的数据性不会被编译器和处理器考虑,其只会作用于单处理器和单线程环境,下面三种情况,只要重排序 两个操作的执行顺序,程序的执行结果就会被改变
案例:
volatile使用场景
- 作为一个布尔状态标志,用于指示发生了一个重要的一次性事件,例如完成初始化或任务结束
- 当读远多于写,结合使用內部锁和volatile 变量来减少同步的开销
- DCL双端锁的发布
总结
添加volatile关键字后,JVM为什么会加入内存屏障?
CAS
CAS原理
定义:
CAS是compare and swap的缩写,中文翻译成比较并交换,实现并发算法时常用到的一种技术。它包含三个操作数:内存位置、预期原值及更新值。
执行CAS操作的时候,将内存位置的值与预期原值比较:如果相匹配,那么处理器会自动将该位置值更新为新值,如果不匹配,处理器不做任何操作,多个线程同时执行CAS操作只有一个会成功。
CAS时一种系统原语,原语属于操作系统用语范畴,是由若干指令组成的,用于完成某个功能的一个过程,并且原语的执行必须是连续的,在执行过程中不允许被中断,也就是说CAS是一条CPU的原子指令,不会造成所谓的数据不一致问题。
原子类:
java.util.concurrent.atomic包下的所有类,原子类是CAS思想的落地。
实例:
CAS有3个操作数:V、A、B,其中V:要修改属性所在的内存地址,A:旧的预期值,B:修改后的新值。当且仅当旧的A和V对应的属性值相同时,才将V对应的属性值修改为B,否则什么都不做或重试。它重试的这种行为称为----自旋!!
CAS硬件级别的保证原子性:
CAS是JDK提供的非阻塞原子性操作,它通过硬件保证了比较-更新的原子性。它是非阻塞的且自身具有原子性,也就是说这玩意效率更高且通过硬件保证,说明这玩意更可靠。CAS是一条CPU的原子指令(cmpxchg指令),不会造成所谓的数据不一致问题,Unsafe提供的CAS方法(如compareAndSwapXXX)底层实现即为CPU指令cmpxchg。执行cmpxchg指令的时候,会判断当前系统是否为多核系统,如果是就给总线加锁,只有一个线程会对总线加锁成功,加锁成功之后会执行cas操作,也就是说CAS的原子性实际上是CPU实现独占的,
比起用synchronized重量级锁,这里的排他时间要短很多,所以在多线程情况下性能会比较好。
案例:
import java.util.concurrent.atomic.AtomicInteger;
public class JUC08 {
public static void main(String[] args) {
AtomicInteger atomicInteger = new AtomicInteger(5);
System.out.println(atomicInteger.compareAndSet(5, 2022)+"\t"+atomicInteger.get());
System.out.println(atomicInteger.compareAndSet(5, 2023)+"\t"+atomicInteger.get());
}
}
源码:
compareAndSet()方法的源代码:
UnSafe类
UnSafe类是CAS的核心类,由于Java方法无法直接访问底层系统,需要通过本地 (native)方法来访问,Unsafe相当于一个后门,基于该类可以直接操作特定内存的数据。Unsafe类存在于sun.misc包中,其内部方法操作可以像C的指针一样直接操作内存,Java中CAS操作的执行依赖于Unsafe类的方法。
注意Unsafe 类中的所有方法都是native修饰的,也就是说Unsafe类中的方法都直接调用操作系统底层资源执行相应任务。
变量valueOfset,表示该变量值在内存中的偏移地址,因为Unsafe就是根据内存偏移地址获取数据的。
AtomicInteger的incrementAndGet方法:
实例:
原子引用(AtomicReference)
import lombok.AllArgsConstructor;
import lombok.Data;
import java.util.concurrent.atomic.AtomicReference;
public class JUC08 {
public static void main(String[] args) {
User z3 = new User("z3", 22);
User li4 = new User("li4", 28);
User w5 = new User("w5", 33);
AtomicReference<User> userAtomicReference = new AtomicReference<>(z3);
System.out.println(userAtomicReference.compareAndSet(z3, li4)+"\t"+userAtomicReference.get().toString());
System.out.println(userAtomicReference.compareAndSet(z3, w5)+"\t"+userAtomicReference.get().toString());
}
}
@Data
@AllArgsConstructor
class User{
String name;
Integer age;
}
自旋锁
CAS 是实现自旋锁的基础,CAS 利用 CPU 指令保证了操作的原子性,以达到锁的效果,至于自旋呢,看字面意思也很明白,自己旋转。是指尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁,当线程发现锁被占用时,会不断循环判断锁的状态,直到获取。这样的好处是减少线程上下文切换的消耗,缺点是循环会消耗CPU。
自旋锁的实现:
题目:通过cAS操作完成自旋锁,A线程先进来调用myLock方法自己持有锁5秒钟,B随后进来后发现
当前有线程持有锁,所以只能通过自旋等待,直到A释放锁后B随后抢到。
import lombok.AllArgsConstructor;
import lombok.Data;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
public class JUC08 {
AtomicReference<Thread> atomicReference = new AtomicReference<>();
public void lock(){
Thread thread=Thread.currentThread();
System.out.println(Thread.currentThread().getName()+"\t-------come in");
while (!atomicReference.compareAndSet(null, thread)) {} //自旋等待
}
public void unlock(){
Thread thread = Thread.currentThread();
atomicReference.compareAndSet(thread,null);
System.out.println(Thread.currentThread().getName()+"\t-------task over,unLock...");
}
public static void main(String[] args) {
JUC08 juc08 = new JUC08();
new Thread(()->{
juc08.lock();
try {
TimeUnit.SECONDS.sleep(5);
} catch (InterruptedException e) {
e.printStackTrace();
}
juc08.unlock();
},"A").start();
try {
TimeUnit.MILLISECONDS.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
new Thread(()->{
juc08.lock();
juc08.unlock();
},"B").start();
}
}
CAS缺点
- 循环时间长开销很大
- ABA问题
解决ABA问题: 版本号时间戳原子引用(AtomicStampedReference)
import lombok.AllArgsConstructor;
import lombok.Data;
import java.util.concurrent.atomic.AtomicStampedReference;
public class JUC08 {
public static void main(String[] args) {
Book javaBook = new Book(1,"javaBook");
Book mysqlBook = new Book(2,"mysqlBook");
//第二个参数为初始流水号
AtomicStampedReference<Book> stampedReference = new AtomicStampedReference<>(javaBook, 1);
System.out.println(stampedReference.getReference()+"\t"+stampedReference.getStamp());
System.out.println(stampedReference.compareAndSet(javaBook, mysqlBook, stampedReference.getStamp(), stampedReference.getStamp()+1));
System.out.println(stampedReference.getReference()+"\t"+stampedReference.getStamp());
}
}
@Data
@AllArgsConstructor
class Book{
private Integer id;
private String bookName;
}