线程在操作系统内核中,是有很多种状态的,大体可以分为就绪与阻塞两种状态。
而在 Java 中,对于线程的状态,又做了更加明确的划分,Java 中的线程状态其实是一个枚举类型 Thread.State。
public class ThreadState {
public static void main(String[] args) {
for (Thread.State state : Thread.State.values()) {
System.out.println(state);
}
}
}
有以下几种状态:
下面写代码,验证一下各个状态:
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
}
}
下面这幅图,就描述了线程中各个状态的关系。
主干道是 NEW => RUNNABLE => TERMINATED
在 RUNNABLE 会根据特定代码进入支线任务,这些支线任务都是 ”阻塞状态“,这三种阻塞状态,进入的方式不同,同时阻塞的时间也不同,被唤醒的方式也不同。
线程安全问题的罪魁祸首,就是调度器的随机调度 / 抢占式执行的过程。
线程不安全,即在随即调度之下,程序的执行结果有多种可能,其中的某些可能就导致代码出现了 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++ 这一行代码,其实就对应三条机器指令。
这几个步骤在单线程下执行,没有任何问题。如果是多线程并发执行,就不一定了。
下面画图来演示几种可能的调度执行情况:
这就是两个线程并发自增 5w 次,而最终的结果却不是 10 w 的原因。
还有一个问题,为什么是 5w 到 10w 之间的随机数呢?
极端情况下,如果所有的指令排列恰好都是前两种,此时总和就是 10w.
极端情况下,如果所有的指令排列中,恰好都没有前两种,此时的总和就是 5w.
实际的情况,调度器具体调度多少次前两种情况,多少次后面的其他情况,是不确定的,因此最终的结果是 5w - 10w。
经过上面的观察,我们大致可以这么认为:
如果多线程环境下代码运行的结果是符合我们预期的,即在单线程环境中应该的结果,则说这个程序是线程安全的。
造成线程不安全的原因:
操作系统的随机调度 / 抢占式执行。
这是操作系统内核中已经写好的代码,我们无法改变,因此无能为力。
多个线程修改同一个变量。
注意此处的措辞。如果只是一个线程修改一个变量,那么不会造成线程安全问题;如果是多个线程读(不涉及写)同一个变量,也不会有问题;如果是多个线程修改不同的变量,也不会有问题。
【多个】【修改】【同一个】这三个条件缺一不可。
因此我们在写代码的时候,就可以针对该要点进行控制,可以通过调整程序的设计,破坏上面的条件。(但是这种方法范围有限,不是所有的场景都适用)
有些修改操作,不是原子的。
不可拆分的最小单位,就叫原子。如通过 ”=“ 操作来赋值,就只对应一条机器指令,视为是原子的。通过 ++ 来修改,对应三条机器指令,则不是原子的。
内存可见性引起的线程安全问题。
一个线程修改,一个线程读,就特别容易因为内存可见性,引发问题。
如果是正常的情况下,线程1 在读和判断,线程2 突然写了一下。在线程2 写完之后,线程1 就能立即读到内存的变化,从而判断出现变化。
但是在程序运行过程中,可能会涉及到一个操作 ”优化“。
LOAD 是读内存,速度比操作寄存器要慢几千倍、几万倍。LOAD 读的操作太慢,反复读,并且每次读到的数据都一样,JVM 就做出了这样的优化,就不再重复的从内存中读了。而是只读一次,后续的每次操作就不再重新读了。
此时在优化之后,线程2 突然写了一个数据,由于线程1 已经优化成读寄存器了,因此线程2 的修改,线程1 感知不到。线程1 仍然使用旧的数据,就出现了问题。
这就是内存可见性问题(内存改了,但是在优化的背景下,读不到,看不见)。
针对这个问题,Java 就引入了 volatile 关键字,让程序员手动的禁止编译器对某个变量进行上述优化。(后面会详细介绍)
指令重排序
指令重排序,也是 操作系统 / 编译器 / JVM 优化操作的一种优化手段,调整了代码的执行顺序,从而达到提高效率的效果。
比如,去超市买菜的时候,按照超市摊位的顺序买菜,而不是按照购物清单的顺序东跑西跑。
如:Test t = new Test(); 就会有三个步骤:
(1) 创建内存空间
(2) 往这个内存空间上构造一个对象
(3) 把这个内存的引用赋值给 t。
此处就容易出现指令重排序引入的问题,2 和 3 的顺序是可以调换的,在单线程下,调换这两个的顺序,没有影响。
多线程下,另一个线程尝试读取 t 的引用。
如果是按照 2, 3,第二个线程读到 t 为非 null 的时候,此时 t 就一定是一个有效对象。
如果是按照 3, 2,第二个线程读到 t 为非 null 的时候,但 t 可能实际是一个无效对象(可能空有内存空间,但没有实际的对象)。
ava 里加锁有很多种方式,如 synchronized, ReentrantLock。
此处的 synchronized 从字面上翻译,叫做 “同步”。此处在 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 关键字就保证了,把这三条指令打包成了一个原子操作,从而避免了线程不安全。
加锁操作,是针对一个对象来进行的。
滑稽 => 线程。 坑位(的门上的锁)=> 要加锁的对象
1 号滑稽进入 1 号坑位,只是针对 1 号坑位进行了加锁。别人想进入 1 号坑,需要阻塞等待,但是如果想进入其他的空闲坑位,则不需要等待。
多个线程去调用 increase 方法,其实就是在针对这个 counter 对象来加锁。
此时,如果一个线程获取到锁了,另外的线程就要阻塞等待(多个线程对一个对象加锁,就是多个滑稽想进一个坑位);但是如果多个线程是尝试对不同的对象加锁,则相互之间不会出现互斥的情况(多个线程分别对多个不同的对象加锁,就是多个滑稽想进不同的坑位)。
在 Java 里,任何一个对象,都可以用来作为 锁对象。
这点是不太寻常的。C++、Python…各种其他的主流语言,都是专门搞了一类特殊的对象,用来作为锁对象,大部分的正常对象不能用来加锁。
每个对象,内存空间中有一个特殊的区域 - 对象头(JVM自带的,包含对象的一些特殊信息)。
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 指的是,是针对哪个对象进行加锁。
进了代码块 => 加锁。出了代码块 => 解锁
加锁的本质,就是给对象头里设置个标记。看起来绕,本质很简单,只是同一个对象会产生互斥竞争,不同的对象不会产生竞争。竞争的对象是什么样的对象,参与竞争的线程是什么样的线程,都不影响锁的规则。
如果是同一个类中的两个不同方法,一个方法加锁了,另一个方法没有加锁,那么两个线程分别操作这两个方法,此时依然是线程不安全的。如下面的代码:
看下面一段代码,在 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 关键字后,就达到了我们的预期效果。
volatile 操作相当于显式的禁用的编译器的优化,给对应的变量加上了 “内存屏障”(特殊的二进制指令)。JVM 在读这个变量的时候,因为内存屏障的存在,就知道每次都要重新获取这个内存的内容,而不是草率地进行优化。(频繁读内存,虽然速度慢了,但是数据算的对了)
我们去掉 volatile 关键字,在循环体内加上 sleep,观察代码的运行结果。
发现也达到了我们的预期效果。
编译器的优化,是根据代码的实际情况来的。上个版本里循环体是空,所以循环转速极快,导致了读内存操作非常频繁,所以就触发了优化。当前版本里加了 sleep,让循环转速一下就慢了,读内存操作就不怎么频繁了,就不触发优化了。
编译器优化其实很多时候,是一个 “玄学问题”。
由于我们也不好确定,编译器什么时候优化,什么时候不优化,所以就还是得在必要的时候加上 volatile。
再看下面这张图:
这张图网上非常常见。
线程优化之后,主要在操作工作内存,没有及时读取主内存,导致出现误判。
注意:工作内存,不是真正的内存,指的就是 CPU 的寄存器(还可能是加上 CPU 缓存)。
主内存,才是真正的内存。
上述过程,Java 单独起了个名字,叫做 JMM (Java Memory Model),即 Java 内存模型。
工作内存虽然叫内存,但不是内存。这就像鲸鱼叫鱼,但其实不是鱼,是哺乳动物(翻译的问题)。