今天聊聊线程安全问题(值得一读)

304 阅读6分钟

这是我参与8月更文挑战的第23天,活动详情查看:8月更文挑战

image.png

今天我们聊聊线程安全的问题,很多小伙伴在工作过程当中,经常会用到多线程,面试必问也是多线程。 多线程的知识,本章不多解释,后面会出专栏聊多线程。

个人觉得在线程安全问题,是入门多线程的基础,因为你用多线程却不知道解决什么问题。不知其所以然。

希望本章耐心看下来,如果觉得你get到了,麻烦点赞,关注 未来我们共同成长,我会经常出一些干货文章,全是跟我们工作息息相关的。感谢关注

话不多说,进入正题

抛出问题,什么是线程安全

谈线程安全问题,肯定得先知道什么是线程安全,我个人是这样理解的,那就是原子性、可见性

了解多线程的对这个词不会陌生,可见性就是多个线程共同访问一个资源,资源被改掉,比如将int i=0 改成int i = 1,那么这个1对所有线程都是可见的(所有线程都知道改成了1)

基本上所有的操作都不具有原子性,原子性我就不说了,要么该成功,要么全部回滚,恢复成改之前的样子。

《Java Concurrency In Practice》的作者 Brian Goetz 对线程安全是这样理解的,当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行问题,也不需要进行额外的同步,而调用这个对象的行为都可以获得正确的结果,那这个对象便是线程安全的。

读语法太抽象 我们用代码演示下:我个人总结了两点线程安全问题(可能还有其他,但是下面这两个应该是碰到最多的)

  • 多线程赋值问题(这个问题应该是碰到最多的)

  • 争抢“锁”造成死锁、饥饿锁问题

下面我们用实际案例来深入理解体会下

代码演示(多线程赋值问题)

首先,来看多线程同时操作一个变量导致的运行结果错误。


//多线程赋值 操作同一资源
public class ThreadVoluationResult {
 
   volatile static int i;

   public static void main(String[] args) throws InterruptedException {
       Runnable r = new Runnable() {
           @Override
           public void run() {
               for (int j = 0; j < 10000; j++) {
                   i++;
               }
           }
       };
       Thread thread1 = new Thread(r);
       thread1.start();
       Thread thread2 = new Thread(r);
       thread2.start();
       thread1.join();
       thread2.join();
       System.out.println(i);
    }
}

如代码所示,首先定义了一个 int 类型的静态变量 i,然后启动两个线程,分别对变量 i 进行 10000 次 i++ 操作。理论上得到的结果应该是 20000;

实则不然。结果可能是是12996,也可能是13327。这就是今天的第一个问题。原因如下

  • 是因为在多线程下,CPU 的调度是以时间片为单位进行分配的,每个线程都可以得到一定量的时间片

  • 但如果线程拥有的时间片耗尽,它将会被暂停执行并让出 CPU 资源给其他线程,这样就有可能发生线程安全问题。比如 i++ 操作,表面上看只是一行代码,但实际上它并不是一个原子操作,它的执行步骤主要分为三步,而且在每步操作之间都有可能被打断。

下面看幅图。我们都知道线程修改资源 分为三大步。

1、第一是读取

2、第二修改(增加)

3、第三保存

image.png

  • 我们根据箭头指向依次看,线程 1 首先拿到 i=1 的结果,然后进行 i+1 操作,但此时 i+1 的结果并没有保存下来,线程 1 就被切换走了,于是 CPU 开始执行线程 2,它所做的事情和线程 1 是一样的 i++ 操作,但此时我们想一下,它拿到的 i 是多少?实际上和线程 1 拿到的 i 的结果一样都是 1,为什么呢?因为线程 1 虽然对 i 进行了 +1 操作,但结果没有保存,所以线程 2 看不到修改后的结果。

  • 然后假设等线程 2 对 i 进行 +1 操作后,又切换到线程 1,让线程 1 完成未完成的操作,即将 i+1 的结果 2 保存下来,然后又切换到线程 2 完成 i=2 的保存操作,虽然两个线程都执行了对 i 进行 +1 的操作,但结果却最终保存了 i=2 的结果,而不是我们期望的 i=3,这样就发生了线程安全问题,导致了数据结果错误,这也是最典型的线程安全问题。

OK 看到这里 应该理解透彻了吧。接下来我们那分析第二个问题

代码演示(争抢“锁”)产生的线程安全问题

争抢“锁”造成死锁、饥饿锁问题

什么是死锁呢,死锁问题就是程序始终得不到运行的最终结果,相比于第一种线程安全问题带来的数据错误,死锁问题带来的后果可能更严重,比如发生死锁会导致程序完全卡死,无法向下运行。

比如两个线程之间相互等待对方资源,但同时又互不相让,都想自己先执行,如代码所示。


//死锁产生的线程安全问题
public class ThreadDeadLock {
 
    Object o1 = new Object();
    Object o2 = new Object();
 
    public void thread1() throws InterruptedException {
        synchronized (o1) {
            Thread.sleep(500);
            synchronized (o2) {
                System.out.println("线程1成功拿到两把锁");
           }
        }
    }
 
    public void thread2() throws InterruptedException {
        synchronized (o2) {
            Thread.sleep(500);
            synchronized (o1) {
                System.out.println("线程2成功拿到两把锁");
            }
        }
    }
 
    public static void main(String[] args) {
        MayDeadLock mayDeadLock = new MayDeadLock();
        new Thread(new Runnable() {
            @Override
            public void run() {
                try {
                    mayDeadLock.thread1();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }).start();
        new Thread(new Runnable() {
            @Override
            public void run() {
                try {
                    mayDeadLock.thread2();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }).start();
    }
}

首先,代码中创建了两个 Object 作为 synchronized 锁的对象,线程 1 先获取 o1 锁,sleep(500) 之后,获取 o2 锁;线程 2 与线程 1 执行顺序相反,先获取 o2 锁,sleep(500) 之后,获取 o1 锁。  

假设两个线程几乎同时进入休息,休息完后,线程 1 想获取 o2 锁,线程 2 想获取 o1 锁,这时便发生了死锁,两个线程不主动调和,也不主动退出,就这样死死地等待对方先释放资源,导致程序得不到任何结果也不能停止运行。

这就是经典的死锁问题

那还有一个饥饿锁。该锁是什么,顾名思义,就是一直拿不到线程锁。线程需要某些资源时始终得不到,尤其是CPU 资源,就会导致线程一直不能运行而产生的问题。

总结

线程安全问题主要有以下 2 种:

  • 第一种是 i++ 等情况导致的运行结果错误,通常是因为并发读写导致的

  • 第二种线程安全问题就是活跃性问题,包括死锁、活锁和饥饿。

线程安全问题,在我们的工作过程当中是经常碰到的,解决方式会有很多,后面我会持续输出干货。

欢迎大家关注,点赞!

OK 今天就到这里