Java高并发之JMM(java内存模型、volatile变量、JMM的三大特性)

1. Java内存模型概述
① 常见的并发应用场景
  • 常见的并发应用场景:
  1. 多任务处理: 计算机中,CPU的运算速度和它的存储和通信子系统的速度差异很大。为了充分利用CPU资源,让CPU同时处理多个任务。
  2. 服务器: 服务器可以同时为多个客户端提供服务
② 如何充分利用CPU资源?
  • 高速缓存(Cache)
  1. 为了解决CPU和内存之间的速度差异,避免慢的内存影响快的CPU,在CPU和内存之间加入高速缓存作为缓冲。
  2. 缓存的读写速度几乎接近CPU的运算速度,可以将运算需要的数据从内存复制到缓存中,运算完成后,将运算结果从缓存同步回内存。
  3. 在多CPU的计算机系统中,每个CPU都有自己的高速缓存,同时又共享主内存。如果多个CPU的运算任务都涉及到同一块主内存,则很可能导致缓存中的数据不一致
  4. 在CPU访问高速缓存时,需要通过一些协议,保证缓存一致性Cache Coherency)。
    在这里插入图片描述
  • 代码的乱序执行
  1. 为了充分利用CPU中的运算单元,可以将输入的代码乱序执行。
  2. CPU会在运算结束后将乱序执行的结果重组,保证乱序执行的结果与顺序执行的结果一致。。
③ Java的内存模型(JMM)
  • Java内存模型(Java Memory Model,JMM)可以屏蔽各种硬件和操作系统的内存访问差异,使得Java程序在不同平台上都能达到一致的内存访问效果。
  • 传统的编程语言,比如C和C++,直接使用硬件和操作系统的内存模型。当程序的运行平台发生变化时,可能需要重新编写或编译代码。
  • 在JDK1.5发布以后,Java的内存模型已经成熟和完善起来了。
④ 主内存与工作内存
  • Java内存模型:
  1. 所有的变量都存储在主内存Main Memory)中,变量包括实例字段、静态字段、构成数组对象的元素,但不包括局部变量和方法参数,
  2. 每个线程都有自己的工作内存Working Memory),对应计算机的高速缓存或者寄存器。工作内存中保存了被线程使用到的变量的主内存副本拷贝
  3. 线程对变量所有操作都只能在工作内存中进行,不能直接读写主内存。
  4. 每个线程无法直接访问其他线程的工作内存,因此线程间变量值的传递需要依靠主内存来完成
    在这里插入图片描述
⑤ 内存间的交互操作
  • 如何将变量从主内存拷贝到工作内存,以及如何从工作内存同步回主内存,Java内存模型规定了主内存和工作内存之间的8种基本交互操作。
  1. lock(锁定): 作用于主内存,将主内存中的变量标识为一个线程独占状态
  2. unlock(解锁): 作用于主内存,将主内存中被锁定的变量解锁
  3. read(读取): 作用于主内存,将主内存中的变量值传递到工作内存以便随后的load操作使用
  4. laod(载入): 作用于工作内存,将read操作从主内存中获取到的变量值放入工作内存的变量副本中
  5. use(使用): 作用于工作内存,将工作内存中的变量值传递给执行引擎。每当JVM遇到一条使用变量的字节码指令时,执行此操作。
  6. assign(赋值): 作用于工作内存,执行引擎将接收到的值赋给工作内存中的变量。每当JVM遇到一条给变量赋值的字节码指令时,执行此操作。
  7. store(存储): 作用于工作内存,将工作内存中的变量值同步回主内存以便随后的write操作使用
  8. write(写入): 作用于主内存,将store操作从工作内存中获取到的变量值放入主内存的变量中
  • 内存间8中基本交互操作的规则:
  1. 如果要把一个变量从主内存拷贝到工作内存,必须顺序执行read和load操作;如果要把一个变量从工作内存同步回主内存,必须顺序执行store和write操作两对操作必须成对、按顺序执行,可以不连续执行read aread bload bload a
  2. 不允许一个线程丢弃它的最近的assign操作,即变量在工作内存中改变了之后必须同步回主内存。
  3. 不允许一个线程无原因的(即没有发生过任何assign操作)把数据从工作内存同步回主内存,。
  4. 一个新的变量只能在主内存中诞生,不允许在工作内存中使用一个未被初始化的变量。即对一个变量实施use、store操作之前,必须先执行过了assign、load操作
  5. 一个变量在同一时刻只允许一个线程对其执行lock操作lock操作可以被同一线程执行多次。之后需要执行多次unlock操作,变量才会被解锁。
  6. 一个变量事先没被lock操作锁住,不允许执行unlock操作,也不允许unlock一个被其他线程锁住的变量。
  7. 对一个变量执行unlock操作前,必须先执行store、write操作把变量同步回主内存中。
  • 注意: 这些规则,以及后面的关于volatile类型的变量、long和double类型的变量的规则严谨复杂,都可以通过先行发生原则happens-before原则)进行等效判断
    在这里插入图片描述
2. 特定类型变量的特殊规则
① volatile类型变量的特殊规则

volatile变量两个特性:

  • 定义为volatile类型的变量,具有两个特性:
  1. 保证变量在线程之间的可见性,即一个线程修改变量的值后,其他线程可以立即读取到修改后的值。
  2. 禁止指令重排序优化,可以保证变量赋值操作的执行顺序与程序代码中的顺序一致,避免指令重排序干扰程序的并发性

对于volatile变量可见性的误解:

  • 误解: 只要将变量定义为volatile类型,就可以保证实现多线程并发访问的正确性。
  1. volatile变量只保证每次使用的值是当前最新的,如果在执行运算期间变量的值发生了更改,就会导致多线程并发访问出错。
  2. 下面的程序,将程序计数器定义为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
  1. 当运算结果依赖于变量的当前值时,volatile只保证变量的可见性,仍需要通过加锁保证线程安全

禁止指令重排序优化

  • 指令重排序优化是机器级的优化操作,对于普通变量,仅仅保证存在指令依赖时能得出正确的执行结果。比如执行指令a + 10a * 2b - 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();
  1. 如果不将initialized 定义为volatile类型,就可能由于指令冲重排序,导致线程A中的initialized=true提前执行。
  2. 这时线程B发现initialized发生改变后,使用的配置信息可能并未初始化,导致想要通过配置信息完成的某些任务执行失败。
  3. 因此必须将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;
    }
}
  1. instance添加volatile关键字后,instance = new Singleton()编译后的字节码指令,会在赋值操作之后添加一条lock操作
  2. lock操作相当于一个内存屏障Memory BarrierMemory Fence),指令重排序时,不能把后面的指令重排序到内存屏障之前的位置
  3. 两次null判断,第一次是普通的null判断与懒汉模式一致;第二次是在同步块中进行null判断,避免多线程同时创建实例对象。

volatile的选择

  • volatile变量读操作与普通变量几乎没什么区别,写操作可能会慢一点。因为他要插入内存屏障指令保证处理器不发生乱序执行。
  • 大多数场景下,volatile总开销比锁低。如果volatile的语义能满足使用场景的需求,就选择使用volatile

volatile需要满足的规则

  • 个人总结: 为了实现volatile变量的在线程之间的可见性,以及禁止指令重排序use或者assign操作volatile变量时,必须遵循的规则。
  1. 每次对volatile变量执行use操作时,都需要先执行load操作,以保证volatile变量的值是最新的。
    线程T对变量V执行的前一个操作load操作,线程T才能对变量V执行use操作;线程T对变量V执行的后一个操作use操作,线程T才能对变量V执行load操作
  2. 每次对volatile变量执行assign操作时,都要接着执行store操作,以保证更新的值对其他线程可见。
    线程T对变量V执行的前一个操作assign操作时,线程T才能对变量V执行store操作;线程T对变量V执行的后一个操作是store操作时,线程T才能对变量V执行assign操作
  3. 同一个线程对多个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 bitlongdouble类型的读写操作,允许划分成两次32 bit的操作来执行。即可以不保证64 bitlongdouble类型变量的readloadstorewrite,这4个操作的原子性。
  • 如果不保证longdouble类型变量readloadstorewrite操作的原子性,可能会读取到半个变量的数值。
  • 商用的JVM保证longdouble类型变量readloadstorewrite操作的原子性,因此在编写代码时无需将longdouble类型变量专门声明为volatile
2. JMM的三大特性
  • JMM的相关操作和规则都是围绕原子性可见性有序性,这三大特性建立的。
① 原子性
  • JMM保证了readloaduseassignstorewrite操作的原子性,对基本数据类型的读写操作是原子性的(除了longdouble非原子协定)。
  • 注意: 这种原子性是指单个操作的原子性,并非指珍格格操作序列的原子性。
  1. 例如,下图中的两个线程虽然都对cnt变量执行了自增操作,但是最后主内存中cnt的值仍然为1,并非为2。
  2. 这也是为什么直接使用int变量作为计数器,多线程并发计数会导致最后的结果出错的原因。
  3. 可以使用循环的CAS操作AtomicInteger作为计数变量,可以实现线程安全的计数器。(循环执行compareAndSet()或者直接执行incrementAndGet()
    在这里插入图片描述
  • 除了使用原子类保证操作的原子性,JMM还提供了lockunlock操作来满足原子性需求。
  1. lockunlock操作,对应JVM中的monitorentermonitorexit指令。
  2. monitorentermonitorexit指令是进入和退出同步块执行的指令,即对应synchronized关键字。
  3. 因此,在同步块之间的操作也具备原子性,可以使用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中,可见性的实现依靠将变量修改后立即同步回主内存使用时先从主内存刷新变量值
  • 可见性的三种实现方式:
  1. volatile: 执行use操作前,必须执行load操作,保证变量的值不过期;执行assign操作后,必须执行store操作,保证变量的更改对其他线程可见。
  2. synchronized: synchronized关键对应的unlock操作,在执行前必须将变量的值同步回主内存。
  3. final:final关键字修饰的变量,一旦完成初始化并没有发生this逃逸其他线程通过this引用访问到初始化了一半的对象),那么其他线程就能看见final变量的值。
③ 有序性
  • 有序性的含义:
  1. 在本线程内观察, 所有的操作都是有序的,即线程内表现为串行的语义
  2. 在一个线程中观察另一个线程,所有的操作都是无序的。即指令重排序现象工作内存与主内存之间的同步延迟现象。
  • 有序性的两种实现方式:
  1. volatile: volatile通过添加内存屏障禁止指令重排序。
  2. synchronized: 由于synchronize的关键字规定,一个变量在同一时刻只允许一个线程对其进行lock操作,这就使得持有同一个锁的两个同步块只能串行进入
④ 不要滥用synchronized
  • synchronized关键字可以保证原子性、可见性、有序性,看起来很万能
  • 但越是万能,对性能的影响越大,不要滥用synchronized关键字。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值