这里写目录标题
一、并发编程的挑战
并发编程的主要目的在于让程序运行的更快,但是并不一定线程越多,程序就运行的越快,有时也会受到硬件、软件资源的限制,这里主要涉及到上下文切换和死锁的问题。
1.1 上下文切换
这里就不再阐述并发和并行的区别了,并发执行程序的过程,就是CPU通过给每个线程一定的时间片去运行,而每个时间片又是毫秒级别,所以线程之间的切换是非常迅速的,人是感知不到这种切换的,以至于让人感觉到貌似是同时执行的。
操作系统中,我们知道,进程之间的切换是有较大开销的,所以逐渐引入了更小单位——线程,而线程之间的切换同样也是有一定开销的,CPU通过时间片分配算法来循环执行任务(实现CPU资源的调度,分配给不同线程),每次切换前,必须要保存上一任务的状态,以便于下一次切换回该任务时,可以再加载这个任务状态。——这就是上下文切换:从保存到再加载任务的过程。
上下文切换是会影响到执行效率的。如下这个例子,两个简单的执行方法,用并行和串行分别执行,当循环次数不超过百万次的时候,并行其实是比串行要慢的,这就源于——上下文的开销
package com.mybatisplus.example.jena;
/**
* @author :erickun
* @date :Created in 2021/11/14 8:57 下午
* @description:
* @modified By:
* @version: $
*/
public class ConcurrencyTest {
private static final long count = 10000l;
public static void main(String[] args) throws InterruptedException {
concurrency();
serial();
}
private static void concurrency() throws InterruptedException {
long start = System.currentTimeMillis();
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
int a = 0;
for (long i = 0; i < count; i++) {
a += 5;
}
}
});
thread.start();
int b = 0;
for (long i = 0; i < count; i++) {
b--;
}
long time = System.currentTimeMillis() - start;
thread.join();
System.out.println("concurrency :" + time + "ms,b=" + b);
}
private static void serial() {
long start = System.currentTimeMillis();
int a = 0;
for (long i = 0; i < count; i++) {
a += 5;
}
int b = 0;
for (long i = 0; i < count; i++) {
b--;
}
long time = System.currentTimeMillis() - start;
System.out.println("serial:" + time + "ms,b=" + b + ",a=" + a);
}
}
如何减少上下文的切换呢?
为了减少上下文切换这个开销,核心思路在于尽量不要让线程之间去竞争锁:
-
无锁并发编程:让不同的线程去处理不同段的数据,这就不用去竞争锁而引发上下文切换。
-
CAS算法:Java的Atomic包下的原子操作CompareAndSet(自旋锁)
-
使用最少的线程:避免创建不需要的线程,这样会造成大量线程等待。
-
协程:在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换。
1.2 死锁
死锁,简单来说,就是两个线程在互相等待对方释放锁,从而形成的一种僵持局面,两方都达不到释放锁的条件,两个线程卡死无法执行下去。
一般可能会遇到,t1线程拿到锁后,由于异常情况、死循环没有释放锁,又或者t1拿到了数据库锁,释放锁的时候又出现了异常,导致没释放掉。
出现了死锁,如何排查是有必要了解的。
死锁的排查
可以使用jstack命令dump线程信息
如下样例:
/**
* @author :erickun
* @date :Created in 2021/11/14 9:53 下午
* @description:
* @modified By:
* @version: $
*/
public class JstackCase {
public static Executor executor = Executors.newFixedThreadPool(5);
public static Object lock = new Object();
public static void main(String[] args) {
Task task1 = new Task();
Task task2 = new Task();
executor.execute(task1);
executor.execute(task2);
}
static class Task implements Runnable{
@Override
public void run() {
synchronized (lock){
calculate();
}
}
public void calculate(){
int i = 0;
while (true){
i++;
}
}
}
}
执行命令:
jstack 49160 >log.txt
避免死锁的几个方法
- 避免一个线程同时获取多个锁
- 避免一个线程在锁内同时占用多个资源,尽量保证每个锁只占用一个资源
- 尝试使用定时锁,使用lock.tryLock(timeout)来替代
- 对于数据库锁,加锁和解锁要在一个数据库连接里,否则会出现解锁失败的情况。
1.3 资源限制的挑战
资源限制是指在进行并发编程时,程序的执行速度受限于计算机硬件资源或软件资源。 例如,服务器的带宽只有2Mb/s,某个资源的下载速度是1Mb/s每秒,系统启动10个线程下载资 源,下载速度不会变成10Mb/s,所以在进行并发编程时,要考虑这些资源的限制。硬件资源限 制有带宽的上传/下载速度、硬盘读写速度和CPU的处理速度。软件资源限制有数据库的连接 数和socket连接数等。
导致的问题:
在并发编程中,将代码执行速度加快的原则是将代码中串行执行的部分变成并发执行, 但是如果将某段串行的代码并发执行,因为受限于资源,仍然在串行执行,这时候程序不仅不 会加快执行,反而会更慢,因为增加了上下文切换和资源调度的时间。例如,之前看到一段程 序使用多线程在办公网并发地下载和处理数据时,导致CPU利用率达到100%,几个小时都不 能运行完成任务,后来修改成单线程,一个小时就执行完成了。
如何解决问题:
对于硬件资源限制,可以考虑使用集群并行执行程序。既然单机的资源有限制,那么就让程序在多机上运行。比如使用ODPS、Hadoop或者自己搭建服务器集群,不同的机器处理不同 的数据。可以通过“数据ID%机器数”,计算得到一个机器编号,然后由对应编号的机器处理这 笔数据。
对于软件资源限制,可以考虑使用资源池将资源复用。比如使用连接池将数据库和Socket 连接复用,或者在调用对方webservice接口获取数据时,只建立一个连接。
二、Java并发机制的底层实现原理
Java代码在编译后会变成Java字节码,字节码被类加载器加载到JVM里,JVM执行字节码,最终需要转化为汇编指令在CPU上执行,Java中所使用的并发机制依赖于JVM的实现和 CPU的指令。
2.1 volatile的应用
volatile和synchronized在并发编程中最为基础和重要!volatile是轻量级的synchronized,在多处理器开发中保证了“可见性”。
“可见性”——当一个线程 修改一个共享变量时,另外一个线程能读到这个修改的值。
volatile比Synchronized执行成本低的原因:不会引起线程的上下文切换和调度。
volatile的实现原理
volatile如何实现来保证多个线程之间同一个变量的可见性呢?这要从CPU角度去剖析:
java:
instance = new Singleton(); // instance是volatile变量
汇编代码:
0x01a3de1d: movb $0×0,0×1104800(%esi);0x01a3de24: lock addl $0×0,(%esp);
有volatile变量修饰的共享变量进行写操作的时候会多出第二行汇编代码,Lock前缀的指令在多核处理器下会引发了两件事情。
-
将当前处理器缓存行的数据写回到系统内存。
-
这个写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效。
对缓存行的理解:
为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的数据读到**内部缓存(L1,L2)**或其他)后再进行操作,但操作完不知道何时会写到内存。
如果对声明了volatile的 变量进行写操作,JVM就会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写回到系统内存。但是,就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题。所以,在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当 处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。
简而言之:
volatile操作,每个处理器会将更新后的变量从缓存区(本地内存)刷回系统内存(总线内存),其他的处理器利用——缓存一致性协议,不断的去看总线上值和自己缓存的值是否相等来判断版本过期没,再更新,处理器如果对volatile变量进行修改时,不仅会刷回总线内存,其他处理器也要去总线内存上更新各自的缓存值。
关键字:jvm -> 汇编Lock指令 -> 缓存刷回系统内存 -> 缓存一致性 : 其他处理器更新缓存
2.2 Synchronized的实现原理
相比于volatile的轻量级,Synchronized是重量级锁,由于太重量级,jdk1.6之后对齐进行了一些列优化,为了减少获得锁和释放锁的性能消耗而引入了偏向锁和轻量级锁,后面会介绍锁升级过程。
Synchronized的底层指令
从JVM规范中可以看到Synchonized在JVM里的实现原理,JVM基于进入和退出Monitor对象来实现方法同步和代码块同步,但两者的实现细节不一样。
代码块同步是使用monitorenter 和monitorexit指令实现的,而方法同步是使用另外一种方式实现的,细节在JVM规范里并没有详细说明。但是,方法的同步同样可以使用这两个指令来实现。
- monitorenter 和monitorexit指令
- 每个对象有一个监视器锁(monitor)。当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权
细节:
monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit是插入到方法结 束处和异常处,JVM要保证每个monitorenter必须有对应的monitorexit与之配对。任何对象都有 一个monitor与之关联,当且一个monitor被持有后,它将处于锁定状态。线程执行到monitorenter 指令时,将会尝试获取对象所对应的monitor的所有权,即尝试获得对象的锁。
下面可以通过反编译来看具体过程:
解释:
每个对象有一个监视器锁(monitor)。当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权,过程如下:
1、如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者。
2、如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1.
3.如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权。
参考链接:
package com.paddx.test.concurrent;
public class SynchronizedDemo {
public void method() {
synchronized (this) {
System.out.println("Method 1 start");
}
}
}
Synchronize锁的是啥?
-
对于普通同步方法,锁是当前实例对象。
-
对于静态同步方法,锁是当前类的Class对象。
-
对于同步方法块,锁是Synchonized括号里配置的对象。
参考链接:
深入理解这三句话:
“锁”本身是个对象,synchronized这个关键字不是“锁”。硬要说的话,加synchronized仅仅是相当于“加锁”这个操作。
所以,所谓的加锁,严格意义上不是锁住代码块!如果这样想的话,后面很多问题就没法解释了。
补充几个概念:
- 互斥的最基本条件是:共用同一把锁
- 静态方法的锁是所在类的Class对象,普通方法的锁是this对象
- 针对同一个线程,synchronized锁可以支持重入
案例1:
t1线程执行m1方法时要去读this对象锁,但是t2线程并不需要读锁,两者各管各的,没有交集(不共用一把锁)
案例2:
同一个类中的synchronized method m1和method m2互斥吗?
synchronized是可重入锁,可以粗浅地理解为同一个线程在已经持有该锁的情况下,可以再次获取锁,并且会在某个状态量上做+1操作
案例3:
子类同步方法synchronized method m可以调用父类的synchronized method m吗(super.m())?
在JVM学习中了解到子类new的类初始化过程:
步骤是:父类静态变量、父类成员变量、子类静态变量、子类成员变量
子类对象初始化前,会调用父类构造方法,在结构上相当于包裹了一个父类对象,用的都是this锁对象
案例4:
静态同步方法和非静态同步方法互斥吗?
Java对象头
一个Java对象由三部分组成:
-
对象头
-
实例数据
-
对齐填充字节
(一) 对象头
java的对象头由以下三部分组成:
1,Mark Word
2,指向类的指针
3,数组长度(只有数组对象才有)
1. Mark Word
Mark Word记录了对象和锁有关的信息,当这个对象被synchronized关键字当成同步锁时,围绕这个锁的一系列操作都和Mark Word有关。
Mark Word在32位JVM中的长度是32bit,在64位JVM中长度是64bit。
Mark Word在不同的锁状态下存储的内容不同,在32位JVM中是这么存的:
其中无锁和偏向锁的锁标志位都是01,只是在前面的1bit区分了这是无锁状态还是偏向锁状态。
JDK1.6以后的版本在处理同步锁时存在锁升级的概念,JVM对于同步锁的处理是从偏向锁开始的,随着竞争越来越激烈,处理方式从偏向锁升级到轻量级锁,最终升级到重量级锁。
☆☆☆锁升级过程
JVM一般是这样使用锁和Mark Word的:
1,当没有被当成锁时,这就是一个普通的对象,Mark Word记录对象的HashCode,锁标志位是01,是否偏向锁那一位是0。
2,当对象被当做同步锁并有一个线程A抢到了锁时,锁标志位还是01,但是否偏向锁那一位改成1,前23bit记录抢到锁的线程id,表示进入偏向锁状态。
3,当线程A再次试图来获得锁时,JVM发现同步锁对象的标志位是01,是否偏向锁是1,也就是偏向状态,Mark Word中记录的线程id就是线程A自己的id,表示线程A已经获得了这个偏向锁,可以执行同步锁的代码。
4,当线程B试图获得这个锁时,JVM发现同步锁处于偏向状态,但是Mark Word中的线程id记录的不是B,那么线程B会先用CAS操作试图获得锁,这里的获得锁操作是有可能成功的,因为线程A一般不会自动释放偏向锁。如果抢锁成功,就把Mark Word里的线程id改为线程B的id,代表线程B获得了这个偏向锁,可以执行同步锁代码。如果抢锁失败,则继续执行步骤5。
5,偏向锁状态抢锁失败,代表当前锁有一定的竞争,偏向锁将升级为轻量级锁。JVM会在当前线程的线程栈中开辟一块单独的空间,里面保存指向对象锁Mark Word的指针,同时在对象锁Mark Word中保存指向这片空间的指针。上述两个保存操作都是CAS操作,如果保存成功,代表线程抢到了同步锁,就把Mark Word中的锁标志位改成00,可以执行同步锁代码。如果保存失败,表示抢锁失败,竞争太激烈,继续执行步骤6。
6,轻量级锁抢锁失败,JVM会使用自旋锁,自旋锁不是一个锁状态,只是代表不断的重试,尝试抢锁。从JDK1.7开始,自旋锁默认启用,自旋次数由JVM决定。如果抢锁成功则执行同步锁代码,如果失败则继续执行步骤7。
7,自旋锁重试之后如果抢锁依然失败,同步锁会升级至重量级锁,锁标志位改为10。在这个状态下,未抢到锁的线程都会被阻塞。
2. 指向类的指针
该指针在32位JVM中的长度是32bit,在64位JVM中长度是64bit。
Java对象的类数据保存在方法区。
3. 数组长度
只有数组对象保存了这部分数据。
该数据在32位和64位JVM中长度都是32bit。
(二)实例数据
对象的实例数据就是在java代码中能看到的属性和他们的值。
(三)对齐填充字节
因为JVM要求java的对象占的内存大小应该是8bit的倍数,所以后面有几个字节用于把对象的大小补齐至8bit的倍数,没有特别的功能。
2.3 原子操作的实现原理
原子(atomic)本意是“不能被进一步分割的最小粒子”,而原子操作(atomic operation)意 为“不可被中断的一个或一系列操作”。在多处理器上实现原子操作就变得有点复杂。下面介绍处理如何实现原子操作。
处理器是通过提供总线锁定和缓存锁定两个机制来保证复杂内存操作的原子性的
2个机制实现原子性
- 总线锁定(开销较大):
就是使用处理器提供的一个 LOCK#信号,当一个处理器在总线上输出此信号时,其他处理器的请求将被阻塞住,那么该处理器可以独占共享内存。
- 缓存锁定:
在同一时刻,我们只需保证对某个内存地址的操作是原子性即可,但总线锁定把CPU和内存之间的通信锁住了,这使得锁定期间,其他处理器不能操作其他内存地址的数据,所以总线锁定的开销比较大,目前处理器在某些场合下 使用缓存锁定代替总线锁定来进行优化。
解释:
频繁使用的内存会缓存在处理器的L1、L2和L3高速缓存里,那么原子操作就可以直接在处理器内部缓存中进行,并不需要声明总线锁
无法使用缓存锁定的2种情况:
- 当操作的数据不能被缓存在处理器内部,或操作的数据跨多个缓存行(cache line)时,则处理器会调用总线锁定。
- 有些处理器不支持缓存锁定。对于Intel 486和Pentium处理器,就算锁定的内存区域在处理器的缓存行中也会调用总线锁定。
Java实现原子操作
CAS自旋锁
自旋锁不是一个锁状态,只是代表不断的重试。区别于synchronized同步锁的一种乐观锁,底层没有用到Synchronize同步操作,性能高于Synchronized。
AtomicInteger类compareAndSet通过原子操作实现了CAS操作,最底层基于汇编语言实现。
简单说一下原子操作的概念,“原子”代表最小的单位,所以原子操作可以看做最小的执行单位,该操作在执行完毕前不会被任何其他任务或事件打断。(执行一气呵成)
CAS是Compare And Set的一个简称,如下理解:
1,已知当前内存里面的值current和预期要修改成的值new传入
2,内存中AtomicInteger对象地址对应的真实值(因为有可能别修改)real与current对比,
相等表示real未被修改过,是“安全”的,将new赋给real结束然后返回;不相等说明real已经被修改,结束并重新执行1直到修改成功。
这里的“自旋”指的就是如果更新不了,就在循环中反复判断。
import java.util.concurrent.atomic.AtomicReference;
/**
* Created by Jack on 2017/1/7.
*/
public class AtomicReferenceTest {
public static void main(String[] args) {
// 创建两个Person对象,它们的id分别是101和102。
Person p1 = new Person(101);
Person p2 = new Person(102);
// 新建AtomicReference对象,初始化它的值为p1对象
AtomicReference ar = new AtomicReference(p1);
// 通过CAS设置ar。如果ar的值为p1的话,则将其设置为p2。
ar.compareAndSet(p1, p2);
Person p3 = (Person) ar.get();
System.out.println("p3 is " + p3);
System.out.println("p3.equals(p2)=" + p3.equals(p2));
}
}
class Person {
volatile long id;
public Person(long id) {
this.id = id;
}
public String toString() {
return "id:" + id;
}
}
分析:
新建AtomicReference对象ar时,将它初始化为p1。
紧接着,通过CAS函数对它进行设置。如果ar的值为p1的话,则将其设置为p2。
最后,获取ar对应的对象,并打印结果。p3.equals(2)的结果为true, 指向的是同一对象。
CAS的三大问题:
- ABA问题
CAS需要在操作值的时候,检查值是否改变,而如果是值从A改到B又改回了A,也无法发现其改变,但实际是变化了,ABA问题的解决思路就是使用版本号,每次更新的时候把版本号+1.AtomicStampedReference的compareAndSet方法的作用就是首先检查当前引用是否等于预期引用,并且检查当前标志是否等于预期标志,全部相等才会更新。
- 循环时间开销大
- 只能保证一个共享变量的原子操作
三、Java内存模型
3.1 内存模型基础
在并发编程中,需要处理两个关键问题:线程之间如何通信及线程之间如何同步(这里的线程是指并发执行的活动实体)。通信是指线程之间以何种机制来交换信息。在命令式编程中,线程之间的通信机制有两种:共享内存和消息传递
并发编程模型的两个关键问题
- 共享内存
- 消息传递
Java内存模型的抽象结构
首先,内存模型针对的是共享区域的变量,对于每个线程所拥有的局部变量、方法定义参数、异常处理器参数,是不会在线程之间共享的,这里回顾一下JVM,每个线程独占的有虚拟机栈、本地方法栈、程序计数器,而共享区域是堆内存和元空间。
- Java内存模型简称JMM,它决定了一个线程对共享变量的写入何时对另一个线程可见。
- JMM定义了线程和主内存之间的抽象关系:
线程之间的共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的 一个抽象概念,并不真实存在
线程A与线程B通信经历的过程
- 线程A把本地内存A中更新过的共享变量刷新到主内存中去。
- 线程B到主内存中去读取线程A之前已更新过的共享变量。
整个过程来看,其实实质上是线程A在向线程B发送消息,而且必须要经过主内存,JMM通过控制主内存与每个线程的本地内存之间的交互,来为Java程序员提供内存可见性保证。
从源代码到指令序列的重排序
在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。重排序分3种类型。
重排序的3种类型
- 编译器优化的重排序。
编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
- 指令级并行的重排序。
现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
- 内存系统的重排序。
由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
重排序针对编译器和处理器的操作
- 对于编译器->禁止特定类型编译器
JMM的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)
- 对于处理器重排序 ->插入内存屏障
JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障(Memory Barriers,Intel称之为 Memory Fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序。
并发编程模型的分类
处理器使用写缓冲区临时保存向内存写入的数据。写缓冲区可以保证指令流水线持续运行,它可以避免由于处理器停顿下来等待向内存写入数据而产生的延迟。同时,通过以批处理的方式刷新写缓冲区,以及合并写缓冲区中对同一内存地址的多次写,减少对内存总线的占用。
虽然写缓冲区有这么多好处,但每个处理器上的写缓冲区,仅仅对它所在的处理器可见。这个特性会对内存操作的执行顺序产生重要的影响:处理器对内存的读/写操作的执行 顺序,不一定与内存实际发生的读/写操作顺序一致!
写缓冲区的特点
- 每个处理器上的写缓冲区,仅仅对它所在的处理器可见
重排序后导致
- 处理器对内存的读/写操作的执行顺序,不一定与内存实际发生的读/写操作顺序一致!
举例:
过程分析:
这里处理器A和处理器B可以同时把共享变量写入自己的写缓冲区(A1,B1),然后从内存 中读取另一个共享变量(A2,B2),最后才把自己写缓存区中保存的脏数据刷新到内存中(A3, B3)。当以这种时序执行时,程序就可以得到x=y=0的结果。
也就是说,处理器在读b值的时候,实际上线程B对b值的修改还没有更新到主内存上去,所以读到的是初始值,对于线程B来说也是一样。
原因分析:
总结一句话就是:写缓冲区仅仅只对自己的处理器可见,它会导致处理器执行内存操作的顺序可能会与内存实际的操作执行顺序不一致!
JMM把内存屏障指令分为4类
为了保证内存可见性,Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。
- LoadLoad Barriers
确保load1数据装在先于Load2及后续装在指令的装载
- StoreStore Barriers
- LoadStore Barriers
- StoreLoad Barriers
StoreLoad Barriers是一个“全能型”的屏障,它同时具有其他3个屏障的效果。现代的多处理器大多支持该屏障(其他类型的屏障不一定被所有处理器支持)。执行该屏障开销会很昂贵,因为当前处理器通常要把写缓冲区中的数据全部刷新到内存中(Buffer Fully Flush)。
happens-before规则
JSR-133使用happens-before的概念来阐述操作之间的内存可见性。在JMM中,如果一 个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关 系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。
- 程序顺序规则
一个线程中的每个操作,happens-before于该线程中的任意后续操作。
- 监视器锁规则
对一个锁的解锁,happens-before于随后对这个锁的加锁。
- volatile变量规则
对一个volatile域的写,happens-before于任意后续对这个volatile域的 读。
- 传递性
如果A happens-before B,且B happens-before C,那么A happens-before C。
注意:
两个操作之间具有happens-before关系,并不意味着前一个操作必须要在后一个 操作之前执行!happens-before仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一 个操作按顺序排在第二个操作之前
一个happens-before规则对应于一个或多个编译器和处理器重排序规则。对于Java程序员来说,happens-before规则简单易懂,它避免Java程序员为了理解JMM提供的内存可见性保证而去学习复杂的重排序规则以及这些规则的具体实现方法。
3.2 重排序
- 重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段。
数据依赖性
-
编译器和处理器可能会对操作做重排序。编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。
-
仅针对单个处理器中执行的指令序列和单个线程中执行的操作, 不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。
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和B之间重排,只要在C之前,怎么做优化都可!
程序顺序规则
-
如果A happens-before B,JMM并不要求A一定要在B之前执行。JMM仅仅要求前一个 操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前
-
共同的目标:在不改变程序执行结果的前提下, 尽可能提高并行度。编译器和处理器遵从这一目标,从happens-before的定义我们可以看出, JMM同样遵从这一目标。
3.3 顺序一致性
顺序一致性内存模型只是一个理论参考模型,在设计的时候,处理器的内存模型和编程语言的内存模型都会以顺序一致性内存模型作为参照。
顺序一致性内存模型
- 一个线程中的所有操作必须按照程序的循序来执行
- (不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序。在顺序一致性内存模型中,每个操作都必须原子执行且立刻对所有线程可见。
同步情况下,该顺序一致性模型如下:
非同步情况下:
JMM与顺序一致性模型对比:
JMM vs 顺序一致性总结
-
顺序一致性模型(理想),就是一个线程中的操作严格按照代码的顺序来执行。
-
JMM中,不保证单线程内按照程序顺序来执行,临界区内的代码可以重排序。
JMM会在退出临界区和进入临界区这两个关键时间点做一些特别处理,使得线程在这两个时间点具有与顺序一致性模型相同的内存视图(具体细节后文会说明)。虽然线程A在临 区内做了重排序,但由于监视器互斥执行的特性,这里的线程B根本无法“观察”到线程A在临 界区内的重排序。这种重排序既提高了执行效率,又没有改变程序的执行结果。
- JMM在具体实现上的基本方针为:在不改变(正确同步的)程序执行结果的前提下,尽可能地为编译器和处理器的优化打开方便之门。
3.4 volatile内存语义
volatile写的内存语义
- 当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存。
volatile读的内存语义
- 当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主 内存中读取共享变量。
volatile变量特性
- 可见性
对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。
- 原子性
对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复合操作不具有原子性。
volatile与锁的内存效果
- volatile的写-读与锁的释放-获取有相同的内存效果。
- volatile写和锁的释放有相同的内存语义。
- volatile读与锁的获取有相同的内存语义。
volatile写-读的内存语义
- 线程A写一个volatile变量,实质上是线程A向接下来将要读这个volatile变量的某个线程发出了消息。
- 线程B读一个volatile变量,实质上是线程B接收了之前某个线程发出的消息。
- 线程A写一个volatile变量,随后线程B读这个volatile变量,这个过程实质上是线程A通过主内存向线程B发送消息。
volatile内存语义的实现
为了实现volatile内存语义,JMM 会分别限制编译器重排序和处理器重排序。
规则如下图:
为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来 禁止特定类型的处理器重排序。下面是基于保守策略的JMM内存屏障插入策略:
JMM内存屏障插入策略
- 在每个volatile写操作的前面插入一个StoreStore屏障。
- 在每个volatile写操作的后面插入一个StoreLoad屏障。
- 在每个volatile读操作的后面插入一个LoadLoad屏障。
- 在每个volatile读操作的后面插入一个LoadStore屏障。
JMM 的实现特点
- 首先确保正确性,然后再去追求执行效率。
volatile vs 锁
-
volatile仅仅保证对单个volatile变量的读/写具有原子性,而锁的互斥执行的特性可以 确保对整个临界区代码的执行具有原子性。
-
在功能上,锁比volatile更强大;在可伸缩性和执行性能上,volatile更有优势。如果读者想在程序中用volatile代替锁,请一定谨慎,具体详情请参阅Brian Goetz的文章《Java理论与实践:正确使用Volatile变量》。
-
volatile本质是在告诉jvm当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取; synchronized则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住。
-
volatile仅能使用在变量级别;synchronized则可以使用在变量、方法、和类级别的
-
volatile仅能实现变量的修改可见性,不能保证原子性;而synchronized则可以保证变量的修改可见性和原子性
-
volatile不会造成线程的阻塞;synchronized可能会造成线程的阻塞。
-
volatile标记的变量不会被编译器优化;synchronized标记的变量可以被编译器优化
JSR-133增强volatile的内存语义
严格 限制编译器和处理器对volatile变量与普通变量的重排序,确保volatile的写-读和锁的释放-获取具有相同的内存语义。从编译器重排序规则和处理器内存屏障插入策略来看,只要volatile 变量与普通变量之间的重排序可能会破坏volatile的内存语义,这种重排序就会被编译器重排序规则和处理器内存屏障插入策略禁止。
3.5 锁的内存语义
线程释放锁的内存语义
- JMM会把该线程对应的本地内存中的共享变量刷新到主内存中
线程获取锁的内存语义
- JMM会把该线程对应的本地内存置为无效,从而使得被监视器保护的临界区代码必须从主内存中读取共享变量
总结volatile和锁的内存语义
- 锁释放与volatile写有相同的内存语义。
- 锁获取与volatile读有相同的内存语义。
锁内存语义的实现
这里以ReentrantLock 实现为例。在ReentrantLock中,调用lock()方法获取锁;调用unlock()方法释放锁。ReentrantLock 的实现依赖java同步器框架 AbstractQueuedSynchronizer (简称AQS),AQS使用一个volatile类型的int变量来维护同步状态。
公平锁加锁代码如下:
可以看到加锁前首先读取volatile变量,如果未加锁则使用cas设置值尝试加锁,加锁成功将执行线程更新为当前线程。如果已经加锁并且是当前线程加锁则修改state的值, 其他情况就是加锁失败。
释放锁代码如下:
释放锁由于肯定是单线程的,所以最后直接更新state的值(写volatile)即可,非公平锁和公平锁类似,使用对volatile变量的cas操作来试下加锁。这个操作包含了volatile 的读和写。根据volatile语义,不允许对volatile写之前和读之后的指令重排序,这样就实现了锁的语义。
综上:对于ReentrantLock源码的分析,锁释放获取语义的实现方式至少有如下两种:
- 利用volatile变量的读写的内存语义来实现
- 利用CAS锁附带的volatile读写得内存语义来实现。
concurrent包的实现
volatile变量的读写 以及cas操作可以实现线程之间的通信。这是concurrent包的基石。仔细分析concurrent包的源码,会发现一个通用的实现模式
- 声明volatile共享变量
- 使用cas原子条件更新共享变量来实现线程之间的同步
AQS,非阻塞数据结构和原子变量类(atomic包下的类),这些基础类都使用这种模式实现,而concurrent包下的高层类都是依赖这些基础类实现的 比如上文提到的 ReentrantLock。所以 volatile和cas 是并发编程的基础。
3.6 final域的内存语义
重排序规则
- 在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用 变量,这两个操作之间不能重排序。
- 初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序。
写final域的重排序规则
- JMM禁止编译器把final域的写重排序到构造函数之外。
- 编译器会在final域的写之后,构造函数return之前,插入一个StoreStore屏障。这个屏障 禁止处理器把final域的写重排序到构造函数之外。
简而言之:就是让final变量的初始化在构造函数之内,避免处理器重排序到之外,导致引用错误。
读final域的重排序规则
- 在一个线程中,初次读对象引用与初次读该对象包含的final 域,JMM禁止处理器重排序这两个操作(注意,这个规则仅仅针对处理器)。编译器会在读final 域操作的前面插入一个LoadLoad屏障。
3.7 happens-before
JMM设计关键
- 程序员对内存模型的使用。程序员希望内存模型易于理解、易于编程。程序员希望基于一个强内存模型来编写代码。
- 编译器和处理器对内存模型的实现。编译器和处理器希望内存模型对它们的束缚越少越好,这样它们就可以做尽可能多的优化来提高性能。编译器和处理器希望实现一个弱内存模型。
由于这两个因素互相矛盾,所以JSR-133专家组在设计JMM时的核心目标就是找到一个 好的平衡点:一方面,要为程序员提供足够强的内存可见性保证;另一方面,对编译器和处理器的限制要尽可能地放松。
happens-before要求禁止的重排序分类
- 会改变程序执行结果的重排序
- 不会改变程序执行结果的重排序
JMM采取的策略
- 对于会改变程序执行结果的重排序,JMM要求编译器和处理器必须禁止这种重排序。
- 对于不会改变程序执行结果的重排序,JMM对编译器和处理器不做要求(JMM允许这种重排序)。
简而言之:在保证结果正确性的前提下,编译器、处理器怎么做优化都可以,只要不改变程序的执行结果
as-if-serial & happens-before目的
as-if-serial语义和happens-before这么做的目的,都是为了在不改变程序执行结果的前提 下,尽可能地提高程序执行的并行度。
3.8 双重检查锁定与延迟初始化
非线程安全的延迟初始化对象
- 不加同步
下面是非线程安全的延迟初始化对象的示例代码。
public class UnsafeLazyInitialization {
private static Instance instance;
public static Instance getInstance() {
if (instance == null) instance = new Instance();
return instance;
}
}
// 1:A线程执行
// 2:B线程执行
假设A线程执行代码1的同时,B线程执行代码2。此时,线程A可能会看到instance引用的对象还没有完成初始化
线程安全的延迟初始化
- 对getInstance() 加Synchronized
对于UnsafeLazyInitialization类,我们可以对getInstance()方法做同步处理来实现线程安全 的延迟初始化:
public class SafeLazyInitialization {
private static Instance instance;
public synchronized static Instance getInstance() {
if (instance == null) instance = new Instance();
return instance;
}
}
由于对getInstance()方法做了同步处理,synchronized将导致性能开销。如果getInstance()方法被多个线程频繁的调用,将会导致程序执行性能的下降。反之,如果getInstance()方法不会被 多个线程频繁的调用,那么这个延迟初始化方案将能提供令人满意的性能。
双重检查锁定来实现延迟初始化
- 在判断instance为空时,再去同步加锁,二次判断
在早期的JVM中,synchronized存在巨大的性能开销。因此, 人们想出了一个“聪明”的技巧:双重检查锁定(Double-Checked Locking)。人们想通过双重检查锁定来降低同步的开销。下面是使用双重检查锁定来实现延迟初始化的示例代码。
此为错误优化:在线程执行到第4行,代码读取到instance不为null时,instance引用的对象有可能还没有完成初始化。
分析问题根源:
instance=new Singleton();具体过程
- memory = allocate(); // 1:分配对象的内存空间
- ctorInstance(memory); // 2:初始化对象
- instance = memory; // 3:设置instance指向刚分配的内存地址
但实际上2和3之间,可能会被重排序,如果发生重排序,那么另一个线程引用到了对象变量时,可能该对象还没有初始化完毕,从而发生错误
解决思路
- 不允许2和3重排序。
- 允许2和3重排序,但不允许其他线程“看到”这个重排序。
解决方案
基于volatile的解决方案
- 把instance声明为volatile型
当声明对象的引用为volatile后,instance=new Singleton();中的3行伪代码中的2和3之间的重排序,在多线程环境中将会被禁止。
基于类初始化的解决方案
-
JVM在类的初始化阶段(即在Class被加载后,且被线程使用之前),会执行类的初始化。在
执行类的初始化期间,JVM会去获取一个锁。这个锁可以同步多个线程对同一个类的初始化。