线程状态
线程的状态实际上是一组枚举类型,其中主要包括以下几种状态:
NEW: 安排了工作但还未行动。创建了Thread对象。但还没调用start()方法。
RUNNING: 可⼯作的.,⼜可以分成正在⼯作中和即将开始⼯。
BLOCKED: 阻塞状态,大部分为因为锁而发生的阻塞等待。排队等待其它事情。
WAITING: 等待状态 ,因为调用无参的join()以及wait()方法,也就是死等。
TIMED_WAITING : 这与WAITING不同的就是有超时时间的等待。
TERMINATED : 内核线程工作已经完成,但是Thread对象还在。
线程安全
概念
线程安全是指在多线程环境中,代码能够正确处理多个线程同时访问共享资源的情况,而不会导致数据损坏或不一致。
造成线程安全的主要原因:
- 操作系统的随机调度(根本原因)
- 多个线程共同修改同一个变量
- 修改操作不具有原子性
- 内存可见性
- 指令重排序
那么上面所说的原因具体是什么意思呢?以及那些专属名词是什么意思呢?下面我会给大家一一解答。
操作系统的随机调度。(操作系统层面)
在操作系统的实际调度中,纯粹的随机调度并不常用,因为它通常不如其他调度算法(如轮询、优先级调度、最短作业优先等)那样高效或可预测。然而,在某些特定的应用场景下,随机调度可以作为一种简单有效的策略。例如,在某些实时系统中,为了确保任务的公平执行,可能会采用随机调度来避免某些任务因为优先级较高而长时间占用CPU。
当多个线程并发访问共享数据,并且至少有一个线程在修改数据时,如果最终结果依赖于线程执行的顺序,就发生了竞态条件。
注意:操作系统随机调度的基本单位是线程,在现代操作系统中,线程通常作为调度的基本单位,因为它们比进程更轻量,创建和销毁的开销更小,更适合于高并发和高吞吐量的应用场景。
多个线程共同修改同一个变量(代码结构问题)
这个就很容易理解了,就如同字面的意思。多个线程共同修改同一个变量,那么我们用代码展现以下这个情景呢。
如下代码:t1,t2线程对同一变量count进行修改。像这样的代码就有可能发生线程安全。大家可以复制代码去试试大概率是无法得到我们所期待的count=100000的。
ini
public class Main{
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
// 对 count 变量进⾏⾃增 5w 次
for (int i = 0; i < 50000; i++) {
count++;
}
});
Thread t2 = new Thread(() -> {
// 对 count 变量进⾏⾃增 5w 次
for (int i = 0; i < 50000; i++) {
count++;
}
});
t1.start();
t2.start();
// 如果没有这俩 join, 肯定不⾏的. 线程还没⾃增完, 就开始打印了. 很可能打印出来的 cou
t1.join();
t2.join();
// 预期结果应该是 10w
System.out.println("count: " + count);
}
}
修改操作不具有原子性
什么叫做原子性呢?
通俗来讲就是: 一个操作要么完整执行,要么不执行。你会出现只改一半,然后中断又去先执行其它操作。
如果我们的代码具有原子性,那么多个线程同时修改一个变就不会出现线程安全问题了。
那么为什么不是原子性操作就有可能发生线程安全呢,那么下面我就给大家也用上面count++的例子用图片给大家演示一下发生线程安全的过程:


解析:首先我们把count++分成三个指令,
load:从寄存器中取count的值
add:把取到的count+1
save:将count+1的值更新到寄存器中
解释完这三个操作,大家看图可能就已经理解了,也就是在线程1中count+1,但是还没更新到寄存器中,导致我们线程2在load的时候去到的是10,所以尽管在线程2 add的时候寄存器中的值count已经更新为11,线程2中的count还是load时读到的10,所以最后save的还是10+1,也就是11.这个就是造成线程安全的主要原因。
如果说前两个我们无法修改,一个是操作系统层面的设置,一个是代码结构有需求要对count进行修改我们都没办法改变。那么这里的原子性操作我们是否能将不是原子的操作变成原子操作呢?答案是我们可以用锁让他具有原子性,但不能让他变成原子性操作,但严格意义上来讲原子操作是由硬件指令支持实现的。
那么什么是锁呢?等我们讲完造成线程安全的原因就给大家娓娓道来。
内存可见性
内存可见性用通俗的话来讲:一个线程对一个变量进行修改,能够被其他线程及时看到。

那么内存可见性又是如何造成线程安全呢?
首先我们要知道工作内存 与主内存,如上图。
主内存: 每个线程都共享一个存储变量数据的内存。
工作内存: 每个线程自己专属存储数据的内存。
我们的线程在运行时,会先从主内存中拷贝数据到自己的工作内存,如果改变了数据(变量)的值,就会先保存在工作内存在同步会主内存。
那么造成线程安全的原因相信大家,看过上面的文字描述也能明白了。


造成线程安全的过程如图:在线程1改变a的值后没能及时返回给主内存,导致线程2在读取主内存的a的值的事后还是10.而不是20,这就有可能导致后面线程安全。
还有一种情况是编译器对的优化,也就是在Java中jvm保证大体逻辑变,对代码进行一些调整,是程序效率更高,导致发生了一些细微变化,但也造成了线程安全,就比如当jvm多次在主内存中load一个值,都没变,有可能就会把代码优化成从自己的工作内存里取值,但恰巧这时这个值刚好就被修改了,这也会造成线程安全。
指令重排序
指令重排序其实也是编译器优化而造成线程安全的原因之一。
首先我们理解一个正常new一个对象赋给一个变量的情况,也就是实例化一个对象。这个过程正常是这样的:
- 申请内存空间
- 在空间内构造对象
- 将内存空间的首地址赋值引用给变量
但是经过编译器的优化,有可能就会将顺序改变为1,3,2这样就会导致变量得到的地址空间是一片还没构造的"空地"。
volatile解决内存可见性和指令重排序造成线程安全的问题
在Java中,volatile 是一个关键字,用于声明变量。它的作用是确保多线程环境下,变量的最新值能够及时被所有线程看到。简单来说,volatile关键字告诉Java虚拟机(JVM),对于该变量的所有读写操作都要直接操作主内存,而不是在本地缓存中。
volatile的工作原理:
- 内存可见性:
-
- 当一个线程修改了volatile 变量的值,其他线程会立即看到这个修改,保证变量的可见性。
- 在没有使用volatile时,线程可能会将变量缓存到本地工作内存中,导致其他线程看不到最新的值。
- 禁止指令重排:
-
- volatile变量还具有禁止指令重排的作用,即保证对该变量的读写操作不会被JVM或者CPU优化重排,从而避免某些多线程操作中的逻辑错误。
代码在写⼊ volatile 修饰的变量的时候:
- 改变线程⼯作内存中volatile变量副本的值
- 将改变后的副本的值从⼯作内存刷新到主内存
代码在读取 volatile 修饰的变量的时候:
- 从主内存中读取volatile变量的最新值到线程的⼯作内存中
- 从⼯作内存中读取volatile变量的副本
使用volatile的注意事项:
- 不可替代同步:
-
- volatile只能保证变量的可见性,无法保证操作的原子性。如果多个线程需要对该变量进行复合操作(如 i++),则 volatile不能替代synchronized 或atomic 类来保证原子性。
- 仅对单一变量有效:
-
- volatile对于操作单个变量有效,但对于需要操作多个变量的复杂场景(如涉及多个共享变量的情况),需要其他同步机制。
如下是一个内存可见性问题的程序代码:
ini
import java.util.Scanner;
class Counter {
public int flag = 0;
}
class Test {
public static void main(String[] args) {
Counter counter = new Counter();
Thread t1 = new Thread(() -> {
while (counter.flag == 0) {
// do nothing
}
System.out.println("循环结束!");
});
Thread t2 = new Thread(() -> {
Scanner scanner = new Scanner(System.in);
System.out.println("输⼊⼀个整数:");
counter.flag = scanner.nextInt();
});
t1.start();
t2.start();
}
}
// 执⾏效果
// 当⽤⼾输⼊⾮0值时, t1 线程循环不会结束. (这显然是⼀个 bug)

可以看出我们在输入一个非0的数时,因为内存可见性的问题,程序却不能识别出我们的flag,导致循环还是不能结束。
但是只要我们在flag加上关键字volatile,那么就能解决这个问题。

synchronized关键字(加锁)
synchronize,在Java中我们通常用他来加锁,那么加锁又是什么意思呢?
互斥
synchronized具有互斥效果,那么什么叫做互斥呢?
首先我们先看两段代码:
第一种:
ini
synchronized (locker) {
count++;
}
第二种

上面两种方式所示:
进入synchronized的就称为加锁。
第一种锁可以理解为是对象是locker(一个类对象)的。
第二种放在方法上就可以理解为对当前对象(即包含这个方法而且已经很实例化的类)的
我们把这些拥有锁的对象叫做对象头。
然后在被 synchronized的花括号被包含的代码块就相当于加锁。
退出synchronized的花括号被包含的代码块就相当于解锁。
synchronized 会起到互斥效果, 某个a线程执行到某个对象的 synchronized 中时, 其他线程如果也执行到同⼀个对象 synchronized 就会阻塞等待. 这种情况就是a线程获取到了锁,所以a线程就可以执行被加锁的代码块。
注: synchronized用的锁是存在Java对象头里的。
如何理解阻塞等待呢?
操作系统和锁的等待队列
每当一个线程请求一个锁时,操作系统会在内部维护一个等待队列,该队列记录了那些因无法获得锁而被阻塞的线程。锁本身是一个资源,只有获得锁的线程才能执行临界区中的代码。
获取锁 :当线程请求锁时,如果锁被其他线程占用,当前线程就会进入阻塞状态,并被放入等待队列。
释放锁: 当锁的持有者释放锁后,操作系统会唤醒一个等待队列中的线程。哪个线程会被唤醒取决于操作系统的线程调度策略。
线程调度与唤醒
虽然一个线程可能在等待队列中排队等候,但操作系统并不保证按照先来后到的原则唤醒线程。在很多情况下,线程调度是由操作系统的调度器控制的,这个调度器可能依据多个因素(如优先级、线程的状态等)来决定哪个线程应该先获得 CPU 执行的机会。因此,当线程 A 释放锁时,即便线程 B 比线程 C 早进入等待队列,操作系统依然可能选择线程 C 而非线程 B 来继续执行。
可重入特性
对于可重入锁我们可以理解为:对同一个代码进行两次加锁,而且外层包含着内层。比如:
java
synchronized (locker) {
synchronized (locker) {
count++;
}
}
对于普通不是可重入锁,着将会导致锁死。就好比两个人,两个人得想法都是等对面坐下我在坐下,最终就导致两个人永远都不会坐下。
而我们的synchronized是可重入锁,它可以重复的自己锁自己。那么他是如何实现的呢?
首先synchronizened这种可重入锁内置了"计数器"和"线程拥有者"两个信息
在某个线程进行加锁时,发现锁已经被占用而且占用该锁的就是自己,那么仍然会让自己获取到锁,并且他就会让计数器+1。
当解锁时,会不断让计数器-1,直到计数器为0。才是真正的解锁,这样其他线程才能获取到这个锁。