Java内存模型知识点小结---《深入理解Java内存模型》(程晓明)读书总结

一、Java内存模型介绍

内存模型的作用范围:

在Java中,所有实例域、静态域和数组元素存放在堆内存中,线程之间共享,下文称之为“共享变量”。局部变量、方法参数、异常处理器等不会在线程之间共享,不存在内存可见性问题,也不受内存模型的影响。

重排序与可见性:

现代编译器在编译源码时会做一些优化处理,对代码指令进行重排序;现代流水线结构的处理器为了提高并行度,在执行时也可能对指令做一些顺序上的调整。重排序包括编译器重排序、指令级并行重排序和内存系统重排序等。一般来说,编译器和处理器在做重排序的时候都会做一些保证,保证程序的执行结果与重排序之前指令的执行结果相同。即as-if-serial,不管怎样重排序,都不能改变程序的执行结果。

CPU在执行指令时一般都会使用缓存技术来提高效率,如果不同线程使用不同的缓存空间则会造成一个线程对一个共享变量的更新不能及时反映给其他线程,也就是多线程对共享变量更新的可见性问题,这个问题是非常复杂的。

Java内存模型的抽象:

对于上述问题,Java内存模型(JMM)为程序员提供了一个抽象层面的描述,我们不用去关心编译器、处理器对指令做了怎样的重排序,也不用关心复杂的系统缓存机制,只要遵循JMM的规则,JMM就能为我们提供代码顺序性、共享变量可见性的保证,从而得到预期的执行结果。

JMM决定了一个线程对共享变量的写入何时对另一个线程可见。从抽象来讲,线程共享变量存放在主内存(main memory),每个线程持有一个本地内存(local memory),本地内存中存储了该线程读写共享变量的副本(本地内存是JMM的一个抽象概念,并不是真实存在的)。如下图:

如果A、B两个线程要通信要经过以下两步:首先线程A将本地内存中更新过的共享变量刷新到主内存中,然后线程B到主内存中读取A之前更新过的变量。

JMM通过控制主内存与每个线程的本地内存之间的交互来为Java程序员提供可见性保证。

重排序:

现代编译器和处理器会对指令执行的顺序进行重排序,以此提高程序的性能。这些重排序可能会导致多线程程序出现内存可见性问题。为了不改变程序的执行结果,对于编译器,JMM会禁止特定类型的编译器重排序;对于处理器重排序,JMM要求在Java编译生产指令序列时,插入特定类型的内存屏障(memory barriers)来禁止特定类型的重排序。

JMM把内存屏障分为以下四类:

屏障类型 指令示例 说明
LoadLoad Barriers Load1; LoadLoad; Load2 确保Load1数据的装载之前于在Load2及其所有后续装载指令
StoreStore Barriers Store1; StoreStore; Store2 确保Store1刷新数据到内存之前与Store2及其后续存储指令
LoadStore Barriers Load1; LoadStore; Store2 确保Load1数据装载之前于Store2及其后续存储指令
StoreLoad Barriers Store1; StoreLoad; Load2
确保Store1刷新数据到内存之前于Load2及其后续装载指令。

StoreLoad Barriers会使该屏障之前的所有内存访问指令完成后才执行屏障后的指令。

StoreLoad Barriers是一个全能型屏障,同时具有其他三个屏障的效果。

Happens-before:

从JDK1.5开始,Java使用新的JSR-133内存模型(以下所有都是针对该内存模型讲的),使用happens-before的概念来阐述操作之间的内存可见性。

如果一个操作要对另一个操作可见,那这两个操作之间必须存在happens-before关系。这两个操作可以在一个线程内,也可以在不同线程之间。ps.(两个操作存在happens-before关系并不意味着前一个操作必须在后一个操作之前执行,仅仅要求前一个操作对后一个操作可见。)

常见的与程序员相关的happens-before规则如下:

①程序顺序规则:一个线程中的每个操作happens-before于其后的任意操作;

②监视器锁规则:对一个监视器的解锁happens-before于随后对这个监视器的加锁;

③volatile规则:对一个volatile域的写happens-before于任意后续对该域的读操作(该规则多个线程之间也成立);

④传递性:如果A happens-before B,且B happens-before C,那么A happens-before C

数据依赖性:如果两个操作访问同一个变量,且这两个操作其中一个为写操作时,这两个操作就存在数据依赖性。如下示例:

写后读
a=1;

b=a;

写后写
a=1;

a=2;

读后写
a=b;

b=a;

上述三类情况存在数据依赖性,此时不允许重排序,否则程序的结果可能会改变。

as-if-serial语义:

as-if-serial语义的意思是:在单线程内,不管怎么重排序,程序的执行结果不变,在程序员看来,就像顺序执行的一样。

示例:

 
a = 1; //A
b = 2; //B
c = a + b; //C

前两条语句就可以进行重排序,而第三条语句与前两条存在依赖关系,不能重排序。

上述A happens-before B,B happens-before C,但并不保证A在B之前执行,只需要保证操作A对B可见(这里A操作不需要对B可见,因此可以重排)

重排序对多线程的影响:

示例:

 
class Demo {
    boolean flag = false;
    int a = 0;
   
    public void fun1() {
        a = 1; //A
        flag = true; //B
    }
   
    public void fun2() {
        if (flag) { //C
            a = a + a; //D
        }
    }
}

假设上述类中fun1()和fun2()在不同线程中执行,操作A、B没有依赖关系,可能被重排序;操作C、D虽然存在控制依赖关系,现代编译器和处理器为了提高并行度,可能采取激进的方法(即先求出if语句块中的值存于临时变量中,如果if条件为真则使用该值,否则丢弃)对其进行重排序,这都可能改变程序的执行结果。

顺序一致性内存模型:

计算机科学家们提出了一个理想化的理论参考模型--顺序一致性模型,它为程序员提供了极强的内存可见性,具有如下两大特性:

①一个线程中的所有操作必须按照程序顺序来执行;

②所有线程(无论同步与否)都只能看到一个单一的操作执行顺序。每个操作都必须是原子的且立刻对所有线程可见。

示例:

假设有A和B两个线程并发执行,A线程中有三个操作,顺序是A1->A2->A3,线程B中也有三个操作,顺序是B1->B2->B3。 先假设这两个线程使用监视器同步,A线程先获得监视器,执行完毕释放监视器后线程B开始执行。那么他们在顺序一致性模型中执行效果如下:

现在我们再假设这两个线程未进行同步,其在顺序一致性模型中执行效果如下:

可以看到,未同步的程序在顺序一致性模型中虽然整体执行顺序是无序的,但所有线程都只看到一个一致的整体执行顺序。如上图,线程A和B看到的执行顺序都是B1->A1->A2->B2-A3->B3。之所以能得到这个保证是因为顺序一致性内存模型中的每个操作必须立即对任何线程可见。

但是JMM中没有这个保证。比如当前线程写数据到本地内存中,在还没有刷新到主内存之前,这个写操作只对当前线程可见,从其他线程角度观察,可以认为这个写操作根本还没有被当前线程执行过。这种情况下,当前线程和其他线程看到的操作执行顺序将不一致。

同步程序的一致性效果:

示例:

 
class SynchronizedDemo {
    int a = 0;
    boolean flag = false;
    public synchronized void write() {
        a = 1;
        flag = true;
    }

public synchronized void read() {
        if(flag) {
            int i = a;
        }
    }
}

上述代码使用同步方法,线程A先执行write()方法,释放锁后线程B获取锁并执行read()方法,执行流程如下:

在顺序一致性模型中,所有操作按顺序执行。在JMM中,临界区内的代码可以重排序(JMM不允许临界区内的代码“逸出”到临界区之外),JMM会在进入和退出临界区的关键点上做一些限定,使得现场在这两个关键点处具有和顺序一致性模型具有相同的内存视图。虽然现场A在临界区内做了重排序,但由于监视器的互斥性,这里线程B根本无法“观察”到线程A在临界区内的重排序,这样既提高了效率又不改变程序的执行结果。

对于未同步的多线程程序,JMM只提供最小安全性:线程执行读操作取得的值,要么是之前线程写入的,要么是默认值(0,null,false),JMM保证线程读取的数据不是无中生有冒出来的。为了实现最小安全,JVM在堆上分配对象时首先会清空内存空间,然后才分配对象(因此对象分配时,域的默认初始化已经完成)。

此外,JMM的最小安全不保证对64位的long和double型变量的读写具有原子性,而顺序一致性模型保证对所有内存读写操作具有原子性。

二、Volatile特性

volatile变量的单次读写,相当于使用了一个锁对这些单个读/写做了同步。

原子性:对volatile变量的单次读写操作具有原子性(ps.这里存在争议,暂且这么写,保留意见);

可见性:锁的happens-before规则保证释放锁和获取锁的两个线程之间的可见性,这意味着对一个volatile变量的读操作总能看到之前任意线程对这个volatile变量最后的写入,即对volatile变量的写操作对其他线程立即可见。

当写一个volatile变量时,JMM会把线程对应的本地内存中的共享变量刷新到主内存;

当读一个volatile变量时,JMM会把改下昵称对应的本地内存置为无效,接下来从主内存中读取共享变量的值。

从内存语义的角度来说,volatile的写-读于锁的释放-获取具有相同的内存效果。因此如果线程A对volatile变量的写操作在线程B对volatile变量的读操作之前,则其存在happens-before关系。

示例:

class VolatileDemo {
     volatile boolean flag = false;
     int a=0;
public void fun1() {
      a=1; //A
      flag = true; //B
    } 

    public void fun2() {
        if (flag) { //C
            a=a+a; //D
        }
    }
}

上述操作A happens-before 操作B,操作C happens-before 操作D,如果线程1调用fun1()方法之后线程2调用fun2()方法,则操作B happens-before 操作C,根据happens-before的传递性,则有A happens-before D,因此可以保证操作D可以正确读取到操作A的赋值。

Volatile的内存语义是JMM通过在volatile读写操作前后插入内存屏障实现的。

三、锁的特性

锁的释放与获取遵循happens-before规则,释放锁线程临界区的操作结果对获取锁的线程可见。

当线程释放锁时,JMM会把线程对应的本地内存中的共享变量刷新到主内存;

当线程获取锁时,JMM会把改下昵称对应的本地内存置为无效,接下来从主内存中读取共享变量的值。

ReentrantLock是java.util.concurrent.locks包下的一个锁的实现,依赖对volatile变量的读写和compareAndSet(CAS)操作实现锁机制。其中CAS操作使用不同的CPU指令实现单次操作的原子性,具有volatile读写操作相同的内存语义。 类图如下:

ReentrantLock根据对抢占锁的线程的处理方式不同,分为公平锁和非公平锁,首先看公平锁,使用公平锁加锁时,加锁方法lock()的方法调用主要有以下四步:

1. ReentrantLock : lock()
2. FairSync : lock()
3. AbstractQueuedSynchronizer : acquire(int arg)
4. ReentrantLock : tryAcquire(int acquires)

在第四步才开始真正加锁,该方法的源码如下:

protected final boolean tryAcquire(int acquires) {
            final Thread current = Thread.currentThread();
            int c = getState();();//获取锁的开始,state是volatile类型变量
            if (c == 0) {
                if (!hasQueuedPredecessors() &&
                    compareAndSetState(0, acquires)) {
                    setExclusiveOwnerThread(current);
                    return true;
                }
            }
            else if (current == getExclusiveOwnerThread()) {
                int nextc = c + acquires;
                if (nextc < 0)
                    throw new Error("Maximum lock count exceeded");
                setState(nextc);
                return true;
            }
            return false;
        }

从上面方法可以看出,加锁方法首先读取volatile变量state。

使用公平锁的unlock()方法调用轨迹如下:

1. ReentrantLock : unlock()
2. AbstractQueuedSynchronizer : release(int arg)
3. Sync : tryRelease(int releases)

在第三步调用时才真正开始释放锁,该方法源码如下:

protected final boolean tryRelease (int releases){
    int c = getState() - releases;
    if (Thread.currentThread() != getExclusiveOwnerThread())
        throw new IllegalMonitorStateException();
    boolean free = false ;
    if (c == 0){
        free = true ;
        setExclusiveOwnerThread( null );
    }
    setState(c);//释放锁后,写volatile变量state
    return free;
}

从上面代码可以看出,在释放锁的最后写volatile变量state。

公平锁在释放锁的最后写volatile变量state,在获取锁的时候首先读这个volatile变量。根据volatile的happens-before规则,释放锁的线程在写volatile变量之后该变量对获取锁的线程可见。

Java中的CAS操作同时具有volatile读和volatile写的内存语义,因此Java线程之间通信现在有了以下四种方式:

1、A线程写volatile变量,随后B线程读这个volatile变量。

2、A线程写volatile变量,随后B线程用CAS更新这个volatile变量。

3、A线程用CAS更新一个volatile变量,随后B线程用CAS更新这个volatile变量。

4、A线程用CAS更新一个volatile变量,随后B线程读这个volatile变量。A线程写

Java的CAS会使用现代处理器上提供的高效机器级别原子指令,这些原子指令以原子方式对内存执行读-改-写操作,同时,volatile变量的读/写和CAS可以实现线程之间的通信。把这些特性整合在一起,就形成了整个concurrent包得以实现的基石。如果我们仔细分析concurrent包的源代码实现,会发现一个通用化的实现模式:

1、首先,声明共享变量为volatile;

2、然后,使用CAS的原子条件更新来实现线程之间的同步;

3、同时,配合以volatile的读/写和CAS所具有的volatile读和写的内存语义来实现线程之间的通信。

四、Final 的特性

与前面介绍的锁和volatile相比较,对final域的读和写更像是普通的变量访问。对于final域,编译器和处理器要遵守两个(分别对应读写)重排序规则:

1、在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。

2、初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序。

写final域的重排序规则

写final域的重排序规则禁止把final域的写重排序到构造函数之外。这个规则的实现包含下面2个方面:

1、JMM禁止编译器把final域的写重排序到构造函数之外。

2、编译器会在final域的写之后,构造函数return之前,插入一个StoreStore屏障。这个屏障禁止处理器把final域的写重排序到构造函数之外 。

写final域的重排序规则可以确保:在对象引用为任意线程可见之前,对象的final域已经被正确初始化过了,而普通域不具有这个保障

对于引用类型,写final域的重排序规则对编译器和处理器增加了如下约束:

在构造函数内对一个final引用的对象的成员域的写入,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。

读final域的重排序规则

在一个线程中,初次读对象引用与初次读该对象包含的final域,JMM禁止处理器重排序这两个操作(注意,这个规则仅仅针对处理器)。编译器会在读final域操作的前面插入一个LoadLoad屏障。初次读对象引用与初次读该对象包含的final域,这两个操作之间存在间接依赖关系。由于编译器遵守间接依赖关系,因此编译器不会重排序这两个操作。

读final域的重排序规则可以确保:在读一个对象的final域之前,一定会先读包含这个final域的对象的引用。在这个示例程序中,如果该引用不为null,那么引用对象的final域一定已经被A线程初始化过了。

时间: 2024-10-06 00:40:52

Java内存模型知识点小结---《深入理解Java内存模型》(程晓明)读书总结的相关文章

Java基础系列1:深入理解Java数据类型

Java基础系列1:深入理解Java数据类型 当初学习计算机的时候,教科书中对程序的定义是:程序=数据结构+算法,Java基础系列第一篇就聊聊Java中的数据类型. 本篇聊Java数据类型主要包括四个内容: Java基本类型 Java封装类型 自动装箱和拆箱 封装类型缓存机制 Java基本类型 Java基本类型分类.大小及表示范围 Java的基本数据类型总共有8种,包括三类:数值型,字符型,布尔型,其中 数值型: 整数类型:byte.short.int.long 浮点类型:float.doubl

Java内存模型与线程 深入理解Java虚拟机总结

在许多情况下,让计算机同时去做几件事情,不仅是因为计算机的运算能力强大了,还有一个很重要的原因是计算机的运算速度与它的存储和通信子系统速度的差距太大, 大量的时间都花费在磁盘I/O.网络通信或者数据库访问上. 如果不希望处理器在大部分时间里都处于等待其他资源的状态,就必须使用一些手段去把处理器的运算能力 " 压榨 " 出来, 否则就会造成很大的浪费,而计算机同时处理几项任务则是最容易想到.也被证明是非常有效的 " 压榨 " 手段. 除了充分利用计算机处理器的能力外,

Java并发指南2:深入理解Java内存模型JMM

一:JMM基础与happens-before 并发编程模型的分类 在并发编程中,我们需要处理两个关键问题:线程之间如何通信及线程之间如何同步(这里的线程是指并发执行的活动实体).通信是指线程之间以何种机制来交换信息.在命令式编程中,线程之间的通信机制有两种:共享内存和消息传递. 在共享内存的并发模型里,线程之间共享程序的公共状态,线程之间通过写-读内存中的公共状态来隐式进行通信.在消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过明确的发送消息来显式进行通信. 同步是指程序用于控制不同

夯实Java基础系列10:深入理解Java中的异常体系

目录 为什么要使用异常 异常基本定义 异常体系 初识异常 异常和错误 异常的处理方式 "不负责任"的throws 纠结的finally throw : JRE也使用的关键字 异常调用链 自定义异常 异常的注意事项 当finally遇上return JAVA异常常见面试题 参考文章 微信公众号 Java技术江湖 个人公众号:黄小斜 - Java异常 本系列文章将整理到我在GitHub上的<Java面试指南>仓库,更多精彩内容请到我的仓库里查看 https://github.c

夯实Java基础系列13:深入理解Java中的泛型

目录 泛型概述 一个栗子 特性 泛型的使用方式 泛型类 泛型接口 泛型通配符 泛型方法 泛型方法的基本用法 类中的泛型方法 泛型方法与可变参数 静态方法与泛型 泛型方法总结 泛型上下边界 泛型常见面试题 参考文章 微信公众号 Java技术江湖 个人公众号:黄小斜 本系列文章将整理到我在GitHub上的<Java面试指南>仓库,更多精彩内容请到我的仓库里查看 https://github.com/h2pl/Java-Tutorial 喜欢的话麻烦点下Star.Fork.Watch三连哈,感谢你的

垃圾收集器与内存分配策略(深入理解Java虚拟机)

3.1 概述 垃圾收集器要解决哪些问题? 哪些内存需要回收 什么时候回收 如何回收 引用计数算法:当有一个地方引用,+1,引用失效,-1.     缺点:对象之间相互循环引用的问题. 可达性分析算法: 思路:通过一系列的成为"Gc Roots"的对象作为起始点,从这些节点开始向下探索,搜索所走过的路径成为引用链(Reference Chain),当一个对象到Gc Roots没有任何引用链相连,则则很难革命此对象是不可用的. Java语言中GC Roots的对象包括下面几种: 1.虚拟机

Java GC专家系列1:理解Java垃圾回收

了解Java的垃圾回收(GC)原理能给我们带来什么好处?对于软件工程师来说,满足技术好奇心可算是一个,但重要的是理解GC能帮忙我们更好的编写Java应用程序. 上面是我个人的主观的看法,但我相信熟练掌握GC是成为优秀Java程序员的必备技能.如果你对GC执行过程感兴趣,也许你只是有一定的开发应用的经验:如果你仔细考虑过如何选择合适的GC算法,说明你对你所开发的程序有了全面的了解.当然这对一个优秀的程序员来说未必是一个通用的标准,但很少人会反对我关于”理解GC是作为优秀Java程序员的必备技能”的

Java基础系列5:深入理解Java异常体系

该系列博文会告诉你如何从入门到进阶,一步步地学习Java基础知识,并上手进行实战,接着了解每个Java知识点背后的实现原理,更完整地了解整个Java技术体系,形成自己的知识框架. 前言: Java的基本理念是“结构不佳的代码不能运行”. “异常”这个词有“我对此感到意外”的意思.问题出现了,你也许不清楚该如何处理,但你的确知道不应该置之不理:你要停下来,看看是不是有别人或在别的地方,能够处理这个问题.只是在当前的环境中还没有足够的信息来解决这个问题,所以就把这个问题提交到一个更高级别的环境中,在

理解Java中的弱引用(Weak Reference)

理解Java中的弱引用(Weak Reference) 本篇文章尝试从What.Why.How这三个角度来探索Java中的弱引用,理解Java中弱引用的定义.基本使用场景和使用方法.由于个人水平有限,叙述中难免存在不准确或是不清晰的地方,希望大家可以指出,谢谢大家:) 1. What——什么是弱引用? Java中的弱引用具体指的是java.lang.ref.WeakReference<T>类,我们首先来看一下官方文档对它做的说明: 弱引用对象的存在不会阻止它所指向的对象变被垃圾回收器回收.弱引