• 【JavaEE】详解线程与线程安全


    1. 线程的状态

    线程在操作系统内核中,是有很多种状态的,大体可以分为就绪与阻塞两种状态。
    而在 Java 中,对于线程的状态,又做了更加明确的划分,Java 中的线程状态其实是一个枚举类型 Thread.State。

    public class ThreadState {    
    	public static void main(String[] args) {        
    		for (Thread.State state : Thread.State.values()) {       
    			System.out.println(state);        
    		}    
    	}
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    有以下几种状态:

    • NEW: 安排了工作, 还未开始行动
      也就是说,创建了 Thread 对象,但是还没有调用 start 方法,还未在系统内核中创建该线程。举个栗子,也就是把新员工招聘进来了,把任务交给了他,但此时还没有让他开始干活。
    • RUNNABLE: 可工作的. 又可以分成正在工作中和即将开始工作.
      此时线程处于就绪状态,又可以划分为两种状态:
      (1) 当前线程正在 CPU 上运行
      (2) 还没有在 CPU 上运行,但是已经准备好了,随时可以工作
    • BLOCKED: 这几个都表示排队等着其他事情(阻塞)
      表明当前线程正在等待锁的释放。
    • WAITING: 这几个都表示排队等着其他事情(阻塞)
      表明在线程中调用了 wait 方法。
    • TIMED_WAITING: 这几个都表示排队等着其他事情(阻塞)
      表明线程是通过 sleep 方法进入的阻塞。
    • TERMINATED: 工作完成了.
      系统里面的线程已经执行完毕,并且销毁了(相当于线程的 run 方法执行完了),但是 Thread 对象还存在。

    下面写代码,验证一下各个状态:

    public class Test1 {
        public static void main(String[] args) throws InterruptedException {
            Thread t = new Thread(() -> {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            });
    
            // 在线程 start 之前获取
            System.out.println(t.getState()); // NEW
            t.start();
            // Thread.sleep(500);
            // 获取到的是线程执行中的状态,因为此时 t 线程还未进入到 sleep,主线程就已经打印
            // 放开主线程中的 sleep,让主线程等待 t 线程进入 sleep 后再打印,得到的可能就是 TIMED_WAITING
            System.out.println(t.getState()); // RUNNABLE
    
            t.join();
            // 等待 t 线程执行结束后获取
            System.out.println(t.getState()); // TERMINATED
        }
    }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24

    在这里插入图片描述

    下面这幅图,就描述了线程中各个状态的关系。
    在这里插入图片描述
    主干道是 NEW => RUNNABLE => TERMINATED

    在 RUNNABLE 会根据特定代码进入支线任务,这些支线任务都是 ”阻塞状态“,这三种阻塞状态,进入的方式不同,同时阻塞的时间也不同,被唤醒的方式也不同。


    2. 线程安全问题

    2.1 观察线程不安全

    线程安全问题的罪魁祸首,就是调度器的随机调度 / 抢占式执行的过程。
    线程不安全,即在随即调度之下,程序的执行结果有多种可能,其中的某些可能就导致代码出现了 bug,与我们预期的结果不相符,这就叫做线程不安全 / 线程安全问题。

    下面看一个典型的例子:
    两个线程对同一个变量进行并发的自增。

    // 创建两个线程,让这两个线程同时并发的对一个变量自增 5w 次,预期最终一共能够自增 10w 次
    class Counter {
        // 用来保存计数结果的变量,初始为 0
        public int count;
    
        public void increase() {
            count++;
        }
    }
    
    public class Test2 {
        // 该实例用来自增
        public static Counter counter = new Counter();
    
        public static void main(String[] args) throws InterruptedException {
            // 创建两个线程分别自增 5w 次
            Thread t1 = new Thread(() -> {
                for(int i = 0; i < 50000; i++) {
                    counter.increase();
                }
            });
    
            Thread t2 = new Thread(() -> {
                for(int i = 0; i < 50000; i++) {
                    counter.increase();
                }
            });
    
            t1.start();
            t2.start();
    
            // 主线程等待自增结束
            t1.join();
            t2.join();
    
            System.out.println(counter.count);
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38

    在这里插入图片描述
    此时运行的时候就发现了,每次运行得到的结果都不太一样。是一个比 5w 多,比 10w 少的随机数(可以自己多试几次,最终的结果都会落在这个范围中)。

    这是因为每次系统随机调度的顺序都不同,就导致每次程序的运行结果都不同了。

    像 count++ 这一行代码,其实就对应三条机器指令。

    1. 从内存读取数据到 CPU (load)
    2. 在 CPU 寄存器中,完成加法运算 (add)
    3. 把寄存器的数据写回到内存中 (save)

    这几个步骤在单线程下执行,没有任何问题。如果是多线程并发执行,就不一定了。

    下面画图来演示几种可能的调度执行情况:
    在这里插入图片描述
    这就是两个线程并发自增 5w 次,而最终的结果却不是 10 w 的原因。

    还有一个问题,为什么是 5w 到 10w 之间的随机数呢?
    极端情况下,如果所有的指令排列恰好都是前两种,此时总和就是 10w.
    极端情况下,如果所有的指令排列中,恰好都没有前两种,此时的总和就是 5w.
    实际的情况,调度器具体调度多少次前两种情况,多少次后面的其他情况,是不确定的,因此最终的结果是 5w - 10w。


    2.2 线程安全的概念

    经过上面的观察,我们大致可以这么认为:
    如果多线程环境下代码运行的结果是符合我们预期的,即在单线程环境中应该的结果,则说这个程序是线程安全的。


    2.3 线程不安全的原因

    造成线程不安全的原因:

    1. 操作系统的随机调度 / 抢占式执行。
      这是操作系统内核中已经写好的代码,我们无法改变,因此无能为力。

    2. 多个线程修改同一个变量。
      注意此处的措辞。如果只是一个线程修改一个变量,那么不会造成线程安全问题;如果是多个线程读(不涉及写)同一个变量,也不会有问题;如果是多个线程修改不同的变量,也不会有问题。
      【多个】【修改】【同一个】这三个条件缺一不可。
      因此我们在写代码的时候,就可以针对该要点进行控制,可以通过调整程序的设计,破坏上面的条件。(但是这种方法范围有限,不是所有的场景都适用)

    3. 有些修改操作,不是原子的。
      不可拆分的最小单位,就叫原子。如通过 ”=“ 操作来赋值,就只对应一条机器指令,视为是原子的。通过 ++ 来修改,对应三条机器指令,则不是原子的。

    4. 内存可见性引起的线程安全问题。
      一个线程修改,一个线程读,就特别容易因为内存可见性,引发问题。
      在这里插入图片描述
      如果是正常的情况下,线程1 在读和判断,线程2 突然写了一下。在线程2 写完之后,线程1 就能立即读到内存的变化,从而判断出现变化。
      但是在程序运行过程中,可能会涉及到一个操作 ”优化“。
      LOAD 是读内存,速度比操作寄存器要慢几千倍、几万倍。LOAD 读的操作太慢,反复读,并且每次读到的数据都一样,JVM 就做出了这样的优化,就不再重复的从内存中读了。而是只读一次,后续的每次操作就不再重新读了。
      在这里插入图片描述
      此时在优化之后,线程2 突然写了一个数据,由于线程1 已经优化成读寄存器了,因此线程2 的修改,线程1 感知不到。线程1 仍然使用旧的数据,就出现了问题。
      这就是内存可见性问题(内存改了,但是在优化的背景下,读不到,看不见)。
      针对这个问题,Java 就引入了 volatile 关键字,让程序员手动的禁止编译器对某个变量进行上述优化。(后面会详细介绍)

    5. 指令重排序
      指令重排序,也是 操作系统 / 编译器 / JVM 优化操作的一种优化手段,调整了代码的执行顺序,从而达到提高效率的效果。
      比如,去超市买菜的时候,按照超市摊位的顺序买菜,而不是按照购物清单的顺序东跑西跑。
      如:Test t = new Test(); 就会有三个步骤:
      (1) 创建内存空间
      (2) 往这个内存空间上构造一个对象
      (3) 把这个内存的引用赋值给 t。
      此处就容易出现指令重排序引入的问题,2 和 3 的顺序是可以调换的,在单线程下,调换这两个的顺序,没有影响。
      多线程下,另一个线程尝试读取 t 的引用。
      如果是按照 2, 3,第二个线程读到 t 为非 null 的时候,此时 t 就一定是一个有效对象。
      如果是按照 3, 2,第二个线程读到 t 为非 null 的时候,但 t 可能实际是一个无效对象(可能空有内存空间,但没有实际的对象)。


    3. 线程不安全的解决方案

    3.1 synchronized 关键字 (监视器锁 moniter lock)

    ava 里加锁有很多种方式,如 synchronized, ReentrantLock。

    此处的 synchronized 从字面上翻译,叫做 “同步”。此处在 synchronized 这里说的 “同步” 指的是 “互斥”。

    互斥,就和谈对象是一样的。通常情况下,一个人同一时刻只能谈一个对象,我和一个小哥哥好上了,那么我就不允许别的妹子接近他。

    3.1.1 synchronized 的特性

    我们再回到之前写的自增 10 w 次的代码。
    在这里插入图片描述
    我们把 increase 方法加上 synchronized 后,运行结果就变成了 10w。
    在这里插入图片描述

    使用 synchronized 关键字后,就会多了 LOCK 和 UNLOCK 指令。
    LOCK 这个指令是存在互斥的。当 t1 线程进行 LOCK 之后,t2 也尝试 LOCK,t2 的 LOCK 就不会直接成功。
    在这里插入图片描述
    t2 执行 LOCK 的时候发现 t1 已经加上锁了,t2 此处无法完成 LOCK 操作,就会阻塞等待(BLOCKED),要阻塞等待到 t1 把锁释放(UNLOCK)。当 t1 释放锁之后,t2 才有可能获取到锁(从 LOCK 中返回,并且继续往下执行)。

    t2 到底能不能拿到锁,得看接下来有多少人和他竞争。如果没有竞争者,才一定能拿到锁,否则是有一定的可能性拿不到锁的。

    已经是进入了 LOCK 指令,进入 BLOCKED 状态的线程,才是竞争者。

    比如,我可能认识 100 个小哥哥,但是其中 50 个对我表白了(我现在已经有男朋友了,但是他们因为表白过了,所以才在等我分手,成为我的备胎),这 50 个才是竞争关系,另外 50 个只是普通朋友。

    在加锁的情况下,线程的执行三个指令就被岔开了。岔开之后,就能够保证到一个线程 save 了之后,另一个线程才 load,于是此时计算结果就准了。synchronized 关键字就保证了,把这三条指令打包成了一个原子操作,从而避免了线程不安全。


    3.1.2 synchronized 使用示例

    加锁操作,是针对一个对象来进行的。
    在这里插入图片描述
    滑稽 => 线程。 坑位(的门上的锁)=> 要加锁的对象

    1 号滑稽进入 1 号坑位,只是针对 1 号坑位进行了加锁。别人想进入 1 号坑,需要阻塞等待,但是如果想进入其他的空闲坑位,则不需要等待。

    多个线程去调用 increase 方法,其实就是在针对这个 counter 对象来加锁。

    此时,如果一个线程获取到锁了,另外的线程就要阻塞等待(多个线程对一个对象加锁,就是多个滑稽想进一个坑位);但是如果多个线程是尝试对不同的对象加锁,则相互之间不会出现互斥的情况(多个线程分别对多个不同的对象加锁,就是多个滑稽想进不同的坑位)。

    在 Java 里,任何一个对象,都可以用来作为 锁对象。
    这点是不太寻常的。C++、Python…各种其他的主流语言,都是专门搞了一类特殊的对象,用来作为锁对象,大部分的正常对象不能用来加锁。

    每个对象,内存空间中有一个特殊的区域 - 对象头(JVM自带的,包含对象的一些特殊信息)。
    在这里插入图片描述
    synchronized 本质上要修改指定对象的 “对象头”. 从使用角度来看, synchronized 也势必要搭配一个具体的对象来使用.

    无论是使用哪种用法,使用 synchronized 的时候都要明确锁对象(明确是对哪个对象加锁)。

    只有当两个线程针对同一个对象加锁的时候,才会发生竞争;如果是两个线程针对不同的对象加锁,则没有竞争。
    就像两个男生追同一个妹子会发生竞争,两个男生追两个不同的妹子,则没有竞争。

    下面看 synchronized 的几种用法:
    (1) 直接修饰普通方法:锁的 Demo 对象

    public class Demo {
    	public synchronized void methond() {
    	}
    }
    
    • 1
    • 2
    • 3
    • 4

    相当于是针对 this 进行加锁,this 可以对应多个不同的实例。

    class Demo {
        // 下面两种写法是等价的
        
        synchronized public void func1() {
            
        }
        
        public void func2() {
            synchronized (this) {
                
            }
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    (2) 修饰静态方法:锁的 Demo 的类对象

    public class Demo {
    	public synchronized static void method() {
    	}
    }
    
    • 1
    • 2
    • 3
    • 4

    相当于是针对 类对象 加锁。类对象在整个 JVM 中只有一个。
    JVM 加载类的时候就会读取 .class 文件,构造类对象在内存中,通过 类名.class 的方式就能拿到这个类的类对象。
    针对 static 方法加锁 => synchronized (类名.class) {}

    class Demo {
        
        // 下面两种写法是等效的
        
        synchronized public static void func1() {
    
        }
    
        public static void func2() {
            synchronized (Demo.class) {
    
            }
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    (3) 修饰代码块:明确指定锁哪个对象

    public class SynchronizedDemo {
    	public void method() {
    		synchronized (this) {
    		
            }
    	}
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    () 里的 this 指的是,是针对哪个对象进行加锁。
    进了代码块 => 加锁。出了代码块 => 解锁

    加锁的本质,就是给对象头里设置个标记。看起来绕,本质很简单,只是同一个对象会产生互斥竞争,不同的对象不会产生竞争。竞争的对象是什么样的对象,参与竞争的线程是什么样的线程,都不影响锁的规则。

    如果是同一个类中的两个不同方法,一个方法加锁了,另一个方法没有加锁,那么两个线程分别操作这两个方法,此时依然是线程不安全的。如下面的代码:
    在这里插入图片描述


    3.2 volatile 关键字

    3.2.1 volatile 能保证内存可见性 / 禁止指令重排序

    看下面一段代码,在 t1 线程中执行死循环,t2 线程中修改新线程的循环判定条件。

    public class Test3 {
        public static int flg;
    
        public static void main(String[] args) {
            Thread t1 = new Thread(() -> {
                while(flg == 0) {
                    // 执行循环,但此处循环什么也不做
                }
                System.out.println("t1 end");
            });
            t1.start();
    
            Thread t2 = new Thread(() -> {
                // 让用户输入一个数字,赋值给 flg
                Scanner scanner = new Scanner(System.in);
                System.out.println("请输入一个整数");
                flg = scanner.nextInt();
            });
            t2.start();
        }
    }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22

    此代码的预期效果是,t2 线程里输入了一个非零的数字,此时 t1 线程循环结束,随之进程结束。
    在这里插入图片描述
    实际的现象是:当我们输入非 0 的数字时,t1 线程并没有结束。

    这就是内存可见性问题。

    t1 做的工作:(1) LOAD 读内存的数据到 CPU 寄存器
    (2) TEST 检测 CPU 寄存器的值是否和预期的相同
    反复进行多次,频繁进行。编译器就会优化为只从内存中读一次数据,然后后续直接从寄存器里反复 TEST。

    编译器优化是属于编译器自带的功能,正常来说,程序员不好干预。但是因为上述场景,编译器知道自己可能会出现误判,因此就给程序员提供了一个干预优化的途径 —— volatile 关键字。

    加上 volatile 关键字后,就达到了我们的预期效果。
    在这里插入图片描述
    在这里插入图片描述
    volatile 操作相当于显式的禁用的编译器的优化,给对应的变量加上了 “内存屏障”(特殊的二进制指令)。JVM 在读这个变量的时候,因为内存屏障的存在,就知道每次都要重新获取这个内存的内容,而不是草率地进行优化。(频繁读内存,虽然速度慢了,但是数据算的对了)

    我们去掉 volatile 关键字,在循环体内加上 sleep,观察代码的运行结果。
    在这里插入图片描述
    在这里插入图片描述
    发现也达到了我们的预期效果。

    编译器的优化,是根据代码的实际情况来的。上个版本里循环体是空,所以循环转速极快,导致了读内存操作非常频繁,所以就触发了优化。当前版本里加了 sleep,让循环转速一下就慢了,读内存操作就不怎么频繁了,就不触发优化了。

    编译器优化其实很多时候,是一个 “玄学问题”。
    由于我们也不好确定,编译器什么时候优化,什么时候不优化,所以就还是得在必要的时候加上 volatile。

    再看下面这张图:
    在这里插入图片描述
    这张图网上非常常见。

    线程优化之后,主要在操作工作内存,没有及时读取主内存,导致出现误判。

    注意:工作内存,不是真正的内存,指的就是 CPU 的寄存器(还可能是加上 CPU 缓存)。
    主内存,才是真正的内存。
    上述过程,Java 单独起了个名字,叫做 JMM (Java Memory Model),即 Java 内存模型。

    工作内存虽然叫内存,但不是内存。这就像鲸鱼叫鱼,但其实不是鱼,是哺乳动物(翻译的问题)。

  • 相关阅读:
    Android逆向学习(二)vscode进行双开与图标修改
    phpstorm配置xdebug插件后: Process finished with exit code 0咋整?
    xcode SDK does not contain ‘libarclite‘
    【排序算法】冒泡排序
    QT添加菜单栏-工具栏-中心区域-状态栏-dock 示范
    矩阵键盘的扫描原理与基础应用
    从Google角度看:Android渲染体系设计→Flutter渲染体系设计
    安装robotframework的RIDE/wxPython 依赖VC的编译器, 使用VC BuildTool 解决
    云原生架构:面向初学者的完整概述
    推进高校学生党建工作数字化建设的思考
  • 原文地址:https://blog.csdn.net/qq_62594207/article/details/126464147