浅谈Jmm的应用:并发安全-从volatile+粗/细锁到原子类

Java 内存模型(JMM)是多线程编程里绕不开的核心,决定了线程怎么跟共享数据打交道。今天咱们就从最基础的玩法聊起,一步步推演,找到问题,再优化到如今主流的高效方案。过程尽量轻松易懂,但逻辑一点不含糊,走着瞧!


JMM 是啥?内存咋分的

JMM 把内存分成两部分,简单粗暴:

  • 主内存:线程共享的地盘,放的是"官方"数据,像对象啊、静态变量啊。
  • 工作内存:每个线程的小天地,里面存的是从主内存抄过来的数据副本。

线程要干活,得先从主内存把数据拉到自己的工作内存,用完再写回去。打个比方,主内存是大账本,工作内存是每个线程的小抄本。

举个栗子 :有个变量 int num = 0 在主内存里。线程 A 抄一份到自己小抄本,改成 1,但没写回去;线程 B 也抄了一份,还是 0。这就暴露了 JMM 的关键挑战:线程间的数据咋保持一致?


最原始的办法:啥也不干

先看一段代码:

java 复制代码
public class NumberGame {
    public static int num = 0;

    public static void main(String[] args) throws InterruptedException {
        Thread a = new Thread(() -> { for (int i = 0; i < 1000; i++) num++; });
        Thread b = new Thread(() -> { for (int i = 0; i < 1000; i++) num++; });
        a.start(); b.start();
        a.join(); b.join();
        System.out.println(num);
    }
}

你可能觉得 num 最后会是 2000,毕竟两个线程各加了 1000 次。但跑几回,结果可能是 1975、1998,咋回事呢?原因出在 num++ 不是一气呵成的,它分三步:读 num、加 1、写回去。线程 A 和 B 可能同时读到 100,都加到 101,写回去还是 101,结果就丢了数据。

这办法的毛病

  1. 看不见 :A 改了 num,B 完全不知道,还是用老数据。
  2. 抢着干:两个线程一块儿改,互相覆盖,谁也不让谁。
  3. 纯靠天:没啥控制,随缘运行,效率和正确性都不靠谱。

第一步优化:加点料------volatile

咱们给 num 加个 volatile 试试:

java 复制代码
public static volatile int num = 0;

volatile 是干啥的?它能:

  • 保证看得到 :一个线程改了 num,立刻刷到主内存,其他线程读的时候也得从主内存拿最新值。
  • 别乱动顺序:编译器和 CPU 不会随便调整指令顺序。

咋做到的?JVM 在背后搞了点小动作,写数据时加个"写墙",确保改完就更新主内存;读时加个"读墙",逼着从主内存拿新鲜数据。

但再跑代码,num 还是不到 2000。为啥?volatile 只管让大家看到最新值,可 num++ 这三步还是分开走,线程抢着写照样会丢数据。

这步的短板

  • 抢夺没解决:数据还是会被覆盖。
  • 效果有限:性能开销不大,但问题没根治。

再升级:锁起来------synchronized

换个思路,把 num++ 塞进 synchronized 里:

java 复制代码
public class NumberGame {
    public static int num = 0;
    public static synchronized void addOne() { num++; }

    public static void main(String[] args) throws InterruptedException {
        Thread a = new Thread(() -> { for (int i = 0; i < 1000; i++) addOne(); });
        Thread b = new Thread(() -> { for (int i = 0; i < 1000; i++) addOne(); });
        a.start(); b.start();
        a.join(); b.join();
        System.out.println(num); // 稳稳的 2000
    }
}

这回成了!synchronized 是咋搞定的?

  • 底层逻辑:靠的是监视器锁。JVM 在代码里加了锁的开关,线程进来先锁门,改完数据再开门。
  • 效果:既让改动立刻被别人看到,又保证一次只能一个线程干活。

新麻烦

  1. 有点慢:锁太重,线程得排队,2 个线程还行,20 个就拖后腿了。
  2. 锁得粗:整个方法都锁住,其实没必要锁那么多。

逼近高招:精细化与高效化

从啥也不管到加锁,咱们再往厉害的方向走几步。

1. 锁得更聪明

别锁整个方法,改用对象锁,只锁关键部分:

java 复制代码
public class NumberGame {
    private int num = 0;
    private final Object lock = new Object();

    public void addOne() {
        synchronized(lock) { num++; }
    }
}

好处:锁的范围小了,线程不用等太久,效率高了不少。现在很多框架都喜欢这么干。

2. 不锁也行------原子类

直接上 AtomicInteger,扔掉锁:

java 复制代码
import java.util.concurrent.atomic.AtomicInteger;

public class NumberGame {
    public static AtomicInteger num = new AtomicInteger(0);

    public static void main(String[] args) throws InterruptedException {
        Thread a = new Thread(() -> { for (int i = 0; i < 1000; i++) num.incrementAndGet(); });
        Thread b = new Thread(() -> { for (int i = 0; i < 1000; i++) num.incrementAndGet(); });
        a.start(); b.start();
        a.join(); b.join();
        System.out.println(num.get()); // 2000,没毛病
    }
}

咋实现的 ?靠 CAS(比较并交换),直接用 CPU 的原子指令,效率高还不堵车。 接轨主流:Java 的并发包里全是这种玩法,像线程池、并发容器都靠它。

3. 锁的进化

JVM 也没闲着,给 synchronized 加了优化:

  • 偏向锁:线程少时几乎没开销。
  • 轻量级锁:竞争稍微多点时用。
  • 重量级锁:真打起来才上。

更现代的路子 :用 ReentrantLock,比 synchronized 灵活,能搞公平锁、条件等待,功能更强。


总结一下

JMM 把内存分成主内存和工作内存,线程间数据同步靠 volatile 管可见性,synchronized 管独占性。从啥也不干到加锁,再到原子类和锁的精细优化,线程安全一步步稳如老狗。两个线程各加 1000 次,最后就是 2000,数字准,方案扎实,拿去用绝对靠谱!

相关推荐
掘金者阿豪11 分钟前
听书别再被会员和广告打断:把NAS变成自己的私人有声图书馆
后端
拉长的苗条44 分钟前
细说 ASP.NET Cache 及其高级用法
后端·asp.net
一路向北North1 小时前
Spring Security OAuth2.0(19):JWT令牌
java·后端·spring
用户6757049885021 小时前
Beanstalkd 实战指南:原来延迟队列、异步任务可以如此简单丝滑!
后端·消息队列
MacroZheng2 小时前
给Claude Code装上这款炫酷的HUD插件,状态信息就一目了然了!
java·人工智能·后端
达达尼昂2 小时前
编码智能体中的模型选择与执行深度
人工智能·后端·程序员
用户6757049885022 小时前
写了 6 年 Go ,我终于领悟到了泛型的真正威力!
后端·go
MegatronKing2 小时前
AI时代我们要如何玩抓包测试
前端·后端·测试
名字还没想好☜2 小时前
Go error 处理:errors.Is/As 与错误包装
开发语言·后端·golang·go·错误处理
doiito2 小时前
【安全,架构】RustyVault 项目深度分析报告以及和HashiCorp Vault的差异
后端·安全·rust