Synchronize和Volatile底层实现原理

本文深入探讨Java并发机制的底层实现原理,涵盖volatile关键字、synchronized机制、原子操作及上下文切换的影响。详解多线程环境下锁的升级过程,包括偏向锁、轻量级锁与重量级锁的状态转换。

最近在看并发编程的艺术这本书,希望加深对并发这块的理解。毕竟并发相关还是十分重要的。本文主要是关于第二章Java并发机制的底层实现原理的相关记录。主要包括volatile,synchronized,原子操作等实现原理的分析。

更多文章欢迎访问我的个人博客–>幻境云图

1. 上下文切换

多线程

即使是单核处理器也支持多线程执行代码,CPU通过给每个线程分配CPU时间片来实现这个机制。

什么是上下文切换

CPU通过时间片分配算法来循环执行任务,当前任务执行一个时间片后会切换到下一个任务。但是,在切换前会保存上一个任务的状态,以便下次切换回这个任务时,可以再加载这个任务的状态。

所以任务从保存到再加载的过程就是一次上下文切换。

上下文切换也会影响多线程的执行速度

因为线程有创建和上下文切换的开销,所以有时候并发不一定比串行快。

减少上下文切换的办法

减少上下文切换的方法有无锁并发编程、CAS算法、使用最少线程和使用协程。

  • 无锁并发编程。多线程竞争锁时,会引起上下文切换,所以多线程处理数据时,可以用一
    些办法来避免使用锁,如将数据的ID按照Hash算法取模分段,不同的线程处理不同段的数据。
  • CAS算法。Java的Atomic包使用CAS算法来更新数据,而不需要加锁。
  • 使用最少线程。避免创建不需要的线程,比如任务很少,但是创建了很多线程来处理,这
    样会造成大量线程都处于等待状态。
  • 协程:在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换。

2. volatile关键字

如果一个字段被声明成volatile,Java线程内存模型确保所有线程看到这个变量的值是一致的。

有volatile变量修饰的共享变量进行写操作的时候会多出第二行汇编代码,其中就包括了Lock前缀.Lock前缀的指令在多核处理器下会引发了两件事情。

1)将当前处理器缓存行的数据写回到系统内存。

Lock前缀指令导致在执行指令期间,声言处理器的LOCK#信号。在多处理器环境中,LOCK#信号确保在声言该信号期间,处理器可以独占任何共享内存。

如果访问的内存区域已经缓存在处理器内部,则不会声言LOCK#信号。相反,它会锁定这块内存区
域的缓存并回写到内存,并使用缓存一致性机制来确保修改的原子性,此操作被称为“缓存锁
定”,缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据。

2)这个写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效。

处理器使用嗅探技术保证它的内部缓存、系统内存和其他处理器的缓存的数据在总线上保持一致。如果通过嗅探一个处理器来检测其他处理器打算写内存地址,而这个地址当前处于共享状态,那么正在嗅探的处理器将使它的缓存行无效,在下次访问相同内存地址时,强制执行缓存行填充。

3. synchronized原理与应用

Java SE 1.6中为了减少获得锁和释放锁带来的性能消耗而引入的偏向锁和轻量级锁,以及锁的存储结构和升级过程。

Java中的每一个对象都可以作为锁。具体表现
为以下3种形式。

  • 对于普通同步方法,锁是当前实例对象。
  • 对于静态同步方法,锁是当前类的Class对象。
  • 对于同步方法块,锁是Synchonized括号里配置的对象。
    当一个线程试图访问同步代码块时,它首先必须得到锁,退出或抛出异常时必须释放锁。

3.1 底层实现

JVM基于进入和退出Monitor对象来实现方法同步和代码块同步,但两者的实现细节不一样。

代码块同步是使用monitorentermonitorexit指令实现的.

方法同步是使用另外一种方式实现的,细节在JVM规范里并没有详细说明。但是,方法的同步同样可以使用这两个指令来实现。

monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit是插入到方法结束处和异常处,JVM要保证每个monitorenter必须有对应的monitorexit与之配对。任何对象都有一个monitor与之关联,当且一个monitor被持有后,它将处于锁定状态。线程执行到monitorenter指令时,将会尝试获取对象所对应的monitor的所有权,即尝试获得对象的锁。

3.2 Java对象头

synchronized用的锁是存在Java对象头里的。

java的对象头由以下三部分组成:

1,Mark Word

2,指向类的指针

3,数组长度(只有数组对象才有)

3.3 锁的升级与对比

Java SE 1.6中,锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状
态和重量级锁状态,这几个状态会随着竞争情况逐渐升级。

偏向锁

HotSpot的作者经过研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。

当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得了锁。如果测试失败,则需要再测试一下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁):如果没有设置,则使用CAS竞争锁;如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。

偏向锁解除

偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有正在执行的字节码)。它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着,如果线程不处于活动状态,则将对象头设置成无锁状态;如果线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程.

轻量级锁

(1)轻量级锁加锁
线程在执行同步块之前,JVM会先在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。
(2)轻量级锁解锁
轻量级解锁时,会使用原子的CAS操作将Displaced Mark Word替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。

优缺点比较

4. 原子操作的实现原理

原子(atomic)本意是“不能被进一步分割的最小粒子”,而原子操作(atomic operation)意为“不可被中断的一个或一系列操作”。

4.1 处理器如何实现原子操作

处理器提供总线锁定和缓存锁定两个机制来保证复杂内存操作的原子性。

第一个机制是通过总线锁保证原子性。

所谓总线锁就是使用处理器提供的一个LOCK#信号,当一个处理器在总线上输出此信号时,其他处理器的请求将被阻塞住,那么该处理器可以独占共享内存。

第二个机制是通过缓存锁定来保证原子性。

总线锁定的开销比较大,目前处理器在某些场合下使用缓存锁定代替总线锁定来进行优化。

所谓“缓存锁定”是指内存区域如果被缓存在处理器的缓存行中,并且在Lock操作期间被锁定,那么当它执行锁操作回写到内存时,处理器不在总线上声言LOCK#信号,而是修改内部的内存地址,并允许它的缓存一致性机制来保证操作的原子性,因为缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据,当其他处理器回写已被锁定的缓存行的数据时,会使缓存行无效.

4.2 Java如何实现原子操作

使用循环CAS实现原子操作

JVM中的CAS操作正是利用了处理器提供的CMPXCHG指令实现的。自旋CAS实现的基本思路就是循环进行CAS操作直到成功为止。

CAS实现原子操作的三大问题

1.ABA问题

但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它的值没有发生变化,但是实际上却变化了。

ABA问题的解决思路就是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加1。

2.循环时间长开销大

自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销。

3.只能保证一个共享变量的原子操作

操作多个共享变量时无法使用CAS操作,这个时候就可以用锁。还有一个取巧的办法,就是把多个共享变量合并成一个共享变量来操作。

使用锁机制实现原子操作

锁机制保证了只有获得锁的线程才能够操作锁定的内存区域。除了偏向锁,JVM实现锁的方式都用了循环CAS,即当一个线程想进入同步块的时候使用循环CAS的方式来获取锁,当它退出同步块的时候使用循环CAS释放锁。

参考

本文内容来自Java并发编程的艺术

<think>嗯,用户想了解C语言中volatile关键字的详细解释使用方法。这个问题很专业,需要系统性地整理volatile的概念、语法、使用场景注意事项。 用户提供的引用资料很有价值,特别是引用[1]引用[2]详细说明了volatile的作用机制使用场景。引用[1]从基础概念入手,解释了volatile如何防止编译器优化;引用[2]则重点强调了多线程硬件编程中的注意事项;引用[3]虽然主要讲char类型,但也展示了C语言关键字的具体用法示例。 准备从这几个维度组织回答:先精确定义volatile的作用原理,用寄存器缓存优化的例子说明必要性;然后展示标准语法格式;接着分类讨论硬件寄存器访问、中断服务程序、多线程共享变量等典型场景;最后列举"中断标志位未声明volatile导致程序错误"等常见陷阱。所有代码示例都会用$...$格式标注行内变量,比如$volatile int flag$。 用户可能是嵌入式开发者或系统程序员,因为volatile在应用层编程中很少见。ta特意要求"详解",说明需要实践指导而不仅是理论定义。考虑在回答末尾添加"如何调试volatile相关错误"等实用问题,帮助ta深入理解。 特别注意要区分volatile原子操作的区别——这是很多初学者容易混淆的点。引用[2]提到"不能保证原子性"这点很重要,要在注意事项里强调。另外用户提供的示例代码中有个字符串初始化错误(char b="a"应为char *b="a"),不过这与volatile无关就不展开了。</think>好的,我们来详细解析C语言中的`volatile`关键字,包括其概念、作用、使用方法关键注意事项。 **`volatile` 关键字详解** 1. **概念与作用** * `volatile` 是C语言中的一个类型修饰符(Type Qualifier),它告诉编译器:**被它修饰的变量的值可能会在意料之外的情况下被改变**。 * 主要目的是**防止编译器进行可能影响程序正确性的优化**。 * 编译器优化通常假设程序在单线程环境中顺序执行,变量值的变化完全由当前线程内的代码控制。因此,编译器可能会: * **缓存变量到寄存器**:如果发现某个变量在代码块内被多次读取且没有写入操作,编译器可能将其值加载到寄存器中,后续读取直接使用寄存器值而不是每次都去内存读取。这能提高速度。 * **省略看似冗余的读写**:如果发现代码对变量进行了写入操作,但后续没有读取就再次写入,或者读取后没有使用,编译器可能会省略掉一些它认为“冗余”的读写操作。 * **问题所在**:对于某些特殊变量,其值的变化并非由当前线程内的代码直接控制,而是由: * **硬件**:映射到内存地址的硬件寄存器(如状态寄存器、数据寄存器),其值会随外部事件(按键、数据到达、中断发生)而改变。 * **其他线程/进程**:在多线程/多任务环境中,一个线程修改了共享变量,另一个线程需要感知这个变化。 * **信号处理程序/中断服务程序(ISR)**:主程序(或另一个线程)中的变量可能被ISR修改。 * **`volatile` 的作用**:告知编译器,**每次需要访问这个变量时,都必须直接从其内存地址读取最新的值;每次修改这个变量时,都必须立即将其值写回内存地址**。编译器不能假设其值不变,也不能缓存它或省略对其的读写操作。 2. **语法用法** * `volatile` 在声明变量时使用,放在类型说明符之前或之后均可(但通常放在前面)。它可以与其他类型修饰符(如`const`)组合使用。 * **基本语法**: ```c volatile int sensor_value; // 声明一个易变的整型变量 volatile char *status_register; // 声明一个指向易变字符的指针 char volatile *data_port; // 同上,volatile位置可互换(修饰指向的对象) int *volatile ptr_to_int; // 声明一个易变的指针(指针本身易变,指向的内容不一定) volatile const int read_only_reg; // 声明一个只读的易变整型变量(硬件常量寄存器) ``` * **关键点**: * `volatile` 修饰的是变量本身或其指向的对象(取决于`*`的位置)。 * `volatile` 影响的是编译器如何生成访问该变量的代码(强制内存访问),而不是变量本身的存储位置(它仍然在内存中)。 3. **典型使用场景** * **硬件寄存器访问 (Memory-Mapped I/O)**: ```c #define PORT_A (*(volatile unsigned char *)0x10000000) void main() { unsigned char data; PORT_A = 0xFF; // 向地址0x10000000写入数据 data = PORT_A; // 从地址0x10000000读取数据 } ``` 硬件寄存器的值随时可能被外部设备改变(如串口接收数据),编译器绝不能缓存或优化掉对其的读写。`volatile` 确保每次`PORT_A`的读取都真实访问硬件地址。 * **中断服务程序(ISR)修改的全局变量**: ```c volatile int data_ready = 0; // 主循环ISR共享的标志 void UART_ISR(void) { // ... 接收数据 ... data_ready = 1; // ISR中设置标志 } int main() { while(1) { if (data_ready) { // 主循环检测标志 process_data(); data_ready = 0; } // ... 其他任务 ... } } ``` 如果`data_ready`没有声明为`volatile`,编译器优化可能认为`while(1)`循环内`data_ready`的值不会变化(因为主循环内没有修改它的代码),从而将`if (data_ready)`优化成只检查一次或使用寄存器中的旧值(很可能是0),导致程序无法感知ISR设置的标志。 * **多线程共享变量**: ```c volatile int shared_counter = 0; void thread_func1() { while (1) { shared_counter++; // 线程1修改共享变量 } } void thread_func2() { while (1) { printf("Counter: %d\n", shared_counter); // 线程2读取共享变量 } } ``` 在多线程环境中,如果没有同步机制(如互斥锁),`volatile` **不能替代锁来保证原子性**(`shared_counter++`不是原子操作)。但`volatile`能确保线程2每次读取`shared_counter`时都去内存获取最新值,而不是使用可能过时的寄存器缓存值。*注意:多线程共享数据通常需要锁或原子操作,`volatile`只解决可见性问题的一部分。* * **信号处理程序修改的全局变量**:类似于ISR修改全局变量的情况。 4. **重要注意事项** * **`volatile` 不保证原子性**:`volatile` **只强制内存访问,不提供任何互斥或同步机制**。像`x++`(读-改-写)这样的操作在多线程或多核环境中,即使`x`是`volatile`的,仍然可能被打断,导致数据竞争(Data Race)。解决原子性问题需要使用锁(互斥量)、信号量或编译器/平台提供的原子操作(如C11的`_Atomic`或GCC的`__atomic_*`内置函数)。 * **`volatile` 与 `const`**:可以组合使用。`volatile const`表示变量本身是只读的(程序不能修改它),但其值可能被外部因素改变(如只读的状态寄存器),编译器仍需每次都读取其值。`const volatile int *p`表示`p`指向一个`volatile const int`。 * **指针的 `volatile`**: * `volatile int *p;`:指针`p`指向一个`volatile int`(指向的对象是易变的)。 * `int *volatile p;`:指针`p`本身是易变的(指针变量的值可能被意外改变,比如指向不同的硬件寄存器地址),但其指向的`int`对象不一定易变。 * `volatile int *volatile p;`:指针`p`本身它指向的对象都是易变的。 * **编译器优化屏障**:`volatile`变量访问本身会阻止编译器对内存访问顺序进行某些重排优化,但**它不能阻止CPU层面的指令重排**。在需要严格内存顺序保证的低级编程(如设备驱动、无锁编程)中,可能需要使用CPU提供的**内存屏障(Memory Barrier/Fence)指令**(如`__sync_synchronize()` in GCC)配合`volatile`使用。 * **不要滥用**:不必要的使用`volatile`会阻止编译器进行有益的优化,降低程序性能。只在确实需要防止编译器优化导致程序逻辑错误时才使用它。 * **初始化**:`volatile`变量可以像普通变量一样初始化。`volatile int x = 10;`是合法的。 5. **示例代码 (结合场景)** ```c #include <stdio.h> #include <signal.h> #include <unistd.h> volatile sig_atomic_t flag = 0; // 用于信号处理程序的标志,使用volatilesig_atomic_t void handle_signal(int sig) { flag = 1; // 信号处理程序修改全局标志 } int main() { signal(SIGINT, handle_signal); // 注册SIGINT (Ctrl+C) 信号处理函数 printf("程序运行中... 按Ctrl+C终止。\n"); while (!flag) { // 主循环检查volatile标志 // 模拟工作 sleep(1); printf("等待信号...\n"); } printf("收到终止信号,程序退出。\n"); return 0; } ``` **说明**: * 信号处理函数`handle_signal`异步地(在主程序执行`while`循环时随时可能被调用)修改全局变量`flag`。 * 如果`flag`没有声明为`volatile`,编译器优化可能将`while (!flag)`优化成`while (true)`(如果它检测到主循环内没有修改`flag`的代码),或者使用寄存器中的旧值(0),导致程序无法响应Ctrl+C信号。 * `volatile`确保每次循环条件检查都从内存读取`flag`的最新值。 * `sig_atomic_t`是一个整数类型,保证在信号处理程序中进行原子读写(通常是一个`int`),**配合`volatile`使用**是处理异步信号标志的常见安全做法[^1][^2]。 6. **总结** `volatile`关键字是C语言中用于处理特殊变量的重要工具,其核心作用是**告知编译器不要对被修饰变量的读写操作进行优化,强制每次访问都直接与内存交互**。它主要用于以下场景: * 访问映射到内存地址的**硬件寄存器**。 * 被**中断服务程序(ISR)**修改的全局变量。 * 被**信号处理程序**修改的全局变量。 * 在特定情况下(需谨慎),**多线程环境**中共享的、作为简单标志的全局变量(但需理解其局限性,不能替代锁或原子操作)。 使用`volatile`的关键是理解编译器优化的行为以及特定场景下变量值被“意外”修改的可能性。正确使用`volatile`能避免隐蔽的程序逻辑错误,尤其是在底层系统编程嵌入式开发中至关重要[^1][^2]。
评论 1
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值