浅谈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,数字准,方案扎实,拿去用绝对靠谱!

相关推荐
coderSong25681 小时前
Java高级 |【实验八】springboot 使用Websocket
java·spring boot·后端·websocket
Mr_Air_Boy2 小时前
SpringBoot使用dynamic配置多数据源时使用@Transactional事务在非primary的数据源上遇到的问题
java·spring boot·后端
咖啡啡不加糖3 小时前
Redis大key产生、排查与优化实践
java·数据库·redis·后端·缓存
大鸡腿同学3 小时前
纳瓦尔宝典
后端
2302_809798325 小时前
【JavaWeb】Docker项目部署
java·运维·后端·青少年编程·docker·容器
zhojiew5 小时前
关于akka官方quickstart示例程序(scala)的记录
后端·scala
sclibingqing5 小时前
SpringBoot项目接口集中测试方法及实现
java·spring boot·后端
JohnYan6 小时前
Bun技术评估 - 03 HTTP Server
javascript·后端·bun
周末程序猿7 小时前
Linux高性能网络编程十谈|C++11实现22种高并发模型
后端·面试