- synchronized 的使用
- Java的对象头和 Monitor
- 对象头
- 实例数据
- 对齐填充
- synchronized 原理
- synchronized修饰代码块示例
- 对象锁的四种状态
- 无锁
- 偏向锁
- 轻量级锁
- 重量级锁
synchronized 的使用
- 如果修饰的是
具体对象
:锁的是对象
- 如果修饰的是
成员方法
:那锁的就是this
- 如果修饰的是
静态方法
:锁的就是这个对象.class
Java的对象头和 Monitor
理解 synchronized 原理之前,我们需要补充一下 java 对象的知识。
对象在内存中的布局分为三块区域:
对象头
实例数据
对齐填充
对象头
Hot Spot 虚拟机对象的对象头部分包括两类信息。
第一类是用于存储对象自身的运行时数据,如哈希码( Hash Code)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为32个比特和64个比特,官方称它为“Mark Word”。
从上图中能看到,把抽象的"锁"的信息存储在对象头的MarkWord中,重点关注最后两位,这两位代表锁标志位,分别对应:无锁、偏向锁、轻量级锁、重量级锁 这四种状态。在Java中,启动对象锁的方式是使用synchronized
关键字。
实例数据
实例数据部分是对象真正存储的有效信息,即我们在程序代码里面所定义的各种类型的字段内容,无论是从父类继承下来的,还是在子类中定义的字段都必须记录起来。
对齐填充
并不是必然存在的,由于 Hotspot 虚拟机的自动内存管理系统要求对象起始地址必须是 8 字节的整数倍,如果对象实例数据部分没有对齐的话,就需要通过对齐填充来补全。
synchronized 原理
在java中,synchronized关键字可以用来同步 线程,synchronized被编译后会生成monitorenter
和monitorexit
两个字节码指令,依赖这两个字节码指令来进行线程同步。
synchronized修饰代码块示例
public class Tues {public int i;public void syncTask(){synchronized (this){i++;}}
}
反编译 class 文件:
Java虚拟机的指令集中有 monitorenter 和 monitorexit 两条指令来支持 synchronized 关键字的语义。(monitorenter 和 monitorexit 两条指令是 C 语言的实现)正确实现 synchronized 关键字需要 Javac 编译器与 Java 虚拟机两者共同协作支持。Monitor的实现基本都是 C++ 代码,通过JNI(java native interface)的操作,直接和cpu的交互编程。
Monitor常常被翻译成监视器或管程。关于Monitor,简单来说可以把它想象成一个只能容纳一名客人房间,而把想要获取对象锁的线程想象成想要进入这个房间的客人。一个线程进入了Monitor,那么其他线程只能等待,只有当这个线程退出,其他线程才有机会进入。
这就是synchronized关键字所实现的同步机制,但是synchronized可能存在性能问题,因为Monitor的下层是依赖于操作系统的Mutex Lock
来实现的。Java线程事实上是对操作系统线程的映射,所以每当挂起或唤醒一个线程都要用户态切换到操作系统的内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,这个操作是比较重量级的。在某些情况下,甚至切换时间本身就会超出线程执行任务的时间,这样的话,使用synchronized将会对程序的性能产生影响。
但是从Java6开始,synchronized进行了优化,引入了 偏向锁、轻量级锁的概念。因此对象锁总共有四种状态,从低到高分别是:无锁、偏向锁、轻量级锁、重量级锁,这就分别对应了Mark Word
中锁标记位的四种状态。
目前为止,我们已经搞懂了什么是锁,什么是对象头,Mark Word
中的字段,synchronized、monitor的初步原理,四种锁状态的由来。
对象锁的四种状态
无锁->偏向锁->轻量级锁->(自旋)->重量级锁。按照这个顺序,锁的重量依次增加。
无锁
无锁顾名思义就是没有对资源进行操作系统级别(Mutex Lock)的锁定。我理解"无锁"其实有两种语义。
第一种比较简单,某种资源不会出现在多线程环境下,或者说即使出现在多线程环境下也不会出现线程竞争的情况,那么确实无需对这个资源进行任何同步保护,直接让他给各个线程随意调用就可以。
另一种情况,资源会被竞争,但是不使用操作系统同步原语对共享资源进行锁定,而是通过一些其他机制来控制同步。比如 CAS,通过诸如这种函数级别的锁,我们可以进行"无锁"编程。顺便一提的是,依赖操作系统Mutex Lock
是导致性能低下的原因,所以在大部分情况下,无锁的效率更高,但这并非意味着无锁能够全面代替有锁。
偏向锁
假如一个对象被加锁了,但在实际运行时,只有一条线程会获取这个对象锁,那么我们最理想的方式,是不要通过系统状态切换,只在用户态把这件事做掉,我们设想的是,最好对象锁能够认识这个线程,只要是这个线程过来,那么对象就直接把锁交出去。我们可以认为这个对象锁偏爱这个线程,所以被称为"偏向锁"。
意思是这个锁会偏向于第一个获得它的线程,当这个线程再次请求锁的时候不需要进行任何同步操作,从而提高性能。那么处于偏向锁模式的时候,对象头的Mark Word
的结构会变为偏向锁结构。
在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,因此为了减少同一线程获取锁的代价而引入偏向锁。那么显然,一旦另一个线程尝试获得这个锁,那么偏向模式就会结束。另一方面,如果程序的大多数锁都是多个线程访问,那么偏向锁就是多余的。
那么偏向锁是怎么实现的呢?其实很简单,在Mark Word
中,当锁标志位是'01',那么判断倒数第三个bit是否为1,如果是1,代表当前对象的锁状态为偏向锁,于是再去读Mark Word
的前23个bit,这23个bit就是线程ID,通过线程ID来确认想要获得对象锁的线程是不是"被偏爱的线程"。
假如情况发生了变化,对象发现目前不只有一个线程,而是有多个线程正在竞争锁,那么偏向锁将会升级为轻量级锁。
轻量级锁
当锁状态还是偏向锁时,是通过Mark Word
中的线程ID来找到占有这个锁的线程,当偏向锁的条件不满足,亦即的确有多线程并发争抢同一锁对象时,但并发数不大时,优先使用轻量级锁。一般只有两个线程争抢锁标记时,优先使用轻量级锁。那么当锁的状态升级到"轻量级锁"时,已经不再使用线程ID这个字段,而是将前30个bit变为了指向虚拟机栈中锁记录的指针。
当一个线程想要获得某个对象的锁时,假如看到锁标志位为'00',那么就知道它是轻量级锁,这时,线程会在自己的虚拟机栈中开辟一块被称为Lock Record
的空间。Lock Record
中存放什么呢?存放的是对象头Mark Word
的副本以及Owner指针。线程通过CAS去尝试获取锁,一旦获得,那么将会复制该对象的Mark Word
到虚拟机栈的Lock Record
中,并且将Lock Record
中Owner指针指向该对象锁。另一方面,对象的Mark Word
中的前30bit将生成一个指针,指向持有该对象锁的线程虚拟机栈中的Lock Record
。这样一来就实现了线程和对象锁的绑定,它们因此互相知道对方的存在。
这时,这个对象被锁定了,获取了这个对象锁的线程就可以去执行一些任务。那么你肯定要问,这时候万一有其他线程也想要获取这个对象,怎么办呢?此时其他线程将会自旋。什么叫"自旋"?可以理解为一种轮询,其他想要获取对象锁的线程自己不断在循环尝试去看一下锁有没有被释放,如果被释放了,那么就获取,如果没有释放就进行下一轮循环,这种方式区别于被操作系统挂起阻塞,因为如果对象锁很快就被释放的话,自旋去获得锁完全在用户空间解决,不需要进行系统中断和现场恢复,所以它的效率更高。
自旋相当于CPU在空转,如果长时间自旋,将会浪费CPU资源,于是出现一种叫做"适应性自旋"的优化,简单来说就是自旋的时间不再固定了,而是由上一次在同一个锁上的自旋时间以及锁的状态来决定。比如在同一个锁上,当前正在自旋等待的线程刚刚成功获得过锁,但是锁目前被其他线程持有,那么虚拟机就会认为下次自旋很有可能再次成功,进而它将允许更长的自旋时间,这就是"适应性自旋"。
假如对象锁被一个线程持有着,此时也有一个线程正在自旋等待,如果同时又有多个线程想要获取这个对象锁。也就是说,一旦自旋等待的线程超过一个,那么轻量级锁将会升级为"重量级锁"。
重量级锁
如果对象锁状态被标记为重量级锁,需要通过Monitor来对线程进行控制,此时将会使用同步原语来锁定资源,对线程的控制也最为严格。
注意:Java中的各种锁对程序员来说是透明的: 在创建锁时,JVM 先创建最轻的锁,若不满足条件则将锁逐次升级。这四种锁之间只能升级,不能降级。