本章主要深入讲解synchronized关键字。
一、synchronized关键字的使用
synchronized关键字是JVM层面实现的锁,常见的使用方式有:
- 修饰实例方法,作用于当前实例加锁,进入同步代码前要获得当前实例的锁。
- 修饰静态方法,作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁。
- 修饰代码块,指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。
1、修饰实例方法
public class SynchronizedTest implements Runnable{
static int i =0;
public synchronized void increase(){
i++;
}
@Override
public void run() {
for(int k =0; k<100;k++){
increase();
}
}
public static void main(String[] args) {
Runnable instance = new SynchronizedTest();
Thread t1 = new Thread(instance);
Thread t2 = new Thread(instance);
t1.start();
t2.start();
t1.join();
t2.join();
}
}
2、修饰静态方法
public class SynchronizedTest implements Runnable{
static int i =0;
public static synchronized void increase(){
i++;
}
@Override
public void run() {
for(int k =0; k<100;k++){
increase();
}
}
public static void main(String[] args) {
Thread t1 = new Thread(new SynchronizedTest());
Thread t2 = new Thread(new SynchronizedTest());
t1.start();
t2.start();
t1.join();
t2.join();
}
}
3、修饰代码块
public class SynchronizedTest implements Runnable{
static int i =0;
private void increase(){ i++; }
@Override
public void run() {
for(int k =0; k<100;k++){
synchronized (this){
increase();
}
}
}
public static void main(String[] args) {
Runnable instance = new SynchronizedTest();
Thread t1 = new Thread(instance);
Thread t2 = new Thread(instance);
t1.start();
t2.start();
t1.join();
t2.join();
}
}
二、synchronized关键字的底层原理
通过反汇编含有synchronized关键字的代码,同步代码块是使用MonitorEnter和MoniterExit指令实现的。编译时,MonitorEnter指令被插入到同步代码块的开始位置,MoniterExit指令被插入到同步代码块的结束位置和异常位置。任何对象都有一个Monitor与之关联,当Monitor被持有后将处于锁定状态。MonitorEnter指令会尝试获取Monitor的持有权,即尝试获取锁。
同步方法依赖flags标志ACC_SYNCHRONIZED实现,如果为非静态方法(没有ACC_STATIC标志),使用调用该方法的对象作为锁对象;如果为静态方法(有ACC_STATIC标志),使用该方法所属的Class类在JVM的内部对象表示Class作为锁对象。
《Java虚拟机规范》规定:Java虚拟机中的同步(Synchronization)基于进入和退出管程(Monitor)对象实现,无论是显式同步还是隐式同步都是如此。
- 显示同步,同步一段指令集序列通常由monitorenter和monitorexit两条指令来支持synchronized关键字的语义。
- 方法级的同步是隐式,即无需通过字节码指令来控制的,它实现在方法调用和返回操作之中。虚拟机从常量池中的方法表结构中的ACC_SYNCHRONIZED访问标志区分一个方法是否同步方法。方法调用时,调用指令将会检查方法的ACC_SYNCHRONIZED访问标志是否被设置,如果设置了,执行线程将先持有管程,然后再执行方法,最后在方法完成时释放管程。方法执行期间,执行线程持有了管程,其他任何线程都无法再获得同一个管程。如果一个同步方法执行期间抛出了异常,并且在方法内部无法处理此异常,那这个同步方法所持有的管程将在异常抛到同步方法之外时自动释放。
- 编译器必须确保方法中调用过的每条monitorenter指令都必须有执行其对应monitorexit指令。为了保证在方法异常完成时monitorenter和monitorexit指令依然可以正确配对执行,编译器会自动产生一个异常处理器,这个异常处理器声明可处理所有的异常,它的目的就是用来执行monitorexit指令。
三、什么是Monitor,与对象有什么关系
在JVM内存中,对象的布局分为3大块区域:对象头、实例变量和对齐填充。
- 实例变量:存放类的属性数据信息,包括父类的属性信息,如果是数组的实例部分还包括数组的长度,这部分内存按4字节对齐。
- 填充数据:由于虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐。
3.1 java对象头
java对象头含有含有三部分:Mark Word(存储对象自身运行时数据)、Class Metadata Address(存储类元数据的指针)、Array length(数组长度,只有数组类型才有)。
虚拟机位数 | 头对象结构 | 说明 |
32/64bit | Mark Word | 存储对象的hashCode、锁信息或分代年龄或GC标志等信息 |
32/64bit | Class Metadata Address | 类型指针指向对象的类元数据,JVM通过这个指针确定该对象是哪个类的实例。 |
32/64bit | 32bit | 仅数组有,该数据在32位和64位JVM中长度都是32bit |
32位系统中Markword的内容有:
锁状态 | 25bit | 4bit | 1bit是否是偏向锁 | 2bit 锁标志位 |
无锁状态 | 对象HashCode | 对象分代年龄 | 0 | 01 |
考虑到JVM的空间效率,Markword被设计成一个非固定的对象结构,以便存储更多有效的数据,它会根据对象本身的状态复用自己的存储空间。有锁的状态,
3.2 管程Monitor
每个对象都存在一个monitor预支关联,对象与其 monitor 之间的关系有存在多种实现方式,如monitor可以与对象一起创建销毁或当线程试图获取对象锁时自动生成。当monitor被一个线程持有后,它就处于锁定状态。monitor有ObjectMonitor实现,Hotspot底层C++的源码有:
ObjectMonitor() {
_header = NULL;
_count = 0; //记录个数
_waiters = 0,
_recursions = 0;
_object = NULL;
_owner = NULL;
_WaitSet = NULL; //处于wait状态的线程,会被加入到_WaitSet
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
_EntryList = NULL ; //处于等待锁block状态的线程,会被加入到该列表
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}
ObjectMonitor中有两个等待队列WaitSet和EntryList,用来保存ObjectWaiter对象列表(每一个线程请求锁都会被封装成一个ObjectWaiter)。_owner指向持有ObjectMonitor对象的线程。当多个线程同时请求对象锁的时候,首先会进入EntryList集合,当线程获取到锁后,会进入Owner区,把_owner设置为当前线程并_count加1;当线程调用wait方法的时候,会释放掉当前持有的monitor,_owner设置为null,count减1,同时该线程进入waitset等待被唤醒。当线程同步执行完毕,也会释放掉monitor。
具体的流程如下:
1) Contention List:竞争队列,所有请求锁的线程首先被放在这个竞争队列中
2) Entry List:Contention List中那些有资格成为候选资源的线程被移动到Entry List中
3) Wait Set:调用wait方法被阻塞的线程被放置在这里
4) OnDeck:任意时刻,最多只有一个线程正在竞争锁资源,该线程被成为OnDeck
5) Owner:当前已经获取到所资源的线程被称为Owner
ContentionList并不是真正意义上的一个队列。仅仅是一个虚拟队列,它只有Node以及对应的Next指针构成,并没有Queue的数据结构。每次新加入Node会在队头进行,通过CAS改变第一个节点为新增节点,同时新增阶段的next指向后续节点,而取数据都在队列尾部进行。
Owner线程会在unlock时,将ContentionList中的部分线程迁移到EntryList中,并指定EntryList中的某个线程为OnDeck线程(一般是最先进去的那个线程)。Owner线程并不直接把锁传递给OnDeck线程,而是把锁竞争的权利交个OnDeck,OnDeck需要重新竞争锁。这样虽然牺牲了一些公平性,但是能极大的提升系统的吞吐量,在JVM中,也把这种选择行为称之为“竞争切换”。
OnDeck线程获取到锁资源后会变为Owner线程,而没有得到锁资源的仍然停留在EntryList中。如果Owner线程被wait方法阻塞,则转移到WaitSet队列中,直到某个时刻通过notify或者notifyAll唤醒,会重新进去EntryList中。
处于ContentionList、EntryList、WaitSet中的线程都处于阻塞状态,该阻塞是由操作系统来完成的(Linux内核下采用pthread_mutex_lock内核函数实现的)
四、JVM对于synchronized的优化处理
java1.6以后,为了减少获取锁和释放锁消耗的性能,引入了偏向锁和轻量级锁。所以锁一共分为四种状态:无锁,偏向锁、轻量级锁和重锁。
4.1 偏向锁
实际统计结果表明,锁不仅不存在多线程竞争,而且总是由同一线程多次获取。当一个线程获取同步块时,会在对象头和栈帧中记录锁偏向的线程ID,以后进入和退出同步块时,不需要进行CAS操作来加锁,只需要简单的比较一下对象头的Mark word是否存储着指向当前线程的锁偏向。如果成功,表示线程已经获得了锁;如果失败,则看锁标记是否有锁,如果无锁,则使用CAS竞争锁;如果有,则尝试使用CAS将对象头里的偏向锁指向当前线程。
偏向锁的撤销:偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动去释放偏向锁。它暂停所有有偏向锁的线程,然后检查持有偏向锁的线程是否存活;如果不存活,将对象头设置为无锁状态;如果存活,持有偏向锁的栈执行,然后遍历所有的偏向对象的锁记录,栈中的锁记录和对象头里的Markword要么偏向其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁。
4.2 轻量级锁
倘若偏向锁失败,虚拟机并不会立即升级为重量级锁,它还会尝试使用一种称为轻量级锁的优化手段(1.6之后加入的),此时Mark Word 的结构也变为轻量级锁的结构。
轻量级锁的加锁过程:
1)在执行同步代码块的时候,如果对象处于无锁状态,虚拟机会首先在当前线程的栈帧中创建一个锁记录(Lock Record)空间,用于存储锁对象目前的Mark Word的拷贝。
2)拷贝对象头Markword到锁记录空间;
3)拷贝成功后,虚拟机尝试用CAS将对象头的Markword更新为指向 lock record的指针,同时将lockrecord的指针指向对象的Markword。
4)如果操作成功,表示线程获取到了锁,更新对象头锁标志为00.
5)如果操作失败,先检查Markword是否指向当前的lock record。如果是,表示当先线程已经获取到锁,执行同步代码;如果不是 ,说明多个线程竞争锁,就会膨胀为重量级锁,锁标志为10,后面竞争锁的线程都会阻塞,当前线程尝试自旋来获取锁。
轻量级解锁时,会使用原子的CAS操作将Displaced Mark Word替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。
有一个注意点:等待轻量锁的线程不会阻塞,它会一直自旋等待锁,并如上所说修改markword。
4.3 自旋锁
轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。基本思路就是持有轻量级锁的线程先自旋等待一段时间看能否成功获取,如果不成功再执行阻塞,这对于占用锁时间比较短的代码块来说性能能大幅度的提升。JVM对于自旋周期的选择,基本认为一个线程上下文切换的时间是最佳的一个时间
五、synchronized关键字的注意点
5.1 可重入性
synchronized锁是互斥的,但可重入。
5.2 与线程中断的关系
java中,线程终端提供了3个相关的方法:
public void interrupt() // 中断线程
public static boolean interrupted() // 判断线程是否被中断,并清除当前的中断状态
public boolean isInterrupted() // 判断线程是否被中断
当一个线程处于被阻塞状态或者试图执行一个阻塞操作时,调用Thread.interrupt()
方式中断该线程,此时将会抛出一个InterruptedException的异常,同时中断状态将会被复位(由中断状态改为非中断状态);
处于运行期且非阻塞的状态的线程,直接调用Thread.interrupt()
中断线程是不会得到任响应的。
对于synchronized来说,如果一个线程在等待锁,那么结果只有两种,要么它获得这把锁继续执行,要么它就保存等待,即使调用中断线程的方法,也不会生效。
5.3 等待唤醒与synchronized
所谓等待唤醒机制本篇主要指的是notify/notifyAll和wait方法,在使用这3个方法时,必须处于synchronized代码块或者synchronized方法中,否则就会抛出IllegalMonitorStateException异常,这是因为调用这几个方法前必须拿到当前对象的监视器monitor对象,也就是说notify/notifyAll和wait方法依赖于monitor对象。
参考资料: