JAVA进阶学习-多线程基础详解(三)-锁

       这几天又没更新了,哎,还是懒啊!大家看了博客之后有啥看法可以说出来嘛,我们讨论讨论,这样我就更有写下去的动力了,最近在学驾照,就快要考科目二了,所以会更的比较慢。好了,进入正题。

       额,我又插几句题外话,其实这时候这篇博客已经写完了,我是回过头来写的这段话,现在是凌晨两点三十分,很困,没想到会写到这么晚。大家看这篇博客之前一定要去看看上一篇博客,因为这篇的代码是基于上一篇改动过来的,至少也应该理解上篇博客的代码之后再来阅读,传送门就在下一段。希望大家学业有成、事业顺利吧,共勉。晚安!


       上一篇博客我们通过一个很直观的例子看到了当线程不同步时,多个线程处理同一个对象会发生的状况,但是那这种错误是我们都不想看到的(老板更不想),所以这一篇博客,我们就来了解一下如何去避免这一错误,这篇博客的代码是在上一篇博客上做出一些改动,所以没看过上一篇的朋友请点传送门

       JAVA为我们提供了两种机制来防止线程受到并发访问的干扰。首先是synchronized关键字,之后Java SE 5.0又引入了ReentrantLock类。我将分别进行讨论,并且用代码给大家演示下具体怎么操作,在开发过程中通常,当线程进入临界区时,我们发现这个线程要在满足某个条件之后才可以执行,这时就要使用一个条件对象来管理那些已经获得了一个锁但是却不能做有用工作的线程,关于条件对象我也将在这篇博客和大家讨论。

1.Lock

  java.util.concurrent.locks包定义了两个锁类,分别是ReentrantLock和ReentrantReadWriteLock,我将在本篇博客里和大家讨论前者,后者则为读写锁,在以后的博客里我们再讨论。

       锁是控制多个线程对共享资源进行访问的工具。通常,锁提供了对共享资源的独占访问。一次只能有一个线程获得锁,对共享资源的所有访问都需要首先获得锁。不过,某些锁可能允许对共享资源并发访问,如 ReadWriteLock 的读取锁。当我们使用ReentrantLock保护代码块后确保了在任何时刻只会有一个线程进入临界区。一旦一个线程封锁了锁对象,其他任何线程就都无法通过lock语句,当其他线程试图调用lock时,它们将会被阻塞,直到第一个线程释放锁对象。

       用ReentrantLock来保护代码块的基本结构如下所示:

myLock.lock(); //一个ReentrantLock对象
try{
	critical section
}finally{
	myLock.unlock();
}

       我来就ReentrantLock的使用举一个例子,我在上一篇博客中写了一个程序来模仿一个有多个账户的银行,每个账户都拥有一个线程,让这些账户随机的向另外一个账户转钱。后来错误就发生了,经过我们的分析我们发现错误的发生就是因为并发导致的。我来使用ReentrantLock来修改下我们的代码。(没看过上一篇博客的朋友去点这篇第二段的传送门哦!)

修改Bank类,代码如下。

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class Bank {

	private Lock bankLock = new ReentrantLock();  //修改1:声明一个Lock对象,初始化为ReentrantLock

	private final double[] accounts; // 账户数组

	public Bank(int n, double initialBalance) { // 构造方法 初始化账户余额

		accounts = new double[n];
		for (int i = 0; i < accounts.length; i++) {
			accounts[i] = initialBalance;
		}

	}

	/**
	 * 从一个账户转移一定的钱款到另一个账户
	 *
	 * @param from
	 *            原账户
	 * @param to
	 *            目标账户
	 * @param amount
	 *            钱数
	 */
	public void transfer(int from, int to, double amount) {

		bankLock.lock(); //修改2:获取锁
		try{
			System.out.println(Thread.currentThread());
			accounts[from] -= amount; // 从原账户中转出钱款
			System.out.printf(" %10.2f from %d to %d", amount, from, to);
			accounts[to] += amount; // 把转出的钱款转移到目标账户
			System.out.printf("Total Balance: %10.2f%n", getTotalBalance());
		}finally{
			bankLock.unlock();//修改3:释放锁
		}
	}

	/**
	 * 统计银行总存款
	 *
	 * @return 总存款
	 */
	public double getTotalBalance() {
		double sum = 0;
		for (double a : accounts) {
			sum += a;
		}
		return sum;
	}

	public int size() {
		return accounts.length;
	}

}

       大家可以看到我对Bank类进行了三处修改,使用了一个锁来保护transfer方法,这时我们运行下程序来看看修改之后的结果,运行结果如下(见证奇迹的时刻到了)!

     793.48 from 92 to 70Total Balance:  100000.00
Thread[Thread-37,5,main]
     969.07 from 37 to 99Total Balance:  100000.00
Thread[Thread-26,5,main]
     247.55 from 26 to 20Total Balance:  100000.00
Thread[Thread-63,5,main]
     748.72 from 63 to 30Total Balance:  100000.00
Thread[Thread-99,5,main]
      71.75 from 99 to 73Total Balance:  100000.00
Thread[Thread-62,5,main]
     190.59 from 62 to 1Total Balance:  100000.00
Thread[Thread-45,5,main]
     420.33 from 45 to 45Total Balance:  100000.00
Thread[Thread-65,5,main]
     199.52 from 65 to 52Total Balance:  100000.00
Thread[Thread-62,5,main]
     770.98 from 62 to 50Total Balance:  100000.00
Thread[Thread-34,5,main]
     713.68 from 34 to 58Total Balance:  100000.00
Thread[Thread-76,5,main]
      83.95 from 76 to 34Total Balance:  100000.00
Thread[Thread-44,5,main]
     410.37 from 44 to 58Total Balance:  100000.00
Thread[Thread-96,5,main]
     761.84 from 96 to 88Total Balance:  100000.00
Thread[Thread-64,5,main]
      46.07 from 64 to 76Total Balance:  100000.00

       我们看到错误消失了,银行的总存款一直是10W,即使这个程序一直运行下去那也不会在出现上一篇博客中看到的错误了,而我其实也仅仅修改了三处,有没有很爽?

       为什么错误就消失了呢?假定一个线程调用transfer方法,这时他就拥有了锁,在该线程完成transfer方法之前如果有另外一个线程也来调用transfer时,由于第二个线程不能获得锁,他就会在调用lock方法时被阻塞。它必须等待第一个线程完成transfer方法的执行之后才能再度被激活。当第一个线程释放锁时,那么第二个线程才能开始运行。注: 要把互斥区放在try内,释放锁放在finally内!!切记切记!!!

2.条件对象

       我们来细化一下银行的模拟程序,有这样一个问题,如果不断的随机进行转账操作的化,那么当一个账户里只有200但是要转出的金额是600该怎么办?难道转出之后账户剩负的300?很明显不能这样,我们在转账之前需要有一个条件来避免选择没有足够资金的账户作为转出账户。

       针对这个需求我们来分析一下,当一个线程进入transfer方法之后,发现要转出的金额大于该线程所属账户的余额,这时应该怎么办呢?等待直到另一个线程向该账户中注入了资金。但是,这一线程刚刚获得了对bankLock的排它性访问,因此别的线程就没有进行存款操作的机会了。这个时候就抓脑袋了!不要紧张,Java为我们提供了条件对象来处理这一尴尬的局面。

       一个锁对象可以有一个或多个相关的条件对象。我们可以使用newCondition方法获得一个条件对象。当发现当前线程不具有执行的条件时,我们可以调用await()方法,这时当前线程就会被阻塞,并且放弃拥有的锁,这时另外的线程就可以获得锁并尝试执行方法了。

       需要注意的是,等待获得锁的线程和调用await方法的线程在阻塞的本质上是不同的。一旦一个线程调用await方法,它就会进入该条件的等待集,当锁可用时,该线程不能马上解除阻塞,它会继续保持阻塞状态,直到另外一个线程调用同一条件上的signalAll方法为止。调用signalAll方法将会重新激活所有因为该条件而等待的线程。当这些线程从等待集中移除时,它们将再次变为可运行状态,调度器将再次激活它们。同时,它们会尝试着重新进入该对象。一旦锁成为可用的,他们中的某个将从await调度返回,获得该锁并从被阻塞的地方继续执行。

       当一个线程调用await时,它没有办法重新激活自身。如果没有其他线程来重新激活等待的线程的话,它就永远不会再执行了,这就会导致死锁。如果其他所有线程被阻塞,最后一个线程在解除其他线程的阻塞状态的之前就调用了await方法的话,那么它也就被阻塞了,那么你的程序就会被挂起了。

       所以何时调用signalAll呢?一般我们会在对象的状态可能会引起等待线程改变的时候去调用signalAll,例如,当一个账户的余额发生改变时,那么这个改变是否会导致等待的线程的余额发生改变呢?这个改变又是否会让等待的线程符合条件呢?这都不好说,所以我们应该在完成一次转账时就调用signalAll。(具体调用的时机,大家多写写先关的程序就有经验了)

       需要注意的是,调用signalAll并不会立即激活一个等待的线程,它仅仅是通知正在等待的线程,这时有可能已经满足了条件,让它再去检测该条件。

       另一个方法signal,则是随机的解除等待集中某个线程的阻塞状态。虽然这会更加的有效,但也存在一定的危险,如果随机选择的线程发现了自己仍然不能运行,那么它再次被阻塞,如果没有其他线程再次调用 signal,那么程序就死锁了。

       下面我们来在刚才的基础上为程序添加条件的约束,代码如下:

import java.util.concurrent.locks.*;

public class Bank {

	private final double[] accounts; // 账户数组
	private Lock bankLock; // 声明一个锁
	private Condition suffcientBalance; // 声明一个条件

	public Bank(int n, double initialBalance) { // 构造方法 初始化账户余额

		accounts = new double[n];
		for (int i = 0; i < accounts.length; i++) {
			accounts[i] = initialBalance;
		}
		bankLock = new ReentrantLock(); // 初始化锁为ReentrantLock
		suffcientBalance = bankLock.newCondition(); // 条件对象为bankLock的条件

	}

	/**
	 * 从一个账户转移一定的钱款到另一个账户
	 *
	 * @param from
	 *            原账户
	 * @param to
	 *            目标账户
	 * @param amount
	 *            钱数
	 */
	public void transfer(int from, int to, double amount)
			throws InterruptedException {
		bankLock.lock(); // 获得锁
		try {
			while (accounts[from] < amount)
				// 判断余额是否符合条件,不符合则阻塞
				suffcientBalance.await();
			System.out.println(Thread.currentThread());
			accounts[from] -= amount; // 从原账户中转出钱款
			System.out.printf(" %10.2f from %d to %d", amount, from, to);
			accounts[to] += amount; // 把转出的钱款转移到目标账户
			System.out.printf("Total Balance: %10.2f%n", getTotalBalance());
			suffcientBalance.signalAll();// 完成一次转账,解除等待线程的阻塞
		} finally {
			bankLock.unlock();// 释放锁
		}

	}

	/**
	 * 统计银行总存款
	 *
	 * @return 总存款
	 */
	public double getTotalBalance() {
		bankLock.lock();
		try {
			double sum = 0;
			for (double a : accounts) {
				sum += a;
			}
			return sum;
		} finally {
			bankLock.unlock();
		}

	}

	public int size() {
		return accounts.length;
	}

}

此时,运行程序我们发现没有出现任何的错误,总余额永远是10W,并且没有任何账户出现过负的余额。

3.synchronized关键字

       前面我讨论了Lock和Condition对象的使用,我们先来简单的总结一下。

  • 锁用来保护代码片段,任何时刻只有一个线程执行被保护的代码。
  • 锁可以管理试图进入被保护代码段的线程
  • 锁可以拥有一个或多个相关的条件对象。
  • 每个条件对象管理那些已经进入被保护的代码段却还不能运行的线程。

       Lock和Condition为我们提供了很不错的锁机制。但是有时我们并不需要使用它们的特性,那么这是就可以使用一种嵌入到JAVA语言内部的机制了。从JAVA1.0版本开始,每个对象其实都拥有自己的一个内部锁。如果一个方法使用synchronized关键字声明的话,那么对象的锁将保护整个方法,使用起来只需要在方法的返回类型之前加上synchronized就可以啦

       内部对象锁只有一个相关条件,wait方法添加一个线程到等待集中,notifyAll/notify方法用来解除等待线程的阻塞状态。

当然内部锁也存在一些局限:

  • 不能中断一个正在试图获得锁的线程。
  • 试图获得锁时不能设定超时。
  • 每个锁仅有单一的条件。

       下面我们就来使用synchronized关键字来修改下我们的程序,我们会发现代码简洁了许多,以下是详细代码:

public class Bank {

	private final double[] accounts; // 账户数组

	public Bank(int n, double initialBalance) { // 构造方法 初始化账户余额

		accounts = new double[n];
		for (int i = 0; i < accounts.length; i++) {
			accounts[i] = initialBalance;
		}

	}

	/**
	 * 从一个账户转移一定的钱款到另一个账户
	 *
	 * @param from
	 *            原账户
	 * @param to
	 *            目标账户
	 * @param amount
	 *            钱数
	 */
	public synchronized void transfer(int from, int to, double amount)
			throws InterruptedException {

		while (accounts[from] < amount)
			// 判断余额是否符合条件,不符合则阻塞
			wait();
		System.out.println(Thread.currentThread());
		accounts[from] -= amount; // 从原账户中转出钱款
		System.out.printf(" %10.2f from %d to %d", amount, from, to);
		accounts[to] += amount; // 把转出的钱款转移到目标账户
		System.out.printf("Total Balance: %10.2f%n", getTotalBalance());
		notify(); //完成一次转账,等待的线程再次检查条件

	}

	/**
	 * 统计银行总存款
	 *
	 * @return 总存款
	 */
	public double getTotalBalance() {

		double sum = 0;
		for (double a : accounts) {
			sum += a;
		}
		return sum;

	}

	public int size() {
		return accounts.length;
	}

}

运行的结果与上一段代码是一样的,但是简化了不少,甚至不用导入任何的包,很爽。

       <font color=red>那么当我们编写并发程序时应该使用哪一种呢?</font>在一般情况下,如果synchronized关键字适合你的程序,那么就推荐使用synchronized,这样可以减少编写的代码数量,减少出错的机率。在需要使用Lock/Condition结构提供的特性,或者synchronized不能满足需求的时候再去使用Lock/Condition。


笔者水平有限,若有错漏,欢迎指正,如果转载以及CV操作,请务必注明出处,谢谢!
版权声明:本文为博主原创文章,未经博主允许不得转载。

打赏一个呗

取消

感谢您的支持,我会继续努力的!

扫码支持
扫码支持
扫码打赏,你说多少就多少

打开支付宝扫一扫,即可进行扫码打赏哦