线程安全
约 4158 字大约 14 分钟
2025-04-10
一、死锁
在遇到线程安全问题的时候,我们会使用加锁机制来确保线程安全,但如果过度地使用加锁,则可能导致锁顺序死锁(Lock-Ordering Deadlock)。
有的场景我们使用线程池和信号量来限制资源的使用,但这些被限制的行为可能会导致资源死锁(Resource DeadLock)。这是来自Java并发必读佳作 Java Concurrency in Practice 关于活跃性危险中的描述。
哲学家就餐问题和银行家算法看这里:操作系统——死锁的概念以及死锁处理策略
public class Test {
Object leftLock = new Object();
Object rightLock = new Object();
public static void main(String[] args) {
final Test test = new Test();
Thread a = new Thread(new Runnable() {
@Override
public void run() {
int i=0;
while (i<10){
test.leftRight();
i++;
}
}
},"aThread");
Thread b = new Thread(new Runnable() {
@Override
public void run() {
int i=0;
while (i<10){
test.rightleft();
i++;
}
}
},"bThread");
a.start();
b.start();
}
public void leftRight(){
synchronized (leftLock){
System.out.println(Thread.currentThread().getName()+":leftRight:get left");
synchronized (rightLock){
System.out.println(Thread.currentThread().getName()+":leftRight:get right");
}
}
}
public void rightleft(){
synchronized (rightLock){
System.out.println(Thread.currentThread().getName()+":rightleft: get right");
synchronized (leftLock){
System.out.println(Thread.currentThread().getName()+":rightleft: get left");
}
}
}
}
1.死锁四个必要条件
对于死锁产生的四个条件只要能破坏其中一条即可让死锁消失,但是条件一是基础,不能被破坏。
互斥使用
即当资源被一个线程占用时,别的线程不能使用
不可抢占
资源请求者不能强制从资源占有者手中抢夺资源,资源只能由占有者主动释放
请求和保持
当资源请求者在请求其他资源的同时保持对原因资源的占有
循环等待
多个线程存在环路的锁依赖关系而永远等待下去,例如T1占有T2的资源,T2占有T3的资源,T3占有T1的资源,这种情况可能会形成一个等待环路
2.如何预防
✒️ 尽量避免使用多个锁,并且只有需要时才持有锁。否则,嵌套的synchronized或者lock非常容易出问题。
✒️ 如果必须使用多个锁,尽量设计好的获取顺序,这个可以参考经典的银行家算法。但一般算法下,可以采取一些简单的辅助手段,比如:
- 将对象(方法) 和锁之间的关系,用图形化的方法表示分别抽取出来。
- 然后根据对象之间组合,调用的关系对比组合,考虑所有的可能的调用顺序。
✒️ 尽可能使用无锁编程,使用开放调用的编码设计
✒️ 尽可能的缩小锁的范围,防止锁住的资源过多引发阻塞和饥饿
✒️ 使用定时锁,比如Lock中的tryLock()。
二、volatile 关键字
volatile通常被比喻成"轻量级的synchronized",也是Java并发编程中比较重要的一个关键字。和synchronized不同,volatile是一个变量修饰符,只能用来修饰变量。无法修饰方法及代码块等。
synchronized可以保证原子性、有序性和可见性。而volatile却只能保证有序性和可见性。
1.保证有序性(防重排序)
如采用双重检查加锁(DCL)的方式来实现单例模式
public class Singleton {
public static volatile Singleton singleton;
/**
* 构造函数私有,禁止外部实例化
*/
private Singleton() {};
public static Singleton getInstance() {
if (singleton == null) {
synchronized (singleton.class) {
if (singleton == null) {
singleton = new Singleton();
}
}
}
return singleton;
}
}
为什么要在变量singleton之间加上volatile关键字。要理解这个问题,先要了解对象的构造过程,实例化一个对象其实可以分为三个步骤:
- 分配内存空间。
- 初始化对象。
- 将内存空间的地址赋值给对应的引用。
但是由于操作系统可以对指令进行重排序,所以上面的过程也可能会变成如下过程:
- 分配内存空间。
- 将内存空间的地址赋值给对应的引用。
- 初始化对象
如果是这个流程,多线程环境下就可能将一个未初始化的对象引用暴露出来,从而导致不可预料的结果。因此,为了防止这个过程的重排序,我们需要将变量设置为volatile类型的变量
2.可见性
对于每一个线程来说,栈都是私有的,而堆是共享的。
也就是说,在栈中的变量(局部变量、方法定义的参数、异常处理的参数)不会在线程之间共享,也就不会有内存可见性的问题,也不受内存模型的影响。而在堆中的变量是共享的,一般称之为共享变量。
所以,内存可见性针对的是堆中的共享变量。
既然堆是共享的,为什么在堆中会有内存不可见问题?
引起可见性问题的主要原因是每个线程拥有自己的一个高速缓存区——线程工作内存。CPU 访问缓存区比访问内存要快得多。
所有的共享变量都存在主存中。
每个线程都保存了一份该线程使用到的共享变量的副本。
如果线程 A 与线程 B 之间要通信的话,必须经历下面 2 个步骤:
- 线程 A 将本地内存 A 中更新过的共享变量刷新到主存中去。
- 线程 B 到主存中去读取线程 A 之前已经更新过的共享变量。
注意,根据 JMM 的规定,线程对共享变量的所有操作都必须在自己的本地内存中进行,不能直接从主存中读取。
所以线程 B 并不是直接去主存中读取共享变量的值,而是先在本地内存 B 中找到这个共享变量,发现这个共享变量已经被更新了,然后本地内存 B 去主存中读取这个共享变量的新值,并拷贝到本地内存 B 中,最后线程 B 再读取本地内存 B 中的新值。
可见性问题案例:
public class TestVolatile {
private static boolean stop = false;
public static void main(String[] args) {
// Thread-A
new Thread("Thread A") {
@Override
public void run() {
while (!stop) {
}
System.out.println(Thread.currentThread() + " stopped");
}
}.start();
// Thread-main
try {
TimeUnit.SECONDS.sleep(1);
System.out.println(Thread.currentThread() + " after 1 seconds");
} catch (InterruptedException e) {
e.printStackTrace();
}
stop = true;
}
}
通过打印结果,可以看到 Thread-main 休眠1秒之后,设置 stop = ture,但是Thread A根本没停下来,这就是可见性问题
Thread[main,5,main] after 1 seconds
// Thread A一直在loop, 因为Thread A 由于可见性原因看不到Thread Main 已经修改stop的值
如果通过在stop变量前面加上volatile关键字则会真正stop:
Thread[main,5,main] after 1 seconds
Thread[Thread A,5,main] stopped
Process finished with exit code 0
3.原理
为了提高处理器的执行速度,在处理器和内存之间增加了多级缓存。但是由于引入了多级缓存,就存在缓存数据不一致问题。
但是,对于volatile变量,当对volatile变量进行写操作的时候,JVM会向处理器发送一条lock前缀的指令,将这个缓存中的变量回写到系统主存中。
public class Test {
private volatile int a;
public void update() {
a = 1;
}
public static void main(String[] args) {
Test test = new Test();
test.update();
}
}
编译后的汇编代码
......
0x0000000002951563: and $0xffffffffffffff87,%rdi
0x0000000002951567: je 0x00000000029515f8
0x000000000295156d: test $0x7,%rdi
0x0000000002951574: jne 0x00000000029515bd
0x0000000002951576: test $0x300,%rdi
0x000000000295157d: jne 0x000000000295159c
0x000000000295157f: and $0x37f,%rax
0x0000000002951586: mov %rax,%rdi
0x0000000002951589: or %r15,%rdi
//在 volatile 修饰的共享变量进行写操作的时候会多出 lock 前缀的指令
0x000000000295158c: lock cmpxchg %rdi,(%rdx)
0x0000000002951591: jne 0x0000000002951a15
0x0000000002951597: jmpq 0x00000000029515f8
0x000000000295159c: mov 0x8(%rdx),%edi
0x000000000295159f: shl $0x3,%rdi
0x00000000029515a3: mov 0xa8(%rdi),%rdi
0x00000000029515aa: or %r15,%rdi
......
但是就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题,所以在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议。
如果一个变量被volatile所修饰的话,在每次数据变化之后,其值都会被强制刷入主存。而其他处理器的缓存由于遵守了缓存一致性协议,也会把这个变量的值从主存加载到自己的缓存中。这就保证了一个volatile在并发编程中,其值在多个缓存中是可见的。
提示
lock 前缀的指令在多核处理器下会引发两件事情:
- 将当前处理器缓存行的数据写回到系统内存。
- 写回内存的操作会使在其他 CPU 里缓存了该内存地址的数据无效。
为了保证各个处理器的缓存是一致的,实现了缓存一致性协议(MESI),每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。
缓存一致性:当一个缓存代表它所属的处理器去读写内存时,其它处理器都会得到通知,它们以此来使自己的缓存保持同步。只要某个处理器写内存,其它处理器马上知道这块内存在它们的缓存段中已经失效。
4.使用场景
在以下场景中可以使用volatile来代替synchronized:
- 对变量的写操作不依赖于当前值
- 变量不需要与其他状态变量共同参与不变约束。
- 只有在状态真正独立于程序内其他内容时才能使用 volatile。
除以上场景外,都需要使用其他方式来保证原子性,如synchronized或者concurrent包。
三、synchronized 关键字
在应用Sychronized关键字时需要把握如下注意点:
- 一把锁只能同时被一个线程获取,没有获得锁的线程只能等待;
- 每个实例都对应有自己的一把锁(this),不同实例之间互不影响;例外:锁对象是*.class以及synchronized修饰的是static方法的时候,所有对象公用同一把锁
- synchronized修饰的方法,无论方法正常执行完毕还是抛出异常,都会释放锁
synchronized关键字主要用两种方法(synchronized方法和synchronzied块),此外该关键字还可以作用静态方法、类或某个实例,但这都对程序的效率有很大的影响。
synchronized 方法 :在方法的声明前加入synchronized关键字。例如:
public synchronized void mutiThreadAccess();
只要把多个线程访问的资源的操作放在mutiThreadAccess方法中,就能够保证这个方法在同一时刻只能被一个线程来访问,从而保证了多线程访问的安全性。然而,当一个方法的方法体规模非常大时,把该方法声明为synchronized会大大影响程序的执行效率。为了提高程序的执行效率,java语言提供了synchronized块。
**synchrnized 块:**可以把任意代码段声明为synchronized,也可以指定上锁的对象,有非常高的灵活性。用法如下:
synchronized(syncObject){
//访问syncObject的代码
}
1. 监听对象为对象
包括方法锁(默认锁对象为this,当前实例对象)和同步代码块锁(自己指定锁对象)
正常情况使用示例
public class AccountingSync implements Runnable {
//共享资源(临界资源)
static int i = 0;
// synchronized 同步方法
public synchronized void increase() {
i ++;
}
@Override
public void run() {
for(int j=0;j<1000000;j++){
increase();
}
}
public static void main(String args[]) throws InterruptedException {
AccountingSync instance = new AccountingSync();
Thread t1 = new Thread(instance);
Thread t2 = new Thread(instance);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("static, i output:" + i);
}
}
输出结果如下:
static, i output:2000000
异常情况使用示例
如果一个线程 A 需要访问对象 obj1 的 synchronized 方法 f1(当前对象锁是 obj1),另一个线程 B 需要访问对象 obj2 的 synchronized 方法 f2(当前对象锁是 obj2),这样是允许的:
public class AccountingSyncBad implements Runnable {
//共享资源(临界资源)
static int i = 0;
// synchronized 同步方法
public synchronized void increase() {
i ++;
}
@Override
public void run() {
for(int j=0;j<1000000;j++){
increase();
}
}
public static void main(String args[]) throws InterruptedException {
// new 两个AccountingSync新实例
Thread t1 = new Thread(new AccountingSyncBad());
Thread t2 = new Thread(new AccountingSyncBad());
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("static, i output:" + i);
}
}
输出结果如下:
static, i output:1224617
上述代码与前面不同的是,我们创建了两个对象 AccountingSyncBad,然后启动两个不同的线程对共享变量 i 进行操作,但很遗憾,操作结果是 1224617 而不是期望的结果 2000000。
因为上述代码犯了严重的错误,虽然使用了 synchronized 同步 increase 方法,但却 new 了两个不同的对象,这也就意味着存在着两个不同的对象锁,因此 t1 和 t2 都会进入各自的对象锁,也就是说 t1 和 t2 线程使用的是不同的锁,因此线程安全是无法保证的。
每个对象都有一个对象锁,不同的对象,他们的锁不会互相影响。
解决这种问题的的方式是将 synchronized 作用于静态的 increase 方法,这样的话,对象锁就锁的是当前的类,由于无论创建多少个对象,类永远只有一个,所有在这样的情况下对象锁就是唯一的。
2. 同步静态方法或者监听对象Class
当 synchronized 同步静态方法时,锁的是当前类的 Class 对象,不属于某个对象。当前类的 Class 对象锁被获取,不影响实例对象锁的获取,两者互不影响,本质上是 this 和 Class 的不同。
由于静态成员变量不专属于任何一个对象,因此通过 Class 锁可以控制静态成员变量的并发操作。
public class AccountingSyncClass implements Runnable {
static int i = 0;
/**
* 同步静态方法,锁是当前class对象,也就是
* AccountingSyncClass类对应的class对象
*/
public static synchronized void increase() {
i++;
}
@Override
public void run() {
for(int j=0;j<1000000;j++){
increase();
}
}
public static void main(String[] args) throws InterruptedException {
//new新实例
Thread t1=new Thread(new AccountingSyncClass());
//new新实例
Thread t2=new Thread(new AccountingSyncClass());
//启动线程
t1.start();t2.start();
t1.join();t2.join();
System.out.println(i);
}
}
输出结果:
2000000
3. synchronized属于可重入锁
从互斥锁的设计上来说,当一个线程试图操作一个由其他线程持有的对象锁的临界资源时,将会处于阻塞状态,但当一个线程再次请求自己持有对象锁的临界资源时,这种情况属于重入锁,请求将会成功。
synchronized 就是可重入锁,因此一个线程调用 synchronized 方法的同时,在其方法体内部调用该对象另一个 synchronized 方法是允许的,如下:
public class AccountingSync implements Runnable{
static AccountingSync instance=new AccountingSync();
static int i=0;
static int j=0;
@Override
public void run() {
for(int j=0;j<1000000;j++){
//this,当前实例对象锁
synchronized(this){
i++;
increase();//synchronized的可重入性
}
}
}
public synchronized void increase(){
j++;
}
public static void main(String[] args) throws InterruptedException {
Thread t1=new Thread(instance);
Thread t2=new Thread(instance);
t1.start();t2.start();
t1.join();t2.join();
System.out.println(i);
}
}
AccountingSync 类中定义了一个静态的 AccountingSync 实例 instance 和两个静态的整数 i 和 j,静态变量被所有的对象所共享。
在 run 方法中,使用了 synchronized(this) 来加锁。这里的锁对象是 this,即当前的 AccountingSync 实例。在锁定的代码块中,对静态变量 i 进行增加,并调用了 increase 方法。
increase 方法是一个同步方法,它会对 j 进行增加。由于 increase 方法也是同步的,所以它能在已经获取到锁的情况下被 run 方法调用,这就是 synchronized 关键字的可重入性。
在 main 方法中,创建了两个线程 t1 和 t2,它们共享同一个 Runnable 对象,也就是共享同一个 AccountingSync 实例。然后启动这两个线程,并使用 join 方法等待它们都执行完成后,打印 i 的值。
此程序中的 synchronized(this) 和 synchronized 方法都使用了同一个锁对象(当前的 AccountingSync 实例),并且对静态变量 i 和 j 进行了增加操作,因此,在多线程环境下,也能保证 i 和 j 的操作是线程安全的。