四、Java内存模型与线程, 来自《深入理解Java虚拟机》

1. 什么是Java的内存模型

内存模型可以理解为在特定的操作协议下, 对特定的内存或高速缓存进行读写访问的过程

Java内存模型规定了所有的变量都存储在主内存中, 这里的主内存其实就是虚拟机内存的一部分, 每条线程还有自己的工作内存, 工作内存保存了被该线程使用的主内存副本,每一个线程都没有办法直接操作主内存的数据, 不同的线程也没有办法互相访问对方工作内存, 所以他们所有的数据传递都必须通过主内存来进行, 他们的关系如下图所示
图4-1

2.1 内存间的交互操作

关于主内存和工作内存之间具体的交互协议, 即一个变量是如何从主内存拷贝到工作内存中的, 工作内存又是如何将数据同步回主内存的, Java内存模型定义了8中操作来完成, 这8中操作必须是原子性的, 但对于double和long类型的在某些平台会有例外

  1. lock(锁定) : 作用于主内存, 他把一个变量标识为一条线程独有的状态
  2. unlock(解锁) : 作用于主内存的变量, 把一个处于锁定状态的变量释放出来, 释放后其他线程才可以锁定
  3. read(读取) : 作用于主内存的变量, 他把一个值从主内存传输到线程的工作内存
  4. load(载入) : 作用于工作内存的变量, 他将read出来的变量值放到工作内存的变量副本中
  5. use(使用) : 作用于工作内存, 他将工作内存中的变量传递给执行引擎, 每当虚拟机遇到一个需要使用变量值的字节码指令时将会执行这个操作
  6. assign(赋值) : 作用于工作内存, 他把一个从执行引擎接收到的值赋给工作内存的变量, 每当虚拟机遇到给变量赋值的字节码指令时执行这个操作
  7. store(存储) : 作用于工作内存, 他把工作内存中的变量值传递到主内存
  8. write(写入) : 作用于主内存, 他把store操作从工作内存中得到的变量放入主内存的变量中

Java内存模型还规定了在上述8个操作中必须满足以下8个规则

  1. 不允许read和load, store和write操作的其中一个单独出现, 就相当于一个东西我给你, 你就一定要拿, 你给我, 我也一定要拿, 不能拒绝
  2. 不允许一个线程丢弃它最近的assign操作, 也就是工作内存中改变的值必须同步回主内存
  3. 不允许一个线程无原因(没有执行过任何assign操作)的将工作内存同步回主内存
  4. 一个新的变量只能在主内存中诞生, 不允许在工作内存中直接使用一个未被初始化(load或assign)的变量, 也就是在use和store之前必须先执行assign和load操作
  5. 一个变量任何时候只能被一个线程lock, 但可以被一个线程重复执行, 多次lock后必须执行相应次数的unlock才会被解锁, 它保证了synchronized的有序性
  6. 如果对一个变量执行load操作, 那么会清空工作内存中此变量的值, 在执行引擎使用这个变量前, 需要重新执行load或assign操作用来初始化变量的值
  7. 如果一个变量没有被lock, 那么就不能执行unlock, 也不能unlock别人lock的变量
  8. 在一个变量执行unlock前必须先把此变量同步回主内存中, 也就是执行store和write操作, 它保证了synchronized的可见性
2.2 对于volatile型变量的特殊规则

当一个变量被定义成volatile之后, 它具有两项特性

1. 保证此变量对所有线程的可见性

这里的可见性是指当一条线程修改了这个变量的值, 新值对于其他线程来说是可以立即得知的, 而普通变量做不到这一点, 普通变量的值必须通过主内存来传递才能被其他线程看到, volatile修饰的变量也是需要通过主内存传递的,但是这个关键字的特殊规则能保证新值立即同步回主内存, 以及每次使用前立即从主内存刷新

volatile修饰的变量在被修改后能立即被其他线程看到, 但是并不能保证在多线程下就是并发安全的, 比如下面的程序, 在每次执行完毕之后总会得到一个小于200000的值, 问题就出在race++这个操作, 通过javap反编译代码后会发现只有一行的increase()在字节码文件中是4条指令, 现在问题很清楚了, 当GETSTATIC指令把race的值取到操作栈顶的时候, volatile关键字保证此时race的值是正确的, 但是当执行ICONST_1、IADD时这4条指令时, 其他线程可能已经把race的值修改了, 而这里得到的值就变成了过期数据, 所以PUTSTATIC之后就可能把较小的值放到主内存之间

GETSTATIC it/test/simple/TestVolatile.race : I
ICONST_1
IADD
PUTSTATIC it/test/simple/TestVolatile.race : I
public class TestVolatile {
    public static volatile int race = 0;

    public static void increase() {
        race++;
    }

    public static final int THREADS_COUNT = 20;
    public static void main(String[] args) {
        Thread[] threads = new Thread[THREADS_COUNT];
        for (int i = 0; i < THREADS_COUNT; i++) {
            threads[i] = new Thread(() -> {
                for (int j = 0; j < 10000; j++) {
                    increase();
                }
            });
            threads[i].start();
        }
        //等待所有线程执行完成, Idea因为会自动创建一条名为Monitor Ctrl-Break的线程
        //所以会导致while循环无法结束, 此处用>2
        while (Thread.activeCount() > 2)
            Thread.yield();
        System.out.println(race);
    }
}

tips 可以利用idea自带的工具很方便查看字节码文件 view->show Bytecode
在这里插入图片描述

什么可以情况下可以使用volatile呢?

  • 当运算结果并不依赖当前值, 或者能够满足只有单一线程修改变量的值
  • 变量不需要于其他的状态变量共同参与不变约束
    例如: 下面的场景就很适合使用volatile变量来控制并发, 当shutdown()方法被调用时, 能够保证所有线程中执行doWork()的方法都立即停下来
volatile boolean shutdownRequested;

public void shutdown(){
    shutdownRequested = true;
}

public void doWork(){
    while(!shutdownRequested){
        //业务逻辑
    }
}
2. 禁止指令重排序优化

重排序是指: 处理器采用了允许将多条指令不按程序规定的顺序分开发送给各个相应的电路单元去处理.

普通的变量仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获得正确的结果, 而不能保证这些变量赋值操作的顺序与代码中的顺序一致.

被volatile修饰的变量在赋值后, 会多执行一个lock操作, 这个操作相当于增加了一层内存屏障, 重排序时不能将后面的指令重排序到内存屏障之前的位置.

另外有依赖的操作时不会进行重排序的, 比如指令1将一个值+10, 指令2将他*3, 指令3再将他-5, 这三个指令是有依赖关系的, 所以他们之间的顺序不能重排序

2.3 针对long和double的特殊规则

long和double的非原子型协定: Java允许虚拟机将没有被volatile修饰的64位数据的读和写划分为两次32位的操作来进行, 即允许虚拟机实现自行选择是否要保证64位数据类型的load, store, read, write这四个操作的原子性

2.4 原子性、可见性与有序性
1. 原子性

Java内存保证原子性的变量操作有6个(除过lock和unlock), 但是当需要更大范围的原子操作时虽然虚拟机没有将lock和unlock操作开放给开发者, 但是却提供了更高层次的字节码指令来使用这两个操作, 对应再Java代码中就是synchronized关键字

2. 可见性:

可见性就是当一个线程修改了共享变量时, 其他线程能够立即得知这个修改, Java内存模型是通过在变量修改后将新值同步回主内存, 在变量读取前从主内存刷新这种依赖主内存传递的方式来实现可见性的, 但是volatile的特殊规则保证了新值能立即同步到主内存, 以及每次使用前立即从主内存刷新, 因此可以说volatile保证了多线程操作时变量的可见性, 除此之外还有synchronized和final也能实现可见性

synchronized的可见性是因为对一个变量unlock前, 必须将此变量同步回主内存中这个规则决定的, final的可见性是指: 被final修饰的字段在构造器中一旦被初始化完成, 并且构造器没有将this的引用传递出去, 那么在其他线程就能看见final字段的值.

3. 有序性

如果在本线程中观察, 所有的操作都是有序的, 如果在一个线程观察另外一个线程, 所有的操作都是无序的. 前半句是指"线程内似表现为串行的语义", 后半句是指"指令重排序现象"和"工作内存与主内存同步延迟"的现象

Java语言提供了volatile和synchronized两个关键字来保证线程之间操作的有序性, volatile关键字本身就包含了禁止指令重排序的定义, 而synchronized的有序性是因为Java内存交互的一个规则 “一个变量在同步时只允许一条线程对其进行lock操作”

2.5 先行发生原则

先行发生就是Java内存模型中定义的两个操作之间的偏序关系, 主要有以下一些先行发生规则

  • 程序次序规则: 在一个线程内,按照控制流顺序, 书写在前面的操作先行发生于书写在后面的操作, 注意这里是控制流顺序而不是程序代码顺序, 因为还要考虑分支, 循环等结构
  • 管程锁定规则: 一个unlock操作先行发生于后面对同一个锁的lock操作, 这里的后面指的是时间上的先后
  • volatile变量规则: 对于一个volatile变量的写操作先行发生于后面对这个变量的读操作, 这里的后面指的是时间上的先后
  • 线程启动规则: Thread对象的start()方法先行发生于此线程的每一个动作
  • 线程终止规则: 线程中的所有操作都先行发生于此线程的终止检测, 我们可以通过Thread::join()方法是否结束, Thread::isAlive()的返回值等来检测程序是否已经终止执行
  • 线程中断规则: 对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生, 可以通过Thread::interrupted()方法检测到是否有发生中断
  • 对象终结规则: 一个对象的初始化完成(构造函数执行结束)先行发生于他的finalize()方法的开始
  • 传递性: 如果操作A先行发生于操作B, 操作B先行发生于操作C, 那么操作A先行发生于操作C

看到这里是不是明白了为什么会用join来保证顺序执行?
因为Java内存模型的先行发生原则的线程终止原则保证了Thread::join()一定发生在所有线程操作之后

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值