在这个张文中,我们主要讨论如何使用互斥来解决并发编程中的原子性问题。
总结
并发编程中原子性问题的根源是线程切换,那么禁止线程切换能解决原子性问题吗?
这需要逐案讨论。在单核CPU的情况下,同一时间只有一个线程在执行,禁止CPU中断,也就是说操作系统不会重新调度线程,从而禁止线程切换,让获得CPU使用权的线程可以继续执行。
在多核CPU的情况下,同时可能有两个线程同时执行,一个线程在CPU-1上执行,另一个线程在CPU-2上执行。此时禁止CPU中断,只能保证某个CPU线程的连续执行,但不能保证只有一个线程在运行。
同一时间只有一个线程在执行,这叫互斥。如果能保证共享变量的修改互斥,那么单核CPU和多核CPU都可以保证原子性。
怎么做?答案是互斥。
互斥锁模型
互斥锁的简单模型
当我们谈到互斥时,我们通常把一段需要互斥执行的代码称为临界段。下面是一个简单的图表。
在线程进入临界区之前,先尝试锁定它。如果成功,就可以进入临界区。如果失败了,需要等待。当关键区域中的代码被执行或发生异常时,线程释放锁。
互斥锁的改进模型
上面的模型虽然直观,但是太简单了。我们需要考虑两个问题:
我们锁定了什么?
我们在保护什么?
在现实世界中,锁和要被锁保护的资源是有对应关系的。一般来说,你用你的锁保护你的东西,而我用我的锁保护我的东西。
在并发编程的世界里,锁和资源应该有相似的对应关系。
下面是一个改进的锁模型。
首先,我们应该在关键区域标记要保护的资源R。然后,我们为资源r创建一个锁LR,最后,当我们进入和离开临界区时,我们需要锁定和解锁锁LR。
通过这样的处理,我们建立了锁和资源的关联关系,不会出现类似“用我的锁保护你的资源”的问题。
Java世界中的互斥锁
在Java语言中,我们使用synchronized关键字实现互斥锁。
synchronized关键字可以应用于方法或直接应用于代码块。
让我们看看下面的示例代码。
public class SynchronizedDemo {
// 修饰实例方法
synchronized void updateData() {
// 业务代码
}
// 修饰静态方法
synchronized static void retrieveData() {
// 业务代码
}
// 修饰代码块
Object obj = new Object();
void createData() {
synchronized(obj) {
// 业务代码
}
}
}
与我们描述的互斥模型相比,我们在上面的代码中看不到与锁定和解锁相关的代码,因为Java编译器已经在synchronized关键字修改的方法或代码块前后自动为我们添加了锁定和解锁逻辑。这样做的好处是我们不用担心锁定后忘记解锁。
同步锁定和锁定对象
当我们使用synchronized关键字时,它锁定的对象是什么?如果没有明确指定锁对象,Java有以下默认规则
修改静态方法时,当前类的类对象被锁定。
修改非静态方法时,当前实例对象被锁定。
根据上述规则,以下代码是等效的。
// 修饰实例方法
synchronized void updateData() {
// 业务代码
}
// 修饰实例方法
synchronized(this) void updateData2() {
// 业务代码
}
// 修饰静态方法
synchronized static void retrieveData() {
// 业务代码
}
// 修饰静态方法
synchronized(SynchronizedDemo.class) static void retrieveData2() {
// 业务代码
}