前言

        在之前文章中,我们学会了如何创建线程、使用Thread的核心方法以及线程的生命周期。但是,当我们真正让多个线程同时操作同一个共享变量时,问题来了——两个线程各自加50000次,结果却不是100000?

        这是经典的线程安全问题。今天这篇文章,从这个问题出发,一步步分析原因,然后介绍synchronized和volatile的应用。

目录

前言

一、问题现场——一个进店的线程不完全案例

二、线程安全的三大原因

2.1.原子性被破坏

2.2.内存可见性问题——Java内存模型(JMM)

2.3指令重排序

三、第一把锁:synchronized

3.1基本用法

3.2使用synchronized解决count++问题

3.3可重入性

3.4锁的底层原理(简要说明)

3.5synchronized缺点

四、轻量级利器:volatile——可见性

4.1volatile的作用

4.2经典应用场景:状态标志位

4.3volatile不能保证原子性

五、综合实践:双重检查锁单例模式(synchronized+volatile)

5.2加synchronized方法(效率低)

5.3双重检查锁


一、问题现场——一个进店的线程不完全案例

public class UnsafeCounter {
    private static 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);  // 期望100000,实际可能8xxxx或9xxxx
    }
}

运行结果每次不一样,但不会是10000

        为什么会这样?

直觉上,两个线程各自加50000次,结果应该是100000

根本原因在于:count++不是一条原子操作,它在底层由三条CPU指令完成:

        1.从内存中读取count的值到寄存器

        2.在寄存器中进行+1操作

        3.将新值写回内存

当两个线程并发执行时,可能出现如下交错:

线程1 线程2 count值
读取count=0 0
读取count=0 0
计算0+1=1 0
计算0+1=1 0
写回count=1 1
写回count=1 1

两次自增,结果只增加了1——丢失更新

二、线程安全的三大原因

        要解决问题,先得知道问题怎么来的。多线程环境下导致不安全的三个主要原因:

2.1.原子性被破坏

        一个操作如果中间不可分割,就是原子的。Java中处理专门设计的原子类或同步块,大多操作都不是原子的,尤其是像count++、new Object()这种看似一条语句实则多步的操作。

2.2.内存可见性问题——Java内存模型(JMM)

JMM规定:1.所有变量存储在主内存

        2.每个线程都有自己的工作内存(CPU缓存/寄存器)

        3.线程对变量的读写,先操作工作内存,再同步回主内存

这就导致:线程A修改了变量,线程B可能看不到最新值,因为B还在用自己工作内存中的旧值。

2.3指令重排序

        为了优化性能,编译器和CPU可能会调整指令的执行顺序(单线程下保证逻辑一致)。

但在多线程下,重排序可能导致意想不到的结果。经典例子:双重检查锁单例模式中,不加volatile可能导致拿到未初始化完成的对象。

三、第一把锁:synchronized

        synchronized是Java中最基础的同步机制,它能保证:

1.原子性:被保护的代码块同一时刻只能由一个线程执行

2.可见性:锁释放前对变量的修改,会强制刷新到主内存,后续获得锁的线程能看见

3.有序性:禁止锁内部的指令重排序(但是锁外部重排序仍可能发生)

3.1基本用法

synchronized锁的是对象,不是代码。两个线程竞争同一把锁才会互斥

用法 锁对象
synchronized(obj){……} obj对象
synchronized(this){……} 当前实例对象
public synchronized void method() 当前实例对象
public static synchronized void method() 当前类的Class对象

3.2使用synchronized解决count++问题

public class SafeCounter {
    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);  // 稳定输出100000
    }
}

原理:同一时刻只有一个线程能拿到lock这把锁,另一个线程必须阻塞等待。这样count++的三条指令就粘在一起执行,不会被打断。

3.3可重入性

synchronized (lock) {
    synchronized (lock) {
        count++;   // 不会死锁!
    }
}

synchronized是可重入锁,同一个线程可以多次获取同一把锁。内部实现:每个锁关联一个持有者线程和一个计数器,每次重入计数器+1,释放时计数器-1,减到0才真正释放。

3.4锁的底层原理(简要说明)

        1.每个Java对象都有一个对象头,其中的Mark Word记录锁状态

        2.JVM会进行锁升级:无锁——偏向锁——轻量级锁(CAS)——重量级锁

        3.重量级锁会涉及操作系统内核态切换,开销较大。

3.5synchronized缺点

        1.性能开销:线程阻塞、唤醒需要内核切换

        2.不可中断:如果拿不到锁,线程会一直阻塞,没有超时机制。

        3.读写不分离:即使多个线程只是读共享变量,也会互斥。

四、轻量级利器:volatile——可见性

        volatile 是Java中的另一个关键字,它比synchronized更轻量,但能做的事情也有限

4.1volatile的作用

        1.保证内存可见性:写volatile变量时,立即刷新到主内存;读volatile变量时,直接从主内存读取,不使用工作内存缓存。

        2.禁止指令重排序:再volatile读写前后加入内存屏障,防止编译器和CPU重排

4.2经典应用场景:状态标志位

public class VolatileFlagDemo {
    private static volatile boolean running = true;

    public static void main(String[] args) throws InterruptedException {
        Thread worker = new Thread(() -> {
            while (running) {
                // 工作
            }
            System.out.println("线程退出");
        });
        worker.start();
        Thread.sleep(1000);
        running = false;   // 主线程修改标志,worker线程能立即看到
        worker.join();
    }
}

如果没有volatile,worker线程可能一直在自己的工作内存中读取running的旧值,导致死循环。

4.3volatile不能保证原子性

volatile int count = 0;
count++;   // 仍然线程不安全!

        因为count++本身不是原子操作,volatile只保证读和写是原子的,但无法把读-改-写三步粘在一起。

五、综合实践:双重检查锁单例模式(synchronized+volatile)

5.1懒汉单例模式(线程不安全版)、

class Singleton {
    private static Singleton instance = null;
    private Singleton() {}
    public static Singleton getInstance() {
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
}

问题:多个线程同时进入if(instance==null),会创建多个实例。

5.2加synchronized方法(效率低)

public static synchronized Singleton getInstance() {
    if (instance == null) {
        instance = new Singleton();
    }
    return instance;
}

问题:每次调用都要加锁,即使实例已经创建也要同步,性能差。

5.3双重检查锁

class Singleton {
    private static volatile Singleton instance = null;
    private Singleton() {}

    public static Singleton getInstance() {
        if (instance == null) {               // 第一次检查(无锁)
            synchronized (Singleton.class) {
                if (instance == null) {       // 第二次检查(有锁)
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

为什么要volatile?

instance=new Singleton();不是原子操作,它分为三步:

·      1.分配内存空间

        2.初始化对象

        3.将引用指向分配的内存

如果不加volatile,编译器可能重排序。

为什么要两次检查?

        外层检查:避免实例已经创建后还要加锁,提高性能

        内层检查:防止多线程通过外层检查后,重复创建实例

Logo

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

更多推荐