JUC 并发编程 (二) JMM
JUC 并发编程
并发基础
共享模型
JMM
多线程在访问共享变量时,需要考虑临界区执行的原子性、资源的可见性和指令的有序性问题。
JMM - Java Memory Model javan内存模型即是一个抽象的概念。它规定了在多线程环境下,不同线程之间如何访问共享内存中的变量以及这些访问的可见性、有序性等方面的规则。
JMM 将内存分为了 主内存 和 工作内存。主内存是所有线程共享的内存区域,工作内存是每一个线程私有的内存,用于存储该线程使用到的变量的拷贝(在运行时,JIT编译器不断优化会将主内存的值拷贝到线程的工作内存中,减少对主内存的访问,提高效率)。
原子性
原子性 - atomicity 是指在一次操作中,要么所有的步骤都被执行,要么都不被执行,不会出现部分执行的情况。
原子性保证了指令的执行不会受到线程上下文切换的影响。
在多线程环境中,这意味着对共享变量的指令操作要么是完全执行,要么完全不执行。
可见性
可见性 - visibility 是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。在没有适当同步的多线程程序中,一个线程对共享变量的修改可能对其他线程不可见。
可见性问题的由来是由于 jvm 的优化和硬件的缓存机制,一个线程对共享变量的修改可能被缓存在线程的工作内存中,导致其他线程不可见。
可见性保证了线程对于共享资源访问和操作都是可见的,不受线程的工作内存的影响。
volatile 保证可见性:
1 | public class SimpleClass { |
synchronized 同步代码块保证可见性:
1 | public class SimpleClass { |
上述实例说明:volatile 保证了可见性,对于变量的读写操作都是在主内存中操作;
synchronized 可以保证同步代码块内变量的可见性来实现可见性,但是加锁的操作会让对象关联monitor监视器,效率比较低
可见性不能保证原子性:
- 可见性保证的是 多个线程中,一个线程的写操作对于其他线程是可见的,其他线程读取到的都是最新值
- 可见性不能避免多线程场景下,字节码指令交错执行的情况
1 | i++和i--的字节码: |
因此 volatile 适用于 一个线程写 多个线程读 的场景
有序性
有序性 - ordering 是指程序代码的执行顺序。有序性保证了指令执行顺序不会受cpu指令并行优化的影响。
指令重排
在不改变程序语义的前提下,为了优化并行性能并充分利用现代处理器的特性,JIT编译器可能会对指令进行重排序,让操作的执行顺序与代码指令顺序不一致。提高指令集的并行度(同一时间内执行多个指令或操作的能力),不能提高单条指令的执行时间,但是可以提高系统整体的性能和吞吐量。
指令重排在单线程的环境下没有问题,但是在多线程的场景下,由于多线程执行交错执行,指令重排可能会影响正确性。
volatile
volatile是用来修饰成员变量和静态成员变量(注意:局部变量是线程私有)的修饰符,它可以保证可见性和有序性
- 保证可见性:保证线程操作 volatile 变量都是直接操作主内存中的。当一个线程修改被 volatile 修饰的变变量的值时,该变化对所有其他线程都是可见的。
- 写操作立即刷新到主存中,读操作从主存中获取值
- 保证有序性:禁止指令重排序
volatile原理
volatile的底层原理是 内存屏障 - memory barrier
写屏障:写指令后加入写屏障
- 确保写屏障之前的所有共享变量的执行变化同步到主存中
- 确保其他线程可以读取到最新变化的数据
- 不会将写屏障之前的代码排在写屏障后执行 即 确保了对于该共享变量写操作之前的代码不被重排序到写操作之后
- 确保写屏障之前的所有共享变量的执行变化同步到主存中
读屏障:读指令前加入读屏障
- 确保读屏障之前的所有共享变量的读取加载主存中的数据
- 确保线程读取到的是最新的数据,而不是工作内存的旧数据
- 不会将读屏障之后的代码排在读屏障前执行 即 确保了对于该共享变量读操作之后的代码不被重排序到读操作之前
- 确保读屏障之前的所有共享变量的读取加载主存中的数据
不能保证原子性,不能解决多线程的指令交错(由 “cpu分时片” 决定)问题,而 synchronized 可以保证原子性和可见性(注意不能保证指令重排,只是防止共享变量在同步代码块中不会存在有序性问题,对象在代码块外也可能有问题)
double-check lock
单例模式
懒汉式单例模式
- 懒惰实例化
- 每次使用 getInstance 获取单例实例都需要添加 synchronized 锁,性能较低
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15public class Singleton {
private static Singleton singleton = null;
private Singleton() {
}
public static synchronized Singleton getInstance() {
if (singleton == null) {
singleton = new Singleton();
}
return singleton;
}
}双重校验锁
基于上述获取锁的才能获取单例实例的问题,双重校验锁在此基础上进行了优化,防止了单例对象被创建后,仍然尝试获取锁。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20public class Singleton {
private static Singleton singleton = null;
private Singleton() {
}
public static Singleton getInstance() {
if (singleton == null) {
// 当 single 没有被创建并赋值时,才会获取 synchronized 锁去创建对象
synchronized (Singleton.class) {
if (singleton == null) {
singleton = new Singleton();
}
}
}
return singleton;
}
}查看 getInstance方法 的字节码文件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
220 getstatic #2
3 ifnonnull 37 (+34)
6 ldc #3 // 将锁前获取类对象
8 dup // 将类对象的引用指针复制
9 astore_0 // 类对象的引用指针副本存储在本地变量表第0位 用于解锁
10 monitorenter // 加锁
11 getstatic #2
14 ifnonnull 27 (+13)
17 new #3 // 构建一个新的对象
20 dup // 复制引用
21 invokespecial #4 // 调用构造方法
24 putstatic #2 // 栈顶的数据存储到类的静态变量中
27 aload_0
28 monitorexit
29 goto 37 (+8)
32 astore_1
33 aload_0
34 monitorexit
35 aload_1
36 athrow
37 getstatic #2
40 areturn在上述代码不断执行的过程中,JIT对于上述代码进行了指令重排序,将24赋值和21调用构造方法的指令交换执行顺序,从而导致了线程安全问题的出现。
从上述时序图可以看出,JIT的指令重排,导致先赋值引用再调用构造函数进行初始化,导致另一个线程在线程赋值进入判断 单例对象是否为空,才是被赋值引用的对象不为空,因此直接返回,而此时返回的是 没有调用构造函数而未被完全初始化的对象。
解决方案:使用 volatile 修饰 静态的单例 single 可以防止单例问题 - volatile 阻止指令重排序 写操作之前的代码不会重排序到读操作之后,保证了 invokespecial 不会重排序重排序到 putstatic 后面执行。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20public class Singleton {
private static volatile Singleton singleton = null;
private Singleton() {
}
public static Singleton getInstance() {
if (singleton == null) {
// 当 single 没有被创建并赋值时,才会获取 synchronized 锁去创建对象
synchronized (Singleton.class) {
if (singleton == null) {
singleton = new Singleton();
}
}
}
return singleton;
}
}
happens-before
happens-before规则 定义了一种内存可见性保证,即保证 对一个共享变量的写操作的结果对其他线程读操作可见。在这种保证下,多线程之间的操作不会产生意料之外的结果。
happens-before为开发者提供了一种方式,用来推断和保证在并发环境中不同线程对共享数据的操作是如何被看到的。
常见happens-before规则
- synchronized
- volatile 读写屏障
- thread start 之前执行写操作
- thread start 和 join 保证读写的可见性
- t1 在 interrupt t2 前执行写操作
- 基础变量默认值
无锁并发
CAS
CAS - compare and swap|set 是乐观锁的机制,用于实现多线程环境下的并发控制,它操作系统层面的原子性操作。CAS操作包括了三个参数:内存位置(通常是一个变量的内存地址相对于结构的偏移量)、期望值和新值。
操作:只有当内存位置的当前值等于期望值时,才会将内存位置的值更新为新值;否则,不进行任何操作。
CAS 底层实现依赖于硬件的原子性操作指令,在X86架构上,这个指令是 cmxchg 指令
乐观锁 CAS 必须配合 volatile 实现非阻塞的无锁并发:cas 需要 volatile 修饰提供可见性,获取共享变量的最新值进行比较
CAS和Synchronized的性能比较
使用 CAS 加 while(true) 实现无锁并发的效率 在并发程度不是特别高时,比加悲观锁Synchronized的效率要高:
1 | public class SimpleClass { |
原因:CAS 加 while(true) 失败重试 实现无锁并发时,线程不会停下来即不会阻塞,线程的上下文切换少(它的线程上下文切换只取决于CPU时间片的分配),而使用 synchronized 时,当线程未竞争到锁会阻塞,会进行线程的上下文切换,频繁的上下文切换会到来性能的消耗。但是在高度竞争的情况下,频繁的CAS操作会导致性能下降,多个线程可能因为更新失败而不断的重试更新
总结
cas 结合 volatile 支持实现无锁并发,使用于线程少,多核cpu的场景下 - 无锁无阻塞并发
- CAS 基于乐观锁的思想,当别的线程修改共享变量时,则该线程CAS修改共享变量失败,进入while(true)的下一次循环重试,不会阻塞,直到修改成功。
- sychronized 是悲观锁,只有一个线程可以操作共享变量,防止其他线程修改共享变量,造成线程安全问题
CAS ABA问题
CAS的ABA问题是在使用CAS时可能遇到的一个特殊问题。这个问题的出现是因为CAS只检查内存中的值是否与预期的值相同,而不检查在此期间该值是否被修改过。
实例:如果一个变量原始值是A,后来被另一个线程改成了B,然后又改回了A,那么进行CAS操作的线程将无法知晓这中间的变化,它只会看到值仍然是A,并且匹配其预期值,因此会错误地认为没有其他线程修改过这个变量。
解决方案:使用版本号和时间戳,每次操作值时更新版本号或者时间戳,cas操作不仅会检查变量的值是否和预期相符,还会检查版本号或者时间戳是否一致。
原子类
原子整数
常见的原子整数包括 AtomicBoolean、AtomicInteger、AtomicLong等
以 AtomicInteger 为例:
1 | public class AtomicInteger extends Number implements java.io.Serializable { |
原子引用
当我们需要保护的数据类型不是基本类型时,就可以使用原子引用类型
常见原子引用的类型:AtomicReference、AtomicMarkableReference、AtomicStampedReference
AtomicReference
1 | private AtomicReference<BigDecimal> atomicReference = new AtomicReference<>(new BigDecimal("10")); |
AtomicStampedReference
AtomicStampedReference 可以防止ABA问题,它包含了两个部分的信息,一个是对象的引用,一个是一个整数值,用于标记引用的版本号
1 | // 参数:值 版本号 |
AutomicMarkabledRefence
AtomicMarkableReference 可以防止ABA问题,它包含了两个部分的信息,一个是对象的引用,一个是布尔值(用于标记引用是否被修改过或者有特定的状态,用这个布尔类型描述对象是否被修改了)。AtomicMarkableReference 不关心值更新了多少次,只关心值是否被修改过。
1 | AtomicMarkableReference<Integer> atomicMarkableReference = new AtomicMarkableReference<>(1, true); |
原子数组
原子数组用于保证原子数组中的元素的线程安全
常见的原子数组 AtomicIntegerArray、AtomicLongArray、AtomicReferenceArray
1 | AtomicIntegerArray atomicIntegerArray = new AtomicIntegerArray(5); |
字段更新器
字段更新器用于保护某个对象的属性或者成员变量的线程安全性
常见的字段更新器:AtomicReferenceFieldUpdater、AtomicIntegerFieldUpdater、AtomicLongFieldUpdater
注意:对象中的字段或属性必须加上volatile修饰保证可见性,否则会保存 IllegalArgumentException: Must be volatile type 异常
1 | public class SimpleDemo { |
原子累加器
原子累加器专用于高并发场景下的累加操作,但是性能比AtomicInteger进行累加好很多。常见的原子累加器 长整型LongAdder、双精度浮点型DoubleAdder。
累加性能高的原因:分段累加
性能提升原因与原子累加器的底层实现有关
AtomicInteger的getAndIncrement方法是底层是通过 while 循环不断尝试CAS操作,往一个共享变量进行累加,当线程之间竞争比较激烈的情况下,尝试的次数也会变多,导致性能下降。
而原子累加器在存在竞争时,设置了多个累加单元Cell(累加单元个数一般不会超过CPU的核心数),对多个累加单元进行操作,最后将结果汇总,减少了对一个共享变量CAS操作失败的重试次数,提高了性能。
LongAddr原理
- p177-183 LongAddr
Unsafe
Unsafe 是位于 sun.misc 包中的被称为 不安全的类,提供了一些底层操作,允许开发者绕过 Java语言的一些安全检查和限制,直接进行内存操作、对象实例化等。它提供了一些直接访问内存和其他执行低级操作的方法,这些方法提高了性能。
Unsafe 绕过了 Java 内存模型和访问权限,因此 Unsafe 类的使用是受限的。
1 | public static void main(String[] args) throws NoSuchFieldException, IllegalAccessException { |
Unsafe CAS
Unsafe 提供了 CAS 原子性操作的支持,这是一种多线程并发控制的机制。CAS是一种乐观锁定的方式,能够在不使用锁的情况下实现对共享变量的原子操作。
Unsafe 底层根据内存偏移量来定位对象的内存位置,在内存位获取据值进行比较和交换的操作。
1 | public class UnsafeDemo { |
重点理解 Unsafe 的 objectFieldOffset 和 compareAndSwapInt 方法
偏移量是属性地址相对于对象实例起始地址的偏移量,每个实例对象都一样
简单实现 AtomicInteger
1 | public class MyAtomicInteger { |
总结
不可变类
不可变类是指在创建后其实例的状态不能被修改的类。一旦不可变类的对象被创建,其内部的状态(属性的值)将保持不变,不能被更改。
不可变类是线程安全的,它们的状态不会发生变化,不会受到多线程并发访问的影响。
String
String 是不可变的,一旦创建了String对象,它的内容将不会被改变,因此不可变类是线程安全的类。
1 | public final class String implements java.io.Serializable, Comparable<String>, CharSequence { |
String 是如何保证性能安全的?
- String 底层使用 private final char value[] 数组来存储字符串的内容,使用 private 确保是私有的,final 确保引用不能改变,因此只有构造时才能对它进行赋值,后续只读。
- String 类上加上了 final 修饰符,所以此类不能被继承,防止任何子类重写覆盖 String 的方法,破环 String 的不可变性。
- String 类中没有提供用于修改字符串的方法,所有对于 String 的操作都是通过返回一个新的String对象完成。
- String 类采用了采用了拷贝性保护的思想,避免传入的引用共享。
拷贝性保护
拷贝性保护(Defensive Copy)是一种编程技术,在处理可变对象时创建它们的副本,而不是直接使用原始对象的引用,以避免引用共享,防止外部对对象的意外修改来保证不可变性。
String 采用了拷贝性保护的思想,避免引用共享,保证了不可变性。
1 | public String(char value[]) { |
享元模式
享元模式(Flyweight Pattern)是一种设计模式,共享相同值的对象以减少内存使用和提高性能。
对于基本数据类型的包装类(如 Integer、Double 等),为了提高内存利用率和性能,采用了享元模式的概念。
包装类维护了一个常量池,其中包含了一定范围内的常用数值的对象实例。当你创建一个新的包装类对象时,系统首先检查常量池中是否已存在相同数值的对象,如果存在,则返回常量池中的对象引用,而不是创建一个新的对象。
1 | public static Long valueOf(long l) { |
- Byte、Long、Short: -128~127
- Character:0~127
- Integer: -128~127 最小值不能改变 最大值可以通过虚拟机参数 java.lang.Integer.IntegerCache.high 调整
- Boolean:TRUE FALSE
应用
final原理
设置final原理
如果一个成员被final所修饰时,在进行赋值时,在赋值指令之后加入写屏障。
1 | public class SimpleClass { |
1 | 0 aload_0 |
写屏障
- 保证写屏障之前的所有共享变量的变化同步主存
- 保证写屏障之前的指令不会被重排序到写屏障之后
无状态类
当一个 Java 类没有任何成员变量并且该类的方法中没有对外部状态的依赖时,则该类是无状态的,该类的实例也就是线程安全的,多个线程可以同时调用该类的方法而不会相互干扰。
在无状态的类中,方法的执行结果只取决于传入的参数,而不受实例内部属性或者状态的影响,从而避免导致线程安全问题。