文章目录
并发编程中的三个问题
可见性
是指一个线程对共享变量进行修改,另一个先立即得到修改后的最新值
演示可见性问题:
实际效果:线程 1 并没有退出循环,说线程二修改了flag的值,而线程一并没有立即得到修改后的值。
原子性
原子性(Atomicity):在一次或多次操作中,要么所有的操作都执行并且不会受其他因素干扰而中断,要么所有的操作都不执行。
演示原子性问题:
执行几次后的实际结果:
证明:
number++
实际上是一个复合操作(且是非原子性的),它包含三个步骤:
- 读取
number
的当前值。 - 将读取的值加1。
- 将结果写回到
number
。
再通过反汇编说明number++的问题:
javap -p -v .\AtomicityDemo
其中,对于number++ 而言(number 为静态变量),实际会产生如下的 JVM 字节码指令:
number++ 一共由于4条命令构成
- getstatic:获取到当前 number 的值
- iconst_1:准备常量 1
- iadd:当前值与 iconst_1 做加法
- putstatic:将结果赋值给 number
以上多条指令在一个线程的情况下是不会出问题的,但是在多线程环境下就可能会出现问题。比如一个线程在执行 13: iadd 时,另一个线程又执行 9: getstatic。会导致两次 number++,实际上只加了1。
有序性
是指程序代码在执行过程中的先后顺序,由于Java在编译期以及运行期的优化,导致了代码的执行顺序未必就是开发者编写代码时的顺序。
举个例子🌰:
int a = 1; // 操作1
int b = 2; // 操作2
// 重排序后
int b = 2; // 操作2
int a = 1; // 操作1
出现可见性问题的两个前提:至少有两个线程、有个共享变量
public class Ordering {
private int num = 0;
private boolean flag = false;
private int x;
public void action1() {
if (flag) {
x = num + num;
} else {
x = 1;
}
// x的可能结果
System.out.println(x);
}
public void action2() {
num = 2;
flag = true;
}
}
x的可能结果:
- 结果1:线程1执行 action1(),此时
flag=false
,x的结果为1 - 结果2:线程2先执行了 action2(),线程1再执行 action1(),此时
flag=true
,num=2
,x的结果为4 - 结果3:java在编译和运行时会对代码进行优化,action2()的执行顺序变成了如下,此时线程2更改flag值之后,CPU切换到线程1执行,num为初始化的值0,x的结果为0
public void action2() { // 因为第2行和第3行代码并没有逻辑关系 Java在编译期以及运行期的优化 可能会将其改变顺序
flag = true;
num = 2;
}
上面的结果3就是有序性产出的并发问题
了解Java内存模型JMM
Java内存模型(Java Memory Model, JMM)是Java虚拟机(JVM)定义的一种规范,用于描述多线程程序中变量(包括实例字段、静态字段和数组元素)如何在内存中存储和传递的规则。规范了线程何时会从主内存中读取数据、何时会把数据写回主内存。
JMM 抽象了线程和主内存之间的关系,就比如说线程之间的共享变量必须存储在主内存中。
JMM 的核心目标是确保多线程环境下的可见性、有序性和原子性,从而避免由于硬件和编译器优化带来的不一致问题。
- 可见性:确保一个线程对变量的修改,能及时被其他线程看到。关键字
volatile
就是用来保证可见性的,它强制线程每次读写时都直接从主内存中获取最新值。 - 有序性:指线程执行操作的顺序。JMM允许某些指令重排序以提高性能,但会保证线程内的操作顺序不会被破坏,并通过
happens-before
关系保证跨线程的有序性。 - 原子性:是指操作不可分割,线程不会在执行过程中被中断。例如,
synchronized
关键字能确保方法或代码块的原子性。
从 JMM 了解可见性:线程 1 将 a 的值拷贝一份并修改了 a 的值,然后同步给主内存的值,而线程 2 一直用的是副本的值,并不知道主内存的值已被修改了,所以线程 1 修改的值,对于线程 2 来说是不可见的。
synchronized 保证三大特性
synchronized能够保证在同一时刻最多只有一个线程执行该段代码,以达到保证并发安全的效果。
synchronized (锁对象) {
// 受保护资源;
}
synchronized 保证原子性
synchronized保证原子性的原理,synchronized保证只有一个线程拿到锁,能够进入同步代码块。
案例演示:5个线程各执行1000次 i++;
public class Test01Atomicity {
private static int number = 0;
public static void main(String[] args) throws InterruptedException {
Runnable increment = () -> {
for (int i = 0; i < 1000; i++) {
synchronized (Test01Atomicity.class) {
number++;
}
}
};
ArrayList<Thread> ts = new ArrayList<>();
for (int i = 0; i < 50; i++) {
Thread t = new Thread(increment);
t.start();
ts.add(t);
}
for (Thread t : ts) {
t.join();
}
System.out.println("number = " + number);
}
}
对number++;增加同步代码块后,保证同一时间只有一个线程操number++;。就不会出现安全问题。
synchronized 保证可见性
案例演示:一个线程根据boolean类型的标记flag, while循环,另一个线程改变这个flag变量的值,另一个线程并不会停止循环。
public class Test01Visibility {
// 多个线程都会访问的数据,我们称为线程的共享数据
private static boolean run = true;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
while (run) {
// 增加对象共享数据的打印,println是同步方法
System.out.println("run = " + run);
}
});
t1.start();
Thread.sleep(1000);
Thread t2 = new Thread(() -> {
run = false;
System.out.println("时间到,线程2设置为false");
});
t2.start();
}
}
点进去println
,本质还是加了 synchronized
关键字
synchronized 保证有序性
为什么要重排序:
为了提高程序的执行效率,编辑器和cpu会对程序中的代码进行重排序
as-if-serial语义:
as-if-serial语义的意思是:不管编译器和CPU如何重排序,必须保证在单线程情况下程序的结果是正确的。 以下数据有依赖关系,不能重排序。
-
写后读
int a = 1; int b = a;
-
写后写
int a = 1; int a = 2;
-
读后写
int a = 1; int b = a; int a = 2;
synchronized 后,虽然进行了重排序,保证只有一个线程会进入同步代码块,也能保证有序性。
synchronized 保证有序性的原理,加s ynchronized 后,依然会发生重排序,只不过,我们有同步代码块,可以保证只有一个线程执行同步代码中的代码。保证有序性。
synchronized 的特性
可重入特性
synchronized内部维护了一个计数器( recursions 变量),记录线程是第几次获取锁,在执行完同步代码块时,计数器的数量会 - 1 ,直到计时器的数量为 0,就释放这个锁。
好处:1)可以避免死锁 2)可以让我们更好的来封装代码
不可中断特性
不可中断特性:一个线程获得锁后,另一个线程想要获得锁,必须处于阻塞或等待状态,如果第一个线程不释放锁,第二个线程会一直阻塞或等待,不可被中断。
public class Demo02_Uninterruptible {
private static final Object obj = new Object();
public static void main(String[] args) throws InterruptedException {
// 1.定义一个Runnable
Runnable run = () -> {
// 2.在Runnable定义同步代码块
synchronized (obj) {
String name = Thread.currentThread().getName();
System.out.println(name + "进入同步代码块");
// 保证不退出同步代码块
try {
Thread.sleep(888888);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
};
// 3.先开启一个线程来执行同步代码块
Thread t1 = new Thread(run);
t1.start();
Thread.sleep(1000);
// 4.后开启一个线程来执行同步代码块(阻塞状态)
Thread t2 = new Thread(run);
t2.start();
// 5.停止第二个线程
System.out.println("停止线程前");
t2.interrupt();
System.out.println("停止线程后");
System.out.println(t1.getState());
System.out.println(t2.getState());
}
}
执行结果:
t1 线程不释放锁,t2 线程会一直阻塞或等待,既不可被中断。
通过反汇编学习synchronized原理
它的实现原理依赖与 JVM 中的 Monitor(监视器锁)和对象头(Object Header)。
当修饰代码块时
会在代码块的前后插入monitorenter
和monitorexit
字节码指令,可以把monitorenter
理解为加锁,monitorexit
理解为解锁。
第二次出现 monitorexit 可以理解为出现异常的情况也需要解锁。
monitorenter:
- synchronized 的锁对象会关联一个 monitor,这个 monitor 不是我们主动创建的,是 JVM 的线程执行到这个同步代码块,发现锁对象没有monitor 就会创建 monitor,monitor 内部有两个重要的成员变量owner: 拥有 这把锁的线程,recursions 会记录线程拥有锁的次数,当一个线程拥有 monitor 后其他线程只能等待。
monitorexit:
- 能执行 monitorexit 指令的线程一定是拥有当前对象的 monitor 的所有权的线程。
- 执行 monitorexit 时会将 monitor 的进入数减 1。当 monitor 的进入数减为 0 时,当前线程退出 monitor,不再拥有 monitor 的所有权,此时其他被这个 monitor 阻塞的线程可以尝试去获取这个 monitor的所有权。
当修饰方法时
方法的常量池会添加一个 ACC_SYNCHRONIZED 标识,当某个线程访问这个方法时会检查是否有ACC_SYNCHRONIZED标识,若有则需要获取监视器锁才可以执行方法,此时就保证了方法的同步。
通过JVM源码学习synchronized
monitor 监视器锁
在 HotSpot 虚拟机中 monitor 是通过 ObjectMonitor 实现的,底层是 c++ 实现的,其数据结构和解释如下:
monitor竞争
- 通过CAS尝试把 monitor 的 owner 字段设置为当前线程。
- 如果设置之前的 owner 指向当前线程,说明当前线程再次进入monitor,即重入锁,执行 recursions ++ ,记录重入的次数。
- 如果当前线程是第一次进入该 monitor,设置 recursions 为 1,_owner 为当前线程,该线程成功获 得锁并返回。
- 如果获取锁失败,则等待锁的释放。
monitor等待
- 当前线程被封装成ObjectWaiter对象node,状态设置成ObjectWaiter::TS_CXQ。
- 在for循环中,通过 CAS 把 node 节点 push 到 _cxq 列表中,同一时刻可能有多个线程把自己的 node 节点 push 到_cxq列表中。
- node节点push到_cxq列表之后,通过自旋尝试获取锁,如果还是没有获取到锁,则通过park将当 前线程挂起,等待被唤醒。
- 当该线程被唤醒时,会从挂起的点继续执行,通过ObjectMonitor::TryLock 尝试获取锁。
monitor释放
当某个持有锁的线程执行完同步代码块时,会进行锁的释放,给其它线程机会执行同步代码,在 HotSpot中,通过退出monitor的方式实现锁的释放,并通知被阻塞的线程。
- 退出同步代码块时会让_recursions 减 1,当 recursions 的值减为0时,说明线程释放了锁。
- 根据不同的策略(由QMode指定),从cxq或EntryList中获取头节点,通过 ObjectMonitor::ExitEpilog 方法唤醒该节点封装的线程,唤醒操作最终由unpark完成。
锁升级的过程
Java 中的 synchronized 有偏向锁、轻量级锁、重量级锁三种形式,分别对应了锁【只被一个线程持有、不同线程交替持有锁、多线程竞争锁】三种情况。
- 偏向锁:当一个线程第一次获取锁时,JVM 会将该线程标记为“偏向”状态,后续若该线程再获取该锁,几乎没有开销。
- 轻量级锁:当另一个线程尝试获取已经被偏向的锁时,锁会升级为轻量级锁,使用 CAS 操作来减少锁竞争的开销。
- 重量级锁:当 CAS 失败无法获取锁,锁会升级为重量级锁,线程会被挂起,直到锁被释放。