黑马 JVM ---4 --- 内存模型

5 篇文章 0 订阅
本文深入探讨了Java内存模型(JMM)及其对多线程的影响,包括可见性、有序性和原子性。volatile关键字确保了线程间变量的可见性,但不保证原子性。指令重排可能导致并发问题,而CAS通过无锁机制提供了一种解决方案。此外,文章还讨论了synchronized的优化,如轻量级锁、自旋锁和偏向锁,以及如何通过减少锁粒度和上锁时间来提升并发性能。
摘要由CSDN通过智能技术生成

黑马 JVM —4 — 内存模型 (JMM)

java 内存模型 可见性 有序性 CAS与原子类 synchronized优化


  1. java 内存模型

【java 内存模型】是 Java MemoryModel(JMM)的意思。

官方文档

简单的说,
JMM 定义了一套在多线程读写共享数据时(成员变量、数组)时,
对数据的可见性、有序性、和原子性的规则和保障

所谓 内存模型 , 一部分 叫 工作内存, 一部分 叫 线程内存 。(在 JMM 中)

在这里插入图片描述
JMM 主要 体现在 几个方面

  • 原子性 – 保证 指令 不会 收到 线程 上下文 切换的 影响
  • 可见性 – 保证 指令 不会 收到 CPU 缓存 的影响
  • 有序性 – 保证 指令 不会 收到 CPU 指令 并行优化 的 影响

  1. 可见性

2.1 退不出的循环

先来看一个现象,main 线程对 run 变量的修改对于 t 线程不可见,导致了 t 线程无法停止:

static boolean run = true;
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(()->{
while(run){
// ....
}
});
t.start();
Thread.sleep(1000);
run = false; // 线程t不会如预想的停下来
}

原因 :

  1. 初始状态, t 线程刚开始从主内存读取了 run 的值到工作内存。
    在这里插入图片描述

  2. 因为 t 线程要频繁从主内存中读取 run 的值,JIT 编译器会将 run 的值缓存至自己工作内存中的高
    速缓存中,减少对主存中 run 的访问,提高效率
    在这里插入图片描述

  3. 1 秒之后,main 线程修改了 run 的值,并同步至主存,而 t 是从自己工作内存中的高速缓存中读
    取这个变量的值,结果永远是旧值
    在这里插入图片描述

  4. 解决方法
    volatile(易变关键字)
    它可以用来修饰成员变量和静态成员变量,他可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取它的值,线程操作 volatile 变量都是直接操作主存

vol
atile(可见性)保证的是在多个线程之间,一个线程对 volatile 变量的修改对另一
个线程可见, 不能保证原子性,仅用在一个写线程,多个读线程

注意
synchronized 语句块既可以保证代码块的原子性,也同时保证代码块内变量的可见性。
但缺点是synchronized是属于重量级操作,性能相对更低

如果在前面示例的死循环中加入 System.out.println() 会发现即使不加 volatile 修饰符,线程 t 也能正确看到对 run 变量的修改了,想一想为什么?

因为println()底层 使用了 synchronized 修饰 , 即 对 当前对象 加锁
可以保证原子性与可见性,它是 PrintStream 类的方法。


  1. 有序性

3.1 指令重排
指令重排简单来说可以,在程序结果不受影响的前提下,可以调整指令语句执行顺序。
多线程下指令重排会影响正确性。

写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后

public void actor2(I_Result r) {
 num = 2;
 ready = true; // ready 是被 volatile 修饰的,赋值带写屏障
 // 写屏障
}

读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前

public void actor1(I_Result r) {
 // 读屏障
 // ready 是被 volatile 修饰的,读取值带读屏障
 if(ready) {
 	r.r1 = num + num;
 } else {
 	r.r1 = 1;
 }
}

注意:volatile 不能解决指令交错
写屏障仅仅是保证之后的读能够读到最新的结果,但不能保证其它线程的读跑到它前面去。
而有序性的保证也只是保证了本线程内相关代码不被重排序

// 可以重排的例子 
int a = 10; 
int b = 20; 
System.out.println( a + b );

// 不能重排的例子 
int a = 10;
int b = a - 5;

3.2 多线程下指令重排问题

int num = 0;
boolean ready = false;
// 线程1 执行此方法
public void actor1(I_Result r) {
if(ready) {
r.r1 = num + num;
} else {
r.r1 = 1;
}
}
// 线程2 执行此方法
public void actor2(I_Result r) {
num = 2;
ready = true;
}

在多线程环境下,以上的代码 r1 的值有三种情况:

  • 第一种:线程 2 先执行,然后线程 1 后执行,r1 的结果为 4
  • 第二种:线程 1 先执行,然后线程 2 后执行,r1 的结果为 1
  • 第三种:线程 2 先执行,但是发送了指令重排,num = 2 与 ready = true 这两行代码语序发生装换,

ready = true; // 前
num = 2; // 后

然后执行 ready = true 后,线程 1 运行了,那么 r1 的结果是为 0。

3.3 解决方法
volatile 修饰的变量,可以禁用指令重排

3.4 有序性理解
JVM 会在不影响正确性的前提下,可以调整语句的执行顺序

著名的 double-checked locking (双层检查锁)模式实现单例

public final class Singleton {
	private Singleton() { }
	private static Singleton INSTANCE = null;
	public static Singleton getInstance() {
	// 实例没创建,才会进入内部的 synchronized代码块
	if (INSTANCE == null) {
	synchronized (Singleton.class) {
	// 也许有其它线程已经创建实例,所以再判断一次
	if (INSTANCE == null) {
	INSTANCE = new Singleton();
	}}}
	return INSTANCE;
}}

以上的实现特点是:

  • 懒惰实例化
  • 首次使用 getInstance() 才使用 synchronized 加锁,后续使用时无需加锁

如上面的注释内容所示,读写 volatile 变量操作(即 getstatic 操作和 putstatic 操作)时会加入内存屏障(Memory Barrier(Memory Fence)),

保证下面两点:
可见性
写屏障(sfence)保证在该屏障之前的 t1 对共享变量的改动,都同步到主存当中
而读屏障(lfence)保证在该屏障之后 t2 对共享变量的读取,加载的是主存中最新数据

有序性
写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后
读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前
更底层是读写变量时使用 lock 指令来多核 CPU 之间的可见性与有序性


3.5 happens-before
happens-before 规定了哪些写操作对其它线程的读操作可见,它是可见性与有序性的一套规则总结,
抛开以下 happens-before 规则,JMM 并不能保证一个线程对共享变量的写,对于其它线程对该共享变量的读可见

线程解锁 m 之前对变量的写,对于接下来对 m 加锁的其它线程对该变量的读可见

static int x;
static Object m = new Object();
new Thread(()->{
synchronized(m) {
x = 10;
}
},"t1").start();
new Thread(()->{
synchronized(m) {
System.out.println(x);
}
},"t2").start();

线程解锁 m 之前对变量的写,对于接下来对 m 加锁的其它线程对该变量的读可见

volatile static int x;
new Thread(()->{
x = 10;
},"t1").start();
new Thread(()->{
System.out.println(x);
},"t2").start();

线程 start 前对变量的写,对该线程开始后对该变量的读可见

static int x;
x = 10;
new Thread(()->{
System.out.println(x);
},"t2").start();

线程结束前对变量的写,对其它线程得知它结束后的读可见(比如其它线程调用 t1.isAlive() 或
t1.join()等待它结束)

static int x;
Thread t1 = new Thread(()->{
x = 10;
},"t1");
t1.start();
t1.join();
System.out.println(x);

线程 t1 打断 t2(interrupt)前对变量的写,对于其他线程得知 t2 被打断后对变量的读可见(通
过t2.interrupted 或 t2.isInterrupted)

static int x;
public static void main(String[] args) {
Thread t2 = new Thread(()->{
while(true) {
if(Thread.currentThread().isInterrupted()) {
System.out.println(x);
break;
}
}
},"t2");
t2.start();
new Thread(()->{
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
x = 10;
t2.interrupt();
},"t1").start();
while(!t2.isInterrupted()) {
Thread.yield();
}
System.out.println(x);
}
  • 对变量默认值(0,false,null)的写,对其它线程对该变量的读可见
  • 具有传递性,如果 x hb-> y 并且 y hb-> z 那么有 x hb-> z
    变量都是指成员变量或静态成员变量

  1. CAS与原子类

CAS 即 Compare and Swap , 它 提现的 是 一种 乐观锁 的 思想,
比如 多个线程 要 对 一个 共享 的 整形变量 执行 +1 操作

在这里插入图片描述

在这里插入图片描述
获取共享变量时,为了保证该变量的可见性,需要使用 volatile 修饰。
结合 CAS 和 volatile 可以实现无锁并发,适用于竞争不激烈、多核 CPU 的场景下。

  • 因为没有使用 synchronized,所以线程不会陷入阻塞,这是效率提升的因素之一
  • 但如果竞争激烈,可以想到重试必然频繁发生,反而效率会受影响

为什么无锁效率高?
无锁情况下,即使重试失败,线程始终在高速运行,没有停歇,而 synchronized 会让线程在没有获得锁的时候,发生上下文切换,进入阻塞。
打个比喻:线程就好像高速跑道上的赛车,高速运行时,速度超快,一旦发生上下文切换,就好比赛车要减速、熄火,等被唤醒又得重新打火、启动、加速… 恢复到高速运行,代价比较大

但无锁情况下,因为线程要保持运行,需要额外 CPU 的支持,CPU 在这里就好比高速跑道,没有额外的跑道,线程想高速运行也无从谈起,虽然不会进入阻塞,但由于没有分到时间片,仍然会进入可运行状态,还是会导致上下文切换


CAS 底层依赖于一个 Unsafe 类来直接调用操作系统底层的 CAS 指令

在这里插入图片描述

在这里插入图片描述
在这里插入图片描述


原子操作类
juc(java.util.concurrent)中提供了原子操作类,可以提供线程安全的操作,
例如:AtomicInteger、AtomicBoolean等,
它们底层就是采用 CAS 技术 + volatile 来实现的。可以使用 AtomicInteger 改写之前的例子:

在这里插入图片描述


乐观锁与悲观锁

  • CAS 是基于乐观锁的思想:最乐观的估计,不怕别的线程来修改共享变量,就算改了也没关系,
    我吃亏点再重试呗。

  • synchronized 是基于悲观锁的思想:最悲观的估计,得防着其它线程来修改共享变量,我上了锁
    你们都别想改,我改完了解开锁,你们才有机会。


  1. synchronized优化

Java HotSpot 虚拟机中,每个对象都有对象头(包括 class 指针和 Mark Word)。
Mark Word 平时存储这个对象的 哈希码 、 分代年龄 ,
当加锁时,这些信息就根据情况被替换为 标记位 、 线程锁记录指针 、 重量级锁指针 、 线程ID 等内容


5.1 轻量级锁
如果一个对象虽然有多线程访问,但多线程访问的时间是错开的(也就是没有竞争||不存在竞争),
那么可以使用轻量级锁来优化。

学生(线程 A)用课本占座,上了半节课,出门了(CPU时间到),回来一看,发现课本没变,说明没有竞争,继续上他的课。

如果这期间有其它学生(线程 B)来了,会告知(线程A)有并发访问,线程 A 随即升级为重量级锁,进入重量级锁的流程。

而重量级锁就不是那么用课本占座那么简单了,可以想象线程 A 走之前,把座位用一个铁栅栏围起来
假设有两个方法同步块,利用同一个对象加锁

static Object obj = new Object();
public static void method1() {
synchronized( obj ) {
// 同步块 A
method2();}}
public static void method2() {
synchronized( obj ) {
// 同步块 B}}

每个线程都的栈帧都会包含一个锁记录的结构,内部可以存储锁定对象的 Mark Word
在这里插入图片描述


5.2 锁膨胀
如果在尝试加轻量级锁的过程中,CAS 操作无法成功,这时一种情况就是有其它线程为此对象加上了轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁


static Object obj = new Object();
public static void method1() {
synchronized( obj ) {
// 同步块
}
}

在这里插入图片描述


5.3 重量锁

重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(即这时候持锁线程已经退出了同步块,释放了锁),这时当前线程就可以避免阻塞。
在 Java 6 之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,总之,比较智能。

  • 自旋会占用 CPU 时间,单核 CPU 自旋就是浪费,多核 CPU 自旋才能发挥优势。
  • 好比等红灯时汽车是不是熄火,不熄火相当于自旋(等待时间短了划算),熄火了相当于阻塞(等
    待时间长了划算)
  • Java 7 之后不能控制是否开启自旋功能

自旋重试成功的情况

在这里插入图片描述
自旋重试失败的情况
在这里插入图片描述


5.4 偏向锁 ( 可重入锁)

轻量级锁在没有竞争时(就自己这个线程),每次重入仍然需要执行 CAS 操作。
Java 6 中引入了偏向锁
来做进一步优化:只有第一次使用 CAS 将线程 ID 设置到对象的 Mark Word 头,之后发现这个线程 ID是自己的就表示没有竞争,不用重新 CAS.

  • 撤销偏向需要将持锁线程升级为轻量级锁,这个过程中所有线程需要暂停(STW)
  • 访问对象的 hashCode 也会撤销偏向锁
  • 如果对象虽然被多个线程访问,但没有竞争,这时偏向了线程 T1 的对象仍有机会重新偏向 T2,
    重偏向会重置对象的 Thread ID
  • 撤销偏向和重偏向都是批量进行的,以类为单位
  • 如果撤销偏向到达某个阈值,整个类的所有对象都会变为不可偏向的
    -可以主动使用 -XX:-UseBiasedLocking 禁用偏向锁
    可以参考这篇论文
假设有两个方法同步块,利用同一个对象加锁
5.5 其它优化
1. 减少上锁时间
同步代码块中尽量短
2. 减少锁的粒度
将一个锁拆分为多个锁提高并发度,例如:
static Object obj = new Object();
public static void method1() {
synchronized( obj ) {
// 同步块 A
method2();}}
public static void method2() {
synchronized( obj ) {
// 同步块 B}}

在这里插入图片描述


5.5 其它优化

  1. 减少上锁时间
    同步代码块中尽量短

  1. 减少锁的粒度
    将一个锁拆分为多个锁提高并发度,例如:

ConcurrentHashMap

LongAdder 分为 base 和 cells 两部分。没有并发争用的时候或者是 cells 数组正在初始化的时
候,会使用 CAS 来累加值到 base,有并发争用,会初始化 cells 数组,数组有多少个 cell,就允
许有多少线程并行修改,最后将数组中每个 cell 累加,再加上 base 就是最终的值
LinkedBlockingQueue 入队和出队使用不同的锁,相对于LinkedBlockingArray只有一个锁效率要高


  1. 锁粗化
    多次循环进入同步块不如同步块内多次循环
    另外 JVM 可能会做如下优化,把多次 append 的加锁操作粗化为一次(因为都是对同一个对象加锁,
    没必要重入多次)

new StringBuffer().append(“a”).append(“b”).append(“c”)


  1. 锁消除
    JVM 会进行代码的逃逸分析,例如某个加锁对象是方法内局部变量,不会被其它线程所访问到,这时候就会被即时编译器忽略掉所有同步操作。

  1. 读写分离
    CopyOnWriteArrayList
    ConyOnWriteSet


参考
参考
参考
参考
参考
参考

  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值