JUC并发编程(一):Java内存模型(JMM)及三大特性:可见性、有序性、原子性

简介: 在当今高流量、高并发的互联网业务场景下,**并发编程技术**显得尤为重要,不管是哪一门编程语言,掌握并发编程技术是个人进阶的必经之路。时隔一个半月没有写技术博客文章,有点生疏了。。。闲话少叙,接下来我将围绕并发编程知识点进行总结讲解,这里从并发编程入门开始,讲述Java内存模型和并发的三大特性。

1.简介

在当今高流量、高并发的互联网业务场景下,并发编程技术显得尤为重要,不管是哪一门编程语言,掌握并发编程技术是个人进阶的必经之路。时隔一个半月没有写技术博客文章,有点生疏了。。。闲话少叙,接下来我将围绕并发编程知识点进行总结讲解,这里从并发编程入门开始,讲述Java内存模型和并发的三大特性。

2.Java内存模型(JMM)

Java 内存模型(简称 JMM):定义了线程和主内存之间的抽象关系,即 JMM 定义了 JVM 在计算机内存(RAM)中的工作方式。
其和内存区域是不一样的东西。内存区域是指 JVM 运行时将数据分区域存储,强调对内存空间的划分,即运行时数据区(Runtime Data Area)。

在 JDK1.2 之前,Java 的内存模型实现总是从主存(即共享内存)读取变量,是不需要进行特别的注意的。而在当前的 Java 内存模型下,线程可以把变量保存本地内存(比如机器的寄存器)中,而不是直接在主存中进行读写。这就可能造成一个线程在主存中修改了一个变量的值,而另外一个线程还继续使用它在寄存器中的变量值的拷贝,造成数据的不一致

  • 主内存 :所有线程创建的实例对象都存放在主内存中,不管该实例对象是成员变量还是方法中的本地变量(也称局部变量)
  • 本地内存 :每个线程都有一个私有的本地内存来存储共享变量的副本,并且,每个线程只能访问自己的本地内存,无法访问其他线程的本地内存。本地内存是 JMM 抽象出来的一个概念,存储了主内存中的共享变量副本。

Java内存模型图如下所示:

注意:Java内存模型和内存区域不要混淆,Java内存区域 JVM 运行时将数据分区域存储,强调对内存空间的划分,即运行时数据区(Runtime Data Area),如下图所示

项目推荐:基于SpringBoot2.x、SpringCloud和SpringCloudAlibaba企业级系统架构底层框架封装,解决业务开发时常见的非功能性需求,防止重复造轮子,方便业务快速开发和企业技术栈框架统一管理。引入组件化的思想实现高内聚低耦合并且高度可配置化,做到可插拔。严格控制包依赖和统一版本管理,做到最少化依赖。注重代码规范和注释,非常适合个人学习和企业使用

Github地址https://github.com/plasticene/plasticene-boot-starter-parent

Gitee地址https://gitee.com/plasticene3/plasticene-boot-starter-parent

微信公众号Shepherd进阶笔记

3.并发三大特性:可见性、有序性、原子性

这些年,我们的 CPU、内存、I/O 设备都在不断迭代,不断朝着更快的方向努力。但是,在这个快速发展的过程中,有一个核心矛盾一直存在,就是这三者的速度差,cpu速度>>内存速度>>磁盘设备速度。为了合理利用 CPU 的高性能,平衡这三者的速度差异,计算机体系机构、操作系统、编译程序都做出了贡献,主要体现为:

  1. CPU 增加了缓存,以均衡与内存的速度差异;
  2. 操作系统增加了进程、线程,以分时复用 CPU,进而均衡 CPU 与 I/O 设备的速度差异;
  3. 编译程序优化指令执行次序,使得缓存能够得到更加合理地利用。

当然Java内存模型的存在也是为了平衡上面三者处理速度差异。引入以上功能确实提升了系统的整体处理性能,但是凡事都有双面性,给我们平时并发编程过程中引入了以下问题

3.1 可见性

可见性就是一个线程对共享变量的修改,另外一个线程能够立刻看到。但是基于上面的Java内存模型可知每个线程都有自己的本地内存存储共享变量的副本,这可能会导致一个线程对共享变量的修改对另一个线程是不可见的问题。这里引入经典的i++是线程安全的吗问题?

    private static int i = 0;
    public static void main(String[] args) throws InterruptedException {
   
        // 线程1
        Thread t1 = new Thread(() -> {
   
            for (int k = 0; k < 10000; k++) {
   
                i++;
            }
        });
        // 线程2
        Thread t2  = new Thread(() -> {
   
            for (int k = 0; k < 10000; k++) {
   
                i++;
            }
        });
        // 启动线程
        t1.start();
        t2.start();
        // 等到两个线程执行完成
        t1.join();
        t2.join();
        System.out.println("i="+ i);
    }

这里我们的预期结果是20000,但是执行发现结果再10000~20000之间,说明对共享变量i进行i++不是线程安全的

我们假设线程 A 和线程 B 同时开始执行,那么第一次都会将 i=0 读到各自的 CPU 缓存(线程本地缓存)里,执行完 i++ 之后,各自 CPU 缓存里的值都是 1,同时写入内存后,我们会发现内存中是 1,而不是我们期望的 2。之后由于各自的 CPU 缓存里都有了 i 的值,两个线程都是基于 CPU 缓存里的 i 值来计算,所以导致最终 i 的值都是小于 20000 的。这就是缓存的可见性问题volatile关键字主要用于解决变量在多个线程之间的可见性

3.2 原子性

volatile关键字主要用于解决变量在多个线程之间的可见性,所以我在上面案例中使用volatile关键字修饰变量i,即:

private volatile static int i = 0;

再次执行程序,发现执行结果还是在10000~20000之间,这就很奇怪了,不是说volatile关键字可以解决共享变量在多个线程之间的可见性吗,为啥执行结果还是不对?volatile确实能保证共享变量的可见性,这是毋庸置疑的。这时候我们需要分享一下i++这条语句在执行需要分成几条指令?至少需要三条 CPU 指令。

  • 指令 1:首先,需要把变量 i 从内存加载到 CPU 的寄存器;这时候volatile保证了从内存的值一定最新修改之后的值
  • 指令 2:之后,在寄存器中执行 +1 操作;
  • 指令 3:最后,将结果写入内存(缓存机制导致可能写入的是 CPU 缓存而不是内存)

基于多线程执行任务,操作系统会进行多线程任务切换,可以发生在任何一条CPU 指令执行完,是的,是 CPU 指令,而不是高级语言里的一条语句。对于上面的三条指令来说,我们假设 i=0,如果线程 t1 在指令 1 执行完后做线程切换,此时线程t1的本地内存中i=0,接下来换线程t2执行执行任务,这时候线程t2执行指令1从内存加载共享变量i到本地内存i=0,再完成后续指令,然后cpu再任务切换回线程t1执行时,线程t1的本地内存变量i=0,接下来执行完后续指令,这样一来二去两个线程都是基于i=0进行了i++, 得到的结果不是我们期望的 2,而是 1。

我们潜意识里面觉得 i++ 这个操作是一个不可分割的整体,就像一个原子一样,线程的切换可以发生在 count+=1 之前,也可以发生在 i++ 之后,但就是不会发生在中间。我们把一个或者多个操作在 CPU 执行的过程中不被中断的特性称为原子性。CPU 能保证的原子操作是 CPU 指令级别的,而不是高级语言的操作符,这是违背我们直觉的地方。因此,很多时候我们需要在高级语言层面保证操作的原子性。那怎么保证原子性呢?volatile 关键字能保证数据的可见性,但不能保证数据的原子性。synchronized 关键字两者都能保证

代码逻辑修改如下:

private static int i = 0;
// 使用synchronized
synchronized static void add() {
   
    i++;
}
public static void main(String[] args) throws InterruptedException {
   
    // 线程1
    Thread t1 = new Thread(() -> {
   
        for (int k = 0; k < 10000; k++) {
   
           add();
        }
    });
    // 线程2
    Thread t2  = new Thread(() -> {
   
        for (int k = 0; k < 10000; k++) {
   
            add();
        }
    });
    // 启动线程
    t1.start();
    t2.start();
    // 等到两个线程执行完成
    t1.join();
    t2.join();
    System.out.println("i="+ i);
}

在 32 位的机器上对 long 型变量进行加减操作存在并发隐患原因是long类型64位,所以在32位的机器上,对long类型的数据操作通常需要多条指令组合出来,无法保证原子性,所以并发的时候会出问题

3.3 有序性

有序性。顾名思义,有序性指的是程序按照代码的先后顺序执行。编译器为了优化性能,有时候会改变程序中语句的先后顺序,例如程序中:a=1;b=2,编译器优化后可能变成b=2;a=1,在这个例子中,编译器调整了语句的顺序,但是不影响程序的最终结果。不过有时候编译器及解释器的优化可能导致意想不到的 Bug。经典问题:单例模式的双重检测锁实现方式。实现代码如下:

public class Singleton {
   

    private volatile static Singleton uniqueInstance;

    private Singleton() {
   
    }

    public  static Singleton getUniqueInstance() {
   
       //先判断对象是否已经实例过,没有实例化过才进入加锁代码
        if (uniqueInstance == null) {
   
            //类对象加锁
            synchronized (Singleton.class) {
   
                if (uniqueInstance == null) {
   
                    uniqueInstance = new Singleton();
                }
            }
        }
        return uniqueInstance;
    }
}

uniqueInstance 采用 volatile 关键字修饰也是很有必要。

uniqueInstance 采用 volatile 关键字修饰也是很有必要的, uniqueInstance = new Singleton(); 这段代码其实是分为三步执行:

  1. uniqueInstance 分配内存空间
  2. 初始化 uniqueInstance
  3. uniqueInstance 指向分配的内存地址

但是由于 JVM 具有指令重排的特性,执行顺序有可能变成 1->3->2。指令重排在单线程环境下不会出现问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例。例如,线程 T1 执行了 1 和 3,此时 T2 调用 getUniqueInstance() 后发现 uniqueInstance 不为空,因此返回 uniqueInstance,但此时 uniqueInstance 还未被初始化,如果我们这个时候访问 uniqueInstance 的成员变量就可能触发空指针异常

使用 volatile 可以禁止 JVM 的指令重排,保证在多线程环境下也能正常运行

4.Happens-Before 规则

首先先看下面代码:

    int x = 0;
    volatile boolean v = false;
    public void write() {
   
        x = 10;
        v = true;
    }
    public void read() {
   
        if (v == true) {
   
            System.out.println("x=" + x);
        }
    }
    public static void main(String[] args) {
   
        VolatileDemo demo = new VolatileDemo();
        // 线程1写数据x
        new Thread(() ->{
   
            demo.write();
        }).start();

        // 线程2读数据x
        new Thread(() ->{
   
            demo.read();
        }).start();
    }

假设线程 1 执行 write() 方法,按照 volatile 语义,会把变量 “v=true” 写入内存;假设线程 B 执行 read() 方法,同样按照 volatile 语义,线程 B 会从内存中读取变量 v,如果线程 B 看到 “v == true” 时,那么线程 B 看到的变量 x 是多少呢?

直觉上看,应该是 10,那实际应该是多少呢?这个要看 Java 的版本,如果在低于 1.5 版本上运行,x 可能是 10,也有可能是 0;如果在 1.5 以上的版本上运行,x 就是等于 10。

分析一下,为什么 1.5 以前的版本会出现 x = 0 的情况呢?我相信你一定想到了,变量 x 可能被 CPU 缓存而导致可见性问题。这个问题在 1.5 版本已经被圆满解决了。Java 内存模型在 1.5 版本对 volatile 语义进行了增强。怎么增强的呢?答案是一项 Happens-Before 规则。

Happens-Before 规则:前面一个操作的结果对后续操作是可见的

4.1 程序的顺序性规则

这条规则是指在一个线程中,按照程序顺序,前面的操作 Happens-Before 于后续的任意操作。这还是比较容易理解的,比如刚才那段示例代码,按照程序的顺序,代码 “x = 10;” Happens-Before 于后一行代码 “v = true;”,这就是规则 1 的内容,也比较符合单线程里面的思维:程序前面对某个变量的修改一定是对后续操作可见的。

4.2 volatile 变量规则

这条规则是指对一个 volatile 变量的写操作, Happens-Before 于后续对这个 volatile 变量的读操作。

这个就有点费解了,对一个 volatile 变量的写操作相对于后续对这个 volatile 变量的读操作可见,这怎么看都是禁用缓存的意思啊,貌似和 1.5 版本以前的语义没有变化啊?如果单看这个规则,的确是这样,但是如果我们关联一下规则 3,就有点不一样的感觉了。

4.3 传递性

这条规则是指如果 A Happens-Before B,且 B Happens-Before C,那么 A Happens-Before C

  1. “x=10” Happens-Before 写变量 “v=true” ,这是规则 1 的内容;
  2. 写变量“v=true” Happens-Before 读变量 “v=true”,这是规则 2 的内容 。
  3. 再根据这个传递性规则,我们得到结果:“x=10” Happens-Before 读变量“v=true”。

4.4 管程中锁的规则

这条规则是指对一个锁的解锁 Happens-Before 于后续对这个锁的加锁。

要理解这个规则,就首先要了解“管程指的是什么”。管程是一种通用的同步原语,在 Java 中指的就是 synchronized,synchronized 是 Java 里对管程的实现。

管程中的锁在 Java 里是隐式实现的,例如下面的代码,在进入同步块之前,会自动加锁,而在代码块执行完会自动释放锁,加锁以及释放锁都是编译器帮我们实现的。

synchronized (this) {
    // 此处自动加锁
  // x 是共享变量, 初始值 =10
  if (this.x < 12) {
   
    this.x = 12; 
  }  
} // 此处自动解锁

所以结合规则 4——管程中锁的规则,可以这样理解:假设 x 的初始值是 10,线程 A 执行完代码块后 x 的值会变成 12(执行完自动释放锁),线程 B 进入代码块时,能够看到线程 A 对 x 的写操作,也就是线程 B 能够看到 x==12。这个也是符合我们直觉的,应该不难理解。

4.5 线程 start() 规则

这条是关于线程启动的。它是指主线程 A 启动子线程 B 后,子线程 B 能够看到主线程在启动子线程 B 前的操作。

换句话说就是,如果线程 A 调用线程 B 的 start() 方法(即在线程 A 中启动线程 B),那么该 start() 操作 Happens-Before 于线程 B 中的任意操作。具体可参考下面示例代码。

Thread B = new Thread(()->{
   
  // 主线程调用 B.start() 之前
  // 所有对共享变量的修改,此处皆可见
  // 此例中,var==77
});
// 此处对共享变量 var 修改
var = 77;
// 主线程启动子线程
B.start();

4.6 线程 join() 规则

这条是关于线程等待的。它是指主线程 A 等待子线程 B 完成(主线程 A 通过调用子线程 B 的 join() 方法实现),当子线程 B 完成后(主线程 A 中 join() 方法返回),主线程能够看到子线程的操作。当然所谓的“看到”,指的是对共享变量的操作。

换句话说就是,如果在线程 A 中,调用线程 B 的 join() 并成功返回,那么线程 B 中的任意操作 Happens-Before 于该 join() 操作的返回。具体可参考下面示例代码。

Thread B = new Thread(()->{
   
  // 此处对共享变量 var 修改
  var = 66;
});
// 例如此处对共享变量修改,
// 则这个修改结果对线程 B 可见
// 主线程启动子线程
B.start();
B.join()
// 子线程所有对共享变量的修改
// 在主线程调用 B.join() 之后皆可见
// 此例中,var==66
目录
相关文章
|
2天前
|
存储 Java 程序员
Java面试题:方法区在JVM中存储什么内容?它与堆内存有何不同?
Java面试题:方法区在JVM中存储什么内容?它与堆内存有何不同?
25 10
|
2天前
|
存储 运维 Java
Java面试题:JVM的内存结构有哪些主要部分?请简述每个部分的作用
Java面试题:JVM的内存结构有哪些主要部分?请简述每个部分的作用
22 9
|
1天前
|
缓存 监控 算法
Java内存怎么优化
【7月更文挑战第11天】Java内存怎么优化
11 3
|
缓存 安全 Java
【漫画】JAVA并发编程 如何解决可见性和有序性问题
JAVA并发编程 如何解决可见性与有序性问题呢?HappensBefore八大原则来搞定!胖滚猪用漫画的形式带你迅速入门。
【漫画】JAVA并发编程 如何解决可见性和有序性问题
|
2天前
|
存储 安全 Java
Java面试题:请解释Java内存模型(JMM)是什么,它如何保证线程安全?
Java面试题:请解释Java内存模型(JMM)是什么,它如何保证线程安全?
31 13
|
1天前
|
缓存 安全 Java
Java中线程池如何管理?
【7月更文挑战第11天】Java中线程池如何管理?
8 2
|
1天前
|
安全 算法 Java
Java中线程安全怎么做?
【7月更文挑战第11天】Java中线程安全怎么做?
9 2
|
1天前
|
存储 安全 算法
深入理解Java并发编程:线程安全与性能优化
【5月更文挑战第72天】 在现代软件开发中,尤其是Java应用开发领域,并发编程是一个无法回避的重要话题。随着多核处理器的普及,合理利用并发机制对于提高软件性能、响应速度和资源利用率具有重要意义。本文旨在探讨Java并发编程的核心概念、线程安全的策略以及性能优化技巧,帮助开发者构建高效且可靠的并发应用。通过实例分析和理论阐述,我们将揭示在高并发环境下如何平衡线程安全与系统性能之间的关系,并提出一系列最佳实践方法。
|
1天前
|
Java 调度
Java线程的六种状态
Java线程有六种状态: 初始(NEW)、运行(RUNNABLE)、阻塞(BLOCKED)、等待(WAITING)、超时等待(TIMED_WAITING)、终止(TERMINATED)。
13 1