同步、锁
Java并发编程中,总是会出现多个线程同时对同一条数据的存取,此时可能因为各个线程访问这条数据的次序的顺序不同而造成数据的错误。
下面通过一个银行转账的例子来说明如何实现多线程同步访问数据。
1.未实现同步的银行转账
首先我们定义银行类Bank
package study_7_15;
import java.util.Arrays;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
/**
* @author 大圣啊
* @date 2021/7/15上午 11:38
* @description:
*/
public class Bank {
private final double[] accounts;
public Bank(int n, double initialBalance) {
accounts = new double[n];
Arrays.fill(accounts, initialBalance);
}
public void transfer(int from, int to, double amount) {
/**
* @Description 银行转账模拟
* @Author 大圣啊
* @Date 2021/7/15 下午 02:19
* @Param [from, to, amount]
* @Return void
* @Exception
*/
if (accounts[from] < amount) {
return;
}
System.out.print(Thread.currentThread());
accounts[from] -= amount;
System.out.print(" 从" + from + "转了" + String.format("%.2f", amount) + "到" + to);
accounts[to] += amount;
System.out.println(" 银行总金额:" + String.format("%.2f", getTotalBalance()));
}
public double getTotalBalance() {
/**
* @Description 获取银行总存款金额
* @Author 大圣啊
* @Date 2021/7/15 下午 02:23
* @Param []
* @Return double
* @Exception
*/
double sum = 0;
for (double a : accounts) {
sum += a;
}
return sum;
}
public int size() {
return accounts.length;
}
}
接下来我们创建测试类来定义100个银行账户,每个账户初始金额1000,不实现同步。
package study_7_15;
import javax.security.auth.login.AccountException;
/**
* @author 大圣啊
* @date 2021/7/15上午 11:34
* @description:
*/
public class UnsynchBankTest {
public static final int NACCOUNTS = 100; //账户总数
public static final double INITIAL_BANANCE = 1000; //银行初始余额
public static final double MAX_ACCOUNT = 1000; //转账基数
public static final int DELAY = 10; //进程休眠时间
public static void main(String[] args) {
Bank bank = new Bank(NACCOUNTS, INITIAL_BANANCE);
for (int i = 0; i < NACCOUNTS; i++) {
int fromAccount = i;
Runnable r = () -> {
try {
while (true) {
int toAccount = (int) (bank.size() * Math.random());
double amount = MAX_ACCOUNT * Math.random(); //转账数额
bank.transfer(fromAccount, toAccount, amount); //转账
Thread.sleep((long) (DELAY * Math.random())); //每次转账完成之后休眠进程,计算所有账户总余额
}
} catch (InterruptedException e) {
}
};
Thread thread = new Thread(r);
thread.start();
}
}
}
下面我们来看运行结果,因为是无限循环,所以需要手动停止。
Thread[Thread-73,5,main] 从73转了751.95到69 银行总金额:100000.00
Thread[Thread-93,5,main] 从93转了529.20到66 银行总金额:100000.00
Thread[Thread-52,5,main]Thread[Thread-44,5,main]Thread[Thread-48,5,main] 从44转了510.68到6 银行总额:98562.82
Thread[Thread-54,5,main] 从54转了976.87到58 银行总金额:98562.82
Thread[Thread-60,5,main] 从60转了32.84到89 银行总金额:98562.82
Thread[Thread-80,5,main] 从80转了554.98到56 银行总金额:98562.82
Thread[Thread-41,5,main] 从41转了367.31到62 银行总金额:98562.82
Thread[Thread-53,5,main] 从53转了367.95到59 银行总金额:98562.82
可以看到账户总余额出现的错误,程序运行一段时间,账户总余额总会变多或变少。每当多个进程同时执行时账户总金额都会发生改变,要么变多要么边少,为什么呢?
我们假设某一时刻有两个进程同时执行指令 accounts[to] += amount ,但是问题就在于该指令并不是原子操作。这个指令的处理过程可能如下:
①将 accounts[to] 加载的寄存器中
②将寄存器中的数据 + amount
③将寄存器中的数据写回 accounts[to]
我们假设进程 1 执行完步骤 ①② 之后被剥夺了 CPU 执行权,此时进程 2 被唤醒执行,当进程 2 执行完步骤 ①②③ 之后,已经修改了寄存器中的数据,此时线程 1 倍唤醒执行步骤 ③,
这样执行之后的结果就会发生改变,进程 1 所执行的 accounts[to] += amount 中的变量 amount 是被其他线程修改过的,这就导致了账户总余额发生了错误。
2.通过 ReentrantLock 实现同步
通过ReentrantLock来保护代码块实现同步的基本结构如下:
myLock.lock(); //一个ReentrantLock对象
try{
//需要保护的代码块
}
finally{
myLock.unlock(); //确保执行unLock()方法,即使程序抛出异常
}
这样的结果可以确保任何时刻只有一个线程进入临界区,一旦一个线程*了锁对象,其他任何线程都无法通过lock语句。当其他线程调用lock时,它们会被阻塞,知道第一个线程释放锁对象。
上面的结构中,将unLock()放在finally中是必要的,如果在临界区中抛出了异常,此时锁对象必须被释放,否则其他的线程将被永久阻塞。
了解了ReentrantLock的结构,那么下面就用它来实现对我们代码块的保护。
package study_7_15;
import java.util.Arrays;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
/**
* @author 大圣啊
* @date 2021/7/15上午 11:38
* @description:
*/
public class Bank {
private final double[] accounts;
private Lock bankLock;
private Condition sufficientFunds;
public Bank(int n, double initialBalance) {
/**
* @Description 银行转账模拟
* @Author 大圣啊
* @Date 2021/7/15 下午 02:19
* @Param [from, to, amount]
* @Return void
* @Exception
*/
accounts = new double[n];
Arrays.fill(accounts, initialBalance);
bankLock = new ReentrantLock();
}
public void transfer(int from, int to, double amount) throws InterruptedException{
if (accounts[from] < amount) {
return;
}
bankLock.lock();
try {
System.out.print(Thread.currentThread());
accounts[from] -= amount;
System.out.print(" 从" + from + "转了" + String.format("%.2f", amount) + "到" + to);
accounts[to] += amount;
System.out.println(" 银行总金额:" + String.format("%.2f", getTotalBalance()));
}finally {
bankLock.unlock();
}
}
public double getTotalBalance(){
/**
* @Description 获取银行总存款金额
* @Author 大圣啊
* @Date 2021/7/15 下午 02:23
* @Param []
* @Return double
* @Exception
*/
bankLock.lock();
try {
double sum = 0;
for (double a : accounts) {
sum += a;
}
return sum;
}finally {
bankLock.unlock();
}
}
public int size(){
return accounts.length;
}
}
现在我们来看程序的运行结果
Thread[Thread-84,5,main] 从84转了474.38到68 银行总金额:100000.00
Thread[Thread-60,5,main] 从60转了568.12到22 银行总金额:100000.00
Thread[Thread-2,5,main] 从2转了359.58到75 银行总金额:100000.00
Thread[Thread-66,5,main] 从66转了286.62到78 银行总金额:100000.00
Thread[Thread-82,5,main] 从82转了435.10到9 银行总金额:100000.00
Thread[Thread-0,5,main] 从0转了48.10到97 银行总金额:100000.00
Thread[Thread-75,5,main] 从75转了968.66到90 银行总金额:100000.00
Thread[Thread-4,5,main] 从4转了412.57到67 银行总金额:100000.00
Thread[Thread-4,5,main] 从4转了195.58到83 银行总金额:100000.00
Thread[Thread-74,5,main] 从74转了386.82到49 银行总金额:100000.00
Thread[Thread-6,5,main] 从6转了210.35到71 银行总金额:100000.00
此时我们发现账户总余额并不会发生异常,不会上面的程序中出现多个进程同时执行代码块的情况。
但是此时还存在一个问题,就是有些账户会出现负值,当账户余额小于装出金额时我们应该对该线程进行阻塞,直到余额大于等于转出金额时才释放该线程。通过 newCondition 方法获得一个条件对象来控制余额非负。
package study_7_15;
import java.util.Arrays;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
/**
* @author 大圣啊
* @date 2021/7/15上午 11:38
* @description:
*/
public class Bank {
private final double[] accounts;
private Lock bankLock;
private Condition sufficientFunds;
public Bank(int n, double initialBalance) {
/**
* @Description 银行转账模拟
* @Author 大圣啊
* @Date 2021/7/15 下午 02:19
* @Param [from, to, amount]
* @Return void
* @Exception
*/
accounts = new double[n];
Arrays.fill(accounts, initialBalance);
bankLock = new ReentrantLock();
sufficientFunds = bankLock.newCondition();
}
public void transfer(int from, int to, double amount) throws InterruptedException{
if (accounts[from] < amount) {
return;
}
bankLock.lock();
try {
while (accounts[from]<amount){
sufficientFunds.await();
}
System.out.print(Thread.currentThread());
accounts[from] -= amount;
System.out.print(" 从" + from + "转了" + String.format("%.2f", amount) + "到" + to);
accounts[to] += amount;
System.out.println(" 银行总金额:" + String.format("%.2f", getTotalBalance()));
sufficientFunds.signalAll();
}finally {
bankLock.unlock();
}
}
public double getTotalBalance(){
/**
* @Description 获取银行总存款金额
* @Author 大圣啊
* @Date 2021/7/15 下午 02:23
* @Param []
* @Return double
* @Exception
*/
bankLock.lock();
try {
double sum = 0;
for (double a : accounts) {
sum += a;
}
return sum;
}finally {
bankLock.unlock();
}
}
public int size(){
return accounts.length;
}
}
此时就可以实现不存在账户为负的情况。
3.通过 synchronized 关键字实现同步
上面通过 Lock 和 Condition 对象来完成同步效果,接下来我们通过 synchronized 来实现同步。
如果一个方法用 synchronized 关键字声明,那么对象的锁将保护整个方法。也就是说,要调用该方法,必须获得内部的对象锁。
public synchronized void method (){
//受保护的代码块
}
这段代码就等价于
publicvoid method(){
this.intrinsicLock.lock();
try{
//受保护的代码块
}
finally{
this.intrinsicLock.unLock();
}
}
我们可以将 Bank 的 transfer 声明为 synchronized,而不是使用一个显式的锁。
下面我们来实现。
package study_7_15;
import java.util.Arrays;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
/**
* @author 大圣啊
* @date 2021/7/15上午 11:38
* @description:
*/
public class Bank {
private final double[] accounts;
public Bank(int n, double initialBalance) {
/**
* @Description 银行转账模拟
* @Author 大圣啊
* @Date 2021/7/15 下午 02:19
* @Param [from, to, amount]
* @Return void
* @Exception
*/
accounts = new double[n];
Arrays.fill(accounts, initialBalance);
}
public synchronized void transfer(int from, int to, double amount) throws InterruptedException {
if (accounts[from] < amount) {
return;
}
try {
while (accounts[from] < amount) {
wait();
}
System.out.print(Thread.currentThread());
accounts[from] -= amount;
System.out.print(" 从" + from + "转了" + String.format("%.2f", amount) + "到" + to);
accounts[to] += amount;
System.out.println(" 银行总金额:" + String.format("%.2f", getTotalBalance()));
} finally {
notify();
}
}
public synchronized double getTotalBalance() {
/**
* @Description 获取银行总存款金额
* @Author 大圣啊
* @Date 2021/7/15 下午 02:23
* @Param []
* @Return double
* @Exception
*/
double sum = 0;
for (double a : accounts) {
sum += a;
}
return sum;
}
public int size() {
return accounts.length;
}
}
此时我们来看程序运行结果。
Thread[Thread-50,5,main] 从50转了85.71到94 银行总金额:100000.00
Thread[Thread-84,5,main] 从84转了327.18到96 银行总金额:100000.00
Thread[Thread-12,5,main] 从12转了113.29到14 银行总金额:100000.00
Thread[Thread-99,5,main] 从99转了374.85到68 银行总金额:100000.00
Thread[Thread-83,5,main] 从83转了587.17到13 银行总金额:100000.00
Thread[Thread-93,5,main] 从93转了160.65到68 银行总金额:100000.00
Thread[Thread-72,5,main] 从72转了254.90到78 银行总金额:100000.00
Thread[Thread-96,5,main] 从96转了27.94到50 银行总金额:100000.00
可以看到,synchronized 和 显式的锁实现的效果一样,但是显然 synchronized 实现的代码更简洁。
参考书籍:《Java 核心技术卷I 基础知识》