Java 线程同步

本文最后更新于:2024年3月18日 凌晨

Java 线程同步

临界资源问题

  • 多个线程共享的数据称为临界资源,由于是线程调度程序负责线程的调度,程序员无法精确控制多线程的交替次序,如果没有特殊控制,多线程对临界资源的访问将导致数据的不一致性。
  • 以堆栈操作为例,涉及出栈和进栈两个操作,程序代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Stack {
int idx = 0;
char[] data = new char[10];

public void push(char c) {
data[idx] = c; // 存入数据。
idx++; // 改变栈顶指针。
}
}

public char pop() {
idx--;
return data[idx];
}
  • 可以想象,线程在执行方法的过程中均可能因为调度问题而中断执行,如果一个线程在执行push()方法时将数据存入了堆栈,但未给栈顶指针增值,这时中断执行,另一个线程则执行出栈操作,首先将栈指针减1,这样读到的数据显然不是栈顶数据,为避免此情况,可以采用synchronized给调用方法的对象加锁。

同步方法与同步块(synchronized)

  • synchronized保证一个方法处理的对象资源不会因其他方法的执行而改变,synchronized关键字的使用方法有如下两种:
    • 同步方法
      • synchronized修饰的方法就是同步方法,锁住的是this对象。
      • static方法添加synchronized,锁住的是该类的Class对象。
      • 同步方法控制对象的访问,每个对象对应一把锁,每个同步方法都必须拥有的调用该方法的对象的锁才能执行,否则线程会阻塞,方法一旦执行,就独占锁,直到该方法返回才释放锁,后面被阻塞的线程才能获得这个锁,继续执行。
    • 同步块
      • sysnchronized(Obj){}用在对象前面限制一段代码的执行,表示执行该段代码必须获得指定的对象锁。
  • synchronized的底层原理是跟monitor有关,也就是视图器锁,每个对象都有一个关联的monitor,当Synchronize获得monitor对象的所有权后会进行两个指令:加锁指令跟减锁指令。
  • monitor里面有个计数器,初始值是从0开始的,如果一个线程想要获取monitor的所有权,就查看它计数器是不是0
    • 如果是0的话,那么就说明没人获取锁,那么它就可以获取锁了,然后将计数器+1,也就是执行monitorenter加锁指令,monitorexit减锁指令是跟在程序执行结束和异常里的。
    • 如果不是0的话,就会陷入一个堵塞等待的过程,直到为0等待结束。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Stack {
int idx = 0;
char[] data = new char[10];

public void push(char c) {
synchronized (this) { // 执行以下一段代码锁定对象。
data[idx] = c; // 存入数据。
idx++; // 改变栈顶指针。
}
}

public synchronized char pop() { // 执行该方法时锁定对象。
idx--;
return data[idx];
}
}
  • 被加锁的对象要在synchronized中限制代码执行完毕才会释放对象锁,在此之前,其他线程访问正被加锁的对象时将处于资源等待状态,对象的同步代码的执行过程如下图所示:

  • 使用synchronized可能带来的问题。
    • 一个线程持有锁会导致其他所有需要此临界资源的线程挂起。
    • 在多线程竞争下,加锁,释放锁会导致比较多的上下文切换和调度延时,引起性能问题。
    • 如果一个优先级高的线程等待一个优先级低的线程释放锁,会导致优先级倒置,引起性能问题。

死锁

产生死锁的四个必要条件

  • 互斥条件:一个资源每次只能被一个进程使用。
  • 请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放。
  • 不可剥夺条件:进程已获得的资源,在末使用完之前,不能强行剥夺。
  • 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。
  • 这四个条件是死锁的必要条件,只要系统发生死锁,这些条件必然成立,而只要上述条件之一不成立,则死锁解除。
  • 一个线程可以获取一个锁后,再继续获取另一个锁,例如:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public void add(int m) {
synchronized(lockA) { // 获得lockA的锁。
this.value += m;
synchronized(lockB) { // 获得lockB的锁。
this.another += m;
} // 释放lockB的锁。
} // 释放lockA的锁。
}

public void dec(int m) {
synchronized(lockB) { // 获得lockB的锁。
this.another -= m;
synchronized(lockA) { // 获得lockA的锁。
this.value -= m;
} // 释放lockA的锁。
} // 释放lockB的锁。
}
  • 在获取多个锁的时候,不同线程获取多个不同对象的锁可能导致死锁,对于上述代码,线程1和线程2如果分别执行add()dec()方法时:
    • 线程1:进入add(),获得lockA
    • 线程2:进入dec(),获得lockB
  • 随后:
    • 线程1:准备获得lockB,失败,等待中。
    • 线程2:准备获得lockA,失败,等待中。
  • 此时,两个线程各自持有不同的锁,然后各自试图获取对方手里的锁,造成了双方无限等待下去,这就是死锁。
  • 死锁发生后,没有任何机制能解除死锁,只能强制结束JVM进程,因此,在编写多线程应用时,要特别注意防止死锁,因为死锁一旦形成,就只能强制结束进程。

避免死锁

  1. 使用共享锁代替独占锁,使用乐观锁代替悲观锁,破坏互斥条件。
  2. 剥夺控制法:尝试使用定时锁,使用lock.tryLock(timeout) ,超时等待时当前线程不会堵塞,破坏请求与保持条件。
  3. 资源顺序分配法:线程获取锁的顺序要一致,临界资源按顺序分配,破坏了循环等待条件。
  4. 一次封锁法:就是在方法的开始阶段,已经预先知道会用到哪些数据,然后所有锁住,在方法执行之后,再所有解锁,破坏了请求与保持条件。

解决死锁

  1. 使用jps -l定位进程号。
image-20210613170001778
  1. 使用jstack进程号,找到死锁问题并解决。
image-20210613170029775

注意:suspend()resume()方法天生容易发生死锁,调用suspend()时,目标线程会停下来,但却仍然持有在这之前获得的锁定,此时,其他任何线程都不能访问锁定的资源,除非被"挂起”的线程恢复运行,对任何线程来说,如果它们想恢复目标线程,同时又试图使用任何一个锁定的资源,就会造成令人难堪的死锁,因此从JDK1.2开始就建议不再使用suspend()resume(),更强调wait()notify()方法,可以在自己的线程类中置入一个标志变量,指出线程应该活动还是挂起,若标识指出线程应该挂起,便用wait()命其进入等待状态,若状态指出线程应当恢复,则用一个notify()重新启动线程。

Lock

  • 从JDK5.0开始,Java提供了更强大的线程同步机制,即通过显式定义同步锁对象来实现同步,同步锁使用Lock对象充当。
  • java.util.concurrent.locks.Lock接口是控制多个线程对共享资源进行访问的工具,锁提供了对共享资源的独占访问,每次只能有一个线程对Lock对象加锁,线程开始访问共享资源之前应先获得Lock对象。

synchronized与Lock的对比

  • Lock是一个接口,而synchronized是Java中的关键字,Lock 能完成synchronized所实现的所有功能。
  • synchronized在发生异常时,会自动释放线程占有的锁,因此不会导致死锁现象发生,而Lock在发生异常时,如果没有主动通过unLock()去释放锁,则很可能造成死锁现象,因此使用Lock时需要在finally块中释放锁。
  • Lock是显式锁(手动开启和关闭锁),synchronized是隐式锁,出了作用域自动释放。
  • Lock只有代码块锁,synchronized有代码块和方法锁。
  • Lock可以知道是不是已经获取到锁,而synchronized无法知道。
  • Lock可以让等待锁的线程响应中断,而synchronized却不行,使用synchronized时,等待的线程会一直等待下去,不能够响应中断。

ReentrantLock

  • ReentrantLock可以替代synchronized进行线程同步。
  • 必须先获取到锁,再进入try {...}代码块,最后使用finally保证释放锁。
  • 可以使用tryLock()尝试获取锁。
1
2
3
4
5
6
7
8
9
public class Counter {
private int count;

public void add(int n) {
synchronized(this) {
count += n;
}
}
}
  • 如果用ReentrantLock替代,可以把代码改造为:
1
2
3
4
5
6
7
8
9
10
11
12
13
public class Counter {
private final Lock lock = new ReentrantLock();
private int count;

public void add(int n) {
lock.lock();
try {
count += n;
} finally {
lock.unlock();
}
}
}
  • 因为synchronized是Java语言层面提供的语法,所以我们不需要考虑异常,而ReentrantLock是Java代码实现的锁,我们就必须先获取锁,然后在finally中正确释放锁。
  • 顾名思义,ReentrantLock是可重入锁,它和synchronized一样,一个线程可以多次获取同一个锁。
  • synchronized不同的是,ReentrantLock可以尝试获取锁:
1
2
3
4
5
6
7
if (lock.tryLock(1, TimeUnit.SECONDS)) {
try {
...
} finally {
lock.unlock();
}
}
  • 上述代码在尝试获取锁的时候,最多等待1秒,如果1秒后仍未获取到锁,tryLock()返回false,程序就可以做一些额外处理,而不是无限等待下去。
  • 所以,使用ReentrantLock比直接使用synchronized更安全,线程在tryLock()失败的时候不会导致死锁。

ReadWriteLock

  • 使用ReadWriteLock可以提高读取效率,读多写少的场景。
  • ReadWriteLock只允许一个线程写入,允许多个线程在没有写入时同时读取。
允许 不允许
不允许 不允许
  • ReadWriteLock实现这个功能十分容易,我们需要创建一个ReadWriteLock实例,然后分别获取读锁和写锁:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class Counter {
private final ReadWriteLock rwlock = new ReentrantReadWriteLock();
private final Lock rlock = rwlock.readLock();
private final Lock wlock = rwlock.writeLock();
private int[] counts = new int[10];

public void inc(int index) {
wlock.lock(); // 加写锁。
try {
counts[index] += 1;
} finally {
wlock.unlock(); // 释放写锁。
}
}

public int[] get() {
rlock.lock(); // 加读锁。
try {
return Arrays.copyOf(counts, counts.length);
} finally {
rlock.unlock(); // 释放读锁。
}
}
}
  • 把读写操作分别用读锁和写锁来加锁,在读取时,多个线程可以同时获得读锁,这样就大大提高了并发读的执行效率。
  • 使用ReadWriteLock时,适用条件是同一个数据,有大量线程读取,但仅有少数线程修改。
  • 例如,一个论坛的帖子,回复可以看做写入操作,它是不频繁的,但是,浏览可以看做读取操作,是非常频繁的,这种情况就可以使用ReadWriteLock

StampedLock

  • 如果我们深入分析ReadWriteLock,会发现它有个潜在的问题:如果有线程正在读,写线程需要等待读线程释放锁后才能获取写锁,即读的过程中不允许写,这是一种悲观的读锁。
  • StampedLockReadWriteLock相比,改进之处在于:读的过程中也允许获取写锁后写入!这样一来,我们读的数据就可能不一致,所以,需要一点额外的代码来判断读的过程中是否有写入,这种读锁是一种乐观锁。
  • 乐观锁的意思就是乐观地估计读的过程中大概率不会有写入,因此被称为乐观锁,反过来,悲观锁则是读的过程中拒绝有写入,也就是写入必须等待,显然乐观锁的并发效率更高,但一旦有小概率的写入导致读取的数据不一致,需要能检测出来,再读一遍就行。
  • StampedLock是不可重入锁。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public class Point {
private final StampedLock stampedLock = new StampedLock();

private double x;
private double y;

public void move(double deltaX, double deltaY) {
long stamp = stampedLock.writeLock(); // 获取写锁。
try {
x += deltaX;
y += deltaY;
} finally {
stampedLock.unlockWrite(stamp); // 释放写锁。
}
}

public double distanceFromOrigin() {
long stamp = stampedLock.tryOptimisticRead(); // 获得一个乐观读锁。
// 注意下面两行代码不是原子操作。
// 假设x,y = (100,200)
double currentX = x;
// 此处已读取到x=100,但x,y可能被写线程修改为(300,400)
double currentY = y;
// 此处已读取到y,如果没有写入,读取是正确的(100,200)
// 如果有写入,读取是错误的(100,400)
if (!stampedLock.validate(stamp)) { // 检查乐观读锁后是否有其他写锁发生。
stamp = stampedLock.readLock(); // 获取一个悲观读锁。
try {
currentX = x;
currentY = y;
} finally {
stampedLock.unlockRead(stamp); // 释放悲观读锁。
}
}
return Math.sqrt(currentX * currentX + currentY * currentY);
}
}
  • ReadWriteLock相比,写入的加锁是完全一样的,不同的是读取,注意到首先我们通过tryOptimisticRead()获取一个乐观读锁,并返回版本号,接着进行读取,读取完成后,我们通过validate()去验证版本号,如果在读取过程中没有写入,版本号不变,验证成功,我们就可以放心地继续后续操作,如果在读取过程中有写入,版本号会发生变化,验证将失败,在失败的时候,我们再通过获取悲观读锁再次读取,由于写入的概率不高,程序在绝大部分情况下可以通过乐观读锁获取数据,极少数情况下使用悲观读锁获取数据。
  • 可见,StampedLock把读锁细分为乐观读和悲观读,能进一步提升并发效率,但这也是有代价的:一是代码更加复杂,二是StampedLock是不可重入锁,不能在一个线程中反复获取同一个锁。
  • StampedLock还提供了更复杂的将悲观读锁升级为写锁的功能,它主要使用在if-then-update的场景:即先读,如果读的数据满足条件,就返回,如果读的数据不满足条件,再尝试写。

不需要同步的操作

  • JVM规范定义了几种原子操作:
    • 基本类型(longdouble除外)赋值,例如:int n = m
    • 引用类型赋值,例如:List<String> list = anotherList
  • longdouble是64位数据,JVM没有明确规定64位赋值操作是不是一个原子操作,不过在x64平台的JVM是把longdouble的赋值作为原子操作实现的。
  • 单条原子操作的语句不需要同步,例如:
1
2
3
4
5
public void set(int m) {
synchronized(lock) {
this.value = m;
}
}
  • 就不需要同步。
  • 对引用也是类似,例如:
1
2
3
public void set(String s) {
this.value = s;
}
  • 上述赋值语句并不需要同步。
  • 但是,如果是多行赋值语句,就必须保证是同步操作,例如:
1
2
3
4
5
6
7
8
9
10
class Pair {
int first;
int last;
public void set(int first, int last) {
synchronized(this) {
this.first = first;
this.last = last;
}
}
}
  • 有些时候,通过一些巧妙的转换,可以把非原子操作变为原子操作,例如,上述代码如果改造成:
1
2
3
4
5
6
7
class Pair {
int[] pair;
public void set(int first, int last) {
int[] ps = new int[] { first, last };
this.pair = ps;
}
}
  • 就不再需要同步,因为this.pair = ps是引用赋值的原子操作,而语句:
1
int[] ps = new int[] { first, last };
  • 这里的ps是方法内部定义的局部变量,每个线程都会有各自的局部变量,互不影响,并且互不可见,并不需要同步。

本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!