java内存模型和线程可见性

前言

今天我们来聊一聊java内存模型和线程可见性问题

首先我们先要知道java内存模型是什么它和jvm运行时数据区有什么区别

java内存模型和jvm没有太强的联系也完全可以说它们是两个不同的概念

为什么说同样一份java代码能够在不同的平台(windows、linux、unix)上正常运行

因为java代码和操作系统压根就没多大关系,它是在虚拟机中执行的

当然虚拟机也有很多种,比如说 HostSpot、TaoJvm.虽然虚拟机可以有多种,但是对于java代码而言基本上是没有任何区别

java代码能够在不同的虚拟机上运行而且都不会受其影响,这里面就涉及到虚拟机规范这个概念

也就是说无论谁都可以开发一个虚拟机,但是必须得符合java虚拟机规范

规范那就能说明就算是它是不同的虚拟机但是它的行为基本是一致的,只是实现的方式不同罢了

放在java代码层面上来说大概就是这么个意思:一个接口有多个不同的实现类

对于java虚拟机来说它不管在开发人员写的是什么样的源代码java、JRuby、Scala都没关系

只要能编译成字节码文件,虚拟机就认可这一份代码,也就是说虚拟机上执行的是字节码

所以对于java语言来说会有一个java语言规范来描述java语言的特性

有了这个语言规范以后编译器才能准确的编译成虚拟机认识的字节码文件

所以java内存模型和jvm运行时数据区的区别在于

  • java内存模型是通过java语言规范提出来的
  • jvm运行时数据区是由JVM规范提出来的

java内存模型

java内存模型(Java Memory Model)缩写为jmm

描述了多线程程序语义.它包含了,当多个线程修改了共享变量的值时,应该读取到哪个值的规则

由于这部分规范类似于不同硬件体系结构的内存模型,因此这些语义被称为Java编程语言内存模型

这些语义没有规定如何执行多线程程序.相反,它们描述了允许多线程程序的合法行为

我们先来分析一下以往在多线程开发环境下常见的有哪些问题

  • 所见非所得
  • 无法用肉眼去检测程序的准确性
  • 不同的运行平台有不同的表现
  • 错误很难重现

如果说要把这些问题弄清楚那就得知道java内存模型的规则是什么,下面将用一个示例代码演示一下多线程可见性问题

/**
 * <p>
 * 多线程可见性案例
 * </p>
 *
 * @author 昊天锤
 * @date 2020/10/26 0026 15:46
 */
public class VisibilityDemo {
    private static boolean flag = true;

    public static void main(String[] args) throws InterruptedException {
        System.out.println(Thread.currentThread().getName() + "线程开始执行");

        new Thread(() -> {
            System.out.println(Thread.currentThread().getName() + "线程开始执行");
            int i = 0;
            while (flag) {
                i++;
            }
            System.out.println(Thread.currentThread().getName() + "线程执行结束.I的值=" + i);
        }).start();


        Thread.sleep(1000);
        flag = false;
        System.out.println(Thread.currentThread().getName() + "线程执行结束");
    }
}

这个代码从逻辑上来看是没有任何问题的,主线程休眠一秒后将boolean变量的值改为false.目的是期望子线程结束运行

然而最终的执行结果是该程序从未停止一直运行着

为什么运行出来的结果会是这样,感觉这个变量发生了改变其他线程好像看不到一样

其实在之前有一篇关于线程的文章中也提到过,现代CPU为了提高程序运行的性能在很多方面对程序进行了优化

CPU优化

CPU高速缓存

尽可能地避免处理器访问主内存的时间开销,处理器大多会利用缓存(cache)以提高性能

img

L1 (一级缓存) 是CPU第一层高速缓存,分为数据缓存和指令缓存.一般服务器CPU的L1缓存的容量通常在32-4096KB

L2 (二级缓存) 由于L1级高速缓存容量的限制,为了再次提高CPU的运算速度,在CPU外部放置高速存储器,既二级缓存

L3 (三级缓存) 现在的都是内置的.而它实际上的作用即是,L3缓存的应用可以进一步降低内存延迟,同时提升大数据量计算时处理器的性能.具有较大L3缓存的处理器提供更有效的文件系统缓存行为及较短消息和处理器队列长度.一般是多核共享一个L3缓存

多核CPU读取同样的数据进行缓存,进行不同运算之后.最终写入主内存以哪个CPU为准?

在这种高速缓存回写的场景下,有一个MESI缓存一致性协议,多个CPU厂商对它进行了实现

MESI协议它规定每条缓存有个状态位,同时定义了下面四个状态

状态含义
修改态(Modified)此cache行已被修改过(脏行),内容已不同于主存,为此cache专有
专有态(Exclusive)此cache行内容同于主存,但不出现于其他cache中
共享态(Shared)此cache行内容同于主存,但也出现于其他cache中
无效态(Invalid)此cache行内容无效

多核处理时,单个CPU对缓存数据进行了改动,需要通知给其他的CPU

也就意味着CPU处理要控制自己读写的操作还要监听其他CPU发出的通知从而保证最终一致

也就是说CPU高速缓存之间是有一个缓存一致性协议来保证数据的一致性的

所以CPU的高速缓存不会对我们的程序进行长时间的影响但也可能会有短时间内数据不一致的情况

CPU运行时指令重排

当CPU写缓存时发现缓存区块正在被其他CPU占用,为了提高CPU处理性能,可能将后面的读缓存命令优先执行

重排序并非随便重排,需要遵守as-if-serial语义

as-if-serial语义的意思是指:不管怎么重排序,单线程的执行结果都不能被改变

编译器,runtime和处理器都必须遵循as-if-serial语义

单线程的执行结果不会被改变那么也就说明了多线程的执行结果可能被指令重排序而改变

JIT编译器(Just In Time Compiler)

在了解JIT编译器之前,我们先来了解一下脚本语言和编译语言的区别

  • 解释执行

    既咋们说的脚本.在执行时,由语言的解释器将其一条条翻译成机器可识别的指令.典型的脚本语言就是javascript

  • 编译执行

    将我们编写的程序,直接翻译成机器可以识别的指令码

对于java来说它属于一个半编译半解释执行的语言.接下来将以一个代码例子来分析一下它的执行流程

int i = 0;
while (flag) {
    i++;
}

首先我们知道java代码都要编译成字节码jvm才能运行

那么第一步应该是先会执行javac命令将代码翻译成字节码文件,翻译后的字节码如下

1.将i变量的值刷到内存中
2.读取flag变量的值
3.判断flag值
4.i++
5.跳转到第2

翻译后将该字节码指令交由JVM进行解释执行

可是当一个方法或者是方法中的循环体多次执行将会被JIT编译器升级为编译执行,编译后的代码如下

int i = 0;
if(flag){
    while (true) {
        i++;
    }
}

既JIT认为这段代码执行了太多次,干脆给它优化成只读取一次flag变量的值,之后的循环直接就写个死为true懒得每循环一次都要去读取flag变量

现在知道了问题所在那么我们应该怎么才能解决这个问题呢?

在java中有这么一个volatile的关键字它可以使共享变量在多线程之间变得可见,也可以禁止JIT指令重排序

/**
 * <p>
 * 多线程可见性案例
 * </p>
 *
 * @author 昊天锤
 * @date 2020/10/26 0026 15:46
 */
public class VisibilityDemo {
    private volatile static boolean flag = true;

    public static void main(String[] args) throws InterruptedException {
        System.out.println(Thread.currentThread().getName() + "线程开始执行");

        new Thread(() -> {
            System.out.println(Thread.currentThread().getName() + "线程开始执行");
            int i = 0;
            while (flag) {
                i++;
            }
            System.out.println(Thread.currentThread().getName() + "线程执行结束.I的值=" + i);
        }).start();


        Thread.sleep(1000);
        flag = false;
        System.out.println(Thread.currentThread().getName() + "线程执行结束");
    }
}

这时候可以看到程序执行完成功退出了,那为什么volatile关键字能够干这样的事情.接下来了解一下volatile

volatile

可见性问题:让一个线程对共享变量的修改能够及时的被其他线程看到

java内存模型规定:对volatile变量的写入与所有其他线程后续对该变量的读同步

要满足这些条件,所以volatile关键字就有以下这些功能

禁止缓存

volatile变量的访问控制符会加个ACC_VOLATILE

官网中文档明确定义了 https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-4.html#jvms-4.5

禁止缓存以后cpu便将该变量缓存在主内存中,写的是主内存读也是主内存,也就保证了可见性了

禁止指令重排序

并不是volatile修饰以后就一定不会重排序.只有当多线程有冲突操作时才不会重排序

java关键字之所以能控制CPU的行为,原因是因为CPU提供了两个内存屏障指令(Memory Barrier)

  • 写内存屏障(Store Memory Barrier)

    在指令后插入Store Barrier,能让写入缓存中的最新数据更新写入主内存,让其他线程可见

    强制写入主内存,这种显示调用,CPU就不会因为性能考虑而去对指令重排

  • 读内存屏障(Load Memory Barrier)

    在指令前插入Load Barrier,可以让高速缓存中的数据失效,强制重新从主内存加载数据

    强制读取主内存内容,让CPU缓存与主内存保持一致,避免了缓存导致的一致性问题

可以通过javap命令进行查看class文件

javap -p -v target\classes\com\sydata\cscm\transfer\action\VisibilityDemo.class

Shared Variables定义

可以在线程之间共享的内存称为共享内存或堆内存,之前有文章提到过java运行时数据区分为两大类:线程共享区域线程独享区域

所有实例字段静态字段数组元素都存储在堆内存中,这些字段和数组都是共享变量

共享的情况下如果有一个操作是写,那么对同一个变量的两次访问即是冲突

这些能够被多个线程访问的共享变量是内存模型规范的对象

定义官网地址 https://docs.oracle.com/javase/specs/jls/se8/html/jls-17.html#jls-17.4.1

所有线程间操作都存在可见性问题,JMM需要对其进行规范

线程间操作的定义:

  • 指的是一个程序执行的操作可被其他线程感知或被其他线程影响
  • java内存模型只描述线程间操作,不描述线程内操作,线程内操作按照线程内语义执行

线程间操作有:

  • 读操作
  • 写操作
  • volatile 读操作
  • volatile 写操作
  • Lock、Unlock
  • 线程的第一个和最后一个操作
  • 外部操作 (数据库、mq、redis)

对于同步的规则定义

1.对volatile变量的写入与所有其他线程后续对该变量的读同步

​ 意思是volatile修饰的变量要保证可见性

2.对于监视器m的解锁与所有后续操作对于m的加锁同步

​ 意思是有解锁和加锁操作的也需要保证可见性

3.对于每个属性写入默认值(0,false,null)与每个线程对其进行同步

​ 意思是当初始化一个对象赋上默认值的时候具备可见性的

4.启动线程的操作与线程中的第一个操作同步

​ 意思是线程从创建状态变为Runnable状态的这个过程是可见的

5.线程的最后操作与其他线程发现它已经结束同步

​ 意思是线程执行完状态变成Terminated的这个状态是可见的(isAlive、join可以判断线程是否终结)

6.线程的中断同步

​ 意思是当线程中断以后其他线程对其中断状态是可见的
​ 通过抛出InterruptedException异常或者调用interrupt或isInterrupted方法

Happens-before先行发生原则

happens-before 关系用于描述两个有冲突的动作之间的顺序

如果一个action happens before 另一个action则第一个操作被第二个操作可见

当程序包含两个没有被happens before关系排序的冲突访问时,就称为存在数据竞争

遵守了这个原则,也就意味着有些代码不能进行重排序,有些数据不能进行缓存

JVM需要实现如下happens-before规则:

  • 某个线程中的每个动作都happens-before该线程中该动作后面的动作

  • 某个管程上的unlock动作happens-before同一个管程上后续的lock动作

  • 对某个volatile字段的写操作happens-before每个后续对该volatile字段的读操作

  • 在某个线程对象上调用start方法happens-before被启动线程中的任意动作

  • 如果在线程t1中成功执行了t2.join(),则t2中的所有操作对t1可见

    如果在t1线程中调用了t2线程的join方法,那么t2线程的所有操作对于t1都是可见的

  • 如果某个动作a happens-before 动作b,且动作b happens-before 动作c,则动作a happens-before 动作c

    动作的执行顺序是 a -> b -> c ,那么a就先行发生在c之前

其实对于先行发生原则有太多相似的点前面已经提到过了.所以这里只说了一下那些没有提到过的

final在JMM中的处理

  • final在该对象的构造函数中设置对象的字段,当线程看到该对象时,将始终看到该对象的final字段的正确构造版本

    伪代码示例:

    static class Demo {
        public final boolean flag = true;
    
        public static void main(String[] args) {
    		// 既这个Demo对象中的flag属性无论哪个线程查看值都是true
            Demo demo = new Demo();
            System.out.println(demo.flag);
        }
    }
    
  • 如果在构造函数中设置字段后发生读取,则会看到该final字段分配的值,否则它将看到默认值

    伪代码示例:

    static class Demo {
        public final boolean flag = true;
        public int a;
    
        public Demo() {
    		// 意思是在构造函数赋值的过程中,其他线程读取这个对象的属性是默认值
    		// 因为赋值可能还没执行,所以其他线程只能读取到默认值
    		// 由于很难复现,所以这里在构造函数new一个线程进行睡眠等待
            // 所以这里通过普通变量进行演示而非final变量
            new Thread(() -> {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                a = 1;
            }).start();
        }
    
        public static void main(String[] args) {
            Demo demo = new Demo();
            new Thread(() -> System.out.println(demo.a)).start();
            System.out.println(demo.a);
        }
    }
    

    个人觉得这种事情很难会发生,因为在构造函数进行的赋值,构造函数如果没执行完又怎么会执行启动另一个线程的代码呢

    但是官网确实就说了这个事,所以我们就当会有这个情况存在就好了

  • 读取该共享对象的final成员变量之前,先要读取共享对象

  • 通常被static final修饰的字段都不能够被修改.然而System.in、System.out、System.err被static final修饰却可以修改

    这个问题属于历史遗留问题,必须允许通过set方法改变,我们将这些字段称为写保护.以区别于普通final字段

Word Tearing字节处理

有些处理器(尤其是早期的Alphas处理器)没有提供写单个字节的功能
在这样的处理器上更新byte数组,若只是简单地读取整个内容,更新对应的字节,然后将整个内容再写回内存,将是不合法的

这个问题有时候被称为字分裂(Word Tearing),更新单个字节有难度的处理器,就需要寻求其他方式来解决问题

因此编程人员需要注意:尽量不要对byte数组中的元素进行重新赋值,更不要在多线程程序中这样做

伪代码示例:

public static void main(String[] args) {
    byte[] bytes = {1, 2};

    new Thread(() -> {
        byte[] copyBytes = bytes;
        copyBytes[0] = 3;
        bytes = copyBytes;
    }).start();

    new Thread(() -> {
        byte[] copyBytes = bytes;
        copyBytes[1] = 4;
        bytes = copyBytes;
    }).start();
}

这个代码是不能跑的,因为Variable ‘bytes’ is accessed from within inner class, needs to be final or effectively final

想表达是意思就是改变byte数组中的某个元素是会将整个byte数组覆盖原本的值,所以会导致有覆盖的冲突

double和long的特殊处理

由于《java语言规范》的原因,对非volatile的double或long的单次写操作是分两次来进行的,每次操作其中32位

这可能导致第一次写入后读取的值是脏数据,第二次写入完成后才能读取到正确值

由于double和long都是64位的,每次操作只能写入32位,所以在写入double和long的时候会写入两次

这样就没有保证它的原子性操作,在多线程操作下会出现脏读问题

《java语言规范》中说到: 建议程序员将共享的64位值(double和long)用volatile修饰或正确同步其程序以避免可能的复杂情况

当然商业JVM不会存在这个问题,虽然规范没要求实现原子性.但是考虑到实际应用,大部分都实现了原子性

总结

对于JMM规范的介绍到这就结束了,JMM规范只是描述了多线程程序的语义

描述了它会发生什么样的问题,却没有明确定义这些问题应该怎么解决

对于JMM来说我们要解决最关键的问题就是可见性问题

对于可见性问题JMM会有规范告诉我们它能够通过规范来解决可见性问题

当我们在开发过程中出现可见性问题它的原因源自于JIT会在程序运行过程中对指令进行重排序,可以通过JMM规范来解决这个问题

volatile这个关键字之所以有禁止重排序、保证可见性这些功能源自于JMM规范中定义了这个关键字就有这样的作用

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值