Java并发编程实战(3)- 互斥锁

我们在这篇文章中主要讨论如何使用互斥锁来解决并发编程中的原子性问题。

目录

概述

并发编程中的原子性问题的源头是线程切换,那么禁止线程切换可以解决原子性问题吗?

这需要分情况讨论,在单核CPU的情况下,同一时刻只有一个线程执行,禁止CPU中断,就意味着操作系统不会重新调度线程,也就禁止了线程切换,这样获取CPU使用权的线程就可以不间断的执行。

在多核CPU的情况下,同一时刻,有可能有两个线程同时执行,一个线程执行在CPU-1上,另外一个线程执行在CPU-2上,这时禁止CPU中断,只能保证某一个CPU上的线程连续执行,但并不能保证只有一个线程在运行。

同一时刻只有一个线程执行,我们称之为互斥,如果我们能够保证对共享变量的修改是互斥的,那么无论是单核CPU还是多核CPU,就都能保证原子性了。

如何能做到呢?答案就是互斥锁

互斥锁模型

互斥锁简易模型

当我们谈论互斥锁时,我们一般会把一段需要互斥执行的代码称为临界区,下面是一个简单的示意图。
Java并发编程实战(3)- 互斥锁
当线程进入临界区之前,首先尝试加锁,如果成功,可以进去临界区,如果失败,需要等待。当临界区的代码被执行完毕或者发生异常时,线程释放锁。

互斥锁改进模型

上面的模型虽然直观,但是过于简单,我们需要考虑2个问题:

  • 我们锁的是什么?
  • 我们保护的又是什么?

在现实世界中,锁和锁要保护的资源是有对应关系的,通俗的讲,你用你家的锁保护你家的东西,我用我家的锁保护我家的东西。

在并发编程的世界中,锁和资源也应该有类似的对应关系。

下面是改进后的锁模型。
Java并发编程实战(3)- 互斥锁
首先,我们要把临界区中要保护的资源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中的锁和锁对象

我们在使用synchronized关键字时,它锁定的对象是什么呢?如果没有显式指定锁对象,Java有如下默认规则

  • 当修饰静态方法时,锁定的是当前类的Class对象。
  • 当修饰非静态方法时,锁定的是当前实例对象this。

根据上述规则,下面的代码是等价的。

// 修饰实例方法
	synchronized void updateData() {
		// 业务代码
	}
	
	// 修饰实例方法
	synchronized(this) void updateData2() {
		// 业务代码
	}
	// 修饰静态方法
	synchronized static void retrieveData() {
		// 业务代码
	}
	
	// 修饰静态方法
	synchronized(SynchronizedDemo.class) static void retrieveData2() {
		// 业务代码
	}

synchronized示例

我们在之前的文章中描述过count=count+1的例子,当时没有做并发控制,结果引发了原子性问题,我们现在看一下,如何使用synchronized关键字来解决并发问题。

首先我们来复习一下Happens-Before规则,synchronized修饰的临界区是互斥的,也就是说同一时刻只有一个线程执行临界区的代码,而Happens-Before中的“对一个锁解锁Happens-Before后续对这个锁的加锁”,指的是前一个线程解锁操作对后一个线程的加锁操作是可见的,然后结合Happens-Before传递性原则,我们可以得出前一个线程在临界区修改的共享变量,对于后续完成加锁进入临界区的线程是可见的。

下面是修改后的代码:

public class ConcurrencySafeAddDemo {

	private long count = 0;

	private synchronized void safeAdd() {
		int index = 0;
		while (index < 10000) {
			count = count + 1;
			index++;
		}
	}

	private void reset() {
		this.count = 0;
	}

	private void addTest() throws InterruptedException {

		List<Thread> threads = new ArrayList<Thread>();

		for (int i = 0; i < 6; i++) {
			threads.add(new Thread(() -> {
				this.safeAdd();
			}));
		}
		
		for (Thread thread : threads) {
			thread.start();
		}
		
		for (Thread thread : threads) {
			thread.join();
		}
		
		threads.clear();

		System.out.println(String.format("Count is %s", count));
	}

	public static void main(String[] args) throws InterruptedException {
		ConcurrencySafeAddDemo demoObj = new ConcurrencySafeAddDemo();
		for (int i = 0; i < 10; i++) {
			demoObj.addTest();
			demoObj.reset();
		}
	}
}

执行结果如下。

Count is 60000
Count is 60000
Count is 60000
Count is 60000
Count is 60000
Count is 60000
Count is 60000
Count is 60000
Count is 60000
Count is 60000

这里和我们的预期是一致的。

和第一版的代码相比,我们只是用synchronized关键字修饰了safeAdd()方法。

锁与受保护的资源的关系

对于互斥锁来说,锁与受保护的资源之间的关联关系非常重要,那么这两者之间到底是什么关系呢?一个合理的解释是:锁与受保护的资源之间是N:1的关系,也就是说:

  • 一个锁可以应用到多个受保护资源
  • 一个受保护资源上只能有一个锁

我们可以用球赛门票来做类比,其中座位是资源,门票是锁。一个座位只能用一张门票来保护,如果是“包场”的情况,一张包场门票就可以对应多个座位。不会出现一个座位有多张门票的情况。

同理,在互斥锁的场景下,如果两个锁使用了不同的锁对象,那么这两个所对应的临界区不是互斥的。 这一点很重要,忽视它的话,很容易引发莫名其妙的并发问题。

例如,我们把上面示例代码中的safeAdd()方法改成下面的样子,它还能正常工作吗?

	private void safeAdd() {
		int index = 0;
		synchronized(new Object()) {
			while (index < 10000) {
				count = count + 1;
				index++;
			}
		}
	}

这里,我们在为synchronized关键字设置锁对象时,每次都新建一个Object对象,那么每个线程在运行到这里时,都是使用不同的锁对象,那么临界区中的代码就不是互斥的,最后得出的结果也不会是我们期望的。

Count is 17355
Count is 18215
Count is 19244
Count is 20863
Count is 60000
Count is 60000
Count is 60000
Count is 20430
Count is 60000
Count is 60000

一个锁保护多个资源

上面我们谈到一个互斥锁可以保护多个资源,但是一个资源不可以被多个互斥锁保护。

那么,我们如何用一个锁来保护多个资源呢?

一个锁保护多个没有关联关系的资源

对于多个没有关联关系的资源,我们很容易用一个锁去保护。

以银行账户为例,银行账户可以有取款操作,也有修改密码操作,那么账户余额和账户密码就是两个没有关联关系的资源。

我们来看下面的示例代码。

public class BankAccountLockDemo {

	private double balance;
	private String password;
	
	private Object commonLockObj = new Object();
	
	// 取钱
	private void withdrawMoney(double amount) {
		synchronized(commonLockObj) {
			// 业务代码
			balance = balance - amount;
		}
	}
	
	// 修改密码
	private void changePassword(String newPassword) {
		synchronized(commonLockObj) {
			// 业务代码
			password = newPassword;
		}
	}
}

我们可以看到,上述代码使用了共享锁commonLockObj来保护balance和password,是可以正常工作的。

但是这样做存在的问题是取款和修改密码操作不能同时进行,从业务角度看,这两块业务是没有关联的, 应该是可以并行的。

解决办法是每个业务使用各自的互斥锁对相关资源进行保护。上述代码中可以创建两个锁对象:balanceLockObjpasswordLockObj,这样两个业务操作就不会互相影响了,这样的锁也被称为细粒度锁

一个锁保护多个有关联关系的资源

对于有关联关系的资源,情况会复杂一些。

我们以转账操作为例进行说明,转账的过程会涉及两个账户的余额,这两个余额就是两个有关联关系的资源。

我们来看下面的示例代码。

public class BankAccountTransferLockDemo {
	private double balance;
	
	private Object lockObj = new Object();
	
	private void transfer(BankAccountTransferLockDemo sourceAccount, BankAccountTransferLockDemo targetAccount, double amount) {
		synchronized(lockObj) {
			sourceAccount.balance = sourceAccount.balance - amount;
			targetAccount.balance = targetAccount.balance + amount;
		}
	}
}

上述代码有问题吗? 答案是有问题。

看上去我们在操作balance的时候,使用了加锁处理,但是需要注意这里的锁对象是lockObj,是一个Object对象,如果此时有其他业务也需要操作相同账户的balance,例如存取款操作,其他业务是没有办法使用lockObj来创建锁的,从而造成多个业务同时操作balance,引发并发问题。

问题的解决办法是我们创建的锁需要能够覆盖受保护资源的所有场景。

回到我们上面的示例,如果使用Object对象作为锁对象不能覆盖所有相关业务,那么我们需要升级锁对象,将其由Object对象变为Class对象,代码如下:

	private void transfer(BankAccountTransferLockDemo sourceAccount, BankAccountTransferLockDemo targetAccount, double amount) {
		synchronized(BankAccountTransferLockDemo.class) {
			sourceAccount.balance = sourceAccount.balance - amount;
			targetAccount.balance = targetAccount.balance + amount;
		}
	}

上述资源之间的关联关系,如果用更具体、更专业的语言来描述,其实是一种“原子性”的特征,原子性有两层含义:1) CPU指令级别的原子性,2)业务含义上的原子性。

“原子性”的本质什么?

原子性的表象是不可分割,其本质是多个资源间有一致性的要求,操作的中间状态对外不可见。

解决原子性问题,就是要保证中间状态对外不可见,这也是互斥锁要解决的问题。

发表评论

评论已关闭。

相关文章