一、线程不安全

1.1 线程不安全的直观现象

多线程的优势是提升效率,但多个线程同时操作共享数据时,极易出现数据错乱的问题,这就是线程不安全。

计数器案例:

private static int count = 0;
public static void main(String[] args) throws InterruptedException {
    // 线程1:自增5万次
    Thread t1 = new Thread(() -> {
        for (int i = 0; i < 50000; i++) count++;
    });
    // 线程2:自增5万次
    Thread t2 = new Thread(() -> {
        for (int i = 0; i < 50000; i++) count++;
    });
    t1.start();
    t2.start();
    t1.join();
    t2.join();
    // 预期10万,实际永远小于10万
    System.out.println("count: " + count);
}

运行结果永远小于预期的10万,这就是典型的线程不安全问题。

1.2 线程不安全的原因

操作系统对线程的调度是随机的,这也是线程不安全的罪魁祸首。

(1)原子性缺失:操作被拆分打断

原子性指一段操作不可分割,要么全部执行,要么全部不执行

count++看似一行代码,实则对应3个CPU的指令:

  1. load:把内存中的值加载到CPU寄存器中
  2. add:把寄存器的内容+1
  3. save:把寄存器中的内容保存回内存中

执行这三个指令时不一定能一次执行完,很有可能1和2执行完,调度走;过了很久,再调度回来执行3。

如果两个线程对于同一个count进行操作,极大概率t1还没来得及保存新结果(1),t2就已经加载并修改,保存了数据(0 -> 1),此时再调度t1,t1接下来该保存新数据(1),t1的修改覆盖了t2的修改,对于这两次自增,count的结果是1。这样多次覆盖,就会导致count最终的结果小于100000

(2)可见性缺失:数据更新互相看不见

Java内存模型(JMM)规定线程有独立工作内存,共享数据存主内存

  • 线程修改共享变量时,先改工作内存副本,再同步到主内存。
  • 线程A修改了变量,但未及时同步到主内存,线程B读取的还是旧值,导致逻辑错误。

(3)有序性缺失:指令被乱序优化

为提升效率,编译器和CPU会对指令重排序(不影响单线程结果),但多线程下会打乱逻辑:

  • 典型场景:双重检查锁单例模式中,instance = new Singleton()可能被重排序,导致线程获取未初始化的对象,引发空指针。

二、synchronized

synchronized是Java内置的互斥锁,能同时保证原子性、可见性、有序性,是解决线程安全最常用的关键字。

  • 进入 synchronized 修饰的代码块, 相当于加锁
  • 退出 synchronized 修饰的代码块, 相当于解锁

加锁操作不是把线程锁死在CPU上,不让这个线程被调度走,而是禁止其他线程重新加这个锁,避免其他线程的操作,在当前线程的执行过程中插队。

2.1 三大特性

(1)互斥性(原子性)

synchronized用的锁是存在Java对象里的,可以粗略的理解为每个对象在内存中存储时,都有一块内存表示当前锁定的状态(类似于厕所的有人/无人)

  • 如果是无人状态,就可以使用,使用时设置为“有人”状态
  • 如果是有人状态,其他人无法使用,只能等待。

理解阻塞等待
针对每一把锁,操作系统内部都维护了一个等待队列,当这个锁被某个线程占有时,其他线程尝试进行加锁,就加不上了,就会阻塞等待,一直到之前的线程解锁后,由操作系统唤醒一个新线程,再来获取这个锁。

  • 上个一个线程解锁后,下一个线程不是立即就能获取,而是靠操作系统来“唤醒”,这也是操作系统线程调度的一部分工作
  • 假设A B C三个线程,线程A先获得锁,然后B尝试获取,C再尝试获取。此时B和C都在阻塞队列中排队等待,但是当A释放锁之后,虽然B比C先来,B不一定立即获得锁,而是重新与C竞争。
  • 同一时刻,只有一个线程能获取同一把锁,执行临界区代码。
  • 其他线程尝试获取锁时,会进入阻塞等待状态,直到锁被释放。

(2)可见性

  • 线程进入synchronized代码块时,清空工作内存,从主内存加载最新数据
  • 线程退出代码块时,强制将修改后的数据刷新回主内存,其他线程能立即看到最新值。

(3)可重入性

理解“把自己锁死”
一个线程没有释放锁,又尝试重新加锁
例如第一次加锁,成功上锁;第二次加同一把锁,锁已经被占用,就会阻塞等待。
按照之前的锁的设定,第二次加锁,阻塞等待,直到第一次的锁释放;而释放第一个锁也是由该线程完成,这样就陷入了死循环,把自己锁死了。
这样的锁称为“不可重入锁”

Java的synchronized引入了可重入的概念,同一线程可重复获取同一把锁,不会自己锁死自己。

底层通过线程持有者+计数器实现:加锁时计数器+1,解锁时计数器-1,计数器为0时真正释放锁。

2.2 三种使用方式

(1)修饰代码块(锁自定义对象)

// 锁任意对象
private static final Object lock = new Object();
public static void main(String[] args) throws InterruptedException {
    Thread t1 = new Thread(() -> {
        for (int i = 0; i < 50000; i++) {
            synchronized (lock) { // 加锁
                count++;
            } // 解锁
        }
    });
}

对于锁来说任意对象都可以,一般情况下,我们会专门定义一个Object类给锁使用。

(2)修饰实例方法(锁当前对象)

// 锁当前实例对象
public synchronized void increment() {
    count++;
}

(3)修饰静态方法(锁类对象,全局唯一)

// 锁当前类的Class对象,所有实例共享一把锁
public synchronized static void increment() {
    count++;
}

2.3 修复计数器案例

count++synchronized锁,保证原子性:

private static int count = 0;
private static final Object lock = new Object();
public static void main(String[] args) throws InterruptedException {
    Thread t1 = new Thread(() -> {
        for (int i = 0; i < 50000; i++) {
            synchronized (lock) { count++; }
        }
    });
    Thread t2 = new Thread(() -> {
        for (int i = 0; i < 50000; i++) {
            synchronized (lock) { count++; }
        }
    });
    t1.start();
    t2.start();
    t1.join();
    t2.join();
    System.out.println("count: " + count); // 输出10万
}

2.4 死锁

死锁是指两个或多个线程,各自拿着对方需要的锁,又互相等待对方释放锁,谁都不放手、谁都走不了,程序永久阻塞卡死。

构成死锁的必要条件

  1. 锁是互斥的。一个线程拿到锁后,另一个线程想要拿锁必须阻塞等待
  2. 锁不可剥夺。线程1拿到锁,线程2也想获取这个锁,必须阻塞等待,而不能直接抢占
  3. 请求和保持。一个线程拿到锁A,不释放锁1的前提下,获取锁B
  4. 循环等待。 多个线程的等待过程构成了循环,例如A等B释放,B也在等A释放

如何避免死锁

前两个条件是锁的基本特性,想要避免死锁的出现就要破坏掉3或4.

  1. 统一锁的获取顺序
    所有线程都按固定顺序拿锁,例如约定从序号小的锁开始获取,如果锁已经被获取,就要阻塞等待
  2. 放弃请求与保持
    要么一次性把需要的锁全部拿到,一把都不拿不到就不执行。
  3. 设置超时
    尝试拿锁等待一段时间,拿不到就放弃,不永久阻塞。
  4. 减少嵌套加锁

2.5 Java标准库中的线程安全类

Java标准库中很多是线程不安全的,这些类可能会涉及多线程修改共享数据,又没有加锁措施。

  • ArrayList
  • LinkedList
  • HashMap
  • TreeMap
  • HashSet
  • TreeSet
  • StringBuilder

线程安全类:

  • StringBuffer是线程安全的,正是因为其中的方法都有synchronized。
    在这里插入图片描述
    但是由于synchronized的限制,代码中可能出现锁的竞争导致阻塞,会使代码的效率大打折扣

  • String:虽然没有加锁,但是不涉及修改,仍然是线程安全的

三、volatile

3.1 内存可见性问题

以下面代码为例:t2改变flag的值来影响t1的执行,当输入1时,理论上t1内while条件不成立,应该跳出循环,线程结束。然而实际上输入1后t1线程还在继续

public class demo18 {
    private static int flag = 0;

    public static void main(String[] args) {
        Thread t1 = new Thread(()->{
            while(flag==0){

            }
            System.out.println("t1 线程结束");
        });

        Thread t2 = new Thread(()->{
            //修改flag
            Scanner scan = new Scanner(System.in);
            System.out.println("输入flag的值:");
            flag = scan.nextInt();
        });
        t1.start();
        t2.start();
    }
}

很明显,这也是由于线程安全导致的bug。一个线程在读取,一个线程在修改,修改的值没有被另一个线程读取到,这就是“内存可见性问题”。

这涉及到编译器优化

我们写的代码,都会通过javac.java文件编译成.class字节码文件,由jvm执行。编译器可以保持代码逻辑不变的情况下,对代码进行优化,提升效率。

而在多线程场景,编译器很有可能判断错误,导致优化前后的逻辑不完全相同。

分析编译器如何误判

t1中是空循环,对于CPU主要就是两个操作:load (加载flag的值),cmp(条件跳转)

  • 对于cmp:cpu的寄存器操作,速度快很多
  • 对于load:需要到内存中访问,时间可能是cmp的几千倍。

每轮循环执行速度非常快,短时间内就可以执行很多次,每次读取flag的值都是不变的。经过多次循环,JVM认为这个读取操作可以被优化(正是因为load在循环中时间消耗是cmp的几千倍),因此把读内存操作改成了读寄存器操作。

而用户输入值可能要经过好几秒,与上述的操作时间完全不是一个量级。等到用户真的输入flag的值,t1已经感知不到了(编译器优化使得t1的读操作不是真正的读内存)

如果在循环中加入一些语句

while(flag==0){
	sleep(1);
   }

加入sleep后,使循环的速度大大大大幅度下降,此时load时间占比对于整个循环小了很多,JVM认为这个优化没有必要,每次都是读内存操作。因此t2的修改可以被t1感知到,结果正确。

3.2 volatile的作用

volatile轻量级并发关键字不保证原子性,仅保证可见性和禁止指令重排序,适合解决“一个线程写、多个线程读”的场景。

(1)保证可见性:数据更新立即同步

  • volatile变量:修改后立即刷新到主内存
  • volatile变量:强制从主内存读取最新值,不读工作内存缓存。

解决线程感知不到变量更新的问题:

public class demo18 {
    private volatile static int flag = 0;

    public static void main(String[] args) {
        Thread t1 = new Thread(()->{
            while(flag==0){
             
            }
            System.out.println("t1 线程结束");
        });

        Thread t2 = new Thread(()->{
            //修改flag
            Scanner scan = new Scanner(System.in);
            System.out.println("输入flag的值:");
            flag = scan.nextInt();//输入后t1就可以感知到,跳出循环,线程结束 
        });
        t1.start();
        t2.start();
    }
}

(2)禁止指令重排序:避免逻辑错乱

  • volatile变量前后会加内存屏障,禁止编译器和CPU对其前后指令重排序。
  • 应用:双重检查锁(DCL)单例模式,防止指令重排序导致的空指针。

volatile适合无复合操作(如count++)、仅需可见性/有序性的场景;复合操作必须用synchronizedAtomicInteger

四、wait 等待 / notify 通知

多线程不仅要“互斥”,还要“协作”——比如生产者生产完数据,通知消费者消费;消费者无数据时等待。wait()notify()notifyAll()是实现线程等待-唤醒的核心方法,定义在Object类中。

4.1 方法详解

(1)wait():让线程等待并释放锁

  • 作用:当前线程进入阻塞等待状态,释放持有的锁,允许其他线程获取锁执行任务。
  • 重载:wait()(无限等待)、wait(long timeout)(超时等待,毫秒)。
  • 必须在synchronized代码块/方法中调用,否则抛IllegalMonitorStateException

(2)notify():随机唤醒一个等待线程

  • 作用:随机唤醒一个在当前对象锁上等待的线程
  • 唤醒后不立即释放锁,需当前线程退出synchronized代码块后,被唤醒线程才能竞争锁。

(3)notifyAll():唤醒所有等待线程

  • 作用:唤醒所有在当前对象锁上等待的线程
  • 所有线程被唤醒后竞争同一把锁,同一时刻只有一个线程能执行。

注意:

  1. 调用wait(),notify()以及这两个方法所在的synchronized代码块内必须是同一对象才能生效
  2. 要确保先wait 再notify,才会有作用。如果先notify再wait,不会对notify所在的线程有影响,但是没啥用

4.2 wait()与sleep()的区别

特性 wait() sleep()
所属类 Object类 Thread类
锁行为 释放锁 不释放锁
唤醒方式 需notify()/notifyAll()唤醒 超时自动唤醒
使用场景 线程协作(等待-唤醒) 线程休眠(暂停执行)

如果sleep()在synchronized代码块内,就会出现“抱着锁睡”的情况,休眠期间其他线程也不能拿到这把锁。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐