并发编程有三大特性,原子性、有序性、可见性。我们先通过volatile 了解下,为什么volatile 能保证并发编程的有序性和可见性 而不能保证原子性。
先介绍下什么是 可见性、原子性、有序性
- 可见性
- 可见性就是,一个线程修改了一个变量的值,另外一个线程立刻可以感知到。是由CPU缓存导致的可见性问题
- 并发编程为什么会有可见性的问题?
- 因为CPU是有自己的缓存的,CPU执行计算时,会把变量从内存加载到CPU缓存计算,之后再对这个变量计算就不会再从缓存加载了。
- 这么设计的原因是,CPU从内存加载数据和CPU本身的计算相比,太慢了。加载数据的过程可能是计算本身的几百倍,这就导致CPU大部分时间都在等待,CPU利用率极低。所以就硬件工程师弄出了CPU缓存。
- 现在的计算机大多都是多核的,比较常见的就是4核8G。如果一个计算机有多个CPU,那就意味着每个CPU都有自己的缓存。如果是多线程执行,那可能是多个CPU分别执行不同的线程。
- 如果是多个线程读写同一个共享变量。比如线程A去修改 i 的值,i++。线程B去读取 i 的值。
- i++ 需要执行3条CPU指令,线程A的执行步骤是这样的
- 加载 i 的值到CPU缓存
- 计算 i 的值
- 把计算好的值写回缓存
- 这样,线程B 感知不到线程A修改了 i ,因为线程B是从内存里读取的 i,线程A并没有把修改后的值写回内存。
- 所以,这就是CPU缓存导致的可见性问题。
- i++ 需要执行3条CPU指令,线程A的执行步骤是这样的
- 因为CPU是有自己的缓存的,CPU执行计算时,会把变量从内存加载到CPU缓存计算,之后再对这个变量计算就不会再从缓存加载了。
- 那 volatile 为什么可以保证多线程的可见性呢
- 基于lock前缀指令和MESI缓存一致性协议来保证可见性的
- 对volatile修饰的变量,执行写操作的话,JVM会发送一个lock前缀指令给CPU,CPU在计算完后会立刻将这个值写回内存。同时因为有 MESI缓存一致性协议,各个CPU会对总线进行嗅探,自己本地缓存中的数据是否被别人修改,如果被修改,缓存失效,重新从内存读取
- 所以,通过lock前缀指令和MESI缓存一致性协议,就可以保证线程间的可见性
- 那 volatile 为什么可以保证多线程的可见性呢
- 基于lock前缀指令和MESI缓存一致性协议来保证可见性的
- 对volatile修饰的变量,执行写操作的话,JVM会发送一个lock前缀指令给CPU,CPU在计算完后会立刻将这个值写回内存。同时因为有MESI缓存一致性协议,各个CPU会对总线进行嗅探,自己本地缓存中的数据是否被别人修改,如果被修改,缓存失效,重新从内存读取
- 所以,通过lock前缀指令和MESI缓存一致性协议,就可以保证线程间的可见性
- 原子性
- 一个操作或多次操作,要么全部执行,要么都不执行,称为原子性。原子性是由多线程并发,线程切换导致的
我们高级语言里的一个操作,可能涉及到多条CPU指令,比如i++,就要三条指令- 加载 i 的值到CPU缓存
- 计算 i 的值
- 把计算好的值写回缓存或内存
- 一个操作或多次操作,要么全部执行,要么都不执行,称为原子性。原子性是由多线程并发,线程切换导致的
- volatile为什么不能保证原子性,两个线程同时 写 共享变量 会出现问题
- 也就是说,volatile可以允许一个线程修改共享变量,一个线程读取共享变量。不能保证两个线程同时修改共享变量。
- 比如一个变量temp,存在方法区,线程1,线程2,同时对temp++;
- 解释下为啥不能保证原子性:(总结就是,在写内存那步发生线程切换)
1)线程1去方法区读取temp,存到工作内存,temp=10;
2)线程2去方法区读取temp,存到工作内存,temp=10;
3)线程1,执行temp++;此时线程1的工作内存,temp=11;
4)线程1此时想要把temp写回到主存,但是发生了线程切换;
5)线程2,把工作内存的temp++,temp=11,然后把temp写到主存。此时所有线程的temp缓存失效;
6)线程1的temp缓存失效,但是! 线程1已经计算完temp的值了,根不就不需要缓存的temp,之前已经计算完temp=11,就差一步,就是把temp=11写到主存;
7)所以,线程1,又把tmep=11写到了主存,这就不对了,如果是正常情况下,temp应该等于12;
8)所以 volatile是不能保证原子性的,也就是不能支持 两个线程并发的修改同一条数据。如果想要保证就要用锁了;
- 有序性
- 编译优化带来的有序性问题
- 有序性就是,正常编译器执行我们的代码,是按顺序执行的,不过有时候,在代码顺序对程序的结果没有影响时,编译器可能会为了性能,改变代码的顺序。
- 如果是单个线程,即使编译器优化指令顺序,对结果没有影响的话,也是无所谓的,但是并发编程就不行了。
- 并发编程为什么会有有序性的问题
- 最经典的并发编程的有序性问题就是 单例模式的 volatile + double check。
- 顺便解释下,为什么double check 不行,一定要加volatile,代码如下
public class Singleton {
private static Singleton instance;
public static Singleton getInstance(){
if (instance == null) {
synchronized(Singleton.class) {
if (instance == null)
instance = new Singleton();
}
}
return instance;
}
}
-
假设线程 A 先调用 getInstance() 方法,instance == null 判断成立,所以进入后 对 Singleton 加锁,此时第二个instance == null 还是成立的,所以再执行 instance = new Singleton();
- 问题出现在 new 操作上,正常来说 new 操作的指令是三条
- 分配一块内存 M;
- 在内存 M 上初始化 Singleton 对象;
- 然后 M 的地址赋值给 instance 变量。
- 但是经过编译器优化后的指令是这样的
- 分配一块内存 M;
- 将 M 的地址赋值给 instance 变量;
- 最后在内存 M 上初始化 Singleton 对象。
- 假设线程 A 先执行 getInstance() 方法,当执行完指令 2 时恰好发生了线程切换,切换到了线程 B 上
- 如果此时线程 B 也执行 getInstance() 方法,那么线程 B 在执行第一个判断时会发现 instance != null ,所以直接返回 instance,而此时的 instance 是没有初始化过的,如果我们这个时候访问 instance 的成员变量就可能触发空指针异常
- 问题出现在 new 操作上,正常来说 new 操作的指令是三条
-
那volatile如何解决double check问题呢
- 使用volatile可以禁止指令重排,这个例子我们就是禁止了instance变量的指令重排,那如果在第二个指令执行完发生切换,此时没有给instance赋值地址,所以instance还是null的。线程B会进入第一个判断里面,等待线程A释放锁,等线程A把第三个指令执行完,释放了锁,线程B才会执行,就没有问题了。
-
总结,然后说下 volatile 底层原理
- 是基于happens-before规则 ,来保证有序性的
- volatile 变量规则:对volatile修饰的变量的写操作必须在读操作之前
- 内存屏障:就是在volatile指令的前后加一些屏障,禁止指令重排,内存屏障分几种。load (加载)、store(存储)这个简单了解,说一下就行
- LoadLoad屏障:Load1;LoadLoad;Load2,确保Load1数据的装载先于Load2后所有装载指令,他的意思,Load1对应的代码和Load2对应的代码,是不能指令重排的
- StoreStore屏障:Store1;StoreStore;Store2,确保Store1的数据一定刷回主存,对其他cpu可见,先于Store2以及后续指令
- LoadStore屏障:Load1;LoadStore;Store2,确保Load1指令的数据装载,先于Store2以及后续指令
- StoreLoad屏障:Store1;StoreLoad;Load2,确保Store1指令的数据一定刷回主存,对其他cpu可见,先于Load2以及后续指令的数据装载
-
基于lock前缀指令和MESI缓存一致性协议来保证可见性的
- 所以,通过lock前缀指令和MESI缓存一致性协议,就可以保证线程间的可见性
- 不用深究这两个东西,没什么意义
- 只要知道 lock前缀指令 是一个CPU指令就好
- MESI 缓存一致性协议,就是个协议。就是对共享变量嗅探,如果被修改就失效。这个也不用深究
- 对volatile修饰的变量,执行写操作的话,JVM会发送一个lock前缀指令给CPU,CPU在计算完后会立刻将这个值写会内存。同时因为有 MESI缓存一致性协议,各个CPU会对总线进行嗅探,自己本地缓存中的数据是否被别人修改
- 所以,通过lock前缀指令和MESI缓存一致性协议,就可以保证线程间的可见性
如果多线程编程想要保证原子性,那就必须使用锁了,让所有线程串行化去修改共享变量
- Java 中有两种方式可以加锁
- 一种是使用Java 内置的关键字Synchronzied
- 一种是实现了Lock接口的实现类
- 先介绍 Synchronzied
-
Sychronzied 的使用:
- Synchronzied 平时使用的时候,有4种方式
1)Synchronzied 方法- 如果加的普通的方法上,加锁的目标是对象
- 如果加到static方法上,加锁的目标是方法区的class类
2)Synchronzied 代码块
- 加锁的目标是对象
3)Synchronzied(this)
- 加锁的目标是对象
4)Synchronzied(类名.class)
- 加锁的目标是方法区的class类
- Synchronzied 平时使用的时候,有4种方式
-
- 自动加锁释放锁:
- Synchronzied 会在Synchronzied方法,或者Synchronzied代码块里的自动加锁和释放锁
- 两个JVM指令,monitorenter 、monitorexit
- 如果我们的方法加了Synchronzied,那编译后的指令是这样的
- 线程如果遇到了monitorenter ,必须去对应的对象上加锁,才能执行下面的代码
- 遇到了monitorexit 会释放锁
- 具体如何加锁的,下面解释
monitorenter // 加锁
// 对应方法的指令
monitorexit // 释放锁
- Synchronzied 的底层原理
- 实际上,Synchronzied底层是通过Monitor 来加锁的
- 这个要从JVM的内存模型说起,因为Synchronzied 加锁可能在堆中,也可能在方法区中,我先画个图,这是JVM的内存模型
- 然后,如果我们使用Synchronzied 给对象加锁的话,那就得先介绍下JVM 堆内存里对象的结构
- 堆中的一个对象(图1),主要分三部分:
- 1)对象头
- Mark Word(图2)
- 锁的标志位(分几种锁,这个后面单独说)
- 指向monitor的指针(重量级锁的指针)
- 线程通过这个指针的地址,去对象对应的monitor中加锁
- 对象的分代年龄(垃圾回收)
- 对象的hashcode
- 等等
- Class Metedata Address
- 这个就是指向永久代的,这个对象对应的class文件
- Mark Word(图2)
- 2)实例变量
- 存放类的属性数据信息,包括父类的属性信息
- 3)填充数据
- 由于虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐,这点了解即可。
- 由于虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐,这点了解即可。
- 1)对象头
- 堆中的一个对象(图1),主要分三部分:
- 然后说一下 Monitor
- Monitor 也叫管程,不止Java有,是实现锁的一种方式。
- Java 中每个对象都有自己对应的 Monitor
- Monitor 的创建
- 当线程想要对这个对象加锁时,自动创建一个与这个对象对应的 Monitor
- 如果是多线程,都想修改这个变量,想要给这个对象加锁,那首先要通过对象头的Mark Word中的 Monitor 指针,找到这个对象对应的 Monitor
- Monitor的底层结构
- JVM 的 Monitor 是由ObjectMonitor实现的
- 位于HotSpot虚拟机源码ObjectMonitor.hpp文件,C++实现的
ObjectMonitor() {
_header = NULL;
_count = 0; //记录个数
_waiters = 0,
_recursions = 0;
_object = NULL;
_owner = NULL; // 当前加锁线程
_WaitSet = NULL; //处于wait状态的线程,会被加入到_WaitSet
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
_EntryList = NULL ; //处于等待锁block状态的线程,会被加入到该列表
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}
-
下面是 ObjectMonitor 的底层结构,如图(1)
- 主要字段
- count
- 有两个值
- 0:当前对象无人加锁
- 1:当前对象已经加锁
- 有两个值
- owner
- 当前加锁线程
- entryList
- 想加锁,但是没加上,阻塞等待加锁的线程
- waitSet
- 如果获取到锁的线程,调用了 wait() 方法,当前线程会进入这个列表
- 如果获取到锁的线程,调用了 wait() 方法,当前线程会进入这个列表
- count
- 主要字段
-
然后,说一下三个线程(线程A、线程B、线程C)给对象加锁的流程
- 线程A、线程B 通过对象的 Monitor 指针找到了这个对象的 Monitor ,进入了entryList
- entryList 中的线程,通过CAS的方式,都去尝试把count 的值从0改为1
- 假设线程A 成功了
- 那 owner 的值,从null 变成 线程A,线程B就阻塞了
- 然后此时,线程C 想要对对象加锁,但是发现count = 1,不能修改,进入entryList中阻塞
- 此时,线程A 调用了 wait() 方法,线程A 释放锁,进入 waitSet列表等待别的线程调用notify() 。此时 count = 0,owner = null
- 线程A释放了锁,在entryList中的线程B 和 线程C,会继续以CAS的方式,尝试修改count 的值,假设线程B成功了
- 那线程C继续阻塞
- 如果线程A 被唤醒,那线程A 会等待线程B释放锁后,和线程C一起对count尝试CAS操作,获取锁
-
然后说一下JDK1.6 开始对 Synchronzied 锁的优化和锁的升级
- 偏向锁、轻量级锁、重量级锁、自旋锁。这些不是单独的锁,这些都是Synchronzied的锁的实现。Synchrozied会根据不同的场景选择不同的锁,我们只使用Synchronzied,不用关心它具体使用的哪个锁。
- 偏向锁
- 这个意思就是说,monitorenter和monitorexit是要使用CAS操作加锁和释放锁的,开销较大。如果大多数情况下,都只有一个线程来竞争锁,那这个线程获取到锁后,就会进入偏向模式,Mark Word标记为偏向锁,下次这个线程再来加锁,无需做任何同步操作。
- 轻量级锁
- 如果偏向锁失败了,JVM 会尝试升级为轻量级锁。轻量级锁适用于,两个或者几个线程,交替执行,没有冲突。一旦有冲突就会升级重量级锁。
- 重量级锁
- 就是上面说的那种
- 自旋锁
- 适用于线程竞争特别激烈,并且每个线程执行的时间很短的情况。如果每个线程获取到锁,执行1ms,就释放锁。那100个线程这么来回加锁、释放锁。线程上下文切换的成本太高了。所以,自旋锁是这样的,如果一个线程获取不到锁,就空循环几次,不会发生上下文切换,等待机会获取到锁就继续执行。
- 锁消除
- 这个是对Sychronzied的一个优化,如果一段代码,连续出现几个Synchronzied代码块,编译器会尝试合并为一个。避免频繁的加锁、释放锁。
- 偏向锁
- 偏向锁、轻量级锁、重量级锁、自旋锁。这些不是单独的锁,这些都是Synchronzied的锁的实现。Synchrozied会根据不同的场景选择不同的锁,我们只使用Synchronzied,不用关心它具体使用的哪个锁。
-
再说下Synchronzied 的几个特性
- 可重入性
- Synchronzied是可重入锁,就是说,一个线程获取到锁后,还可以继续对这个对象加锁,加多层锁。
- 线程中断问题
- 当线程处于阻塞状态时,我们可以通过interrupt() 方法来中断该线程,然后在try catch中会抛出InterruptedException,抛出后线程会复位(变成非中断状态)
- 当线程处于非阻塞状态时,interrupt() 方法无法让线程停止。必须在线程的run() 方法里加个判断 if (isInterrupted()){ 结束线程}
- 使用Synchronzied 加锁,interrupt() 方法无法中断一个正在等待锁的线程。
- wait、notify、notifyAll
- 这几个方法都是Object内的方法,必须在Synchronzied内使用
- 因为这几个方法都依赖于Monitor实现,如果不在Synchronzied内使用,可能这个对象根本就没对应的Monitor对象,会抛异常。
- 调用wait方法,加锁线程会进入 waitSet列表等待,等待其它线程调用notify() 或者notifyAll()
- wait方法会释放锁,上面Synchronzied原理那已经说了
- Sleep
- sleep不会释放锁,只是休眠。这个类是Thread里的,不是Objcet方法。可以在任何地方使用。
- 可重入性
然后说一下实现了Lock 接口的ReentrantLock
-
ReentrantLock
- 使用方式和Synchronzied 不同
- 需要创建个lock对象, Lock lock = new ReentrantLock() ,然后通过 lock对象加锁
- 显式加锁
- 加锁
- lock.lock();
- 释放锁
- lock.unlock();
- 必须在finally中调用,即使抛异常也会释放锁
- 加锁
- 也是可重入锁
- 支持对一个对象重复加锁
- 如果加两次,那释放锁也要两次。分析底层原理的时候细说
- 高级功能
- 可指定公平锁
- 构造函数传true,则是公平锁,默认非公平锁
- 分析底层原理的时候细说
- 构造函数传true,则是公平锁,默认非公平锁
- 在指定的时间内尝试获取锁
- lock.tryLock(long time, TimeUnit unit)
- 获取到了返回true,获取不到返回false
- 不会像Synchronzied一直在那阻塞
- lock.tryLock(long time, TimeUnit unit)
- 可指定公平锁
- 可中断正在等待锁的线程
- 如果想要中断正在等待锁的线程,那加锁的时候不要使用lock.lock()方法
- lock() 方法,优先考虑获取锁,获取锁后才考虑响应中断
- 应该使用 lock.lockInterruptibly() 方法来加锁
- lockInterruptibly() ,优先考虑响应中断,可在等待锁的时候中断
- 如果想要中断正在等待锁的线程,那加锁的时候不要使用lock.lock()方法
- 使用方式和Synchronzied 不同
-
ReentrantLock 的底层原理
- 是基于CAS + AQS 实现的
- CAS : CompareAndSet
- AQS:AbstractQueuedSynchronizer 抽象队列同步器
- 是基于CAS + AQS 实现的
-
AQS 内部有三个核心组件,如图
- 1)state
- 由volatile 修饰,标志着线程是否可以获取锁
- state = 0,没有线程获取锁
- state = 1,已有线程获取锁
- 由volatile 修饰,标志着线程是否可以获取锁
- 2)AQS 内部队列
- 没有获取到锁的线程,进入该队列
- 该队列底层结构是双向链表,双向链表可以当做队列来用,比如LinkedList
- 队尾进,队头出
- 没获取到锁的线程,进入队尾,tail指针指向队尾
- 只有head指向的节点,才可以获取锁
- 每个节点由Node组成
- 主要的字段
- prev :前节点
- next:后节点
- thread:当前线程
- 还有一些线程状态的字段
- 主要的字段
- 队尾进,队头出
- 该队列底层结构是双向链表,双向链表可以当做队列来用,比如LinkedList
- 没有获取到锁的线程,进入该队列
- 3)当前加锁线程
- 记录当前获取到锁的线程
- 记录当前获取到锁的线程
- 1)state
-
举例,三个线程获取锁的情况
- 线程A、线程B、线程C 同时以CAS 的方式尝试获取锁
- 调用lock方法,compareAndSetState(0, 1) // 只有state是0的时候才可以把它改成1
- 只有一个线程可以把state的值从 0 改成 1,其他线程会失败
- 以线程A获取到锁的时间节点的图
- 以线程A获取到锁的时间节点的图
- 线程A、线程B、线程C 同时以CAS 的方式尝试获取锁
-
线程B、线程C加锁失败,进入等待队列
- 假设线程B 、线程C 同时进入队列
- 因为队列是个双向链表,队尾进入,所以不可能同时进入队尾
- 所以,线程B 和 线程C 也以CAS的方式,一个一个进入队尾,如图
-
因为ReentrantLock 是可重入锁,所以如果此时线程A继续来加锁的情况
- 首先,线程A 尝试的 compareAndSetState(0, 1) 会失败
- 但是,会走到 else 方法里,继续判断这个线程 是否是 当前加锁线程
- 这个判断会成功,因为 线程A = 线程A
- 之后会把 state ++,也就是state = 2
- 释放的时候,也要释放两次,因为只有state = 0 ,别的线程才有可能加锁成功
-
然后,ReentranLock 有公平锁 和 非公平锁
- 公平锁
- 判断head节点指向的是否为null,如果不为null,说明队列里有线程在等待。
- 此时会唤醒head指向的线程,随后线程进入for循环,以CAS的方式抢占锁,修改state的值,如果抢到了锁,才把头结点移除
- 此时没有进入队列的线程,如果想获取锁,首先要判断队列的head是否指向null,只有指向null,才能获取锁。也就是说,只有队列里没有排队线程的时候,其他线程才能获取锁;
- 公平锁
-
非公平锁
- 没进入队列的线程(线程D),不管什么情况都可以尝试获取锁。
- 这个就不画图了,没啥可画的
-
Condition
- 配合Lock 接口的锁使用,代替 wait 和 notify、notifyAll
- 使用方式:
- 依赖于lock对象
- 一个锁,可以有多个Condition
// 实例化一个ReentrantLock对象
private ReentrantLock lock = new ReentrantLock();
// 为线程A注册一个Condition
public Condition conditionA = lock.newCondition();
// 为线程B注册一个Condition
public Condition conditionB = lock.newCondition();
- condition 底层也是一个队列,主要的方法有await()、signal()、signalAll()
- 我们在调用await()方法时,其实就是把线程塞到队列里阻塞
- 调用singal(),线程被唤醒,继续以CAS的方式尝试获取锁
- Condition 相对于 Synchronzied的wait和notify的优点
- 其实就是可以创建多个等待队列了,用Synchronzied的方法加锁,调用wait方法后,所有线程都被放到了加锁对象的monitor的waitSet里了,只有一个waitSet,使用Condition可以创建多个
- 那如果是多个condition,哪个线程放到哪个condition,怎么放?
- 首先,condition必须用在lock 和 unlock 之间
- 也就是说,必须有一个线程获取到锁了,才能使用
- 以生产者消费者举例
- 生产者两个线程,消费者两个线程
- 那生产者肯定只能执行生产者的代码,消费者执行消费者的代码
- 生产者的线程A,获取到锁后,调用condition1.await(),那线程A就进入这个condition1的等待队列里
- 消费者的线程B,执行消费者代码,获取到锁后,调用condition2.await(),那线程B就进入condition2的等待队列
- 消费者如果想唤醒生产者的队列,只要调用condition1.signal() 就好了
- ReentrantLock 与 synchronized 的比较
- 相同点
- 都是独占锁
- 都是可重入锁
- 不同点
- Synchronzied 的优点
- Synchronzied 是Java内置关键字,ReentrantLock是API;
- 管理锁定请求和释放时,JVM 在生成线程转储时能够包括锁定信息。这些对调试非常有价值,因为它们能标识死锁或者其他异常行为的来源。 Lock 类只是普通的类,JVM 不知道具体哪个线程拥有 Lock 对象
- Synchronzied 加锁,JVM会有锁升级的过程,不同场景用不同的锁,而且重量级锁也是基于CAS实现的,也有一个entryList和 waitSet,性能并不比ReentrantLock差;
- 自动加锁释放锁,ReentrantLock 不行
- ReentrantLock 的优点
- ReentrantLock 可以中断等待获取锁的线程,Synchronzied不行;
- ReentrantLock 加锁更灵活,有tryLock()
- ReentrantLock 有Condition,对线程的等待和唤醒更灵活
- ReentrantLock 支持公平锁
- Synchronzied 的优点
- 相同点
- 怎么选择
- 除非Synchronzied遇到性能瓶颈,或者需要使用ReentrantLock的高级功能,否则使用Synchronzied;
- 开源项目里很少见到ReentrantLock,读写锁ReentrantReadWriteLock 在开源项目里较多。如果只是普通的加锁,大部分还是 Synchronzied,说明性能并不差;