目录
概念
JDK官网中对synchronized关键字有如下定义,synchronized关键字可以实现一个简单的策略来防止线程干扰和内存一致性错误,如果一个对象对多线程是可见的,那么对该对象的所有读或者写都将通过同步的方式来进行。具体表现如下:
- synchronized关键字提供了一种锁机制,能够确保共享变量的互斥访问,从而防止数据不一致问题的出现。
- synchronized关键字包括monitor enter和monitor exit两个JVM指令,它能够保证在任何时候任何线程执行到monitor enter成功之前都必须从主内存中获取数据,而不是从缓存中,在monitor exit运行成功之后,共享变量被更新后的值必须刷入主内存
- synchronized的指令严格遵守java happens-before规则,一个monitor exit指令之前必定要有一个monitor enter
可重入性
synchronized和ReentrantLock都是可重入锁。当一个线程试图操作一个由其他线程持有的对象锁的临界资源时,将会处于阻塞状态,但当一个线程再次请求自己持有对象锁的临界资源时,这种情况属于重入锁。通俗一点讲就是说一个线程拥有了锁仍然还可以重复申请锁。
具体用法
synchronized修饰的三种对象分别为:静态方法,成员函数和静态代码块。
- 修饰实例方法,作用于当前实例加锁,进入同步代码前要获得当前实例的锁。
- 修饰静态方法,作用于当前类加锁,进入同步代码前要获得当前类的锁。
- 修饰代码块,指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象。
字节码实现
synchronized总的来说有两种形式上锁,一个是对方法上锁,一个是构造同步代码块。他们的底层实现其实都一样,在进入同步代码之前先获取锁,获取到锁之后锁的计数器+1,同步代码执行完锁的计数器-1,如果获取失败就阻塞式等待锁的释放。只是他们在同步块识别方式上有所不一样,从class字节码文件可以表现出来,一个是通过方法flags标志,一个是monitorenter和monitorexit指令操作。
同步代码块
反编译命令: javap -v SynTest.class
从反编译的同步代码块可以看到同步块是由monitorenter指令进入,然后monitorexit释放锁,在执行monitorenter之前需要尝试获取锁,如果这个对象没有被锁定,或者当前线程已经拥有了这个对象的锁,那么就把锁的计数器加1。当执行monitorexit指令时,锁的计数器也会减1。当获取锁失败时会被阻塞,一直等待锁被释放。
但是为什么会有两个monitorexit呢?其实第二个monitorexit是来处理异常的,仔细看反编译的字节码,正常情况下第一个monitorexit之后会执行goto
指令,而该指令转向的就是23行的return
,也就是说正常情况下只会执行第一个monitorexit释放锁,然后返回。而如果在执行中发生了异常,第二个monitorexit就起作用了,它是由编译器自动生成的,在发生异常时处理异常然后释放掉锁。
同部方法
该标志用来告诉JVM这是一个同步方法,在进入该方法之前先获取相应的锁,锁的计数器加1,方法结束后计数器-1,如果获取失败就阻塞住,直到该锁被释放。
对象内存布局
要理解synchronized锁原理,首先要理解Java的对象头和Monitor,在Jvm中对象是分三部分存在的:对象头,实例数据,对齐填充。
对象头
- Mark Word
Mark Word用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等等。Java对象头一般占有两个机器码(在32位虚拟机中,1个机器码等于4字节,也就是32bit)。详情如下图所示
- Class Metadata Address(类型指针)
类型指针,即是对象指向它的类的元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
- Array length
如果对象是一个Java数组,那在对象头中还必须有一块用于记录数组长度的数据。
Monitor
Monitor被翻译为监视器,每个Java对象都可以关联一个Monitor对象,如果使用synchronized给对象上锁(重量级)之后,该对象头的Mark Word中就被设置指向Monitor对象的指针。在hotspot虚拟机中,通过ObjectMonitor类来实现monitor。
synchronized加锁过程
- 开始Monitor中Owner为null
- 当Thread-2执行synchronized(obj)就会将Monitor的所有者Owner置为Thread-2,Monitor中只有一个Owner
- 在Thread-2上锁的过程中,如果Thread-3,Thread-4,Thread-5也来执行synchronized(obj),就会进入EntryList BLOCKED
- Thread-2执行完同步代码块的内容,然后通知EntryList中等待的线程来竞争锁,竞争时是非公平的
notify()/wait()/notifyAll()
在虚拟机规范中存在一个wait set(wait set又被称为线程休息室)的概念,至于该wait set是怎样的数据结构,JDK官方并没有给出明确的定义,不同厂家的JDK有着不同的实现方式,甚至相同的JDK厂家不同的版本也存在差异,但是不管怎样线程调用了某个对象的wait方法之后都会被加入与该对象monitor关联的wait set 中,并且释放monitor的所有权。
摘自《Java高并发编程详解》
API介绍
- object.wait() 让进入object监视器的线程到waitSet等待
- object.notify()在object上正在waitSet等待的线程中挑一个唤醒
- object.notifyAll()让object上正在waitSet等待的线程全部唤醒
wait()/notify()过程
- Thread-2作为Owner线程的持有者发现条件不满足,调用wait方法,即可进入WaitSet变为WAIZTING状态,并释放锁资源
- BLOCKED和WAITING的线程都处于阻塞状态,不占用CPU时间片
- BLOCKED线程会在Owner线程释放锁是被唤醒
- WAITING线程会在Owner线程调用notify或者notifyAll时被唤醒,但唤醒后并不意味着立刻获取锁,仍需进入EntryList重新竞争
Synchronized的优化
从Java5到Java6,HotSpot进行了各种锁优化技术,如自旋锁,锁消除,锁膨胀,轻量级锁,重量级锁等。
轻量级锁
轻量级锁的使用场景:如果一个对象虽然有多线程访问,但多线程 访问的时间是错开的(也就是没有竞争),那么可以使用轻量级锁来优化。
轻量级锁对使用者是透明的,语法仍然是synchronized。
工作过程如下:
- 在代码即将进入同步块的时候,如果此时对象没有被锁定(锁标志位为“01”状态),虚拟机首先将当前线程的栈桢中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝。
- 让锁记录中Object reference指向对象,并尝试用cas替换Object的Mark Word,将Mark Word的值存入锁记录
- 如果cas替换成功,对象头中存储了锁记录地址和状态00,表示由该线程给对象加锁,如下所示:
- 如果cas失败,分两种情况
- 如果是其他线程已经持有了该Object的轻量级锁,这时表明有竞争,进入锁膨胀,锁的标志位改为“10”,此时Mark Word中存储的就是指向重量级锁的指针。
- 如果是自己执行了synchronized锁重入,那么再添加一条Lock Record作为重入的计数
- 当退出synchronized代码块(解锁时)如果有取值为null的锁记录,表示有重入,这时重置锁记录,表示重入计数减一
- 当退出synchronized代码块(解锁时)锁记录的值不为null,这时使用cas将Mark Word的值恢复给对象头
- 成功:则解锁成功
- 失败:说明轻量级锁进行了锁膨胀或已经升级为重量级锁,进入重量级锁的解锁流程
锁膨胀
如果在尝试加轻量级锁的过程中,CAS操作无法成功,这时一种情况就是有其他线程为对此对象上加上了轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁。
当Thread-1进行轻量级加锁时,Thread-0已经对该对象加了轻量级锁
这时Thread-1加轻量级锁失败(Mark word已经是00,不是无锁状态,所以失败),进入锁膨胀流程
即为Object对象申请Monitor锁,让Object指向重量级锁地址,同时让Monitor中Owner指向Thread-0,然后自己进入Monitor的EntryList BLOCKED中
当Thread-0退出同步块解锁时,使用cas将Mark Word的值恢复给对象头,失败,这时会进入重量级解锁流程,即按照Monitor地址找到Monitor对象,设置Owner为null,并唤醒EtryList中BLOCKED线程。
自旋优化
重量级锁竞争时,对性能影响最大的就是阻塞问题,挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作给Java虚拟机的并发性能带来了很大的压力,JDK1.4中引入了自旋锁优化,如果当前线程自旋成功(即这时候持锁线程已经推出了同步块,释放了锁),当前线程就可以避免阻塞
- 自旋成功的情况
- 自旋失败
- 如果线程中锁被占用的时间很长,自旋只会白白浪费CPU资源,如果自旋超过了限定的次数仍然没有成功获取锁,就会使用传统的方式去挂起线程。
- 在Java6之后自旋锁是自适应的,如果对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋。
- 自旋会占用CPU时间,单核CPU自旋就是浪费,多核CPU自旋才能发挥优势
- Java7之后不能控制是否开启自旋锁
偏向锁
轻量级锁在没有竞争时(只有本线程),每次重入仍然要执行CAS操作,因此Java6中引入了偏向锁来进一步优化,只有第一次使用CAS将线程ID设置到对象的Mark Word头之后发现这个线程ID是自己的就标识没有竞争,不用重新CAS,以后只要不发生竞争,这个对象就归该线程持有。
static final Object obj = new Object();
public static void m1(){
synchronized (obj){
// 同步块 A
m2();
}
}
public static void m2(){
synchronized (obj){
// 同步块 B
m3();
}
}
public static void m3(){
synchronized (obj){
// 同步块 c
}
}
轻量级锁和偏向锁对比如下:
一旦出现另外一个线程去尝试获取这个锁,偏向模式马上宣告结束,根据当前对象是否处于锁定的状态决定是否撤销偏向(偏向模式为0),撤销后标志位恢复到未锁定(标志位为01)或轻量级锁定(标志位为00)的状态。
该过程理解为,刚开始Object对象无锁,Thread-0执行到同步代码时给Object加偏向锁,在运行过程中,如果Thread-1过来尝试获取锁(这里暗示Object可能会其他线程占有),则把Object升级为轻量级锁,如果Thread-1再次来抢锁,则把Object升级为重量级锁。
ps:面对Thread-1过来抢锁,最后都要把锁升级为重量级锁。
锁消除
虚拟机即时编译器在运行时,对被检测到不可能存在共享数据竞争的锁进行消除。
锁粗化
如果jvm检测到有一串零碎的操作都对同一个对象加锁,将会把锁粗化到整个操作外部,如循环体。
参考资料:《深入理解Java虚拟机》
《Java并发78讲》
《Java高并发编程详解》