Synchronized原理解析

一谈到多线程安全问题,我们总会想到加锁可以解决线程安全的问题,JAVA提供的锁有两个,一个是synchronized关键字,另外一个就是lock类。在JDK1.6之前,synchronized是一个重量级锁,使用不方便,性能低下。在JDK1.6之后,synchronized进行了很大的优化,加入了偏置锁、轻量级锁、自旋锁等,大大提高了synchronized的性能。现在一起看看Synchronized底层实现原理吧。
在这里插入图片描述

1、synchronized的基本概念及使用方法

  1. synchronized的作用:保证了原子性、可见性、有序性
  2. Synchronized可以把任何一个非null对象作为"锁",synchronized的用法有以下三种:
  • 修饰实例方法:锁住的是对象实例this,属于对象锁
  • 修饰静态方法:锁住的是对象class实例,属于类锁
  • 修饰代码块:锁住的是括号里面的对象实例,属于对象锁

    注意,synchronized内置锁是一种对象锁(锁的是对象而非引用变量),作用粒度是对象,可以用来实现对临界资源的同步互斥访问,是可重入的。其可重入最大的作用是避免死锁,如:子类同步方法调用了父类同步方法,如没有可重入的特性,则会发生死锁;

2、synchronized的底层原理解析

1. synchronized的同步原理是在软件层面依赖于JVM,而j.u.c下的lock是依赖于硬件层面。

1> 如果synchronized修饰的是对象,那么它是依赖于monitor对象来实现锁的机制的。代码如下所示:

public class SynchronizeTest {
  public void method(){
        synchronized (this) {
            for (int i = 0; i < 5; i++) {
                System.out.println(Thread.currentThread().getName() + "+++++++" + i);
            }
        }
    }
}

先编译上述文件:javac -encoding utf-8 类名.java

反编译类文件: javap -c 类名.class

反编译后可以看到如下截图:
在这里插入图片描述
编译结果解析:

  • monitorenter:每个对象都是一个监视器锁(monitor)。当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权,过程如下:
  1. 如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者;
  2. 如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1;
  3. 如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权;
  • monitorexit:执行monitorexit的线程必须是objecter所对应的monitor的所有者。指令执行时,monitor的进入数减1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。其他被这个monitor阻塞的线程可以尝试去获取这个 monitor 的所有权。
monitorexit指令出现了两次,第1次为同步正常退出释放锁;第2次为发生异步退出释放锁;

通过上面两段描述,我们应该能很清楚的看出Synchronized的实现原理,
Synchronized的语义底层是通过一个monitor的对象来完成,
其实wait/notify等方法也依赖于monitor对象,
这就是为什么只有在同步的块或者方法中才能调用wait/notify等方法,
否则会抛出java.lang.IllegalMonitorStateException的异常的原因。

2> 如果synchronized修饰的是方法,那么则是通过ACC_SYNCHRONIZED 标示符来进行加锁的。代码如下所示:

public class SynchronizeTest {
   public synchronized void method() {
        for (int i = 0; i < 5; i++) {
            System.out.println(Thread.currentThread().getName() + "+++++++" + i);
        }
    }
}

反编译如下:
在这里插入图片描述
当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 标志符是否被设置,如果设置了,线程将会先获取monitor,获取成功之后才能执行方法体,方法执行完之后再释放monitor。在方法执行期间,其他任何线程都无法再获得同一个monitor对象。

两种同步方式本质上没有区别,只是方法的同步是一种隐式的方式来实现,无需通过字节码来完成。两个指令的执行是JVM通过调用操作系统的互斥原语mutex来实现,被阻塞的线程会被挂起、等待重新调度,会导致“用户态和内核态”两个态之间来回切换,对性能有较大影响。

2.synchronized都是锁对象的,无论是实例对象还类对象。在JVM中,每个对象都是由三部分组成的:对象头、实例数据、数据填充。synchronized的锁的信息都是存储在对象头里。对象组成结构如下:

在这里插入图片描述

  • 实例数据:存放类的属性数据信息,包括父类的属性信息;
  • 对齐填充:由于虚拟机要求 对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐;
  • 对象头:Java对象头一般占有2个机器码(在32位虚拟机中,1个机器码等于4字节,也就是32bit,在64位虚拟机中,1个机器码是8个字节,也就是64bit),但是 如果对象是数组类型,则需要3个机器码,因为JVM虚拟机可以通过Java对象的元数据信息确定Java对象的大小,但是无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度。

Synchronized用的锁就是存在Java对象头里的,那么什么是Java对象头呢?Hotspot虚拟机的对象头主要包括两部分数据:Mark Word(标记字段)、Class Pointer(类型指针)。其中 Class Pointer是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例,Mark Word用于存储对象自身的运行时数据,它是实现轻量级锁和偏向锁的关键。 Java对象头具体结构描述如下:
在这里插入图片描述

其中Mark Word在默认情况下存储着对象的哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等,以下是32位JVM的Mark Word默认存储结构:
在这里插入图片描述

对象头信息是与对象自身定义的数据无关的额外存储成本,但是考虑到虚拟机的空间效率,Mark Word被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据,它会根据对象的状态复用自己的存储空间,也就是说,Mark Word会随着程序的运行发生变化,可能变化为存储以下4种数据:
在这里插入图片描述

synchronized属于对象锁,而任何一个对象都有一个Monitor与之关联,当且一个Monitor被持有后,它将处于锁定状态。在Java虚拟机(HotSpot)中,Monitor是由ObjectMonitor实现的,其主要数据结构如下(位于HotSpot虚拟机源码ObjectMonitor.hpp文件,C++实现的):

ObjectMonitor() {
    _header       = NULL;
    _count        = 0; //记录个数
    _waiters      = 0,
    _recursions   = 0;
    _object       = NULL;
    _owner        = NULL;
    _WaitSet      = NULL; //处于wait状态的线程,会被加入到_WaitSet
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;
    FreeNext      = NULL ;
    _EntryList    = NULL ; //处于等待锁block状态的线程,会被加入到该列表
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;

结构中有几个重要的字段,_count、_owner、_EntryList、_WaitSet。

  • count用来记录线程进入加锁代码的次数。
  • owner记录当前持有锁的线程,即持有ObjectMonitor对象的线程。
  • EntryList是想要持有锁的线程的集合。
  • WaitSet 是加锁对象调用wait()方法后,等待被唤醒的线程的集合

当多个线程访问同步代码块时:

1> 首先线程会进入EntryList集合,然后当线程拿到Monitor对象时,进入owner区域,并把Monitor的owner设置为当前线程,_owner指向持有ObjectMonitor对象的线程,并把计数器count加1.

2> 若线程调用wait方法,将释放当前持有的monitor对象,同时owner变量恢复为null,count自减1,同时该线程进入WaitSet集合中等待被唤醒;

3> 当前线程执行完毕,也将释放monitor(锁)并复位count的值,以便其他线程进入获取monitor(锁);
过程如下图所示:
在这里插入图片描述


Synchronized与等待唤醒:

  • 等待唤醒是指调用对象的wait、notify、notifyAll方法。调用这三个方法时,对象必须被synchronized修饰,因为这三个方法在执行时,必须获得当前对象的监视器monitor对象。
  • 另外,与sleep方法不同的是wait方法调用完成后,线程将被暂停,但wait方法将会释放当前持有的监视器锁(monitor),直到有线程调用notify/notifyAll方法后方能继续执行。而sleep方法只让线程休眠并不释放锁。notify/notifyAll方法调用后,并不会马上释放监视器锁,而是在相应的synchronized代码块或synchronized方法执行结束后才自动释放锁。

Synchronized的可重入与中断:

  • 可重入:当多个线程请求同一个临界资源,执行到同一个临界区时会产生互斥,未获得资源的线程会阻塞。而当一个已获得临界资源的线程再次请求此资源时并不会发生阻塞,仍能获取到资源、进入临界区,这就是重入。Synchronized是可重入的。
  • 中断:与中断相关的有三个方法:
/**
 * Interrupt设置一个线程为中断状态
 * Interrupt操作的线程处于sleep,wait,join 阻塞等状态的时候,清除“中断”状态,抛出一个InterruptedException
 * Interrupt操作的线程在可中断通道上因调用某个阻塞的 I/O 操作(serverSocketChannel. accept()、socketChannel.connect、socketChannel.open、 
 * socketChannel.read、socketChannel.write、fileChannel.read、fileChannel.write),会抛出一个ClosedByInterruptException
 **/
public void interrupt();
/**
 * 判断线程是否处于“中断”状态,然后将“中断”状态清除
 **/
public static boolean interrupted();
/**
 * 判断线程是否处于“中断”状态
 **/
public boolean isInterrupted();

在实际使用中,当线程正处于调用sleep、wait、join方法后,调用interrupt会清除线程中断状态,并抛出异常。而当线程已进入临界区、正在执行,则需要isInterrupted()或interrupted()与interrupt()配合使用中断执行中的线程。

Sychronized修饰的方法、代码块被多个线程请求时,调用中断,正在执行的线程响应中断,正在阻塞的线程、执行中的线程都会标记中断状态,但阻塞的线程不会立刻处理中断,而是在进入临界区后再响应。

3、synchronized锁的优化内容:偏向锁、轻量级锁、重量级锁、锁消除、锁粗化。

1>锁的升级只能从低到高,不能从高到低。
锁的升级过程如下:
在这里插入图片描述

2>锁标志位变化:

在这里插入图片描述

  • 无锁状态:锁的对象头是无锁状态,有1bit专门记录是否为偏向锁,0代表无锁,1代表偏向锁。有2bit位记录锁标志位,
  • 偏向锁:这时线程开始占有锁对象,偏向锁的标志位变为1,23bit位的hashcode存放线程A的线程ID,2bit位存放epoch(共25bit位),如果在多线程并发的环境下(即线程A尚未执行完同步代码块,线程B发起了申请锁的申请),如果线程B成功拿到锁,那么此时还是偏向锁状态。
  • 轻量级锁:如果此时线程获取锁失败,则转化为轻量级锁。首先会在线程A和线程B都开辟一块LockRecord空间,然后把锁对象复制一份到自己的LockRecord空间下,并且开辟一块owner空间留作执行锁使用,并且锁对象的前30bit位合并,等待线程A和线程B来修改指向自己的线程,假如线程A修改成功,则锁对象头的前30bit位会存线程A的LockRecord的内存地址,并且线程A的owner也会存一份锁对象的内存地址,形成一个双向指向的形式。而线程B修改失败,则进入一个自旋状态,就是持续来修改锁对象。
  • 重量级锁:如果线程B自旋一定次数后,还没有拿到锁,这个时候锁就会升级为重量级锁,这时我们的线程B会由用户态切换到内核态,申请一个互斥量,并且将锁对象的前30bit指向我们的互斥量地址,并且进入睡眠状态,然后我们的线程A继续运行知道完成时,当线程A想要释放锁资源时,发现原来锁的前30bit位并不是指向自己了,这时线程A释放锁,并且去唤醒那些处于睡眠状态的线程,锁升级到重量级锁。

3>锁消除:因为Synchronized锁的是对象,如果每一个线程都锁一个新的对象,那么这个时候就不需要进行上锁了,就是所谓的锁消除,锁消除的底层实现原理是JVM的逃逸分析原理。如以下代码所示:

 synchronized (new Object()){
        System.out.println("开始处理逻辑");
    }

4>锁粗化:
把很多次锁的请求合并成一个请求,以降低短时间内大量锁请求、同步、释放带来的性能损耗。

StringBuffer sb = new StringBuffer();

public void lockCoarseningMethod() {
    synchronized (Test.class) {
        sb.append("1");
    }

    synchronized (Test.class) {
        sb.append("2");
    }
    synchronized (Test.class) {
        sb.append("3");
    }
    synchronized (Test.class) {
        sb.append("4");
    }
}

锁粗化后:

StringBuffer sb = new StringBuffer();

public void lockCoarseningMethod() {
    synchronized (Test.class) {
        sb.append("1");
        sb.append("2");
        sb.append("3");
        sb.append("4");
    }
}

欢迎各位关注我的JAVAERS公众号,陪你一起学习,一起成长,一起分享JAVA路上的诗和远方。在公众号里面都是JAVA这个世界的朋友,公众号每天会有技术类文章,面经干货,也有进阶架构的电子书籍,如Spring实战、SpringBoot实战、高性能MySQL、深入理解JVM、RabbitMQ实战、Redis设计与实现等等一些高质量书籍,关注公众号即可领取哦。
在这里插入图片描述

发布了9 篇原创文章 · 获赞 39 · 访问量 546

猜你喜欢

转载自blog.csdn.net/qq_36526036/article/details/104782986
今日推荐