《Java并发编程的艺术》读书笔记 - 第三章 - Java内存模型

本文深入探讨Java内存模型(JMM)的基础概念,解析并发编程中的关键问题,并详细介绍JMM的抽象结构及其对重排序、happens-before规则的处理方式。此外,还分析了volatile、锁等机制的内存语义。

目录

前言

Java内存模型的基础

并发编程模型的两个关键问题

Java内存模型的抽象结构

从源代码到指令序列的重排序

happens - before 简介

重排序

数据依赖性

as-if-serial语义

顺序一致性

数据竞争与顺序一致性

顺序一致性内存模型

未同步程序的执行特性

volatile的内存语义

volatile的特性

volatile 写 - 读的内存语义

volatile内存语义的实现

锁的内存语义

锁的释放 - 获取建立的 happens - before 关系

锁的释放和获取的内存语义

锁内存语义的实现

concurrent包的实现

final 域的内存语义

final 域的重排序规则

写 final 域的重排序规则

读 final 域的重排序规则

final 域为引用类型

为什么final引用不能从构造函数内“溢出”

双重检查锁定与延迟初始化

双重检查锁定的由来

问题的根源

基于volatile的解决方案

基于类初始化的解决方案

二者对比

延迟初始化的优劣


前言

《Java虚拟机规范》中曾试图定义一种 “Java内存模型”(Java Memory Model,JMM)来屏蔽各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。 

Java内存模型的基础

并发编程模型的两个关键问题

线程之间如何通信及线程之间如何同步?

  • 共享内存:通过写 - 读内存中的公共状态进行隐式通信,显式同步(Java采用)
  • 消息传递:通过发送消息显式通信,隐式同步

Java内存模型的抽象结构

线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存中存储了该线程以读 / 写共享变量的副本。

图片来源:百度 

线程A与线程B之间如果需要进行通信必须经历以下两个步骤:

  • 线程 A 把本地内存 A 中更新过的共享变量刷新到主内存中
  • 线程 B 到主内存中读取线程 A 之前已更新过的共享变量

    实际上这个过程是线程 A 在向线程 B 发送消息,而且这个通信过程必须要经过主内存。JMM通过控制主内存与每个线程的本地内存之间的交互,来为Java程序员提供内存可见性保证。

从源代码到指令序列的重排序

重排序分为以下三种:

  • 编译器优化的重排序
  • 指令级并行的重排序
  • 内存系统的重排序 

上述 1 属于编译器重排序,2 和 3 属于处理器重排序。对于编译器,它的重排序规则会禁止特定类型的编译器重排序。对于处理器重排序,它的规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障,通过内存屏障指令来禁止特定类型的处理器重排序。

内存屏障类型表

图片来源:百度

happens - before 简介

在Java内存模型中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens - before 关系。

八个happens - before规则如下:

  • 单一线程规则:在一个线程内,在程序前面的操作先行发生于后面的操作。
  • 管程锁定规则:一个 unlock 操作先行发生于后面对同一个锁的 lock 操作。
  • volatile 变量规则:对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作。
  • 线程启动规则:Thread 对象的 start() 方法调用先行发生于此线程的每一个动作。
  • 线程加入规则:Thread 对象的结束先行发生于 join() 方法返回。
  • 线程中断规则:对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过 interrupted() 方法检测到是否有中断发生。
  • 对象终结规则:一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize() 方法的开始
  • 传递性:如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,那么操作 A 先行发生于操作 C。

    happens - before 仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前。

重排序

重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段。

数据依赖性

如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。

 图片来源:百度

这里的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。

as-if-serial语义

不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。故在单线程程序中,编译器和处理器不会对存在数据依赖关系的操作做重排序。但在多线程程序中,可能会有操作重排序改变程序的运行结果。

顺序一致性

顺序一致性内存模型是一个理论参考模型,在设计的时候,处理器的内存模型和编程语言的内存模型都会以顺序一致性内存模型作为参照。 

数据竞争与顺序一致性

Java内存模型规范对数据竞争的定义如下:

在一个线程中写一个变量,在另一个线程中读同一个变量,且写和读没有通过同步来排序。 

JMM对正确同步的多线程程序的内存一致性做了如下保证:

如果程序是正确同步的,程序的执行将具有顺序一致性 - 即程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同。 

顺序一致性内存模型

顺序一致性内存模型的两大特性

  • 一个线程中的所有操作必须按照程序的顺序来执行。
  • (不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序。在顺序一致性内存模型中,每个操作都必须原子执行且立刻对所有线程可见。

图片来源:百度

在JMM中,未同步程序不但整体的执行顺序是无序的,而且所有线程看到的操作执行顺序也可能不一致。 

未同步程序的执行特性

对于未同步或未正确同步的多线程程序,JMM只提供最小安全性:线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值(0、Null、False)。为了实现最小安全性,JVM在堆上分配对象时,首先会对内存空间进行清零,然后才会在上面分配对象(JVM内部会同步这两个操作)。因此,在已清零的内存空间分配对象时,域的默认初始化已经完成了 

未同步程序在顺序一致性模型与JMM中有如下几个差异:

  • 顺序一致性模型保证单线程内的操作会按程序的顺序执行,而JMM不保证单线程内的操作会按程序的顺序执行
  • 顺序一致性模型保证所有线程只能看到一致的操作执行顺序,而JMM不保证所有线程能看到一致的操作执行顺序
  • JMM不保证对64位的long型和double型变量的写操作具有原子性,而顺序一致性模型保证对所有的内存读 / 写操作都具有原子性

    第3个差异与处理器总线的工作机制有关。在计算机中,数据通过总线在处理器和内存之间传递。每次处理器和内存之间的数据传递都是通过一系列步骤来完成的,这被称为总线事务(Bus Transaction)。总线事务包括读事务和写事务。读事务从内存传送数据到处理器,写事务从处理器传送数据到内存,每个事务会读 / 写内存中一个或多个物理上连续的字。总线会同步试图并发使用总线的事务,在一个处理器执行总线事务期间,总线会禁止其他的处理器和 I/O 设备执行内存的读 / 写。

volatile的内存语义

volatile的特性

  • 可见性:对于一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入
  • 有序性:volatile会使用内存屏障禁止指令重排序
  • 原子性:对于任意单个volatile变量的读 / 写具有原子性,但类似于volatile++这种复合操作不具有原子性

volatile 写 - 读的内存语义

  • 写:当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存
  • 读:当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效,接下里从主内存中读取共享变量

volatile内存语义的实现

为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序,如下所示:

  • 在每个volatile写操作的前面插入一个StoreStore屏障
    禁止之前的普通写和volatile写重排序,保证在volatile写之前,之前所有的普通写操作已经对任何处理器可见。
  • 在每个volatile写操作的后面插入一个StoreLoad屏障
    禁止volatile写与之后的volatile读/写重排序。
  • 在每个volatile读操作的后面插入一个LoadLoad屏障
    禁止volatile读之后的普通读与其重排序
  • 在每个volatile读操作的后面插入一个LoadStore屏障
    禁止volatile读之前可能的volatile读和之后的普通写重排序

    在实际执行时,只要不改变volatile写 - 读的内存语义,编译器可以根据具体情况省略不必要的屏障。

锁的内存语义

锁的释放 - 获取建立的 happens - before 关系

 图片来源:百度

线程 A 在释放锁之前所有可见的共享变量,在线程 B 获取同一个锁之后,将立即变得对 B 线程可见。

锁的释放和获取的内存语义

当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中。当线程获取锁时,JMM会把该线程对应的本地内存置为无效。从而使得被监视器保护的临界区代码必须从主内存中读取共享变量。

与volatile对比:线程释放锁 = volatile写   线程获取锁 = volatile读

锁内存语义的实现

  • 公平锁和非公平锁释放时,最后都要写一个volatile变量state
  • 公平锁获取时,首先会去读volatile变量
  • 非公平锁获取时,首先会用CAS更新volatile变量,这个操作同时具有volatile读和写的内存语义

concurrent包的实现

一个通用化的实现模式

  • 声明共享变量为volatile
  • 使用CAS的原子条件更新来实现线程之间的同步
  • 配合以 volatile 的读/写和CAS所具有的volatile读和写的内存语义来实现线程之间的通信

concurrent包示意图 


 

final 域的内存语义

final 域的重排序规则

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

写 final 域的重排序规则

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

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

读 final 域的重排序规则

在一个线程中,初次读对象引用与初次读该对象包含的final域,JMM禁止处理器重排序这两个操作(这个规则仅针对处理器)。编译器会在读final域操作的前面插入一个LoadLoad屏障

这个规则可以确保在读一个对象的final域之前,一定会先读包含这个final域的对象的引用。

final 域为引用类型

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

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

为什么final引用不能从构造函数内“溢出”

在构造函数返回前,被构造对象的引用不能为其他线程所见,因为此时的final域可能还没有初始化。只要对象是正确构造的,在构造函数返回后,不需要使用同步任意线程都将能保证能看到final域正确初始化之后的值。 

双重检查锁定与延迟初始化

双重检查锁定的由来

在Java程序中,有时候可能需要推迟一些高开销的对象初始化操作,并且只有在使用这些对象时才进行初始化。此时,程序员可能会采用延迟初始化。但要正确实现线程安全的延迟初始化需要一些技巧,否则很容易出现问题。

早期用synchronized关键字同步处理实现线程安全的延迟初始化

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

由于对getInstance()方法做了同步处理,synchronized将导致性能开销。如果该方法被多个线程频繁的调用,将会导致程序执行性能的下降。

第一版本的 “双重检查锁定”(Double-Checked Locking DCL)

public class SafeLazyInitialization {

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

如果第一次检查instance不为null,那么就不需要执行下面的加锁和初始化操作。因此,可以大幅降低synchronized带来的性能开销。看起来似乎很完美,但这是一个错误的优化。 

问题的根源

instance = new SafeLazyInitialization(); 这一行代码可以分解为如下的3行伪代码:

memeory = allocate();  1:分配对象的内存空间

ctorInstance(memory); 2:初始化对象
instance = memory;      3:设置instance指向刚分配的内存地址

上面的 2 和 3之间可能存在重排序变为如下顺序

memeory = allocate();  1:分配对象的内存空间

instance = memory;      3:设置instance指向刚分配的内存地址

ctorInstance(memory); 2:初始化对象

多线程执行时序表
时间线程A线程B

t1

A1:分配对象的内存空间
t2A3:设置instance指向内存空间
t3B1:判断instance是否为空
t4B2:由于instance不为null,线程B将访问instance引用的对象
t5A2:初始化对象
t6A4:访问instance引用的对象

此时线程B将会访问到一个还未初始化的对象

基于volatile的解决方案

仅需要将instance变量加上volatile关键字修饰即可

private volatile static SafeLazyInitialization instance;

当声明对象的引用为volatile后, 2 和 3之间的重排序在多线程环境中将会被禁止

基于类初始化的解决方案

Java语言规范规定,对于每一个类或者接口C,都有一个唯一的初始化锁LC与之对应。从C到LC的映射,由JVM的具体实现去自由实现。JVM 在类初始化期间会获取这个初始化锁,并且每个线程至少获取一次锁来确保这个类已经被正确初始化过了。

public class ClassInitializationInstance {
    
    private static class InstanceHolder {
        public static ClassInitializationInstance instance = new ClassInitializationInstance();
    }
    
    public static ClassInitializationInstance getInstance() {
        return InstanceHolder.instance;
    }
    
}

根据Java语言规范,在首次发生下列任意一种情况时,一个类或接口类型T将被立刻初始化

  • T是一个类,而且一个T类型的实例被创建
  • T是一个类,且T中声明的一个静态方法被调用
  • T中声明的一个静态字段被赋值
  • T中声明的一个静态字段被使用,而且这个字段不是一个常量字段
  • T是一个顶级类,而且一个断言语句嵌套在T内部被执行 

二者对比

基于volatile的双重检查锁定的方案除了可以对静态字段实现延迟初始化外,还可以对实例字段实现延迟初始化。基于类初始化仅支持前者,但其方案实现代码更简洁。

延迟初始化的优劣

字段延迟初始化降低了初始化类或创建实例的开销,但增加了访问被延迟初始化的字段的开销。在大多数时候,正常的初始化要优于延迟初始化。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值