多线程Thread(初阶三:线程的状态及线程安全)

简介: 多线程Thread(初阶三:线程的状态及线程安全)



一、线程的状态

1.NEW Thread:对象创建好了,但是还没有调用 start 方法在系统中创建线程。

2.TERMINATED: Thread 对象仍然存在,但是系统内部的线程已经执行完毕了。

3.RUNNABLE: 就绪状态,表示这个线程正在 cpu 上执行,或者准备就绪随时可以去 pu 上执行。

4.TIMED WAITING: 指定时间的阻塞.就在到达一定时间之后自动解除阻塞使用 sleep 会进入这个状态 使用带有超时时间的join也会。

5.WAITING: 不带时间的阻塞(死等),必须要满足一定的条件,才会解除阻塞join 或者 wait 都会进入 WAITING。

6.BLOCKED: 由于锁竞争,引起的阻塞.(后面线程安全的时候具体介绍)

这六种状态在生命周期的大概位置,如图:

用代码形式测试 NEW 、RUNNABLE、TERMINATED 状态

代码:

public class ThreadDemo3 {
    private static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                count++;
            }
            System.out.println("count: " + count);
        });
        //t线程开始前
        System.out.println("t线程开始前: " + t.getState());
        t.start();
        //t线程执行中
        System.out.println("t线程执行中: " + t.getState());
        //t线程结束后
        t.join();
        System.out.println("t线程结束后: " + t.getState());
    }
}

执行效果:

学习这些状态,最大的作用就是调试多线程代码bug的时候,给我们提供重要的参考依据当某个程序卡住了,也就是意味着一些关键的线程阻塞了,我们就可以观察线程的状态,分享出一些原因。

注意:一个线程只能start一次,也就是说只有是NEW状态的线程才能start。

二、线程安全

线程安全:某个代码,不管它是单个线程执行,还是多个线程执行,都不会产生bug,这个情况就成为“线程安全”。

线程不安全:某个代码,它单个线程执行,不会产生bug,但是多个线程执行,就会产生bug,这个情况就成为 “线程不安全”,或者 “存在线程安全问题”。

举个线程不安全例子,现在,我们想计算一个变量的自增次数,它循环了100000次,用两个线程去计算,各自计算循环50000次的次数。

1、线程不安全样例

(1)根本原因,代码结构,直接原因的例子:

根本原因:线程的随机调度,抢占式执行

代码结构:不同线程修改同一数据

直接原因:多线程操作不是原子的

代码:

public class ThreadDemo4 {
    private static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 1; i < 50000; i++) {
                count++;
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 50000; i <= 100000; i++) {
                count++;
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("count: " + count);
    }
}

我们知道,从1到10_0000,肯定是自增了10_0000次,但是我们看看输出结果如何?

答案却不是10_0000,是50000多次,这是为何呢?原因就是多线程代码,它们是并发执行的。

这个count++是由cpu的三个指令构成的:

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

(2)add 把寄存器中的值 + 1。

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

因为上面两个线程是并发执行的,那么 t1 和 t2 线程的执行顺序就是无序的,他们可能同时读取数据,自增完再+1,下面用图示他们的一些可能性。

暂时就画这么多,因为线程并发的执行结果个数是无数的,并不是简单的排列组合就能穷举出来,因为并发的原因,可能 t1 线程它执行了两次,才执行一次 t2 线程,也可能更多,或者 t2 执行的次数更多,t1 线程只执行一次。就又要排列组合了,这些情况都是有可能的。

这时,t1 和 t2自增的时候,就可能拿的是同一个值,这两线程的其中一个自增后,没放回内存中,另一个线程就又拿了,这肯定是不符合我们预期的。

所以我们从上面的可能情况找,符合我们预期的效果就只有这两个了,如图

那么这种情况也就是串行化执行,执行完 t1,再执行t2,或者两个顺序相反。

代码:

public class ThreadDemo4 {
    private static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 1; i < 50000; i++) {
                count++;
            }
        });
        Thread t2 = new Thread(() -> {
            try {
                t1.join();
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            for (int i = 50000; i <= 100000; i++) {
                count++;
            }
        });
        t1.start();
        t2.start();
        t2.join();
        System.out.println("count: " + count);
    }
}

执行效果:

但这样就没必要使用多线程了,因为串行化,一个线程就能搞定了,使用多线程也没太大意义。

(2)内存可见性例子

一个线程读,一个线程写,也可能存在线程安全问题。

代码:

public class ThreadDemo3 {
    private static int flag = 1;
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            while (flag == 1) {
                //循环题里,啥也不写
            }
            System.out.println("t1线程结束");
        });
        Thread t2 = new Thread(() -> {
            System.out.println("请输入flag的值");
            Scanner scanner = new Scanner(System.in);
            flag = scanner.nextInt();
        });
        t1.start();
        t2.start();
    }
}

执行预期是当我们输入不等于1的值,就打印 “t1线程结束”,但结果却不是,执行结果如下:

flag值是0吗?t1线程也在一直在循环下去,但t2线程是实实在在的改变flag的值,这是为什么呢?

这就涉及到了jvm内部的优化了,和内存可见性相关;

t1线程的flag==0这一操作有两个核心指令:

(1)load,读取内存flag的值到寄存器

(2)拿着寄存器中的值和0比较(条件跳转指令)

这里load的每次操作取到的值都是一样的,而当我们执行scanner操作,修改flag的值,load这一指令,已经执行上百亿次了,我们知道,从内存中取数据这一操作是非常耗时的,远远比条件跳转指令花时间,这时因为load开销太大,jvm就会产生怀疑,怀疑这个load操作是否有必要?从而给出优化,把从内存读数据load这一操作给优化掉了,比较激进,从而不会再从内存中拿数据,而是把load拿到的值放到寄存器中,从寄存器拿到数据,进行比较。这样可以大幅度的提高循环的执行速度。

补充:编译器 / jvm是非常厉害的,很多地方都涉及到优化,jvm可以智能的分析出你写的代码哪里不合适,对你的代码进行调整,在保证你代码原有的逻辑不变前提下,提高程序的执行效率。这个事情,单线程情况下还好,但是多线程就容易出现 误判

而上面的例子,t2修改了内存,但是t1没看到内存变化,就称为内存可见性问题。而内存可见性问题,是高度依赖编译器优化的问题,啥时候会触发这一问题,不好说。

解决方案一:(t1 循环里加sleep)

代码:

public class ThreadDemo3 {
    private static int flag = 1;
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            while (flag == 1) {
                try {
                    Thread.sleep(1);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                //循环题里,啥也不写
            }
            System.out.println("t1线程结束");
        });
        Thread t2 = new Thread(() -> {
            System.out.println("请输入flag的值");
            Scanner scanner = new Scanner(System.in);
            flag = scanner.nextInt();
        });
        t1.start();
        t2.start();
    }
}

解释:t1 循环里面加不加sleep的区别是很大的,因为1秒中都可以让里面的循环执行上百亿次,这时,load的开销就非常大,代码的优化迫切程度就比较大;但是加了sleep后,load的开销就小了很多,代码的优化迫切程度就降低了。

解决方案二:(给flag变量加volatile修饰)

java提供volatile,其中一个核心作用,就是保证 “内存可见性”(另一功能:禁止指令重排序)可以使上述的优化被强制关闭,这样就可以确保循环每次都是从内存中拿数据了,但这样执行效率也会下降,但数据更为准确了,这在实际开发中,就要看需求,是期望数据更准一点,还是执行效率更快一点。

代码:

public class ThreadDemo3 {
    private volatile static int flag = 1;
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            while (flag == 1) {
                //循环题里,啥也不写
            }
            System.out.println("t1线程结束");
        });
        Thread t2 = new Thread(() -> {
            System.out.println("请输入flag的值");
            Scanner scanner = new Scanner(System.in);
            flag = scanner.nextInt();
        });
        t1.start();
        t2.start();
    }
}

执行效果:

这样,内存可见性的问题就能解决了。

补充:我们上面对编译器优化是这样表述的:编译器发现,每次循环都要从内存中读取数据,内存开销都太大了,于是把读取内存这一操作优化成读取寄存器这一操作。

但是在JMM模型是这样描述的:编译器发现,每次循环都要从 “主内存” 中读取数据,就会把数据从 “主内存” 中复制下到 “工作内存” 中,后续每次读取都是在 “工作内容” 这读取。(这里的“工作内容代指cpu寄存器 + 缓存这一套东西”)

这里的 “主内存” 翻译成内存,“工作内存” 翻译成cpu寄存器 ;为啥我们的描述和JMM的描述不同呢?为了能说明白,通俗易懂

JMM作为jav的规范文档,描述要严谨,java规范文档提供的这些词,本身也是抽象概念;如果描述成cpu寄存器或者缓存,会比较拗口,而且,不同的cpu,寄存器情况会不一样,缓存有几级也不一样,变数比较多,主要是为了严谨,也为了表述没那么绕,才引入“工作内容”这一词。而为啥要引入JMM(主内存,工作内容)呢?主要还是为了跨平台。

2、总结线程不安全的原因

(1)根本原因

       操作系统上的线程是“抢占式执行”,随机调度的。

(2)代码结构

       多个线程同时修改同一个变量。

(3)直接原因

       多线程修改操作,本身不是原子的,比如count++,在cpu上有3个指令,可能修改还没保存,就被其他线程调度走了。

(4)内存可见性

       一个线程读,一个线程写,也会导致线程安全的问题。

(5)指令重排序

       编译器的一种优化,在保证代码逻辑不变的情况下,将一些代码的指令重新排序,从而提高代码的执行效率,但是有时候会因为重排序后,多线程编程就会出现线程安全问题。

3、针对上述原因给出的解决方案

对于原因1

       我们无法给出解决方案,因为操作系统内部已经实现了“抢占式执行”,我们干预不了

对于原因2

       分情况,有的时候,代码结构可以调整,有的时候调整不了。

对于原因3

       把要修改的变量这操作,通过特殊手段,把这操作在系统里的多个指令打包成一个“整体”。例如加锁操作,而加锁的操作,就是把多个指令打包成一个原子的操作。

对于原因4

       可以对代码进行调整,避免内存可见性的问题;也可以使用volatile进行修饰,强制把代码优化关了,这样数据就更准确了,但执行效率也就变慢了。

对于原因5

       将某些可能会指令重排序的变量,加volatile修饰,强制取消指令重排序的优化。

注意:volatile可以解决内存可见性问题,也可以解决指令重排序的问题。


都看到这了,点个赞再走吧,谢谢谢谢谢

相关文章
|
3天前
|
NoSQL Redis
单线程传奇Redis,为何引入多线程?
Redis 4.0 引入多线程支持,主要用于后台对象删除、处理阻塞命令和网络 I/O 等操作,以提高并发性和性能。尽管如此,Redis 仍保留单线程执行模型处理客户端请求,确保高效性和简单性。多线程仅用于优化后台任务,如异步删除过期对象和分担读写操作,从而提升整体性能。
14 1
|
1天前
|
Java 程序员 调度
【JavaEE】线程创建和终止,Thread类方法,变量捕获(7000字长文)
创建线程的五种方式,Thread常见方法(守护进程.setDaemon() ,isAlive),start和run方法的区别,如何提前终止一个线程,标志位,isinterrupted,变量捕获
|
1天前
|
安全 Java API
【JavaEE】多线程编程引入——认识Thread类
Thread类,Thread中的run方法,在编程中怎么调度多线程
|
29天前
|
Java C# Python
线程等待(Thread Sleep)
线程等待是多线程编程中的一种同步机制,通过暂停当前线程的执行,让出CPU时间给其他线程。常用于需要程序暂停或等待其他线程完成操作的场景。不同语言中实现方式各异,如Java的`Thread.sleep(1000)`、C#的`Thread.Sleep(1000)`和Python的`time.sleep(1)`。使用时需注意避免死锁,并考虑其对程序响应性的影响。
|
2月前
|
Java 开发者
在Java多线程编程中,选择合适的线程创建方法至关重要
【10月更文挑战第20天】在Java多线程编程中,选择合适的线程创建方法至关重要。本文通过案例分析,探讨了继承Thread类和实现Runnable接口两种方法的优缺点及适用场景,帮助开发者做出明智的选择。
25 2
|
2月前
|
Java
在Java多线程编程中,实现Runnable接口通常优于继承Thread类
【10月更文挑战第20天】在Java多线程编程中,实现Runnable接口通常优于继承Thread类。原因包括:1) Java只支持单继承,实现接口不受此限制;2) Runnable接口便于代码复用和线程池管理;3) 分离任务与线程,提高灵活性。因此,实现Runnable接口是更佳选择。
56 2
|
1月前
|
数据采集 Java Python
爬取小说资源的Python实践:从单线程到多线程的效率飞跃
本文介绍了一种使用Python从笔趣阁网站爬取小说内容的方法,并通过引入多线程技术大幅提高了下载效率。文章首先概述了环境准备,包括所需安装的库,然后详细描述了爬虫程序的设计与实现过程,包括发送HTTP请求、解析HTML文档、提取章节链接及多线程下载等步骤。最后,强调了性能优化的重要性,并提醒读者遵守相关法律法规。
66 0
|
2月前
|
存储 消息中间件 资源调度
C++ 多线程之初识多线程
这篇文章介绍了C++多线程的基本概念,包括进程和线程的定义、并发的实现方式,以及如何在C++中创建和管理线程,包括使用`std::thread`库、线程的join和detach方法,并通过示例代码展示了如何创建和使用多线程。
60 1
|
2月前
|
Java 开发者
在Java多线程编程中,创建线程的方法有两种:继承Thread类和实现Runnable接口
【10月更文挑战第20天】在Java多线程编程中,创建线程的方法有两种:继承Thread类和实现Runnable接口。本文揭示了这两种方式的微妙差异和潜在陷阱,帮助你更好地理解和选择适合项目需求的线程创建方式。
32 3
|
2月前
|
Java
Java中多线程编程的基本概念和创建线程的两种主要方式:继承Thread类和实现Runnable接口
【10月更文挑战第20天】《JAVA多线程深度解析:线程的创建之路》介绍了Java中多线程编程的基本概念和创建线程的两种主要方式:继承Thread类和实现Runnable接口。文章详细讲解了每种方式的实现方法、优缺点及适用场景,帮助读者更好地理解和掌握多线程编程技术,为复杂任务的高效处理奠定基础。
41 2