【JavaEE 初阶】线程安全核心知识点总结 - 详解



文章目录
前言:
听到线程安全问题,大家是不是心里在想知道何为线程安全、为什么会产生线程安全问题。那是由于多线程并发时,共享资源的非原子操作易引发数据错乱,线程安全问题暗藏风险。所以理解其根源与解决之道,是保障程序稳定的关键。接下来我将一一叙述线程安全问题。
一:线程安全的概念
线程安全是指在多线程并发执行环境中,当多个线程同时访问共享资源时,程序的执行结果始终符合预期逻辑,不会因线程调度的交错而出现数据错乱、逻辑异常等问题,无需额外同步措施即可保证操作的正确性。那么如果多线程环境下代码运行的结果是符合我们预期的,即在单线程环境应该的结果,则说这个程序是线程安全的。如果某个程序在单线程下执行是OK的,但是在多线程下产生bug则就产生了线程安全问题。
二:线程不安全的原因
2.1:线程的调度是随机的
线程的调度是随机的,有明确的底层逻辑(如优先级、时间片、阻塞状态等),程序员无法精确预测多个线程的执行顺序。
2.2:修改共享数据
以这个代码为例
package ThreadDemo;
public class demo13 {
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++;
}
});
Thread t1=new Thread(()->{
for (int i = 0; i <5000 ; i++) {
count++;
}
});
t.start();
t1.start();
t.join();
t1.join();
System.out.println("count:"+count);
}
}
预期结果输出应该是10000,但实际结果不是,而且每次执行的结果都是不一样的。

此时这个 count 是一个多个线程都能访问到的 “共享数据”,这时就产生线程安全问题。
上面count最终的结果也是会有小于5000的情况出现的。我们可以看成进行load(将内存中的数据放到cpu的寄存器上)、add(在寄存器上进行add操作)、save(再将寄存器中的值返回到内存上)操作。
当出现这种情况时候
这里看起来是加了三次,但实际上最终结果是1,相当于只加了一次。所以最终count的值是会出现小于5000的情况的。

2.3:原子性(cpu执行一条指令)
那么什么是原子性:我们把一段代码想象成一个房间,每个线程就是要进入这个房间的人。如果没有任何机制保证,A进入房间之后,还没有出来;B 是不是也可以进入房间,打断 A 在房间里的隐私。这个就是不具备原子性的。
那我们应该如何解决这个问题呢?是不是只要给房间加一把锁,A 进去就把门锁上,其他人是不是就进不来了。这样就保证了这段代码的原子性了。
有时也把这个现象叫做同步互斥,表示操作是互相排斥的。
2.4:可见性
可见性(Visibility) 指的是:当一个线程修改了共享变量的值后,其他线程能够及时感知到这个修改的特性。如果缺乏可见性,线程可能会基于 “过时” 的数据进行操作,导致程序逻辑错误。
Java 内存模型 (JMM): Java虚拟机规范中定义了Java内存模型.
目的是屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果.
为什么会出现可见性问题?
可见性问题的根源是计算机硬件的缓存机制。现代 CPU 为了提高效率,不会直接频繁操作主内存(内存速度远慢于 CPU),而是引入了多级缓存(如 L1、L2、L3 缓存)和线程的 “工作内存”(逻辑概念,可理解为 CPU 缓存或寄存器):
- 线程读取共享变量时,会先将主内存中的值加载到自己的工作内存中,后续操作直接使用工作内存中的副本;
- 线程修改共享变量时,会先修改工作内存中的副本,再在某个时机(非立即)同步回主内存。
这种 “缓存 - 同步” 机制在单线程下没问题,但多线程下可能导致:线程 A 修改了共享变量并存在自己的工作内存中,但未同步到主内存;线程 B 的工作内存中仍保留该变量的旧值,因此无法感知 A 的修改。
2.5:指令重排序
指令重排序(Instruction Reordering) 是指编译器或 CPU 为了优化性能,在不改变单线程程序执行结果的前提下,对指令的执行顺序进行调整的行为。这种优化能提高 CPU 利用率和程序运行效率,但在多线程环境下可能破坏操作的有序性,导致线程安全问题。
三:解决线程安全问题
使用
synchronized关键字
synchronized 会起到互斥效果, 某个线程执行到某个对象的 synchronized 中时, 其他线程如果也执行到同一个对象 synchronized 就会阻塞等待.
- 进入
synchronized修饰的代码块, 相当于加锁 - 退出
synchronized修饰的代码块, 相当于解锁
可以想象成上厕所,当上厕所时候进入时把门锁上,此时在想进来人就只能在门外等待。此时就称为锁竞争,锁冲突。
以上面代码为例解决线程安全问题,对count进行加锁操作。
package ThreadDemo;
public class demo13 {
private static int count=0;
private static Object locker=new Object();
public static void main(String[] args) throws InterruptedException {
Thread t=new Thread(()->{
for(int i=0;i<5000;i++){
synchronized (locker){
count++;
}
}
});
Thread t1=new Thread(()->{
for (int i = 0; i <5000 ; i++) {
synchronized(locker){
count++;
}
}
});
t.start();
t1.start();
t.join();
t1.join();
System.out.println("count:"+count);
}
}

此时就可以看到最终的结果是10000.这里我们需要一个锁对象,在Java中任何一个对象都可以作为锁对象。
加锁就是把若干操作打包成“一个原子”,不是把count++的三个指令变成了一个指令,也不是说三个指令必须一下执行完不会触发调度。而是加锁会影响到其他加锁的线程,而且是加同一个锁的线程。
当两个线程,尝试竞争同一把锁,才会产生阻塞,如果竞争的不同的锁,则不会产生阻塞。
当我们对加锁操作把for循环加上时,发现结果也是正确的。

那么这两种方法有什么区别呢?
第一个t1加上锁,t1就会不停的执行for循环,把5000次执行完才会释放锁。t2只能阻塞等待,一直等到t1释放锁才会执行。此时的这俩线程的两个循环完全是串行的,也就和一个线程类似。没有把多核心利用起来。
而第二种只有count++是串行的,两个线程可以不受约束,各自执行各自的比较和i++。这种写法代码的并发程度更高。引入多线程就是为了并发执行,为了利用cpu的多核心资源。
可重入
synchronized 同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题;
在可重入锁的内部, 包含了 “线程持有者” 和 “计数器” 两个信息.
- 如果某个线程加锁的时候, 发现锁已经被人占用, 但是恰好占用的正是自己, 那么仍然可以继续获取到锁, 并让计数器自增.
- 解锁的时候计数器递减为 0 的时候, 才真正释放锁. (才能被别的线程获取到)
3.1: synchronized的使用案例
锁任意对象
public class SynchronizedDemo {
private Object locker = new Object();
public void method() {
synchronized (locker) {
}
}
}
锁当前对象
public class SynchronizedDemo {
public void method() {
synchronized (this) {
}
}
}
直接修饰普通方法: 锁的 SynchronizedDemo 对象
public class SynchronizedDemo {
public synchronized void methond() {
}
}
修饰静态方法: 锁的 SynchronizedDemo 类的对象
public class SynchronizedDemo {
public synchronized static void method() {
}
}
3.2:Java 标准库中的线程安全类
线程不安全的类. 这些类可能会涉及到多线程修改共享数据,又没有任何加锁措施。
ArrayList、LinkedList 、HashMap、TreeMap 、HashSet
、TreeSet 、StringBuilder
一些是线程安全的
• Vector (不推荐使用)、HashTable (不推荐使用)、 ConcurrentHashMap、StringBuffer
还有一个String,虽然没有加锁,但是不涉及修改,即线程是安全的。
四:volatile 关键字
volatile 修饰的变量, 能够保证 “内存可见性”
当代码写入被
volatile修饰的变量时:
- 改变线程工作内存中该 volatile 变量副本的值
- 将修改后的副本值从工作内存刷新到主内存
当代码读取被
volatile修饰的变量时:
- 从主内存中读取该 volatile 变量的最新值,加载到线程的工作内存中
- 从工作内存中读取该 volatile 变量的副本值
以这个代码为例
package ThreadDemo;
import java.util.Scanner;
public class demo14 {
private static volatile int flag=0;
public static void main(String[] args) throws InterruptedException {
Thread t1=new Thread(()->{
while(flag==0){
//什么都不做
}
System.out.println("t1:结束");
});
Thread t2=new Thread(()->{
Scanner scanner=new Scanner(System.in);
System.out.println("请输入一个flag的值");
flag=scanner.nextInt();
System.out.println("t2:结束");
});
t1.start();
t2.start();
t1.join();
t2.join();
}
}

可以发现输入非零的值,t1线程并没有结束。
这里产生的原因就是内存可见性,flag变量的修改,对于t1线程“不可见了”,t2修改了flag但是t1“没看见”。
对于这个程序来说编译器看到的效果是:有一个变量flag
会快速的、反复的读取这个内存的值。(反复执行load ,cmp,load,cmp),同时每次拿到的flag值还是一样的,load操作相比cmp耗时多很多,读内存比读寄存器效率慢很多。 所以编译器就把从内存读flag这个操作给优化掉了。
但是我们的t2线程对flag进行了修改,所以就出现了bug。即编译器优化机制,自身出现的bug。
为解决这个问题,volatile 就出现了。对编译器进行提醒,这个变量是“易变的”。此时就不要对这个易变的变量进行优化。
可见加入volatile关键字后,t1线程也就结束了。
volatile虽然能够解决内存可见性问题,但是不具备原子性的特点。
以这个代码为例
package ThreadDemo;
public class demo15 {
private static volatile int count=0;
public static void main(String[] args) throws InterruptedException {
Thread t1=new Thread(()->{
for(int i=0;i<50000;i++){
count++;
}
});
Thread t2=new Thread(()->{
for(int i=0;i<50000;i++){
count++;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count);
}
}

此时对count加上volatile后最终的结果不是预期的结果。他们两个各自适合各自的使用场景 (volatile 和 synchronized 有着本质的区别. synchronized 能够保证原子性, volatile 保证的是内存可见性)。
五:wait 和 notify
- 在Java中,
wait()和notify()(以及notifyAll())是Object类中定义的方法,用于实现线程间的协作,核心作用是让线程在特定条件下进入等待状态,或唤醒等待的线程,从而协调多个线程的执行顺序。
核心作用:
wait():让当前线程释放对象锁并进入“等待状态”,直到被其他线程通过notify()或notifyAll()唤醒,或等待超时。notify():唤醒在此对象监视器上等待的单个线程(具体唤醒哪个线程由JVM决定,不确定)。notifyAll():唤醒在此对象监视器上等待的所有线程。
使用前提:wait()、notify()、notifyAll()必须在同步代码块(synchronized块)或同步方法中调用,否则会抛出IllegalMonitorStateException。
原因:这些方法需要操作“对象的监视器锁(monitor)”,而只有获取了锁的线程才能操作锁相关的等待/唤醒机制。
详细工作流程:
1. wait()的执行过程
当线程A调用对象obj的wait()时:
- 线程A会释放它持有的
obj的锁(这是wait()的关键特性,允许其他线程获取锁)。 - 线程A进入
obj的“等待队列(wait set)”,进入阻塞状态(不再参与CPU调度)。 - 直到被其他线程通过
obj.notify()/notifyAll()唤醒,或等待超时(wait(long timeout)),或被中断(抛出InterruptedException),线程A才会从等待队列中移出。 - 唤醒后,线程A不会立即执行,而是进入“同步队列(entry set)”,等待重新竞争获取
obj的锁。 - 成功获取锁后,继续执行
wait()之后的代码。
2. notify()的执行过程
当线程B调用对象obj的notify()时:
- 从
obj的等待队列中随机唤醒一个线程(假设是线程A)。 - 被唤醒的线程A从等待队列移到同步队列,等待重新竞争
obj的锁。 - 线程B不会立即释放锁,而是继续执行当前同步块中的代码,直到退出同步块后才释放锁。此时线程A才有机会获取锁并继续执行。
3. notifyAll()的执行过程
与notify()类似,但会唤醒等待队列中所有线程,让它们全部进入同步队列竞争锁。
关键注意事项:
避免虚假唤醒(Spurious Wakeup)
线程可能在没有被notify()的情况下“意外唤醒”(JVM底层实现导致)。因此,wait()必须放在循环中,而非if判断中,确保唤醒后重新检查条件。
示例:synchronized (obj) { // 用while循环检查条件,而非if while (!condition) { // 条件不满足时等待 obj.wait(); } // 条件满足后执行逻辑 }与
sleep()的区别对比项 wait()sleep(long)所属类 Object类Thread类是否释放锁 释放对象锁 不释放锁(持有锁时仍阻塞) 使用场景 必须在同步块/方法中 可在任意地方 唤醒方式 需 notify()/超时/中断时间到自动唤醒/中断 锁的释放时机
wait()会立即释放锁。notify()/notifyAll()不会立即释放锁,而是等当前同步块/方法执行完毕后才释放。
典型使用场景:生产者-消费者模型
通过wait()和notify()协调生产者(生产数据)和消费者(消费数据)的执行:
- 消费者无数据可消费时,调用
wait()进入等待。 - 生产者生产数据后,调用
notify()唤醒消费者。
示例代码:
public class ProducerConsumer {
private int count = 0;
private final int MAX = 10; // 最大数据量
private final Object lock = new Object();
// 生产者
public void produce() throws InterruptedException {
synchronized (lock) {
// 数据满了就等待
while (count == MAX) {
lock.wait();
}
count++;
System.out.println("生产后:" + count);
lock.notify(); // 通知消费者
}
}
// 消费者
public void consume() throws InterruptedException {
synchronized (lock) {
// 数据空了就等待
while (count == 0) {
lock.wait();
}
count--;
System.out.println("消费后:" + count);
lock.notify(); // 通知生产者
}
}
}
wait()和notify()是Java线程协作的核心机制,通过释放/竞争锁和等待/唤醒队列,实现线程间的有序执行,需注意同步环境、虚假唤醒和锁释放时机。
浙公网安备 33010602011771号