多线程(2)——线程的六种状态

1. 线程的所有状态

进程状态:

就绪:正在 cpu 上执行,或者随时可以去 cpu 上执行

阻塞:暂时不能参与 cpu 执行

Java 的线程,对于状态做了更详细的区分,不仅仅是就绪和阻塞了,六种:

  1. NEW:当前 Thread 对象虽然有了,但是内核的线程还没有(还没调用 start)
  2. TERMINATED:当前 Thread 对象虽然还在,但是内核的线程已经销毁了(线程已经结束了)
  3. RUNNABLE:就绪状态,正在 cpu 上运行 或 随时可以去 cpu 上运行
  4. BLOCKED:因为 锁竞争 引起的阻塞
  5. TIMED_WAITNG:有超时时间的等待,比如 sleep 或者 join 带参数版本
  6. WAITING:没有超时时间的等待 join /wait

上述线程状态都可以通过 jconsole 来观察

2. 线程安全

2.1 线程不安全代码:

public class Demo {
    private static int count = 0;

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                count++;
            }
        });

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                count++;
            }
        });

        t1.start();
        t2.start();

        t1.join();
        t2.join();

        System.out.println("count = " + count);
    }
}

我们预期结果为 100000,运行结果为:

实际运行结果与预期不符,这就是 bug


当把代码改为:

上面写法中 t1 先执行,t1 执行完,t2 再执行,t1 和 t2 是串行执行的,这样就没问题

而一开始的代码中 t1 和 t2 是并发执行的,是有 bug 的

像这样因为多个线程并发执行引起的 bug 称为“线程安全问题”或者叫做“线程不安全


2.2 上例线程不安全的原因:

上述代码中的 count++ 操作,在 cpu 的视角来看,是 3 个指令

1) 把内存中的数据读取到 cpu 寄存器里 (load)

2) 把 cpu 寄存器里的数据 +1                  (add)

3) 把寄存器的值写回内存                       (save)

tip:由于不同架构的 cpu 有不同的指令集,不同的指令集里有不同的指令,针对这三个操作,不同 cpu 里的对应指令名称肯定是不同的!

cpu 在调度执行线程的时候,不一定什么时候就会把线程给切换走(抢占式执行,随机调度)

指令是 cpu 执行的最基本单位,要调度至少把当前指令执行完,不会执行一半调度走

但是由于这里 count++ 是三个指令,可能会出现 cpu 执行了其中的 1 个或 2 个或 3 个指令调度走的情况(都有可能,无法预测)

基于上面的情况,两个线程同时对 count 进行 ++ 就容易出现 bug

画图示例:

但上述的执行顺序,只是一种可能的调度顺序,由于调度过程是“随机”的,因此就会产生很多其他的执行顺序(下面就是得到不正确结果的调度,略写...)


tip:得到值 <50000 的特殊情况

 

上面这样,在 t1++ 一次的过程中,t2++ 两次,这样的结果一共是 ++3 次,实际上只得到了 1

这样 t1 和 t2 相互踩对方的结果就会出现 <50000 的情况(需要两个线程实际有效 的次数都得 < 2.5w)

2.3 线程不安全的原因

1) 线程在操作系统中是 随机调度,抢占式执行(根本原因)

2) 多个线程同时修改同一个变量

3) 修改操作不是“原子”的

4) 内存可见性问题

5) 指令重排序

3. 解决线程不安全问题(synchronized 关键字 -- 监视器锁 monitor lock)

最主要的方法就是把“非原子”的修改成“原子”的

3.1 synchronized 使用

synchronized ( ),是关键字,不是函数,( ) 中的并非“参数”

需要指定一个“锁对象”,通过锁对象来进行后续的判定,这里的 ( ) 可以指定任何的对象

3.1.1 针对上面例子加锁:

{ } 中的代码就是需要加锁的代码,只要是合法的 Java 代码,都可以放入

执行过程分析:

由于 t1 和 t2 都是针对 locker 对象加锁,t1 先加锁成功了,所以 t1 继续执行 { } 中的代码,t2 后加锁,发现 locker 对象已经被其他线程加锁了,所以 t2 只能阻塞等待

又因为 t1 的 unlock 操作一定是在 save 之后,确保了 t2 执行 load 的时候,t1 已经 save ,这样两者进行 ++ 操作,就不会因为穿插执行而导致相互覆盖对方结果了

本质上是把随机的并发执行过程强制变成了串行

tip:

1. 锁对象,最重要的是看多个线程是否是同一个锁对象

        针对同一个对象加锁,就会出现“阻塞”(锁竞争/锁冲突)

        针对不同对象加锁,不会出现“阻塞”,两个线程仍然是随机调度的并发执行

2. 锁对象不能用 int,double 这种内置类型,必须是 Object 及其子类

3. 加锁代码是比 join 串行效率高很多的,加锁只是将线程中的一小部分逻辑变为“串行执行”,剩下的其他部分仍然可以并发执行

3.1.2 三个线程对同一个对象加锁

假设有 1,2,3 线程

1 先拿到锁,2 和 3 阻塞等待,当 1 释放锁后, 2 和 3 谁先拿到锁是不一定的,是随机的,即使在代码中是 2 先加锁,3 后加锁,也不一定谁先拿到

3.2 synchronized 特性

3.2.1 底层原理

synchronized 是 JVM 提供的功能,synchronized 底层实现就是在 JVM 中通过 C++ 代码实现的,也是依靠 操作系统 提供的 api 实现的加锁,操作系统的 api 则是来自于 cpu 上支持的特殊指令来实现的

因此,加锁操作并不是 Java 独有的,其他语言也有加锁操作

系统原生的加锁 api 其实是两个函数:lock()、unlock()

不仅仅原生 api 是这样,很多编程语言的加锁操作也是类似的封装方法,如 C++/Python 加锁是一个函数,解锁是一个函数,像 Java 这样通过 synchronized 关键字来同时完成加锁解锁是比较少见的

系统原生的这种做法一个最大的问题就是:unlock 可能会执行不到


3.2.2 加锁其他写法

一个 Java 进程中,一个类的类对象是只有唯一一个的,类对象也是对象,所以也能写到 synchronized( ) 里面,写类对象和写其他对象没有任何本质的区别

synchronized 修饰一个普通的方法


1) 相当于针对 this 加锁


2) synchronized 修饰一个静态方法

相当于针对 对应的类对象 加锁

static 方法没有 this ,其也叫做类方法,和具体的实例无关,只和类相关,而 this 是指向实例的

这样的写法就是在给类对象加锁


3.2.3 线程问题之——死锁

分析:

1) add 方法的 synchronized 想要拿到锁,就需要 for 循环中的 synchronized 释放锁

2) for 循环的 synchronized 想要释放锁就需要执行到 }

3) 要想执行到 } 就需要执行完这里的 add

4) 但是 add 正在阻塞中

出现死锁的三种场景:

1) 一个线程针对一把锁,连续加锁两次

但是当我们运行时,发现程序正常运行了,结果并没有问题,没有发生死锁,这是因为 Java 的 synchronized 为了减少我们写出死锁的概率,引入了 “可重入锁” 的特殊机制,解决了上述问题


2) 两个线程两把锁

线程1、线程2、锁A、锁B

线程 1 先对 A 加锁,线程 2 对 B 加锁;线程 1 不是放锁 A 的前提下,再对 B 加锁,同时线程 2 在不释放锁 B 的前提下,再对 A 加锁

public class Demo17 {
    private static Object locker1 = new Object();
    private static Object locker2 = new Object();

    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            synchronized (locker1) {
                System.out.println("t1 加锁 locker1 完成");

                //这里的 sleep 是为了确保 t1 和 t2 都先分别拿到 locker1 和 locker2,然后再分别拿对方的锁
                //如果没有 sleep,执行顺序就不可控,可能会出现某个线程一下拿到两把锁,另一个线程还没执行,导致无法构造出死锁
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }

                synchronized (locker2) {
                    System.out.println("t1 加锁 locker2 完成");
                }
            }
        });

        Thread t2 = new Thread(() -> {
           synchronized (locker2) {
               System.out.println("t2 加锁 locker2 完成");

               try {
                   Thread.sleep(1000);
               } catch (InterruptedException e) {
                   throw new RuntimeException(e);
               }

               synchronized (locker1) {
                   System.out.println("t2 加锁 locker1 完成");
               }
           }
        });

        t1.start();
        t2.start();
    }
}

运行结果:


3) N 个线程,M 个锁(经典模型:哲学家就餐问题)

现有五位哲学家,他们坐在一个圆桌旁边,每个人左手边有一根筷子,圆桌中间是他们要吃的大碗宽面

任何一个科学家要想吃到面条都需要拿起左手和右手的筷子,他们现在只会做两件事:1) 思考人生,放下手里的筷子;2) 吃面条,拿起左右手两边的筷子

通常情况下,这个模型是可以运行的,但是当所有哲学家在某一时刻,同时想要吃面条,同时拿起了自己左手边的筷子,他们就拿不到右手边的筷子了,由于这些哲学家很固执,当他们吃不到面条的时候,绝不会放下左手的筷子,此时就形成了死锁


总结:死锁的四个必要条件(缺一不可)

1) 锁是互斥的(锁的基本特性)

2) 锁是不可被抢占的(线程 1 拿到了锁 A,如果线程 1 不主动释放 A,线程 2 不能把锁 A 抢过来)(锁的基本特性)

3) 请求和保持:线程 1 拿到锁 A 之后,不释放 A 的前提下,去拿锁 B;如果是先释放 A ,再拿 B 就不会有问题(特殊情况,有些代码里面就需要写成请求保持的方式)

4) 循环等待/环路等待/循环依赖(多个线程获取锁的过程中,存在循环等待...)

假设代码按照请求和保持的方式获取到 N 个锁,只需要给锁编号(1,2,3,N...),约定所有的线程在加锁的时候都必须按照一定的顺序来加锁(比如,必须先针对编号小的锁加锁,后针对编号大的锁加锁)


哲学家就餐问题解决方案:

假设同一时间,所有哲学家拿起第一根筷子

给筷子编号(1~5),规定每个哲学家每次拿筷子都要从小往大拿,即:

第一位哲学家不能拿左手边的2号筷子,先拿起右手边的1号筷子

第二位拿起右手边的2号筷子

第三位拿起右手边的3号筷子

第四位拿起右手边的4号筷子

第五位不能拿左手边的5号筷子,阻塞等到右手边的1号筷子被放下

由于5号筷子是空闲的,第四位就可以拿起5号筷子并吃到面条,之后就会释放4号5号筷子,3号就可以拿起4号筷子吃面条......

只要遵守上述的拿起筷子的顺序,无论接下来这个模型运行顺序如何,出现怎么极端的情况都不会出现死锁了

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值