【详解】Java多线程之内存模型三大特性

一、数据不一致的问题

  • CPU运行速度比较快,每次去内存读取数据就比较耗时
  • 所以,CPU与内存之间会存在一个高速缓存来提高CPU的效率
  • 这个高速缓存存放着从内存中读取的数据
  • 当一个线程仅仅对数据是读取的操作,Java会认为该线程不需要刷新缓存。当有另一个线程,对数据进行修改时,第一个线程不可见,出现了数据不一致的问题

JMM模型

代码演示

public class Volatile {

    private static int INIT_VALUE = 0;

    private final static int MAX_LIMIT = 5;

    public static void main(String[] args) {
        new Thread(() -> {//负责修改数据
            int localValue = INIT_VALUE;
            while (localValue < MAX_LIMIT) {
                System.out.println("数值即将更新为" + ++localValue);
                INIT_VALUE = localValue;
                try {
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }

        }, "UPDATER").start();


        new Thread(() -> {//新建一个读取数据的线程
            int localValue = INIT_VALUE;
            while (localValue < MAX_LIMIT) {
                if (localValue != INIT_VALUE) {//当数据发生更改
                    System.out.println("数据现在为" + INIT_VALUE);
                    localValue = INIT_VALUE;
                }
            }

        }, "READER").start();


    }
}

结果:

数值即将更新为1
数值即将更新为2
数值即将更新为3
数值即将更新为4
数值即将更新为5

二、解决方法

解决方法有两种方法:

给数据总线加锁

  • CPU和其他部件进行通信都是通过总线来进行的,,如果对总线加LOCK#锁的话,也就是说阻塞了其他CPU对其他部件访问(如内存)
  • 由于在锁住总线期间,其他CPU无法访问内存,导致效率低下

CPU高速缓存一致性协议

例如 Intel 的MESI协议,MESI协议保证了每个缓存中使用的共享变量的副本是一致的,它核心的思想是:当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。

三、JMM模型中的三个概念

原子性

  • 一个操作或者多个操作要么全部执行成功,要么全部执行失败
  • 类似于数据库理论中的原子性

比如

i = 9:
9 的二进制为1001

执行赋值的操作时必须保证二进制的每一位都赋值成功

不能出现: i = 1000 或者 i = 0001

可见性

  • 当多个线程修改同一个变量时,其他线程必须要能感知到
  • 主要需要解决缓存不一致的问题

有序性

  • 保证代码的顺序
  • 这个主要避免Java优化器的重排序(只要求最终的一致性)

比如:

----------Thread-1----------------
init = true          2;
obj = createObj()    1;


----------Thread-2----------------
while(!init){
	sleep(100);
}
useTheObj(obj);
----------------------------------
  • 在多线程时,出现了重排序让线程1的语句2执行在语句1的前面
  • 此时线程2使用该对象时,就会出现空指针异常

四、Java中对三个特性的保证

保证原子性

  • 对于基本数据类型的变量读取和赋值是保证了其原子性,这些操作不可中断

分析

i = 10; 原子性
b = a ; 不满足,1.load a,2. assign b
c++;    不满足,1.load c,2. add ,3. assign c
c = c+1;不满足,1.load c,2. add, 3. assign c

下面以cnt++举例

  • 由此可以发现:对 int 类型读写操作满足原子性只是说明 load、assign、store 这些单个操作具备原子性
  • 注:Java对64 位数据(long,double)的读写操作划分为两次 32 位的操作来进行即 load、store、read 和 write 操作可以不具备原子性。

保证可见性

使用volatile关键字

  • 但是不能保证其原子性
  • 所有的线程都会直接去内存拿数据,而不再使用高速缓存,避免了缓存不一致的情况

synchronized

  • 对一个变量执行 unlock 操作之前,必须把变量值同步回主内存。

final

  • 被 final 关键字修饰的字段在构造器中一旦初始化完成,并且没有发生 this 逃逸(其它线程通过 this 引用访问到初始化了一半的对象),那么其它线程就能看见 final 字段的值。

保证有序性(happens-before relationship)

1. 单一线程原则

  • 在一个线程内,在程序前面的操作先行发生于后面的操作

2. 管程锁定规则

  • 一个 unlock 操作先行发生于后面对同一个锁的 lock 操作

3. volatile 变量规则

  • 对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作

4. 线程启动规则

  • Thread 对象的 start() 方法调用先行发生于此线程的每一个动作

5. 线程加入规则

  • Thread 对象的结束先行发生于 join() 方法返回。

6. 线程中断规则

  • 对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过 interrupted() 方法检测到是否有中断发生

7. 对象终结规则

  • 一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize() 方法的开始。

8. 传递性

  • 如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,那么操作 A 先行发生于操作 C

五、volatile关键字

一个共享变量被volatile修饰,具备两层语义

  • 强制对缓存的修改操作立刻写入到主存中,并且会使其他线程的缓存失效。保证了线程间的可见性
  • 禁止对其进行重排序,不会把后面的指令放在前面,也不会把前面的指令放在后面。也就是保证了有序性
  • 并未保证原子性
public class Volatile {

    private static volatile int INIT_VALUE = 0;

    private final static int MAX_LIMIT = 5;

    public static void main(String[] args) {
        new Thread(() -> {//负责修改数据
            int localValue = INIT_VALUE;
            while (localValue < MAX_LIMIT) {
                System.out.println("数值即将更新为" + ++localValue);
                INIT_VALUE = localValue;
                try {
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }

        }, "UPDATER").start();


        new Thread(() -> {//新建一个读取数据的线程
            int localValue = INIT_VALUE;
            while (localValue < MAX_LIMIT) {
                if (localValue != INIT_VALUE) {//当数据发生更改
                    System.out.println("数据现在为" + INIT_VALUE);
                    localValue = INIT_VALUE;
                }
            }

        }, "READER").start();


    }
}

六、volatile使用场景

1. 状态量的标记

volatile boolean flag = true;

while(flag){
//....
}

.....
  • 因为线程只存在读的操作,所以加了volatile可以让线程读取的数据是内存中最新的数据

2. 保证屏障前后的一次性


private boolean volatile  init  = false
----------Thread-1----------------

obj = createObj()    1;
.....                2;
init = true          3;
....                 4;
....                 5;


----------Thread-2----------------
while(!init){
	sleep(100);
}
useTheObj(obj);
----------------------------------
  • 可以保证1、2一定在3执行之前,4、5一定再3执行之后,但是不能保证1、2和3、4的顺序

七、重排序的验证

以下代码可以验证Java的重排序

public class Main {

    /** * 这是一个验证结果的变量 */
    private static int a = 0;
    /** * 这是一个标志位 */
    private static boolean flag = false;

    public static void main(String[] args) throws InterruptedException {
        //由于多线程情况下未必会试出重排序的结论,所以多试一些次
        for (int i = 0; i < 1000000; i++) {
            ThreadA threadA = new ThreadA();
            ThreadB threadB = new ThreadB();
            threadA.start();
            threadB.start();
            //这里等待线程结束后,重置共享变量,以使验证结果的工作变得简单些.
            threadA.join();
            threadB.join();
            a = 0;
            flag = false;
        }
    }

    static class ThreadA extends Thread {
        public void run() {
            a = 1;
            flag = true;
        }
    }

    static class ThreadB extends Thread {
        public void run() {
            if (a == 0 && flag) {
                System.out.println("ha,a==0");
            }
        }
    }
}
  • 按照代码顺序,如果执行到flag = true,此时a一定为1
  • 但是,线程二依然会输出结果
  • 原因在于flag = true和 a=1 的出现了重排序

在 happens-before 原则下

  • 线程A的无论怎么进行重排序,都不会影响到线程A最终的结果
  • 但是,在共享变量与多线程的情况下,重排序导致线程B对资源的访问出现的BUG
全部评论

相关推荐

一颗宏心:华为HR晚上过了十二点后还给我法消息。
点赞 评论 收藏
分享
1 1 评论
分享
牛客网
牛客企业服务