Java锁机制

本文将从Synchronized、ReentrantLock、Volatile、CAS的各个角度来分析

Synchronized

前言:为什么早期Synchronized是重量级锁,因为需要经过额外的内核态,通过内核态的允许去访问操作系统硬件来获取这把锁。锁在计算机底层大概就是一个信号,在总线上有这个信号,然后其他cpu就不可以去执行。
而经过优化后,Synchronized不单单是重量级锁了,可以直接在用户态(实质是不需要额外的内核态)就可以获取到这把锁,比如CAS(也叫轻量级锁),如果解决不了再慢慢升级到重量级锁。
为什么说是额外的内核态?因为你非要说的话,其实所有的java指令最后都会翻译成汇编语言,都会通过内核态去执行底层硬件
首先,现来看一下,synchronized的使用方法:
1、对一个对象进行加锁
synchronized(this){ //代码 }
2、对一个方法进行加锁
public synchornized void test(){ //代码 }
实际上,无论是对一个对象进行加锁还是对一个方法进行加锁,实际上,都是对对象进行加锁。也就是说,对于方式2,实际上虚拟机会根据synchronized修饰的是实例方法还是类方法,去取对应的实例对象或者Class对象来进行加锁。
(补充:静态同步方法锁的是对象所属类public static synchornized void test(){ //代码 } )

所以既然是对对象加锁,我们是不是应该了解下对象的结构呢?
锁的信息都是存在对象头中的MarkWord中的。结构如下:
在这里插入图片描述
HotSpot虚拟机中,对象在内存中存储的布局可以分为三块区域:
对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)

HotSpot虚拟机的对象头(Object Header)包括两部分信息:
第一部分用于存储对象自身的运行时数据, 如哈希码(HashCode)、GC分代年龄、锁状态标志、
线程持有的锁、偏向线程ID、偏向时间戳等等,这部分数据的长度在32位和64位的虚拟机
(暂不考虑开启压缩指针的场景)中分别为32个和64个Bits,官方称它为“Mark Word”。

对象头的另外一部分是类型指针,即是对象指向它的类的元数据的指针,
虚拟机通过这个指针来确定这个对象是哪个类的实例。

Synchronized锁的升级过程:
从只能直接是重量级锁到根据情况自动调整锁等级
在这里插入图片描述
偏向锁就是:只是把自己线程的id贴在markword上,表示这个变量我正在用,假装上锁了,其实根本没有上锁,
为何会有偏向锁?
因为多数sychronized方法,在很多情况下,只有一个线程在运行。既然这样,就没必要去内核态申请一把大锁,这样太麻烦浪费时间、资源。
例如: StringBuffer中的一些sync方法、Vector中的一些sync方法
很大情况下我们运行StringBuffer的一些sync方法,其实就一个线程,没有优化前的synchronized操作都需要去操作系统内核态申请锁、优化后就不用这样了
偏向锁实质上没有加锁,万一有另外一个线程来了吗,变成了2个线程呢?
会自动升级为自旋锁、轻量级锁。2个人自己争抢
有了轻量级锁为什么还要重量级锁?
首先轻量级锁抢不到会while循环,会占用cpu资源,如果正在等待的线程执行的时间不久可以接受。反之,是极其消耗cpu资源的,因为外面的许多线程会一直while。这个时候用重量级锁更合适。重量级锁是放进队列,一个一个来
轻量级锁什么时候升重量级锁?
jdk1.6之前,有线程CAS转圈超过10卷或者多个线程在等待(超过cpu/2个线程),升级。参数可配置
jdk1.6之后,自适应,jvm内部会调整
什么情况下片偏向直接升级到轻量级锁?
偏向锁默认启动,上锁一般是在对象创建之后4秒之后加上,为什么是4秒?因为留4秒是给jvm启动会进行相关数据加载、协调进程同步的资源等。 如果发现这个对象将会被多个线程进行访问,则没有必要上偏向锁了,直接上轻量级锁。

ReentrantLock

首先说明是AQS
AbstractQueuedSynchronizer 类如其名,抽象的队列式的同步器,AQS 定义了一套多线程访问 共享资源的同步器框架,许多同步类实现都依赖于它,如常用的 ReentrantLock/Semaphore/CountDownLatch
AQS 只是一个框架,具体资源的获取/释放方式交由自定义同步器去实现
synchronized 和 ReentrantLock的区别
相同点:

  1. 都是用来协调多线程对共享对象、变量的访问
  2. 都是可重入锁,同一线程可以多次获得同一个锁(什么是 “可重入”,可重入就是说某个线程已经获得某个锁,该线程可以不用释放再次获取该锁n次而不会出现死锁。比如拿碗排队取水喝,取完下一个取,但是我在取水的时候喝掉然后可以再取!)
  3. 都保证了可见性和互斥
    在这里插入图片描述

synchronized与lock的异同:
● synchronized是java关键字,内置,而lock不是内置,是一个类,可以实现同步访问且比synchronized中的方法更加丰富
● synchronized不会手动释放锁,而lock需手动释放锁(不解锁会出现死锁,需要在 finally 块中释放锁)
● lock等待锁的线程会相应中断,而synchronized不会相应,只会一直等待
● 通过 Lock 可以知道有没有成功获取锁,而 synchronized 却无法办到
● Lock 可以提高多个线程进行读操作的效率(当多个线程竞争的时候)

volatile

前置知识:学习《深入理解Java虚拟机》中12.2 硬件的效率与一致性 开始往后的内容。
前置知识:什么是指令重排序
就是为了加快CPU执行速度的一种方案:
1、在CPU和内存之间增加高速缓存
2、使用指令重排序,就是为了使处理器内部的运算单元能尽量被充分利用,处理器可能会对输入 代码进行乱序执行优化,所谓的乱序执行优化就是将代码转为的 指令 分发给多个不同的CPU处理器去执行(处理器采用了允许将多条指令 不按程序规定的顺序分开发送给各个相应的电路单元进行处理)不同处理器执行速度无法保持一致,所以无法保证这些指令是按顺序执行的。
但并不是说指令任意重排,处理器必须能正确处理指令依赖情况保障程序能得出正确的执行结果。 所在当只有一个线程请求时、只有一个处理器访问内存时,执行一段代码,采用指令重排序是没问题的(自己会调整保证结果的正确性,譬如指令1把地址A中的值加10,指令2 把地址A中的值乘以2,指令3把地址B中的值减去3,这时指令1和指令2是有依赖的,它们之间的顺序 不能重排——(A+10)2与A2+10显然不相等,但指令3可以重排到指令1、2之前或者中间,只要保证 处理器执行后面依赖到A、B值的操作时能获取正确的A和B值即可。所以在同一个处理器中,重排序 过的代码看起来依然是有序的)。但是在多线程并发情况下、有两个或更多处理器访问同一块内存,存在不安全情况。
前置知识:Java内存模型的基础

1、并发编程模型的两个问题

在并发编程中,需要了解并会处理这两个关键问题:
1.1、线程之间如何通信?
通信是指线程之间以何种机制来交换信息。在命令式编程中,线程之间的通信机制有两种:共享内存和消息传递。
a) 在共享内存的并发模型里,线程之间共享程序的公共状态,通过写-读内存中的公共状态进行隐式通信。(重点)
b) 在消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过发送消息来显式进行通信
1.2、线程之间如何同步?
同步是指程序中用于控制不同线程间操作发生相对顺序的机制。
在共享内存的并发模型里,同步是显示进行的。因为程序员必须显式指定某个方法或某段代码需要在线程之间互斥执行。
在消息传递的并发模型里,由于消息的发送必须在消息的接收之前,因此同步是隐式进行的。
知道并了解上面两个问题后,对java内存模型的了解,就打下了基础。因为Java的并发模型采用的是共享内存模型,java线程之间的通信总是隐式进行,整个通信过程对程序员完全透明。

2、Java内存模型的抽象结构

Java线程之间的通信由Java内存模型(简称JMM)控制。JMM决定一个线程对共享变量的写入何时对另一个线程可见。从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存存储了该线程以读/写共享变量的副本。本地内存是JMM的抽象概念,并不真实存在。Java内存模型的抽象示意图:
在这里插入图片描述
从上图来看,如果线程A和线程B之间要通信的话,必须要经历下面两个步骤:
1)线程A把本地内存A中更新过的共享变量刷新到主内存中去。
2)线程B到主内存中去读取线程A之前已更新过的共享变量。
举个例子:线程A与线程B进行通信,如下图:
在这里插入图片描述
假设初始时,这三个内存中x的值都为0,线程A在执行时,把更新后的x值临时放在本地内存。当线程A与线程B需要通信时,
步骤1:线程A首先会把自己本地内存中修改后的x值刷新到主内存中,此时主内存中的x值变为了1。
步骤2:线程B到主内存中读取线程A更新后的X值,此时线程B的本地内存x的值也变为了1。
从整体(不考虑重排序,按顺序执行)来看,这两个步骤实质上是线程A在向线程B发送消息,而且这个通信过程必须要经过主内存。JMM通过控制主内存与每个线程的本地内存之间的交互,提供内存可见性的保证。

volatile 变量具备两种特性:变量可见性和禁止指令重排序

特性一、变量可见性
当一个变量被定义成volatile之后,它将具备两项特性:第一项是保证此变量对所有线程的可见 性,这里的“可见性”是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知 的( 其实也是走主内存,暂且理解为立即 )。而普通变量并不能做到这一点,普通变量的值在线程间传递时均需要通过主内存来完成。比如, 线程A修改一个普通变量的值,这个时候只是工作内存中这个值改变了,需要向主内存进行回写,另外一条线程B在线程A回写完成了之后再对主内存进行读取到工作内存的操作,新变量值才会对线程B可见。

关于volatile变量的可见性,经常会被开发人员误解,他们会误以为下面的描述是正确的:
“volatile 变量对所有线程是立即可见的,对volatile变量所有的写操作都能立刻反映到其他线程之中。换句话 说,volatile变量在各个线程中是一致的,所以基于volatile变量的运算在并发下是线程安全的”。这句话 的论据部分并没有错,但是由其论据并不能得出“基于volatile变量的运算在并发下是线程安全的”这样 的结论。
volatile变量在各个线程的工作内存中是不存在一致性问题的(从物理存储的角度看,各个线 程的工作内存中volatile变量也可以存在不一致的情况,但由于每次使用之前都要先刷新,执行引擎看 不到不一致的情况,因此可以认为不存在一致性问题),但是Java里面的运算操作符并非原子操作, 这导致volatile变量的运算在并发下一样是不安全的,我们可以通过一段简单的演示来说明原因,请看 代码清单12-1中演示的例子。
代码清单12-1 volatile的运算[1]

/**
* volatile变量自增运算测试
*
* @author zzm
*/
public class VolatileTest {
    public static volatile int race = 0;
    public static void increase() {
        race++;
    }
    private static final int THREADS_COUNT = 20;
    public static void main(String[] args) {
        Thread[] threads = new Thread[THREADS_COUNT];
        for (int i = 0; i < THREADS_COUNT; i++) {
            threads[i] = new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int i = 0; i < 10000; i++) {
                        increase();
                    }
                }
            });
            threads[i].start();
        }
        // 等待所有累加线程都结束
        while (Thread.activeCount() > 1)
            Thread.yield();
        System.out.println(race);
    }
}

这段代码发起了20个线程,每个线程对race变量进行10000次自增操作,如果这段代码能够正确并 发的话,最后输出的结果应该是200000。读者运行完这段代码之后,并不会获得期望的结果,而且会 发现每次运行程序,输出的结果都不一样,都是一个小于200000的数字。这是为什么呢?
答:问题就出在自增运算“race++”之中,我们用Javap反编译这段代码后会得到代码清单12-2所示,发 现只有一行代码的increase()方法在Class文件中是由4条字节码指令构成(return指令不是由race++产生 的,这条指令可以不计算),从字节码层面上已经很容易分析出并发失败的原因:**当getstatic指令把 race的值取到操作栈顶时,volatile关键字保证了race的值在此时是正确的,**但是在执行iconst_1、iadd这 些指令的时候,其他线程可能已经把race的值改变了,而操作栈顶的值就变成了过期的数据,所以 putstatic指令执行后就可能把较小的race值同步回主内存之中。 volatile变量保证可见性并不保证原子性!
代码清单12-2 VolatileTest的字节码

public static void increase();
Code:
Stack=2, Locals=0, Args_size=0
0: getstatic #13; //Field race:I
3: iconst_1
4: iadd
5: putstatic #13; //Field race:I  //这里主要是发生写入的时候被其他线程改了,是代码的问题。
                                  //对于这个值来说,volatile还是通知到了其他线程
                                  //只是说这个值是错的
8: return
    LineNumberTable:
line 14: 0
    line 15: 8

实事求是地说,其实使用字节码来分析并发问题仍然是不严谨的,因为即使编译出来只有一条字 节码指令,也并不意味执行这条指令就是一个原子操作。一条字节码指令在解释执行时,解释器要运 行许多行代码才能实现它的语义。如果是编译执行,一条字节码指令也可能转化成若干条本地机器码 指令。此处使用-XX:+PrintAssembly参数输出反汇编来分析才会更加严谨一些,但是考虑到读者阅读 的方便性,并且字节码已经能很好地说明问题,所以此处使用字节码来解释。
由于volatile变量只能保证可见性,在不符合以下两条规则的运算场景中,我们仍然要通过加锁 (使用synchronized、java.util.concurrent中的锁或原子类)来保证原子性
● 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值。
● 变量不需要与其他的状态变量共同参与不变约束。

而在像代码清单12-3所示的这类场景中就很适合使用volatile变量来控制并发,当shutdown()方法被 调用时,能保证所有线程中执行的doWork()方法都立即停下来
代码清单12-3 volatile的使用场景

volatile boolean shutdownRequested;
public void shutdown() {
    shutdownRequested = true; //将volatile变量shutdownRequested改为true,
                              //其他线程都感知到,也变成了true
}
public void doWork() {
    while (!shutdownRequested) {
        // 代码的业务逻辑
    }
}

特性二、禁止指令重排序优化
普通的变量仅会保证在该方法的执行过程 中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的 执行顺序一致(普通的变量在执行的过程中指令会被重排序)。因为在同一个线程的方法执行过程中无法感知到这点,这就是Java内存模型中描述的 所谓“线程内表现为串行的语义”(Within-Thread As-If-Serial Semantics),但是在多线程情况下指令重排序会干扰程序的并发执行。
上面描述仍然比较拗口难明,我们还是继续通过一个例子来看看为何指令重排序会干扰程序的并 发执行。
演示程序如代码清单12-4所示。

Map configOptions;
char[] configText;
// 此变量必须定义为volatile
volatile boolean initialized = false;
// 假设以下代码在线程A中执行
// 模拟读取配置信息,当读取完成后
// 将initialized设置为true,通知其他线程配置可用
configOptions = new HashMap();
configText = readConfigFile(fileName);
processConfigOptions(configText, configOptions);
initialized = true;
// 假设以下代码在线程B中执行
// 等待initialized为true,代表线程A已经把配置信息初始化完成
while (!initialized) {
    sleep();
}
// 使用线程A中初始化好的配置信息
doSomethingWithConfig();

代码清单12-4中所示的程序是一段伪代码,其中描述的场景是开发中常见配置读取过程,只是我 们在处理配置文件时一般不会出现并发,所以没有察觉这会有问题。读者试想一下,如果定义 initialized变量时没有使用volatile修饰,就可能会由于指令重排序的优化,导致位于线程A中最后一条 代码“initialized=true”被提前执行(这里虽然使用Java作为伪代码,但所指的重排序优化是机器级的优 化操作,提前执行是指这条语句对应的汇编代码被提前执行),这样在线程B中使用配置信息的代码 就可能出现错误,而volatile关键字则可以避免此类情况的发生
指令重排序是并发编程中最容易导致开发人员产生疑惑的地方之一,除了上面伪代码的例子之 外,笔者再举一个可以实际操作运行的例子来分析volatile关键字是如何禁止指令重排序优化的。
下面代码是一段标准的双锁检测(Double Check Lock,DCL)单例[3]代码,可以观察加入volatile 和未加入volatile关键字时所生成的汇编代码的差别

public class Singleton {
    private volatile static Singleton instance;
    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
    public static void main(String[] args) {
        Singleton.getInstance();
    }
}

先说未使用volatile的变量结果:
instance =newSingleton();这行代码。是instance对象的初始化,具体来说可以拆分为三个部分(可以理解是内部执行这三个指令):
1.分配对象内存空间
2.初始化对象
3.赋值给引用
而JVM在执行过程中为了保证效率等因素可能会进行重排序,如顺序变为132。这样操作对该线程本身是不会产生影响的,但如果在13之后,进来了另一个线程,该线程判断instance将不为null(因为该引用已经指向了一块内存空间),直接返回 instance,就会导致异常。因此,加入volatile的另一个作用是为了禁止指令重排序。
为什么volatile能禁止指令重排序?
编译上面的代码为汇编语言进行分析,这段代码对instance变量赋值的部分如代码清单如下所示:

0x01a3de0f: mov $0x3375cdb0,%esi ;...beb0cd75 33
; {oop('Singleton')}
0x01a3de14: mov %eax,0x150(%esi) ;...89865001 0000
0x01a3de1a: shr $0x9,%esi ;...c1ee09
0x01a3de1d: movb $0x0,0x1104800(%esi) ;...c6860048 100100
0x01a3de24: lock addl $0x0,(%esp) ;...f0830424 00
;*putstatic instance
; - Singleton::getInstance@24

通过对比发现,关键变化在于有volatile修饰的变量,赋值后(前面mov%eax,0x150(%esi)这句便是赋值操作)多执行了一个“lock addl$0x0,(%esp)”操作,这个操作的作用相当于一个内存屏障 Memory Barrier,指重排序时不能把该变量所生成的后面的指令重排序到内存屏障之前的位置,只能按照顺序执行下去(也就是说lock前缀指令是volatile禁止重排序实现,注意不要与第3章中介绍的垃圾收集器用于捕获变量访问的内存屏障互相混淆)

只有一个处理器访问内存时,并不需要内存屏障(不会被其他人影响,自己一个人执行);但如果有两个或更多处理器访问同一块内存,且其中有一个在观测另一个,就需要内存屏障来保证一致性了。

这句指令中的“addl$0x0,(%esp)”(把ESP寄存器的值加0)显然是一个空操作,它的作用是将本处理器的工作缓存写入了内存,强制的执行,由于OS的缓存一致性协议MESI,该写入动作会引起 别的处理器或者别的内核无效化其缓存,原理是别的处理器根据嗅探(snooping)"协议发现自己缓存无效后只能再去主内存取最新的,这种操作相当于对缓存中的变量做了一次前面 介绍Java内存模式中所说的“store和write”操作[4]。所以通过这样一个空操作,可让前面volatile变量的修改对其他处理器立即可见**(addl$0x0,(%esp)指令是volatile可见性的实现)**
lock addl$0x0,(%esp)指令把修改同步到内存时,意味着所有之前的操作都已经执行完成。这样便形成了“指令重排序无法越过内存屏障”的效果。

嗅探协议

所有内存的传输都发生在一条共享的总线上,而所有的处理器都能看到这条总线:
缓存本身是独立的,但是内存是共享资源,所有的内存访问都要经过仲裁
(同一个指令周期中,只有一个CPU缓存可以读写内存)。
CPU缓存不仅仅在做内存传输的时候才与总线打交道,
而是不停在嗅探总线上发生的数据交换,跟踪其他缓存在做什么
。所以当一个缓存代表它所属的处理器去读写内存时,
其它处理器都会得到通知,它们以此来使自己的缓存保持同步。
只要某个处理器一写内存,其它处理器马上知道这块内存在它们的缓存段中已失效。

补充知识点:现代的多核操作系统默认都有缓存一致性协议,所以其实操作java的普通变量
也是有嗅探协议的,那这样的话其他java普通变量和volatile变量不是没有区别了吗?
首先可以肯定的是volatile变量相比普通变量具有禁止重排序的特点,再者就是volatile
强制触发修改后立刻写回主内存(连续且一起出现assign <-store <-write组成原子操作),
而普通变量这个过程并没有要求连续且是立刻,只要求成对出现,可能一致性没有那么保证,
会出现没有及时更新读取的情况,具体规则可以看:volatile变量定义的特殊规则 和 普通变量的规则

解决了volatile的语义问题,再来看看在众多保障并发安全的工具中选用volatile的意义——它能让 我们的代码比使用其他的同步工具更快吗?在某些情况下,volatile的同步机制的性能确实要优于锁 (使用synchronized关键字或java.util.concurrent包里面的锁),但是由于虚拟机对锁实行的许多消除和 优化,使得我们很难确切地说volatile就会比synchronized快上多少。如果让volatile自己与自己比较,那 可以确定一个原则:volatile变量读操作的性能消耗与普通变量几乎没有什么差别,但是写操作则可能 会慢上一些,因为它需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行。不过即 便如此,大多数场景下volatile的总开销仍然要比锁来得更低。我们在volatile与锁中选择的唯一判断依 据仅仅是volatile的语义能否满足使用场景的需求。

1、普通变量的规则
如果要把一个变量从主内存拷贝到工作内存,那就要按顺序执行read和load操作,如果要把变量从
工作内存同步回主内存,就要按顺序执行store和write操作。注意,Java内存模型只要求上述两个操作必须按顺序执行,但不要求是连续执行(但是volatile变量特殊,要求连续执行!)。也就是说read与load之间、store与write之间是可插入其他指令的,如对主内存中的变量a、b进行访问时,一种可能出现的顺序是read a、read b、load b、load a。除此之外,Java内存模型还规定了在执行上述8种基本操作时必须满足如下规则:
● 不允许read和load、store和write操作之一单独出现,即不允许一个变量从主内存读取了但工作内存不接受,或者工作内存发起回写了但主内存不接受的情况出现。
● 不允许一个线程丢弃它最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步回主内存。
● 不允许一个线程无原因地(没有发生过任何assign操作)把数据从线程的工作内存同步回主内存中
● ·一个新的变量只能在主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load或 assign)的变量,换句话说就是对一个变量实施use、store操作之前,必须先执行assign和load操作。
● ·一个变量在同一个时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执 行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。
● ·如果对一个变量执行lock操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作以初始化变量的值(保证一致性)。**
● ·如果一个变量事先没有被lock操作锁定,那就不允许对它执行unlock操作,也不允许去unlock一个 被其他线程锁定的变量。
● ·对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store、write操作)。

2、volatile变量定义的特殊规则 (兼容普通变量的规则同时自己特有的规则)
本节的最后,我们再回头来看看Java内存模型中对volatile变量定义的特殊规则的定义。假定T表示 一个线程,V和W分别表示两个volatile型变量,那么在进行read、load、use、assign、store和write操作 时需要满足如下规则:

  1. 只有当线程T对变量V执行的前一个动作是load的时候,线程T才能对变量V执行use动作;并且, 只有当线程T对变量V执行的后一个动作是use的时候,线程T才能对变量V执行load动作。线程T对变量 V的use动作可以认为是和线程T对变量V的load、read动作相关联的,必须连续且一起出现。 (use <-load <-read组成原子操作)
    这条规则要求在工作内存中,每次使用V前都必须先从主内存刷新最新的值,用于保证能看见其 他线程对变量V所做的修改。

  2. 只有当线程T对变量V执行的前一个动作是assign的时候,线程T才能对变量V执行store动作;并 且,只有当线程T对变量V执行的后一个动作是store的时候,线程T才能对变量V执行assign动作。线程 T对变量V的assign动作可以认为是和线程T对变量V的store、write动作相关联的,必须连续且一起出现。 (assign ->store -> write组成原子操作)
    这条规则要求在工作内存中,每次修改V后都必须立刻同步回主内存中(普通变量可没有此规定),用于保证其他线程可以 看到自己对变量V所做的修改。

  3. 假定动作A是线程T对变量V实施的use或assign动作,假定动作F是和动作A相关联的load或store动作,假定动作P是和动作F相应的对变量V的read或write动作;
    A F P 是对 V user/assgin load/store read/write 动作
    与此类似,假定动作B是线程T对变量W 实施的use或assign动作,假定动作G是和动作B相关联的load或store动作,假定动作Q是和动作G相应的 对变量W的read或write动作。
    B G Q 是对 W user/assgin load/store read/write 动作
    如果A先于B,那么P先于Q。这个是禁止重排序的实现
    这条规则要求volatile修饰的变量不会被指令重排序优化,从而保证代码的执行顺序与程序的顺序 相同。

指令参考:
lock(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态。
unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量
才可以被其他线程锁定。
read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以
便随后的load动作使用。
load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的
变量副本中。
use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚
拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收的值赋给工作内存的变量,
每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随
后的write操作使用。
write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的
变量中。

比 sychronized 更轻量级的同步锁
在访问 volatile 变量时不会执行加锁操作,因此也就不会使执行线程阻塞,因此 volatile 变量是一 种比 sychronized 关键字更轻量级的同步机制。
volatile 适合这种场景:一个变量被多个线程共享,线程直接给这个变量赋值。
在这里插入图片描述
当对非 volatile 变量进行读写的时候,每个线程先从内存拷贝变量到 CPU 缓存中。如果计算机有 多个 CPU,每个线程可能在不同的 CPU 上被处理,这意味着每个线程使用的变量直接从自己的 CPU cache 获取了,不是最新的。而声明变量是 volatile 的,JVM 保证了每次读变量都从内存中读,跳过 CPU cache 这一步,获取到的是最新的。
适用场景
值得说明的是对 volatile 变量的单次读/写操作可以保证原子性的,如 long 和 double 类型变量, 但是并不能保证 i++这种操作的原子性,因为本质上 i++是读、写两次操作。在某些场景下可以 代替 Synchronized。但是,volatile 的不能完全取代 Synchronized 的位置,只有在一些特殊的场景下,才能适用 volatile。
总的来说,必须同时满足下面两个条件才能保证volatile在并发环境的线程安全:
(1)对变量的写操作不依赖于当前值(比如 i++),或者说是单纯的变量赋值(boolean flag = true)。
(2)该变量没有包含在具有其他变量的不变式中,也就是说,不同的 volatile 变量之间,不能互相依赖。只有在状态真正独立于程序内其他内容时才能使用 volatile。

CAS

CAS: compare And Set,也有人叫自旋锁、轻量级锁,无锁(叫无锁的原因是加锁过程不需要额外内核态的锁)。就是在把修改后的值写回去的时候,要比较一下看现在的值是否还是我读取的时候的那个值。如果是就写过去,如果不是则重新执行。
CAS无其他并发线程干扰情况:
在这里插入图片描述
CAS存在其他并发线程干扰情况:
在这里插入图片描述
会有ABA问题,可通过加一个版本号解决,任何一个线程对它执行操作都会产生一个新的版本号
在这里插入图片描述

反正在java.util.concurrent.atomic包下的类,都是以CAS锁来保证线程安全的
CAS研究:以AtomicInteger类型为例,对象.incrementAndGet()-> unsafe.getAndaddInt()
-> this.compareAndSwapInt()(native方法) -> Unsafe_CompareAndSwapInt()(C++源码)
-> Atomic::cmpxchg(C++源码) -> LOCK_IF_MP(%4) cmpxchg(汇编指令)
CAS的底层研究下去就是一条汇编指令cmpxchg指令,这条指令不是原子的(存在比较值确实是没被修改,将要写回去的时候指令被其他cpu打断了的可能性,等你恢复过来的时候num值已经又被修改了,但是你前面已经比较过了,不会再比较,而是直接修改,这样就出问题了!),所以前面给加上了lock cmpxchg实现原子操作、如果被打断了失败了就什么都不做,退回之前的状态继续while重复操作。

单处理器平台cmpxchg指令不需要加lock:
因为指令执行中不可能被插入,即使单线程并发调度执行中断也是在完整指令之间执行切换中才发生,所以原子操作没必要。如decl指令,三个过程:读->改->写涉及2次内存操作,都是完成之后才会切换执行其他指令,不可能执行到一半自己退出执行其他指令
多处理器平台cmpxchg指令需要加lock:
体系中运行着多个独立的cpu,即使是可以在某个指令中完成的操作也可能会被干扰。典型的例子就是decl指令,它细分为三个过程:读->改->写涉及2次内存操作。如果多个cpu运行的多个进程在同时对同一块内存执行这个指令,情况是无法预测的
例子:
i++操作,因此单线程执行绝对不会出现安全问题,多线程就会了!
原子性操作:
所谓原子操作是指不会被线程调度机制打断的操作。整个操作,要么全部完成,要么全部不完成,不可能停滞在中间某个环节

先行发生原则

如果Java内存模型中所有的有序性都仅靠volatile和synchronized来完成,那么有很多操作都将会变 得非常啰嗦,但是我们在编写Java并发代码的时候并没有察觉到这一点,**这是因为Java语言中有一 个“先行发生”(Happens-Before)的原则。**这个原则非常重要,它是判断数据是否存在竞争,线程是否安全的非常有用的手段。依赖这个原则,我们可以通过几条简单规则一揽子解决并发环境下两个操 作之间是否可能存在冲突的所有问题,而不需要陷入Java内存模型苦涩难懂的定义之中。
为什么会有happens-before 规则?
因为jvm会对代码进行编译优化,指令会出现重排序的情况,为了避免编译优化对并发编程安全性的影响,需要happens-before规则定义一些禁止编译优化的场景,保证并发编程的正确性。

现在就来看看“先行发生”原则指的是什么。先行发生是Java内存模型中定义的两项操作之间的偏序关系,比如说操作A先行发生于操作B,其实就是说在发生操作B之前,操作A产生的影响能被操作B 观察到,“影响”包括修改了内存中共享变量的值、发送了消息、调用了方法等。这句话不难理解,但 它意味着什么呢?我们可以举个例子来说明一下。如代码清单12-8所示的这三条伪代码。
代码清单12-8 先行发生原则示例1

// 以下操作在线程A中执行
i = 1;
// 以下操作在线程B中执行
j = i;
// 以下操作在线程C中执行
i = 2;

假设线程A中的操作“i=1”先行发生于线程B的操作“j=i”是确定的,那我们就可以确定在线程B的操作执行 后,变量j的值一定是等于1,得出这个结论的依据有两个:一是根据先行发生原则,“i=1”的结果可以 被观察到;二是线程C还没登场,线程A操作结束之后没有其他线程会修改变量i的值。
现在再来考虑 线程C,我们依然保持线程A和B之间的先行发生关系,而C出现在线程A和B的操作之间,但是C与B没有先行发生关系,那j的值会是多少呢?答案是不确定!1和2都有可能,**因为线程C对变量i的影响可能 会被线程B观察到,也可能不会,**这时候线程B就存在读取到过期数据的风险,不具备多线程安全性。
下面是Java内存模型下一些“天然的”先行发生关系,**这些先行发生关系无须任何同步器协助就已经存在、不存在被指令重排序、可以在编码中直接使用的。**如果两个操作之间的关系不在此列,并且无法从下列规则推导出 来,则它们就没有顺序性保障,虚拟机可以对它们随意地进行重排序,这个时候你想保证线程安全才需要依靠volatile或synchronized来做。

程序次序规则(Program Order Rule):在一个线程内,按照控制流顺序,书写在前面的操作先行
发生于书写在后面的操作。注意,这里说的是控制流顺序而不是程序代码顺序,因为要考虑分支、循
环等结构。
·管程锁定规则(Monitor Lock Rule):一个unlock操作先行发生于后面对同一个锁的lock操作。
这里必须强调的是“同一个锁”,而“后面”是指时间上的先后。(举个例子,
你的代码出现了xx.unlock()和xx.lock(),这种先行关系是确定了的,unlock一定在lock前,不存在
执行lock的线程先于unlock执行。)
    
·volatile变量规则(Volatile Variable Rule):对一个volatile变量的写操作(写类似释放锁的内存语义)
 先行发生于后面对这个变量的读操作,这里的“后面”同样是指时间上的先后。
·线程启动规则(Thread Start Rule):Thread对象的start()方法先行发生于此线程的每一个动作。
·线程终止规则(Thread Termination Rule):线程中的所有操作都先行发生于对此线程的终止检
测,我们可以通过Thread::join()方法是否结束、Thread::isAlive()的返回值等手段检测线程是否已经终止
执行。
·线程中断规则(Thread Interruption Rule):对线程interrupt()方法的调用先行发生于被中断线程
的代码检测到中断事件的发生,可以通过Thread::interrupted()方法检测到是否有中断发生。
·对象终结规则(Finalizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的
finalize()方法的开始。
·传递性(Transitivity):如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出
操作A先行发生于操作C的结论。

Java语言无须任何同步手段保障就能成立的先行发生规则有且只有上面这些,下面演示一下如何 使用这些规则去判定操作间是否具备顺序性,对于读写共享变量的操作来说,就是线程是否安全。读 者还可以从下面这个例子中感受一下“时间上的先后顺序”与“先行发生”之间有什么不同。演示例子如 代码清单12-9所示。
代码清单12-9 先行发生原则示例2

private int value = 0;
pubilc void setValue(int value){
this.value = value;
}
public int getValue(){
return value;
}

代码清单12-9中显示的是一组再普通不过的getter/setter方法,假设存在线程A和B,线程A先(时间上的先后)调用了setValue(1),然后线程B调用了同一个对象的getValue(),那么线程B收到的返回值是什么?
我们依次分析一下先行发生原则中的各项规则。
1、由于两个方法分别由线程A和B调用,不在一个线程中,所以程序次序规则在这里不适用;
2、由于没有同步块,自然就不会发生lock和unlock操作,所以管 程锁定规则不适用;
3、由于value变量没有被volatile关键字修饰,所以volatile变量规则不适用;
4、后面的线 程启动、终止、中断规则和对象终结规则也和这里完全没有关系。因为没有一个适用的先行发生规 则,所以最后一条传递性也无从谈起,因此我们可以判定,尽管线程A在操作时间上先于线程B,但是无法确定线程B中getValue()方法的返回结果(无法确定A的操作一定先行与B执行),换句话说,这里面的操作不是线程安全的。
那怎么修复这个问题呢?我们至少有两种比较简单的方案可以选择:要么把getter/setter方法都定 义为synchronized方法,这样就可以套用管程锁定规则;要么把value定义为volatile变量,由于setter方 法对value的修改不依赖value的原值,满足volatile关键字使用场景,这样就可以套用volatile变量规则来 实现先行发生关系
通过上面的例子,我们可以得出结论:一个操作“时间上的先发生”不代表这个操作会是“先行发 生”。那如果一个操作“先行发生”,是否就能推导出这个操作必定是“时间上的先发生”呢?很遗憾,这 个推论也是不成立的。一个典型的例子就是多次提到的“指令重排序”。时间先后顺序与先行发生原则之间基本没有因果关系, 所以我们衡量并发安全问题的时候不要受时间顺序的干扰,一切必须以先行发生原则为准。

锁的内存语义

Java中的锁包括synchronized锁和lock锁。他们都能保证锁的内存语义正确的实现,但是他们的底层原理却是不一样的,Lock锁的底层是使用volatile和CAS的内存语义来实现锁的内存语义的,而synchronized用的锁是存在java对象头里的,是基于JVM的支持来实现的。
语义:
当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中。锁释放与volatile写具有相同的内存语义。
当线程获取锁时,JMM 会把该线程对应的本地内存置为无效。从而使得被监视器保护的临界区代码必须要从主内存中去读取共享变量。锁获取与volatile读具有相同的内存语义
锁释放和锁获取的内存语义总结:
线程A释放一个锁,实质上是线程A向接下来将要获取这个锁的某个线程发出了(线程A对共享变量所做修改的)消息。
线程B获取一个锁,实质上是线程B接收了之前某个线程发出的(在释放这个锁之前对共享变量所做修改的)消息。
线程A释放锁,随后线程B获取这个锁,这个过程实质上是线程A通过主内存向线程B发送消息。

Lock锁内存语义的实现
以ReentrantLock为例,在ReentrantLock中,调用lock()方法获取锁;调用unlock()方法释放锁。
ReentrantLock的实现依赖于Java同步器框架AbstractQueuedSynchronizer(本文简称之为AQS)。AQS使用一个整型的volatile变量(命名为state)来维护同步状态,这个volatile变量是ReentrantLock内存语义实现的关键。
ReentrantLock分为公平锁和非公平锁.我们首先分析公平锁
使用公平锁时,加锁方法lock()调用轨迹如下:

ReentrantLock:lock()
FairSync:lock()
AbstractQueuedSynchronizer:acquire(int arg)
FairSync:tryAcquire(int acquires)

在第4步真正开始加锁,下面是该方法的部分源代码:

protected final boolean tryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    // 获取锁的开始,首先读volatile变量state
    int c = getState();
     //..............

从上面源代码中我们可以看出,加锁方法首先读volatile变量state。(比如读到这个变量的值不是2,就可以判断到不是加锁状态,然后对对它加锁把值改为2,其他线程读到2就知道被加锁了)
在使用公平锁时,解锁方法unlock()调用轨迹如下:

ReentrantLock:unlock()
AbstractQueuedSynchronizer:release(intarg)
Sync:tryRelease(int releases)

在第3步真正开始释放锁,下面是该方法的部分源代码:

protected final boolean tryRelease(int releases) {
    int c = getState() - releases;
    if (Thread.currentThread() != getExclusiveOwnerThread())
        throw new IllegalMonitorStateException();
    boolean free = false;
    if (c == 0) {
        free = true;
        setExclusiveOwnerThread(null);
    }
    // 释放锁的最后,写volatile变量state
    setState(c);     
    return free;
}

从上面的源代码可以看出,在释放锁的最后写volatile变量state。(可以理解为是把变量改为不是2的状态,好让其他线程知道这个变量无锁了)
非公平锁就不演示了,总结:
● 公平锁和非公平锁释放时,最后都要写一个volatile变量state。
● 公平锁获取锁是,首先会去读volatile变量。
● 非公平锁获取锁时,首先会用CAS更新volatile变量,这个操作同时具有volatile读和写的内存语义

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值