【谈一谈】并发Synchronized

Synchronized

又到周末了,最近的话(有点子小日子不好过,哈哈哈!~)但是,我还是报之以歌哈哈哈

本次写关于并发_Synchronized的优化以及底层实现原理

说说心里话~其实是非常的累,原因应该怎么说呢?我发现自己在如今的这家公司,我处于一种活多钱少以及关键现在给的或自己不想干,因为没有一点儿子的技术性

你可能会问:那就跳呗!~特么现在技术还不够啊,哈哈哈,真的是无语坏了,还说鸡毛,哈哈哈,就是吐槽!

好吧~进入正文

本文总纲

1.类锁和对象锁

在上篇的总纲中我们已经介绍,这里我们复习下概念(来自官方解释~哈哈哈!)

在Java多线程编程中,锁主要用于控制对共享资源的访问,以保证数据的一致性和完整性。

类锁和对象锁是两种不同粒度的锁。

  1. 对象锁
    • 在Java中,每个对象都有一个内置锁(也称为监视器锁),
    • 当一个线程试图访问 某个对象的synchronized代码块或方法时,该线程必须先获得该对象的锁。
    • 同一时刻只能 有一个线程持有对象锁,其他线程必须等待。
    • 换句话说(大白话),对象锁是针对具体对象实例的,用于保护对象实例的并发安全

例如:

java 复制代码
public class MyClass {
    public synchronized void method() {
        // 同一时间只有一个线程可以执行此方法
    }
}

或者

java 复制代码
public class MyClass {
    public void method() {
        synchronized (this) {
            // 同一时间只有一个线程可以执行此代码块
        }
    }
}
  1. 类锁:类锁也是通过 synchronized 关键字来实现的,但不是作用于对象实例上,而是作用在整个类的Class对象上。在Java中,每个类在JVM中只有一个Class对象,所以类锁也是全局的,是一种粗粒度的锁。

例如:

java 复制代码
public class MyClass {
    private static synchronized void classMethod() {
        // 同一时间只有一个线程可以执行此静态方法,不论多少个对象实例
    }
}

或者

java 复制代码
public class MyClass {
    private static final Object classLock = new Object();

    public void instanceMethod() {
        synchronized (MyClass.classLock) {
            // 同一时间只有一个线程可以通过任何对象实例进入此同步代码块
        }
    }
}

总的来说,对象锁用于控制单个对象实例的并发访问,而类锁则用于控制所有对象实例对该类的静态成员或代码块的并发访问。

2.SYNCHRONIZED的优化

这里说得优化:主要是JDK的官方所说的三个优化:(在JDK1.6,JDK 团队对Synchronized做的大量优化,因为在JDK1.5时候,被ReentrantLock完虐,被迫优化了,哈哈哈)

  1. 锁消除
  2. 锁膨胀
  3. 锁升级

1.锁消除(Lock Elimination)

Synchronized修饰的代码中,如果不存在操作临界资源的情况,会触发锁消除,

啥意思呢?就是即便你写了Synchronized,也不会触发(~就是这么的豪横!,哈哈)

具体点解释

Java虚拟机(JVM )的一项优化技术 ,主要应用于并发编程场景。

  • 在某些情况下,JVM能够检测到某个同步块内的数据在该同步块的执行过程中没有发生竞争,
  • 也就是说,不存在多个线程同时访问这段代码和共享数据的情况。
  • 此时,JVM就可以安全地消除对该同步块的锁定,从而提高程序运行效率。

举个例子:

假设array数组的元素都是不同的字符串对象,并且这段代码中的同步块只对局部变量localString进行操作,没有改变任何共享状态或与其他线程交互,

那么JVM 通过分析可以判断这个同步块实际上是不必要的,因此可以进行锁消除

java 复制代码
public void doSomething(int index) {
    String localString = array[index];
    synchronized (localString) {
        // 对localString进行操作,但并未涉及任何共享状态或与其他线程的交互
    }
}

2.锁膨胀(Lock Coarsening)

如若在一个循环中,频繁的获取和释放资源,这样会带来很大的消耗!

为了避免和减少这种状况,锁膨胀就出现了,就是将锁的范围扩大,避免频繁的竞争和获取锁资源带来的不必要消耗

官方术语

锁膨胀:

  • 是Java虚拟机(JVM)进行并发优化的一种手段,
  • 与锁消除相反,它不是去除不必要的锁,而是合并多个细粒度的锁为一个粗粒度的锁,
  • 以减少锁竞争和上下文切换开销,提高并发性能。

多线程编程中,

  • 如果一段代码涉及到对多个独立对象的同步操作,可能会导致 频繁的锁获取和释放 操作,增加系统开销
  • 锁膨胀就是将这些原本独立的对象锁合并成一个更粗粒度的锁,比如使用同一个锁来保护一组相关对象的操作,使得在多线程环境下,可以减少锁的竞争次数,提升系统的并发性能。

例如

假设有一个场景,程序需要对两个独立对象A和B进行同步访问:

java 复制代码
synchronized (objectA) {
    // 对objectA进行操作
}
synchronized (objectB) {
    // 对objectB进行操作
}

在高并发场景下,不同的线程可能交替对A、B对象进行加锁,造成锁竞争激烈。

通过锁膨胀优化,可以将上述代码改为(实际上 还是之前的上面的代码,只是JDK在这被优化如下面这串代码):

我们清晰的可看到:锁的范围扩大了(所有对A和B对象的同步操作都由一个共享的锁来控制,从而减少了锁竞争的可能性。)

java 复制代码
private static final Object sharedLock = new Object();

synchronized (sharedLock) {
    synchronized (objectA) {
        // 对objectA进行操作
    }
    synchronized (objectB) {
        // 对objectB进行操作
    }
}

3.锁升级(本文的重点!)(后面会说)

我们先说下背景:(这是为了更快的掌握嘛!~别急,慢慢来,比较快!)

在Java中,synchronized关键字用于实现线程间的同步控制,它提供了内置的锁机制来确保数据的并发访问安全。随着JDK版本的发展,尤其是从Java 6开始,为了优化synchronized在不同场景下的性能表现,引入了锁升级的概念,即根据竞争情况动态地将锁从一种状态转换为另一种状态。

  • 这种锁升级机制可以更高效 地利用CPU资源,在无竞争 或竞争不激烈的情况下提供更好的性能,
  • 而在高并发 竞争情况下则退化为传统的重量级锁保证线程安全。
  • 需要注意 的是,具体的锁升级策略和细节可能因不同的Java虚拟机实现而略有差异。(但是都差不多)

synchronized锁的升级过程主要包括以下四个阶段

  1. 无锁状态(Unlocked)
  • 当对象没有被任何线程锁定时,对象头中的锁标志位是未锁定的状态。
  1. 偏向锁(Biased Locking)
  • 在只有一个线程进入同步代码块的情况下,JVM会把锁设置为偏向模式,将锁绑定到当前获得锁的线程上,这样后续该线程再次进入同步代码块时无需再进行同步操作,从而减少获取锁和释放锁带来的开销。
  1. 轻量级锁(Lightweight Locking)
  • 当有第二个线程尝试进入已经被第一个线程持有偏向锁的方法或代码块时,偏向锁会被撤销,并升级为轻量级锁。轻量级锁采用CAS(Compare and Swap)操作尝试获取锁,如果获取失败,则通过自旋等待一段时间尝试重新获取,如果经过一定次数的自旋仍然无法成功获取锁,说明存在较为激烈的锁竞争,此时锁会进一步升级。
  1. 重量级锁(Heavyweight Locking)
  • 当自旋获取轻量级锁失败后,锁会升级为重量级锁。重量级锁会导致线程阻塞并进入操作系统层面的线程调度,直至锁被释放。此时,其他竞争线程将进入阻塞队列等待,持有锁的线程执行完毕后唤醒等待队列中的下一个线程继续执行。

3.Synchronized的实现原理

synchronized关键字的实现原理 涉及到几个方面:

  1. Java对象结构、
  2. 虚拟机内部的锁状态管理
  3. 以及操作系统级别的线程同步机制等多个层面。

Java中的synchronized关键字是用于实现线程同步的一种机制,其底层实现原理 主要包括以下几个核心要点

  1. 监视器锁(Monitor)

    • synchronized的实现基于Java对象头中的监视器锁。每个Java对象都有一个关联的监视器锁,也称为Monitor
    • 当线程试图访问被synchronized修饰的方法或代码块时,会先尝试获取该对象的监视器锁。
  2. 对象头(Object Header)

    • 在HotSpot虚拟机中,对象在内存中的布局包括对象头、实例数据和对齐填充等部分。对象头中的Mark Word存储了对象自身的运行时数据,其中包括锁状态标志位,这些标志位记录了当前锁的状态(如无锁、偏向锁、轻量级锁、重量级锁等)。
  3. 锁升级过程

    • 从JDK 6开始引入了锁优化策略,即自旋锁、偏向锁、轻量级锁到重量级锁的升级过程。
      • 偏向锁:如果只有一个线程访问同步块,则将锁偏向给这个线程,后续无需再次获取锁,减少了CAS操作。
      • 轻量级锁:当有第二个线程尝试获取偏向锁时,偏向锁会撤销并升级为轻量级锁,通过CAS操作尝试快速获取锁,失败则进行自旋等待。
      • 重量级锁:轻量级锁自旋一定次数后仍无法获取,或者存在多线程竞争时,会升级为重量级锁,此时会阻塞其他线程,直到持有锁的线程释放锁。
  4. 字节码指令

    • 编译器在编译过程中,会对synchronized关键字修饰的方法或代码块生成monitorenter和monitorexit两个字节码指令,分别对应于锁的获取与释放。
  5. 操作系统互斥原语

    • 重量级锁的实现依赖于操作系统的Mutex互斥原语,例如Linux下的futex系统调用,来确保同一时刻只有一个线程能够获得锁。
  6. 内存可见性

    • synchronized除了保证同步外,还提供了内存可见性。当一个线程退出synchronized代码块时,会确保对共享变量的所有更新对其他线程立即可见。
Java 复制代码

4,Synchronized的锁升级

最重要的还是MarkWord(我们看一个Java的对象堆)先卖个关子

前面的概念的我们已经清楚了

解释下MarkWord

在Java虚拟机中,MarkWord是对象头(Object Header)的一部分,它是一个与对象自身紧密相关的数据结构。

对于64位JVMMarkWord通常占用8个字节,并且存储了关于Java对象的运行时元数据和状态信息,这些信息包括但不限于:

  1. 锁状态标志:用于表示当前对象的锁状态,例如无锁、偏向锁、轻量级锁或重量级锁等。

  2. 哈希码(HashCode :在没有进行同步锁定时,MarkWord可能存储对象的哈希码以优化散列操作。

  3. GC分代年龄:记录对象在垃圾回收过程中的年龄,帮助垃圾收集器确定对象是否应该被晋升到老年代或者被回收。

  4. 偏向线程ID:在偏向锁的情况下,会记录最后一次获得该锁的线程ID,使得该线程可以无需再次获取锁就能访问对象。

  5. 线程持有的锁信息:当对象处于同步块中时,这里会存储相关线程的同步信息。

通过设计灵活的Mark Word结构,JVM可以在不增加额外内存开销的情况下实现高效的对象同步以及垃圾回收机制。

MarkWord的内容会在不同状态下动态改变,这种设计有助于提高性能并适应多线程环境下的各种并发控制需求。

为了在Java中可看到对象头的Markword信息,我们导入如下依赖:

整个锁的升级状态:

重量级锁的底层ObjectMonitor(可以不看,了解就行)

ObjectMonitor是一种用于实现线程同步和调度的内部数据结构,它包含了多个字段来管理锁的状态以及等待队列。

  • 当一个线程试图进入synchronized修饰的方法或代码块时,JVM会在对象头中设置相应的锁标志,并尝试获取对应的ObjectMonitor
  • 如果获取失败,则线程将会被阻塞,并进入上述的等待逻辑。而当线程退出synchronized区域时,会释放所持有的ObjectMonitor,从而可能唤醒等待队列中的其他线程继续执行

我们需要进入源码去看了,百度搜索openJdk

如果上面的打不开,就看这个hg.openjdk.org

先查看这个属性:

Monitor.hpp (不想看直接看下面的)

主要包括以下内容:

Java 复制代码
 ObjectMonitor() {
    _header       = NULL;   //存储着我们Markworld
    _count        = 0;		//竞争锁的线程个数
    _waiters      = 0,		//wait的线程个数
    _recursions   = 0;		//标识当前Synchronized的锁重入的次数
    _object       = NULL;
    _owner        = NULL;  //持有锁的线程
    _WaitSet      = NULL;	//保存wait线程信息,双向链表
    _WaitSetLock  = 0 ;		
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;	//获取锁资源失败后,线程要放到当前的单项链表中 
    FreeNext      = NULL ;
    _EntryList    = NULL ;   //_cxq以及被唤醒的waitSet中的线程,在一定的机制下,会放到EntryList中
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
    _previous_owner_tid = 0;
  }

查看的C++的举例

Java 复制代码
int ObjectMonitor::TryLock (Thread * Self) {
   for (;;) {
      void * own = _owner ;
      if (own != NULL) return 0 ;
      if (Atomic::cmpxchg_ptr (Self, &_owner, NULL) == NULL) {
         // Either guarantee _recursions == 0 or set _recursions = 0.
         assert (_recursions == 0, "invariant") ;
         assert (_owner == Self, "invariant") ;
         // CONSIDER: set or assert that OwnerIsThread == 1
         return 1 ;
      }
      // The lock had been free momentarily, but we lost the race to the lock.
      // Interference -- the CAS failed.
      // We can either return -1 or retry.
      // Retry doesn't make as much sense because the lock was just acquired.
      if (true) return -1 ;
   }
}

举个Java的例子

在这个例子中,我们有一个共享资源sharedResource,并创建了两个线程t1t2

每个线程都试图进入synchronized代码块来操作这个共享资源。
为了更好地理解重量级锁(通过ObjectMonitor实现)的工作原理,我们可以通过一个简单的Java代码示例来进行说明:

  • t1线程首先获取到sharedResource的锁时,它会在ObjectMonitor中设置_owner为t1线程,并将_count置为1。
  • 此时t2线程尝试获取相同的锁,由于t1线程持有该锁,所以t2线程会被阻塞,并放入_EntryList等待队列中进行自旋尝试获取锁。
  • t1线程在执行完同步代码块后会释放锁,即ObjectMonitor中的_owner字段变为null,_count减为0,同时唤醒_EntryList中的等待线程(这里是t2线程)。
  • t2线程被唤醒后再次尝试获取锁,这次成功获取到sharedResource的锁,然后执行同步代码块。
java 复制代码
public class HeavyLockExample {
    private static Object sharedResource = new Object();

    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            synchronized (sharedResource) {
                System.out.println("Thread 1 acquired the lock");
                try {
                    Thread.sleep(2000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("Thread 1 releasing the lock");
            }
        });

        Thread t2 = new Thread(() -> {
            synchronized (sharedResource) {
                System.out.println("Thread 2 acquired the lock");
            }
        });

        t1.start();
        t2.start();
    }
}

整个过程中,ObjectMonitor扮演了关键角色,负责管理和调度多个线程对同一锁的竞争与协作,确保了并发环境下的数据一致性。而这种基于操作系统互斥原语实现的锁被称为"重量级锁",因为它涉及到线程上下文切换等昂贵的操作,在高并发场景下可能成为性能瓶颈。

相关推荐
鬼火儿7 小时前
SpringBoot】Spring Boot 项目的打包配置
java·后端
cr7xin7 小时前
缓存三大问题及解决方案
redis·后端·缓存
间彧8 小时前
Kubernetes的Pod与Docker Compose中的服务在概念上有何异同?
后端
间彧8 小时前
从开发到生产,如何将Docker Compose项目平滑迁移到Kubernetes?
后端
间彧8 小时前
如何结合CI/CD流水线自动选择正确的Docker Compose配置?
后端
间彧8 小时前
在多环境(开发、测试、生产)下,如何管理不同的Docker Compose配置?
后端
间彧8 小时前
如何为Docker Compose中的服务配置健康检查,确保服务真正可用?
后端
间彧8 小时前
Docker Compose和Kubernetes在编排服务时有哪些核心区别?
后端
间彧8 小时前
如何在实际项目中集成Arthas Tunnel Server实现Kubernetes集群的远程诊断?
后端
brzhang9 小时前
读懂 MiniMax Agent 的设计逻辑,然后我复刻了一个MiniMax Agent
前端·后端·架构