背景
在实际工程实践中,多线程并发执行场景十分常见。所谓线程安全性即是多线程并发执行场景中需要保证的基本要求,如果不能保证线程安全性,那么势必会在实际工程实践中产生错误数据、甚至严重且不易察觉的异常处理,导致最终结果的不确定性。对于临界资源,或者是必须串行操作的流程,势必需要保证多个线程中每次仅有一个线程持有或仅有一个线程进入。如何保证多个线程由并行转串行,去持有临界资源或进入必须串行操作的流程呢?计算机领域中提供了“锁”的概念来进行保证,各种语言中都提供了对锁的实现。我们在这里仅针对JAVA语言中的锁进行分析。
synchronized关键字的原理
在早期的JAVA程序中,通常是使用synchronized关键字来保证线程安全性的。synchronized关键字,顾名思义表达的是同步的意思。也就是说,被synchronized关键字修饰的方法、语句块同一时刻只能有一个线程进入。
很多同学都会很好奇,究竟synchronized关键字的原理是怎样的,才能做到同一时刻只能有一个线程进入呢?其实是JVM来对此进行实现和保证的。每一个JAVA对象,其都对应一个Monitor(监视器)。标注有synchronized的语句块(如果我们将函数也看做是语句块,只不过是一段包装在函数内部的语句块)的头部和尾部,在JDK编译的时候,加入了monitorenter和monitorexit指令。通过对monitor的排他性获取,实现了同一时间仅有一个线程可以进入synchronized语句块,当语句执行完毕之后,线程释放monitor,从而使得其他等待获取monitor的线程依次获取到monitor,串行进入语句块,实现并行转串行。
使用方式
synchronized关键字的使用方式主要有以下三种:
- 修饰非静态方法
- 修饰静态方法
- 修饰语句块
修饰非静态方法的方式保证同一个对象(注意与静态方法进行区分)的这个方法内部同一时刻仅有一个线程可以进入;
相对应的,修饰静态方法的方式保证的是同一个类(注意不再是类的对象了,因为是静态方法)的这个方法内部同一时刻仅有一个线程可以进入。
修饰语句块的方式与修饰非静态方法的方式类似。
synchronized关键字的局限性
从上述的原理描述中,我们不难看出,由于synchronized是通过monitorenter和monitorexit来对语句块进行加锁,保证单一线程进入的,因此具备以下问题:
- 易死锁;
- 无法设置尝试获取锁的超时时间,一旦synchronized语句块中需要进行耗时的操作时,同时等待的线程的任务完成时间不可控且不可预期。
我们现在针对这二者进行简单的描述。
对于1,参见下述例子:
public class DeadLockDemo {
public Integer aInt = new Integer(1);
public Integer bInt = new Integer(1);
public static void main(String[] args) {
DeadLockDemo deadLockDemo = new DeadLockDemo();
Thread thread1 = new Thread(() -> {
synchronized (deadLockDemo.aInt) {
System.out.println("in thread1");
try {
Thread.currentThread().sleep(5000);
System.out.println("waiting for getting bInt object");
synchronized (deadLockDemo.bInt) {
System.out.println("got bInt object");
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
thread1.start();
Thread thread2 = new Thread(() -> {
synchronized (deadLockDemo.bInt) {
System.out.println("in thread2");
try {
Thread.currentThread().sleep(5000);
System.out.println("waiting for getting aInt object");
synchronized (deadLockDemo.aInt) {
System.out.println("got aInt object");
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
thread2.start();
}
}
从中不难看出,共有两个线程。线程1首先获取aInt对象的锁,然后尝试获取bInt对象的锁。线程2与线程1恰恰相反。最终导致,线程1持有aInt对象的锁并尝试获取bInt对象的锁的同时,线程2也在尝试获取aInt对象的锁,导致二者均无法继续向下运行。
由于synchronized无法提供超时机制,因此一旦出现死锁很难通过超时机制解锁。
对于2,我们就不构造代码实例了。
改进方案
上边提出了两个比较影响实际工程使用的问题,JAVA后续提出了ReentrantLock(可重入锁)对此进行了改进。
ReentrantLock记录了尝试获取锁的线程数,并维护了等待线程的队列,提供公平方式(正常排队,根据到达等待时间点的先后顺序安排解锁后获取锁的顺序)和非公平方式(插队抢占模式),当解锁后将锁分配给后续等待的线程。此外,ReentrantLock提供了超时时间,使得等待时间更可控。
在后面的文章中,会重点介绍ReentrantLock的原理。