二刷Java多线程:Java内存模型

一、可见性、原子性和有序性问题

1、Java内存模型的抽象结构与可见性问题

在Java中,所有实例域、静态域和数组元素都存储在堆内存中,堆内存在线程之间共享。局部变量、方法定义参数和异常处理器参数不会在线程之间共享,它们不会有内存可见性问题,也不受内存模型的影响

Java线程之间的通信由Java内存模型(JMM)控制,JMM决定一个线程对共享变量的写入何时对另一个线程可见。从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在,它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化
在这里插入图片描述
如果线程A与线程B之间要通信的话

1)线程A把本地内存A中更新过的共享变量刷新到主内存中去

2)线程B到主内存中取读取线程A之前已更新过的共享变量
在这里插入图片描述
本地内存A和本地内存B都有主内存中共享变量x的副本,假设初始时,这3个内存中的x值都为0。线程A在执行时,把更新后的x值(假设值为1)临时存放在自己的本地内存A中。当线程A和线程B需要通信时,线程A首先会把自己本地内存中修改后的x值刷新到主内存中,此时主内存中的x值变为了1。随后,线程B到主内存中去读取线程A更新后的x值,此时线程B的本地内存的x值也变为了1

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

2、线程切换带来的原子性问题

操作系统允许某个进程执行一小段时间,例如50毫秒,过了50毫秒操作系统就会重新选择一个进程来执行(称为任务切换),这个50毫秒称为时间片
在这里插入图片描述
在一个时间片内,如果一个进程进行一个IO操作,例如读个文件,这个时候进程可以把自己标记为休眠状态并出让CPU的使用权,待文件读进内存,操作系统会把这个休眠的进程唤醒,唤醒后的进程就有机会重新获得CPU的使用权了

这里的进程在等待IO时之所以会释放CPU使用权,是为了让CPU在这段等待时间里可以做别的事情,这样一来CPU的使用率就上来了;此外,如果这时有另外一个进程也读文件,读文件的操作就会排队,磁盘驱动在完成一个进程的读操作后,发现有排队的任务,就会立即启动下一个读操作,这样IO的使用率也上来了

任务切换的时机大多数是在时间片结束的时候,高级编程语言里一条语句往往需要多条CPU指令完成,例如count+=1,至少需要三条CPU指令

  • 指令1:首先,需要把变量count从内存加载到CPU的寄存器
  • 指令2:之后,在寄存器中执行+1操作
  • 指令3:最后,将结果写入内存(缓存机制导致可能写入的是CPU缓存而不是内存)

操作系统做任务切换,可以发生在任何一条CPU指令执行完,对于上面的三条指令来说,我们假设count=0,如果线程A在指令1执行完后做线程切换,线程A和线程B按照下图的序列执行,那么我们会发现两个线程都执行了count+=1的操作,但是得到的结果不是我们期望的2,而是1
在这里插入图片描述
一个或者多个操作在CPU执行的过程中不被中断的特性称为原子性

3、重排序带来的有序性问题

在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序

  • 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序
  • 指令级并行的重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序
  • 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行
    在这里插入图片描述
    1属于编译器重排序,2和3属于处理器重排序。这些重排序可能会导致多线程程序在这里插入图片描述

出现内存可见性问题。对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序。对于处理器重排序,JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障指令,通过内存屏障指令来禁止特定类型的处理器重排序

1)、数据依赖性

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

编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序

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

2)、as-if-serial语义

as-if-serial语义的意思是:不管怎么重排序,程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义

为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果

		double pi = 3.14;// A
		double r = 1.0;// B
		double area = pi * r * r;// C

在这里插入图片描述
A和C之间存在数据依赖关系,同时B和C之间也存在数据依赖关系。因此在最终执行的指令序列中,C不能被重排序到A和B的前面。但A和B之间没有数据依赖关系,编译器和处理器可以重排序A和B之间的执行顺序
在这里插入图片描述
as-if-serial语义把单线程程序保护了起来,遵守as-if-serial语义的编译器、runtime和处理器共同为编写单线程程序的程序员创建了一个幻觉:单线程程序是按程序的顺序来执行的。as-if-serial语义使单线程程序员无需担心重排序会干扰他们,也无需担心内存可见性问题

3)、重排序对多线程的影响

一个经典的案例就是利用双重检查创建单例对象,例如下面的代码:在获取实例getInstance()的方法中,首先判断instance是否为空,如果为空,则锁定Singleton.class并再次检查instance

public class Singleton {
	private static Singleton instance;

	private Singleton() {
	}

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

实际上这个getInstance()方法并不完美,我们认为的new操作应该是:

1)分配一块内存M

2)在内存M上初始化Singleton对象

3)然后M的地址赋值给instance变量

但实际上优化后的执行路径却是这样的:

1)分配一块内存M

2)将M的地址赋值给instance变量

3)最后在内存M上初始化Singleton对象

优化后,我们假设线程A先执行getInstance()方法,当执行完指令2时恰好发生了线程切换,切换到线程B上;如果此时线程B也执行getInstance()方法,那么线程B会发现instance!=null,所以直接返回instance,而此时的instance是没有初始化过的,如果这个时候访问instance的成员变量就可能触发空指针异常
在这里插入图片描述
使用volatile关键字修饰instance变量解决上述问题:

public class Singleton {
	private volatile static Singleton instance;

	private Singleton() {
	}

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

Java语言规范引入了Java内存模型,通过定义多项规则对编译器和处理器进行限制,主要是针对可见性和有序性。而锁的出现主要是针对原子性问题

二、happens-before

从JDK5开始,Java使用新的JSR-133内存模型。JSR-133使用happens-before的概念来阐述操作之间的内存可见性。在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系。这里提到的两个操作既可以在一个线程之内,也可以是在不同线程之间

happens-before规则如下

  • 程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作
  • 监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁
  • volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读
  • 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C
  • start()规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作
  • join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回

happens-before仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前
在这里插入图片描述
一个happens-before规则对应一个或多个编译器和处理器重排序规则

三、volatile的内存语义

volatile保证了可见性和有序性,但不保证原子性

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

1、volatile写-读建立的happens-before关系

对一个volatile域的写,happens-before于任意后续对这个volatile域的读

public class VolatileFeaturesExample {
	int a = 0;
	volatile boolean flag = false;

	public void writer() {
		a = 1;// 1
		flag = true;// 2
	}

	public void reader() {
		if (flag) {// 3
			int i = a;// 4
            ......
		}
	}
}

假设线程A执行writer()方法之后,线程B执行reader()方法。根据happens-before规则,这个过程建立的happens-before关系可以分为3类

1)根据程序次序规则,1 happens-before 2;3 happens-before 4

2)根据volatile规则,2 happens-before 3

3)根据happens-before的传递性规则,1 happens-before 4
在这里插入图片描述
A线程写入一个volatile变量后,B线程读同一个volatile变量。A线程在写volatile变量之前所有可见的共享变量,在B线程读同一个volatile变量后,将立即变得对B线程可见

2、volatile写-读的内存语义

volatile写的内存语义:当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存
在这里插入图片描述
线程A在写flag变量后,本地内存A中被线程A更新过的两个共享变量的值被刷新到主内存中。此时,本地内存A和主内存中的共享变量的值是一致的

volatile读的内存语义:当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量

在读flag变量后,本地内存B包含的值已经被置为无效。此时,线程B必须从主内存中读取共享变量。线程B的读取操作将导致本地内存B与主内存中的共享变量的值变为一致

  • 线程A写一个volatile变量,实质上是线程A向接下来将要读这个volatile变量的某个线程发出了(其对共享变量所做修改的)消息
  • 线程B读一个volatile变量,实质上是线程B接收了之前某个线程发出的(在写这个volatile变量之前对共享变量所做修改的)消息
  • 线程A写一个volatile变量,随后线程B读这个volatile变量,这个过程实质上是线程A通过主内存向线程B发送消息
    在这里插入图片描述
    对于一个volatile变量:
  • 对该变量的写操作之后,编译器会插入一个写屏障
  • 对该变量的读操作之前,编译器会插入一个读屏障

内存屏障能够在类似变量读、写操作之后,保证其他线程对volatile变量的修改对当前线程可见,或者本地修改对其他线程提供可见性

四、锁的内存语义

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

public class MonitorExample {
	int a = 0;

	public synchronized void writer() {// 1
		a++;// 2
	}// 3

	public synchronized void reader() {// 4
		int i = a;// 5
	}// 6
}

假设线程A执行writer()方法,随后线程B执行reader()方法。根据happens-before规则,这个过程包含的happens-before关系可以分为3类

1)根据程序次序规则,1 happens-before 2,2 happens-before 3;4 happens-before 5;5 happens-before 6

2)根据监视器锁规则,3 happens-before 4

3)根据happens-before的传递性,2 happens-before 5
在这里插入图片描述

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

当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中
在这里插入图片描述
当线程获取锁时,JMM会把该线程对应的本地内存置为无效。从而使得被监视器保护的临界区代码必须从主内存中读取共享变量
在这里插入图片描述
锁释放与volatile写有相同的内存语义,锁获取与volatile读有相同的内存语义

  • 线程A释放一个锁,实质上是线程A向接下来将要获取这个锁的某个线程发出了(线程A对共享变量所做修改的)消息
  • 线程B获取一个锁,实质上是线程B接收了之前某个线程发出的(在释放这个锁之前对共享变量所做修改的)消息
  • 线程A释放锁,随后线程B获取这个锁,这个过程实质上是线程A通过主内存向线程B发送消息

3、锁内存语义的实现

Java SDK里面Lock的使用,有一个经典的范例,就是try{}finally{},需要重点关注的是在finally里面释放锁

public class ReentrantLockExample {
	private final Lock rtl = new ReentrantLock();
	int value;

	public void addOne() {
		// 获取锁
		rtl.lock();
		try {
			value += 1;
		} finally {
			// 保证锁能释放
			rtl.unlock();
		}
	}
}

Lock保证可见性利用了volatile相关的Happens-Before规则。Java SDK里面的ReentrantLock内部持有一个volatile的成员变量state,获取锁的时候,会读写state的值;解锁的时候,也会读写state的值。也就是说,在执行value += 1之前,程序先读写一次volatile变量state,在执行value += 1之后,又读写一次volatile变量state。根据相关的Happens-Before规则:

1)顺序性规则:对于线程T1,value += 1 Happens-Before 释放锁的操作unlock()

2)volatile变量规则:由于state = 1会先读写state,所以线程T1的unlock()操作 Happens-Before 于线程T2的lock()操作

3)传递性规则:线程T1的value += 1 Happens-Before 线程T2的lock()操作

class SampleLock {
	volatile int state;

	// 加锁
	lock() {
	    // 省略代码无数
	    state = 1;
	  }

	// 解锁
	unlock() {
	    // 省略代码无数
	    state = 0;
	  }
}

五、final域的内存语义

1、final域的重排序规则

对于final域,编译器和处理器要遵守两个重排序规则:

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

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

public class FinalExample {
	int i;// 普通变量
	final int j;// final变量
	static FinalExample obj;

	public FinalExample() {// 构造函数
		i = 1;// 写普通域
		j = 2;// 写final域
	}

	public static void writer() {// 写线程A执行
		obj = new FinalExample();
	}

	public static void reader() {// 读线程B执行
		FinalExample object = obj;// 读对象引用
		int a = obj.i;// 读普通域
		int b = obj.j;// 读final域
	}
}

2、写final域的重排序规则

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

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

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

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

3、读final域的重排序规则

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

初次读对象引用与初次读该对象包含的final域,这两个操作之间存在间接依赖关系。由于编译器遵守间接依赖关系,因此编译器不会重排序这两个操作。有少数处理器允许对存在间接依赖关系的操作做重排序,这个规则就是专门针对这种处理器的
在这里插入图片描述
读final域的重排序规则可以确保:在读一个对象的final域之前,一定会先读包含这个final域的对象的引用

4、final域为引用类型

public class FinalReferenceExample {
	final int[] intArray;
	static FinalReferenceExample obj;

	public FinalReferenceExample() {// 构造函数
		intArray = new int[1];// 1
		intArray[0] = 1;// 2
	}

	public static void writerOne() {// 写线程A执行
		obj = new FinalReferenceExample();// 3
	}

	public static void writerTwo() {// 写线程B执行
		obj.intArray[0] = 2;// 4
	}

	public static void reader() {// 读线程C
		if (obj != null) {// 5
			int temp1 = obj.intArray[0];// 6
		}
	}
}

对于引用类型,写final域的重排序规则对编译器和处理器增加了如下约束:在构造函数内对一个final引用的对象的成员域的写入,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序

JMM可以确保读线程C至少能看到写线程A在构造函数中对final引用对象的成员域的写入。即C至少能看到数组下标0的值为1。而写线程B对数组元素的写入,读线程C可能看得到,也可能看不到。JMM不保证线程B的写入对读线程C可见,因为写线程B和读线程C之间存在数据竞争,此时的执行结果不可预知

如果想要确保读线程C看到写线程B对数组元素的写入,写线程B和读线程C之间需要使用同步原语(lock或volatile)来确保内存可见性

5、为什么final引用不能从构造函数内逸出

写final域的重排序规则可以确保:在引用变量为任意线程可见之前,该变量指向的对象的final域已经在构造函数中被正确初始化过了。在构造函数内部,不能让这个被构造对象的引用为其他线程可见,也就是对象引用不能在构造函数中逸出

public class FinalReferenceExample {
	final int i;
	static FinalReferenceExample obj;

	public FinalReferenceExample() {
		i = 1;// 1写final域
		obj = this;// 2this引用在此逸出
	}

	public static void writer() {
		new FinalReferenceExample();
	}

	public static void reader() {
		if (obj != null) {// 3
			int temp = obj.i;// 4
		}
	}
}

在这里插入图片描述在这里插入图片描述
在构造函数返回前,被构造对象的引用不能为其他线程所见,因为此时的final域可能还没有被初始化。在构造函数返回后,任意线程都将保证能看到final域正确初始化之后的值

6、JSR-133为什么要增强final的语义?

通过为final域增强写和读重排序规则,可以为Java程序员提供初始化安全保证:只要对象是正确构造的(被构造对象的引用在构造函数中没有逸出),那么不需要使用同步就可以保证任意线程都能看到这个final域在构造函数中被初始化之后的值

在极客时间的《Java并发编程实战》课程中,有关于Java内存模型的小节中一段评论总结了Java内存模型相关的知识点,可以一起来学习一下

1. 为什么定义Java内存模型?
现代计算机体系大部是采用的对称多处理器的体系架构。每个处理器均有独立的寄存器组和缓存,多个处理器可同时执行同一进程中的不同线程,这里称为处理器的乱序执行。在Java中,不同的线程可能访问同一个共享或共享变量。如果任由编译器或处理器对这些访问进行优化的话,很有可能出现无法想象的问题,这里称为编译器的重排序。除了处理器的乱序执行、编译器的重排序,还有内存系统的重排序。因此Java语言规范引入了Java内存模型,通过定义多项规则对编译器和处理器进行限制,主要是针对可见性和有序性
2. 三个基本原则:原子性、可见性、有序性
3. Java内存模型涉及的几个关键词:
锁、volatile字段、final修饰符与对象的安全发布
其中:第一是锁,锁操作是具备happens-before关系的,解锁操作happens-before之后对同一把锁的加锁操作。实际上,在解锁的时候,JVM需要强制刷新缓存,使得当前线程所修改的内存对其他线程可见。第二是volatile字段,volatile字段可以看成是一种不保证原子性的同步但保证可见性的特性,其性能往往是优于锁操作的。但是,频繁地访问 volatile字段也会出现因为不断地强制刷新缓存而影响程序的性能的问题。第三是final修饰符,final修饰的实例字段则是涉及到新建对象的发布问题。当一个对象包含final修饰的实例字段时,其他线程能够看到已经初始化的final实例字段,这是安全的
4. Happens-Before的7个规则:
1)、程序次序规则:在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作。准确地说,应该是控制流顺序而不是程序代码顺序,因为要考虑分支、循环等结构
2)、管程锁定规则:一个unlock操作先行发生于后面对同一个锁的lock操作。这里必须强调的是同一个锁,而"后面"是指时间上的先后顺序
3)、volatile变量规则:对一个volatile变量的写操作先行发生于后面对这个变量的读操作,这里的"后面"同样是指时间上的先后顺序
4)、线程启动规则:Thread对象的start()方法先行发生于此线程的每一个动作
5)、线程终止规则:线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值等手段检测到线程已经终止执行
6)、线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测到是否有中断发生
7)、对象终结规则:一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始
5. Happens-Before的1个特性:传递性
6. Java内存模型底层怎么实现的?
主要是通过内存屏障(memory barrier)禁止重排序的,即时编译器根据具体的底层体系架构,将这些内存屏障替换成具体的CPU指令。对于编译器而言,内存屏障将限制它所能做的重排序优化。而对于处理器而言,内存屏障将会导致缓存的刷新操作。比如,对于volatile,编译器将在volatile字段的读写操作前后各插入一些内存屏障

  • 3
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
提供的源码资源涵盖了安卓应用、小程序、Python应用和Java应用等多个领域,每个领域都包含了丰富的实例和项目。这些源码都是基于各自平台的最新技术和标准编写,确保了在对应环境下能够无缝运行。同时,源码中配备了详细的注释和文档,帮助用户快速理解代码结构和实现逻辑。 适用人群: 这些源码资源特别适合大学生群体。无论你是计算机相关专业的学生,还是对其他领域编程感兴趣的学生,这些资源都能为你提供宝贵的学习和实践机。通过学习和运行这些源码,你可以掌握各平台开发的基础知识,提升编程能力和项目实战经验。 使用场景及目标: 在学习阶段,你可以利用这些源码资源进行课程实践、课外项目或毕业设计。通过分析和运行源码,你将深入了解各平台开发的技术细节和最佳实践,逐步培养起自己的项目开发和问题解决能力。此外,在求职或创业过程中,具备跨平台开发能力的大学生将更具竞争力。 其他说明: 为了确保源码资源的可运行和易用,特别注意了以下几点:首先,每份源码都提供了详细的运行环境和依赖说明,确保用户能够轻松搭建起开发环境;其次,源码中的注释和文档都非常完善,方便用户快速上手和理解代码;最后,我定期更新这些源码资源,以适应各平台技术的最新发展和市场需求。

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

邋遢的流浪剑客

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

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

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

打赏作者

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

抵扣说明:

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

余额充值