飞道的博客

【并发编程】第一章:synchronized底层原理

368人阅读  评论(0)

一:原子性,有序性和可见性

1.1 原子性

我们知道数据库事务的ACID中的A(Atomic)原子性的含义是一个事务的多个步骤要么同时成功要么同时失败。而并发编程的原子性是CPU在一个或多个指令在执行的过程中是不能被打断的。


1.1.1 一个简单的案例

public class Test01 {
   
    volatile int i=0;
    public void increment(){
   
        i++;
    }
}

思考:这里的i++是原子性的吗?

我们可以通过该程序的字节码指令来判断。
Test01.class

通过观察发现i++操作是有三个CPU指令组成,在任意一个阶段都有可能因为CPU把时间片分给其它线程而导致中断,所以i++显然不是原子性的。


1.1.2 保证i++是原子性的解决方案

可以通过加锁的方式来保证其原子性。(synchronized,lock,CAS)

代码

public class Test01 {
   
    volatile int i=0;
    public void increment(){
   
        synchronized (this){
   
            i++;
        }
    }
}

注意:synchronized和lock这两种锁都有可能导致线程被挂起,从而导致用户态和内核态的切换降低性能。而CAS自旋锁的性能一般都比synchronized和lock锁的性能高,因为CAS自旋锁不会导线程被挂起。


1.1.3 CAS自旋锁

CAS自旋锁(Compare And Swap),线程基于CAS修改数据的方式:先获取主内存数据,在修改之前,先比较数据是否一致,如果一致修改主内存数据,如果不一致,放弃这次修改。

示例

在真正修改值的时候发生CAS,CPU保证同一时间只有一个CAS正在进行,所以CAS是原子性的。
注意:CAS在Java层面仅仅实现了一次CAS失败返回false,成功返回true的逻辑,如果要实现失败后的重试策略需要自己实现。

CAS的缺点

  • 只能保证对一个变量的修改保证其原子性。
  • CAS存在ABA问题。
  • 在CAS自旋次数过多时,但是却一直无法完成修改操作。CPU会一直调度这个线程导致性能降低。

1.1.4 CAS的ABA问题及解决方案

示例:主存中有一个成员变量i=1;
线程A要将i=1修改为i=2,但是在修改之前被线程B打断了,线程B也要将i=1修改为i=2,且成功了。此时线程C抢占到了CPU资源,将i=2修改为i=1,终于线程A又抢占到了CPU资源,把i修改为2。
存在问题:线程不安全。线程A最终将i=1修改为i=2,但在此期间这块内存已经被线程B和线程C操作过了。
解决方案
为变量的每个状态加一个唯一的版本号,在CAS的比较阶段拿着这个版本号一起比较,只有版本号和变量值完全相同时,才修改成功。


1.1.5 CAS自旋次数多导致性能降低的解决方案

synchronized实现方式
CAS自旋一定次数后,如果还不成,就进行锁升级,升级为重量级锁,会挂起线程

LongAdder实现方式
当CAS失败后,将操作的值,存储起来,随着后序一起添加。


1.2 有序性

指令在CPU调度执行时,CPU会为了提升执行效率,在不影响结果的前提下,对CPU指令进行重新排序。

取消指令重排
如果我们不想进行指令重排,我们可以通过volatile关键字修饰该变量,CPU就不会对当前属性的操作进行指令重排序。

经典案例:单例模式的DCL双重判断

public class Singleton {
   
    private static Singleton instance;
    private Singleton(){
   
    }
    public static Singleton getInstance(){
   
        if(instance==null){
   
            synchronized (Singleton.class){
   
                if(instance==null){
   
                    instance=new Singleton();
                }
            }
        }
        return instance;
    }
}

存在的问题

CPU会对语句 instance=new Singleton() 进行指令重排,正常顺序是申请内存空间,创建对象 --> 对象的初始化 --> 返回对象的引用给instance变量。一旦CPU进行指令重排后,顺序就变成了 创建对象 -->返回对象的引用给instance变量 -->对象初始化。这样就会导致如果线程A在对象还未初始化就被线程B打断,线程B会直接不通过第一重判断而直接返回未初始化的对象,从而导致严重的BUG。

正确的使用方法,加volatile关键字取消指令重排。
正确代码

public class Singleton {
   
    private static volatile Singleton instance;
    private Singleton(){
   

    }
    public static Singleton getInstance(){
   
        if(instance==null){
   
            synchronized (Singleton.class){
   
                if(instance==null){
   
                    instance=new Singleton();
                }
            }
        }
        return instance;
    }
}

 

1.3 可见性

可见性:前面说过CPU在处理时,需要将主内存数据甩到我的寄存机中再执行指令,执行完指令后,需要将寄存器数据扔回到主内存中。但是寄存器数据同步到主内存是遵循MESI协议的,说人话就是并不是每一次CPU处理数据后就直接同步到主存中。
存在的问题:
因为并不是每一次CPU处理数据后就直接同步到主存中,会导致线程之间看到的数据不一致问题。

解决方案

  • volatile修饰的变量,每次操作后就会之间同步到主存中。
  • synchronized:触发同步数据到主存中。
  • final修饰的变量是不可变量,所有线程中看到的都是一样的自然不存在不可见问题。

二:synchronized 使用

synchronized是互斥锁,即每次只能有一个线程在同一时间持有锁资源。它是基于对象锁实现的。

2.1 对象锁

public class Test02 {
   

    public synchronized void add(){
   
        /*
        * 业务代码
        * */
    }
}

💡 在方法的返回值之前加synchronized关键字,锁对象是当前方法的this对象。在同一时间只能有一个线程可以通过该对象调用这个方法。

public class Test02 {
   

    public void add(){
   
        
        
        synchronized (this){
   
            /*
            * 业务代码
            * */
        }
    }
}

💡: 同一时间只能有一个线程通过该对象执行此代码块。

2.2 类锁

public class Test02 {
   

    public synchronized static void add(){
   
        /*
        * 业务代码
        * 
        * */
    }
}
public class Singleton {
   
    private static volatile Singleton instance;
    private Singleton(){
   

    }
    public static Singleton getInstance(){
   
        if(instance==null){
   
            synchronized (Singleton.class){
   
                if(instance==null){
   
                    instance=new Singleton();
                }
            }
        }
        return instance;
    }
}


 

锁的是当前class对象


三:对象头的MarkWord信息(拓展)

3.1 对象头结构图

3.2 MarkWord详细结构


四:Synchronized的锁升级策略

Synchronized在JDK 1.6之前一直都是重量级锁,当前线程获取不到锁资源就挂起线程,导致性能效率很低。在JDK 1.6通过synchronized的锁升级策略进行了优化。

4.1 锁的升级

  1. 无锁,匿名偏向锁:没有线程要获取锁资源,
  2. 偏向锁:只有一个线程获取锁资源,不存在线程竞争。
  3. 轻量级锁:偏向锁出现线程竞争获取时,会升级为重量级锁,升级到重量级锁时会涉及锁撤销。轻量级锁的状态下,线程会基于CAS的方式,尝试获取锁资源,CAS的次数是基于自适应自旋锁实现的,JVM会自动的基于上一次获取锁是否成功,来决定这次获取锁资源要CAS多少次。
  4. 重量级锁 :轻量级锁CAS一定次数后会升级为重量级锁。重量级锁一旦获取不到锁资源就挂起线程。

4.2 一个问题:偏向锁是延迟开启的为什么?

JVM在启动时是默认不开启偏向锁的,偏向锁默认是延迟4s开启的,这是为什么?
1. 偏向锁的撤销是比较消耗资源的。
synchronized在由偏向锁升级为轻量级锁的时候,会涉及到偏向锁撤销的步骤。而偏向锁不是直接就能撤销的,需要等到GC的一个安全点才能撤销,所以说并发偏向锁撤销是比较消耗资源的。
2. JVM启动时需要加载大量class文件
项目在启动的时候默认是不开启偏向锁的,这是因为项目启动时,classLoader会加载大量.class文件,这个过程中为了保证加载的class文件的唯一性,会涉及到synchronized加锁操作。为了避免在项目启动时就涉及到偏向锁撤销从而导致项目启动变慢,所以偏向锁是延迟开启的。

注意:如果在开启偏向锁的情况下,查看锁对象,默认对象是匿名偏向。


五:synchronized-ObjectMonitor

一般涉及到重量级锁才会涉及到。ObjectMonitior是锁对象监视器,这个对象中储存着锁对象的各种参数信息。

  ObjectMonitor() {
   
    _header       = NULL;
    _count        = 0;     // 抢占锁资源的线程个数
    _waiters      = 0,     // 调用wait的线程个数。
    _recursions   = 0;     // 可重入锁标记,
    _object       = NULL; 
    _owner        = NULL;  // 持有锁的线程
    _WaitSet      = NULL;  // wait的线程  (双向链表)
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;  // 假定的继承人(锁释放后,被唤醒的线程,有可能拿到锁资源)
    _cxq          = NULL ;  // 挂起线程存放的位置。(单向链表)
    FreeNext      = NULL ;
    _EntryList    = NULL ;  // _cxq会在一定的机制下,将_cxq里的等待线程扔到当前_EntryList里。  (双向链表)
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
    _previous_owner_tid = 0;
  }

 

转载:https://blog.csdn.net/ai15013602354/article/details/125709673
查看评论
* 以上用户言论只代表其个人观点,不代表本网站的观点或立场