1 Java内存模型
1 简介
- Java内存模型(Java Memory Model, JMM)概述
- 内存模型可以理解为在特定的操作协议下,对特定的内存或高速缓存进行读写访问的过程抽象
不同架构的物理机器可以拥有不一样的内存模型,JVM也有自己的内存模型
- 主流程序语言(如C、C++等)直接使用物理硬件和操作系统的内存模型。因此,由于不同平台上内存模型的差异,有可能导致程序在一套平台上并发完全正常,而在另一套平台上并发访问却经常出错,所以在某些场景下必须针对不同的平台来编写程序
- Java内存模型用于屏蔽各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果
- Java内存模型的主要目的
定义程序中各种变量的访问规则,即关注在虚拟机中把变量值存储到内存和从内存中取出变量值这样的底层细节
- 此处说的变量(下同)包括了实例字段、静态字段和构成数组对象的元素,但是不包括局部变量与方法参数,因为后者是线程私有的,不会被共享
- 如果局部变量是一个引用类型,它引用的对象在Java堆中可被各个线程共享,但是引用本身是在Java栈的局部变量表中,是线程私有的
2 主内存与工作内存
- Java内存模型示意图
主内存和工作内存解析
- Java内存模型规定所有的变量都存储在主内存中
- 每条线程拥有自己的工作内存,线程的工作内存中保存了被该线程使用的变量的主内存副本
- 线程对变量的所有操作(读写)都必须在工作内存中进行,而不能直接读写主内存中的数据
- 不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成
Java内存模型与Java内存区域
Java内存模型的主内存、工作内存与Java内存区域的堆、栈、方法区等不是同一个层次的对内存的划分,两者没有任何关系
- Java内存模型与硬件
内存模型中的主内存直接对应于物理硬件的内存,而为了获得更好的运行速度,JVM可能会让工作内存优先存储于寄存器和高速缓存中,因为程序运行时主要访问的是工作内存
3 主内存与工作内存间的交互操作
交互操作概述
- 关于主内存与工作内存之间具体的交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存这一类的实现细节,Java内存模型定义了8种操作来完成
- JVM实现交互操作时保证所有操作都是原子的、不可再分的(对于double和long两个64位的类型,有些操作可能不是原子性的)
8种交互操作
- lock
- unlock
- read
- load
- use
- assign
- store
- write
4 原子性、可见性和有序性
- Java内存模型是围绕着在并发过程中如何处理原子性、可见性和有序性这三个特征来建立的
1 原子性
原子性概述
- 原子性是指操作是不可分的,其表现在于对于变量的某些操作,应该是不可分的,必须连续完成
- Java内存模型保证的原子性变量操作包括read、load、assign、use、store、write
- 如果应用场景需要一个更大范围的原子性保证,Java内存模型还提供了lock和unlock来满足需求,尽管JVM未把lock和unlock操作开放给用户,但是却提供了两个更高层次的字节码指令monitorenter和monitorexit来隐式地使用这两个操作,这两个字节码反映到Java代码中就是synchronized关键字,即synchronized关键字可以确保原子性
- Java中的各种锁(synchronized关键字和JUC包下的锁)和原子类保证了原子性
原子性被破坏导致线程不安全的原因
- 当多个线程对共享资源读写操作时发生指令交错,就破坏原子性,导致线程不安全
- 实例
考虑以下场景,线程1和线程2分别对成员变量count增加5000和减少5000,如下
Thread thread1 = new Thread(() -> {
for (int i = 0; i < 5000; i++)
count++;
}, "t1");
Thread thread2 = new Thread(() -> {
for (int i = 0; i < 5000; i++)
count--;
}, "t2");
thread1.start();
thread2.start();
thread1.join();
thread2.join();
log.debug("{}", count);
上述代码的预期输出为0,但实际运行时的输出是随机的,因为Java中对变量的自增、自减虽然是一条指令,但并不是原子操作,具体可以从字节码来进行分析
- 对于i++而言(i 为静态变量),实际会产生如下的JVM字节码指令
getstatic i // 获取静态变量i的值
iconst_1 // 准备常量1
iadd // 自增
putstatic i // 将修改后的值存入静态变量i
- 对于i--而言,实际会产生如下字节码指令
getstatic i // 获取静态变量i的值
iconst_1 // 准备常量1
isub // 自减
putstatic i // 将修改后的值存入静态变量i
多线程下同时执行自增自减,8行字节码可能交错运行导致结果不确定,如下述出现-1的情况
- 注意字节码并不保证原子性,但是可以从字节码的角度来分析
2 可见性
可见性概述
- 可见性就是指当一个线程修改了共享变量的值时,其他线程能够立即得知这个修改
- Java有三种方式实现可见性
- volatile
- synchronized
- final
volatile实现可见性
- Java内存模型通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的,无论是普通变量还是volatile变量都是如此
- 普通变量与volatile变量的区别是,volatile的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新,因此volatile保证了多线程操作时变量的可见性,而普通变量不能保证
synchronized实现可见性
synchronized的一条规则保证了可见性:对一个变量执行unlock操作之前,必须先把此变量同步回主内存中,即必须执行store、write操作
- final实现可见性
被final修饰的字段在构造器中一旦被初始化完成,并且构造器没有让this引用逃逸出去,那么在其他线程中就能看见final字段的值
3 有序性
有序性概述
- 如果在本线程内观察,那么所有的操作都是有序的;如果在一个线程中观察另一个线程,那么所有的操作都是无序的
- 前半句话是指“线程内似表现为串行的语义”;后半句是指“指令重排序”现象
- Java有两种方式实现有序性
- volatile
- synchronized
volatile实现有序性
volatile关键字本身就包含了禁止指令重排序的语义
- synchronized实现有序性
synchronized的一条规则保证了有序性:一个变量在同一个时刻只允许一条线程对其进行lock操作,这条规则决定了持有同一个锁的两个同步块只能串行地进入,即固定了同步块的执行顺序
2 线程安全
1 相关定义
- 线程安全定义
现有多个线程在同时运行,且这些线程可能会使用共享资源。如果多线程每次运行的结果和单线程运行的结果是一样的就是线程安全的。
临界区(Critical Section)
- 一段代码块内如果存在对共享资源的多线程读写操作,称这段代码块为临界区
- 临界区内一定有线程安全问题,要有一定措施加以保护
竞态条件(Race Condition)
多个线程在临界区内执行,由于代码的执行序列不同而导致结果无法预测,称之为发生了竞态条件
2 Java线程安全分析
- 根据线程安全的“安全程度”由强至弱排序,可以将Java中的数据分为以下几类
不可变类 > 相对线程安全 > 线程兼容 > 线程对立
1 不可变类
不可变类概述
- 不可变类是指其对象一旦构建后,对象的内部状态(成员变量)就不会改变
- Java中的不可变类包括String、枚举类型、java.lang.Number的部分子类(如Integer、Long等)
- 某些不可变类包含修改值的方法,如String类的substring(),但这些方法实际上并不会影响其原来的值,只会返回一个新构造的对象,即其成员变量本身并不会改变
不可变类的安全性
因为不可变类内部的成员变量永远不会变,因此不可变类是绝对线程安全的,即无论是类的方法实现还是方法的调用者。都不需要再进行任何线程安全保障
- 数值包装类型自增自减操作不是线程安全的
- 对于Integer类型来说,虽然它是不可变类,但是对它进行自增/自减操作时不是线程安全的
- 对Integer类型变量进行自增/自减操作时,实际上Integer要进行拆箱操作返回基本类型数据,基本类型数据执行完自增/自减后,再调用Integer的装箱操作重新创建对象,因此每次自增/自减结束后,Integer变量指向的对象都不是同一个
2 相对线程安全类
相对线程安全概述
- 相对线程安全就是我们通常意义上所讲的线程安全,Java中声称线程安全的类都属于相对线程安全
- 相对线程安全需要保证线程单次操作对象是线程安全的,我们在单次调用线程安全类的方法时不需要进行额外的保障措施
常见相对线程安全类
- StringBuffer
- Random
- Vector
- Hashtable(HashMap非线程安全 )
- java.util.concurrent(简称JUC)包下的类
组合调用线程安全类的方法
单次调用线程安全类的方法是安全的,但组合调用它们并不是线程安全的,实例如下
Hashtable table = new Hashtable();
new Thread(()->{
if(table.get("key") == null)
table.put("key", value);
}).start();
new Thread(()->{
if(table.get("key") == null)
table.put("key", value);
}).start();
执行过程:
3 线程兼容类
- 线程兼容类概述
- 线程兼容类是指类本身不是线程安全的,但可以通过在调用它们的时候正确地使用同步手段来保证线程安全
- Java中大部分类都是线程兼容的
4 线程对立
- 线程对立概述
- 线程对立是指不管调用端是否采取了同步措施,都无法在多线程的环境中并发使用
- Java中很少有线程对立的代码,一个线程对立的例子是Thread类的suspend()方法和resume()方法,无论在调用时是否进行了同步,在多线程环境下都可能造成死锁,因此这两个方法也被废弃了
5 局部变量安全分析
- 局部变量是线程安全的
局部变量引用的对象不一定线程安全
- 如果该对象没有逃离方法的作用范围,它是线程安全的
- 如果该对象逃离方法的作用范围(对象外泄),需要考虑线程安全
- 实例
考虑以下场景
public class Test {
public static void main(String[] args) throws InterruptedException {
Syn syn = new Syn();
new Thread(() -> {
syn.m1();
}, "t1").start();
new Thread(() -> {
syn.m1();
}, "t2").start();
}
}
class Syn {
public void m1() {
List<Integer> list = new ArrayList<>();
for (int i = 0; i < 500; i++) {
m2(list);
m3(list);
}
}
private void m2(List<Integer> list) {
list.add(1);
}
private void m3(List<Integer> list) {
list.remove(0);
}
}
- 上述代码中的的局部变量list是线程安全的
再考虑以下场景
public class Test {
public static void main(String[] args) throws InterruptedException {
Syn syn = new SynSub(); //创建Syn子类的实例
new Thread(() -> {
syn.m1();
}, "t1").start();
new Thread(() -> {
syn.m1();
}, "t2").start();
}
}
class Syn {
public void m1() {
List<Integer> list = new ArrayList<>();
for (int i = 0; i < 500; i++) {
m2(list);
m3(list);
}
}
private void m2(List<Integer> list) {
list.add(1);
}
public void m3(List<Integer> list) {
list.remove(0);
}
}
class SynSub extends Syn {
@Override
public void m3(List<Integer> list) {
new Thread(() -> {
list.remove(0);
}).start();
}
}
- 子类重写了父类的m3方法并在m3方法中开启了一个新的线程去修改list,list逃离了方法的作用范围
- 上述代码运行后会出现线程不安全问题,导致报错
从上述例子中可以看到private或final修饰符所提供的安全的意义
3 线程安全的实现方法
1 互斥同步
- 互斥同步(Mutual Exclusion & Synchronization)概述
- 互斥同步是一种最常见也是最主要的线程安全保障手段
- 同步是指在多个线程并发访问共享数据时,保证共享数据在同一个时刻只被一条线程使用。
互斥是实现同步的一种手段,临界区、信号量等都是常见的互斥实现方式
互斥是方法,同步是目的
互斥同步的局限性
- 互斥同步面临的主要问题是进行线程阻塞和唤醒所带来的性能开销,因此这种同步也被称为阻塞同步
- 从解决问题的方式上看,互斥同步属于一种悲观的并发策略,其总是认为只要不去做正确的同步措施(例如加锁),那就肯定会出现问题,即无论共享的数据是否真的会出现竞争,它都会进行加锁(实际上JVM锁优化将会优化掉很大一部分不必要的加锁),这将会导致用户态到核心态转换、维护锁计数器和检查是否有被阻塞的线程需要被唤醒等开销。
Java实现互斥同步的方法
- synchronized关键字
synchronized关键字是Java中最基本的互斥同步手段
- java.util.concurrent.locks.Lock接口
JDK5起,Java类库新提供了java.util.concurrent包(JUC包),其中的Lock接口是Java的另一种全新的互斥同步手段
2 非阻塞同步
非阻塞同步概述
- 我们已经了解到阻塞同步的局限性,非阻塞同步方法则致力于获得更好的性能
- 非阻塞同步是基于冲突检测的乐观并发策略,即不管风险,先进行操作,如果没有其他线程争用共享资源,那操作就直接成功;如果共享的资源的确被争用,产生了冲突,那再进行其他的补偿措施,最常用的补偿措施是不断地重试,直到出现没有竞争的共享数据为止
- 这种乐观并发策略的实现不再需要把线程阻塞挂起,因此这种同步操作被称为非阻塞同步,使用这种措施的代码常被称为无锁编程
实现非阻塞同步的方法
- 实现非阻塞同步需要硬件指令,因为必须要求操作和冲突检测两个步骤具有原子性,这里的原子性不能靠互斥同步来保证,因此只能靠硬件来实现这件事情
- 硬件实现能够保证某些从语义上看起来需要多次操作的行为只通过一条处理器指令就能实现
常用的硬件指令如下
- 测试并设置(Test-and-Set)
- 获取并增加(Fetch-and-Increment)
- 交换(Swap)
- 比较并交换(Compare-and-Swap,简称CAS)
- 加载链接/条件存储(Load-Linked/Store-Conditional,简称LL/SC)
其中前面的三条是20世纪就已经存在于大多数指令集之中的处理器指令
后面两条是现代处理器新增的指令,且这两条指令的目的和功能是类似的
3 无同步方案
无同步方案概述
- 要保证线程安全,也并非一定要进行阻塞同步或非阻塞同步,同步与线程安全两者并没有必然的联系,同步只是保障存在共享数据争用时正确性的手段
- 如果能让一个方法本来就不涉及共享数据,那它自然就不需要任何同步措施去保证共享数据的正确性,有一些代码天生就是线程安全的
实现无同步方案的方法
- 可重入代码
- 线程本地存储
3 volatile关键字
volatile关键字概述
- volatile关键字是JVM提供的最轻量级的同步机制,可以保证线程的可见性和有序性
- volatile并不容易被正确、完整地理解,以至于很多程序员都避免去使用它,遇到需要处理多线程数据竞争问题的时候一律使用锁机制来进行同步
volatile关键字的功能
- volatile可以修饰实例变量和类变量
- 被volatile修饰的变量将拥有两大特性:可见性、有序性
1 volatile可见性分析
volatile变量的可见性
- volatile变量对所有线程是立即可见的,对volatile变量的所有写操作都能立刻反映到其他线程之中
- 普通变量与volatile变量的区别是,volatile的特殊规则保证了volatile变量的新值能立即同步到主内存,每次使用volatile变量前也立即从主内存刷新,因此volatile保证了多线程操作时变量的可见性,而普通变量不能保证
volatile原理
- 对于写volatile变量时,会在volatile变量之后加上写屏障,保证在该屏障之前在工作内存中所有共享变量的改动(包括volatile变量)都同步到主存中去
- 对于读volatile变量时,会在volatile变量之前加上读屏障,保证在该屏障之后读取到的共享变量是最新数据
volatile变量与线程安全
- 基于volatile变量的运算在并发下并不是线程安全的
- volatile变量在各个线程的工作内存中时不存在一致性问题的,但是Java里面的运算操作符并非是原子操作,这导致volatile变量的运算在并发下一样是不安全的,因为volatile无法保证原子性
从字节码角度分析
如下字节码,一个线程对volatile变量的修改对另一个线程可见,保证了可见性
getstatic run //线程 t 获取 run true
getstatic run //线程 t 获取 run true
getstatic run //线程 t 获取 run true
getstatic run //线程 t 获取 run true
putstatic run //线程 main 修改 run 为 false
getstatic run //线程 t 获取 run false
volatile只能保证线程能看到最新值,即保证getstaic这一字节码获取到i的最新值,但不能解决指令交错
//假设i的初始值为0
getstatic i //线程2-获取静态变量i的值 线程内i=0
getstatic i //线程1-获取静态变量i的值 线程内i=0
iconst_1 //线程1-准备常量1
iadd //线程1-自增 线程内i=1
putstatic i //线程1-将修改后的值存入静态变量i 静态变量i=1
iconst_1 //线程2-准备常量1
isub //线程2-自减 线程内i=-1
putstatic i //线程2-将修改后的值存入静态变量i 静态变量i=-1
- volatile的使用规则
由于volatile只能保证可见性和有序性,在不符合以下两条规则的运算场景中,仍然要通过加锁的方式来保证线程安全
- 运算结果不依赖当前变量的当前值,或能保证只有单一的线程修改变量的值
- 变量不需要与其他的状态变量共同参与不变约束
- volatile常用场景
- 如果只有一个线程写共享变量,其他线程读共享变量,那么就可以使用volatile修饰变量
- 在同步代码块内的变量可见性由synchronized保证,不在同步代码块内的变量的可见性由volatile保证
2 volatile有序性分析
- volatile变量的有序性
volatile变量通过禁止指令重排序来保证有序性
- 指令重排序概述
- 指令重排序是机器级的操作
- 现在的CPU一般采用流水线来执行指令。一个指令的执行被分成:取指、译码、访存、执行、写回、等若干个阶段。然后,多条指令可以同时存在于流水线中,同时被执行。
- 指令流水线并不是串行的,并不会因为一个耗时很长的指令在“执行”阶段呆很长时间,而导致后续的指令都卡在“执行”之前的阶段上。我们编写的程序都要经过优化后(编译器和处理器会对我们的程序进行优化以提高运行效率)才会被运行,优化分为很多种,其中有一种优化叫做重排序,重排序需要遵守as-if-serial规则和happens-before规则
3 volatile和synchronized的选择
- 功能方面
synchronized在功能上是volatile的超集
- 性能方面
volatile的同步机制的性能确实要优于锁,大多数场景下volatile的总开销总比synchronized更低
- 总结
- 如果volatile的语义能够满足使用场景的需求,那么可以选用volatile
4 double-checked locking问题
- double-checked locking问题(dcl)概述
- dcl问题是通过两次检查并加锁的方式来避免多次进入同步代码块的一种优化手段
- 由于是两次检查,有一次检查在同步块外,因此需要volatile来保证可见性
- 具体以著名的double-checked locking单例模式为例
未使用double-checked locking的单例模式
public final class Singleton {
private Singleton() { }
private static Singleton INSTANCE = null;
public static Singleton getInstance() {
synchronized (Singleton.class) {
if (INSTANCE == null) { // t1
INSTANCE = new Singleton();
}
}
return INSTANCE;
}
}
- 多线程调用
getInstance()
方法时,由于指令交错可能导致创建多个实例,如线程t1判断INSTANCE==null
并进入创建实例,还未创建完成时线程t2再次进行判断INSTANCE==null
并创建实例。
因此需要加synchronized关键字保护临界区
- 这样做存在一个问题,即第一个线程创建过实例后,其他线程每次想要获取实例时仍然需要尝试获取锁
使用double-checked locking的单例模式
public final class Singleton {
private Singleton() { }
private static Singleton INSTANCE = null;
public static Singleton getInstance() {
if (INSTANCE == null) {
//首次访问会同步,而之后的使用没有synchronized
synchronized (Singleton.class) {
if (INSTANCE == null) {
INSTANCE = new Singleton();
}
}
}
return INSTANCE;
}
}
- 可以在同步代码块之前再加一个判断,此时即为double-checked
- 这样在首次调用
getInstance()
时才需要尝试获取锁,后续使用时无需获取锁
即使在首次调用时有多个线程通过了第一个判断,但只有一个线程能通过第二个判断
- 这样做仍然存在一个问题,即第一个判断的INSTANCE是在同步块之外,由于同步块内部的指令重排序,可能导致
**getInstance()**
方法返回一个没有调用构造器的对象
**getInstance()**
方法可能返回没有调用构造器的对象的原因分析
INSTANCE = new Singleton();
语句的部分字节码如下17: new #3 // class cn/itcast/n5/Singleton
20: dup
21: invokespecial #4 // Method "<init>":()V
24: putstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
- 17 表示创建对象,将对象引用入栈
- 20 表示复制一份对象引用
- 21 表示利用一个对象引用,调用构造方法
- 24 表示利用一个对象引用,赋值给static INSTANCE
JVM有可能进行指令重排序,先执行24,再执行21,即在执行构造方法前就把对象赋给了INSTANCE
- 现假设线程t1进入了同步代码块中,先执行了putstatic #2字节码,还没执行invokespecial #4字节码,此时线程t2进行
getInstance()
方法的第一个判断,由于t2看t1的字节码是无序的,因此会通过判断,获取到一个没有调用构造器的对象。如果这时候t2使用了对象,就很容易出现问题 - 上述问题的图解如下
double-checked locking单例模式的最终方案
public final class Singleton {
private Singleton() { }
// INSTANCE加volatile保护
private static volatile Singleton INSTANCE = null;
public static Singleton getInstance() {
if (INSTANCE == null) {
synchronized (Singleton.class) {
if (INSTANCE == null) {
INSTANCE = new Singleton();
}
}
}
return INSTANCE;
}
}
- 给INSTANCE加上volatile即可
4 Happens-Before原则
Happens-Before原则概述
- 如果Java内存模型中所有的有序性都仅靠volatile和synchronized来完成,那么有很多操作都将会变得非常啰嗦,但我们在实际编写代码时会发现大部分情况下并不需要我们去确保有序性,这是因为Java语言有先行发生原则(Happens-Before原则)
- 先行发生原则对于判断数据是否存在竞争,线程是否安全来说是非常有用的手段。
- 根据先行发生原则,我们可以轻松的判断并发环境下两个操作之间是否可能存在冲突,而不需要陷入Java内存模型苦涩难懂的定义中
Happens-Before的含义
- 先行发生是Java内存模型中定义的两项操作之间的偏序关系
比如操作A先行发生与操作B,操作A之前包括操作A产生的影响能被操作B观察到
- 两个操作如果具有先行发生关系,那么就可以确保其顺序性
- 如果两个操作之间的关系不属于先行发生关系,则它们就没有顺序性保障,JVM可以对它们随意地进行重排序
- 对于不存在先行发生关系的操作,我们可以根据实际情况来使用synchronized关键字或volatile关键字来套用管程锁定规则和volatile变量规则
- Happens-Before原则规定的8种先行关系
- 程序次序规则
- 在一个线程内,按照控制流顺序,书写在前面的操作先行发生于书写在后面的操作
即一个线程看自己内部是顺序的
- 管程锁定规则
- 一个unlock操作先行发生于后面对同一个锁的lock操作
- 这也是synchronized能够确保有序性的原因
- volatile变量规则
- 对一个volatile变量的写操作先行发生于后面对这个变量的读操作
- 这是显而易见的,因为volatile可以保证可见性和有序性
- 线程启动规则
- Thread对象的start()方法先行发生于此线程的每一个动作
- 线程终止规则
- 线程中的所有操作都先行发生于对此线程的终止检测(如Thread::join()和Thread::isAlive())
- 线程中断规则
thread.interrupt()
方法的调用先行发生于被中断线程的代码检测到中断事件的发生- 即被中断的线程可以看到被中断前的所有改变
- 对象终结规则
- 一个对象的初始化完成(构造函数执行完成)先行发生于它的finalize()方法的开始
- 传递性
- 如果操作A先行发生于操作B,操作B先行发生于操作C,则操作A先行发生于操作C
5 CAS
- 先看一个实例 ```java import java.util.ArrayList; import java.util.List; import java.util.concurrent.atomic.AtomicInteger;
class AccountSafe implements Account { private final AtomicInteger balance;
public AccountSafe(Integer balance) {
this.balance = new AtomicInteger(balance);
}
@Override
public Integer getBalance() {
return balance.get();
}
//这里针对共享变量balance的操作并没有加锁,而是使用了原子类提供的CAS方法
@Override
public void withdraw(Integer amount) {
while (true) {
int prev = balance.get();
int next = prev - amount;
if (balance.compareAndSet(prev, next)) {
break;
}
}
}
}
interface Account { //获取余额 Integer getBalance();
//取款
void withdraw(Integer amount);
/**
* 方法内会启动 1000 个线程,每个线程做 -10 元 的操作
* 如果初始余额为 10000 那么正确的结果应当是 0
*/
static void demo(Account account) {
List<Thread> ts = new ArrayList<>();
long start = System.nanoTime();
for (int i = 0; i < 1000; i++) {
ts.add(new Thread(() -> {
account.withdraw(10);
}));
}
ts.forEach(Thread::start);
ts.forEach(t -> {
try {
t.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
});
long end = System.nanoTime();
System.out.println(account.getBalance()
+ " cost: " + (end - start) / 1000_000 + " ms");
}
}
@Slf4j() public class Test { public static void main(String[] args) { Account account = new AccountSafe(10000); Account.demo(account); } }
- **可以看到上面的实例中对共享变量的操作并没有加锁**
```java
@Override
public void withdraw(Integer amount) {
while (true) {
//获取balance当前的值
int prev = balance.get();
//计算balance之后的值
int next = prev - amount;
//尝试将balance从prev更新到next
if (balance.compareAndSet(prev, next)) {
break;
}
}
}
- 其中的关键是原子类提供的compareAndSet方法,简称CAS操作(也叫Compare And Swap)
- compareAndSet方法可能会执行失败,原因是compareAndSet方法会将传入的参数prev与balance的最新值做比较,如果不相同,说明balance已经被更改过了,因此本次执行不成功
- 方法可能的执行流程如下,其中线程1不断尝试CAS,直到成功
- CAS概述
- CAS实际上是一条硬件指令,保证了原子性
- CAS(原子性)和volatile(可见性、有序性)配合可以实现无锁线程安全
CAS必须借助volatile才能保证在比较时读取到的共享变量值是最新值,因此原子类的value属性都是由volatile来修饰
- CAS体现的是无锁并发、无阻塞并发
当出现线程冲突时会让线程进入忙等,即不断地尝试
- 由于CAS不会让线程阻塞,因此也不会发生用户态与内核态的互相切换和线程上下文切换,因此CAS属于轻量锁
- CAS与synchronized
- CAS是基于乐观锁的思想,不管风险,先进行操作
synchronized是基于悲观锁的思想,不管风险都加以保护
- 线程不会陷入阻塞,这是CAS相较于synchronized效率提升的因素之一,但如果竞争激烈,可以想到重试必然频繁发生,反而效率会受影响
- CAS适用于线程数少、多核CPU的场景下,即线程冲突较少的场景
CAS指令实现步骤
- CAS指令需要三个操作数,分别是内存位置V(可以理解为变量的内存地址)、旧的预期值A、准备设置给V的新值B
- CAS指令执行时,当且仅当V中的值与A一致时,才会用B的值更新V的值,否则就不执行更新
- 不管是否更新了V的值,都会返回V的旧值
- 上述处理过程是一个硬件处理过程,是原子操作
CAS与Java
- 在JDK9之前,只有Java类库可以使用CAS,比如JUC包中的整数原子类提供的
compareAndSet()
方法和getAndIncrement()
方法 - 在JDK9之后,Java类库在VarHandle类向用户开放了CAS操作
- 在JDK9之前,只有Java类库可以使用CAS,比如JUC包中的整数原子类提供的
6 synchronize关键字
1 synchronized工作流程
- synchronized概述
synchronized俗称对象锁,是Java实现的一种阻塞式同步方法
- synchronized工作原理
- synchronized会锁住一个对象(该对象称为锁对象)并保护指定的代码区域(被保护的代码区域基本都是临界区,不是临界区的代码也不需要保护),之后同一时刻至多只有一个线程能持有锁对象,获取到锁对象的线程才能执行临界区的代码
- synchronized实现的同步(互斥)机制效率很低,但synchronized是其他并发容器实现的基础
- 实例
锁对象
- 锁对象也被称为同步锁、同步监视器
- 锁对象就像临界区的钥匙,获取这把钥匙的线程才能进入临界区
- 任何一个类的对象,都可以充当同步锁
锁对象释放
- 获取对象锁的线程,即使时间片用完了也不会释放对象锁,只有其再次获得时间片并执行完synchronized保护的临界区才会释放对象锁
- 线程调用
sleep()
方法并不会释放锁对象
synchronized修饰的目标
- synchronized可以修饰方法,从而保护整个方法中的代码,令被修饰的方法具有原子性
被synchronized修饰的方法称为同步方法
- synchronized也可以修饰方法中的某段代码,表示只对这个区块的代码进行保护
被synchronized修饰的代码块称为同步代码块
1 同步代码块
语法格式
synchronized(同步锁){
//临界区
}
- 同步锁传入什么对象,synchronized就将那个对象作为锁对象并将其锁住
2 同步方法
语法格式
访问控制修饰符 synchronized 非访问控制修饰符 返回值类型 方法名(参数列表){
//临界区
}
synchronized修饰方法时的锁对象
- synchronized修饰的是实例方法,则锁对象为方法所在类的实例对象(即this)
- synchronized修饰的是静态方法,则锁对象为方法所在类的类对象
- 如果某个静态方法被synchronized修饰,那么无论是用
类名.方法名
的方式还是实例.方法名
的方式调用,尝试获取的锁对象都是类对象。 - 类对象在堆中只有一个,因此如果想让某个类的所有实例同步使用某个临界区时,该临界区的锁对象可以是该类的类对象
- 如果某个静态方法被synchronized修饰,那么无论是用
3 应用
- 场景1
- 线程1和线程2分别对变量count增加5000和减少5000
- 分析
- 由于Java中的共享资源通常是堆上的对象,因此保证线程安全相关的操作一般都在被共享的对象上
- 线程每次使用线程安全的共享对象时就不需要考虑线程安全问题,线程安全由被共享的对象内部解决(如果需要组合使用同一个共享对象的线程安全方法时,一般也需要synchronized保护)
- 代码
```java
public class Test {
public static void main(String[] args) throws InterruptedException {
} }Syn syn = new Syn();
Thread thread1 = new Thread(() -> {
for (int i = 0; i < 5000; i++)
syn.increment();
}, "t1");
Thread thread2 = new Thread(() -> {
for (int i = 0; i < 5000; i++)
syn.decrement();
}, "t2");
thread1.start();
thread2.start();
System.out.println(syn.getCount()); //输出:0
//线程安全类Syn class Syn { static int count = 0;
public synchronized void increment() {
count++;
}
public synchronized void decrement() {
count--;
}
public synchronized int getCount() {
return count;
}
}
2. **场景2**
模拟转账流程,线程1和线程2互相转账
- **原始代码**
```java
@Slf4j(topic = "c.Test")
public class Test {
public static void main(String[] args) throws InterruptedException {
Account a = new Account(1000);
Account b = new Account(1000);
//账户a向账户b转账
Thread t1 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
a.transfer(b, randomAmount());
}
}, "t1");
//账户b向账户a转账
Thread t2 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
b.transfer(a, randomAmount());
}
}, "t2");
t1.start();
t2.start();
t1.join();
t2.join();
log.debug("total:{}", (a.getMoney() + b.getMoney()));
}
//Random是线程安全类
static Random random = new Random();
public static int randomAmount() {
return random.nextInt(100) + 1;
}
}
class Account {
private int money;
public Account(int money) {
this.money = money;
}
public int getMoney() {
return money;
}
public void setMoney(int money) {
this.money = money;
}
public void transfer(Account target, int amount) {
if (this.money > amount) {
this.setMoney(this.getMoney() - amount);
target.setMoney(target.getMoney() + amount);
}
}
}
- 分析
很容易发现上述代码中transfer()方法不是线程安全的,因为它既被两个线程同时调用,方法内部还操作了共享变量this.mony和target.money
修改
public synchronized void transfer(Account target, int amount) {
if (this.money > amount) {
this.setMoney(this.getMoney() - amount);
target.setMoney(target.getMoney() + amount);
}
}
- 上述改进是没有效果的,因为在方法上加synchronized只是锁住了this,但是方法内部对共享变量target.money的读写并没有保护。
- 由于需要保护共享变量的对象都属于Account类,因此可以使用Account.class作为锁对象来保护这段代码,当然这样做效率很低下
public void transfer(Account target, int amount) {
synchronized(Account.class){
if (this.money > amount) {
this.setMoney(this.getMoney() - amount);
target.setMoney(target.getMoney() + amount);
}
}
}
2 synchronized实现原理
1 Monitor(锁)概念
1 Java对象头
- 对象头概述
- 对象在堆内存中的存储布局可以划分为三个部分:对象头(Header)、实例数据(Instance Data)、对齐填充(Padding)
- 对象头中包括两类信息
- Mark Word
用于存储对象自身的运行时数据,这部分数据是与对象自身定义的数据无关的额外存储成本
这部分数据长度在32位和64位虚拟机中分别为32bit和64bit
- **Klass Word**
这部分是类型指针,用于存储指向方法区对象类型数据的指针,JVM通过该指针来确定该对象是哪个类的实例
- 对象头Mark Word详解
考虑JVM空间成本,Mark Word被设计成一个有着动态定义的数据结构,根据对象的状态复用自己的存储空间
- 对象不同状态对应的MarkWord存储空间定义如下
- hashcode - 哈希码
- thread - 线程ID
- age - 对象分代年龄
- biased_lock - 偏向标志位
- ptr_to_lock_record - 指向调用栈中锁记录的指针
- ptr_to_headvyweight_monitor - 指向Monitor对象的地址指针
2 Monitor工作流程
- Monitor概述
- Monitor被翻译为监视器或管程
- 每个Java锁对象都会关联一个Monitor对象,不是锁对象的Java对象不关联Monitor。例如使用synchronized给对象上锁(重量级)之后,该对象的对象头中Mark Word的类型就会转换为Heavyweight Locked类型,此时Mark Word就会被设置为指向Monitor对象的指针
- Monitor对象不在Java层面,而是在操作系统层面
- Monitor是真正管理锁相关事务的对象,如锁的获取、释放、阻塞线程队列、等待线程队列等
- Monitor工作流程
- 刚开始Monitor中Owner为null
- 当Thread-2执行
synchronized(obj)
时,Monitor就会将Owner置为Thread-2
- 每个Monitor中只能有一个Owner
- 在Thread-2上锁的过程中,如果Thread-3、Thread-4、Thread-5也执行synchronized(obj),就会进入EntryList(阻塞队列),并进入BLOCKED状态
- Thread-2执行完临界区的内容后,唤醒EntryList中等待的线程来竞争锁,竞争时是非公平的
- WaitSet中的Thread-0、Thread-1是之前获得过锁,但条件不满足进入WAITING状态的线程,即调用了wait()方法
2 synchronized原理解析
- 加锁相关字节码
理解synchronized底层需要了解synchronized关键字经过Javac编译之后的字节码,现有加锁的Java代码
static final Object lock = new Object();
static int counter = 0;
public static void main (String[]args){
synchronized (lock) {
counter++;
}
}
上述Java代码对应的字节码如下
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=1
0: getstatic #2 // <- lock引用 (synchronized开始)
3: dup
4: astore_1 // lock引用 -> slot 1
5: monitorenter // 将 lock对象 MarkWord 置为 Monitor 指针, lock_count + 1
6: getstatic #3 // <- i
9: iconst_1 // 准备常数 1
10: iadd // +1
11: putstatic #3 // -> i
14: aload_1 // <- lock引用
15: monitorexit // 将 lock对象 MarkWord 重置, 唤醒 EntryList, lock_count - 1
16: goto 24
19: astore_2 // e -> slot 2
20: aload_1 // <- lock引用
21: monitorexit // 将 lock对象 MarkWord 重置, 唤醒 EntryList, lock_count - 1
22: aload_2 // <- slot 2 (e)
23: athrow // throw e
24: return
Exception table:
from to target type
6 16 19 any //如果6-16行(临界区)出现异常,转到19行
19 22 19 any
LineNumberTable:
line 8: 0
line 9: 6
line 10: 14
line 11: 24
LocalVariableTable:
Start Length Slot Name Signature
0 25 0 args [Ljava/lang/String;
StackMapTable: number_of_entries = 2
frame_type = 255 /* full_frame */
offset_delta = 19
locals = [ class "[Ljava/lang/String;", class java/lang/Object ]
stack = [ class java/lang/Throwable ]
frame_type = 250 /* chop */
offset_delta = 4
- monitorenter和monitorexit
《Java虚拟机规范》要求
- 在执行monitorenter指令时,首先要尝试获取锁对象,如果这个锁对象没有被锁定,或者当前线程已经持有了锁对象,就把锁的计数器的值增加1
- 锁对象初次被线程持有时,其对象头MarkDown部分将被重置为Heavyweight Locked类型,并指向一个Monitor
- 在执行monitorexit指令时将锁计数器减1,一旦计数器的值为0,锁对象就被释放了
根据monitorenter和monitorexit的行为描述,可以得出synchronized的两个推理
- 被synchronized修饰的同步代码是可重入的
- 线程持有锁对象是一个重量级(Heavy-Weight)操作
1 可重入
- synchronized可重入的原因
因为monitorenter指令的行为是,在线程尝试获得锁对象时,如果这个锁对象没有被锁定,或者当前线程已经持有了锁对象,就把锁的计数器的值增加1,因此被synchronized修饰的同步代码是可重入的
2 重量级锁
- synchronized是重量级锁的原因
- 持有锁对象的线程在释放锁之前,会无条件地阻塞后续所有尝试获取锁对象的线程,并且无法强制已获取锁的线程释放锁,也无法强制正在等待锁的线程中断等待或超时退出
- Java的线程是映射到操作系统的原生内核线程之上的(1:1映射),如果要阻塞阻塞或唤醒一条线程,则需要操作系统来帮忙完成,这就不可避免地陷入用户态到核心态的转换中,进行这种转换需要耗费很多的处理器时间
而且线程的阻塞于唤醒也伴随着线程上下文的切换
- 对于代码特别简单的同步块(如setter()和getter()),状态转换消耗的时间甚至会比代码本身执行的时间还长
- 由于Java中的线程是基于内核线程实现的(1:1),因此各种线程操作,如创建、同步操作(操作管程)、析构都需要进行系统调用
- 申请锁 - 两次用户态与内核态的切换
申请锁时,从用户态进入内核态
申请到后从内核态返回用户态;没有申请到时阻塞睡眠在内核态,直到被唤醒返回用户态
- **释放锁 - 两次用户态与内核态的切换**
使用完资源后释放锁,从用户态进入内核态唤醒阻塞等待锁的进程,返回用户态;
所以,使用一次锁包括申请、持有到释放,当前进程要进行四次用户态与内核态的切换
synchronized导致线程阻塞与唤醒 -> 导致用户态与内核态的切换以及内核线程的切换 -> 导致线程上下文切换
内核态与用户态和内核线程切换(阻塞与唤醒)的主要开销是线程上下文的切换,这涉及一系列数据在各种寄存器、缓存中来回拷贝,是一种重量级操作
- 锁优化的引入
- synchronized是一个重量级操作,有经验的程序员都只会在确实必要的情况下使用
- JVM本身也对synchronized进行了优化(如在通知操作系统阻塞线程之前加入一段锁自旋的等待过程),以避免频繁地在用户态与核心态之间切换
7 锁优化
- 锁优化概述
- 高效并发是JDK6的一项重要改进,HotSpot开发团队花费了大量资源去实现各种锁优化技术
- 锁优化是为了在线程之间更高效地共享数据及解决竞争问题,从而提高程序的执行效率
- 锁优化是JVM内部实现的,因此对程序员是透明的
1 锁自旋
锁自旋出现的原因
- 互斥同步对性能最大的影响是阻塞的实现,挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作给JVM的并发性能带来了很大的压力
- JVM开发团队注意到,共享数据的锁定状态通常只会持续很短的一段时间,为了这段时间去挂起和恢复线程并不值得
锁自旋的工作流程
- 当某个线程持有锁对象时,我们可以让后面请求锁的线程“稍等一会儿”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁
- 为了让线程等待,只须让线程执行一个无意义的循环,这项技术就是所谓的锁自旋
- 多核CPU下锁自旋才有意义
自旋的时间
- 自旋等待本身虽然避免了线程切换的开销,但它是要占用处理器时间的,如果锁被占用的时间很短,自旋等待的时间就会非常好,反之如果锁被占用的时间很长,那么自旋的线程只会白白消耗处理器资源,而不会做任何有价值的工作,这就会带来性能的浪费
- 自旋等待的时间必须有一定的限度,如果自旋超过了限定的次数仍然没有成果获得锁,就应当使用阻塞的方式去挂起线程
自适应锁自旋
- 自适应意味着自旋的时间不再是固定的,而是由上一次在同一个锁上的自旋时间及锁拥有者的状态来决定
- 如果在同一个锁对象上,自旋等待刚刚成果获得过锁,并且持有锁的线程正在运行中,那么JVM就会认为这次自旋也很有可能再次成功,进而允许自旋等待持续相对更长的时间
- 如果对于某个锁,自旋很少成功获得过锁,那在以后要获取这个锁时将有可能直接省略掉自旋过程直接阻塞,以避免浪费处理器资源
2 轻量级锁
轻量级锁概述
- 轻量级锁并不是用来代替重量级的,它是为了在没有多线程竞争的前提下,减少重量级锁使用操作系统Monitor产生的性能消耗
- 锁对象的对象头中的Mark Word是实现轻量级锁和偏向锁的关键
轻量级锁工作过程
- 在线程进入同步代码时,如果锁对象没有被重量锁锁定(Mark Word类型不为01),JVM就在当前线程的当前栈帧中建立一个Lock Record(锁记录)空间
- Lock Record包含两条记录
- 一条是用来与锁对象Mark Word交换的Mark Word,其中含有指向栈帧中锁记录的指针,并且类型为00(表示轻量锁状态)
- 另一条是锁对象的引用
- JVM使用CAS操作尝试把锁记录中的Mark Word与锁对象中的Mark Word交换,如果CAS操作成功了,即代表该线程拥有了这个锁对象
- 如果第2步的CAS操作失败了,那就意味着有一个线程已经持有该锁对象。JVM会检查锁对象的Mark Word是否指向当前线程的栈帧
- 如果是,说明是当前线程拥有的锁对象,那直接进入同步代码即可,否则就说明该锁对象已经被其他线程抢占了。
- 如果不是,说明发生了多线程竞争,那轻量锁就不再有效,必须要膨胀为重量级锁。
此时为锁对象申请Monitor,并让锁对象中的Mark Word指向Monitor,同时Mark Word的状态值将变为10.此后,等待锁的线程必须进入阻塞状态(当前竞争锁的线程也进入阻塞状态)
- 轻量级锁的解锁过程也是通过CAS操作完成的。JVM同样会检查锁对象的Mark Word是否指向当前线程的栈帧
- 如果是,就使用CAS操作把锁对象当前的Mark Word和锁记录中的Displaced Mark Word交换,整个同步过程就完成了
- 如果不是,则说明有其他线程尝试过获取该锁,此时就要在释放锁的同时,去唤醒被阻塞的线程
- 轻量级锁对性能的提升分析
- 轻量级锁能提升程序同步性能依据的是经验,即对于绝大部分的锁,在整个同步周期内都是不存在竞争的
- 如果没有发生锁竞争,轻量级锁便通过CAS操作成功避免了使用Monitor的开销
- 如果发生锁竞争,除了使用Monitor本身的开销以外,还额外发生了CAS操作的开销,因此在有锁竞争的情况下,轻量级锁反而会比重量级锁更慢
3 偏向锁
- 偏向锁概述
- 轻量级锁是在无多线程竞争的情况下使用CAS操作去取代操作Monitor,偏向锁则是在无多线程竞争的情况下把整个轻量级锁同步过程(使用CAS更新Mark Word等操作)都消除掉了,即偏向锁的目的是消除关于锁的同步过程
- 偏向锁中的“偏”是“偏心”的意思,它会偏向于第一个获得它的线程
在线程第一次获得偏向锁后,如果在接下来的过程中该锁没有被其他线程尝试获取,则持有偏向锁的线程将永远不需要执行加锁、解锁、CAS操作等
- JVM默认开启偏向锁,即偏向标志位biased_lock为1
偏向锁的获取
- 当线程第一次获取锁对象时,如果锁对象的偏向标志位biased_lock为1(表示开启偏向锁),且线程ID部分为0(表示没有线程获取该偏向锁),则线程成功获取该偏向锁,然后使用CAS操作将线程的ID记录在锁对象的Mark Down之中
- 如果上述操作成功,那么持有偏向锁的线程无论何时进入锁对象相关的同步代码时都可以直接进入(线程会检查锁对象Mark Word中记录的线程ID是否与自己的ID相同),离开时也不需要进行任何操作
偏向锁的撤销
有两种情况会导致偏向锁的撤销
- 其他线程尝试获取锁对象
- 锁对象计算哈希码
- 在Java中如果一个对象计算过哈希码,就应该一直保持该值不变(如果锁对象的hashCode()方法没有被重写),JVM通过在对象头中存储哈希码的第一次计算结果来保证第一次计算后,再次调用该方法取到的哈希码值永远不会再发生改变
- 使用偏向锁时需要占用哈希码的位置来存储线程ID,而偏向锁又没有提供额外的空间来存储哈希码(轻量级锁存储在锁记录、重量级锁存储在Monitor),因此锁对象一旦计算哈希码,偏向锁只能被撤销
- 偏向锁被撤销时,又会根据偏向锁是否被锁定而进入两种状态
- 偏向锁变为不可偏向锁
- 当偏向锁没有被锁定时,偏向锁将变为不可偏向锁,此时锁对象Mark Word中的biased_lock为0
- 偏向锁变为轻量级锁
- 当偏向锁被锁定时,偏向锁将变为轻量级锁(锁升级)
- 如果是其他线程的出现导致偏向锁的撤销,此时其他线程将会尝试通过CAS操作获取轻量级锁,如果CAS操作失败,轻量级锁将会膨胀为重量级锁
4 锁消除
锁消除概述
- 锁消除是指JVM即时编译器在运行时检测到某段需要同步的代码根本不可能存在共享数据竞争而实施的一种对锁进行消除的优化策略
- 锁消除的主要判定依据来源于逃逸分析,如果判断一段同步代码中,在堆上的所有数据都不会逃逸出去被其他线程访问到,那就可以把它们当作栈上的数据对象,认为它们是线程私有的,同时取消代码的同步措施
锁消除的必要性
- 变量是否会逃逸是程序员可以知道的,因此程序员不会在不需要同步的地方施加同步措施,但是有许多同步措施不是程序员自己加入的,同步的代码在JDK中的出现非常频繁
- 比如在JDK5之前,字符串加法会自动转化为StringBuffer对象的连续append()操作,而append()方法被synchronized修饰,是线程安全的,此时如果是单线程执行字符串加法操作且没有锁消除策略,JVM就会浪费很多资源在保证代码的同步上
5 锁粗化
锁粗化出现的原因
- 我们在编写代码的时候,总是推荐将同步代码的作用范围限制得尽量小,尽量只在共享数据的实际作用域中进行同步,这样是为了使得需要同步的操作数量尽量少,如此可以尽快释放锁,让等待的线程尽快拿到锁
- 多数情况下,上述原则是正确的,但是如果一系列的连续操作都对同一个锁对象反复加锁和解锁,甚至加锁操作出现在循环体中,那即使没有多线程竞争,频繁地加锁、解锁操作也会导致不必要的性能消耗
锁粗化的工作原理
如果JVM探测到有一串零碎的操作都对同一个锁对象加锁,将会把同步的范围扩展(粗化)到一串零碎操作的外部,这样一串零碎操作只需要一次加锁解锁即可
8 wait() & notify()/notifyAll()原理
- wait()¬ify()概述
wait()
和notify()
/notifyAll()
方法是Object类的本地final方法(因为需要系统调用),无法被重写。wait()
方法使当前线程进入等待状态,前提是线程必须先获得锁,一般配合synchronized的锁对象使用
notify()
/notifyAll()
用于唤醒等待状态的线程
- 一般在synchronized同步代码块里使用
wait()
、notify()
/notifyAll()
方法
- wait()¬ify()工作流程
- 拥有锁对象的线程(Owner线程)发现条件不满足,调用
wait()
方法,进入Monitor的WaitSet并变为WAITING状态 - BLOCKED和WAITING的线程都处于阻塞状态,不占用CPU时间片
- BLOCKED线程会在Owner线程释放锁时唤醒
- WAITING线程会在Owner线程调用
notify()
或notifyAll()
时被唤醒,但唤醒后并不意味着立刻获得锁,仍需进入EntryList重新竞争锁对象 - 注意由于WaitSet是与锁对象有关的,因此在调用
wait()
和notify()
/notifyAll()
时,是在锁对象上调用的
上述方法调用的对象都是已获得的锁对象