synchronized关键字
synchronized
翻译成中文就是同步的意思。我们通常使用synchronized
关键字来给一段代码或一个方法上锁。其主要有以下三种方式
1 | //关键字在实例方法上,锁为当前实例 |
java中的锁都是对象锁,我们常说的类锁也是对象锁。Java类只要一个Class对象,多个实例对象共享这一个Class对象。类锁就是Class对象的锁。
Java中临界区指的是某一代码区域同一时刻只能由一个线程执行。synchronized
关键字如果加在方法上,那么整个方法都是临界区;如果加载代码块上,临界区就是代码块内部区域。
偏向锁、轻量级锁与重量级锁
Java6为了减少获得和释放锁带来的性能消耗,引入了偏向锁和轻量级锁。Java6以前,所有的锁都是重量级锁。
一个对象有4种锁状态,从低到高分别为:
- 无锁
- 偏向锁
- 轻量级锁
- 重量级锁
无锁就是没有对资源进行锁定,任何线程都可以去执行。
几种锁会随着竞争情况逐级升级,锁的升级很容易发生,但降级条件很苛刻。
Java对象头
每个Java对象都有对象头,如果是非数组类型,则用2个字宽来存储对象头,如果是数组,则会用3个字宽存储对象头。32位机器中,一个字宽是32位;64位虚拟机中,一个字宽是64位。对象头的内容如下表:
长度 | 内容 | 说明 |
---|---|---|
32/64bit | Mark Word | 存储对象的hashCode或锁信息等 |
32/64bit | Class Metadata Address | 存储到对象类型数据的指针 |
32/64bit | Array length | 数组的长度(如果是数组) |
其中,Mark Word
的格式如下:
锁状态 | 29或61bit | 1bit是否是偏向锁 | 2bit锁标志位 |
---|---|---|---|
无锁 | 0 | 01 | |
偏向锁 | 线程ID | 1 | 01 |
轻量级锁 | 指向栈中锁记录的指针 | 此时这一位不用于标识偏向锁 | 00 |
重量级锁 | 指向互斥量(重量级锁)的指针 | 此时这一位不用于标识偏向锁 | 10 |
GC标志 | 此时这一位不用于标识偏向锁 | 11 |
当对象状态为偏向锁时,Mark Word
中记录的是线程ID;当状态是轻量级锁时,Mark Word
中存储的是指向线程栈中Lock Record
的指针;当状态是重量级锁时,Mark Word
为指向堆中的monitor对象的指针。
偏向锁
大多数情况下,锁不仅不存在多线程竞争,还总是由同一线程多次获得,于是引入了偏向锁。
偏向锁会偏向于第一个获得锁的线程,如果在接下来的运行过程中,该锁没有被其他线程访问,则持有偏向锁的线程永远不会触发同步。也就是,偏向锁在资源无竞争条件下消除了同步语句,连CAS操作都不做了,提高了程序的运行性能。
CAS: Compare And Swap
比较并设置。
实现原理
一个线程在第一次进入同步块时,会在对象头和栈帧的锁记录中存储锁的偏向的线程ID。当下次该线程进入这个同步块时,会去检查锁的Mark Word
中是否存在自己的线程ID。
如果是,代表当前线程已获得锁,以后该线程在进入和退出同步块时不需要花费CAS操作来加锁和解锁。
如果不是,代表有另一个线程来竞争这个偏向锁。这时会尝试用CAS操作来替换Mark Word
中的线程ID为新线程的ID,有两种结果:
- 如果成功,代表之前的线程不存在了,
Mark Word
中为新线程的ID,锁不会升级,仍然为偏向锁。 - 如果失败,表示之前的线程依然存在。暂停之前的线程,设置锁标识为0,并设置锁标识位为00,升级为轻量级锁,按照轻量级锁的方式竞争锁。
线程竞争偏向锁的过程如下:
图中涉及到了lock record
指针指向当前堆栈中最近的一个lock record
,是轻量级锁按照先来先服务的模式进行轻量级锁的加锁。
撤销偏向锁
偏向锁使用了一种等到竞争出现才释放锁的机制,只有当其他线程竞争偏向锁时,持有锁的线程才会释放锁。
偏向锁升级为轻量级锁时,会暂停拥有偏向锁的线程,这个过程的开销是很大的。
偏向锁的获得和撤销流程如下:
轻量级锁
多个线程在不同时间段获得同一把锁,不存在锁竞争的情况,也就没有线程阻塞。这种情况下,可以采用轻量级锁来避免线程的阻塞和唤醒。
轻量级锁的加锁
JVM会为每个线程在当前线程的栈帧中创建存储锁记录的空间,称为Displaced Mark Word
。如果一个线程获得锁时发现是轻量级锁,会把锁的Mark Word
复制到自己的Displaced Mark Word
中。
然后线程尝试把锁的Mark Word
更改为指向自己的锁记录的指针。如果成功,当前线程获得锁;如果失败,表示Mark Word
已经被替换为其他线程的锁记录,有其他线程正在竞争锁,当前线程则尝试使用适应性自旋来获得锁。
线程的自旋是会消耗CPU资源的,如果一直处于自选状态就会白白浪费CPU资源,所以JDK采用了适应性自旋的方式,就是如果线程自旋成功了,下次自旋的次数就会更多,如果失败了,自旋的次数就会减少。
当自旋一定次数后,依然没有获得锁,称为自旋失败,这个线程会阻塞。锁也会升级为重量级锁。
轻量级锁的释放
释放锁时,当前线程使用CAS操作将Displaced Mark Word
中的内容复制回锁的Mark Word
中。如果操作成功,即锁的Mark Word
没有被其他线程更改,即没有发生竞争。如果有其他线程多次自旋失败导致锁升级为重量级锁,那么CAS操作会失败,此时会释放锁并唤醒阻塞的线程。
轻量级锁及膨胀流程图如下:
重量级锁
重量级锁是依赖操作系统的互斥量(mutex)来实现的,而操作系统中线程间状态的转换需要较多的时间,因此重量级锁的效率很低,但被阻塞的线程不会消耗CPU。
每个对象都可以当做一个锁,当多个线程请求一个对象锁时,对象锁会设置几个状态来区分请求的线程。
Contention List
:所有请求锁的线程都会被首先放置到该竞争队列Entry List
:Contention List
中那些有资格成为候选人的线程被移到Entry List
Wait Set
:调用wait
方法被阻塞的线程进入Wait Set
OnDeck
:任何时刻最多只有一个线程在竞争锁,这个线程被称为OnDeck
Owner
:获得锁的线程被称为Owner
!Owner
:释放锁的线程
当一个线程尝试获得锁时,如果该锁已经被占用,则会将该线程封装成一个ObjectWaiter
对象插入到Contention List
的队首,然后调用park
函数挂起当前线程。
当线程释放锁时,会从Contention List
或Entry Set
中挑选一个线程唤醒,被选中的线程叫做Heir presumptive
,即假定继承人,假定继承人被唤醒后会尝试获得锁,但synchronized
是非公平的,所以假定继承人不一定会获得锁。这是因为对于重量级锁,线程先自旋尝试获得锁,这样做的目的是减少执行操作系统同步操作带来的开销。如果线程自旋不成功在进入等待队列。对于已经在等待队列中的线程来说是不公平的。
如果线程获得锁后调用Object.wait()
方法,则会将线程加入Wait Set
中,当被notify
唤醒后,会将线程移到Contention List
或Entry Set
中去。当调用一个锁对象的wait
和notify
方法时,如果当前锁的状态是偏向锁或轻量级锁,则会先膨胀为重量级锁。
锁的升级流程总结
- 每一个线程在准备获取共享资源时,第一步先检查
Mark Word
中存放的是不是自己的ThreadId
,如果是,当前线程处于偏向锁。 - 如果
Mark Word
不是自己的ThreadId
,锁升级为轻量级锁。这时,采用CAS来执行切换,新的线程利用Mark Word
中现有的ThreadId
通知之前的线程暂停,之前的线程将Mark Word
置为空。 - 两个线程都把锁对象的
HashCode
复制到自己新建的用于存储锁记录的空间,接着通过CAS操作把锁对象的Mark Word
修改为指向自己新建的存储锁记录的空间的地址,通过这种方式来竞争锁。 - 成功执行CAS的获得锁,失败的进入自旋
- 自旋的线程在自旋中成功获得锁(之前获取锁的线程执行完毕并释放了锁),依然处于轻量级锁的状态,如果多次自旋失败,升级为重量级锁。
- 升级为重量级锁后,自旋的线程进入阻塞,等待之前线程执行完毕并唤醒自己。
各种锁的对比
锁 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
偏向锁 | 加锁和解锁不需要额外的消耗,和执行非同步方法的性能差距很小 | 如果线程间存在锁竞争,会带来额外的锁撤销的消耗 | 适用于只有一个线程访问同步块的情况 |
轻量级锁 | 竞争的线程不会阻塞,提高了程序的响应速度 | 如果始终得不到锁,竞争的线程会消耗CPU资源 | 追求响应时间。同步块执行速度非常快 |
重量级锁 | 线程竞争不使用自旋,不会消耗CPU | 线程阻塞,响应时间慢 | 追求吞吐量。同步块执行时间较长 |