美文网首页java进阶干货程序员Java
Java 多线程(二):内存模型与 Synchronized、V

Java 多线程(二):内存模型与 Synchronized、V

作者: 聪明的奇瑞 | 来源:发表于2018-03-10 12:00 被阅读387次

Java 内存模型

  • Java 内存模型即 Java Memory Model(JMM),JMM 定义了 Java 虚拟机(JVM)在计算机内存(RAM)中的工作方式
  • 要想深入了解 Java 并发编程,要先理解好 Java 内存模型,Java 内存模型定义了多线程之间共享变量的可见性以及对共享变量的同步

并发编程

  • 在并发编程中,线程之间通过共享内存(线程之间通过读写公共属性来隐式通信)和消息传递来通信(线程之间通过明确发送消息来进行通信)
  • JAVA 的并发采用的是共享内存模型,JAVA 线程之间的通信总是隐式进行,通信过程对程序员完全透明,如果程序员不理解隐式通信的工作机制,便会出现内存可见性问题

内存模型抽象

  • 在 JAVA 中
    • 所有实例域(new创建的对象)、静态域、数组元素存储在堆内存中,堆内存在线程之间共享(共享变量)
    • 局部变量、方法定义参数和异常处理参数不会在线程之间共享,他们不会有内存可见性问题,也不受内存模型影响
  • JMM 决定一个线程共享变量的写入何时对另一个线程可见。线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存中存储了该线程以读/写共享变量的副本
  • JMM 会通过控制主内存与每个线程之间的本地内存之间的交互尽可能来提供内存可见性保证,如果A线程与B线程之间要通信的话,必须经历下面2个步骤:
    • 线程 A 把内存 A 中更新过的共享变量刷新到主内存中
    • 线程 B 从主内存中读取线程 A 之前已更新过的共享变量
内存可见性

重排序

  • 在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序,重排序在单线程中是安全的,但是在多线程访问共享变量时可能会有内存可见性问题,重排序分为三种:
    • 编译器优化的重排序
    • 指令级并行的重排序
    • 内存系统的重排序
  • 如果两个线程访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性,例如下面情况:
名称 代码示例 说明
写后读 a=1;b=a; 写一个变量之后,再读这个位置
写后写 a=1;b=2; 写一个变量之后,再写这个位置
读后读 a=b;b=1; 读一个变量之后,再写这个位置
  • 上面三种情况存在依赖关系,只要重排序两个操作的执行顺序,程序执行结果就会改变
  • 编译器和处理器在重排序时会遵守数据的依赖性,不会改变存在数据依赖关系的两个操作执行顺序(仅在单线程中安全)
  • as-if-serial 语义
    • as-if-serial 指编译器,runtime 和处理器无论怎么重排序,(单线程)程序的执行结果不能被改变
    • 为了遵守 as-if-serial 语义,编译器和处理器不会对存在依赖关系的操作做重排序
  • 例如下图中 A 与 C、B 与 C 存在依赖关系,但 A 与 B 不存在依赖关系,所以 A 与 B 的执行顺序是可以进行重排序的,这不影响最终结果

重排序
  • 当程序未正确同步时,就会存在顺序竞争,当代码中包含数据竞争时,程序的执行往往产生违反直觉的结果:
    • 在一个线程中写入一个变量
    • 在另外一个线程中读取同一个变量
    • 而写和读没有同步来排序
  • 数据一致性内存模型
    • 顺序一致性内存模型具有两个特征:
      • 一个线程中的所有操作必须按照程序的顺序执行
      • 所有线程只能看到一个单一的操作执行顺序,在顺序一致性内存模型中,每个操作都必须原子执行且立刻对所有线程可见
    • 顺序一致性模型有一个单一的全局内存,这个内存通过一个左右摆动的开关可以连接到任意一个线程,同时,每一个线程必须按程序的顺序来执行内存读/写操作,在任意时间点最多只能有一个线程可以连接到内存。

可见性分析

  • 线程的不安全主要体现在共享变量的不可见,导致共享变量在线程之间不可见的原因:
    • 线程的交叉执行
    • 重排序结合线程交叉执行
    • 共享变量更新后的值没有在工作内存与主内存之间得到更新

synchronized 特性

  • 原子性:(同步)在任何时刻,只能有一个线程在执行锁内的代码,因此也解决了线程交叉执行与重排序(重排序再单个线程内不会有问题)等问题
  • 可见性
    • 线程解锁前,必须把共享变量的最新值刷新到主内存中
    • 线程加锁时,将清空工作内存中共享变量的值,从而线程内存变量需要重新从主内存中读取最新的值

Volatile 特性

  • 可见性:volatile 修饰的变量能保证其可见性,这是通过内存屏障来实现的:
    • 对 volatile 变量执行写操作时,会在写操作后加入一条 store 屏障指令,将变量值从工作内存刷新到主内存去
    • 对 volatile 变量执行读操作时,会在写操作后加入一条 load 屏障指令,将从主内存中读取共享变量的值到工作内存中
  • 不能保证原子性:volatile 不能保证 volatile 变量复合操作的原子性,例如
number++;
  • 它非原子操作,它分为三步,这可能会被重排序:
    • 读取 number 的值
    • 将 number 值加1
    • 写入最新的 number 值
  • 不保证同步:线程的交叉执行可能会影响程序的最终结果

案例分析

  • 下面代码中创建了两个线程,并执行了 write() 方法,随后 reader() 方法,它会存在下面几种代码执行顺序(不完全列举):
描述 顺序 输出结果
写线程执行 1.1 操作后,读线程抢占到了 CPU 资源执行了 2.1、2.2,然后写线程再执行了 1.2 1.1->2.1->2.2->1.2 3
写线程 1.1 跟 1.2 进行了冲排序,写线程先执行了 1.2 操作后,读线程抢占到了 CPU 资源执行了 2.1、2.2,然后写线程再执行了 1.1 1.2->2.1->2.2->1.1 0
读线程先抢占到 CPU 资源执行了 2.1,然后写线程再执行了 1.1、1.2 2.1->1.1->1.2 0
public class Demo {
    // 共享变量
    private int result = 0;
    private boolean flag = false;
    private int number = 1;

    // 写操作
    public void write() {
        flag = true;        // 1.1
        number = 2;        // 1.2
    }

    // 读操作
    public void read() {
        if (flag) {          // 2.1
            result = number * 3;      // 2.2
        }
        System.out.println("result 值为:" + result);
    }

    // 线程内部类
    private class ReadWriteThread extends Thread{
        // 根据构造方法中传入的 flag 确定线程是读操作还是写操作
        private boolean flag;

        public ReadWriteThread(boolean flag) {
            this.flag = flag;
        }

        @Override
        public void run() {
            if (flag)
                // flag 值为 true 执行写操作
                write();
            else
                // flag 值为 false 执行读操作
                read();
        }
    }

    public static void main(String[] args) {
        Demo demo = new Demo();
        // 启动线程执行写操作
        demo.new ReadWriteThread(true).start();
        // 启动线程执行读操作
        demo.new ReadWriteThread(false).start();
    }
}

案例优化

  • 利用 synchronized 保证可见性与同步
// 写操作
public synchronized void write() {
    flag = true;        // 1.1
    number = 2;        // 1.2
}
// 读操作
public synchronized void read() {
    if (flag) {          // 2.1
        result = number * 3;      // 2.2
    }
    System.out.println("result 值为:" + result);
}
  • 注意:此时结果仍然可能为 0,这是因为读线程比写线程优先抢占到了 CPU 资源,并先执行了读方法,这种情况与可见性无关

相关文章

网友评论

  • 半夏风痕:使用-Xms和-Xmx来指定JVM堆空间的初始值和最大值都为6144M,JAVA进程起来后,用top命令查看内存,该进程实际上只占用了2.9多个G的内存。但跑一段时间的业务,内存占用增加了5个多G。请问楼主,这个初始值为什么没生效?这种现象是出现内存泄露了吗?
    半夏风痕:我设置了最小值了-Xms=6144M
    半夏风痕:@林塬 那top出来的值应该大于我设置的堆初始值啊?我设置堆初始值为6G,可top出来的值才2.9G多
    聪明的奇瑞:@半夏风痕 top出来的memory占用还有PermSize等,是整个进程总和,因为你只是设置java堆内存的最大值。除了堆以外,jvm还有栈,方法区,常量池
  • 半夏风痕:案例优化后,结果仍然可能为0。请问,怎样再次优化后,能保证结果都是3呢?也就是限死先写再读的顺序?
    半夏风痕:已看,多谢!
    聪明的奇瑞:@半夏风痕 你可以使用 Java Executor 框架创建一个 SingleThreadExecutor,创建一个单线程的 Executor,它只会用唯一的线程来执行任务,保证所有任务按照指定顺序(FIFO, LIFO, 优先级)执行!你可以看下我这篇文章:https://www.jianshu.com/p/09d77a7756a0

本文标题:Java 多线程(二):内存模型与 Synchronized、V

本文链接:https://www.haomeiwen.com/subject/tcwvfftx.html