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
目录
相关文章
|
28天前
|
Java 编译器 开发者
深入理解Java内存模型(JMM)及其对并发编程的影响
【9月更文挑战第37天】在Java的世界里,内存模型是隐藏在代码背后的守护者,它默默地协调着多线程环境下的数据一致性和可见性问题。本文将揭开Java内存模型的神秘面纱,带领读者探索其对并发编程实践的深远影响。通过深入浅出的方式,我们将了解内存模型的基本概念、工作原理以及如何在实际开发中正确应用这些知识,确保程序的正确性和高效性。
|
10天前
|
存储 Java
深入探讨了Java集合框架中的HashSet和TreeSet,解析了两者在元素存储上的无序与有序特性。
【10月更文挑战第16天】本文深入探讨了Java集合框架中的HashSet和TreeSet,解析了两者在元素存储上的无序与有序特性。HashSet基于哈希表实现,添加元素时根据哈希值分布,遍历时顺序不可预测;而TreeSet利用红黑树结构,按自然顺序或自定义顺序存储元素,确保遍历时有序输出。文章还提供了示例代码,帮助读者更好地理解这两种集合类型的使用场景和内部机制。
27 3
|
12天前
|
存储 Java 开发者
HashSet和TreeSet教你重新认识Java集合的无序与有序
【10月更文挑战第14天】本文深入探讨了Java集合框架中的HashSet和TreeSet,解析了它们分别实现无序和有序存储的机制。通过理解HashSet基于哈希表的无序特性和TreeSet利用红黑树实现的有序性,帮助开发者更好地选择合适的集合类型以满足不同的应用场景。
12 2
|
14天前
|
存储 Java
Java内存模型
【10月更文挑战第11天】Java 内存模型(JMM)是 Java 虚拟机规范中定义的多线程内存访问机制,解决内存可见性、原子性和有序性问题。它定义了主内存和工作内存的概念,以及可见性、原子性和有序性的规则,确保多线程环境下的数据一致性和操作正确性。使用 `synchronized` 和 `volatile` 等同步机制可有效避免数据竞争和不一致问题。
27 3
|
14天前
|
缓存 安全 Java
使用 Java 内存模型解决多线程中的数据竞争问题
【10月更文挑战第11天】在 Java 多线程编程中,数据竞争是一个常见问题。通过使用 `synchronized` 关键字、`volatile` 关键字、原子类、显式锁、避免共享可变数据、合理设计数据结构、遵循线程安全原则和使用线程池等方法,可以有效解决数据竞争问题,确保程序的正确性和稳定性。
27 2
|
16天前
|
存储 监控 算法
深入理解Java内存模型与垃圾回收机制
【10月更文挑战第10天】深入理解Java内存模型与垃圾回收机制
16 0
|
3月前
|
存储 编译器 C语言
【C语言篇】数据在内存中的存储(超详细)
浮点数就采⽤下⾯的规则表⽰,即指数E的真实值加上127(或1023),再将有效数字M去掉整数部分的1。
297 0
|
5天前
|
存储 C语言
数据在内存中的存储方式
本文介绍了计算机中整数和浮点数的存储方式,包括整数的原码、反码、补码,以及浮点数的IEEE754标准存储格式。同时,探讨了大小端字节序的概念及其判断方法,通过实例代码展示了这些概念的实际应用。
13 1
|
9天前
|
存储
共用体在内存中如何存储数据
共用体(Union)在内存中为所有成员分配同一段内存空间,大小等于最大成员所需的空间。这意味着所有成员共享同一块内存,但同一时间只能存储其中一个成员的数据,无法同时保存多个成员的值。
|
14天前
|
存储 弹性计算 算法
前端大模型应用笔记(四):如何在资源受限例如1核和1G内存的端侧或ECS上运行一个合适的向量存储库及如何优化
本文探讨了在资源受限的嵌入式设备(如1核处理器和1GB内存)上实现高效向量存储和检索的方法,旨在支持端侧大模型应用。文章分析了Annoy、HNSWLib、NMSLib、FLANN、VP-Trees和Lshbox等向量存储库的特点与适用场景,推荐Annoy作为多数情况下的首选方案,并提出了数据预处理、索引优化、查询优化等策略以提升性能。通过这些方法,即使在资源受限的环境中也能实现高效的向量检索。