【读后感】Java Concurrency in Practice:14.Java内存模型

0. 看这本书的时间跨度挺大的

前面的章节中,我们尽可能的避开了Java内存模型(JMM)的底层细节,而将重点放在一些高层设计问题,例如:安全发布、同步策略的规范以及一致性等。其实,它们的安全性都来自于JMM。

1. 什么是内存模型,为什么需要它

value = 3;

上面的变量value的赋值在什么时候可以被其他线程所看到?这听起来似乎是一个愚蠢的问题,但如果缺少同步,那么将会有许多因素使得线程无法立即甚至永远地看到这个赋值地结果。

导致其他线程无法及时看到最新值地一些因素:

  • 编译器中生成的指令顺序,可以与源码中地顺序不同
  • 编译器还会把变量保存在寄存器中而不是内存中
  • 处理器可以乱序或并行等方式来执行指令
  • 缓存可能会对变量的写入操作提交到主存地次序产生影响
  • 保存在处理器本地缓存的值,对其他地处理器是不可见的

在多线程环境中,维护程序地串行性将导致很大地开销。但当多个线程共享数据时,我们不得不通过同步操作告诉JVM在何时发生线程间地协调操作。而JMM规定了JVM必须遵守地一组最小保证,这组保证规定了对变量的写入操作将会何时对于其他线程可见(人话:JMM即一套规范了 JVM线程间状态共享 的标准,可以帮助Java开发人员屏蔽底层实现的差异)。

1.1 平台的内存模型

在共享内存的多处理器体系架构中,每个处理器都拥有自己的缓存,并且定期地与主存进行协调(其实就是同步/更新变量的值)。

不同的处理器架构中提供了不同级别的缓存一致性,其中一部分只提供最小的保证(最差的一致性保证),即允许不同的处理器在任意时刻从同一个存储位置上看到不同的值。操作系统、编译器以及运行时 需要弥补这种在硬件能力与线程安全需求之间的差异。

要想确保每个处理器都能在任意时刻知道其他处理器正在做什么,这种级别的一致性保证将需要非常大的开销,大多数时间里这么做是不必要的,因此处理器会适当放宽处理器的一致性保证——由具体的处理器架构定义的内存模型来告诉应用程序可以从内存中获得怎么的一致性保证,并提供一些特殊的指令(称为 内存栅栏,用于与主存同步共享数据)

为了使Java开发人员无需关心不同处理器架构上的内存模型的差异,Java提供了自己的内存模型,并且JVM通过在适当位置插入内存栅栏来屏蔽在JMM与底层平台的内存模型上的差异。

1.2 重排序

没有充分同步的程序中,除了线程之间交替执行的顺序无法预知,JMM也会使得不同线程看到的操作执行顺序不同,这使得在缺乏同步的情况下要推断操作的执行顺序变得更加复杂。

同步将限制编译器、运行时和硬件对内存操作重排序的方式,从而在实施重排序时不会破坏JMM提供的可见性保证(在大多数主流的处理器架构中,内存模型都非常强大,使得读取volatile变量的性能与读取非volatile变量的性能大致相当)

1.3 Java内存模型简介

Java内存模型时通过各种操作来定义的,包括对变量的读/写操作,监视器的加锁和释放操作,以及线程的启动和合并操作。

JMM为程序中所有的操作都定义了一个偏序关系(你可以理解成一个程序执行时候的一些既定的习惯),称之为Happens-before,满足此关系的操作之间将表现出串行一致性。要想满足执行操作B的线程可以看到操作A的结果,那么在A和B之间必须满足Happens-before关系,否则JVM可以对它们任意的重排序。

人话:Happens-before 的代码会按照特定顺序执行


Happens-before的规则包括:

程序顺序规则:如果程序中操作A在操作B之前,那么A将先于B而执行

监视器锁规则:在监视器上面的解锁操作必须在同一监视器锁上的加锁操作之前执行(内置锁、显式锁都有这种内存语义)

volatile变量规则:对于volatile变量的写入操作必须在其读取操作之前执行(原子变量也是如此)

线程结束规则:线程中的任何操作都必须在其他线程检测到该线程已经结束之前执行,或从Thread.join中返回,或者在调用Thread.isAlive时返回false

中断规则:当线程A在线程B上调用interrupt时,线程A必须在被中断线程(B)检测到interrupt调用之前执行(执行啥?通过抛出InterruptException,或调用isInterrupted和interrupted)

终结器规则:对象的构造函数必须在启动该对象的终结器之前执行完成

传递性:操作A先于B,B先于C -> 操作A先于操作C

全序关系:锁的释放、获取;volatile变量的读取、写入;

1.4 借助同步

由于Happens-before的排序功能很强大,因此有时候可以使用"借助(Piggyback)"现有同步机制的可见性属性。这需要将Happens-before的程序顺序规则与 其他某个顺序规则(通常是 监视器锁规则 或者 volatile变量规则)结合起来,从而对某个未被锁保护的变量的访问操作进行排序。这项技术由于对语句的顺序非常敏感,因此很容易出错,它是一项高级的技术。

人话:假设有这么一波操作:A - B - C -D。如果在某些Happens-before的排序规则下,B操作 要先于 C操作 执行 -> 那么,我们可以借助B&C两者的Happens-before关系,帮助我们可以不需要额外的同步操作下,让操作D"拥有了"对操作A的可见性


其实产消模型借助BlockingQueue实现安全发布,也是一种“借助”。

参考j.u.c.FutureTask借助内部变量state来维护任务执行状态:

package java.util.concurrent;

import java.util.concurrent.locks.LockSupport;

/**
 * @since 1.5
 * @author Doug Lea
 * @param <V> The result type returned by this FutureTask's {@code get} methods
 */
public class FutureTask<V> implements RunnableFuture<V> {
	
	// 省略无关的代码 ...
	
	private volatile int state;
	private static final int NEW          = 0;
	private static final int COMPLETING   = 1;
	private static final int NORMAL       = 2;
	private static final int EXCEPTIONAL  = 3;
	private static final int CANCELLED    = 4;
	private static final int INTERRUPTING = 5;
	private static final int INTERRUPTED  = 6;
	
	// Unsafe mechanics
	private static final sun.misc.Unsafe UNSAFE;
}

请添加图片描述

简单解释一下:由于Happens-before排序规则中的 程序顺序规则 & volatile变量规则(像FutureTask这类基于AQS构建的同步组件中的同步状态底层使用volatille修饰) -> volatile 遵循 先写后读 -> 于是就满足了 A -B -C - D的操作(1.4小节的人话部分) -> 赋值result - releaseShared(volatile写) - acquireSharedInterruptibly(volatile读) - 返回result


除了上面提到的Happens-before,还包括:

  • CountDownLatch 上的倒数操作将在线程从CountDownLatch的await方法中返回之前执行
  • 释放Semaphore许可的操作将在该Semaphore上获得一个许可之前执行
  • Future中任务执行将在get返回之前完成
  • Executor的任务提交将在任务开始执行前完成
  • 线程到达CyclicBarrier或Exchanger的操作将早于线程从CyclicBarrier或Exchanger中被释放

2. 发布

从JMM的角度来看,不安全的发布的真正原因是—— “发布一个共享对象” 与 “另一个线程访问该对象” 之间缺少一种 Happens-before排序

2.1 不安全的发布

当缺少happens-before关系时,就可能出现重排序的问题,这就解释了在没有充分同步的情况下发布一个对象会导致另一个线程看到一个只被部分构造的对象。

除了不可变对象以外,使用被另一个线程初始化的对象通常是不安全的,除非对象的发布操作是在使用该对象的线程开始使用之前执行的。

2.2 安全的发布

通过使用一个由锁保护共享变量或者使用共享的volatile类型变量,也可以确保对该变量的读取操作和写入操作按照Happens-before关系来排序。

如果线程A将变量X置入队列的操作在线程B从队列中获取变量X之前执行,那么线程B不仅能看到线程A操作过后的变量X,而且还能看到线程A在将变量X置入队列之前所作的所有操作(JMM确保线程B至少可以看到线程A刚置入到队列中的X的值,而对于随后变量X被写入的值,B可能看到也可能看不到)。

2.3 安全初始化模式

有时候,我们需要推迟一些高开销的对象初始化操作,并且只有当使用这些对象时才进行初始化。

2.3.1 静态初始化器(JVM延迟加载机制)

竞争不激烈时,下面的做法有不错的表现:

public synchronized static Resource getInstance(){
	if(resource == null) return new Resource();
	return resource;
}

在静态方法或静态代码块中初始化实例(作者称之为“静态初始化器”)是可以得到额外的线程安全性保证的,这是因为:
静态初始化器是由JVM在类的初始化阶段(类初始化阶段,并非JVM初始化)执行,即在类被加载后&使用之前

JVM在初始化期间会获得一个锁(静态初始化器的类的锁),每个线程都至少获取一次这个锁以确保这个类已经被加载,因此在静态初始化期间,内存写入操作(静态初始化的操作)将自动对所有线程可见,并且不需要显式的同步。

不足:不能实时的、动态的、运行时的初始化线程安全的类。

2.3.2 提前初始化

提前初始化的静态版本,可以避免静态初始化器带来的同步开销:

private static Resource resource = new Resource();
public static Resource getResource(){return resource;}

2.3.3 延迟初始化占位类(Holder)模式

多的不说了,建议想象一下—— 2.3.1 + 2.3.2 小节的产物:

public class ResourceFactory {
	private static class ResourceHolder {
		public static Resource resource = new Resource();
	}
	public static Resource getResource() {
		return ResourceHolder.resource;
	}
}

效果:当某一个线程第一次调用getResource()方法时,实现一次线程安全的类初始化。

2.4 双重检查加锁

在任何一本介绍并发的书中都会讨论声名狼藉的双重检查加锁(DCL)。之所以会有双重加锁方案的提出,是因为早期JVM的同步操作(甚至是无竞争的同步)存在巨大的性能开销。

public static Resource getInstance() {
	// 这里可能看到的只是旧的resource
	if(resource == null) {
		synchronized (this.getClass()){
			if(resource == null) resource = new Resource();
		}
	}
	return resource;
}	

不难理解,提出这种方案是原因即 很好地理解了"独占性",但却没有很好地理解 “可见性”的含义。


虽然,将resource声明为volatile类型即可解决可见性的问题,但是这种做法对性能的帮助并不大。相比之下,使用初始化占位类模式更容易理解。

2.5 初始化过程的安全性

初始化安全性将确保:对于被正确构造的对象,所有线程都能看到由构造函数为对象给各个final域(变量)设置的正确值。

对于通过final域可达的初始变量的写入操作,将不会域构造过程后的操作一起被重排序。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

肯尼思布赖恩埃德蒙

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值