Java并发编程的艺术(三)

简介

本系列为《Java并发编程的艺术》读书笔记。在原本的内容的基础上加入了自己的理解和笔记,欢迎交流!

chapter 3 Java内存模型(Java Memory Model JMM)

本章节包括四个部分:

  1. 内存模型基础;
  2. 顺序一致性:指令重排序与顺序内存一致性模型;
  3. 同步原语:synchronized、volatile、final。
  4. 内存的设计模型。

1. 内存模型基本概念介绍

1.1 并发中两个关键问题:(非常关键)

  1. 线程间如何通信;共享内存和消息传递。
    1. 共享内存是指多线程访问公共的内容,是一种隐式的通信;
    2. 消息传递是发送消息的方式,是显示的传递。
  2. 线程间如何同步:同步是指控制多线程并发操作的相对顺序的机制。根据通信方式的不同,会有不同的同步方式。
    1. 显式同步:共享内存的模型中,我们需要指定互斥执行的同步代码块;
    2. 隐式同步:消息传递的模型中,消息的发送必须等到消息的获取才可以,所以是隐式的。

1.2 内存可见性

Java中内存可见性是采用的共享内存的方式。线程的通信是隐式的,但是需要显示地指定需要同步的内容。

1.3 内存模型

多线程并发存在的问题一定是在对共享的内容操作时引发的。JVM中,共享的区域是堆区。java线程之间的通信是通过Java Memory Model控制。

由于Java采用的是共享内存的通信模型,所以AB线程之间想通信,就必须通过修改主存

比如此时A想修改一个共享变量value的值,那么A线程就会将修改的值写会到主存中,并通过一致性协议,让B中的副本失效,此时B如果要使用value 的时候会发现本地的副本失效了,就会去主存中读取最新的值。
在这里插入图片描述

1.3 指令重排

指令重排是为了优化程序的性能,但是在多线程的情况下可能会存在风险:

  1. 编译器的优化重排序:JMM编译器重排序规则会禁止特定类型的编译器重排序;
  2. 处理器重排序:JMM会在特定位置插入内存屏障(Memory Barriers),通过内存屏障来禁止某些处理重排序。
    1. 指令级并行重排序
    2. 内存系统重排序

1.4 内存屏障

由于现代处理器会在使用写缓冲区来加速对数据的处理(每个处理器私有一个写缓冲区),加上使用指令重排,带来的问题就是处理器执行的内存操作的顺序可能与内存实际的操作执行顺序不一致
因此,JMM需要使用内存屏障来确保某些对数据的操作的顺序一致(即在某些情况下不允许对指令重排)。常用内存屏障有四类:
在这里插入图片描述

1.4 happens-before语义

happens-before用于向编程人员描述内存可见性,非常重要。即:如果一个操作A执行的结果想对另一个操作B可见,那么AB之间需要满足happens-before关系

2. 重排序

之所以要重排序,是为了提高并行度,加速程序的运行。

2.1 数据依赖性

两个操作同时访问一个变量,且有一个操作是写操作,就存在数据依赖的问题。
在这里插入图片描述

2.2 as-if-serial语义

语义是:无论如何重排序,单线程程序的执行结果是不可以被改变的。编译器、处理器都必须满足这个语义。

所以不会对存在数据依赖的操作进行指令重排序。但是数据之间可能存在控制依赖,比如A线程里面的if分支判断条件依赖于B线程中的某个变量,如果对A线程中分支体内部的指令和B线程中修改变量的指令进行乱序,则会导致程序混乱。

2.3 happens-before规则

A happens-before B的语义是:A的操作一定要对B可见。这不等同于A的操作一定要在B之前进行。

JMM保证在不改变运行结果的前提下,尽最大可能提升并行度

2.4 总结:JMM对重排序的处理

JMM保证了不会对存在数据依赖的操作进行重排序
但是JMM不保证不会对存在控制依赖的操作重排序,这时候需要我们设置合适的 同步方式

3. 顺序一致性模型

是一种理论性的参考模型。JMM定义了数据争用:
1. 在一个线程中写一个变量,
2. 在另一个线程读同一个变量,
3. 而且写和读没有通过同步来排序。

发生了数据争用会导致执行的结果出现错误,那么此时,需要用同步来保证程序的正确运行。
JMM保证了,如果程序正确同步,那么执行的结果和顺序一致性模型的结果一致

3.1 顺序一致性定义

顺序一致性模型定义了:

  1. 一个线程中所有的操作都必须按照程序的顺序执行;
  2. 所有线程看到的操作都必须是一致的。

第二条是要求所有线程不论是否同步,都必须要保证一致性。可以看出,顺序一致性是一种很强烈的保证,但是实际上很多情况下,我们不需要这种强烈的保证,因为这是以牺牲性能为代价的

JMM保证了,如果程序正确同步,那么执行的结果和顺序一致性模型的结果一致,如果线程没有同步,JMM不能保证执行结果是程序设计者所希望的

4. volatile语义

volatile关键字修饰变量时有以下特点:

  1. 每个线程总是可以读到一个volatile变量的最新的值;
  2. 对于单个volatile的读写具有原子性。
    在这里插入图片描述

4.1 volatile读写语义

volatile的内存可见性

  1. 当读取一个volatile变量时,JMM要求将本地内存中的变量置为无效,线程将从主存中读取这个变量的值(内存共享模型)

  2. 当写一个volatile变量时,JMM要求将本地内存中变量写回到主存中

4.2 volatile的内存语义

JVM通过在对volatile变量的操作前后插入内存屏障的方式来确保volatile变量的内存可见性。

  1. 在每个volatile写操作的前面插入一个StoreStore屏障。
  2. 在每个volatile写操作的后面插入一个StoreLoad屏障。
  3. 在每个volatile读操作的后面插入一个LoadLoad屏障。
  4. 在每个volatile读操作的后面插入一个LoadStore屏障。

在JSR-133之前的volatile的读写内存语义是弱于锁的读写内存语义的。JSR-133中增强了volatile语义,**使得其严格限制编译器和处理器对volatile变量和普通变量的指令重排。**这使得在很多场景下,我们可以选择volatile而不是使用锁,这会节省开销。

5 锁语义

5.1 锁的读写语义

锁的读写语义是:和volatile一致

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

5.2 CAS操作

CAS的含义就是比较一个位置的值是否是预期值,如果是则修改这个位置的值,否则失败。

CAS操作生成的汇编代码中有一行是一条lock指令,该指令会确保了对内存的CAS操作是一个原子性的操作。

  1. 禁止该指令与之前和之后的读写指令重排序;
  2. 把当前写缓冲区的数据刷新到内存中。

5.3 公平锁和非公平锁

二者的区别在于,非公平锁会让新到来的线程和在同步队列中等待的线程共同竞争锁,会造成线程饥饿的现象。书中举的例子是ReentrantLock:可重入锁。
ReentrantLock的实现依赖于Java同步器框架AbstractQueuedSynchronizer(本文简称之为AQS)。AQS使用一个整型的volatile变量(命名为state)来维护同步状态。
总的来说,这两种锁都是在获取和释放时修改状态值,由于state是一个volatile变量,对其的修改和读取具有内存可见性。区别在于:

  1. 公平锁获取时,首先会去读volatile变量并判断自己队列下一个要出队的节点是不是自己。
  2. 非公平锁获取时,直接使用CAS更新volatile变量,如果修改成功那么就会获取到锁。

5.4 concurrent包的实现

Java的CAS是比较和更改,所以同时具有volatile读和volatile写的内存语义。
文中总结了一个通用的线程间同步的方式:

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

在这里插入图片描述

6. final语义

final修饰的成员变量可以在构造函数中初始化,修饰的静态变量可以在静态代码块中初始化。 任何final变量都可以在声明时初始化。

final定义的内容是不可修改的,所以针对final修饰变量,JMM也需要对final变量的读写进行约束:

  1. 写final域语义:在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
    • 该语义保证了一个对象在对任意线程可见之前,其final域的内容已经被初始化
    • 编译器会在final域的初始化语句之后,构造函数返回之前后加入一个storestore内存屏障;
    • 对于普通变量来说,其初始化可能会被编译器重排到构造函数之外。
  2. 读final域语义: 初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序,即不可以先读到final域而对象还没有创建完
    • 该语义保证了:一个线程在读一对象的final域之前,一定会先读到这个对象的引用(不会读到null)
    • 编译器会在对final域读取之前加入一个loadload内存屏障。

其实final语义的总结就是:JMM保证final域的初始化不会从构造函数中溢出

7. JMM如何定义happens-before

happens-before是JMM的核心概念,JMM保证了程序员在设计程序时,只需要了解happens-before这样的简单概念,无需了解JMM底层是如何对程序进行优化的。

opA happens-before opB对于不同的角色来说有不同的含义:

  1. JMM向程序员保证:如果opA happens-before opB,那么opA的结果对opB可见,且opA在opB之前执行;
  2. JMM向编译器保证:如果opA happens-before opB,那么只要程序执行的结果和内存一致性模型中执行的结果相同,那么二者执行的顺序可以重排。

本质上 happens-before和as-if-serial语义是不同场景下的同一语义。happens-before是在多线程场景下的,而as-if-serial是单线程场景下的。

8. 总结

上述的一些概念:

  1. 顺序一致性内存模型是一个理论参考模型;
  2. JMM是语言级别的内存模型;
  3. 处理器内存模型是硬件级内存模型。

JMM如何保证内存可见性:

  • 单线程程序:单线程程序不会出现内存可见性问题。
  • 正确同步的多线程程序:正确同步的多线程程序的执行将具有顺序一致性。JMM通过限制编译器和处理器的重排序来为程序员提供内存可见性保证
  • 未同步/未正确同步的多线程程序:JMM为它们提供了最小安全性保障:线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值(0、null、false)。
  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值