java synchronized浅析

介绍synchronized

    synchronized 是Java编程语言中的一个关键字,用于实现线程间的同步。在多线程环境下,它确保了共享资源在同一时刻只能被一个线程访问或修改,从而避免了因多个线程并发操作同一数据而导致的数据不一致和竞态条件。

        synchronized可以用来修饰实例方法、静态方法、代码块。

        synchronized修饰实例方法时,它获取的是当前对象实例(即 this)的内置锁。当一个线程调用这个方法时,其他线程必须等待该线程执行完此方法后才能再次获得对该类实例的访问权限并执行这个同步方法。

/**
 * synchronized修饰实例代码
 */
public class SynchronizedDemo {    
    public synchronized void method(){
        // 业务逻辑
    }
}

        synchronized修饰静态方法时,它获取的是当前Class的锁(静态成员属于当前类的,不属于实例对象,后面有案例分析对在同一类中对静态方法加锁和实例加锁的是否互斥)。

/**
 * synchronized修饰实例代码
 */
public class SynchronizedDemo {  
    public synchronized static void method(){
        // 业务逻辑
    }
}

        当synchronized修饰代码块时,通过指定一个特定的对象或者Class作为锁。在同步代码块中,当线程进入时会获取到指定对象或者Class的锁,退出时释放锁。这种方式更灵活,因为可以决定锁住哪个对象,而不是默认锁定整个方法的调用者。需要注意的是不要使用String作为锁的对象,因为jvm为了避免字符串重复创建开辟了一块专门的区域存放字符串(字符串常量池),字符串常量池会缓存字符串对象的引用。

  public class SynchronizedDemo {  
    private final Object lock = new Object();

    public void method(){
        synchronized (lock){
            // 业务逻辑
        }
    }
}

分析加锁对象

        通过一些案例,让大家对synchronized加锁时锁的是哪个对象有更深的印象。

    定义一个Test对象,对象中有read、write两个方法,在LockObjectDemo中开启两个线程分别调用read、write方法。

// Test.java
    public void read(){
        System.out.println("test read----");
        // 睡眠一段时间,更方便查看锁的对象
        try {
            Thread.sleep(2000L);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    public void write(){
        System.out.println("test write----");
    }


//LockObjectDemo.java
    public static void main(String[] args) {

        Test test = new Test();

        new Thread(() -> {
            test.read();
        },"a").start();


        new Thread(() -> {
            test.write();
        },"b").start();
    }

无锁的运行结果如下,线程a与线程b各自执行调用的相关方法,互不影响。

        案例一:a、b两个线程分别访问同一个Test对象中的不同方法(read、write方法用synchronized修饰),运行结果如下。

        分析:a、b两个线程分别访问同一个Test对象中的不同方法(read、write方法用synchronized修饰),锁的是当前实例对象(Test实例),a线程先获得锁,b线程需要等待a线程释放锁才能继续往下执行。

        一个对象里面如果有多个synchronized方法,某一时刻内,只要有一个线程去调用其中的一个synchronized方法,其他的线程都只能等待,换句话说,某一时刻内,只能有唯一的一个线程去访问这些synchronized方法。锁的是当前对象this,被锁定后,其他线程都不能进入到当前对象的其他synchronized方法中        

        案例二:同案例一条件不变,在Test对象中新增一个普通方法,在LockObjectDemo新启动一个线程调用普通方法,运行结果如下。 

        分析:线程c中调用新加的普通方法不受锁的影响,线程a和线程b继续竞争锁。

        案例三: 分别声明t1、t2Test对象,a线程调用t1对象的read方法、b线程调用t2对象的write方法(read、write方法用synchronized修饰),运行结果如下。

        分析:a线程获取的是t1对象的锁,b线程获取的是t2对象的锁,两者获得的锁不是同一把,所以调用方法互不影响。

        Test t1 = new Test();
        Test t2 = new Test();

        new Thread(() -> {
            t1.read();
        },"a").start();


        new Thread(() -> {
            t2.write();
        },"b").start();

        案例四:a、b两个线程分别访问同一个Test对象中的不同方法(read、write方法用static synchronized修饰)

        案例五:分别声明t1、t2Test对象,a线程调用t1对象的read方法、b线程调用t2对象的write方法(read、write方法用static synchronized修饰),

    案例四与案例五运行结果相同,运行结果如下。

    分析:为什么案例四与案例五运行结果是一样的,案例一与案例三运行结果不同。我们知道statci成员归类所有,static成员在类加载的时候就会被分配内存,通过类名就能访问(这里通过实例对象访问静态成员的方式只是为了更方便的查看线程获取的是当前实例还是当前类的锁)。案例四中的a、b线程虽然调用的是同一个实例对象的不同static方法,实际这两个线程获取的是Test.Class的锁,而不是实例对象的锁。案例五a、b线程通过不同实例对象调用相关方法,竞争的还是Test.Class这个类锁。

         案例六:a、b两个线程分别访问同一个Test对象中的不同方法(read方法用synchronized修饰、write方法用static synchronized修饰)。运行结果如下。

    分析:a线程获取的是实例对象的锁,b线程获取的是当前类的锁,锁的对象不同,两个线程之间不会发生竞争。

synchronized深入探究  

      字节码分析

        先从字节码层面分析synchronized修饰的代码块、实例方法、静态方法。

        先编写一个synchronized修饰的代码块程序实示例,再通过javap -c class名字 命令反编译。通过反编译后的代码可以看到,synchronized修饰的代码块是通过monitorenter和monitorexit指令来保证锁的获取和释放,有疑问的是为什么出现了两个monitorexit指令,第一个monitorexit指令保证代码块正常执行后释放锁,如果代码块中出现异常导致代码执行中断,第二个monitorexit指令会将锁释放。

//java程序示例    
private Object object = new Object();

public void sync(){
    synchronized (this.object){
        // 业务逻辑
        System.out.println("hello");
    }
}


//javap -c clss名称 反编译后的代码
public void sync();
    Code:
       0: aload_0
       1: getfield      #3                  // Field object:Ljava/lang/Object;
       4: dup
       5: astore_1
       6: monitorenter
       7: getstatic     #4                  // Field java/lang/System.out:Ljava/io/PrintStream;
      10: ldc           #5                  // String hello
      12: invokevirtual #6                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      15: aload_1
      16: monitorexit
      17: goto          25
      20: astore_2
      21: aload_1
      22: monitorexit
      23: aload_2
      24: athrow
      25: return
    Exception table:
       from    to  target type
           7    17    20   any
          20    23    20   any

        反编译一下synchronized修饰的普通实例方法,没有monitorenter和monitorexit指令了,取而代之的是方法flags里的ACC_SYNCHRONIZED标识,通过设置这个标识,jvm会自动识别给这个方法加锁,等方法执行完后无论是否异常都会释放锁。

        synchronized修饰的静态方法与普通实例同步方法相比只是多了一个ACC_STATIC标识,以此来判断是获取类锁还是实例对象的锁。

//普通实例方法
public synchronized void sync1(){
    //业务逻辑
    System.out.println("hello");
}


//javap -v class名称 反编译后的(synchronized)普通实例方法
public synchronized void sync1();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_SYNCHRONIZED
    Code:
      stack=2, locals=1, args_size=1
         0: getstatic     #4                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #5                  // String hello
         5: invokevirtual #6                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: return
      LineNumberTable:
        line 23: 0
        line 24: 8
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       9     0  this   Lcom/juc/chapter12/SynDemo2;


//静态方法
public synchronized static void sync1(){
    //业务逻辑
    System.out.println("hello");
}


//javap -v class名称 反编译后的(synchronized)静态方法
public static synchronized void sync1();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_STATIC, ACC_SYNCHRONIZED
    Code:
      stack=2, locals=0, args_size=0
         0: getstatic     #4                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #5                  // String hello
         5: invokevirtual #6                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: return
      LineNumberTable:
        line 16: 0
        line 17: 8

      管程       

        引用《深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)》中的一段话:

Java虚拟机可以支持方法级的同步和方法内部一段指令序列的同步,这两种同步结构都是使用管程(Monitor,更常见的是直接将它称为“锁”)来实现的。 

         管程(Monitor,也称为监视器)是一种程序结构,结构内的多个子程序(对象或模块)形成的多个工作线程互斥访问共享资源。这些共享资源一般是硬件设备或一群变量。对共享变量能够进行的所有操作集中在一个模块中。(把信号量及其操作原语“封装”在一个对象内部)管程实现了在一个时间点,最多只有一个线程在执行管程的某个子程序。管程提供了一种机制,管程可以看做一个软件模块,它是将共享的变量和又于这些共享变量的操作封装起来,形成一个具有一定接口的功能模块,进程可以调用管程来实现进程级别的并发控制。

        在jvm层次会为每个java对象创建一个相关联的管程(Monitor),多个线程竞争对象锁,实际上竞争的是对象关联管程的操作权限。执行monitorenter指令,尝试去获取管程(Monitor)的操作权限,如果获取失败,表示已有其他的线程持有了此对象的锁,需要等待monitorexit指令释放锁,再次尝试去获取管程(Monitor)的操作权限。

      ObjectMonitor

        在HotSpot虚拟机中,Monitor是通过ObjectMonitor实现的,ObjectMonitor定义的基本信息如下:

  // jvm源码路径- src/share/vm/runtime/objectMonitor.hpp
  ObjectMonitor() {
    _header       = NULL;
    _count        = 0; //用来记录该线程获取锁的次数
    _waiters      = 0,
    _recursions   = 0; //线程的重入次数
    _object       = NULL; //存储该monitor的对象
    _owner        = NULL; //指向持有该monitor的线程
    _WaitSet      = NULL; // 将处于等待状态的线程加入到该队列中
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ; //多线程竞争锁时的队列
    FreeNext      = NULL ;
    _EntryList    = NULL ; //存放处于等待锁block状态的线程队列
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
    _previous_owner_tid = 0;
  }

  // jvm源码路径- src/share/vm/runtime/objectMonitor.cpp
  // enter、exit、wait、notify方法这里不做过多解析,感兴趣的朋友可以参考
  // https://blog.csdn.net/lhm964517471/article/details/131710893
   

        锁升级

          在Java早期版本中,synchronized属于重量级锁,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的MutexLock(系统瓦斥量)来实现的,挂起线程和恢复线程都需要转入内核态去完成,阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态切换需要耗费处理器时间,如果同步代码块中内容过于简单,这种切换的时间可能比用户代码执行的时间还长”,时间成本相对较高,这也是为什么早期的synchronized效率低的原因Java 6之后,为了减少获得锁和释放锁所带来的性能消耗,引入了轻量级锁和偏向锁。

        在Java 6中,锁从低到高一共有四种状态,这几个状态会随着竞争情况逐渐升级。锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率。

        对象内存布局

        锁在竞争的情况下具体怎么升级的,首先从了解对象的内存布局开始。在HotSpot虚拟机里,对象在堆内存中的存储布局分为三个部分:对象头、实例数据、对象填充。

        对象头包含Mark Word两部分。Mark Word里默认存储对象的HashCode、分代年龄和锁标记位。在64位虚拟中,Mark Word占用8个字节(64bit),类型指针也占用8个字节。

        Mark Word存储内容如下:

存储内容标志位状态
对象哈希码、对象分代年龄01未锁定
指向锁记录的指针00轻量级锁定
指向重量级锁的指针10膨胀(重量级锁定)
空,不需要记录信息11GC标记
偏向线程ID、偏向时间戳、对象分代年龄01可偏向

        类型指针即对象指向它的类型元数据的指针,Java虚拟机通过这个指针来确定该对象是哪个类的实例。

        实例数据存放类的属性数据信息,包括父类的属性信息。

        对齐填充 Java虚拟机要求对象起始地址必须是8字节的整数倍。不足8字节整数倍的对象,会被填充到8字节整数倍。可以通过导入jol.jar包查看对象的对齐填充,示例如下:

    public static void main(String[] args) {
        Object o = new Object();
        // 打印一个对象的布局
        System.out.println(ClassLayout.parseInstance(o).toPrintable());
    }

        无锁

        对象处于无锁状态时,64位jvm虚拟机中Mark Word存储的信息如下:        

        偏向锁

         大多数情况下,锁不存在多线程竞争,并且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。

        64位jvm虚拟机中处于偏向锁状态下的Mark Word存储的信息如下:   

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

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

         注意:2020年9月发布的JDK15中,有一项新声明(JEP 374: Disable and Deprecate Biased Locking) :在默认情况下禁用偏向锁定,并弃用所有相关命令行选项。

        轻量级锁

        当偏向锁功能关闭或者多线程竞争偏向锁,偏向锁会升级为轻量级锁。

        64位jvm虚拟机中处于轻量级锁状态下的Mark Word存储的信息如下:  

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

       重量级锁

        java中synchronized中的重量级锁,是基于进入和退出Monitor对象实现的。在编译时会将同步块的开始位置插入monitorenter指令,在结束位置插入monitorexit指令,当线程执行到monitorenter指令时,会尝试获取对象所对应的Monitor所有权,如果获取到了,即获取到了锁,会在Monitor的_owner中存放当前线程的id,这样它将处于锁定状态,除非退出同步块,否则其他线程无法获取到这个Monitor。

         64位jvm虚拟机中处于重量级锁状态下的Mark Word存储的信息如下:

        synchronized、对象、Moniter的联系如下:

         可重入锁

    可重入锁又名递归锁,是指同一个线程在外层方法获取锁的时候,在进入该线程的内部方法会自动获取锁(前提,锁对象是同一个对象),不会因为之前已经获取过锁还没释放而阻塞。

    public synchronized void a(){
        b();
        System.out.println("current method a");
    }

    public synchronized void b(){
        c();
        System.out.println("current method b");
    }


    public synchronized void c(){
        System.out.println("current method c");
    }

        synchronized和reentrantLock都是可重入锁,可重入锁在一定程度上可避免死锁。

        synchronized可重入锁的实现机制,每个锁对象拥有一个锁计数器和一个指向持有该锁的线程的指针,当执行monitorenter时,如果目标锁对象的计数器为0,那么说明它没有被其他线程持有,java虚拟机会将该锁对象的持有线程设置为当前线程,并且将其计数器+1。在目标锁对象的计数器不为0的情况下,如果锁对象的持有对象时当前线程,那么java虚拟机可以将其计数器+1,否则需要等待,直至持有线程释放该锁
当指向monitorexit时,java虚拟机则需将锁对象的计数器-1,计数器为0代表锁被释放。

        这里的锁对象指的是monitor,每一个对象都有一个monitor,在Java虚拟机(HotSpot)中,monitor是由ObjectMonitor实现的。从ObjectMonitor源码中我们可以看到锁计数器、指向持有该锁的线程的指针存放的位置。

        锁消除

            消除是指虚拟机即时编译器在运行时,对一些代码要求同步,但是对被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判定依据来源于逃逸分析的数据支持,如果判断到一段代码中,在堆上的所有数据都不会逃逸出去被其他线程访问到,那就可以把它们当作栈上数据对待,认为它们是线程私有的,同步加锁自然就无须再进行。

    public void sync(){
        // object的引用不会逃逸到方法外,其他线程无法访问到object
        // 这段代码再解释执行时会加锁,在经过服务端编译器的即时编译之后,这个同步措施会被忽略
        // 实际开发中一般不会这么写
        Object lock = new Object();

        synchronized (lock){
            System.out.println("hello word");
        }
    }

        锁粗化

        如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体之中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。虚拟机探测到有这样一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部。

    static Object lock = new Object();
        
    public void sync(){
        synchronized (lock){
            System.out.println("a");
        }
        synchronized (lock){
            System.out.println("b");
        }
        synchronized (lock){
            System.out.println("c");
        }
    }

    // JIT编译器编译后 -- 锁粗化
    public void sync(){
        synchronized (lock){
            System.out.println("a");
            System.out.println("b");
            System.out.println("c");
        }
    }

总结          

1.互斥同步,多个线程访问共享数据时,在同一时刻只能有一个线程访问,保证数据正确性。

2.synchronized作用对象是非静态的,获得锁是对象锁,如果是静态的,获得锁则是类锁。

3.synchronized锁是可重入的,同一个线程在外部获取锁以后,在内部也能自动获取锁(前提是同一个对象)。

4.在Java早期版本中,synchronized属于重量级锁,效率低下,Java 6之后为了减少获得锁和释放锁所带来的性能消耗,引入了轻量级锁和偏向锁。

5.JDK15后默认禁用偏向锁定。

参考

【1】深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)

【2】Java并发编程的艺术

【3】Java——聊聊JUC中的锁(synchronized & Lock & ReentrantLock)_51CTO博客_java锁synchronized原理

【4】Java多线程:objectMonitor源码解析(4)-CSDN博客

【5】b站 - 尚硅谷JUC并发编程

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值