多线程(二) 线程同步

一、多线程临界资源的问题。数据不一致问题

又是卖票这个例子,以及多线程下的懒汉式单例模式

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
/**
* @author xia17
* @date 2020/1/2 21:26
*/
public class SellToken {

public static void main(String[] args) {
Thread t1 = new Thread(TokenBox::sell, "线程1");
Thread t2 = new Thread(TokenBox::sell, "线程2");
Thread t3 = new Thread(TokenBox::sell, "线程3");
Thread t4 = new Thread(TokenBox::sell, "线程4");
t1.start();
t2.start();
t3.start();
t4.start();
}

}

class TokenBox{

public static int num = 50 ;

public static void sell(){
while (true){
if (num > 0){
// 线程休眠100毫秒
ThreadUtil.sleep(100);
--num;
System.out.println(Thread.currentThread().getName() + "卖出了一张票,余票:" + num);
}else {
break;
}
}
}

}

执行一下结果如下.

1
2
3
4
5
6
7
线程3卖出了一张票,余票:2
线程2卖出了一张票,余票:1
线程1卖出了一张票,余票:1
线程4卖出了一张票,余票:0
线程3卖出了一张票,余票:0
线程2卖出了一张票,余票:-2
线程1卖出了一张票,余票:-2

我们发现余票出现了负数 这显然是不对的。

why? 这是因为可能出现了线程2在 num >0 判断之后休息了,而线程4将num减一了,最后导致 num出现负数。

二、锁的概念

在并发编程中,经常会遇到多个线程访问同一个共享资源,这时候我们要保证数据的一致性,那么就要用到了锁的概念,给资源加上锁,拿到锁所有权的人才能够进行操作共享资源,没有拿到锁的线程需要等待,等锁的所有权放开。

2.1 synchronized与Lock

Java中有两种加锁的方式:一种是用synchronized关键字,另一种是用Lock接口的实现类。

形象地说,synchronized关键字是自动档,可以满足一切日常驾驶需求。但是如果你想要玩漂移或者各种骚操作,就需要手动档了——各种Lock的实现类。

所以如果你只是想要简单的加个锁,对性能也没什么特别的要求,用synchronized关键字就足够了。自Java 1.5之后,才在java.util.concurrent.locks包下有了另外一种方式来实现锁,那就是Lock。也就是说,synchronized是Java语言内置的关键字,而Lock是一个接口,这个接口的实现类在代码层面实现了锁的功能,具体细节不在本文展开,有兴趣可以研究下AbstractQueuedSynchronizer类,写得可以说是牛逼爆了。

2.2 同步代码块、方法

同步代码块语法: synchronized (锁对象){代码块}

改写TokenBox的sell方法如下

1
2
3
4
5
6
7
8
9
10
11
12
public static void sell(){
while (true){
synchronized (TokenBox.class){
if (num > 0){
--num;
System.out.println(Thread.currentThread().getName() + "卖出了一张票,余票:" + num);
}else {
break;
}
}
}
}

同步方法:在方法前加上synchronized 关键字,如下

1
2
3
4
5
6
7
8
9
10
public synchronized static void sell(){
while (true){
if (num > 0){
--num;
System.out.println(Thread.currentThread().getName() + "卖出了一张票,余票:" + num);
}else {
break;
}
}
}

2.3 显示锁Reentrant

锁必须保证只有一把。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class TokenBox{

private static int num = 50 ;
private static ReentrantLock lock = new ReentrantLock();

public static void sell(){
while (true){
lock.lock();
if (num > 0){
--num;
System.out.println(Thread.currentThread().getName() + "卖出了一张票,余票:" + num);
}else {
break;
}
lock.unlock();
}
}
}

三、死锁的出现

所谓死锁,是指多个进程在运行过程中因争夺资源而造成的一种僵局,当进程处于这种僵持状态时,若无外力作用,它们都将无法再向前推进。 因此我们举个例子来描述,如果此时有一个线程A,按照先锁a再获得锁b的的顺序获得锁,而在此同时又有另外一个线程B,按照先锁b再锁a的顺序获得锁。如下图所示:
img

3.1 死锁

可归结为如下两点:

  • a 竞争资源
    • 系统中的资源可以分为两类:
      • 可剥夺资源,是指某进程在获得这类资源后,该资源可以再被其他进程或系统剥夺,CPU和主存均属于可剥夺性资源;
      • 另一类资源是不可剥夺资源,当系统把这类资源分配给某进程后,再不能强行收回,只能在进程用完后自行释放,如磁带机、打印机等。
    • 产生死锁中的竞争资源之一指的是竞争不可剥夺资源(例如:系统中只有一台打印机,可供进程P1使用,假定P1已占用了打印机,若P2继续要求打印机打印将阻塞)
    • 产生死锁中的竞争资源另外一种资源指的是竞争临时资源(临时资源包括硬件中断、信号、消息、缓冲区内的消息等),通常消息通信顺序进行不当,则会产生死锁
  • b 进程间推进顺序非法
    • 若P1保持了资源R1,P2保持了资源R2,系统处于不安全状态,因为这两个进程再向前推进,便可能发生死锁
    • 例如,当P1运行到P1:Request(R2)时,将因R2已被P2占用而阻塞;当P2运行到P2:Request(R1)时,也将因R1已被P1占用而阻塞,于是发生进程死锁

3.2 死锁产生的4个必要条件?

  1. 互斥条件:进程要求对所分配的资源进行排它性控制,即在一段时间内某资源仅为一进程所占用。
  2. 请求和保持条件:当进程因请求资源而阻塞时,对已获得的资源保持不放。
  3. 不剥夺条件:进程已获得的资源在未使用完之前,不能剥夺,只能在使用完时由自己释放。
  4. 环路等待条件:在发生死锁时,必然存在一个进程–资源的环形链。

3.3 预防死锁

  • 资源一次性分配:一次性分配所有资源,这样就不会再有请求了:(破坏请求条件)
  • 只要有一个资源得不到分配,也不给这个进程分配其他的资源:(破坏请保持条件)
  • 可剥夺资源:即当某进程获得了部分资源,但得不到其它资源,则释放已占有的资源(破坏不可剥夺条件)
  • 资源有序分配法:系统给每类资源赋予一个编号,每一个进程按编号递增的顺序请求资源,释放则相反(破坏环路等待条件)

1、以确定的顺序获得锁

如果必须获取多个锁,那么在设计的时候需要充分考虑不同线程之前获得锁的顺序。按照上面的例子,两个线程获得锁的时序图如下:

img

如果此时把获得锁的时序改成:

img

那么死锁就永远不会发生。 针对两个特定的锁,开发者可以尝试按照锁对象的hashCode值大小的顺序,分别获得两个锁,这样锁总是会以特定的顺序获得锁,那么死锁也不会发生。问题变得更加复杂一些,如果此时有多个线程,都在竞争不同的锁,简单按照锁对象的hashCode进行排序(单纯按照hashCode顺序排序会出现“环路等待”),可能就无法满足要求了,这个时候开发者可以使用银行家算法,所有的锁都按照特定的顺序获取,同样可以防止死锁的发生,该算法在这里就不再赘述了,有兴趣的可以自行了解一下。

2、超时放弃

当使用synchronized关键词提供的内置锁时,只要线程没有获得锁,那么就会永远等待下去,然而Lock接口提供了boolean tryLock(long time, TimeUnit unit) throws InterruptedException方法,该方法可以按照固定时长等待锁,因此线程可以在获取锁超时以后,主动释放之前已经获得的所有的锁。通过这种方式,也可以很有效地避免死锁。 还是按照之前的例子,时序图如下:
img

四、wait 和 notify

wait : 等待,是Object类中的一个方法,当前的线程释放自己的所标记,并且让出CPU资源,使得当前线程进入等待队列。如果该线程没有占有该锁,那么会抛出一个异常。

notify:通知,是Object类中的一个方法,唤醒等待队列中的一个等待指定锁的线程,使这个线程进入锁池。

notifyAll:通知,是Object类中的一个方法,唤醒等待队列中的所有等待指定锁的线程,使这个线程进入锁池。