synchronized 的使用及实现原理
2018-07-27 18:21:59 最后更新: 2018-07-27 18:21:59 访问数量:790
2018-07-27 18:21:59 最后更新: 2018-07-27 18:21:59 访问数量:790
上一篇日志中,我们介绍了 java 的内存模型和 volatile 关键字实现
我们提到,volatile 可以在满足以下两个条件的情况下保证线程安全性:
大部分场景下,我们的并发环境是无法满足这两个条件的,这时就需要使用锁机制了,本篇日志我们就来介绍一下 java 原生的 synchronized 锁是如何实现的以及我们应该如何去使用它
synchronized 关键字可以在以下三种场景中应用:
在类实例方法上声明 synchronized 关键字,作用于当前实例(this 引用)加锁,进入同步代码前需要先获取当前实例的锁
我们来看下面的代码:
运行上述代码,打印出了我们预期的结果 2000000,由于 ++ 操作是非线程安全的,所以这一结果表明我们加锁是成功的
两个线程是通过同一个实例 instance 运行的,所以他们通过这个实例的同一把锁实现了线程的并发安全,而下面的代码就将无法得到预期的结果:
运行结果打印出了 1452317,这是因为两个线程分别运行在两个 new 出来的不同的实例中,这意味着他们有着两个不同的实例对象锁,因此他们各自对 increase 方法加锁是无法实现锁的作用的
与实例方法不同,static 方法是无法获取到实例对象的 this 引用的,因此对 static 方法加锁,锁定的目标就是 class 对象,所有使用该类的线程都将获取到同一把锁
此时运行上述代码可以得到预期的加锁效果,虽然我们通过 new 传入不同的实例,但是因为 static 方法上的 synchronized 关键字是通过对 class 对象加锁,两个线程仍然是在竞争同一把锁
编写 synchronized 同步代码块是最灵活的一种加锁方式了,他不仅可以实现上述两种加锁方式的功能,还可以实现更加精细化的加锁控制
在某些情况下,我们编写的方法体可能比较大,也可能存在一些比较耗时的操作,而需要同步的代码又只有一小部分,如果直接对整个方法进行同步操作,可能会得不偿失,此时我们可以使用同步代码块的方式对需要同步的代码进行包裹,这样就无需对整个方法进行同步操作了
上述代码我们通过对代码执行的一部分加锁,实现了上述对实例方法加锁的相同功能,我们通过对类的 static 成员 instance 加锁,实现并发安全性
常用的使用方法有对 this 引用加锁和对 class 对象加锁:
Java 虚拟机中的同步(Synchronization)基于进入和退出管程(Monitor)对象实现, 无论是显式同步(有明确的 monitorenter 和 monitorexit 指令,即同步代码块)还是隐式同步都是如此
被 synchronized 修饰的同步方法并不是由 monitorenter 和 monitorexit 指令来实现同步的,而是由方法调用指令读取运行时常量池中方法的 ACC_SYNCHRONIZED 标志来隐式实现的
在 JVM 中,堆内存中的对象分为三块区域:
占用内存空间大小 | 名称 | 说明 |
32bit | Mark Word | 存储对象的hashCode、锁信息或分代年龄或GC标志等信息 |
32bit | Class Metadata Address | 类型指针指向对象的类元数据,JVM通过这个指针确定该对象是哪个类的实例 |
轻量级锁和偏向锁是 java6 对 synchronized 锁进行优化后新增的锁类型,通常我们使用的 synchronized 对象锁指的就是重量级锁
重量级锁中互斥量指针指向的是monitor对象(也称为管程或监视器锁)的起始地址
每个对象都存在着一个 monitor 与之关联,当一个 monitor 被某个线程持有后,它便处于锁定状态,下面是 HotSpot 虚拟机的 monitor 类数据结构:
可以看到,每一个 Monitor 对象都维护了两个队列:_WaitSet 和 _EntryList
当多个线程同时访问一段同步代码时,首先会进入 _EntryList 集合,获取到锁则将线程引用赋值给 _owner 字段,否则将被加入到 _WaitSet 队列中
可见,Monitor 对象存在于每个 Java 对象的对象头中,这也是为什么在 Java 中任何对象都可以作为锁的原因
这也为对象的 notify、notifyAll、wait 方法的提供了实现
java 字节码中,用来实现同步代码块的是 monitorenter 和 monitorexit 指令
monitorenter 指令执行时,当前线程试图获取 objectref 所存储的对象锁(ObjectMonitor 对象)
如果取到的 monitor 对象 _count 字段为 0,则将该线程引用存储在 monitor 对象的 _owner 字段中,并且将 _count 字段加1,线程继续执行,直到线程执行 monitorexit 指令
如果取到的 monitor 对象 _count 字段不为 0,那么首先会判断该线程是否与 monitor 对象的 _owner 字段存储的引用相同,如果相同,_count 值加 1,仍然可以继续运行,这保证了 synchronized 的可重入性,如果不同,则将该线程放入 _WaitSet,线程进入 wait 状态等待唤醒
与 synchronized 代码块不同,JVM可以从方法常量池中的方法表结构(method_info Structure) 中的 ACC_SYNCHRONIZED 访问标志区分一个方法是否同步方法
当方法调用时,调用指令将会 检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先持有 monitor 对象,然后再执行方法,最后再方法完成(无论是正常完成还是非正常完成)时释放 monitor
如果一个同步方法执行期间抛 出了异常,并且在方法内部无法处理此异常,那这个同步方法所持有的monitor将在异常抛到同步方法之外时自动释放
在Java早期版本中,synchronized属于重量级锁,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的Mutex Lock来实现的,而操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,这也是为什么早期的synchronized效率低的原因
Java 6之后Java官方对从JVM层面对synchronized较大优化,所以现在的synchronized锁效率也优化得很不错了
上面我们其实已经有提到过,java6 的优化主要是引入了偏向锁和轻量级锁,从而减少了获取锁和释放锁的性能消耗
锁的状态总共有四种,无锁状态、偏向锁、轻量级锁和重量级锁。随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁,但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级
研究表明,在大多数情况下,锁不仅不存在多线程竞争,甚至总是由同一个线程多次获取,因此,java 为了减少同一线程反复获取没有竞争的锁造成的性能损耗而引入了偏向锁
偏向锁的核心思想是,如果一个线程试图获取偏向锁,如果他与锁对象头信息中的线程 ID 相同,那么他将直接进入到代码的执行而不需要任何同步机制
一旦两次获取锁的线程不同,偏向锁将立即升级为轻量级锁
与偏向锁一样,轻量级锁也是 jdk6 引入的新特性
他解决加锁性能问题的依据是,绝大部分锁,即使被多个线程共享,在整个同步周期内,也都不存在竞争
JVM 将 monitor 对象放在栈内存中,而不进行任何同步机制,从而保证了性能
一旦出现获取轻量级锁失败,JVM 会尝试通过自旋的方式等待锁而不是让线程挂起
大部分情况下,即使存在竞争,竞争的线程也不会超过十位数,同时每个线程的等待时间也不会很长,此时,如果让线程挂起,并且通过操作系统的 Mutex 锁实现同步,性能将会显著下降
JVM 对这一情况的优化是通过自旋锁实现的,也就是说,当获取轻量级锁失败后,JVM 并不会立即让线程挂起,而是经过 50 到 100 次空循环后重新获取锁,这也就是他被称为自旋锁的原因
如果还不能获得锁,那就会将线程在操作系统层面挂起,也就只能升级为重量级锁了。
JVM 在 JIT 编译时,通过运行上下文扫描,会发现不可能存在竞争的锁,这样的锁在编译过程中会被直接消除,从而节省毫无意义的请求锁时间
我们知道,通过线程的 interrupt 方法,我们可以将处于被阻塞状态或者试图执行一个阻塞操作的线程中断,那么,在等待 synchronized 锁的线程是否可以被 interrupt 方法中断呢?
答案是不可以的,如果一个线程在等待锁,那么结果只有两种,要么它获得这把锁继续执行,要么它就保存等待,即使调用中断线程的方法,也不会生效
所谓等待唤醒机制本篇主要指的是 notify/notifyAll 和 wait 方法,在使用这3个方法时,必须处于 synchronized 代码块或者 synchronized 方法中,否则就会抛出 IllegalMonitorStateException 异常
原因是这几个方法都必须拿到当前对象的 monitor 对象,只有进入 synchronized 代码块或 synchronized 方法时,线程才会去获取 monitor 对象
需要注意的是,wait 方法调用后,线程将马上释放 monitor 锁,直到有线程调用 notify/notifyAll 方法后才能继续执行,而 sleep 方法只是让线程休眠一定时间,并不会释放锁
《深入理解Java虚拟机 —— JVM高级特性与最佳实践》
https://blog.csdn.net/javazejian/article/details/72828483
欢迎关注微信公众号,以技术为主,涉及历史、人文等多领域的学习与感悟,每周三到七篇推文,只有全部原创,只有干货没有鸡汤