深入解析synchronized实现原理,如何保证原子性、有序性和可见性?

世界上并没有完美的程序,但我们并不因此而沮丧,因为写程序本来就是一个不断追求完美的过程。

前言

我在上一篇文章简单的介绍了一些synchronized关键字的知识点和用法(有兴趣的可以点这里,传送门biubiu),而这篇文章主要介绍synchronized底层实现,还有它是如何保证原子性、有序性和可见性的。
在进入正题之前,我先铺垫一下,举个小栗子:
看代码:

public class Demo {

    public synchronized void method1() {
        System.out.println("Hello, do method1.");
        
        //模拟网络IO
        try {
            Thread.sleep(50);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        
        System.out.println("Finish doing method1.");
    }

    public synchronized void method2() {
        System.out.println("Hey, do method2.");
    }


    public void method3() {
        System.out.println("Let's do method3.");
    }
}

先说结论:如果一个对象有多个被synchronized修饰的方法,只要有一个线程访问了其中一个synchronized方法,那么其他线程则不能同时访问这个对象中任何一个synchronized方法,但是对于这个对象的其他的非synchronized修饰的方法,其他线程仍旧可以访问到。还有一点需要注意的是,不同对象实例的synchronized方法是互不干预的,其它线程可以同时访问此类下的另一个对象实例中的synchronized方法

验证结论,测试代码-1:

        Demo demo = new Demo();
        ExecutorService executor = Executors.newFixedThreadPool(9);
        for (int i = 0; i< 3; i++) {
            executor.execute(demo::method1);
        }
        for (int i = 0; i< 3; i++) {
            executor.execute(demo::method2);
        }
        for (int i = 0; i< 3; i++) {
            executor.execute(demo::method3);
        }

运行结果-1:
在这里插入图片描述从上面的运行结果可以看出,method3()的执行完全没受到method1()的影响,但是它影响到了method2()的执行,method2()的执行在method1()执行完成释放完锁之后。

测试代码-2:

        ExecutorService executor = Executors.newFixedThreadPool(9);
        for (int i = 0; i< 3; i++) {
            executor.execute(new Demo()::method1);
        }
        for (int i = 0; i< 3; i++) {
            executor.execute(new Demo()::method2);
        }
        for (int i = 0; i< 3; i++) {
            executor.execute(new Demo()::method3);
        }

运行结果-2:
在这里插入图片描述
从上面的运行结果可以看出,method1()method2()method3()的执行没有受到任何影响,这就说明了不同对象实例的synchronized方法是互不干预的

synchronized实现原理

在此之前,需要知道部分JVM知识的,比如HotSpot虚拟机,下面截图来自《深入理解java虚拟机-第二版》:
在这里插入图片描述
在这里插入图片描述
如果还不是很清楚HotSpot虚拟机,用CMD命令输入java -version,就可以看到我们其实一直用的是它:
在这里插入图片描述
在HotSpot虚拟机中,对象在内存中存储的布局可分为3块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。

  • 对象头:用于存储对象自身的运行时数据,如哈希码、GC分代年龄、锁状态标志、线程持有锁、偏向线程ID、偏向时间戳等,这部分数据官方称它为 “Mark Word”。五种状态(无锁、轻量级锁定、重量级锁定、GC标记、可偏向)下对象头 Mark Word的存储内容:
存储内容标志位状态
对象哈希码、对象分代年龄01未锁定
指向锁记录的指针00轻量级锁定
指向重量级锁的指针10膨胀(重量级锁定)
空,不需要记录信息11GC标志
偏向线程ID、偏向时间戳、对象分代年龄01可偏向
  • 实例数据:对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容。无论是从父类继承下来的,还是在子类中定义的,都需要记录起来。这部分的存储顺序会受到虚拟机分配策略参数和字段在Java源码中的定义顺序的影响,HotSpot虚拟机默认分配策略为longs/doubles、ints、shorts\chars、bytes\booleans、oops(Ordinary Object Pointers),从分配策略中就可以看出,相同宽度的字段总是被分配到一起。
  • 对齐填充:这部分并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。由于HotSpot VM的自动内存管理系统要求对象起始地址必须是8字节的整数倍,因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。

开始进入正题啦,来聊聊synchronized的实现原理,请看下面代码:

public class Demo2 {

    //同步方法
    public synchronized void method1() {
        // TODO: 2020/8/30
        System.out.println("Hello, do method1.");
    }

    public void method2() {
        //同步代码块
        synchronized (this) {
            // TODO: 2020/8/30
            System.out.println("Let's do method2.");
        }
    }
}

上面是两种最常使用synchronized的方式,执行"javap -c -v"命令进行反汇编,过滤掉其他无用的信息,反汇编结果如下:

public synchronized void method1();
    descriptor: ()V
    flags: 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 Hello, do method1.
         5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: return

public void method2();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=1
         0: aload_0
         1: dup
         2: astore_1
         3: monitorenter
         4: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         7: ldc           #5                  // String Let's do method2.
         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

从上面反汇编结果可以看出:JVM对于同步方法和同步代码块的处理方式不同,对于同步方法,JVM采用ACC_SYNCHRONIZED标记符来实现同步,而对于同步代码块,JVM则采用 monitorentermonitorexit 这两个指令实现同步。

  • ACC_SYNCHRONIZED标记符

方法级的同步是隐式的。同步方法的常量池中会有一个ACC_SYNCHRONIZED标志。当某个线程要访问某个方法的时候,会检查是否有ACC_SYNCHRONIZED标志,如果有设置,则需要先获得监视器锁,然后开始执行方法方法执行之后再释放监视器锁。这时如果其他线程来请求执行方法,会因为无法获得监视器锁而被阻断住。值得注意的是,如果在方法执行过程中,发生了异常,并且方法内部并没有处理该异常,那么在异常被抛到方法外面之前监视器锁会被自动释放。

  • monitorentermonitorexit 指令

可以把执行monitorenter指令理解为加锁,执行monitorexit理解为释放锁。 每个对象维护着一个记录着被锁次数的计数器。未被锁定的对象的该计数器为0,当一个线程获得锁(执行monitorenter)后,该计数器自增变为 1 ,当同一个线程再次获得该对象的锁的时候,计数器再次自增。当同一个线程释放锁(执行monitorexit指令)的时候,计数器再自减。当计数器为0的时候。锁将被释放,其他线程便可以获得锁。

但是细心的你是不是发现了上面出现了两条monitorexit 指令呢?这是为啥嘞?

是这样的,编译器需要确保方法中调用过的每条monitorenter指令都要执行对应的monitorexit 指令。为了保证在方法异常时,monitorentermonitorexit指令也能正常配对执行,编译器会自动产生一个异常处理器,它的目的就是用来执行异常的monitorexit指令。而字节码中多出的monitorexit指令,就是异常结束时被执行用来释放monitor的。

如何保证原子性、有序性和可见性?

  • 原子性: 即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行
    Java内存模型提供了字节码指令monitorentermonitorexit来隐式的使用这两个操作,在synchronized块之间的操作是具备原子性的。

线程1在执行monitorenter指令的时候,会对Monitor进行加锁加锁后其他线程无法获得锁,除非线程1主动解锁。即使在执行过程中,由于某种原因,比如CPU时间片用完,线程1放弃了CPU,但是它并没有进行解锁。而由于synchronized的锁是可重入的,下一个时间片还是只能被他自己获取到,还是会继续执行代码。直到所有代码执行完。这就保证了原子性

  • 有序性: 程序执行的顺序按照代码的先后顺序执行。
    在并发时,程序的执行可能会出现乱序。给人的直观感觉就是:写在前面的代码,会在后面执行。但是synchronized提供了有序性保证,这其实和as-if-serial语义有关。
    as-if-serial语义是指不管怎么重排序(编译器和处理器为了提高并行度),单线程程序的执行结果都不能被改变。编译器和处理器无论如何优化,都必须遵守as-if-serial语义。只要编译器和处理器都遵守了这个语义,那么就可以认为单线程程序是按照顺序执行的,由于synchronized修饰的代码,同一时间只能被同一线程访问。那么可以认为是单线程执行的。所以可以保证其有序性。

但是需要注意的是synchronized虽然能够保证有序性,但是无法禁止指令重排和处理器优化的

  • 可见性: 当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
    synchronized修饰的代码,在开始执行时会加锁,执行完成后会进行解锁,但在一个变量解锁之前,必须先把此变量同步回主存中,这样解锁后,后续其它线程就可以访问到被修改后的值,从而保证可见性。

非常感谢你能看到最后,如果能够帮助到你,是我的荣幸!

参考文章:

深入理解多线程(一)——Synchronized的实现原理
Java多线程:由浅入深看synchronized的底层实现原理

<think>嗯,用户现在问的是为什么volatile只能解决可见性有序性,而synchronized能同时保证原子性可见性有序性。这需要结合之前的问题来看,用户之前已经总结过四个特性,现在想更深入理解两者的区别。 首先,我需要确认用户对volatilesynchronized的基本理解。用户可能已经知道它们都是用于多线程的同步机制,但具体差异可能还不清楚。用户提到synchronized在锁释放时同步主内存,这可能涉及到内存屏障锁的语义。而volatile通过内存屏障保证可见性有序性,但无法保证复合操作的原子性。 接下来,我应该分点解释volatilesynchronized各自的机制。对于volatile,需要说明它如何确保变量的读写直接在主内存中进行,防止指令重排序,但像i++这样的操作包含多个步骤,volatile无法保证这些步骤的原子性。这时候需要用例子说明,比如两个线程同时进行i++操作,即使变量是volatile,依然可能发生竞态条件。 然后,关于synchronized,需要强调它通过互斥锁来确保同一时刻只有一个线程执行代码块,这样不仅保证原子性,还因为在锁释放时会强制将变量写回主内存,从而保证可见性。同时,synchronized块内的指令不会被重排序到外部,确保有序性。 可能用户还不太清楚内存屏障的具体作用,或者volatile锁在JMM中的不同实现方式。这时候需要简要提到Java内存模型中的happens-before规则,比如对volatile变量的写操作happens-before后续的读操作,而synchronized的解锁happens-before后续的加锁。 另外,用户可能在实际开发中遇到过错误使用volatile的情况,比如误以为volatile变量可以替代原子类。这时候需要指出volatile适用于单一变量的读写原子性,而复合操作需要synchronized或原子类如AtomicInteger。 总结时,需要对比两者的适用场景,帮助用户理解何时使用volatile,何时需要synchronized。可能用户需要明确知道volatile的局限性,以及为什么synchronized更重量级但功能更全面。 最后,确保回答结构清晰,分点说明,用代码示例帮助理解,并总结对比两者的区别适用情况。这样用户能更好地掌握它们的内部机制正确使用方法。</think>好的!这是一个关于多线程并发编程的核心问题。下面分点解释 **`volatile` `synchronized` 的差异**,结合底层机制代码示例说明。 --- ### 一、`volatile` 的特性与局限 #### 1. **解决可见性** - **机制**: 强制对 `volatile` 变量的读写直接通过 **主内存**,禁止线程将其缓存在本地(如CPU缓存)。 - 写操作后,立即将最新值刷新到主内存。 - 读操作前,强制从主内存重新加载最新值。 #### 2. **解决有序性** - **机制**: 通过插入 **内存屏障(Memory Barrier)**,禁止 JVM/CPU 对 `volatile` 变量的读写操作进行 **指令重排序**。 - 确保写操作前的其他操作不会被重排序到写之后(StoreStore + StoreLoad 屏障)。 - 确保读操作后的其他操作不会被重排序到读之前(LoadLoad + LoadStore 屏障)。 #### 3. **不保证原子性** - **根本原因**: `volatile` 仅保证对变量的 **单次读/写操作** 是原子的,但无法保证 **复合操作**(如 `i++`)的原子性。 - **示例**: ```java volatile int count = 0; // 线程A线程B同时执行: count++; // 实际分解为:1.读取count → 2.计算count+1 → 3.写入count ``` 若线程AB同时读取 `count=0`,各自计算后均写入 `1`,最终结果为 `1`(而非预期的 `2`)。 - **结论**:`volatile` **无法阻止多线程对复合操作的交叉执行**。 --- ### 二、`synchronized` 的特性与优势 #### 1. **保证原子性** - **机制**: 通过 **互斥锁** 确保同一时刻只有一个线程能执行同步代码块(或方法),其他线程被阻塞。 ```java synchronized (lock) { count++; // 整个操作在锁保护下不可中断 } ``` - **原子性范围**:同步块内的所有操作作为一个整体执行。 #### 2. **保证可见性** - **机制**: - 线程进入 `synchronized` 块时,强制从主内存重新加载共享变量(清空本地缓存)。 - 线程退出 `synchronized` 块时,强制将修改后的变量刷新到主内存。 #### 3. **保证有序性** - **机制**: - 同步块内的代码不会被重排序到同步块外(通过锁的内存屏障)。 - 临界区内的操作对其他线程表现为 **顺序一致性**(如同单线程执行)。 --- ### 三、对比总结 | | `volatile` | `synchronized` | |------------------|-------------------------------------|-------------------------------------| | **原子性** | ❌ 仅保证单次读/写的原子性 | ✅ 保证同步块内所有操作的原子性 | | **可见性** | ✅ 强制主内存读写 | ✅ 锁释放时同步主内存 | | **有序性** | ✅ 禁止重排序(通过内存屏障) | ✅ 禁止重排序(通过锁的语义) | | **性能** | 轻量级(无锁) | 重量级(锁竞争可能引发上下文切换) | | **适用场景** | 单一变量的状态标志 | 需要保护复合操作或临界区代码 | --- ### 四、关键补充 #### 1. **为什么 `volatile` 不保证原子性?** - **复合操作本质**: 像 `i++`、`i = i + 1` 等操作本质是 **“读-改-写”** 三步,即使单条语句也可能被线程切换打断。 - **`volatile` 的职责**: 仅确保变量的读写直接关联主内存,但 **不提供互斥访问**,无法阻止多线程同时执行复合操作。 #### 2. **如何解决原子性问题?** - 使用 `synchronized` 或显式锁(如 `ReentrantLock`)。 - 使用原子类(如 `AtomicInteger`),其底层通过 **CAS(Compare-And-Swap)** 实现无锁原子操作。 --- ### 五、代码示例对比 #### 1. `volatile` 的局限性 ```java public class VolatileExample { private volatile int count = 0; public void increment() { count++; // 非原子操作,多线程下结果可能错误 } } ``` #### 2. `synchronized` 的原子性保障 ```java public class SynchronizedExample { private int count = 0; public synchronized void increment() { count++; // 原子操作 } } ``` --- ### 六、总结 - **`volatile`**:适用于 **单一变量的状态标记**(如 `boolean flag`),确保其他线程立即可见且不被重排序。 - **`synchronized`**:适用于 **复合操作或需要互斥访问的临界区**,通过锁机制全面保障原子性可见性有序性。 理解这些差异,可以更精准地选择线程安全工具,避免并发 Bug!
评论 11
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值