线程的状态、多线程意义、线程安全问题、加锁与死锁

承接 认识线程、Java多线程编程、Thread类及常见方法!!!

一、线程的状态

状态是针对当前线程调度情况来描述的。线程是调度的基本单位,状态是线程的属性 (后面谈到状态都是针对线程)
Java对于线程的状态进行了细化。

1.1 线程的所有状态

线程的状态是一个枚举类型 Thread.State 。

public class ThreadState {
    public static void main(String[] args) {
        for (Thread.State state : Thread.State.values()) {
            System.out.println(state);
       }
   }
}
  • NEW: 安排了工作,还未开始行动
  • RUNNABLE: 可工作的,又可以分成正在工作中和即将开始工作
    a) 正在CPU上执行
    b) 在就绪队列里随时可以去CPU上执行
  • TERMINATED: 工作完成了
  • TIMED_WAITING: 排队等着其他事情
  • BLOCKED: 排队等着其他事情
  • WAITING: 排队等着其他事情

注意:
1.Java中没有running状态,无法区分当前是RUNNABLE的哪种情况,像Linux这样的操作系统也是区分不了的。不区分这俩也没关系,因为还是把线程调度的工作全权委托给系统了~~
2.后三个状态都表示阻塞 (都表示线程PCB正在阻塞队列中),但这三个状态是不同原因的阻塞。

在这里插入图片描述
在这里插入图片描述
PCB没了,但对象还在。可以调用对象的一些方法属性,但是没法通过多线程来做一些事情了!
t 线程对象,如果TERMINATED之后还有重新启用的机会,程序猿就不好判定当前这里的 t到底是一个有效的还是无效的;如果明确TERMINATED就是终结,没有重新 start的机会了,此时程序猿就可以心安理得地放弃t,同时后续任何代码中使用了 t的线程都可以视为是不太科学的操作了~~

所以,之前我们学过的 isAlive() 方法,可以认为是处于不是 NEW 和 TERMINATED 的状态都是活着的。

1.2 示例观察

观察1: 关注 NEW、RUNNABLE、TERMINATED状态的转换

  • 使用 isAlive 方法判定线程的存活状态
public class ThreadStateTransfer {
	public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(() -> {
            for (int i = 0; i < 1000_0000; i++) {
            // 什么都不干
            }
        }, "李四");
        System.out.println(t.getName() + ": " + t.getState());;
        t.start();
        while (t.isAlive()) {
            System.out.println(t.getName() + ": " + t.getState());;
        }
        System.out.println(t.getName() + ": " + t.getState());;
    }
}

观察2: 关注 RUNNABLE、TIMED_WAITING状态的转换

public class ThreadDemo {
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(() -> {
            for (int i = 0; i < 1000_0000; i++) {
                try{
                    Thread.sleep(10);   // 实际sleep时间很久,因为外面还套了一个大循环!
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        }, "李四");
        System.out.println(t.getName() + ": " + t.getState());;
        t.start();
        for(int i = 0;i < 100;i++){
            System.out.println(t.getName() + ": " + t.getState());
        }
        t.join();
        System.out.println(t.getName() + ": " + t.getState());;
    }
}

在这里插入图片描述
RUNNABLE / TIMED_WAITING取决于当前咱们的t线程是运行到哪个环节了!

在这里插入图片描述


BLOCKED、WAITING接下来会讲解~

二、多线程意义-加快执行速度

2.1 示例

案例:多线程的意义是什么呢?
写个代码来感受一下单个线程和多个线程之间执行速度的差别。

程序分为CPU密集IO密集
CPU密集包含了大量算术运算和逻辑运算;
IO密集涉及到读写文件、读写控制台、读写网络。

这里我们来分析CPU密集型~
两个变量各自增100亿次的时间?
(currentTimeMillis 获取到当前系统的ms级时间戳)

单线程执行:

public class ThreadDemo {
    public static void main(String[] args) {
        serial();
    }

    public static void serial(){
        // currentTimeMillis 获取到当前系统的ms级时间戳
        long beg = 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();
        System.out.println("执行时间" + (end-beg) + " ms");
    }
}

为了更严谨,我们取三次求平均值~:
在这里插入图片描述在这里插入图片描述在这里插入图片描述


使用两个线程分别完成自增:

public class ThreadDemo {
    public static void main(String[] args) {
        concurrency();
    }

    public static void concurrency(){
        // 使用两个线程分别完成自增!
        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 beg = System.currentTimeMillis();
        t1.start();
        t2.start();
        try{
            t1.join();;
            t2.join();;
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        long end = System.currentTimeMillis();
        System.out.println("执行时间" + (end-beg) + " ms");
    }
}

同样取三次求平均值~:
在这里插入图片描述在这里插入图片描述在这里插入图片描述

在这里插入图片描述

注意:
1.main调用方法进行的操作也属于主线程操作!
在这里插入图片描述

2.在这里插入图片描述

2.2 总结

多线程在这种CPU密集型的任务中有非常大的作用,可以充分利用CPU的多核资源从而加快程序的运行效率!
在这里插入图片描述


多线程在IO密集型的任务中,也是有作用的 (代码上不好演示)~
在这里插入图片描述

三、多线程带来的的风险-线程安全 (重点)

3.1 线程安全的概念

想给出一个线程安全的确切定义是复杂的,但我们可以这样认为:
如果多线程环境下代码运行的结果是符合我们预期的 (即在单线程环境应该的结果),则说这个程序是线程安全的。

线程安全问题的罪魁祸首、万恶之源就是,多线程的抢占式执行,带来的随机性!

如果没有多线程,此时程序代码执行顺序就是固定的 (只有一条路),所以结果也是固定的;如果存在多线程,此时抢占式执行下,代码执行的顺序会出现更多的变数,代码执行顺序的可能性就从一种情况变成了无数种情况!所以就需要保证这无数种线程调度顺序的情况下,代码的执行结果都是正确的!!!只要有一种情况执行结果不正确就都视为有bug,即线程不安全~~
在这里插入图片描述

3.2 观察线程不安全

class Counter{
    public int count = 0;

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

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

        // 搞两个线程,分别针对 counter 来调用 5w 次的 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) {
            throw new RuntimeException(e);
        }

        // 打印最终的 count 值
        System.out.println("count = " + counter.count);
    }
}

三次运行代码得到的结果:
在这里插入图片描述在这里插入图片描述在这里插入图片描述

3.3 分析问题

上面的线程不安全的代码中,涉及到多个线程针对counter.count变量进行修改,此时这个counter.count是一个多个线程都能访问到的"共享数据"。
在这里插入图片描述

counter.count 这个变量就是在堆上,因此可以被多个线程共享访问。

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述
在这里插入图片描述

甚至一个线程走三步的过程中,另一个线程走了两个三步:
在这里插入图片描述
因此会有无穷种组合!!!且count结果 > < = 50000 都有可能~~
前两种情况是正确的,没有线程安全问题;之后的情况都会出现"后一次自增覆盖了前一次的结果"的问题!!!

其实就和事务读未提交read uncommitted是一样的,相当于t1读到的是一个t2还没来得及提交的脏数据,于是就出现了脏读问题!
此处讲的多线程和前面的并发事务本质上都是 并发编程 问题!并发处理事务,底层也一定是基于多线程这样的方式来实现的~~

3.4 进一步理解 CPU指令

在这里插入图片描述

CPU有个重要的组成部分:寄存器。
寄存器也能存数据,但空间更小,访问速度更快!CPU进行的运算都是针对寄存器中的数据进行的~~
在这里插入图片描述
CPU里的寄存器有很多种:
1.通用寄存器 (用来参与运算的),如EAX、EBX、ECX…
2.专用寄存器 (有特定功能的),如EBP、ESP、EIP

保存上下文时:用PCB里的内存把当前所有的寄存器都给保存起来!

机器指令就是汇编,机器指令就是直接在CPU上运行的,势必要经常操作寄存器~

在这里插入图片描述

在这里插入图片描述

3.5 线程不安全的原因

到底是什么样的情况会出现线程安全问题?是所有的多线程代码都会涉及到线程安全问题吗??
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

在这里插入图片描述

3.6 从原子性入手解决

如何从原子性入手来解决线程安全问题?
加锁! 通过加锁,把不是原子的转成"原子"的~~

在这里插入图片描述

在这里插入图片描述
即使 t1这会儿没在CPU上执行,但是没有释放锁,t2仍然得阻塞等待!

在这里插入图片描述
在这里插入图片描述
修改代码:
在这里插入图片描述在这里插入图片描述

class Counter{
    public int count = 0;

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

运行结果:
在这里插入图片描述
达到预期~~ 加锁之后,线程安全问题就得到改善了!

详解请看第四章:加锁与死锁!~~

3.7 Java标准库中的线程安全类

Java标准库中很多都是线程不安全的,这些类可能会涉及到多线程修改共享数据,又没有任何加锁措施:(如果多个线程操作同一个集合类,就需要考虑到线程安全问题)
在这里插入图片描述

但是还有一些是线程安全的,使用了一些锁机制来控制:
在这里插入图片描述
在这里插入图片描述

StringBuffer的核心方法都带有synchronized

线程安全的为什么不推荐使用?为啥不都加上锁呢??
因为加锁这个操作是有副作用:额外的时间开销的!!!

还有的虽然没有加锁,但是不涉及 “修改”,所以仍然是线程安全的:

  • String

四、加锁与死锁详解(JVM)

4.1 synchronized的使用

监视器锁monitor lock:JVM给synchronized起了这个名字,因此代码中有时候出异常可能会看到这个说法。

在这里插入图片描述

在这里插入图片描述
所以是线程对对象加锁,锁不是加到方法上的!!!(虽然synchronized是修饰了方法)

在这里插入图片描述

我们重点要理解,synchronized锁的是什么,两个线程竞争同一把锁,才会产生阻塞等待!

两个线程分别尝试获取两把不同的锁,不会产生竞争~~
在这里插入图片描述

“同一把锁”是看针对的是否为“同一个对象”!

在这里插入图片描述
在这里插入图片描述

在这里插入图片描述
在这里插入图片描述

在这里插入图片描述

synchronized的力量是JVM提供的;jvm的力量是操作系统提供的;操作系统的力量是CPU提供的。
追根溯源,是CPU提供了加锁这样的指令才能让操作系统实现锁;操作系统把锁的API提供给JVM;JVM提供给synchronized。

4.2 可重入

synchronized 同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题。

一个线程针对同一个对象,连续加锁两次,看是否会有问题~~ 如果没问题,就叫可重入的;如果有问题,就叫不可重入的。

在这里插入图片描述
在这里插入图片描述

因为在Java中类似代码是很容易出现的,为了避免不小心就死锁,Java就synchronized设定成可重入的了,即可重入锁!!!(但是C++、Python、操作系统原生的锁,都是不可重入的…)

在这里插入图片描述
在可重入锁的内部,包含了 “线程持有者” 和 “计数器” 两个信息。
如果某个线程加锁的时候,发现锁已经被人占用,但是恰好占用的正是自己,那么仍然可以继续获取到锁,并让计数器自增,解锁的时候计数器递减为 0 的时候,才真正释放锁。(才能被别的线程获取到)

4.3 死锁

死锁是一个非常影响程序猿幸福感的问题。
一旦程序出现死锁,就会导致线程直接跪了 (无法继续执行后续工作了)!程序势必会有严重bug。死锁非常隐蔽,开发阶段不经意间就会写出死锁代码,且不容易测试出来。

4.3.1 死锁的几种情况

1) 一个线程,一把锁连续加锁两次,如果锁是不可重入锁,就会死锁!
Java里synchronized和ReentrantLock都是可重入锁,这个现象演示不了~~

2) 两个线程两把锁,t1和t2各自先针对锁A和锁B加锁,再尝试获取对方的锁。
举个例子,东北地区人吃饺子蘸酱油;西北地区人吃饺子蘸醋。有天一个东北人和一个西北人结伴吃饺子,两人分别先拿了酱油和醋,然后同时想要获取到对方的醋 / 酱油。如果两人互不相让,就僵持住了,这就是死锁!

public class ThreadDemo {
    public static void main(String[] args){
        Object jiangyou = new Object();
        Object cu = new Object();

        Thread dong = new Thread(() -> {
            synchronized (jiangyou){
                try {
                    Thread.sleep(1000);  // 确保线程先把第一个锁拿到,否则不容易构造出来,因为线程是抢占式执行的!

                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

                synchronized (cu){
                    System.out.println("东北人把酱油和醋都拿到了!");
                }
            }
        });

        Thread xi = new Thread(() -> {
            synchronized (cu){
                try {
                    Thread.sleep(1000);  // 确保线程先把第一个锁拿到,否则不容易构造出来,因为线程是抢占式执行的!
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

                synchronized (jiangyou){
                    System.out.println("西北人把酱油和醋都拿到了!");
                }
            }
        });
        dong.start();
        xi.start();
    }
}

Thread.sleep(1000);是为了确保两个线程先把第一个锁拿到,否则不容易构造出来,因为线程是抢占式执行的!
运行结果:
在这里插入图片描述
当前这里没有任何日志,说明没有线程拿到两把锁!

使用jconsole看一下线程的情况:
在这里插入图片描述
针对这样的死锁问题,也是需要借助像jconsole这样的工具来进行定位的,观察线程的状态和调用栈,就可以分析出代码是在哪里死锁了!

3) 多个线程多把锁 (相当于 2) 的一般情况)
教科书上的经典案例,哲学家就餐问题:
在这里插入图片描述
每个哲学家有两种状态:
1.思考人生 (相当于线程的阻塞状态);
2.拿起筷子吃面条 (相当于线程获取到锁然后执行一些计算)。
由于操作系统随机调度,这五个哲学家随时都可能想吃面条,也随时可能要思考人生~
要想吃面条,需要拿起左手和右手的两根筷子!

假设出现了极端情况,就会死锁!:
同一时刻,所有的哲学家同时拿起左手的筷子!这时所有的哲学家都拿不起来右手的筷子,都要等待右边的哲学家把筷子放下~

4.3.2 死锁的四个必要条件

锁更多,线程更多,情况就更复杂了。分析一下死锁到底是怎样形成的?
在这里插入图片描述
在这里插入图片描述

4.3.3 打破死锁

在这里插入图片描述

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

yyhgo_

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值