前言
承接上回,我们已经理解了线程的一些相关操作,本篇内容将会讲到如何去解决线程竞争导致的数据错乱。
前期回顾:线程操作
目录
前言
线程竞争的场景
竞态条件的详解
synchronized 关键字
ReentrantLock 类
线程竞争的场景
概念:
在大多数实际的多线程运用中,两个或者两个以上的线程需要共享存储相同的数据。如果两个线程存取同一个对象,并且每个线程分别调用了一个修改该对象状态的方法,那么会发生什么呢?这两个线程会相互覆盖。取决于线程访问数据的次序,可能会导致线程被破坏。这种情况通常称为 “竞态条件”。
为了让大家更好的理解,这里先举两个例子
例子一:
一个银行账户中有 1000 元,现在有 用户A、用户B分别从这个账户中取钱:那么必然包括两个操作:A向银行取钱,B向银行取钱,如果两次取钱都与银行存款相对应则证明银行的取钱操作是成功。
试想一下,如果这两个操作不具备原子性,假设从 用户A 取走 100 元之后,操作突然终止了,那么 用户A 把钱取走了,但是银行账户的钱并没有减少。这种情况是很严重的,可能会致使银行破产。
上述操作有两个步骤,出现意外后导致取钱失败,说明没有原子性。
原子性的解释:
- 原子性:即一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
- 原子操作:即不会被线程调度机制打断的操作,没有上下文切换。
现在我们可以用代码实现一下以上操作:
class Bank {
// 一个账户有1000块钱
static int money = 1000;
// 柜台 Counter 取钱的方法
public void Counter(int money) {// 参数是每次取走的钱
Bank.money -= money; // 取钱后总数减少
System.out.println("A取走了" + money + "还剩下" + (Bank.money));
}
// ATM取钱的方法
public void ATM(int money) {// 参数是每次取走的钱
Bank.money -= money; // 取钱后总数减少
System.out.println("B取走了" + money + "还剩下" + (Bank.money));
}
}
class PersonA extends Thread {
// 创建银行对象
Bank bank;
// 通过构造器传入银行对象,确保两个人进入的是一个银行
public PersonA(Bank bank) {
this.bank = bank;
}
//重写run方法,在里面实现使用柜台取钱
@Override
public void run() {
while (Bank.money >= 100) {
bank.Counter(100);// 每次取100块
try {
sleep(100); // 取完休息0.1秒
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
class PersonB extends Thread {
// 创建银行对象
Bank bank;
// 通过构造器传入银行对象,确保两个人进入的是一个银行
public PersonB(Bank bank) {
this.bank = bank;
}
// 重写run方法,在里面实现使用柜台取钱
@Override
public void run() {
while (Bank.money >= 200) {
bank.ATM(200);// 每次取200块
try {
sleep(100);// 取完休息0.1秒
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
class MainClass {
public static void main(String[] args) {
System.out.println("一个账户总共有1000块钱");
// 实力化一个银行对象
Bank bank = new Bank();
// 实例化两个人,传入同一个银行的对象
PersonA pA = new PersonA(bank);
PersonB pB = new PersonB(bank);
// 两个人开始取钱
pA.start();
pB.start();
}
}
打印结果:
一个账户总共有1000块钱
B取走了200还剩下700
A取走了100还剩下900
A取走了100还剩下600
B取走了200还剩下600
B取走了200还剩下300
A取走了100还剩下500
B取走了200还剩下100
A取走了100还剩下0
可以看到这里出现了几处错误:比如 B 是第一个取钱的,但是取完之后只剩下 700。程序在运行一段时间,我们发现有一段 A、B 取完钱之后,银行账户余额没有发生改变。如果一个银行采用的是这样的系统,你还会将钱存进这个银行吗?
例子二:
class Test {
public static int Count = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(()->{
for(int i = 0;i<50000;i++){
Count++;
}
});
Thread t2 = new Thread(()->{
for(int i = 0;i<50000;i++){
Count++;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(Count);
}
以上程序运行的结果是什么?如果是单线程的话,估计你会立刻说出答案 -- 100000。但是这里是多线程,我们打印的结果往往是小于 100000 的数字。
关于以上问题的答案我们现在就来揭晓 ~
竞态条件的详解
我们以第二个简单的例子进行讲解,由于 t1 线程在执行时会受到 t2 线程的干扰,所以这不是原子操作。那么 Count++ 这条语句可能就有如下三条指令
(1)把内存中的数据,读取到 cpu 寄存器里 (2)把 CPU 寄存器里的数据 +1 (3)把寄存器的值,写回内存
现在假定 t1 线程执行步骤1和步骤2,然后,它的运行权被强占。这时 t1 寄存器刚从内存中读取到数据并加 1,只差修改内存数据了。再假设现在 t2 线程现在被唤醒,并完美的执行三个步骤:由于 t1 线程并没有修改内存所以 t2 寄存器从内存中读取到的值仍然是 0,然后加 1 修改内存,此时内存的值为 1。最后终于运行 t1 的步骤3,这个时候 t1 寄存器的值就会覆盖 t2 线程所作出的修改,其结果仍然是 1。这相当于运行了两次 for 循环,但是 Count 只增加了 1。
那么如何解决上述问题呢? -- 通常有三种方法:
方法一:由于程序是并发执行的,所以关联线程(两个线程共用一个变量)之间势必会发生竞争,那么我们只需让一个线程等待等一个线程执行完即可。使用 join() 就可以解决问题。
上述代码是让线程 t2 阻塞等待 t1线程结束后再开始运行。结果表明这种方法是可行的。
方法二:可以设置两个变量在不同的线程中运行,最后在整合起来。
class Test { public static int Count1 = 0; public static int Count2 = 0; public static void main(String[] args) throws InterruptedException { Thread t1 = new Thread(()->{ for(int i = 0;i<50000;i++){ Count1++; } }); Thread t2 = new Thread(()->{ for(int i = 0;i<50000;i++){ Count2++; } }); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println(Count1+Count2); } }
synchronized 关键字
第三种方法是本章节重点要求掌握的 -- 同步锁。 Java 提供了一个关键字 synchronized 可以防止并发访问一个代码块。
synchronized 的介绍
它的作用域默认是当前对象,这时锁就是对象,谁拿到这个锁谁就可以运行它所控制的那段代码。如果这个对象有多个 synchronized 方法,其它线程就不能同时访问这个对象中任何一个 synchronized 方法。
class Test { public static int Count = 0; public static void main(String[] args) throws InterruptedException { Thread t1 = new Thread(()->{ for(int i = 0;i<50000;i++){ synchronized (Test.class) { Count++; } } }); Thread t2 = new Thread(()->{ for(int i = 0;i<50000;i++){ synchronized (Test.class) { Count++; } } }); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println(Count); } }
Test.class 的意思是引用当前类的对象。此时程序运行结果就是 100000,我们回去看看给例子一加锁答案将会如何:
程序运行结果:
一个账户总共有1000块钱 A取走了100还剩下900 B取走了200还剩下700 A取走了100还剩下600 B取走了200还剩下400 A取走了100还剩下300 B取走了200还剩下100 A取走了100还剩下0
ReentrantLock 类
synchronized 关键字用于加锁,但这种锁一是很重,二是获取时必须一直等待,没有额外的尝试机制。除了 synchronized 能锁住线程外,Java 5还引入了 ReentrantLock 类。
上述代用如果用 ReentrantLock 写的话,可以这么写:
class Test {
public static int Count = 0;
public static void main(String[] args) throws InterruptedException {
ReentrantLock myLock = new ReentrantLock();
Thread t1 = new Thread(()->{
for(int i = 0;i<50000;i++){
myLock.lock();
try{
Count++;
}finally {
myLock.unlock();
}
}
});
Thread t2 = new Thread(()->{
for(int i = 0;i<50000;i++){
myLock.lock();
try{
Count++;
}finally {
myLock.unlock();
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(Count);
}
}
使用 ReentrantLock 保护代码块的基本结构如下:
myLock.lock();
try{
//Count++;
}finally {
myLock.unlock();
}
这个结构确保了任何时刻只有一个线程进入临界状态。一旦一个线程锁定了对象,任何其他线程都无法通过 lock 语句。当其他线程调用 lock 语句时,它们会处于阻塞状态,直到第一个线程释放这个锁对象。
注意:因为 synchronized 是Java语言层面提供的语法,所以我们不需要考虑异常,而ReentrantLock 是Java代码实现的锁,我们就必须先获取锁,然后在 finally 中正确释放锁(也就是将 unlock 操作包在 finally 语句中),否则其他线程将永远阻塞。
关于更多的锁操作,后续将继续介绍~