本文最后更新于: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) { this.value += m; synchronized(lockB) { this.another += m; } } }
public void dec(int m) { synchronized(lockB) { this.another -= m; synchronized(lockA) { this.value -= m; } } }
|
- 在获取多个锁的时候,不同线程获取多个不同对象的锁可能导致死锁,对于上述代码,线程1和线程2如果分别执行
add()
和dec()
方法时:
- 线程1:进入
add()
,获得lockA
- 线程2:进入
dec()
,获得lockB
- 随后:
- 线程1:准备获得
lockB
,失败,等待中。
- 线程2:准备获得
lockA
,失败,等待中。
- 此时,两个线程各自持有不同的锁,然后各自试图获取对方手里的锁,造成了双方无限等待下去,这就是死锁。
- 死锁发生后,没有任何机制能解除死锁,只能强制结束JVM进程,因此,在编写多线程应用时,要特别注意防止死锁,因为死锁一旦形成,就只能强制结束进程。
避免死锁
- 使用共享锁代替独占锁,使用乐观锁代替悲观锁,破坏互斥条件。
- 剥夺控制法:尝试使用定时锁,使用
lock.tryLock(timeout)
,超时等待时当前线程不会堵塞,破坏请求与保持条件。
- 资源顺序分配法:线程获取锁的顺序要一致,临界资源按顺序分配,破坏了循环等待条件。
- 一次封锁法:就是在方法的开始阶段,已经预先知道会用到哪些数据,然后所有锁住,在方法执行之后,再所有解锁,破坏了请求与保持条件。
解决死锁
- 使用
jps -l
定位进程号。
- 使用
jstack
进程号,找到死锁问题并解决。
注意: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
,会发现它有个潜在的问题:如果有线程正在读,写线程需要等待读线程释放锁后才能获取写锁,即读的过程中不允许写,这是一种悲观的读锁。
StampedLock
和ReadWriteLock
相比,改进之处在于:读的过程中也允许获取写锁后写入!这样一来,我们读的数据就可能不一致,所以,需要一点额外的代码来判断读的过程中是否有写入,这种读锁是一种乐观锁。
- 乐观锁的意思就是乐观地估计读的过程中大概率不会有写入,因此被称为乐观锁,反过来,悲观锁则是读的过程中拒绝有写入,也就是写入必须等待,显然乐观锁的并发效率更高,但一旦有小概率的写入导致读取的数据不一致,需要能检测出来,再读一遍就行。
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(); double currentX = x; double currentY = y; 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规范定义了几种原子操作:
- 基本类型(
long
和double
除外)赋值,例如:int n = m
- 引用类型赋值,例如:
List<String> list = anotherList
long
和double
是64位数据,JVM没有明确规定64位赋值操作是不是一个原子操作,不过在x64平台的JVM是把long
和double
的赋值作为原子操作实现的。
- 单条原子操作的语句不需要同步,例如:
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
是方法内部定义的局部变量,每个线程都会有各自的局部变量,互不影响,并且互不可见,并不需要同步。