知识铺垫
1、CAS
CAS全称叫做CompareAndSwap,有的也叫CompareAndSet、CompareAndExchange;意为比较并交换。
CompareAndSwap是Unsafe类中的一个方法,主要参数有三个:
主内存中存放的共享变量的值:V(一般情况下这个V是内存的地址值,通过这个地址可以获得内存中的值)
工作内存中共享变量的副本值,也叫预期值:A
需要将共享变量更新到的最新值:B
主内存中保存了一个共享变量,线程通过这个V来获取到这个共享变量到这个线程的工作内存A中,然后经过计算后变成B值,最后再将B值写回到内存V值中去。
CAS的核心就是在将B值写入回去的时候,通过V值获取到主内存中的共享变量与A值对比是否相同,如果相同则将B写入到主内存中,如果不相同,就说明在此期间有其他线程修改了主内存中的值,则继续重复上述操作。
CAS的具体实现是native的方法,native指的是原生函数,是用C或C++实现的方法。
CAS被称为无锁操作,实际上真的是无所操作吗?
如果CAS是无锁操作,那么就会有一个疑问,因为比较A与V值和将B值写入主内存一定是两步操作,那么在如果线程A比对完两个值发现相同,可以将B写入到主内存中,此时线程B也比对完两个值,然后将自己线程计算完成的B值写入到主内存中,此时A线程又将自己计算完成的B值写入到主内存中,这个时候CAS操作就不是一致性的,所以我们再想CAS真的没有加锁吗?
实际上如果一步步去追踪源码,会发现它最终调用了汇编写的LOCK_IF_MP方法,意思是在多个处理器的情况下就加上LOCK指令,如果是单个处理器的情况下就不加LOCK指令。
最终执行的是lock cmpxchg指令,lock指令不允许被其他CPU打断,保证执行的一定是原子操作,所以说实际上CAS最终可能还是加了锁,并不是无锁操作。
提到CAS就必须要说CAS的ABA问题:
A线程通过V获取到主内存中的共享变量为1,在自己工作内存中将1变更为2。
B线程通过V获取到主内存中的共享变量为1,在自己工作内存中将1变更为2,对比V与A相同,将2写回主内存。
C线程通过V获取到主内存中的共享变量为2,在自己工作内存中将2变更为1,对比V与A相同,将1写回主内存。
A线程对比V与A相同,将2写回主内存。
此时就出现了问题,A线程虽然比对相同,但是在中间过程中已经有其他线程修改过这个值,所以A线程的这一次CAS操作严格来说并不是原子性的操作。
JDK为了避免ABA问题的发生,增加了一个AtomicStampedReference类,在CAS原有的基础上增加了一个参数S版本号/标记,每次主内存中的共享变量被修改过一次,版本号就会发生一次变化,比对的时候首先检查V与A值是否相等,然后再检查当前版本号是否与预期版本号相同,都相同才会将B值写回主内存,否则不修改,重新来过。
JUC包下的Atomic*类都是通过CAS来实现原子性的操作的。
2、对象的内存布局
这里我们讲的只是HotSpot实现的虚拟机的对象内存布局,我们可以将其划分为三个部分:对象头、实例数据、对其填充。
对象头:
对象头包含两类信息:
1、存储对象自身的运行时数据,如哈希码、GC分带年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等等,这部分数据也被官方称之为“Mark Word”。
2、类型指针,即对象指向它的类型元数据的指针,也就是Java虚拟机通过这个指针来确定该对象是那个类的实例。但是并不是所有的对象都是这样的,比如如果是一个Java数据,那么对象头中还必须有一块专门用记录数组长度的数据。
实例数据:
这部分数据即我们在程序代码中定义的各种类型的字段内容,不论是从父类继承来的,还是在子类中定义的定义的字段都必须记录起来。
对齐填充:
这一部分并不是必然存在的,也没有什么特别的含义,它仅仅是起到占位符的作用,因为HotSpot实现的虚拟机的自动内存管理系统要求对象的起始地址必须是8字节的整数倍,对象头被精心设计过,正好是8字节的整数倍(可能是1倍也可能是2倍),实例数据不一定是8字节的整数倍,所以最后差多少字节,就需要这部分来填充补全。
正文
了解完对象的内存布局,我们会知道实际上Synchronized就是通过修改Mark Word来实现加锁解锁。
**偏向锁:**将第一次来获取该锁的线程ID或者说是线程指针、唯一标识贴在Mark Word当中,每次该线程获取来获取锁的时候,只要Mark Word还存在该线程的唯一标识就可以直接获取到锁。
**轻量级锁(自旋锁):**线程在自己的线程栈中生成一个LR(Lock Record),然后通过CAS的方式去修改Mark Word的指针,修改成功后,Mark Word的指针指向的就是这个线程的LR。
**重量级锁:**利用内核中的互斥量mutex,它是一个同步原语,它只允许对一个线程的共享资源进行独占访问,如果一个线程获取到了mutex,那么其他线程就无法获取到mutex。
偏向锁和轻量级锁都是在用户空间就可以完成的,不需要去内核申请mutex,所以这两个都被统称为轻量级锁,而重量级锁需要到内核态去申请mutex,中间还涉及了用户态与内核态之间的切换,很耗费资源、时间,而且最初Synchronized没有锁升级,每次获取都是重量级锁,但是大多情况下却只有一个线程在使用,正因如此,JDK对Synchronized进行了锁优化,引入了这两个轻量级的锁。
第一条线:
我们平时了解的可能更多的是无锁→偏向锁→轻量级锁→重量级锁,但是这条线直接略过了偏向锁。
想要搞明白为什么直接略过了偏向锁,可以先思考一个问题,当我们在明确知道会有锁争抢的前提下,偏向锁一定会比轻量级锁更快吗?
明确知道锁一定会被争抢的前提下,每个锁对象实际上都会从无锁升级成偏向锁,然后进行一步偏向锁锁撤销的过程,然后升级为轻量级锁,为了节省这一步锁撤销的过程,JVM在启动时直接关闭了偏向锁的开关,等待几秒钟过后在开启偏向锁,所以在未开启偏向锁的时候锁会直接升级为轻量级锁。
升级重量锁之前,线程都在自旋等待,那么升级为重量级锁之后,所有线程都会丢到一个叫WaitSet的队列中去,这个WaitSet是ObjectMonitor对象的一个属性,进入到这个队列中,这些线程就不需要自旋消耗CPU资源了,也就是为什么有了轻量级锁为什么还要使用重量级锁,这个时候进入这个队列等待,要比自己空转自旋要更合适。
我们再看字节码文件的时候可以发现:
monitorenter 上锁
monitorexit 解锁
但是你会发现生成了两条monitorexit指令:
第一条monitorexit是正常释放锁
第二条monitorexit是当抛出任何异常的时候也释放锁
所以说Synchronized是自动上锁,自动释放锁。
第二条线:
这一条线出现了匿名偏向的一个概念,意思是没有存放偏向的线程的标识,但是通过Mark Word会发现此时它是偏向锁。
第三条线:
在偏向锁的时候调用wait方法直接掠过轻量级锁,升级为重量级锁。
wait方法是Object类的方法,具体实现中调用完wait函数之后,Synchronized直接膨胀为重量级锁。
可重入性
Synchronized是可重入锁,A线程获取到了这把锁,可以在这个同步代码块中继续获取这把锁,但相应的最终释放锁的时候也要释放两次。
Synchronized是如何记录重入次数的呢?
偏向锁重入:
上面我们讲到了LR(Lock Record),线程再自己的线程栈中生产一个LR,Mark Word中记录了指向这个LR的指针,这个指针占用了Mark Word存放hashCode(identityHashCode:根据对象实际存储的物理地址生成的HashCode,和我们调用的HashCode值不是一个)的位置,hashCode被存到到了另一个地方,LR存放了可以寻找到这个地方的指针;
当线程再一次获取锁的时候,当前线程栈会再生成一个空的LR,每重入一次就会生成一个新的LR,每释放一次就会从栈中弹出一个LR,这就可以保证获取锁的次数和释放锁的次数一致。
轻量级锁重入:
轻量级锁的重入和偏向锁类似,通过生成空的LR来解决重入的问题。
重量级锁重入:
重量级锁Mark Word中存放的就不再是线程ID或者线程指针了,而是一个指向OBjectMonitor对象的指针,这是一个C++的对象。OBjectMonitor里有个属性记录了重入的次数。
竞争激烈:
有线程自旋超过10次(1.6之前可以通过-XX:PreBlockSpin修改),或者自旋线程数超过CPU核数一半,就会升级为重量级锁,1.6之后加入了自适应自旋,具体自旋多少次由JVM自己来控制,不需要自己修改。
以上测试全部是基于JDK1.8进行的,目前JDK11默认就是开启偏向锁的。