看完这篇,还不懂JAVA内存模型(JMM)算我输

简介: 看完这篇,还不懂JAVA内存模型(JMM)算我输

前言


开篇一个例子,我看看都有谁会?如果不会的,或者不知道原理的,还是老老实实看完这篇文章吧。

@Slf4j(topic = "c.VolatileTest")
public class VolatileTest {
    static boolean run = true;
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(() -> {
            while (run) {
                // do other things
            }
            // ?????? 这行会打印吗?
            log.info("done .....");
        });
        t.start();
        Thread.sleep(1000);
       // 设置run = false
        run = false;
    }
}

main函数中新开个线程根据标位run循环,主线程中sleep一秒,然后设置run=false,大家认为会打印"done ......."吗?


答案就是不会打印,为什么呢?


JAVA并发三大特性


我们先来解释下上面问题的原因,如下图所示,


1671202382048.jpg


现代的CPU架构基本有多级缓存机制,t线程会将run加载到高速缓存中,然后主线程修改了主内存的值为false,导致缓存不一致,但是t线程依然是从工作内存中的高速缓存读取run的值,最终无法跳出循环。


可见性


正如上面的例子,由于不做任何处理,一个线程能否立刻看到另外一个线程修改的共享变量值,我们称为"可见性"。

如果在并发程序中,不做任何处理,那么就会带来可见性问题,具体如何处理,见后文。


有序性


有序性是指程序按照代码的先后顺序执行。但是编译器或者处理器出于性能原因,改变程序语句的先后顺序,比如代码顺序"a=1; b=2;",但是指令重排序后,有可能会变成"b=2;a=1", 那么这样在并发情况下,会有问题吗?

在单线程情况下,指令重排序不会有任何影响。但是在并发情况下,可能会导致一些意想不到的bug。比如下面的例子:

public class Singleton {
  static Singleton instance;
  static Singleton getInstance(){
    if (instance == null) {
      synchronized(Singleton.class) {
        if (instance == null)
          instance = new Singleton();
        }
    }
    return instance;
  }
}

假设有两个线程 A、B 同时调用 getInstance() 方法,正常情况下,他们都可以拿到instance实例。

但往往bug就在一些极端的异常情况,比如new Singleton() 这个操作,实际会有下面3个步骤:

  1. 分配一块内存 M;
  2. 在内存 M 上初始化 Singleton 对象;
  3. 然后 M 的地址赋值给 instance 变量。

现在发生指令重排序,顺序变为下面的方式:

  1. 分配一块内存 M;
  2. 将 M 的地址赋值给 instance 变量;
  3. 最后在内存 M 上初始化 Singleton 对象。

优化后会导致什么问题呢?我们假设线程 A 先执行 getInstance() 方法,当执行完指令 2 时恰好发生了线程切换,切换到了线程 B 上;如果此时线程 B 也执行 getInstance() 方法,那么线程 B 在执行第一个判断时会发现 instance != null ,所以直接返回 instance,而此时的 instance 是没有初始化过的,如果我们这个时候访问 instance 的成员变量就可能触发空指针异常。

这就是并发情况下,有序性带来的一个问题,这种情况又该如何处理呢?

当然,指令重排序并不会瞎排序,处理器在进行重排序时,必须要考虑指令之间的数据依赖性。


原子性


1671202403878.jpg


如上图所示,在多线程的情况下,CPU资源会在不同的线程间切换。那么这样也会导致意向不到的问题。

比如你认为的一行代码:count += 1,实际上涉及了多条CPU指令:

  • 指令 1:首先,需要把变量 count 从内存加载到 CPU 的寄存器;
  • 指令 2:之后,在寄存器中执行 +1 操作;
  • 指令 3:最后,将结果写入内存(缓存机制导致可能写入的是 CPU 缓存而不是内存)。

操作系统做任务切换,可以发生在任何一条CPU 指令执行完。假设 count=0,如果线程 A 在指令 1 执行完后做线程切换,线程 A 和线程 B 按照下图的序列执行,那么我们会发现两个线程都执行了 count+=1 的操作,但是得到的结果不是我们期望的 2,而是 1。

1671202410556.jpg

我们潜意识认为的这个count+=1操作是一个不可分割的整体,就像一个原子一样,我们把一个或者多个操作在 CPU 执行的过程中不被中断的特性称为原子性。但实际情况就是不做任何处理的话,在并发情况下CPU进行切换,导致出现原子性的问题,我们一般通过加锁解决,这个不是本文的重点。


Java内存模型真面目


前面讲解并发的三大特性,其中原子性问题可以通过加锁的方式解决,那么可见性和有序性有什么解决的方案呢?其实也很容易想到,可见性是因为缓存导致,有序性是因为编译优化指令重排序导致,那么是不是可以让程序员按需禁用缓存以及编译优化, 因为只有程序员知道什么情况下会出现问题 顺着这个思路,就提出了JAVA内存模型(JMM)规范

Java 内存模型是 Java Memory Model(JMM),本身是一种抽象的概念,实际上并不存在,描述的是一组规则规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式。

1671202418403.jpg


默认情况下,JMM中的内存机制如下:

  • 系统存在一个主内存(Main Memory),Java 中所有变量都存储在主存中,对于所有线程都是共享的
  • 每条线程都有自己的工作内存(Working Memory),工作内存中保存的是主存中某些变量的拷贝
  • 线程对所有变量的操作都是先对变量进行拷贝,然后在工作内存中进行,不能直接操作主内存中的变量
  • 线程之间无法相互直接访问,线程间的通信(传递)必须通过主内存来完成

同时,JMM规范了 JVM 如何提供按需禁用缓存和编译优化的方法,主要是通过volatilesynchronizedfinal 三个关键字,那具体的规则是什么样的呢?

JMM 中的主内存、工作内存与 JVM 中的 Java 堆、栈、方法区等并不是同一个层次的内存划分,这两者基本上是没有关系的。


Happens-Before规则


JMM本质上包含了一些规则,那这个规则就是大家有所耳闻的Happens-Before规则,大家都理解了些规则吗?

Happens-Before规则,可以简单理解为如果想要A线程发生在B线程前面,也就是B线程能够看到A线程,需要遵循6个原则。如果不符合 happens-before 规则,JMM 并不能保证一个线程的可见性和有序性。

1.程序的顺序性规则

在一个线程中,逻辑上书写在前面的操作先行发生于书写在后面的操作。

这个规则很好理解,同一个线程中他们是用的同一个工作缓存,是可见的,并且多个操作之间有先后依赖关系,则不允许对这些操作进行重排序。

2.volatile变量规则

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

怎么理解呢?比如线程A对volatile变量进行写操作,那么线程B读取这个volatile变量是可见的,就是说能够读取到最新的值。

3.传递性

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

这个规则也比较容易理解,不展开讨论了。

  1. 锁的规则

这条规则是指对一个锁的解锁 Happens-Before于后续对这个锁的加锁,这里的锁要是同一把锁, 而且用synchronized或者ReentrantLock都可以。

如下代码的例子:

synchronized (this) { // 此处自动加锁
  // x 是共享变量, 初始值 =10
  if (this.x < 12) {
    this.x = 12; 
  }  
} // 此处自动解锁
  • 假设 x 的初始值是 8,线程 A 执行完代码块后 x 的值会变成 12(执行完自动释放锁)
  • 线程 B 进入代码块时,能够看到线程 A 对 x 的写操作,也就是线程 B 能够看到 x==12

5.线程start() 规则

主线程 A 启动子线程 B 后,子线程 B 能够看到主线程在启动子线程 B 前的操作。

这个规则也很容易理解,线程 A 调用线程 B 的 start() 方法(即在线程 A 中启动线程 B),那么该 start() 操作 Happens-Before 于线程 B 中的任意操作。

6.线程join() 规则

线程 A 中,调用线程 B 的 join() 并成功返回,那么线程 B 中的任意操作 Happens-Before 于该 join() 操作的返回。


使用JMM规则


我们现在已经基本讲清楚了JAVA内存模型规范,以及里面关键的Happens-Before规则,那有啥用呢?回到前言的问题中,我们是不是可以使用目前学到的关于JMM的知识去解决这个问题。

方案一: 使用volatile

1671202434745.jpg


根据JMM的第2条规则,主线程写了volatile修饰的run变量,后面的t线程读取的时候就可以看到了。

方案二:使用锁

1671202441836.jpg


利用synchronized锁的规则,主线程释放锁,那么后续t线程加锁就可以看到之前的内容了。

小结:

volatile 关键字

  • 保证可见性
  • 不保证原子性
  • 保证有序性(禁止指令重排)

volatile 修饰的变量进行读操作与普通变量几乎没什么差别,但是写操作相对慢一些,因为需要在本地代码中插入很多内存屏障来保证指令不会发生乱序执行,但是开销比锁要小。volatile的性能远比加锁要好。

synchronized 关键字

  • 保证可见性
  • 不保证原子性
  • 保证有序性

加了锁之后,只能有一个线程获得到了锁,获得不到锁的线程就要阻塞,所以同一时间只有一个线程执行,相当于单线程,由于数据依赖性的存在,单线程的指令重排是没有问题的。

线程加锁前,将清空工作内存中共享变量的值,使用共享变量时需要从主内存中重新读取最新的值;线程解锁前,必须把共享变量的最新值刷新到主内存中。


总结


本文讲解了JAVA并发的3大特性,可见性、有序性和原子性。从而引出了JAVA内存模型规范,这主要是为了解决并发情况下带来的可见性和有序性问题,主要就是定义了一些规则,需要我们程序员懂得这些规则,然后根据实际场景去使用,就是使用volatilesynchronizedfinal关键字,主要final关键字也会让其他线程可见,并且保证有序性。那么具体他们底层的实现是什么,是如何保证可见和有序的,我们后面详细讲解。

目录
相关文章
|
3月前
|
Java 大数据 Go
从混沌到秩序:Java共享内存模型如何通过显式约束驯服并发?
并发编程旨在混乱中建立秩序。本文对比Java共享内存模型与Golang消息传递模型,剖析显式同步与隐式因果的哲学差异,揭示happens-before等机制如何保障内存可见性与数据一致性,展现两大范式的深层分野。(238字)
108 4
|
3月前
|
存储 缓存 Java
【深入浅出】揭秘Java内存模型(JMM):并发编程的基石
本文深入解析Java内存模型(JMM),揭示synchronized与volatile的底层原理,剖析主内存与工作内存、可见性、有序性等核心概念,助你理解并发编程三大难题及Happens-Before、内存屏障等解决方案,掌握多线程编程基石。
|
3月前
|
设计模式 缓存 Java
【JUC】(4)从JMM内存模型的角度来分析CAS并发性问题
本篇文章将从JMM内存模型的角度来分析CAS并发性问题; 内容包含:介绍JMM、CAS、balking犹豫模式、二次检查锁、指令重排问题
137 1
|
4月前
|
安全 Java 应用服务中间件
Spring Boot + Java 21:内存减少 60%,启动速度提高 30% — 零代码
通过调整三个JVM和Spring Boot配置开关,无需重写代码即可显著优化Java应用性能:内存减少60%,启动速度提升30%。适用于所有在JVM上运行API的生产团队,低成本实现高效能。
444 3
|
4月前
|
缓存 监控 Kubernetes
Java虚拟机内存溢出(Java Heap Space)问题处理方案
综上所述, 解决Java Heap Space溢出需从多角度综合施策; 包括但不限于配置调整、代码审查与优化以及系统设计层面改进; 同样也不能忽视运行期监控与预警设置之重要性; 及早发现潜在风险点并采取相应补救手段至关重要.
641 17
|
5月前
|
存储 监控 算法
Java垃圾回收机制(GC)与内存模型
本文主要讲述JVM的内存模型和基本调优机制。
|
5月前
|
存储 缓存 Java
Java数组全解析:一维、多维与内存模型
本文深入解析Java数组的内存布局与操作技巧,涵盖一维及多维数组的声明、初始化、内存模型,以及数组常见陷阱和性能优化。通过图文结合的方式帮助开发者彻底理解数组本质,并提供Arrays工具类的实用方法与面试高频问题解析,助你掌握数组核心知识,避免常见错误。
|
5月前
|
边缘计算 算法 Java
Java 绿色计算与性能优化:从内存管理到能耗降低的全方位优化策略与实践技巧
本文探讨了Java绿色计算与性能优化的技术方案和应用实例。文章从JVM调优(包括垃圾回收器选择、内存管理和并发优化)、代码优化(数据结构选择、对象创建和I/O操作优化)等方面提出优化策略,并结合电商平台、社交平台和智能工厂的实际案例,展示了通过Java新特性提升性能、降低能耗的显著效果。最终指出,综合运用这些优化方法不仅能提高系统性能,还能实现绿色计算目标,为企业节省成本并符合环保要求。
210 0
|
5月前
|
监控 Kubernetes Java
最新技术栈驱动的 Java 绿色计算与性能优化实操指南涵盖内存优化与能效提升实战技巧
本文介绍了基于Java 24+技术栈的绿色计算与性能优化实操指南。主要内容包括:1)JVM调优,如分代ZGC配置和结构化并发优化;2)代码级优化,包括向量API加速数据处理和零拷贝I/O;3)容器化环境优化,如K8s资源匹配和节能模式配置;4)监控分析工具使用。通过实践表明,这些优化能显著提升性能(响应时间降低40-60%)同时降低资源消耗(内存减少30-50%,CPU降低20-40%)和能耗(服务器功耗减少15-35%)。建议采用渐进式优化策略。
254 1
|
6月前
|
SQL 缓存 安全
深度理解 Java 内存模型:从并发基石到实践应用
本文深入解析 Java 内存模型(JMM),涵盖其在并发编程中的核心作用与实践应用。内容包括 JMM 解决的可见性、原子性和有序性问题,线程与内存的交互机制,volatile、synchronized 和 happens-before 等关键机制的使用,以及在单例模式、线程通信等场景中的实战案例。同时,还介绍了常见并发 Bug 的排查与解决方案,帮助开发者写出高效、线程安全的 Java 程序。
342 0

热门文章

最新文章