Java多线程初阶(三)——线程安全问题以及synchronized与voltile如何解决线程安全
前言
在之前文章中,我们学会了如何创建线程、使用Thread的核心方法以及线程的生命周期。但是,当我们真正让多个线程同时操作同一个共享变量时,问题来了——两个线程各自加50000次,结果却不是100000?
这是经典的线程安全问题。今天这篇文章,从这个问题出发,一步步分析原因,然后介绍synchronized和volatile的应用。
目录
五、综合实践:双重检查锁单例模式(synchronized+volatile)
一、问题现场——一个进店的线程不完全案例
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,编译器可能重排序。
为什么要两次检查?
外层检查:避免实例已经创建后还要加锁,提高性能
内层检查:防止多线程通过外层检查后,重复创建实例
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)