线程的状态和线程安全问题

线程的状态

在 Java 中, 对线程的状态做出了细分

线程的所有状态

在 Thread 类中, 线程的状态是一个枚举的类型 Thread.State, 可以通过以下的代码来打印出在 Java 中线程的所有状态

public class ThreadDemo10 {
    public static void main(String[] args) {
        for (Thread.State state : Thread.State.values()) {
            System.out.println(state);
        }
    }
}

打印出如下结果:

可以看出, 线程有如下几个状态:

  1. NEW: 创建了 Thread 对象, 但是还没调用 start (内核中没有创建对应的 PCB)
  2. TERMINATED: 表示内核中的 PCB 已经执行完毕, 但是 Thread 对象还在
  3. RUNNABLE: 可运行的(正在 CPU 上执行的 和 在就绪队列中准备由 CPU 调度的)
  4. WAITING: 处于不同原因的阻塞状态
  5. TIMED_WAITING: 处于不同原因的阻塞状态
  6. BLOCKED: 处于不同原因的阻塞状态
线程的状态转换


可以通过代码来得到线程启动之前和结束之后的状态转换:

public class ThreadDemo11 {
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(() -> {
            for (int i = 0; i < 100_0000; i++) {
                // 啥都不干
            }
        });

        // 启动之前, 获取 t 的状态 (NEW)
        System.out.println("start 之前: " + t.getState());

        t.start();
        System.out.println("t 执行中的状态: " + t.getState());
        t.join();

        // 线程执行完毕之后 (TERMINATE)
        System.out.println("t 结束之后: " + t.getState());
    }
}

得到如下结果:

查看 TIME_WAITING 状态(在线程实现过程中休眠):

public class ThreadDemo11 {
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(() -> {
            for (int i = 0; i < 100_0000; i++) {
                try {
                    Thread.sleep(10);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        t.start();
        for (int i = 0; i < 1000; i++) {
            System.out.println("t 执行中的状态: " + t.getState());
        }
        t.join();
    }
}

得到以下结果:

可以看出, RUNNABLE(执行中未被休眠) 和 TIME_WAITING(执行中处于休眠状态) 交替打印.

得到结论:

  1. 线程启动之前 (即线程对象刚创建, PCB 还未创建) 的状态为 NEW 状态
  2. 线程执行过程中 (线程的任务中不能进行休眠操作) 时的状态为 RUNNABLE 状态
  3. 线程执行过程中(当线程被休眠的时候) 时的状态 为 TIME_WAITING 状态
  4. 线程执行完毕之后 (PCB 释放了, 但是线程对象还在 (因为 main 线程还未结束) ) 时的状态为TERMINATED 状态

当前只介绍上述四个状态, 剩下两个状态(WAITING / BLOCKED)在后面的内容会介绍.

拓展:
TERMINATED 状态有什么用吗?

其实 TERMINATED 状态并没有什么实际意义上的作用, 只是起到了一个标识的作用.
在 Java 中, 之所以存在 TERMINATED 状态是因为迫不得已, Java 中有着对象的生命周期这个规则, 但是这个规则与系统内核中的线程并非一致. 所以内核中的线程释放的时候, 并不能保证 Java 中的代码中的 Thread 对象也立即被释放. 因此就势必会存在着当 PCB 已经被销毁了, 但是 Java 中的对象依然存在的情况, 此时就需要一个特定的状态来把这个 Thread 对象标识成 “无效的”, 这就是 “TERMINATED” 状态的用处

一个线程被标记为 TERMINATED 状态的时候, 就不能再次 start 了.(一个线程只能 start 一次)

多线程对比单线程效率的提升

通过下列的案例来演示多线程对比单线程效率的提升

// 通过这个代码来演示多线程和单线程相比效率的提升
public class ThreadDemo12 {
    // 串行执行(一个线程完成)
    public static long serial() {
        // 加上一个计时操作
        long begin = System.currentTimeMillis();

        long a = 0;
        for (long i = 0; i < 100_0000_0000L; i++) {
            a++;
        }

        long b = 0;
        for (long i = 0; i < 100_0000_0000L; i++) {
            b++;
        }

        long end = System.currentTimeMillis();
        return end - begin;
    }

    // 并发执行(两个线程完成)
    public static long concurrency() throws InterruptedException {
        // 使用两个线程分别完成自增
        Thread t1 = new Thread(() -> {
           long a = 0;
            for (long i = 0; i < 100_0000_0000L; i++) {
                a++;
            }
        });

        Thread t2 = new Thread(() -> {
            long b = 0;
            for (long i = 0; i < 100_0000_0000L; i++) {
                b++;
            }
        });
        // 计时
        long begin = System.currentTimeMillis();
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        long end = System.currentTimeMillis();
        return end - begin;
    }

    public static void main(String[] args) throws InterruptedException {
        // 假设目前有两个变量, 需要把两个变量各自自增 1000w 次 (典型的 CPU 密集型场景)
        // 可以一个线程, 先针对 a 自增, 再针对 b 自增
        long serialTime = 0;
        // 取三次平均值
        for (int i = 0; i < 3; i++) {
            serialTime += serial();
        }
        System.out.println("[serial] 单线程版本执行时间: " + serialTime/3 + " ms");
        // 还可以两个线程, 分别对 a 和 b 自增

        long concurrencyTime = 0;
        // 取三次平均值
        for (int i = 0; i < 3; i++) {
            concurrencyTime += concurrency();
        }
        System.out.println("[serial] 单线程版本执行时间: " + concurrencyTime/3 + " ms");
    }
}

得到结果:

可以看出, 多线程的执行效率比单线程的执行效率要高.

多线程的风险 - 线程安全问题

多线程程序的执行过程是: 抢占式执行, 随机调度.
如果不是多线程的程序, 代码的执行顺序只有一条顺序, 代码的顺序是固定的, 那么程序的结果就是固定的. 但是在多线程程序的环境下, 多个线程抢占式执行带来的后果是: 代码执行的顺序是不固定的, 会出现更多的变数, 最终的结果就由一种情况变成了无数种情况.
只要有一种情况下代码的结果不正确, 这个程序就有线程安全问题.

案例

这里给出一个经典的线程安全问题的案例
创建一个 Counter 类, 里面有一个成员变量 count 以及一个自增方法 add, 在 main 方法中创建两个线程, 分别对调用 5w 次 add 方法, 观察最终 count 的结果.

class Counter {
    public int count = 0;

    public void add() {
        count++;
    }
}

public class ThreadDemo13 {
    public static void main(String[] args) {
        Counter counter = new Counter();

        // 创建两个线程, 两个线程分别针对 counter 调用 50000 次的 add 方法
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                counter.add();
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                counter.add();
            }
        });
        // 启动线程
        t1.start();
        t2.start();

        // 等待两个线程结束
        try {
            t1.join();
            t2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        // 打印最终 count 的值
        System.out.println("count = " + counter.count);
    }
}

多次运行查看最终结果:

这三次运行的结果都是由同一个代码运行产生的, 但是结果都不一样, 并且最终的正确结果应该是 10w, 而这些结果都离正确结果相差甚远.

分析

为什么上面的程序会出现不符合预期的结果, 并且每次运行的结果都是不固定的呢?

我们先来看一下 count++ 这个操作:
根据以往的学习, count++ 这个操作本质上分为 3 个步骤:

  1. 先把内存中的值读取到 CPU 的寄存器中. (load)
  2. 把 CPU 寄存器里的数值进行 +1 运算. (add)
  3. 把得到的结果写到内存中. (sava)

这三个操作就是 CPU 上执行的三个指令.

如果两个线程并发执行 count++ 操作, 此时就相当于两组 load add save 操作同时进行, 此时不同的线程调度顺序就会产生结果上的差异.

上图就是其中一种可能性, 但是线程是并发执行的, 也可能有其它的可能性
在这里插入图片描述

可以看出, 这两个线程进行自增操作的指令执行的顺序, 有非常多种可能性, 但是基本上都是有线程安全问题的情况.

我们来以下图的一种情况为例, 来分析一下最终的结果:

上图中 t1 和 t2 两个线程分别进行了一次自增操作, 假设 count 的初始值为 0.

  1. t1 先将内存中 count 的值读取到寄存器中, 也就是目前 t1 线程的寄存器中的 count 值为 0.
  2. t2 也将内存中 count 的值读取到寄存器中, 也就是目前 t2 线程的寄存器中的 count 值为 0.
  3. t2 进行 add 操作, 将 t2 中寄存器的 count 值进行 +1 操作, 此时 t2 寄存器中的 count 值 为 1.
  4. t2 进行 save 操作, 将 t2 寄存器中的 count 值写回到内存中, 此时内存中的 count 值 为 1.
  5. t1 进行 add 操作, 将 t1 中寄存器的 count 值进行 +1 操作, 此时 t1 寄存器中的 count 值为1.
  6. t1 进行 save 操作, 将 t1 寄存器中的 count 值写回到内存中, 此时最终内存中的值为1.

经过上面两个线程分别对 count 值进行 count++ 操作, 一共进行两次, 最终正确的结果应该是 2, 但是根据上面这种情况的分析, 得到的结果是 1, 并不符合预期, 所以得到结论是上面的情况是有线程安全问题的.

原因

到底什么情况会出现线程安全问题?
造成线程安全的原因主要有以下几点:

  1. [根本原因] 线程抢占式执行, 随机调度.
  2. 代码结构 (多个线程同时修改一个变量)
  3. 原子性 (如果修改操作是非原子的, 出现问题的概率非常高)
  4. 内存可见性问题
  5. 指令重排序问题

上述给出的是 5 个典型原因, 并不是全部, 一个代码究竟是线程安全还是不安全, 都得具体问题具体分析, 很难一概而论

解决方法

如何从原子性入手, 来解决线程安全问题
可以通过加锁, 来把不是原子的一系列操作转换为 “原子” 的, 从而解决线程安全问题

想要解决案例中的 add 操作非原子性的问题, 可以修改一下 Counter 的方法

synchronized public void add() {
        count++;
}

可以看到最终的运算结果就是正确的了:

给 add 方法加入一个关键字 synchronized, 这个关键字是 Java 中的加锁操作.
加了 synchronized 之后, 进入方法就会加锁, 出了方法就会解锁. 如果两个线程同时尝试加锁, 此时一个能获取锁成功, 另一个只能进入阻塞等待状态(BLOCKED), 一直阻塞到刚才的线程释放锁(解锁)之后, 当前线程才能加锁成功.

我们以刚才分析的案例来将其修改为线程安全的.

加锁说是保证原子性, 但其本质上并不是让这里的三个操作一次完成, 也不是这三步操作过程中进行不同的调度, 而是让其它也想操作的线程阻塞等待了. 加锁的本质就是把并发变成了串行.

synchronized 关键字 - monitor lock (监视器锁)

使用 synchronized 关键字

使用 synchronized 关键字, 有以下几个使用方法.

  • 修饰方法

修饰普通方法(进入方法就加锁, 离开方法就解锁)

public synchronized void methond() {

}

修饰静态方法

public synchronized static void method() {

}

(这两者虽然都是修饰方法, 但是 synchronized 加锁加的地方不一样, 普通方法是加在 this 对象上, 静态方法是加在类对象上)

  • 修饰代码块(进入代码块就加锁, 出代码块就解锁)
synchronized (this) {

}
synchronized 的特性
互斥

synchronized 会起到互斥的效果, 当某个线程执行到某个对象的 synchronized 代码块中的时候, 如果其它线程也执行到了同一个对象的 synchronized 代码块, 就会发生阻塞等待, 只有上一个线程解锁之后其它线程才能再次进行加锁.

  • 进入 synchronized 代码块相当于加锁
  • 出了 synchronized 代码块相当于解锁

针对每一把锁, 操作系统内部都提供了一个 “阻塞队列”, 当这个锁被一个线程占用的时候, 其它线程尝试加锁, 就会加不上, 操作系统会把这个线程放进阻塞队列中, 进入阻塞状态, 直到之前的线程解锁之后, 由操作系统唤醒这个阻塞队列中的其中一个线程来获取这个锁.

可重入

一个线程针对同一个对象连续加锁两次, 如果没问题, 就是可重入的, 如果有问题, 就是不可重入的.

synchronized public void add() {
	synchronized (this) {
		count++;
	}
}

synchronized 代码块对于同一条线程是可重入的, 不会出现把自己锁死的问题.

对于不可重入锁, 如果一个线程没有释放锁的情况, 又尝试加锁, 就会触发阻塞等待, 直到第一次的锁被释放, 才能获取到第二个锁, 所以这个线程就进入了阻塞等待的状态, 但是进入阻塞状态之后就无法解锁第一个锁了, 这就出现了 死锁 的情况.

Java 标准库中的线程安全类

Java 标准库中很多线程都是线程不安全的, 这些类会涉及到多线程修改共享数据, 但是又没有加锁的措施

  • ArrayList
  • LinkedList
  • HashMap
  • TheeMap
  • HashSet
  • TreeSet
  • StringBuilder

有些类是线程安全的, 强行加锁了

  • Vector(不推荐用)
  • HashTable(不推荐用)
  • ConcurrentHashMap
  • StringBuffer

还有一种虽然没有加锁, 但是不涉及修改的类, 也是线程安全的

  • String

上述中的线程安全的类, 其中已经内置了 synchronized 相对来说更安全一点, 但是安全带来的另一种影响是性能上的损耗, 要在适当的场景中合理的使用.

死锁

死锁是什么

死锁是在多线程程序中可能会出现的一个问题, 它的表现形式是: 程序在执行过程中, 其中的两个或者多个线程在阻塞的过程中同时等待对方释放资源, 导致这些线程无限期的被阻塞, 以致于程序无法正常运行. 死锁在一个程序中是一个很严重的问题, 一旦出现, 就会导致程序无法运行.

三种典型的死锁案例
  • 一个线程一把锁, 同时加锁两次, 如果这个锁是不可重入锁, 那么就会出现死锁.

但是要注意的是: Java 中的 synchronized 和 ReentrantLock 都是可重入锁, 所以在 Java 中使用这两把锁可以避免这种问题. 但是在 Python, C++ 和操作系统中原生的加锁的 API 都是不可重入锁, 所以可能会出现这种情况

  • 两个线程两把锁, t1 和 t2 各自先针对 锁A 和 锁 B 加锁, 再尝试获取对方的锁.

代码示例:

public class ThreadDemo14 {
    public static void main(String[] args) {
        Object locker1 = new Object();
        Object locker2 = new Object();

        Thread t1 = new Thread(() -> {
            synchronized (locker1) {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                synchronized (locker2) {
                    System.out.println("t1 获取到了两把锁");
                }
            }
        });
        Thread t2 = new Thread(() -> {
            synchronized (locker2) {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                synchronized (locker1) {
                    System.out.println("t2 获取到了两把锁");
                }
            }
        });
        t1.start();
        t2.start();
    }
}

可以观察到, 控制台中并没有任何的输出, 说明没有任何线程拿到了两把锁, t1 和 t2 两个线程都同时处于阻塞状态, 都在等待对方释放资源, 于是就出现了 死锁 这个现象.

  • 多个线程, 多把锁 (是 的 一般情况)

这里引入一个经典案例: 哲学家就餐问题, 来更好的描述这个情况

如图所示: 一个桌子旁围了五个哲学家, 每个哲学家的左右手边都放着一根筷子, 每个哲学家想要吃到中间的意大利面, 就需要同时拿起左右手边的筷子凑成一双之后才能吃.

每个哲学家都相当于一个线程, 它们只会有两种状态

  1. 什么都不干 (线程阻塞状态)
  2. 吃意大利面 (线程获取到锁之后并执行)

由于操作系统的随机调度, 这些哲学家随时都哟预计可能在吃意大利, 也有可能什么都不干.

假设有一种极端的情况:

如上图所示, 每个哲学家都在同一时间拿起了右手边的筷子, 这时候桌上的筷子全都被拿起来了, 但是每个哲学家想要吃意大利面的时候, 都要等待左手边的人放下左手边的筷子, 这就出现了循环等待的情况, 每个人都持有一根筷子, 每个人都在等对方放下手中的筷子, 这就导致了 僵住了 的情况. 这就是一种典型的死锁问题.

死锁的必要条件

多个线程之间抢占多把锁, 这种情况是非常复杂的, 需要先来理解一下死锁产生的必要条件

  1. 互斥使用: 一个线程获取到了锁的过程中, 其它线程想要获取到这把锁, 就要进入阻塞等待的状态.
  2. 不可抢占: 一个线程获取到了锁, 除非是这个线程主动释放了锁, 不然其它线程就不能强行获取这把锁.
  3. 请求和保持: 一个线程获取到了一把锁之后, 再尝试获取另一把锁, 第一把锁还是被这个线程持有的状态, 不会因为去获取另一把锁就把第一把锁给释放了.
  4. 循环等待: 线程 1 尝试获取到 锁A 和 锁 B, 线程 2 尝试获取到 锁B 和 锁A. 线程 1 在获取锁 B 的时候等待线程 2 释放 锁B, 同时线程 2 在获取 锁A 的时候等待线程 1 释放 锁A.
如何打破死锁

在 Java 中. 死锁的必要条件中, 对于 synchronized 来说, 是它的基本特性, 不可打破. 想要打破死锁, 就要从第四点的循环等待入手.

最简单的办法, 就是给锁编号. 然后加锁的过程中, 需要按照一个固定的顺序来加锁. 就比如先获取编号小的锁, 再获取编号大的锁. 或者先获取编号大的锁, 再获取编号小的锁 …

我们根据上面的哲学家就餐问题, 把每根筷子都编一个号. 让每一个哲学家都先拿编号小的筷子, 再拿编号大的筷子, 假设这五个哲学家还是同时拿起筷子, 那么会是下面这个情况:

一号哲学家先拿一号筷子, 二号哲学家先拿二号筷子 … … 到了五号哲学家的时候, 他需要拿一号筷子. 但是一号筷子已经被一号哲学家所持有, 那么他就需要进入阻塞等待的状态, 这时四号哲学家就能获取到五号筷子, 并且吃完了意大利面之后, 放下五号和四号的筷子, 三号哲学家这时就能获取到四号哲学家放下的四号筷子 … … 等到一号哲学家获取了二号和一号的筷子并且吃完了意大利面之后, 放下了一号筷子, 这时, 五号哲学家就能获取到五号和一号筷子并且开始吃意大利面.
整个过程中的循环等待过程就被打破了
那么就可以根据这个方法, 来修改一下 中的代码, 打破其中的循环等待的过程.

public class ThreadDemo14 {
    public static void main(String[] args) {
        Object locker1 = new Object();
        Object locker2 = new Object();

        Thread t1 = new Thread(() -> {
            synchronized (locker1) {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                synchronized (locker2) {
                    System.out.println("t1 获取到了两把锁");
                }
            }
        });
        Thread t2 = new Thread(() -> {
            synchronized (locker1) {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                synchronized (locker2) {
                    System.out.println("t2 获取到了两把锁");
                }
            }
        });
        t1.start();
        t2.start();
    }
}

上述代码相对于之前死锁情况的改动为: 两个线程都先获取 locker1, 再获取 locker2, 这样就能打破之前的循环等待的过程了.

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值