最全java多线程总结2--如何进行线程同步

  上篇对线程的一些基础知识做了总结,本篇来对多线程编程中最重要,也是最麻烦的一个部分——同步,来做个总结。

  创建线程并不难,难的是如何让多个线程能够良好的协作运行,大部分需要多线程处理的事情都不是完全独立的,大都涉及到数据的共享,本篇是对线程同步的一个总结,如有纰漏的地方,欢迎在评论中指出。

为什么要有同步

  我们来看一个简单的例子,有两个数 num1,num2,现在用 10 个线程来做这样一件事–每次从 num1 中减去一个随机的数 a,加到 num2 上。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
public class Demo1 {
public static void main(String[] args) {
Bank bank = new Bank();
//创建10个线程,不停的将一个账号资金转移到另一个账号上
for (int i = 0; i < 100; i++) {
new Thread(() -> {
while (true) {
int account1 = ((Double) Math.floor(Math.random() * 10)).intValue();
int account2 = ((Double) Math.floor(Math.random() * 10)).intValue();
int num = ((Long) Math.round(Math.random() * 100)).intValue();
bank.transfer(account1, account2, num);
try {
Thread.sleep(((Double) (Math.random() * 10)).intValue());
} catch (Exception e) {
}
}
}).start();
}
}
}

class Bank {
/**
* 10个资金账户
*/
public int[] accounts = new int[10];

public Bank() {
Arrays.fill(accounts, 1000);
}

public void transfer(int from, int to, int num) {
accounts[from] -= num;
accounts[to] += num;
//计算和
int sum = 0;
for (int j = 0; j < 10; j++) {
sum += accounts[j];
}
System.out.println(sum);
}
}

正常情况下,无论什么时候资金账号的和应该都是 10000.然而真的会这样吗?运行程序一段时间后会发现和不等于 10000 了,可能变大也可能变小了。

竞争

  上面的代码中有多个程序同时更新账户信息,因此出现了竞争关系。假设两个线程同时执行下面的一句代码:

1
accounts[account1] -= num;

该代码不是原子性的,可能会被处理成如下三条指令:

  1. 将 accounts[account1]加载到寄存器
  2. 值减少 num
  3. 结果写回到 accounts[account1]

这里仅说明单核心情况下的问题(多核一样会有问题),单核心是不能同时运行两个线程的,如果一个线程 A 执行到第三步时,被剥夺了运行权,线程 B 开始执行完成了整个过程,然后线程 A 继续运行第三步,这就产生了错误,线程 A 的结果覆盖了线程 B 的结果,总金额不再正确。如下图所示:
竞争

如何同步

锁对象

  为了防止并发导致数据错乱,Java 语言提供了 synchronized 关键字,并且在 Java SE 5 的时候加入了 ReentrantLock 类。synchronized 关键字自动提供了一个锁以及相关的条件,这个后面再说。ReentrantLock 的基本使用如下:

1
2
3
4
5
6
7
myLock.lock()//myLock是一个ReetrantLock对象示例
try{
//要保护的代码块
}finally{
//一定要在finally中释放锁
myLock.unlock();
}

  上述结构保证任意时刻只有一个线程进入临界区,一旦一个线程调用 lock 方法获取了锁,其他所有线程都会阻塞在 lock 方法处,直到有锁线程调用 unlock 方法。

  将 ban 类中的 transfer 方法加锁,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class Bank {
/**
* 10个资金账户
*/
public int[] accounts = new int[10];

private ReentrantLock lock = new ReentrantLock();

public Bank() {
Arrays.fill(accounts, 1000);
}

public void transfer(int from, int to, int num) {
try {
lock.lock();
accounts[from] -= num;
accounts[to] += num;
//计算和
int sum = 0;
for (int j = 0; j < 10; j++) {
sum += accounts[j];
}
System.out.println(sum);
} finally {
lock.unlock();
}
}
}

经过加锁,无论多少线程同时运行,都不会导致数据错乱。

  锁是可以重入的,已经持有锁的线程可以重复获取已经持有的锁。锁有一个持有计数(hold count)来跟踪 lock 方法的嵌套调用。每 lock 一次计数+1,unlock 一次计数-1,当 lock 为 0 时锁释放掉。

  可以通过带 boolean 参数构造一个带有公平策略的锁–new ReentrantLock(true)。公平锁偏爱等待时间最长的线程。但是会导致性能大幅降低,而且即使使用公平锁,也不能确保线程调度器是公平的。

条件对象

  通常我们会遇到这样的问题,当一个线程获取锁后,发现需要满足某个条件才能继续往后执行,这就需要一个条件对象来管理已经获取锁但是却不能做有用工作的线程。

  现在来考虑给转账加一个限制,只有资金充足的账户才能作为转出账户,也就是不能出现负值。注意下面的代码是不可行的:

1
2
3
if(bank.accounts[from]>=num){
bank.transfer(from,to,num);
}

因为多线程下极有可能 if 判断成功后,刚好数据被其他线程修改了。

  可以通过条件对象来这样实现判断:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
class Bank {
/**
* 10个资金账户
*/
public int[] accounts = new int[10];

private ReentrantLock lock = new ReentrantLock();
private Condition condition = lock.newCondition();

public Bank() {
Arrays.fill(accounts, 1000);
}

public void transfer(int from, int to, int num) {
try {
lock.lock();
while (accounts[from] < num) {
//进入阻塞状态
condition.await();
}
accounts[from] -= num;
accounts[to] += num;
//计算和
int sum = 0;
for (int j = 0; j < 10; j++) {
sum += accounts[j];
}
System.out.println(sum);
//通知解除阻塞
condition.signalAll();
} catch (Exception e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
}

  在 while 循环中判断是否满足,如果不满足条件,调用await方法进入阻塞状态,同时放弃锁。这样让其他线程有机会给转出账户转入资金也满足判断条件。

  当某一个线程完成转账工作后,应该调用signalAll方法让所有阻塞线程接触阻塞状态,因为此时可能会满足判断条件,可以继续转账操作。

  注意:调用signalAll不会立即激活一个等待线程,仅仅只是接触阻塞状态,以便这些线程可以通过竞争获取锁,继续进行 while 判断。

  还有一个方法signal随机解除一个线程的阻塞状态。这里可能会导致死锁的产生。

synchronized 关键词

  上一节中的 Lock 和 Condition 为开发人员提供了强大的同步控制。但是大多数情况并不需要那么复杂的控制。从 java 1.0 版本开始,Java 中的每个对象都有一个内部锁。如果一个方法用synchronized声明,那么对象的锁将保护整个方法,也就是调用方法时自动获取内部锁,方法结束时自动解除内部锁。

  同 ReentrantLock 锁一样,内部锁也有 wait/notifyAll/notify 方法,对应关系如下:

  • wait 对应 await
  • notifyAll 对应 signalAll
  • notify 对应 signal
    之所以方法名不同是因为 wait 这几个方法是 Object 类的 final 方法,为了不发生冲突,ReentrantLock类中方法需要重命名。

  用 synchronized 实现的 ban 类如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
class Bank {
/**
* 10个资金账户
*/
public int[] accounts = new int[10];

private ReentrantLock lock = new ReentrantLock();
// private Condition condition = lock.newCondition();

public Bank() {
Arrays.fill(accounts, 1000);
}

synchronized public void transfer(int from, int to, int num) {
try {
// lock.lock();
while (accounts[from] < num) {
//进入阻塞状态
// condition.await();
this.wait();
}
accounts[from] -= num;
accounts[to] += num;
//计算和
int sum = 0;
for (int j = 0; j < 10; j++) {
sum += accounts[j];
}
System.out.println(sum);
//通知解除阻塞
// condition.signalAll();
this.notifyAll();
} catch (Exception e) {
e.printStackTrace();
}
// finally {
// lock.unlock();
// }
}
}

  静态方法也可以声明为 synchronized,调用这中方法,获取到的是对应类的类对象的内部锁。

代码中怎么用

  • 最好既不使用 Lock/Condition 也不使用 synchronized 关键字,大多是情况下都可以用 java.util.concurrent 包中的类来完成数据同步,该包中的类都是线程安全的。会在下一篇中讲到。

  • 如果能用 synchronized 的,尽量用它,这样既可以减少代码数量,减少出错的几率。

  • 如果上面都不能解决问题,那就只能使用 Lock/Condition 了。

本篇所用全部代码:github

本文原创发布于:https://www.tapme.top/blog/detail/2019-04-10-20-52

坚持原创技术分享,您的支持将鼓励我继续创作!