并发编程:线程安全

  • 一、线程安全
  • 1. 多线程的潜在风险
  • 2. 线程不安全原因分析
  • 二、线程同步
  • 1. 同步代码块
  • 2. 同步方法
  • 3. Lock锁

一、线程安全

如果有多个线程在同时运行,而这些线程可能会同时运行这段代码。程序每次运行结果和单线程运行的结果是一样的,而且其他的变量的值也和预期的是一样的,就是线程安全的。

1. 多线程的潜在风险

电影院的多窗口售票:假设一场电影可以卖100张票。使用线程对象模拟售票窗口,多个线程对象同时执行售票任务。使用 Runnable 接口实现线程类

class RunnableImpl implements Runnable {
    // 定义多线程共享的票源
    private int ticket = 100;
    @Override
    public void run() {
        // 先判断票是否存在
        while (ticket > 0) {
            // 提高安全问题出现的概率,让线程睡眠
            try {
                Thread.sleep(10);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            // 票存在,卖票
            System.out.println(Thread.currentThread().getName() + "正在卖第 " + ticket + " 张票");
            ticket --;
        }
    }
}

class DemoThreadSafety {
    public static void main(String[] args) {
        // 创建 Runnable 实现类对象
        RunnableImpl window = new RunnableImpl();
        // 对一个Runnable实现类对象启动 3 条售票线程
        new Thread(window).start();
        new Thread(window).start();
        new Thread(window).start();
    }
}

运行结果:

Thread-2正在卖第 100 张票
Thread-0正在卖第 100 张票
Thread-1正在卖第 100 张票

……

Thread-2正在卖第 1 张票
Thread-1正在卖第 0 张票
Thread-0正在卖第 -1 张票

2. 线程不安全原因分析

从结果中可以发现两个问题:

  • 相同的票数,比如100这张票被卖了三回

分析原因:在本轮CPU执行权竞争中,三条线程先后抢到CPU执行权,但是恰好分别均执行到打印卖票语句(18行)后就进入睡眠,至少前两个抢到执行权的线程没有执行到 ticket-- 语句。导致在下一轮CPU执行权竞争时,三条线程先后均卖出了同一张票。

  • 不存在的票,比如0票与-1票,是不存在的

分析原因:在仅剩1张票的时候,三条线程先后抢到了 CPU 执行权,然后恰好分别执行到了循环判断语句(9行)后就分别进入睡眠。在此轮中,每个线程在执行 while 语句时,票数均为1>0,所以三条线程此刻均满足进入 while 循环的条件。到下一轮竞争时:

  1. Thread-2 首先抢到 CPU 执行权,继续执行卖掉了第1张票,然后ticket–,然后睡眠;
  2. Thread-1 接着抢到CPU执行权,由于上一轮中已经执行了while的循环判定,那时票数还为1,因此在上一轮中已经判定为可以进入循环,所以 Thread-1 继续执行循环体,但此时票数已经是0了,打印出卖第0张票,接着ticket-- 变为-1;
  3. Thread-0 同理,等到它抢到CPU执行权的时候,ticket 已经变为-1了,所以它的打印结果就是卖第-1张票

以上问题中,几个窗口(线程)票数不同步了,这种问题称为线程不安全

线程安全问题都是由全局变量及静态变量引起的。若每个线程中对全局变量、静态变量只有读操作,而无写操作,一般来说,这个全局变量是线程安全的;若有多个线程同时执行写操作,一般都需要考虑线程同步,否则的话就可能影响线程安全。

二、线程同步

当多个线程访问同一资源,且多个线程中对该资源执行了写操作时,就容易出现线程安全问题。

根据上一节中的案例可知,我们期望的是:当窗口1线程进入操作的时候,窗口2和窗口3线程只能在外等着。只有窗口1操作结束,窗口1和窗口2和窗口3才有机会进入代码去执行。也就是说在某个线程修改共享资源的时候,其他线程不能修改该资源,等待修改完毕同步之后,才能去抢夺CPU资源,完成对应的操作,保证了数据的同步性,解决了线程不安全的现象。

要解决上述多线程并发访问一个资源的安全性问题:也就是解决重复票与不存在票问题,Java 引入了线程同步机制(synchronized)来解决线程安全问题。它的实现方式有三种: 同步代码块同步方法锁机制,以下将分别介绍三种同步机制。

1. 同步代码块

同步代码块:Java 提供了 synchronized 关键字可以用于修饰方法中的某段代码区块,表示只对这个区块的资源实行互斥访问。

格式:

synchronized(同步锁){
    需要同步操作的代码
}

同步锁:对象的同步锁只是一个概念,可以想象为在对象上标记了一个锁。同步锁具有以下特点

  1. 锁对象可以是任意类型
  2. 多个线程对象要使用同一把锁
  3. 在任何时候,最多允许一个线程拥有同步锁。谁拿到锁就执行同步代码,其他的线程只能处于等待状态
  4. 锁对象尽量使用 final 修饰为不可变对象

通常可以使用Object类作为锁对象,实际开发中,锁对象的选择具有一定技巧性,通常会拿代码中现成的不可变对象作为同步锁

class RunnableImpl01 implements Runnable {
    // 定义多线程共享的票源
    private int ticket = 100;
    // 创建一个锁对象
    final Object obj = new Object();
    @Override
    public void run() {
        /*run方法中可以执行很多代码,而同步代码块只是一小部分。
        出去同步代码块都是并发执行的,只不过到了同步代码块才会同步。
        比如,买票之前你会跟柜台小姐姐询问票价等业务,多个柜台同步,只不过在出票这一环节同步*/
        while (true) {
            try {
                Thread.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            synchronized (obj) {       
                if (ticket > 0) {
                    System.out.println(Thread.currentThread().getName() + " 正在卖第 " + ticket + " 张票");
                    ticket--;
                } else {
                    break;
                }
            }
        }
    }
}

class DemoThreadSafety1 {
    public static void main(String[] args) {
        // 创建 Runnable 实现类对象
        Runnable window = new RunnableImpl01();
        // 对一个Runnable实现类对象启动 3 条售票线程
        new Thread(window).start();
        new Thread(window).start();
        new Thread(window).start();
    }
}

运行结果:

Thread-2 正在卖第 100 张票
Thread-0 正在卖第 99 张票
Thread-1 正在卖第 98 张票
Thread-2 正在卖第 97 张票
Thread-0 正在卖第 96 张票
……
Thread-1 正在卖第 4 张票
Thread-2 正在卖第 3 张票
Thread-0 正在卖第 2 张票
Thread-2 正在卖第 1 张票

线程安全问题得到解决

2. 同步方法

同步方法:使用 synchronized 修饰的方法,就叫做同步方法,保证A线程执行该方法的时候,其他线程只能在方法外等待。

格式:

public synchronized void method() {
    // 可能会产生线程安全问题的代码
}

采用同步方法时,谁充当了同步锁的角色?

  • 对于非 static 方法,同步锁就是 this,即对象本身作为同步锁。
  • 对于 static 方法,我们使用当前方法所在类的字节码对象(类名.class)作为同步锁,这里涉及到反射机制,日后补充。

非静态同步方法示例:

class RunnableImpl02 implements Runnable {
    private int ticket = 100;
    @Override
    public void run() {
        while (saleTicket()) {
            try {
                Thread.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
    /*
        同步方法也会将内部代码锁住,只让一个线程执行
        同步方法的锁对象是谁?就是实现类对象本身,也就是this
     */
    private synchronized boolean saleTicket() {    // 同步方法
        if (ticket > 0) {
            System.out.println(Thread.currentThread().getName() + "-->" + "正在卖第 " + ticket + " 张票");
            ticket--;
            return true;
        } else {
            return false;
        }
    }
}

class DemoThreadSafety2 {
    public static void main(String[] args) {
        Runnable run = new RunnableImpl02();
        // 启动3条售票线程
        new Thread(run).start();
        new Thread(run).start();
        new Thread(run).start();
    }
}

静态同步方法示例:

class RunnableImpl02a implements Runnable {
    private static int ticket = 100;                        // 票数使用静态修饰符
    @Override
    public void run() {
        while (saleTicket()) {
            try {
                Thread.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
    /*
        静态同步方法也会将内部代码锁住,只让一个线程执行
        静态同步方法的锁对象是谁? 本类的.class字节码 --> class文件对象(反射)
     */
    private static synchronized boolean saleTicket() {    // 同步方法
        if (ticket > 0) {
            System.out.println(Thread.currentThread().getName() + "-->" + "正在卖第 " + ticket + " 张票");
            ticket--;
            return true;
        } else {
            return false;
        }
    }
}

class DemoThreadSafety2a {
    public static void main(String[] args) {
        Runnable run = new RunnableImpl02a();
        // 启动3条售票线程
        new Thread(run).start();
        new Thread(run).start();
        new Thread(run).start();
    }
}

3. Lock锁

java.util.concurrent.locks.Lock 接口提供了比 synchronized 代码块和 synchronized 方法更广泛的锁定操作,同步代码块/同步方法具有的功能Lock都有,除此之外更强大,更体现面向对象。

Lock接口中的方法:

  • void lock(): 获取锁
  • void unlock(): 释放锁

Lock接口的实现类:

  • java.util.concurrent.locks.ReentrantLock

使用 Lock 锁:

  1. 在成员位置实例化一个 ReentrantLock 对象
  2. 在可能出现线程安全问题的代码前调用 Lock 接口中的方法 lock() 获取锁
  3. 在可能出现线程安全问题的代码后调用 Lock 接口中的方法 unlock() 获取锁

通常,尽量使用 try … catch … finally 形式实现 Lock 锁同步,将 unlock() 方法放在 finally 代码块中执行,这样的好处是:不存线程中是否出现异常,最后总会将锁释放。

class RunnableImpl03 implements Runnable {
    // 定义多线程共享的票源
    private int ticket = 1000;
    // 创建一个 ReentrantLock 对象
    final Lock lock = new ReentrantLock();
    @Override
    public void run() {
        while (true) {
            try {
                Thread.sleep(1);    // 维护线程公平性
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            try {
                lock.lock();    // 获取锁
                if (ticket > 0) {
                    System.out.println(Thread.currentThread().getName() + " 正在卖第 " + ticket + " 张票");
                    ticket--;
                } else {
                    break;
                }
            } catch (Exception e) {
                e.printStackTrace();
            } finally {                                             // 将释放锁放在finally代码块中,无论是否出现异常,最终都会释放线程锁
                lock.unlock();  // 释放锁
            }
        }
    }
}

class DemoThreadSafety3 {
    public static void main(String[] args) {
        // 创建 Runnable 实现类对象
        Runnable window = new RunnableImpl03();
        // 对一个Runnable实现类对象启动 3 条售票线程
        new Thread(window).start();
        new Thread(window).start();
        new Thread(window).start();
    }
}