当前位置: 首页 > news >正文

【JVM】- 内存模式

Java内存模型:JMM(Java Memory Model),定义了一套在多线程环境下,读写共享数据(成员变量、数组)时,对数据的可见性,有序性和原子性的规则和保障。


原子性

问题分析

问题】:两个线程对初始值为0的静态变量操作,一个线程做自增,一个线程做自减,各做50000次,结果是0吗?

public class Demo01 {static int i = 0;public static void main(String[] args) throws InterruptedException {Thread t1 = new Thread(() -> {for (int j = 0; j < 50000; ++j) {i++;}});Thread t2 = new Thread(() -> {for (int j = 0; j < 50000; ++j) {--i;}});t1.start();t2.start();// 让主线程等待t1和t2两个子线程执行完毕后,再执行后续代码t1.join();t2.join();System.out.println(i);}
}

结果】:上边代码输出,每次运行的结果不一样。

原因】:Java中对静态变量的自增自减并不是原子操作,对于i++而言:

getstatic i // 获取静态变量i的值
iconst_1 // 准备常量1
iadd // 加法
putstatic i // 将修改后的值存入静态变量i中

Java的内存模型如下,如果需要完成静态变量的自增、自减,需要在主内存和工作线程的内存中进行交换数据。
在这里插入图片描述

由于当线程是按顺序执行,所以并不会出现问题。
但是在多线程下,可能出现交错运行。线程是一个抢占式的,大家都是轮流使用CPU

解决方法

synchronized(对象) {要作为原子操作的代码
}

修正后:

public class Demo02 {static int i = 0;static Object obj = new Object();public static void main(String[] args) throws InterruptedException {Thread t1 = new Thread(() -> {synchronized (obj) {for (int j = 0; j < 50000; ++j) {i++;}}});Thread t2 = new Thread(() -> {synchronized (obj) {for (int j = 0; j < 50000; ++j) {--i;}}});t1.start();t2.start();t1.join();t2.join();System.out.println(i);}
}

这样++i和–i的四条指令都可以作为一个整体来运行。
并且:t1和t2必须锁的是同一个obj对象(相当于这两个人进入了两个不同的房间)

Monitor(监视器)

Monitor 是一种线程同步机制,可以理解为 对象锁 的内部实现。每个 Java 对象(Object)在 JVM 内部都有一个关联的 Monitor,用于实现 synchronized 同步机制。

Monitor 的组成部分

(1) Owner(持有者)

  • 作用:表示当前持有 Monitor 的线程。
  • 特点
    • 当线程进入 synchronized 代码块时,会尝试获取 Monitor 的 owner 权限。
    • 如果 ownernull(即没有线程持有锁),当前线程会成为 owner
    • 如果 owner 已经被其他线程持有,当前线程会进入 entryList 等待。

(2) EntryList(入口队列)

  • 作用:存储 竞争锁的线程(即等待获取锁的线程)。
  • 特点
    • 当线程 A 持有锁时,线程 B 尝试进入 synchronized 代码块,会进入 entryList 并进入 BLOCKED 状态。
    • 当线程 A 释放锁(退出 synchronized 代码块),JVM 会从 entryList 中唤醒一个线程,使其竞争锁。

(3) WaitSet(等待队列)

  • 作用:存储 调用了 wait() 的线程(即主动放弃锁的线程)。
  • 特点
    • 当线程 A 调用 wait() 时,它会释放锁,并进入 waitSet,状态变为 WAITING
    • 当其他线程调用 notify()notifyAll() 时,JVM 会从 waitSet 中随机唤醒一个(或全部)线程,使其重新竞争锁。

3. Monitor 的工作流程

synchronized (obj) {  // 1. 尝试获取 Monitor 的 ownerwhile (!condition) {obj.wait();    // 2. 释放锁,进入 waitSet}// 3. 执行同步代码
}
obj.notify();          // 4. 唤醒 waitSet 中的线程
  1. 线程 A 进入 synchronized 代码块
    • 检查 owner,如果为空,线程 A 成为 owner
    • 如果 owner 已被线程 B 持有,线程 A 进入 entryList(BLOCKED 状态)。
  2. 线程 A 调用 wait()
    • 释放 owner,线程 A 进入 waitSet(WAITING 状态)。
    • JVM 从 entryList 中唤醒一个线程(如线程 B),使其成为新的 owner
  3. 线程 B 调用 notify()
    • waitSet 中随机唤醒一个线程(如线程 A),使其重新进入 entryList(BLOCKED 状态)。
    • 线程 A 需要重新竞争锁(不会立即获得锁)。
  4. 线程 B 退出 synchronized 代码块
    • 释放 owner,JVM 从 entryList 中选择一个线程(如线程 A),使其成为新的 owner

可见性

问题分析

问题】:main线程对于run变量的修改对t线程是不可见的,这就导致了t线程无法停止:

public class Demo03 {static boolean run = true;public static void main(String[] args) throws InterruptedException {Thread t = new Thread(() -> {while (run) {// ...}});t.start();Thread.sleep(1000);run = false; // 不会停下来}
}

原因】:

  1. 初始的时候,t线程刚开始从main线程的内存中读取了run的值到工作线程
  2. 因为t线程要频繁的从主内存中读取run的值,JIT编译器会将run的值缓存到自己的工作内存中的高速缓冲区中,这样就可以减少对主内存的读取。
  3. 主线程睡眠1s后,main线程修改了run的值,并同步到贮存,而t线程仍然是从自己工作内存中的高速缓存中读取这个变量的值,结果永远是旧值。
    在这里插入图片描述

解决办法

volatile(易变关键字):用来修饰成员变量和静态成员变量,可以避免线程从自己的工作缓存中查找变量的值必须到主存中获取它的值,线程操作volatile变量都是直接操作主内存。

public class Demo03 {static volatile boolean run = true;public static void main(String[] args) throws InterruptedException {Thread t = new Thread(() -> {while (run) {// ...}});t.start();Thread.sleep(1000);run = false; // 不会停下来}
}
  • volatile:保证的是在多个线程之间,一个线程对volatile变量的修改对另一个线程可见,但是不能保证原子性,只能用在一个写线程,多个读线程的情况。
  • synchronized:既可以保证原子性,也能保证代码块变量的可见性。但是缺点是:synchronized属于重量级操作,性能相对更低。

补充】:在上边的代码中,如果不加volatile,但是在for循环里加System.out.println(),t线程也能正常看到对run变量的修改。
原因】:System.out.println()底层使用syncronized关键字,强制要求当前的线程不要从高速缓存中获取,从主线程中获取。

有序性

问题分析——指令重排序

public class Demo04 {static int num = 0;static boolean ready = false;// 线程1:执行此方法public static void actor1(I_Result r) {if(ready) {r.r1 = num + num;}else {r.r1 = 1;}}// 线程2:执行此方法public static void actor2(I_Result r) {num = 2;ready = true;}public static void main(String[] args) throws InterruptedException {I_Result r = new I_Result();Thread t1 = new Thread(() -> {actor1(r);});Thread t2 = new Thread(() -> {actor2(r);});t1.start();t2.start();t1.join();t2.join();System.out.println(r.r1);}
}class I_Result {int r1;
}

上边的代码执行一共可能会有三种不同的输出:

  1. 正常执行:t2先执行完,t1后执行 ==> 输出4
  2. t1先执行完,t2后执行 ==> 输出1
  3. 指令重排序(导致ready先变成true,num还未赋值)
    • t2先执行ready = true
    • t1执行(此时num还未被t2修改):r.r1 = num + num = 0
    • t2再执行num = 2(但是t1已经计算完毕,不会影响结果)

解决方法

如果要保证线程安全,可以:

  1. 使用 volatile 修饰 readynum,禁止指令重排序,并保证可见性:
   static volatile int num = 0;static volatile boolean ready = false;
  • 这样 t2num = 2ready = true 不会重排序,且 t1 能立即看到修改。
  • 可能的输出:14(不会出现 0)。
  1. 使用 synchronized 加锁,确保原子性:
   public static synchronized void actor1(I_Result r) { ... }public static synchronized void actor2(I_Result r) { ... }
  • 这样 t1t2 不会同时执行,输出一定是 14

有序性理解

static int i, j;
// 在某个线程内执行:
i = ...; // 较为耗时的操作
j = ...; 

由于这段代码先执行i还是先执行j对结果并不会有影响,所以上面代码的执行可以是先对i赋值,再对j赋值;也可以是先对j赋值,再对i赋值

案例:双重检查锁

public class Singleton {private Singleton(){}private static Singleton INSTANCE = null;public static Singleton getInstance(){// 实例没创建,才会进入内部的synchronized代码块if(INSTANCE == null){synchronized (Singleton.class){// 也许有其他线程已经创建实例,所以再判读一次if(INSTANCE == null){INSTANCE = new Singleton();}}}return INSTANCE;}
}

上边是通过懒汉式的方式实现单例模式,只有首次使用getInstance()才使用synchronized加锁,后续使用无需加锁。

多线程下可能的问题

但是在多线程环境下,上边的代码是有问题的
INSTANCE = new Singleton();这行代码在JVM中并不是原子操作,它分为三个步骤:

  1. 分配内存空间(malloc)
  2. 初始化对象(调用构造方法Sington())
  3. 将INSTANCE指向分配的内存地址(赋值)

但是JVM可能会对指令重排序(优化执行顺序),变成:

  1. 分配内存空间
  2. 将INSTANCE指向分配的内存地址(此时INSTANCE != null,但是对象未初始化)
  3. 初始化对象(调用构造方法)

如果发生这种重排序,可能导致:

  • 线程 A 执行 INSTANCE = new Singleton();,但只完成了 步骤 1 和 2(INSTANCE 已不为 null,但对象未初始化)。
  • 线程 B 调用 getInstance(),发现 INSTANCE != null,直接返回 未初始化完成的对象,导致错误!

解决办法

使用volatile禁止指令重排序

private static volatile Singleton INSTANCE = null;

happens-before

是JMM的核心规则,定义了 多线程环境下操作的可见性和顺序性,确保一个线程对共享变量的修改能被其他线程正确观察到。


Java 内存模型定义了 6 种 Happens-Before 规则:程序顺序、锁、volatile、线程启动、线程终止、传递性

(1) 程序顺序规则(Program Order Rule)

在同一个线程中,前面的操作 Happens-Before 后面的操作。

int x = 1;    // (1)
int y = x + 1; // (2) —— (1) Happens-Before (2)
  • 单线程下,代码顺序执行,(1) 的结果对 (2) 可见。

(2) 锁规则(Monitor Lock Rule)

解锁操作 Happens-Before 后续的加锁操作。

synchronized (lock) {x = 10;    // (1)
}              // 解锁 (1) Happens-Before 后续的加锁
synchronized (lock) {int y = x; // (2) —— 能读到 x = 10
}
  • 线程 A 解锁后,线程 B 加锁时能看到 A 的修改。

(3) volatile 变量规则(Volatile Variable Rule)

volatile 变量的写操作 Happens-Before 后续的读操作。

volatile boolean flag = false;// 线程 A
flag = true;   // (1) —— 写操作// 线程 B
if (flag) {    // (2) —— (1) Happens-Before (2),能读到 flag = true// do something
}
  • volatile 保证可见性,写操作后,读操作一定能看到最新值。

(4) 线程启动规则(Thread Start Rule)

线程的 start() 方法 Happens-Before 该线程的所有操作。

int x = 0;Thread t = new Thread(() -> {System.out.println(x); // (2) —— 能读到 x = 1
});
x = 1;                    // (1)
t.start();                 // (1) Happens-Before (2)
  • 主线程修改 x = 1 后,子线程能读到这个值。

(5) 线程终止规则(Thread Termination Rule)

线程的所有操作 Happens-Before 它的终止检测(如 join())。

int x = 0;Thread t = new Thread(() -> {x = 1;                // (1)
});
t.start();
t.join();                // (2) —— (1) Happens-Before (2)
System.out.println(x);    // 输出 1
  • 子线程修改 x = 1 后,主线程 join() 后能读到最新值。

(6) 传递性规则(Transitivity Rule)

**如果 A Happens-Before B,且 B Happens-Before C,那么 A Happens-Before C。

int x = 0;
volatile boolean flag = false;// 线程 A
x = 1;            // (1)
flag = true;      // (2) —— (1) Happens-Before (2)// 线程 B
if (flag) {       // (3) —— (2) Happens-Before (3)System.out.println(x); // 输出 1 —— (1) Happens-Before (3)
}
  • 由于 (1) → (2) → (3),所以 (1)(3) 可见。

CAS与原子类

CAS

CAS:Compare and Swap,是一种乐观锁的思想。
案例】多个线程要对一个共享变量的整型变量执行 + 1操作:

// 需要不断尝试
while(true) {int 旧值 = 共享变量; // 旧值 = 0int 结果 = 旧值 + 1// 结果 = 0 + 1 = 1/*这时候如果别的线程把共享变量改成了5,本线程的正确结果1就作废了,此时:compareAdnSwap:返回false,重新尝试,直到:compareAndSwap:返回true,表示本线程做修改的同时,其他线程没有干扰*/if(compareAndSwap(旧值, 结果)) {// 成功,退出循环}
}

注意】:
共享变量一定要用volatile修饰,保证共享变量的可见性,当前线程拿到的共享变量必须一定要是新值。(结合CAS和volatile就可以实现无锁并发了,适用于竞争不激烈、多核CPU的场景)

  • 如果竞争激烈,线程重试会频繁发生,效率会受到影响
  • 因为没有使用synchronized,线程并不会陷入阻塞,这也是效率提升的因素

CAS底层依赖于Unsafe类来直接调用操作系统底层的CAS指令

乐观锁与悲观锁

CAS:最乐观的估计,不怕别的线程来修改共享变量,如果改了就重试即可。
synchronized:最悲观的估计,得防着其他线程来修改共享变量,直接给代码上锁,等执行完解开锁了,其他线程才有机会执行。

原子操作类

juc(java.util.concurrent)包下提供了原子操作类,可以提供线程安全的操作,例如:AtomicInteger、AtomicBoolean…,他们的底层就是使用CAS + volatile来实现的。

public class Demo05 {static AtomicInteger i = new AtomicInteger(0);public static void main(String[] args) throws InterruptedException {Thread t1 = new Thread(() -> {for (int j = 0; j < 50000; ++j) {i.getAndIncrement();}});Thread t2 = new Thread(() -> {for (int j = 0; j < 50000; ++j) {i.getAndDecrement();}});t1.start();t2.start();// 让主线程等待t1和t2两个子线程执行完毕后,再执行后续代码t1.join();t2.join();System.out.println(i);}
}

synchronized优化

JVM中,每个对象都有对象头(包括class指针、Mark Word)。Mark Word平时存储这个对象的哈希码、分代年龄…,当加锁时,这些信息就会被替换成标记位、线程锁记录指针、重量级指针、线程id…

轻量级锁

如果一个对象虽然有多个线程访问,但多线程访问的时间是错开的(没有竞争),那么可以用轻量级锁来优化。

这就类似于:学生A(线程A)用课本占座,短暂的离开教室了一下(时间片到)

  • 回来发现课本没变(没有竞争),就会继续上课(仍然保持轻量级锁)
  • 如果期间又来了一个学生B(线程B),就会告知学生A(线程A)此时有并发访问,线程A就会升级成重量级锁,进入重量级锁的流程。

每个线程的栈帧都会包含一个锁记录的结构,内部可以存储锁定对象的Mark Word。

static Object obj = new Object();
public static void method1() {synchronized(obj) {// 同步块Amethod2();}
}
public static void method2() {synchronized(obj) {// 同步块B}
}
线程1对象Mark Word线程2
访问同步块A,把MarkWord赋值到线程1的锁记录01(无锁)-
CAS修改MarkWord为线程1锁记录01(无锁)-
成功(加锁)00(轻量级锁)线程1锁记录地址-
执行同步块A00(轻量级锁)线程1锁记录地址-
访问同步块B,把MarkWord赋值到线程1的锁记录00(轻量级锁)线程1锁记录地址-
CAS修改MarkWord为线程1锁记录00(轻量级锁)线程1锁记录地址-
失败(发现是自己的锁)00(轻量级锁)线程1锁记录地址-
锁重入00(轻量级锁)线程1锁记录地址-
执行同步块B00(轻量级锁)线程1锁记录地址-
同步块B执行完毕00(轻量级锁)线程1锁记录地址-
同步块A执行完毕00(轻量级锁)线程1锁记录地址-
成功(解锁)01(无锁)-
-01(无锁)访问同步块A,把MarkWord赋值到线程2的锁记录
-01(无锁)CAS修改MarkWord为线程2锁记录
-00(轻量级锁)线程1锁记录地址成功(加锁)

锁膨胀

在尝试加轻量级锁的过程中,CAS操作无法成功,这时如果其他线程为这个对象加上轻量级锁(有竞争),这时就需要进行锁膨胀,将轻量级锁变为重量级锁

static Object obj = new Object();
public static void method1() {synchronized(obj) {// 同步块}
}
线程1对象Mark Word线程2
访问同步块,把MarkWord赋值到线程1的锁记录01(无锁)-
CAS修改MarkWord为线程1锁记录01(无锁)-
成功(加锁)00(轻量级锁)线程1锁记录地址-
执行同步块00(轻量级锁)线程1锁记录地址-
执行同步块00(轻量级锁)线程1锁记录地址访问同步块,把MarkWord赋值到线程2
执行同步块00(轻量级锁)线程1锁记录地址CAS修改MarkWord为线程2锁记录
执行同步块00(轻量级锁)线程1锁记录地址失败(发现别人已经占了锁)
执行同步块00(轻量级锁)线程1锁记录地址CAS修改Mark为重量级锁
执行同步块10(重量级锁)重量锁指针阻塞中
执行完毕10(重量级锁)重量锁指针阻塞中
失败(解锁)10(重量级锁)重量锁指针阻塞中
释放重量锁,唤起阻塞线程竞争10(重量级锁)重量锁指针阻塞中
-10(重量级锁)重量锁指针竞争重量锁
-10(重量级锁)重量锁指针成功(加锁)

加重量级锁是为了后边唤醒的时候,根据重量级锁的指针唤醒阻塞中的线程。

重量级锁

重量级锁竞争时,可以使用自旋来进行优化,如果当时线程自旋成功(说明此时持有锁的线程已经退出同步代码块,释放锁),此时当前线程就可以避免阻塞,直接进入运行状态。

自旋锁是自适应的

  • 对象刚刚的一次自选操作成功了,那么认为这次自旋成功的可能性会高,就会多自旋几次;
  • 反之,就少自旋 或 不自旋

注意,自旋会占用CPU时间,只有多核的CPU才能发挥自旋的优势。

偏向锁

只有第一次使用CAS将线程ID设置到对象的Mark Word投,之后发现这个线程ID是自己的,就表示没有竞争,不用重新CAS。

其他优化

  1. 较少上锁时间:同步代码块中尽量短
  2. 减少锁的粒度:将一个锁拆分成多个锁提高并发度
  • ConcurrentHashMap:每次只锁住了一个部分,其他读取操作不会受到影响。
  • LongAdder:累加工具类,分为base和cells两部分
    • 没有并发争用或cells数组正在初始化时,就会使用CAS来累加到base
    • 有并发争用,就会初始化cells数组,数组有多少个cell,就允许多少线程并行修改,最后将数组中每个cell累加,再加上base就是最终的值
  • LinkedBlockingQueue:出队和入队使用的就是不同的锁,相对于LinkedBlockingArray只有一个锁效率要高
  1. 锁粗化:StringBuffer的append方法都会调用synchronized来进行同步保护,如果不加以限制,那么下边这段代码会重复调用三次synchronized。JVM会将多次的append的加锁操作粗化为一次(因为都是一个对象加锁,没必要重入多次)
new StringBuffer().append("a").append("b").append("c");
  1. 锁消除:JVM会进行代码的逃逸分析,例如某个加锁对象是方法内局部变量,不会被其他线程访问到,这时就会被即时编译器忽略掉所有同步操作。
  2. 读写分离:CopyOnWriteArrayList、CopyOnWriteSet(读原始数组的内容;写操作会复制一份,在新数组上进行写操作)

相关文章:

  • 如何在Windows上使用qemu安装ubuntu24.04服务器?
  • 【Elasticsearch】文档(二):更新
  • 变幻莫测:CoreData 中 Transformable 类型面面俱到(四)
  • Matlab数字信号处理——基于GUI的ECG信号处理平台设计与实现
  • 解决docker下的Linux系统调用GPU失败
  • STM32 HAL库学习 RNG篇
  • 国产操作系统-银河麒麟本地化部署Ollama国产开源的AI大模型Qwen3
  • 华为云Flexus+DeepSeek征文 | 基于华为云ModelArts Studio平台与Cherry Studio搭建知识库问答助手
  • 【运维系列】【ubuntu22.04】安装Docker
  • Matlab | matlab中100个常用函数全面解析
  • Spring事务简介
  • wordpress外贸独立站搭建步骤
  • 构建多智能体(AI Agent)的高效协作平台——CrewAI探索
  • 从代码学习深度学习 - 词的相似性和类比任务 PyTorch版
  • 电池预测 | 第32讲 Matlab基于CNN-BiLSTM-Attention的锂电池剩余寿命预测,附锂电池最新文章汇集
  • 【系统设计【3】】系统设计面试框架:从理论到实践的完整指南
  • Tomcat 配置双击启动
  • java 设计模式_行为型_19命令模式
  • Django 5.2.3 构建的图书管理系统
  • Linux-多线程安全
  • 滚动网站模版/培训机构咨询
  • wordpress single cat/搜狗seo软件
  • 网站域名更改了怎么换/湖人今日排名最新
  • 简述网站推广方式/南宁百度seo排名价格
  • 长沙小学网站建设/百度关键词seo排名
  • 做英文简历的网站/搜狐财经峰会