前言

线程安全是并发编程中的重要关注点,造成线程安全问题的主要原因有两点,一是存在共享数据(也称临界资源),二是存在多条线程共同操作共享数据。

因此为了解决这个问题,我们可能需要这样一个方案,当存在多个线程操作共享数据时,需要保证同一时刻有且只有一个线程在操作共享数据,其他线程必须等到该线程处理完数据后再进行,这种方式叫互斥锁,即能达到互斥访问目的的锁,也就是说当一个共享数据被当前正在访问的线程加上互斥锁后,在同一个时刻,其他线程只能处于等待的状态,直到当前线程处理完毕释放该锁。

一、synchronized锁机制

1、synchronized 的作用:

synchronized 通过当前线程持有对象锁,从而拥有访问权限,而其他没有持有当前对象锁的线程无法拥有访问权限,保证在同一时刻,只有一个线程可以执行某个方法或者某个代码块,从而保证线程安全。

synchronized 可以保证线程的可见性,synchronized 属于隐式锁,锁的持有与释放都是隐式的,我们无需干预。synchronized最主要的三种应用方式:

  • 修饰实例方法:作用于当前实例加锁,进入同步代码前要获得当前实例的锁

  • 修饰静态方法:作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁

  • 修饰代码块:指定加锁对象,进入同步代码库前要获得给定对象的锁

2、synchronized 底层语义原理:

synchronized 锁机制在 Java 虚拟机中的同步是基于进入和退出监视器锁对象monitor实现的(无论是显示同步还是隐式同步都是如此),每个对象的对象头都关联着一个monitor对象,当一个monitor被某个线程持有后,它便处于锁定状态。在HotSpot虚拟机中,monitor 是由ObjectMonitor实现的,每个等待锁的线程都会被封装成ObjectWaiter对象,ObjectMonitor中有两个集合,_WaitSet_EntryList,用来保存ObjectWaiter对象列表 ,owner 区域指向持有ObjectMonitor对象的线程。

当多个线程同时访问一段同步代码时,首先会进入_EntryList集合尝试获取 moniter,当线程获取到对象的monitor后进入_Owner区域并把_owner变量设置为当前线程,同时monitor中的计数器 count 加1;若线程调用wait()方法,将释放当前持有的monitor,count自减1,owner 变量恢复为 null,同时该线程进入_WaitSet集合中等待被唤醒。若当前线程执行完毕也将释放monitor并复位变量的值,以便其他线程获取monitor。如下图所示:

  • _EntryList:存储处于Blocked状态的ObjectWaiter对象列表。

  • _WaitSet:存储处于wait状态的ObjectWaiter对象列表。

3、 synchronized 的显式同步与隐式同步:

synchronized 分为显式同步(同步代码块)和隐式同步(同步方法),显式同步指的是有明确的monitorentermonitorexit指令,而隐式同步并不是由monitorentermonitorexit指令来实现同步的,而是由方法调用指令读取运行时常量池中方法的ACC_SYNCHRONIZED标志来隐式实现的。

3.1、synchronized 代码块底层原理:

synchronized同步语句块的实现是显式同步的,通过monitorentermonitorexit指令实现,其中monitorenter指令指向同步代码块的开始位置,monitorexit指令则指明同步代码块的结束位置,当执行monitorenter指令时,当前线程将尝试获取objectref(即对象锁)所对应的monitor的持有权:

  • 当对象锁的monitor的进入计数器为 0,那线程可以成功取得monitor,并将计数器值设置为 1,取锁成功。

  • 如果当前线程已经拥有对象锁的monitor的持有权,那它可以重入这个monitor,重入时计数器的值也会加1。

  • 若其他线程已经拥有对象锁的monitor的所有权,那当前线程将被阻塞,直到正在执行线程执行完毕,即monitorexit指令被执行,执行线程将释放monitor并设置计数器值为0,其他线程将有机会持有monitor

编译器会确保无论方法通过何种方式完成,无论是正常结束还是异常结束,代码中调用过的每条monitorenter指令都有执行其对应monitorexit指令。为了保证在方法异常完成时,monitorentermonitorexit指令依然可以正确配对执行,编译器会自动产生一个异常处理器,这个异常处理器可处理所有的异常,它的目的就是用来执行monitorexit指令。

3.2、synchronized 方法底层原理:

synchronized同步方法的实现是隐式的,无需通过字节码指令来控制,它是在方法调用和返回操作之中实现。JVM 可以通过方法常量池中的方法表结构(method_info Structure)中的ACC_SYNCHRONIZED访问标志 判断一个方法是否同步方法。

当方法调用时,调用指令将会检查方法的ACC_SYNCHRONIZED访问标志是否被设置,如果设置了,标识该方法是一个同步方法,执行线程将先持有monitor, 然后再执行方法,最后再方法完成(无论是正常完成还是非正常完成)时释放monitor。在方法执行期间,执行线程持有了monitor,其他任何线程都无法再获得同一个monitor

如果一个同步方法执行期间抛出了异常,并且在方法内部无法处理此异常,那这个同步方法所持有的monitor将在异常抛到同步方法之外时自动释放。

4、JVM 对 synchronized 锁的优化:

在早期版本中,synchronized 属于重量级锁,效率低下,因为监视器锁monitor是依赖于操作系统的Mutex互斥量来实现的,操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高。在 JDK6 之后,synchronized 在 JVM 层面做了优化,减少锁的获取和释放所带来的性能消耗,主要优化方向有以下几点:

4.1、锁升级:偏向锁->轻量级锁->自旋锁->重量级锁

锁的状态总共有四种,无锁状态、偏向锁、轻量级锁和重量级锁。随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁,但是锁的升级是单向的,只能从低到高升级,不会出现锁的降级。重量级锁基于从操作系统的互斥量实现的,而偏向锁与轻量级锁不同,他们是通过CAS并配合Mark Word一起实现的。

4.1.1、synchronized 的 Mark word 标志位:

synchronized 使用的锁对象是存储在 Java 对象头里的,那么 Java 对象头是什么呢?对象实例分为:

  • 对象头

  • Mark Word

  • 指向类的指针

  • 数组长度

  • 实例数据

  • 对齐填充

其中,Mark Word 记录了对象的hashcode、分代年龄、锁标记位相关的信息,由于对象头的信息是与对象自身定义的数据没有关系的额外存储成本,因此考虑到 JVM 的空间效率,Mark Word被设计成为一个非固定的数据结构,以便存储更多有效的数据,它会根据对象本身的状态复用自己的存储空间,在 32位 JVM 中的长度是 32 位,具体信息如下图所示:

4.1.2、锁升级过程:

1)偏向锁:如果一个线程获得了锁,那么进入偏向模式,当这个线程再次请求锁的时候,只需去对象头的 Mark Word 中判断偏向线程ID是否指向它自己,无需再进入 monitor 中去竞争对象,这样就省去了大量锁申请的操作,适用于连续多次都是同一个线程申请相同的锁的场景。偏向锁只有初始化的时候需要一次 CAS 操作,但如果出现其他线程竞争锁资源,那么偏向锁就会被撤销,并升级为轻量级锁。

2)轻量级锁:不需要申请互斥量,允许短时间内的锁竞争,每次申请、释放锁都至少需要一次 CAS,适用于多个线程交替执行同步代码块的场景

3)自旋锁:自旋锁假设在不久将来,当前的线程可以获得锁,因此在轻量级锁升级成为重量级锁之前,虚拟机会让当前想要获取锁的线程做几个空循环,在经过若干次循环后,如果得到锁,就顺利进入临界区,如果还不能获得锁,那就会将线程在操作系统层面挂起。

这种方式确实可以提升效率的,但是当线程越来越多竞争很激烈时,占用 CPU 的时间变长会导致性能急剧下降,因此 JVM 对于自旋锁有一定的次数限制,可能是50或者100次循环后就放弃,直接挂起线程,让出CPU资源。

4)自适应自旋锁:自适应自旋解决的是 “锁竞争时间不确定” 的问题,自适应意味着自旋的时间不再固定了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。

  • 如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而它将允许自旋等待持续相对更长的时间,比如100个循环。

  • 相反的,如果对于某个锁,自旋很少成功获得过,那在以后要获取这个锁时将可能减少自旋时间甚至省略自旋过程,以避免浪费处理器资源。

但自旋锁带来的副作用就是不公平的锁机制:处于阻塞状态的线程,并没有办法立刻竞争被释放的锁。然而,处于自旋状态的线程,则很有可能优先获得这把锁。

5)重量级锁:适用于多个线程同时执行同步代码块的场景,且锁竞争时间长。在这个状态下,未抢到锁的线程都会进入到Monitor中并阻塞在_EntryList集合中(具体的争夺锁的原理见该部分的第2点:synchronized 底层语义原理)。

4.2、锁消除:

消除锁属于编译器对锁的优化,JIT 编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译)会使用逃逸分析技术,通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间。

4.3、锁粗化:

JIT 编译器动态编译时,如果发现几个相邻的同步块使用的是同一个锁实例,那么 JIT 编译器将会把这几个同步块合并为一个大的同步块,从而避免一个线程“反复申请、释放同一个锁“所带来的性能开销。

5、偏向锁的废除:

在 JDK6 中引入的偏向锁能够减少竞争锁定的开销,使得 JVM 的性能得到了显著改善,但是 JDK15 却将决定将偏向锁禁用,并在以后删除它,这是为什么呢?主要有以下几个原因:

  • 为了支持偏向锁使得代码复杂度大幅度提升,并且对 HotSpot 的其他组件产生了影响,这种复杂性已成为理解代码的障碍,也阻碍了对同步系统进行重构

  • 在更高的 JDK 版本中针对多线程场景推出了性能更高的并发数据结构,所以过去看到的性能提升,在现在看来已经不那么明显了。

  • 围绕线程池队列和工作线程构建的应用程序,性能通常在禁用偏向锁的情况下变得更好。

二、Lock 锁机制

讲到 Synchronized 锁机制,肯定离不开的话题就是 Lock 的锁机制,那这里我们就简单介绍下 Lock 锁机制。

1、Lock 锁是什么:

Lock 锁其实指的是 JDK5 之后在 JUC 中引入的 Lock 接口,该接口中只有6个方法的声明,对于实现该接口的所有锁可以称为 Lock 锁。

Lock 锁是显式锁,锁的持有与释放都必须手动编写,当前线程使用lock()方法与unlock()对临界区进行加锁与释放锁,当前线程获取到锁之后,其他线程由于无法持有锁将无法进入临界区,直到当前线程释放锁,unlock()操作必须在 finally 代码块中,这样可以确保即使临界区执行抛出异常,线程最终也能正常释放锁。

2、ReentrantLock 重入锁:

ReentrantLock重入锁是基于 AQS 框架并实现了Lock接口,支持一个线程对资源重复加锁,作用与synchronized锁机制相当,但比synchronized更加灵活,同时也支持公平锁和非公平锁。

2.1、ReentrantLock 与 synchronized 的区别:

1)使用的区别:synchronized 是 Java 的关键字,是隐式锁,依赖于 JVM 实现,当 synchronized 方法或者代码块执行完之后,JVM 会自动让线程释放对锁的占用;ReentrantLock依赖于 API,是显式锁,需要lock()unlock()方法配合try/finally语句块来完成。

在发生异常时,JVM 会自动释放 synchronized 锁,因此不会导致死锁;而ReentrantLock在发生异常时,如果没有主动通过unLock()去释放锁,则很可能造成死锁现象,这也是unLock()语句必须写在 finally 语句块的原因。

2)功能的区别ReentrantLock相比于synchronzied更加灵活, 除了拥有synchronzied的所有功能外,还提供了其他特性:

  • ReentrantLock可以实现公平锁,而synchronized不能保证公平性。

  • ReentrantLock可以知道有没有成功获取锁(tryLock),而synchronized不支持该功能

  • ReentrantLock可以让等待锁的线程响应中断,而使用synchronized时,等待的线程不能够响应中断,会一直等待下去;

  • ReentrantLock可以基于Condition实现多条件的等待唤醒机制,而如果使用synchronized,则只能有一个等待队列

3)性能的区别:在 JDK6 以前,如果竞争资源不激烈,两者的性能是差不多的,而当竞争资源非常激烈时,此时ReentrantLock的性能要远远优于synchronizsed。但是在 JDK6 及以后的版本,JVM 对synchronized进行了优化,所以两者的性能变得差不多了

总的来说,synchronizsedReentrantLock都是可重入锁,在使用选择上需要根据具体场景而定,大部分情况下依然建议使用synchronized关键字,原因之一是使用方便语义清晰,二是性能上虚拟机已为我们自动优化。如果确实需要使用到ReentrantLock提供的多样化特性时,我们可以选择ReentrantLock

“可重入锁”概念是:自己可以再次获取自己的内部锁。比如一个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的,如果不可重入的话,就会造成死锁。同一个线程每次获取锁,锁的计数器都自增1,所以要等到锁的计数器下降为0时才能释放锁。

3、ReadWriteLock 读写锁:

ReentrantLock某些时候有局限,如果使用ReentrantLock,主要是为了防止线程A在写数据、线程B在读数据造成的数据不一致,但如果线程C在读数据、线程D也在读数据,由于读数据是不会改变数据内容的,所以就没有必要加锁,但如果使用了ReentrantLock,那么还是加锁了,反而降低了程序的性能,因此诞生了读写锁ReadWriteLock

ReadWriteLock是一个接口,而ReentrantReadWriteLockReadWriteLock接口的具体实现,实现了读写的分离,读锁是共享的,写锁是独占的,读和读之间不会互斥,读和写、写和写之间才会互斥,提升了读写的性能。

学习更多JAVA知识与技巧,关注与私信博主(555)!
热爱学习和渴望进阶的小伙伴,各种JAVA学习路线、笔记、面试题,免费分享!