JavaEE初阶:多线程初阶(2)

1. 线程的状态

从操作系统看来,进程状态分为就绪状态和阻塞状态。接上文所述,我们了解了中断线程、等待线程和休眠方法等。在这些方法的作用下,线程将会呈现出一些不同的状态。如下:

|--------------|---------------------------------------|
| 状态名称 | 说明 |
| NEW | 安排了任务,但还没有开始执行 |
| RUNNABLE | 既有可能代表正在工作中,也有可能是即将开始工作;类似就绪状态 |
| BLOCKED | 表阻塞状态,是一种比较特殊的阻塞:由于导致的阻塞,该部分后面再讲 |
| WAITING | 表示死等,指没有超时时间的阻塞等待 |
| TIME_WAITING | 也是阻塞状态的一种,但是这是有超时时间的阻塞等待 |
| TERMINATED | 任务或工作已完成 |

这些状态我们都可以在运行时使用第三方工具查看。

  1. NEW :一般是指 new 了 Thread 状态,还没 start 的状态。

  2. TERMINATED:在出现该状态时,内核中的线程虽然结束了,但是 Thread 的对象还在。

  3. 状态之间是可以互相转化的,例如:sleep(1000) 这个代码,在没执行该代码时是 RUNNABLE 状态,执行了之后的 1s 之内是 TIME_WAITING 状态;在 1s 之后就又是 RUNNABLE 状态。

2. 线程安全问题(重点)

2.1 线程安全问题复现和分析

线程安全问题在多线程编程中是非常重要的部分,可以说如果不能正确理解线程安全问题,你写出来的多线程代码一定有 bug。线程安全问题其实就是之前所说的随机调度问题和并发执行问题

现在举一个例子:每个线程循环 5000 次累加并用变量 count 的值来存储,之后直接打印,来看看打印出来的值是多少。

java 复制代码
public class Demo14 {
    public static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Object locker = new Object();
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                synchronized (locker){
                    count++;
                }
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                synchronized (locker){
                    count++;
                }
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("count = " + count);
    }
}

写这个代码需要注意的是:因为线程的创建我们使用的是 lambada 表达式,所以 count 不能在 main 中创建,会获取不到值;需要将 count 独自设成一个成员变量就可以被捕获到了。还有别忘记启动线程的 start() 方法。现在我们来打印值,看看会打印出什么。

可以看到打印出来的是 0,为什么呢?这是由于并发执行的原因,使得 main 、t1 和 t2 并发执行,这时 count 还未 ++,所以打印的就是 0 。为了解决这个办法,我们可以先让 t1 和 t2 先执行完之后再来执行打印,是不是就会打印出 10000 了呢?来证实一下:

在 t1.start(); 和 t2.start(); 后加上 t1.join(); 和 t2.join(); ,即让 main 线程等待 t1 和 t2 执行完再执行。理论上来说应该就可以打印出 10000 了,现在我们来看一看结果:

可以发现,这样的代码的预期结果和实际结果还是不一致,很明显是存在 bug 的。而这样的 bug 就属于线程安全问题(由于并发执行和随机调度引起,这是线程安全问题的罪魁祸首)。而 bug 的引起和 count++ 这一代码也脱不了干系。现在让我们从 CPU 指令执行的角度来观察一下:

count++,这个操作看起来只是一行代码,并没有什么起眼的。但是,这个代码实际上对应着三个指令,在EE初阶开篇文章提到过。即:

  1. load:把内存中的值(count 变量)读取到 CPU 寄存器;

  2. add:把指定寄存器中的值,进行 +1 的操作(结果还是在这个寄存器中)

  3. save:将寄存器中的值,写回到内存中。

由于要执行三条指令,并且由于系统随机调度(抢占式执行)的原因,所以随时都有可能会触发线程切换。就有可能会出现以下的情况:

这里只列举部分情况,实际上还会有很多的情况。我们来以三种调度顺序的 count ++来讨论为什么会出现 bug。

(1)正常调度,先 t1 后 t2

1. t1 的 load:t1为0,t2没有值,count为0;

2. t1 的 add:t1为1,t2没有值,count为0;

3. t1 的 save:t1为1,t2没有值,count为1;

4. t2 的 load:t1为1,t2为1,count为1;

5. t2 的 add:t1为1,t2为2,count为1;

6. t2 的 save:t1为1,t2为2,count为2;可以看到这个顺序执行,得到的两次 count++ 的值是正确的。

(2)t1 和 t2 开始一种随机调度

接下来来看这个顺序下是否是正确的值:

1. t1 的 load:t1为0,t2没有值,count为0;

2. t2 的 load:t1为0,t2为0,count为0;

3. t2 的 add:t1为0,t2为1,count为0;

4. t2 的 save:t1为0,t2为1,count为1;

5. t1 的 add:t1为1,t2为1,count为1;

6. t1 的 save:t1为1,t2为1,count此时save的是1;可以看到最后的结果还是1。(相当于有一次 count ++ 没加上)

(3)t1 和 t2 开始另一种随机调度

1. t1 的 load:t1为0,t2没有值,count为0;

2. t2 的 load:t1为0,t2为0,count为0;

3. t1 的 add:t1为1,t2为0,count为0;

4. t2 的 add:t1为1,t2为1,count为0;

5. t2 的 save:t1为1,t2为1,count为1;

6. t1 的 save:t1为1,t2为1,count此时save的仍然是1;

总结:通过这几种情况的讨论。不难发现,如果两个线程 load 到的数据都是 0 ,那就一定会出错;换句话说,如果一个 load 到 0 ,一个 load 是 1,结果才是正确的。即:一个线程的 load 得在另一个线程的 save 之后才能正确执行

那么我在这提出一个问题,会不会出现 count 值 < 5000 的情况?

其实是会的,不过这种情况的概率会小很多(情况极端一点),下面我来举个例子就明白了:

1. t1 的 load:t1为0,t2没有值,count为0;

2. t2 的 load:t1为0,t2为0,count为0;

3. t2 的 add:t1为0,t2为1,count为0;

4. t2 的 save:t1为0,t2为1,count为1;

5. t2 的 load:t1为0,t2为1,count为1;

6. t2 的 add:t1为0,t2为2,count为1;

7. t2 的 save:t1为0,t2为2,count为2;

8. t2 的 load:t1为0,t2为2,count为2;

9. t2 的 add:t1为0,t2为3,count为2;

10. t2 的 save:t1为0,t2为3,count为3;

11. t1 的 add:t1为1,t2为3,count为3;

12. t1 的 save:t1为1,t2为3,count此时save的从 3 改为 1 了;这里四次 count ++ 的最终结果还是 1。这样的极端情况在系统随机调度多来一些次数,就会使 count 值 < 5000。

2.2 线程安全问题引起原因

线程安全问题产生原因可从以下五点来展开:

(1)根本原因:操作系统对于线程的调度是随机的 ,即抢占式执行

(2)多个线程同时修改了某一个变量引起的,即 CPU 中发生的写操作;例如情况3,t1 和 t2 都在同时修改同一个内存空间;现列举各个情况以能够直观看出哪些情况不会引起线程安全问题:

1. 如果是一个线程修改一个变量 ------ 没问题

2. 如果是多个线程,不是同时修改同一个变量 ------ 没问题

3. 如果多个线程修改不同变量 ------ 没问题

4. 如果多个线程同时读取同一个变量 ------ 也没问题;因为这在 CPU 中是读操作 而不是写操作读操作是不会引发线程安全问题的!这一点务必记住。

(3)修改操作不是原子的;

之前在数据库的事务中我们提到事物的四种性质:原子性、一致性、隔离性、持久性;而原子性和这里的原子的是一类意思,即如果修改操作只对应到一个 CPU 指令,则就可以认为这个操作是原子的 ,而 count ++ 则有 load、add、save 三条指令,其他类似的还有:++、+=、--、-=。这些都不是原子的操作

(4)内存可见性问题

(5)指令重排序问题

这俩问题引起的线程不安全,我们之后再来讲。

2.3 线程安全问题解决方法

现根据上述线程安全问题的诱因,来分别列出并阐述是否有可行的解决方法。

(1)操作系统的随机调度;这是操作系统内部的底层设定,一般程序员是无法干预的。所以此方法不可取。

(2)多线程同时修改一个变量;该部分是与程序员所写的代码结构息息相关的我们通过调整代码结构是能够解决一些线程不安全的代码,但是这样的方案不够通用。原因:有些情况下,某些项目就是需要你多线程修改同一个变量,这样的情况就不能够解决安全隐患了。

(3)修改操作不是原子的;这个是 Java 中解决线程安全问题方面最主要、最实用的方案。在这里我们将引入一个新概念 ------ 加锁(locker)。

在计算机中的锁,与生活中的锁一样具有互斥 的作用。例如:你在一个房间里,一旦把锁加上了,即 " 加锁 ",其他人再要想进来,就得 " 阻塞等待 ",等你 " 解锁 " 了之后才能进来 。所以我们就可以使用锁,将刚才不是原子操作的 count ++ 括起来;这样,就可以先进行一个 count++,等第一个 count ++ 线程结束并解锁之后,再来进行另一个 count ++。这样的步骤,其他线程就无法插队了。也满足我们刚刚所说的:一个线程的 load 得在另一个线程的 save 之后才能正确执行

3. 加锁操作(synchronized)

3.1 synchronized 的语法与写法

加锁和解锁,本身原来是操作系统提供的 API ,很多编程语言都对于这样的 API 进行适配于自己的封装。在 Java 中则是使用了 synchronized 这样的关键字,来搭配代码块来实现类似组队效果。不会念这个关键字的可以查下词典。该关键字语法如下:

java 复制代码
synchronized () {       //进入代码块,就是加锁

   //执行一些要保护的逻辑

}                   //出了代码块,就是解锁

( ) 中所填写的是锁的对象(名称),要进行加锁和解锁,首先你也得先实例化一个锁出来。这个锁对象的类型是什么不重要,自行创建即可。我个人一般是这么创建的:

java 复制代码
Object locker = new Object();

需要注意的是,两个线程需要针对同一个对象加锁才会产生互斥的效果(即两个线程再竞争同一个锁,所以一定会有一个线程没获取到锁而造成阻塞等待),等到第一个线程释放锁了,另一个线程才有机会获取到锁。如果是不同的锁对象,此时不会有互斥的效果,而线程安全问题仍然存在。

按照上述的加锁语法,可衍生出两种加锁方式:

(1)在 for 循环内,对 count ++ 进行加锁

java 复制代码
public class Demo14 {
    public static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Object locker = new Object();
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                synchronized (locker){
                    count++;
                }
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                synchronized (locker){
                    count++;
                }
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("count = " + count);
    }
}

(2)对整个线程内的 for 循环进行加锁

java 复制代码
public class Demo12 {
    public static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Object locker = new Object();
        Thread t1 = new Thread(() -> {
            synchronized (locker){
                for (int i = 0; i < 5000; i++) {
                    count++;
                }
            }
        });
        Thread t2 = new Thread(() -> {
            synchronized (locker){
                for (int i = 0; i < 5000; i++) {
                    count++;
                }
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("count = " + count);
    }
}

这两种写法,它们虽然最后得到的结果相同,但是这两种写法所代表的意义是不同的。第一种是只有 count ++ 这个操作会涉及到互斥,而第二种意味着整个 for 循环都是以互斥的方式执行的。

3.2 sychronized 其他写法

sychronized 除了可以用来修饰代码块以外,还可以用来修饰方法,即在方法创建时就加上sychronized 。修饰普通方法之后,则相当于是给 this,即针对类对象加锁。我们再以上述累加为例,写出代码:

java 复制代码
class Docker {
    private int count = 0;
    synchronized public void add(){
        count++;
    }
    public int get(){
        return count;
    }
}
public class Demo15 {
    public static void main(String[] args) throws InterruptedException {
        Docker docker = new Docker();
        Object locker = new Object();
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                    docker.add();
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                    docker.add();
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("count = " + docker.get());
    }
}

如果 synchronized 修饰的是静态方法,则相当于是给类对象加锁。这一点需要注意。

3.3 sychronized 其他重要特性和注意事项

3.3.1 可重入

先来看一段代码:

这样的代码,将会引起阻塞等待!为什么呢?

因为在第二个 synchronized 执行的时候,由于第一个 synchronized 正在使用者 locker1 锁对象,所以第二个 synchronized 就无法获取到 locker1 对象,引起阻塞。这里虽然我们写的错误代码看起来很明显,但是之后业务中方法的调用可能会很深,就常常有可能编译的时候出现这样的情况。这种 " 要想解除阻塞,就需要向下执行 " 和 " 要想向下执行,就需要第一个锁对象先被释放" 的矛盾,我们称为 " 死锁 "。死锁也是一种非常严重的 bug ,它会使代码块直接卡住不执行了。

所以为了解决上述的问题,Java 对于这种原因引起的死锁引入了可重入的概念 ,即:当某个线程针对一个锁对象加锁成功之后,后续又有线程再次针对该锁对象进行加锁时,则不会触发阻塞,而是代码会继续执行下去。可重入锁的实现原理,关键是在于让锁对象内部保存住当前是哪个线程持有的哪把锁,后续有线程针对锁加锁的时候,对比一下是否是同一个锁对象。

现在我提出一个问题,如下:

刚刚说过,我们在 1 处才是真正的加锁,2 和 3 处由于可重入原因相当于是直接略过的;那么在 4、5、6 三处中哪一处才是真正解锁呢?

答案是 6 处,加解锁的括号是对应的,因为如果在 4、5、6 处还有一些其他逻辑的话,你不对应的去解锁就有可能会引起线程安全问题。那么问题又来了,编译器是如何确定括号是一定对应的呢?

答案:引入一个计数器;即先引入一个变量,计数器初始为0,每次触发 { 时,计数器 ++;每次触发 } 时,再将计数器 -- ;最后在计数器为 0 的那一处 } 就是真正需要解锁的地方。

如果在面试的时候,面试官问你如何自己实现一个可重入锁?你就可以回答:

(1)在锁内部记录当前是哪个线程持有的锁,后续每次加锁都进行一系列判定;

(2)通过计数器记录当前加锁的次数,从而确定什么时候才真正解锁。

4. 死锁的产生

既然有加锁的操作,如果加锁发生了异常,就会引发死锁。死锁会引起线程阻塞,妨碍代码运行。以下是几种死锁的触发方式

4.1 一个线程一把锁,连续加锁两次(可重入)

该方式也是一种死锁的构造方式,刚刚已经讲过,就一笔带过了。

4.2 两个线程两把锁,在先获取到一把不同的锁之后,再尝试获取对方的锁

具体代码举例如下:

java 复制代码
public class Demo16 {
    public static void main(String[] args) throws InterruptedException {
        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("locker1和2都获取到");
            }
        });
        Thread t2 = new Thread(() -> {
            synchronized (locker2){
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
            synchronized (locker1){
                System.out.println("locker1和2都获取到");
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("没有构成死锁");
    }
}

想要确保以上这种死锁情况必定出现,一定要加上 Thread.sleep 之后才能确保一定执行;因为不加 sleep ,由于系统随机调度的原因,有可能 t1 一下子就把 locker1 和 locker2 都拿到了,但是这个时候 t2 还没开始呢,自然就构成不了死锁

这时如果我们运行起来并使用 jconsole 工具查看线程状态,显示出来的就会是 BLOCKED,即死锁引起的线程阻塞。

4.3 N 个线程 M 把锁

这种情况在计算机中就是一个非常经典的问题 ------ 哲学家就餐问题。

哲学家就餐问题由荷兰学者艾兹格·迪科斯彻提出,设想五位哲学家围坐在一张圆桌旁,进行两项活动:思考和进餐。每位哲学家面前有一碗意大利面,且每两位哲学家之间有一根筷子。为了吃面,哲学家必须同时拿起左右两根筷子。如果每一位哲学家都只拿起一根筷子,他将无法进餐,必须等待邻近的哲学家放下筷子才能继续。这种情况下,可能会发生死锁,即每位哲学家都在等待另一位哲学家放下筷子,导致所有人都无法进餐。如图所示:

在哲学家就餐问题中,死锁的发生原因是每位哲学家都需要同时获取两个共享资源(筷子)。如果所有哲学家同时拿起左侧的筷子,就会导致每个人都在等待右侧的筷子,从而形成循环等待的状态。这样就会引起阻塞,这样的情况虽然不常发现,但是只要存在这种问题。一旦发生,就有可能产生无法挽回的后果!作为程序员写代码不能抱有侥幸心理。

5. 如何解决死锁

从上述几种情况,我们能够分析得出构成死锁的几个必要条件:

(1)锁是互斥的。一个线程拿到一把锁之后,另一个线程再来尝试获取锁,就会触发阻塞等待

(2)锁是不可被抢占的。线程 1 拿到了锁,而线程 2 也尝试获取这个锁,此时线程 2 必须阻塞等待,而不是直接把锁抢过来。

(3)请求和保持。一个线程拿到锁 1 之后,在不释放锁 1 的前提下,获取到锁 2。即:哲学家就餐问题,我先拿起左手的筷子,在不释放左手筷子的前提下,尝试获取右手筷子;这样也会构成死锁。

(4)循环等待。多个线程、多把锁之间的等待过程构成了循环。例如:A 等待 B,B 等待 C,C 又在等待 A。

以上(1)和(2)都是锁的基本特性引起的死锁。所以我们无法去干预,因此(3)和(4)才是我们需要去关注的解决方案,只要破坏掉这两种的其中一种,就可以打破死锁。现在我们分别来看看(3)和(4)是否都可行。

在(3)中,拿完锁 1 立马再去拿锁 2 的操作,这其实是连贯的、嵌套的操作。那解决办法就很简单了,代码加锁的时候不要 " 嵌套 " 就可以了,即将嵌套锁改为串行的锁 。但是但是,这种做法的通用性是不够的,因为有的时候确实是需要拿到多个锁再来进行一系列操作的,所以嵌套很难避免。

但是在(4)中,循坏等待引起的死锁,我们只用需要规定好加锁顺序,就能很好地避免死等的情况。具体方案是:给每个锁标上序号,之后每个线程加锁的时候,永远是先获取序号小的锁,再来获取序号大的锁,然后接下来循环下去。我们把这个理论代入到哲学家就餐问题来看看:

根据上述约束,我们不难发现:

第一轮 :0 号哲学家拿到 0 号筷子;1 号哲学家拿到 1 号筷子;2 号哲学家拿到 2 号筷子;3 号哲学家拿到 3 号筷子;而此时在最后的 4 号哲学家,他的旁边有 0 号 和 4 号筷子由于约束问题,并且 0 号筷子已经被拿走了,所以此时他拿不到筷子,故 4 号哲学家进入阻塞等待

第二轮:由于只有 4 号筷子剩余着,而其他的筷子都被占据着,所以 0、1、2 号哲学家也进入阻塞等待,获取不到筷子而此时的 3 号哲学家还能获取到了 4 号筷子 ,所以就使用了 3 和 4 号筷子吃完了面,最后并把 3 和 4 号筷子放下,供其他人使用......一直按这样下去,最后所有人都能吃到面,也解决了死锁的问题

6. Java 标准库中的线程安全类

在我们之间所学习的数据结构中,就有一些数据结构是线程安全的,有一些又不是线程安全的。现在列出一些集合类,来直观展示下:

|--------------------------------------------------------------------|
| 线程不安全的集合类 |
| ArrayList、LinkedList、HashMap、HashSet、TreeMap、TreeSet、StringBuilder |

这些集合类,它们本身的方法中没有进行任何的加锁限制,因此是线程不安全的。现在列出一些线程安全的集合类:

|-------------------------------------------------|---|
| 线程安全的集合类 | |
| Vector、HashTable、ConcurrentHashMap、StringBuffer | |

前三个集合类虽然是线程安全的,我们也不推荐使用。因为加锁这个操作不是没有代价的,一旦代码中使用了锁,那就意味着很有可能会因为锁的竞争,部分代码块产生阻塞。这样就会使程序的执行效率大打折扣线程阻塞,CPU 就会从该线程上调度走,那并不是阻塞结束了之后就会立即执行的哦,系统的随机调度可不会人性化。况且,也不是写了 synchronized 就一定是线程安全的代码,万事大吉了,还得具体代码具体来分析。所以我们在写项目的时候,一定要思考好这一部分是否真的需要加上 synchronized。

6.1 特殊集合类 String

String 这个集合类,它虽然没有加锁,但是它也是线程安全的。为什么呢?

因为该集合类中的方法中没有修改操作,全都是读取操作 。还记得我们之前所说过的线程安全问题的原因之一吗?没错,就是内存指令中的 " 写操作 "。由于该集合类内全都是读操作,自然 String 它就是天然线程安全的

总结,本节内容较多,需要自己手搓代码,然后再结合代码理解,才能内化于心。

那么,本篇文章到此结束!希望能对你有帮助。

相关推荐
明天…ling2 小时前
php底层原理与安全漏洞实战
开发语言·php
爱说实话2 小时前
C# DependencyObject类、Visual类、UIElement类
开发语言·c#
智码未来学堂2 小时前
C语言指针:打开通往内存世界的大门
c语言·开发语言
黎雁·泠崖2 小时前
Java面向对象:对象数组核心+综合实战
java·开发语言
Mr.LJie2 小时前
记录使用iText7合并PDF文件、PDF发票、PDF火车票
java·pdf
野生技术架构师2 小时前
2026最新最全Java 面试题大全(整理版)2000+ 面试题附答案详解
java·开发语言
南村群童欺我老无力.2 小时前
Flutter 框架跨平台鸿蒙开发 - 打造表情包制作器应用
开发语言·javascript·flutter·华为·harmonyos
小北方城市网2 小时前
SpringBoot 集成 MinIO 实战(对象存储):实现高效文件管理
java·spring boot·redis·分布式·后端·python·缓存
Solar20252 小时前
工程材料企业数据采集系统十大解决方案深度解析:从技术挑战到架构实践
java·大数据·运维·服务器·架构