目录
前言
先介绍一下线程的内存可见和执行顺序。
内存可见:就是线程执行结果在内存中对其它线程的可见性(volatile,final,synchronized)。
执行顺序:控制代码的执行顺序及是否可以并发执行。
在java的并发编程中,关于线程安全问题层出不穷,以下介绍两种常见的关键字synchronized
和volatile
,它们用于实现线程安全和可见性的机制。
1、概述
以下就是本章节要讲解的内容:
synchronized
是 Java 的一种锁机制,可以保证同一时间只有一个线程可以执行某个方法或代码块。当一个线程获取到某个对象的锁时,其他线程必须等待直到该锁被释放。
volatile
是一个轻量级的同步机制,用于保证变量的可见性。它告诉 JVM 不要对该变量进行缓存,每次都从主内存读取,而不是从线程的工作内存(缓存)中读取。
2、synchronized
2.1、组成
synchronized
是一种机制,用于确保同一时刻只有一个线程能够访被synchronized
修饰的代码,是一种内置的关键字,用于实现线程间的互斥访问。它主要通过对象头中的 Mark Word 来记录锁的状态,并利用 JVM 提供的 Monitor(监视器)来管理线程之间的竞争。
即:mark word+monitor组成实现;
内存模型:关于synchronized的monitor的介绍
2.2、原理
原理:对象头 +monitor-enter和monitor-exit来完成加锁和释放锁的操作。
2.2.1.对象头
由图可知,java的object对象在内存里面由:对象头+实例数据+对齐填充组成。
而关于对象头markwork由64bit组成。具体可参考以下图:
锁标志位可分为:
-
00:轻量级锁
-
01:无锁/偏向锁
-
10:重量级锁
-
11:GC标记
2.2.2.Monitor 机制
Monitor 是 Java 中实现同步的基础机制,每个 Java 对象都可以关联一个 Monitor 对象。
关于monitor的结构如下:
如上图可知:monitor主要由:entrylist、owner、waitset、Recursions(重入次数)组成;
整体的执行流程如下图:
1. monitorenter 执行流程
-
检查对象的 Mark Word 中的锁标志位
-
如果是无锁状态(01):
-
通过 CAS 操作尝试获取锁
-
成功则将 Mark Word 中的锁标志位改为 00(轻量级锁)
-
并将 Mark Word 内容替换为指向当前线程栈中锁记录的指针
-
-
如果是轻量级锁(00):
-
检查是否是当前线程持有锁(锁重入)
-
如果是,则增加重入计数
-
如果不是,则锁升级为重量级锁(10)
-
-
如果是重量级锁(10):
-
检查 Owner 是否是当前线程
-
如果是,则增加重入计数
-
如果不是,则线程进入 EntryList 阻塞等待
-
2. monitorexit 执行流程
-
检查当前线程是否是锁的持有者
-
如果是轻量级锁:
-
减少重入计数
-
如果计数为0,则释放锁,恢复 Mark Word
-
-
如果是重量级锁:
-
减少重入计数
-
如果计数为0:
-
设置 Owner 为 null
-
唤醒 EntryList 中的等待线程
-
-
2.3、实现
2.3.1.同步代码块
通过 monitorenter
和 monitorexit
指令实现。
代码示例同步代码块字节码:
public void syncBlock();
Code:
0: aload_0
1: dup
2: astore_1
3: monitorenter // 显式加锁
4: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
7: ldc #3 // String synchronized block
9: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
12: aload_1
13: monitorexit // 显式释放锁(正常路径)
14: goto 22
17: astore_2
18: aload_1
19: monitorexit // 显式释放锁(异常路径)
20: aload_2
21: athrow
22: return
Exception table:
from to target type
4 14 17 any
17 20 17 any
2.3.2.同步方法
ACC_SYNCHRONIZED
1.当锁住同步方法:通过方法表中的 ACC_SYNCHRONIZED
标志实现。
public synchronized void syncMethod() {
// 代码
}
2.主要原因是:
-
JVM 可以统一处理同步方法的进入和退出。
-
减少字节码大小(不需要为方法内的每个退出点生成
monitorexit
)。
3.处理流程
-
方法调用时:
-
当调用带有
ACC_SYNCHRONIZED
的方法时。 -
JVM 会隐式获取锁(对于实例方法是
this
,静态方法是 Class 对象)。 -
相当于在方法开始处自动插入
monitorenter。
-
-
方法执行期间:
-
当前线程持有锁。
-
其他线程无法获取同一个锁。
-
-
方法返回时:
-
无论是正常返回还是异常退出。
-
JVM 都会自动释放锁。
-
相当于在方法所有退出路径插入
monitorexit。
-
2.3.3.区别
与显式 monitorenter/monitorexit
不同:
-
不需要为每个可能的异常路径生成
monitorexit。
-
JVM 保证在任何退出情况下都会释放锁。
-
不会出现因异常导致锁无法释放的情况。
代码示例:
public synchronized void test() {
System.out.println("synchronized method");
}
对应的字节码(使用 javap -v
查看):
public synchronized void test();
descriptor: ()V
flags: (0x0021) ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
stack=2, locals=1, args_size=1
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #3 // String synchronized method
5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
注意 flags
中的 ACC_SYNCHRONIZED
标志。
当一个线程持有该对象的锁时,其他线程必须等待锁释放后才能进入 synchronized 的代码块。
-
加锁机制
当一个线程进入synchronized
方法或代码块时,JVM 首先会尝试获取该对象上的锁。如果成功,则继续执行;否则,当前线程会被阻塞并加入等待队列。 -
解锁机制
一旦持有锁的线程完成方法调用或者退出代码块范围,就会释放锁资源,允许其他处于等待状态下的线程重新争夺此锁。
示例:
public class Counter {
private int count;
public synchronized void increment() { // 对象级别锁定
count++;
}
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 1000; ++i) {
counter.increment();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 1000; ++i) {
counter.increment();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(counter.getCount());
}
public int getCount(){
return this.count;
}
}
输出:
count数字=2000
2.4、特点
1.保证互斥性:
通过加锁,确保同一时间只有一个线程可以执行被 synchronized
修饰的代码,避免数据不一致。
2.保证内存可见性
当一个线程修改被 synchronized
修饰的变量后,其他线程在获得该锁时,会看到最新的变量值。
2.5、使用方式
1.实例方法:
这里的 synchronized
修饰的是实例方法,相当于对当前实例对象加锁。
使用原理参照2.3.2章节的acc_synchronized来进行实现。
示例:
public synchronized void instanceMethod() {
// 线程安全的代码
}
2.静态方法:
这里的synchronized修饰的是静态方法,相当于对这个类的类对象(Class 对象)加锁。
public static synchronized void staticMethod() {
// 线程安全的代码
}
3.同步代码块
参考2.3.1章节的monitor_enter和monitor_exit来完成加锁和释放锁。
public void method() {
synchronized (this) { // 也可以用其他对象
// 线程安全的代码
}
}
允许更精细化的控制,只对特定代码块加锁,相比于修饰方法,它的性能开销更小。
2.6、优缺点
- 优点:
- 提供强一致性,确保线程安全。
- 适合复杂的同步需求。
- 缺点:
- 性能开销较大,可能导致线程阻塞和上下文切换。
- 易导致死锁,需小心使用。
3、volatile
3.1、介绍
一般情况下线程在执行时,Java中为了加快程序的运行效率,会先把主存数据拷贝到线程本地(寄存器或是CPU缓存),操作完成后再把结果从线程本地缓存刷新到主存中,这样就会导致修改后放入变量结果同步到主存中需要一个过程,而此时另外的线程看到的还是修改之前的变量值,这样就会导致不一致。
如下图所示:
在这种情况volatile关键字就可以完美解决可见性的问题.
主要用于保证变量的可见性。当一个线程对
volatile
变量的写入,其他线程会立即看到这个更新。JVM 确保对volatile
变量的操作不会被重排序,并且会从主内存中强制读取这些变量,而不是从线程的本地内存。
通过在变量声明时加上 volatile
关键字:
private volatile int count;
3.2、原理
volatile是变量修饰符,其修饰的变量具有内存可见性,基于CPU内存屏障指令(强制 CPU 按照特定的顺序执行加载和存储操作,防止因优化导致的指令重排)实现的。
3.3、特点
1.可见性:
volatile 它会使得所有对 volatile 变量的读写都会直接读写主存,而不是先读写线程本地缓存,这样就保证了变量的内存可见性。
示例:
public class VolatileExample {
private volatile boolean flag = false; // 使用 volatile 声明
public void writer() {
System.out.println("Writer thread is running...");
// 模拟一些操作
try {
Thread.sleep(1000); // 睡眠一秒,模拟工作
} catch (InterruptedException e) {
e.printStackTrace();
}
flag = true; // 修改 flag 的值
System.out.println("Writer thread has set flag to true.");
}
public void reader() {
System.out.println("Reader thread is running...");
// 当 flag 为 false 时,保持循环
while (!flag) {
// 空循环,等待 flag 的值变为 true
}
// 当 flag 为 true 时,打印信息
System.out.println("Reader thread sees flag is true. Exiting loop.");
}
public static void main(String[] args) {
VolatileExample example = new VolatileExample();
Thread writerThread = new Thread(example::writer);
Thread readerThread = new Thread(example::reader);
readerThread.start(); // 启动读线程
writerThread.start(); // 启动写线程
}
}
输出:
Reader thread is running...
Writer thread is running...
Writer thread has set flag to true.
Reader thread sees flag is true. Exiting loop.
2.防止指令重排:
volatile可以禁止进行指令重排
处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证各个语句的执行顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。
指令重排序不会影响单个线程的执行,但是会影响到线程并发执行时的正确性。
线程执行到volatile修饰变量的读写操作时,其他线程对这个变量的操作肯定已经完成了,且结果已经同步到了主存中,即对其他的线程可见,本线程再对该变量操作完全没有问题的。
3.4、适合范围
volatile
适用于简单的状态标志(如 boolean
变量,如boolen、 short 、int 、long等)或单个变量的更新,而不适合复杂的数据结构或方法,因为它无法提供互斥访问。
3.5、i++问题
i++
这类操作实际上是三个步骤的组合:
- 读取变量
i
的当前值。 - 将这个值加 1。
- 将新值写回变量
i
。
假如多个线程同时执行i++,volatile只能保证他们操作的i是同一块内存,但不能保证i结果的正确性。
示例:
public class Counter {
private volatile int count = 0; // 使用 volatile 声明
public void increment() {
for (int i = 0; i < 1000; i++) {
count++; // 这里是 i++
}
}
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Thread t1 = new Thread(counter::increment);
Thread t2 = new Thread(counter::increment);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("Final count: " + counter.count);
}
}
多次测试发现
由于两个线程都在并发执行 count++
操作,在执行过程中,它们可能会同时读取到相同的 count
值,导致更新冲突。
解决方案:
1.synchronized
示例:
public class SynchronizedExample {
private int count = 0;
public synchronized void increment() {
for (int i = 0; i < 1000; i++) {
count++; // 这里的 i++ 操作被 synchronized 修饰,确保它是原子的
}
}
public static void main(String[] args) throws InterruptedException {
SynchronizedExample example = new SynchronizedExample();
Thread t1 = new Thread(example::increment);
Thread t2 = new Thread(example::increment);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("Final count: " + example.count); // 现在这里应该是 2000
}
}
2.
AtomicInteger
示例:
import java.util.concurrent.atomic.AtomicInteger;
public class AtomicExample {
private AtomicInteger count = new AtomicInteger(0);
public void increment() {
for (int i = 0; i < 1000; i++) {
count.incrementAndGet(); // 使用原子操作
}
}
public static void main(String[] args) throws InterruptedException {
AtomicExample example = new AtomicExample();
Thread t1 = new Thread(example::increment);
Thread t2 = new Thread(example::increment);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("Final count: " + example.count.get()); // 这里应该是 2000
}
}
3.6、优缺点
- 优点:
- 性能开销小,相比
synchronized
来说。 - 简单易用,适合用于简单的可见性场景。
- 性能开销小,相比
- 缺点:
- 只能保证单个变量的可见性,无法保证原子性。
- 不适用于需要复杂同步场景的数据操作。
4. 锁升级
想了解更多java的锁介绍,可参考本人文章:java常用的锁-CSDN博客
由上面monitor-enter的执行流程可知,锁会进行升级,以下重点讲述下升级过程。
-
检查对象的 Mark Word 中的锁标志位,如下所示:
根据上图可知:无锁和偏向锁区别是偏向锁的的锁标记为1。
偏向锁----》轻量级锁-----》重量级锁。
同时,若偏向锁的线程未活动,锁撤销,则回滚未无锁状态。
4.1 偏向锁
-
初始时对象处于可偏向状态(匿名偏向)
-
第一个线程访问同步块:
-
通过 CAS 将线程ID写入 Mark Word
-
之后该线程进入同步块不需要同步操作
-
-
出现竞争时:
-
检查偏向的线程是否存活
-
如果不存活,则撤销偏向锁
-
如果存活,则升级为轻量级锁
-
4.2 轻量级锁
-
线程在栈帧中创建锁记录(Lock Record)
-
将对象头的 Mark Word 复制到锁记录中(Displaced Mark Word)
-
通过 CAS 将对象头的 Mark Word 替换为指向锁记录的指针
-
如果成功,则获取锁
-
如果失败,则自旋尝试
-
自旋超过阈值后升级为重量级锁
4.3 重量级锁
-
向操作系统申请互斥量(mutex)
-
线程挂起进入阻塞状态
-
被放入 Monitor 的 EntryList 中
-
锁释放时唤醒 EntryList 中的线程
锁升级代码示例:
public class LockUpgradeDemo {
private static final Object lock = new Object();
private static int counter = 0;
public static void main(String[] args) {
// 第一阶段:偏向锁
synchronized (lock) {
counter++;
}
// 第二阶段:轻量级锁(两个线程交替执行)
new Thread(() -> {
for (int i = 0; i < 100; i++) {
synchronized (lock) {
counter++;
}
}
}).start();
for (int i = 0; i < 100; i++) {
synchronized (lock) {
counter++;
}
}
// 第三阶段:重量级锁(多线程竞争)
for (int i = 0; i < 10; i++) {
new Thread(() -> {
for (int j = 0; j < 1000; j++) {
synchronized (lock) {
counter++;
}
}
}).start();
}
}
}
在实际过程中,减少锁的竞争,
比如在读写分离场景:可使用ReentrantReadWriteLock。
总结
-
无竞争时:使用最轻量的偏向锁
-
轻度竞争:使用自旋的轻量级锁
-
高竞争时:使用阻塞的重量级锁(考虑使用
java.util.concurrent
中的并发工具)
5、联系
1、volatile仅能使用在变量级别,synchronized则可以使用在变量、方法、和类级别的。
2、volatile仅能实现变量的修改可见性,不能保证原子性,而synchronized则可以保证变量的修改可见性和原子性。
3、volatile不会造成线程的阻塞,而synchronized可能会造成线程的阻塞。
4、volatile标记的变量不会被编译器优化,而synchronized标记的变量可以被编译器优化。
5、由于 4 中的区别,在某些情况下 volatile 的性能优于 synchronized。
参考文章: