- Synchronized可以作用在哪里? 分别通过对象锁和类锁进行举例。
- Synchronized本质上是通过什么保证线程安全的? 分三个方面回答:加锁和释放锁的原理,可重入原理(当线程拥有Monitor权限时,重新进入将会将),保证可见性原理。
- Synchronized在使用时有何注意事项?
- Synchronized修饰的方法在抛出异常时,会释放锁吗?
- 多个线程等待同一个snchronized锁的时候,JVM如何选择下一个获取锁的线程?
- Synchronized使得同时只有一个线程可以执行,性能比较差,有什么提升的方法?
- 我想更加灵活地控制锁的释放和获取(现在释放锁和获取锁的时机都被规定死了),怎么办?
- 不同的JDK中对Synchronized有何优化?
- 什么是锁的升级和降级? 什么是JVM里的偏斜锁、轻量级锁、重量级锁?
- Synchronized由什么样的缺陷? Java Lock是怎么弥补这些缺陷的。
- Synchronized和Lock的对比,和选择?
- 如何通过对象锁实现跨方法加锁?通过 sun.misc.Unsafe#monitorEnter sun.misc.Unsafe#monitorExit 进行实现;不推荐使用
锁类型的定义: 线程的生命周期:
synchronized 锁
synchronized可以保证方法或者代码块在运行时,同一时刻只有一个线程可以进入到临界区(互斥),同时它还可以保证共享变量的内存可见性 原子性 有序性
Java中每一个非null对象都可以作为锁(都会在JVM 内部维护一个与之对应Monitor对象(管程对象)),这是synchronized实现同步的基础。
synchronized 常见的三种使用方法如下:
- 普通同步方法,锁是当前实例对象
- 静态同步方法,锁是当前类的class对象
- 同步方法块, 锁是括号里面的对象
public Class MyClass {
public void synchronized method1() {
// ...
}
public static void synchronized method2() {
// ...
}
}
等价于
public class MyClass {
public void method1() {
synchronized(this) {
// ...
}
}
public static void method2() {
synchronized(MyClass.class) {
// ...
}
}
}
JVM内置锁通过synchronized使用,通过内部对象Monitor(监视器锁)实现,基于进入与退出Monitor对象实现方法与代码块同步,
监视器锁的实现依赖底层操作系统的Mutex lock(互斥锁)实现,它是一个重量级锁性能较低;
当然,JVM内置锁在1.6之后版本做了重大的优化,如锁粗化(Lock Coarsening)、锁消除(Lock Elimination)、轻量级锁(Lightweight Locking)、偏向锁(Biased Locking)、适应性自旋锁(Adaptive Spinning)等技术来减少锁操作的开销,,内置锁的并发性能已经基本与Lock持平。
反编译出字节码文件: monitorEnter和monitorExit 关键字
每个对象有一个对象监视器锁(monitor)。当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权,
获取monitor所有权的过程:
- 如果monitor的进入数为0,则该线程进入monitor,然后将进入数_count设置为1,该线程即为monitor的所有者。
- 如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数再加1。(可重入的原理)
- 如果其他线程已经占用了monitor,则该线程进入同步队列SynchronizedQueue 处于阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权
执行 monitorexit的线程必须是object ref所对应的monitor的所有者。
- 指令执行时,monitor的进入数减1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。
- 其他被这个monitor阻塞的线程可以尝试去获取这个 monitor 的所有权。
通过这两段描述,我们应该能很清楚的看出Synchronized的实现原理,Synchronized的语义底层是通过一个monitor的对象来完成,
其实wait/notify等方法也依赖于monitor对象,这就是为什么只有在同步的块或者方法中才能调用wait/notify等方法,否则会抛出java.lang.IllegalMonitorStateException的异常的原因。
ObjectMonitor 类的结构
//结构体如下
ObjectMonitor::ObjectMonitor() {
_header = NULL; // 对象头
_count = 0; // 记录加锁的次数,锁重入时用到
_waiters = 0, // 当前有多少处于wait状态的Thread
_recursions = 0; // 线程的重入次数
_object = NULL;
_owner = NULL; // 标识拥有该monitor的线程对象thread
_WaitSet = NULL; // 处于wait状态的的线程会被记录到_waitSet中 等待线程组成的双向循环链表,_WaitSet是第一个节点 同步阻塞队列
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ; //多线程竞争锁进入时的单向链表
FreeNext = NULL ;
_EntryList = NULL ; //_owner从该双向循环链表中唤醒线程结点,_EntryList是第一个节点
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}
我们知道synchronized加锁加在对象上,对象是如何记录锁状态的呢? 对象头
认识对象的内存结构:对象头:比如 hash码,对象所属的年代,对象锁,锁状态标志,偏向锁(线程)ID,偏向时间,数组长度(数组对象)等 对象
实际数据:即创建对象时,对象中成员变量,方法等
对齐填充:对象的大小必须是8字节的整数倍
Java对象头
synchronized用的锁是存在Java对象头里的,那么什么是Java对象头呢?
Hotspot虚拟机的对象头主要包括两部分数据:Mark Word(标记字段)、Class Pointer(类型指针)。
其中Class Point是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例, 比如通过 Class.getClass() 获取Class 类型
Mark Word 用于存储对象自身的运行时数据,它是实现轻量级锁和偏向锁的关键。所以下面将重点阐述:
Mark Word 用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等等。
Java对象头一般占有两个机器码(在32位虚拟机中,1个机器码等于4字节,也就是32bit),
但是如果对象是数组类型,则需要三个机器码:因为JVM虚拟机可以通过Java对象的元数据信息确定Java对象的大小,但是无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度。
下图是Java对象头的存储结构(32位虚拟机):
对象头信息: 是与对象自身定义的数据无关的额外存储成本,但是考虑到虚拟机的空间效率,Mark Word被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据,
它会根据对象的状态复用自己的存储空间,也就是说,Mark Word会随着程序的运行发生变化,变化状态如下(32位虚拟机):
补充:什么是 偏向锁 轻量级锁 自旋锁 自适应自旋锁 锁消除偏向锁 :偏向锁是Java 6之后加入的新锁,它是一种针对加锁操作的优化手段 适用于: 单独一个线程访问
经过研究发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,
因此为了减少同一线程获取锁(会涉及到一些CAS操作,耗时,同步数据等草走)的代价而引入偏向锁。(引入偏向锁的原因)
偏向锁的核心思想是:如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word 的结构也变为偏向锁结构,当这个线程再次请求锁时,
无需再做任何同步操作,即获取锁的过程,这样就省去了大量有关锁申请的操作,从而也就提供程序的性能。所以,对于没有锁竞争的场合,偏向锁有很好的优化效果。
但是对于锁竞争比较激烈的场合,偏向锁就失效了,因为这样场合极有可能每次申请锁的线程都是不相同的,因此这种场合下不应该使用偏向锁,
否则会得不偿失,需要注意的是,偏向锁失败后,并不会立即膨胀为重量级锁,而是先升级为轻量级锁。
轻量级锁:倘若偏向锁失败,虚拟机并不会立即升级为重量级锁,它还会尝试使用一种称为轻量级锁的优化手段(1.6之后加入的), 适用于:竞争不激烈,多个线程之间交替执行
此时Mark Word 的结构也变为轻量级锁的结构。
轻量级锁能够提升程序性能的依据是:“对绝大部分的锁,在整个同步周期内都不存在竞争”,(引入轻量级锁的原因)
需要了解的是,轻量级锁所适应的场景是线程交替执行同步块的场合,如果存在同一时间访问同一锁的场合,就 会导致轻量级锁膨胀为重量级锁。
自旋锁: 轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。
这是基于在大多数情况下,线程持有锁的时间都不会太长,如果直接挂起操作系统层面的线程可能会得不偿失,(引入自旋锁的原因)
毕竟操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对 比较长的时间,时间成本相对较高,
因此自旋锁会假设在不久将来,当前的线程可以获得锁,因此虚拟机会让当前想要获取锁的线程做几个空循环(这也是称为自旋的原因),
一般不会太久,可能是50个循环或100循环,在经过若干次循环后,如果得到锁,就顺利进入临界区。如果还不能获得锁,那就会将线程在操作系统层面挂起,这就是自旋锁的优化方式,
这种方式确实也是可以提升效率的。 最后没办法也就只能升级为重量级锁了。
自适应自旋锁 : 在JDK 1.6中引入了自适应自旋锁。这就意味着自旋的时间不再固定了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的。(引入自适应自旋锁的原因)
如果在同一个锁对象上,自旋等待刚刚成功获取过锁,并且持有锁的线程正在运行中,那么JVM会认为该锁自旋获取到锁的可能性很大,会自动增加等待时间。比如增加到100此循环。
相反,如果对于某个锁,自旋很少成功获取锁。那再以后要获取这个锁时将可能省略掉自旋过程,以避免浪费处理器资源。
有了自适应自旋,JVM对程序的锁的状态预测会越来越准确,JVM也会越来越聪明
锁消除:消除锁是虚拟机另外一种锁的优化,这种优化更彻底,
Java虚拟机在JIT编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,(引入锁消除的原因)
通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间,
如下StringBuffer的 append是一个同步方法,但是在add方法中的StringBuffer属于一个局部变量, 并且不会被其他线程所使用,因此StringBuffer不可能存在共享资源竞争的情 景,JVM会自动将其锁消除。
锁的膨胀升级过程锁的状态总共有四种,无锁状态、偏向锁、轻量级锁和重量级锁。
随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁,但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级。下图为锁的升级全过程:
注:其中无锁和偏向锁的锁标志位都是01,只是在前面的1bit区分了这是无锁状态还是偏向锁状态。
JVM一般是这样使用锁和Mark Word的:
1,当没有被当成锁时,这就是一个普通的对象,Mark Word记录对象的HashCode,锁标志位是01,是否偏向锁那一位是0。
2,当对象被当做同步锁并有一个线程A抢到了锁时,锁标志位还是01,但是否偏向锁那一位改成1,前23bit记录抢到锁的线程id,表示进入偏向锁状态。
3,当线程A再次试图来获得锁时,JVM发现同步锁对象的标志位是01,是否偏向锁是1,也就是偏向状态,Mark Word中记录的线程id就是线程A自己的id,表示线程A已经获得了这个偏向锁,可以执行同步锁的代码。
4,当线程B试图获得这个锁时,JVM发现同步锁处于偏向状态,但是Mark Word中的线程id记录的不是B,那么线程B会先用CAS操作试图获得锁,这里的获得锁操作是有可能成功的,因为线程A一般不会自动释放偏向锁。如果抢锁成功,就把Mark Word里的线程id改为线程B的id,代表线程B获得了这个偏向锁,可以执行同步锁代码。如果抢锁失败,则继续执行步骤5。
5,偏向锁状态抢锁失败,代表当前锁有一定的竞争,偏向锁将升级为轻量级锁。JVM会在当前线程的线程栈中开辟一块单独的空间,里面保存指向对象锁Mark Word的指针,同时在对象锁Mark Word中保存指向这片空间的指针。上述两个保存操作都是CAS操作,如果保存成功,代表线程抢到了同步锁,就把Mark Word中的锁标志位改成00,可以执行同步锁代码。如果保存失败,表示抢锁失败,竞争太激烈,继续执行步骤6。
6,轻量级锁抢锁失败,JVM会使用自旋锁,自旋锁不是一个锁状态,只是代表不断的重试,尝试抢锁。从JDK1.7开始,自旋锁默认启用,自旋次数由JVM决定。如果抢锁成功则执行同步锁代码,如果失败则继续执行步骤7。
7,自旋锁重试之后如果抢锁依然失败,同步锁会升级至重量级锁,锁标志位改为10。在这个状态下,未抢到锁的线程都会被阻塞。
补充:
偏向锁的撤销过程:偏向锁使用了一种等待竞争出现才会释放锁的机制。所以当其他线程尝试获取偏向锁时,持有偏向锁的线程才会释放锁。但是偏向锁的撤销需要等到全局安全点(就是当前线程没有正在执行的字节码)。它会首先暂停拥有偏向锁的线程,让你后检查持有偏向锁的线程是否活着。如果线程不处于活动状态,直接将对象头设置为无锁状态。如果线程活着,JVM会遍历栈帧中的锁记录,栈帧中的锁记录和对象头要么偏向于其他线程,要么恢复到无锁状态或者标记对象不适合作为偏向锁
为什么需要进行锁优化?
Synchronized是通过对象内部的一个叫做监视器锁(monitor)来实现的,监视器锁本质又是依赖于底层的操作系统的Mutex Lock(互斥锁)来实现的。而操作系统实现线程之间的切换需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么Synchronized效率低的原因。因此,这种依赖于操作系统Mutex Lock所实现的锁我们称之为“重量级锁”。
可参考链接:synchronized深度解析 syn关键字分析
关键字: synchronized详解 | Java 全栈知识体系



