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

文章目录

  • 1. 线程的状态
  • 2. 线程安全问题
    • 2.1 观察线程不安全
    • 2.2 线程安全的概念
    • 2.3 线程不安全的原因
  • 3. 线程不安全的解决方案
    • 3.1 synchronized 关键字 (监视器锁 moniter lock)
      • 3.1.1 synchronized 的特性
      • 3.1.2 synchronized 使用示例
    • 3.2 volatile 关键字
      • 3.2.1 volatile 能保证内存可见性 / 禁止指令重排序

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);        
		}    
	}
}

有以下几种状态:

  • 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
    }
}

【JavaEE】详解线程与线程安全_第1张图片

下面这幅图,就描述了线程中各个状态的关系。
【JavaEE】详解线程与线程安全_第2张图片
主干道是 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);
    }
}

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

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

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

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

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

下面画图来演示几种可能的调度执行情况:
【JavaEE】详解线程与线程安全_第3张图片
这就是两个线程并发自增 5w 次,而最终的结果却不是 10 w 的原因。

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


2.2 线程安全的概念

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


2.3 线程不安全的原因

造成线程不安全的原因:

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

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

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

  4. 内存可见性引起的线程安全问题。
    一个线程修改,一个线程读,就特别容易因为内存可见性,引发问题。
    【JavaEE】详解线程与线程安全_第4张图片
    如果是正常的情况下,线程1 在读和判断,线程2 突然写了一下。在线程2 写完之后,线程1 就能立即读到内存的变化,从而判断出现变化。
    但是在程序运行过程中,可能会涉及到一个操作 ”优化“。
    LOAD 是读内存,速度比操作寄存器要慢几千倍、几万倍。LOAD 读的操作太慢,反复读,并且每次读到的数据都一样,JVM 就做出了这样的优化,就不再重复的从内存中读了。而是只读一次,后续的每次操作就不再重新读了。
    【JavaEE】详解线程与线程安全_第5张图片
    此时在优化之后,线程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 就不会直接成功。
【JavaEE】详解线程与线程安全_第6张图片
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 使用示例

加锁操作,是针对一个对象来进行的。
【JavaEE】详解线程与线程安全_第7张图片
滑稽 => 线程。 坑位(的门上的锁)=> 要加锁的对象

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

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

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

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

每个对象,内存空间中有一个特殊的区域 - 对象头(JVM自带的,包含对象的一些特殊信息)。
【JavaEE】详解线程与线程安全_第8张图片
synchronized 本质上要修改指定对象的 “对象头”. 从使用角度来看, synchronized 也势必要搭配一个具体的对象来使用.

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

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

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

public class Demo {
	public synchronized void methond() {
	}
}

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

class Demo {
    // 下面两种写法是等价的
    
    synchronized public void func1() {
        
    }
    
    public void func2() {
        synchronized (this) {
            
        }
    }
}

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

public class Demo {
	public synchronized static void method() {
	}
}

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

class Demo {
    
    // 下面两种写法是等效的
    
    synchronized public static void func1() {

    }

    public static void func2() {
        synchronized (Demo.class) {

        }
    }
}

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

public class SynchronizedDemo {
	public void method() {
		synchronized (this) {
		
        }
	}
}

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

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

如果是同一个类中的两个不同方法,一个方法加锁了,另一个方法没有加锁,那么两个线程分别操作这两个方法,此时依然是线程不安全的。如下面的代码:
【JavaEE】详解线程与线程安全_第9张图片


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();
    }
}

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

这就是内存可见性问题。

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

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

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

我们去掉 volatile 关键字,在循环体内加上 sleep,观察代码的运行结果。
【JavaEE】详解线程与线程安全_第11张图片
【JavaEE】详解线程与线程安全_第12张图片
发现也达到了我们的预期效果。

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

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

再看下面这张图:
【JavaEE】详解线程与线程安全_第13张图片
这张图网上非常常见。

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

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

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

你可能感兴趣的:(java-ee,java,jvm)