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

在这里插入图片描述

我的专栏人工智能领域、java-数据结构、Javase、C语言,MySQL,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 修饰的变量时:

  1. 改变线程工作内存中该 volatile 变量副本的值
  2. 将修改后的副本值从工作内存刷新到主内存

当代码读取被 volatile 修饰的变量时:

  1. 从主内存中读取该 volatile 变量的最新值,加载到线程的工作内存中
  2. 从工作内存中读取该 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调用对象objwait()时:

  • 线程A会释放它持有的obj的锁(这是wait()的关键特性,允许其他线程获取锁)。
  • 线程A进入obj的“等待队列(wait set)”,进入阻塞状态(不再参与CPU调度)。
  • 直到被其他线程通过obj.notify()/notifyAll()唤醒,或等待超时(wait(long timeout)),或被中断(抛出InterruptedException),线程A才会从等待队列中移出。
  • 唤醒后,线程A不会立即执行,而是进入“同步队列(entry set)”,等待重新竞争获取obj的锁。
  • 成功获取锁后,继续执行wait()之后的代码。
2. notify()的执行过程

当线程B调用对象objnotify()时:

  • obj的等待队列中随机唤醒一个线程(假设是线程A)。
  • 被唤醒的线程A从等待队列移到同步队列,等待重新竞争obj的锁。
  • 线程B不会立即释放锁,而是继续执行当前同步块中的代码,直到退出同步块后才释放锁。此时线程A才有机会获取锁并继续执行。
3. notifyAll()的执行过程

notify()类似,但会唤醒等待队列中所有线程,让它们全部进入同步队列竞争锁。

关键注意事项:

  1. 避免虚假唤醒(Spurious Wakeup)
    线程可能在没有被notify()的情况下“意外唤醒”(JVM底层实现导致)。因此,wait()必须放在循环中,而非if判断中,确保唤醒后重新检查条件。
    示例:

    synchronized (obj) {
    // 用while循环检查条件,而非if
    while (!condition) {  // 条件不满足时等待
    obj.wait();
    }
    // 条件满足后执行逻辑
    }
  2. sleep()的区别

    对比项wait()sleep(long)
    所属类ObjectThread
    是否释放锁释放对象锁不释放锁(持有锁时仍阻塞)
    使用场景必须在同步块/方法中可在任意地方
    唤醒方式notify()/超时/中断时间到自动唤醒/中断
  3. 锁的释放时机

    • 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线程协作的核心机制,通过释放/竞争锁和等待/唤醒队列,实现线程间的有序执行,需注意同步环境、虚假唤醒和锁释放时机。


posted @ 2025-11-29 16:47  clnchanpin  阅读(16)  评论(0)    收藏  举报