发展
- JDK1.6之前:
synchronized 是一个重量级锁,主要通过内部对象Monitor实现(反编译字节码可提现),而Monitor锁又是依赖于底层操作系统的Mutex Lock(互斥锁,互斥量),调用Pthread库实现的,而Pthread库是处于系统的内核空间中,JVM存在于用户空间中,故此非常耗时。
- JDK1.6之前出现问题后:
AQS(AbstractQueuedSynchronizer)出现了,由国外某大佬用JAVA实现的各种锁,保证了锁的可重入性和公平性,最重要的是耗时问题的解决。
- JDK包括1.6之后:
在Java被oracle收购以后,为了实现自己的并发框架,对synchronized做了升级和改良,引入了偏向锁、轻量级锁等,让锁有了升级机制,解决了耗时和公平性等问题。
加锁方式
- 对于普通同步方法,锁是当前实例对象。
- 对于静态同步方法,锁是当前类的Class对象。
- 对于同步方法块,锁是synchronized括号里配置的对象。
synchronized原理
对象头、各个锁hashcode 的存放位置
synchronized是一种对象锁(锁的是对象而非引用),锁的粒度是对象,用来实现临界资源的同步于互斥,也具有可重入性。
JVM是基于进入和退出Moniter对象来实现方法同步和代码块同步,但两者细节有所区别。代码块同步时使用monitorenter和monitorexit指令实现的,方法是通过添加标志ACC_SYNCHRONIZED实现的。
而synchronized正是基于JVM的内置锁Monitor(监视器锁)实现的。监视器锁的实现以来底层操作系统的Mutex Lock(互斥锁)实现,它是一个重量级锁,性能较低。
synchronized实现
Java对象头和monitor是实现synchronized的基础。
Java对象头
synchronized用的锁是存在Java对象头里的。
在HotSpot,对象在内存中存储的布局可以分为3个区域:
1对象头(Header)
2实例数据(Instance Date)
3对齐填充(Padding)
对象头由Mark Word(标记字段)和Klass pointer(类型指针)组成。
Mark Word:(32位占4字节,64位占8字节)用于存储对象自身的运行时数据,如哈希码(Hash Code)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。(对象和数组稍有差别,数组长度占4字节)。
Klass pointer:(开启指针压缩占4字节,不开启占8字节)指对象指向它的类元数据的指针,JVM通过这个指针来确定这个对象是哪个类的实例。
下图是内存布局大概分布:
下图是32位虚拟机对象头各锁对应的信息;
对象头一般占有两个机器码(在32位虚拟机中,1个机器码等于4字节,也就是32bit)),它是实现轻量级锁和
偏向锁的关键。
Monitor
什么是Monitor?我们可以把它理解为一个同步工具,也可以描述为一种同步机制,它通常被描述为一个对象。
与一切皆对象一样,所有的Java对象是天生的Monitor,每一个Java对象都有成为Monitor的潜质,因为在Java
的设计中 ,每一个Java对象自打娘胎里出来就带了一把看不见的锁,它叫做内部锁或者Monitor锁。
Monitor 是线程私有的数据结构,每一个线程都有一个可用monitor record列表,同时还有一个全局的可用列
表。每一个被锁住的对象都会和一个monitor关联(对象头的MarkWord中的LockWord指向monitor的起始地
址),同时monitor中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。
如:某个线程进入了monitor(初始为0),进入数+1,重复进入+1,退出-1。为0代表没有线程进入。
其结构如下:
- Owner:初始时为NULL表示当前没有任何线程拥有该monitor record,当线程成功拥有该锁后保存线程唯一标识,当锁被释放时又设置为NULL;
- EntryQ:关联一个系统互斥锁(semaphore),阻塞所有试图锁住monitor record失败的线程。
- RcThis:表示blocked或waiting在该monitor record上的所有线程的个数。
- Nest:用来实现重入锁的计数。
- HashCode:保存从对象头拷贝过来的HashCode值(可能还包含GC age)。
- Candidate:用来避免不必要的阻塞或等待线程唤醒,因为每一次只有一个线程能够成功拥有锁,如果每次前一个释放锁的线程唤醒所有正在阻塞或等待的线程,会引起不必要的上下文切换(从阻塞到就绪然后因为竞争锁失败又被阻塞)从而导致性能严重下降。Candidate只有两种可能的值:0表示没有需要唤醒的线程,1表示要唤醒一个继任线程来竞争锁。
锁膨胀升级过程
锁的状态总共有四种:无锁状态、偏向锁、轻量级锁和重量级锁。
随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁,但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级。
从JDK 1.6 中默认是开启偏向锁和轻量级锁的,可以通过-XX:-UseBiasedLocking来禁用偏向锁。
偏向锁
HotSpot作者经过研究发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,因此为了减少同一线程获取锁(会涉及到一些CAS操作,耗时)的代价而引入偏向锁。
偏向锁的核心思想是:如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word 的结构也变为偏向锁结构,当这个线程再次请求锁时,获取锁无需再做任何同步操作,这样就省去了大量有关锁申请的操作,从而也就提供程序的性能。所以,对于没有锁竞争的场合,偏向锁有很好的优化效果,毕竟极有可能连续多次是同一个线程申请相同的锁。
但是对于锁竞争比较激烈的场合,偏向锁就失效了,因为这样场合极有可能每次申请锁的线程都是不相同的,因此这种场合下不应该使用偏向锁,否则会得不偿失,需要注意的是,偏向锁失败后,并不会立即膨胀为重量级锁,而是先升级为轻量级锁。
轻量级锁
偏向锁失败后,并不会立即膨胀为重量级锁,而是先升级为轻量级锁。与之同时,Mark Word中锁结构也会变成轻量级锁的结构。适用场景:绝大部分的锁,在整个同步周期内都不存在竞争,即不太存在同一时间访问同一锁的场合,不然会导致膨胀为重量级锁。
自旋锁
轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。这是基于在大多数情况下,线程持有锁的时间都不会太长,如果直接挂起操作系统层面的线程可能会得不偿失,毕竟操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高。(JVM让锁自旋,就是做空循环,一般50-100看具体设置)如果到了时间还没获取锁,就会在操作系统层面挂起线程,膨胀升级为重量级锁。
适应性自旋锁
JDK 1.6引入了更加聪明的自旋锁,即自适应自旋锁。
所谓自适应就意味着自旋的次数不再是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。它怎么做呢?线程如果自旋成功了,那么下次自旋的次数会更加多,因为虚拟机认为既然上次成功了,那么此次自旋也很有可能会再次成功,那么它就会允许自旋等待持续的次数更多。
反之,如果对于某个锁,很少有自旋能够成功的,那么在以后要或者这个锁的时候自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源。
锁消除
为了保证数据的完整性,我们在进行操作时需要对这部分操作进行同步控制,但是在有些情况下,JVM检
测到不可能存在共享数据竞争,这是JVM会对这些同步锁进行锁消除。锁消除的依据是逃逸分析的数据支
持。
如果不存在竞争,为什么还需要加锁呢?所以锁消除可以节省毫无意义的请求锁的时间。变量是否逃逸,
对于虚拟机来说需要使用数据流分析来确定,但是对于我们程序员来说这还不清楚么?我们会在明明知道
不存在数据竞争的代码块前加上同步吗?但是有时候程序并不是我们所想的那样?我们虽然没有显示使用
锁,但是我们在使用一些JDK的内置API时,如StringBuffffer、Vector、HashTable等,这个时候会存在隐
形的加锁操作。比如StringBuffffer的append()方法,Vector的add()方法。(如果JVM检测到变量没有逃
逸,则会将其锁消除掉)
锁消除:前提是java必须运行在server模式(server模式会比client模式作更多的优化),同时必须开启逃逸分析
-XX:+DoEscapeAnalysis 开启逃逸分析
-XX:+EliminateLocks 表示开启锁消除。
逃逸分析
使用逃逸分析,编译器可以对代码做如下优化:
一、同步省略。如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步。
二、将堆分配转化为栈分配。如果一个对象在子程序中被分配,要使指向该对象的指针永远不会逃逸,对象可能是栈分配的候选,而不是堆分配。
三、分离对象或标量替换。有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,
而是存储在CPU寄存器中。
锁粗化
在使用同步锁的时候,需要让同步块的作用范围尽可能小—仅在共享数据的实际作用域中才进行同步,这
样做的目的是为了使需要同步的操作数量尽可能缩小,如果存在锁竞争,那么等待锁的线程也能尽快拿到
锁。
锁粗化概念:就是将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。如:vector每次
add的时候都需要加锁操作,JVM检测到对同一个对象(vector)连续加锁、解锁操作,会合并一个更大范
围的加锁、解锁操作,即加锁解锁操作会移到for循环之外。
升级过程图
参考:https://gorden5566.com/post/1019.html