多线程并发基础(4)线程同步

252 阅读7分钟

1. 写在前面

为什么需要线程同步?当多个线程访问同一个资源时,会出现问题,这时候我们就需要线程同步。

2. 先理解下线程同步

先不急着学习晦涩难懂的概念,可以先尝试从生活角度,理解线程同步。

假如,我们去上厕所,人非常非常多,所有要上厕所的人会自觉排成一个长队,然后轮到谁的时候,就会进入厕所,并锁门。这个例子应该很常见同时也很容易理解吧。

这里,有两个关键点,一个是排队,一个是锁门。排队,是为了不让大家一窝蜂的来争抢,锁门,是加上最后的保障,假如有个别没素质的人,不排队直接来用厕所呢,对吧,这时候锁了门,就可以避免这个问题。

线程同步,和这个例子可以说是一模一样。我们可以把等待上同一个厕所的人们,理解为等待使用同一个资源的线程们,排队,理解为线程排队,锁上厕所门,理解为线程访问到这个资源时,就会上锁,不让其他线程访问。

ok,这就是生活角度的线程同步。

再总结下,当多个线程访问同一个资源时,如果没有让线程们排队并且给访问资源加锁,那么就会出现问题。“排队”与“加锁”,就是线程同步的两个关键点。也可以说,就是线程同步的两个方面,而且两个方面不可缺其一。

3. 先来看个线程不安全的例子

所谓线程不安全,说白了就是多个线程在访问同一个资源的时候,没有排队与加锁。 这个例子其实之前举过,就是买票的例子。直接看代码

public class Main {
    public static void main(String[] args) throws InterruptedException {
        Runnable ticketSellSystem = new TicketSellSystem();
        
        Thread person1 = new Thread(ticketSellSystem);
        Thread person2 = new Thread(ticketSellSystem);
        Thread person3 = new Thread(ticketSellSystem);
        
        person1.start();
        person2.start();
        person3.start();
    }
}

class TicketSellSystem implements Runnable {
    int ticketNum = 10; // 总共有10张票
    @Override
    public void run() {
        if (ticketNum <= 0) {
            System.out.println("票已经为0了,买不了了");
            return;
        }
        while (ticketNum > 0) {
            try {
                Thread.sleep(300);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(Thread.currentThread().getName() + " 买到了第 " + ticketNum-- + " 张票");
        } 
    }
}

输出结果

image.png

输出结果分析

我们发现,有两个问题,一是票没有按照从10递减的顺序卖出,二是居然有第0张票、第-1张票卖出。

那么其实这两个问题,都是一个原因,就是这段代码没有添加线程同步机制。这就是典型的线程不安全的例子,多个线程访问了同一个资源,在这里就是同一个TicketSellSystem对象。并且多个线程之间没有排队,当前访问资源的线程也没有进行加锁。

下面我们来简单分析下,为什么会出现第-1张票卖出的情况。

假设当前只有最后一张票了,然后有两个线程同时走到这里

image.png 第一个线程和第二个线程这时候都以为票是充足的,都有一张,那么他们同时买(也就是对ticketNum同时执行减1操作),就会让ticketNum从1变成-1.从根本上说,就是这里两个线程没有排队,而且一个线程在买的时候,没有锁住这个变量,其他线程依然可以访问,

4. 线程同步关键字:synchronized

那么有没有方式可以解决这个问题呢?当然有,基本最常用的,就是synchronized关键字了。

它可以锁方法,可以锁代码块。锁方法的时候默认就是锁的this这个对象,锁代码块的时候可以手动传入任何一个对象,但推荐使用共享对象。另外,如果锁作用在静态资源上,那一般是对整个类上锁。

比如上述代码,只需要加一行这个,就可以解决问题

image.png 或者,也可以这样

image.png

两种方式都可以解决问题。

但我们一般都会尽量减小synchronized的作用范围,因为它一旦锁住某个资源,那么其他所有需要访问这个资源的线程都需要排队,而且加锁释放锁也需要进行上下文切换,都很耗费资源,降低效率。一般我们只锁需要“写”的区域,“只读”的区域一般不锁。

5. Lock锁

synchronized功能基本是一样的,都是为了线程同步而产生。只不过它可以显式的加锁和释放锁,而synchronized是隐式的。使用上基本就这个区别

还是直接看代码,首先看一段线程不安全的例子,其实还是上面买票的那个例子



public class Main {
    public static void main(String[] args) throws InterruptedException {
        Runnable ticketSellSystem = new TicketSellSystem();
        Thread person1 = new Thread(ticketSellSystem);
        Thread person2 = new Thread(ticketSellSystem);
        Thread person3 = new Thread(ticketSellSystem);
        
        person1.start();
        person2.start();
        person3.start();
    }
}
class TicketSellSystem implements Runnable {
    int ticketNum = 10;
    @Override
    public void run() {
        while (true) {
            if (ticketNum > 0) {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread().getName() + " 买到了第 " + ticketNum-- + " 张票");
            } else {
                return;
            }
        }
    }
}

输出结果

image.png

lock

image.png 记得这里最好用try、finally来写,以保证最终锁的释放。同时,一般用Lock的实现类ReentrantLock来实现加锁与释放锁的操作。

6. 死锁

当两个线程互相持有对方想要的资源,且都不会释放时,造成的互相僵持的现象。如果没有外力干涉,则程序永远无法继续推进。

下面来举个非常形象的例子。 假如有两个小孩,小孩A在玩电脑,小孩B在玩手机。后来,玩电脑的小孩A想要玩手机,结果发现手机被小孩B占有着。同时,玩手机的小孩B想要玩电脑,结果发现电脑被小孩A占有着。这时候,小孩A不会放弃自己的电脑,小孩B也不会放弃自己的手机,造成了一种互相僵持的现象,就是死锁。

有人说,这多简单,同时把资源给对方不就行了么?对,放到人类世界,确实是这样,但程序可没有这么聪明,虽然计算机很牛,但在这里,确实有点傻傻的。

看代码

public class Main {
    public static void main(String[] args) throws InterruptedException {
        Child a = new Child(0); // 小孩A默认在玩电脑
        Child b = new Child(1); // 小孩B默认在玩手机
        
        a.start();
        b.start();
    }
}
// 电脑
class Computer {
    
}

// 手机
class Phone {
    
}

class Child extends Thread {
    static final Computer computer = new Computer(); // 电脑只有一个,所以是静态的
    static final Phone phone = new Phone(); // 手机只有一个,所以是静态的
    
    private final int curPlayWhat; // 小孩此时玩的啥
    
    public Child(int curPlayWhat) {
        this.curPlayWhat = curPlayWhat;
    }
    
    @Override
    public void run() {
        if (curPlayWhat == 0) {
            // 说明这个小孩是玩电脑的小孩
            synchronized (computer) {
                System.out.println(Thread.currentThread().getName() + " 在玩电脑");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                // 玩了1s之后,还想要玩手机
                synchronized (phone) {
                    
                }
            }
        } else {
            // 说明这个小孩是玩手机的小孩
            synchronized (phone) {
                System.out.println(Thread.currentThread().getName() + " 在玩手机");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                // 玩了1s之后,还想要玩电脑
                synchronized (computer) {
                    
                }
            }
        }
    }
}

我们会发现程序执行到这,就执行不下去了

image.png

说明,僵持住了。这就是死锁现象。

死锁有四个必要条件:互斥、请求和保持、循环等待、不可剥夺(具体什么含义就不在这里介绍了),解决死锁的方式有很多种,比如预防死锁的银行家算法,避免死锁的话,可以从四个必要条件着手。假如我们破坏“请求和保持”条件,那么上面的代码,就可以这样来解除死锁

image.png 执行结果就是这样了 image.png

7. 总结一下吧

本篇文章主要介绍了线程同步的相关知识,比如需要线程同步的原因,实现线程同步的两种方式。同时,也通过实例介绍了死锁。