1、相关概念名词
1.1 竞争
竞争现象就是指多个线程共同访问一个对象,同一时刻有两个及两个以上的线程对对象进行写操作,此时可能会出现与预期不一致的结果(比如同一时刻有100个线程对一个临界变量进行+1操作,最终结果并不是100,而是小于100的数),这种现象就是竞争。
这里举个例子,构建一个银行类,里面的成员变量是银行的余额,方法是转钱(输入取走的金额,返回剩余的金额),构造函数里传入一个参数初始化银行账户的金额,注意里面的if-else情况,如下:
public class Bank {
@Getter
private volatile int remainMoney = 0;
public Bank(int initMoney)
{
this.remainMoney = initMoney;
}
public int transferOut(int transferOutMoney)
{
if (transferOutMoney > this.remainMoney)
{
System.out.println(Thread.currentThread().getName() + " 转出的金额大于银行剩余金额");
return -1;
}
else if (transferOutMoney < 0)
{
System.out.println(Thread.currentThread().getName() + " 非法输入,转出的金额小于0");
return -2;
}
else if (this.remainMoney < 0)
{
System.out.println(Thread.currentThread().getName() + " 银行剩余金额小于0");
return -3;
}
else {
// 这里让线程sleep一秒,容易触发竞争场景
try {
Thread.sleep(1000);
}
catch (InterruptedException e)
{
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + " 成功从银行转出金额:" + transferOutMoney);
this.remainMoney -= transferOutMoney;
return this.remainMoney;
}
}
}
写个main方法测一测,初始化一个Bank对象(初始值为1000),异步起两个线程,都执行从这个Bank对象里取走800块这个操作:
public class Main {
public static void main(String[] args) {
int initMoney = 1000;
Bank bank = new Bank(initMoney);
new Thread(() -> System.out.println(bank.transferOut(800))).start();
new Thread(() -> System.out.println(bank.transferOut(800))).start();
}
}
运行结果如下:
Thread-0 成功从银行转出金额:800
200
Thread-1 成功从银行转出金额:800
-600
正常来讲,Thread-0从1000里取出800后,Thread-1再从剩下的200里取800时应该进入第一个if判断句返回-1,但实际上Thread-1进入了else语句依然成功取走800,这就是竞争产生的与预期不一致的情况。
产生这个问题的原因就是Thread-0进入else后,sleep了1秒,此时账户余额并没有立即减少800;在Thread-0 sleep的时候Thread-1又调用了transferMoney方法,此时由于账户里金额依然是1000,Thread-1并没有走第一个if判断句返回-1,而是进入else语句,这个时候Thread-0休眠完毕进入就绪状态,重新获得CPU时间片后执行扣钱操作,银行余额为200;Thread-1 sleep完后依然继续执行else语句里的扣钱操作,将余额200再减去800,剩下-600打印出来。例子里else语句里的sleep1秒很关键。
1.2 同步
针对竞争这种现象,达到同一时刻内仅有一个线程访问某个临界变量(共享变量)的目的,叫同步。在上面的Bank例子中,同步即为同一时刻仅有一个线程调用Bank实例的transferMoney方法取钱,其他线程只能等到这个线程取完钱后再进入。
1.3 对象锁
为了达到同步的目的,Java提出了锁的概念,确切地讲是互斥锁。java里每一个对象都对应一个锁(或者叫监视器),这个锁称之为对象锁。同一时刻多个线程访问一个对象时,仅有一个线程可以获得这个对象的对象锁,获得了对象锁的线程可以访问对象里的临界资源(公共资源),而其他线程只能等待,直到该线程释放了对象锁,其他线程才有机会获得该对象锁访问临界资源。
线程释放对象锁的情况有两种:
- 线程执行完任务,正常释放对象锁;
-
2、synchronized的使用方法
在面对竞争这种现象时,为了达到同步的目的,我们可以使用JDK中的关键字synchronized,具体有以下几种使用方法:
将方法定义成synchronized;
- 用synchronized修改代码块(同步块、同步阻塞);
-
2.1 synchronized方法
为了让第一节中介绍的银行转账的接口调用达到同步的效果,最简单的方法就是将可能产生竞争场景的方法声明为synchronized,即Bank实例的transferMoney方法,如下:
public class Bank { @Getter private volatile int remainMoney = 0; public Bank(int initMoney) { this.remainMoney = initMoney; } public synchronized int transferOut(int transferOutMoney) { if (transferOutMoney > this.remainMoney) { System.out.println(Thread.currentThread().getName() + " 转出的金额大于银行剩余金额"); return -1; } else if (transferOutMoney < 0) { System.out.println(Thread.currentThread().getName() + " 非法输入,转出的金额小于0"); return -2; } else if (this.remainMoney < 0) { System.out.println(Thread.currentThread().getName() + " 银行剩余金额小于0"); return -3; } else { // 这里让线程sleep一秒,容易触发竞争场景 try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getName() + " 成功从银行转出金额:" + transferOutMoney); this.remainMoney -= transferOutMoney; return this.remainMoney; } } }
此时再执行main方法,结果如下:
Thread-0 成功从银行转出金额:800 200 Thread-1 转出的金额大于银行剩余金额 -1
从结果可以看出,Thread-0成功取出了800后,Thread-1并没有再取出800,而是进入第一个if语句,返回-1,实现了同步。
当一个线程调用了某个对象的synchronized方法时,该线程就获得了这个对象的同步锁,其他线程暂时无法访问这个同步方法(即synchronized方法),只有当该线程执行结束或者抛出异常释放了这个对象的同步锁后,其他线程才有机会获得这个对象的同步锁调用对象的同步方法。2.2 synchronized块
synchronized块又叫同步阻塞,也是实现同步的一种方式,如下:
synchronized(this) { 涉及临界资源的操作 } 或者 Object obj = new Object(); synchorized(obj) { 涉及临界资源的操作 }
synchronized块相比synchronized方法提供了更加细粒度的同步设置,因为将方法设置成synchronized调用方法的开销会很大(尤其是在JDK1.6对synchronized优化之前),synchronized块只在方法里对需要设置同步的代码区域提供同步保护,有兴趣的可以将方法调用的时间统计出来比较一下,synchronized块提供同步的代码如下:
public class Bank { @Getter private volatile int remainMoney = 0; public Bank(int initMoney) { this.remainMoney = initMoney; } public int transferOut(int transferOutMoney) { synchronized (this) { if (transferOutMoney > this.remainMoney) { System.out.println(Thread.currentThread().getName() + " 转出的金额大于银行剩余金额"); return -1; } else if (transferOutMoney < 0) { System.out.println(Thread.currentThread().getName() + " 非法输入,转出的金额小于0"); return -2; } else if (this.remainMoney < 0) { System.out.println(Thread.currentThread().getName() + " 银行剩余金额小于0"); return -3; } else { // 这里让线程sleep一秒,容易触发竞争场景 try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getName() + " 成功从银行转出金额:" + transferOutMoney); this.remainMoney -= transferOutMoney; return this.remainMoney; } } } }
实际上就是将transferMoney方法里的代码都包含进synchronized块中,结果跟2里实现了同步的一样。
2.2 synchronzied静态方法
锁还可以这么分种类:局部锁(对象锁)和全局锁(类锁),synchronized同步块和synchronized修饰方法获取的锁可以理解为局部锁。类锁也可以理解为对象锁,即该类的Class对象对应的对象锁。当一个线程调用某个类的static synchorized方法时,其他线程不能同时访问该类的所有被static synchorized修饰的方法,但是可以同时访问该类的仅被synchronized修饰的方法和普通方法。
举个例子,之前的Bank实例都是同一个,现在在main方法里new 2个Bank对象,再起两个异步线程分别执行不同的Bank实例里的transferMoney方法,transferMoney方法设置为synchronized,结果会是什么样子呢?public class Main { public static void main(String[] args) { int initMoney = 1000; Bank bank1 = new Bank(initMoney); Bank bank2 = new Bank(initMoney); new Thread(() -> System.out.println(bank1.transferOut(800))).start(); new Thread(() -> System.out.println(bank2.transferOut(800))).start(); } }
结果如下:
Thread-0 成功从银行转出金额:800 Thread-1 成功从银行转出金额:800 200 200
从结果看出,两个线程正常从各自的bank实例里取了200出来,因为对象锁是针对对象的,两个线程是从不同的Bank对象里取钱,肯定井水不犯河水。
如果此时将transferMoney方法声明为static synchorized,main方法跟上面的保持一致,结果还会是上面结果一致么?将Bank类稍微修改一下,如下:public class Bank { private static int remainMoney = 0; public Bank(int initMoney) { remainMoney = initMoney; } public static synchronized int transferOut(int transferOutMoney) { if (transferOutMoney > remainMoney) { System.out.println(Thread.currentThread().getName() + " 转出的金额大于银行剩余金额"); return -1; } else if (transferOutMoney < 0) { System.out.println(Thread.currentThread().getName() + " 非法输入,转出的金额小于0"); return -2; } else if (remainMoney < 0) { System.out.println(Thread.currentThread().getName() + " 银行剩余金额小于0"); return -3; } else { // 这里让线程sleep一秒,容易触发竞争场景 try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getName() + " 成功从银行转出金额:" + transferOutMoney); remainMoney -= transferOutMoney; return remainMoney; } } }
main中的代码保持不变,运行main方法,结果如下:
Thread-0 成功从银行转出金额:800 200 Thread-1 转出的金额大于银行剩余金额 -1
从结果可以看出,虽然main方法里new了2个bank实例,两个异步线程分别调用这两个bank实例的静态的transferMoney方法取钱,但是结果却是同步的,这就是静态同步方法起的作用。将synchronized方法声明成static,线程调用该对象的静态同步方法时,获得的是这个类的class对象的锁,其他线程在调用这个类的不同对象的静态同步方法时会被阻塞,实现同步。
2.4 使用细节
当一个线程访问某个对象的synchronized方法或synchronized代码块时,其他线程对该对象的所有synchronized方法和synchronized代码块的访问将被阻塞;
- 当一个线程访问某个对象的synchronized方法或synchronized代码块时,其他线程对该对象的非synchronized方法和非synchronized代码块的访问不被阻塞;
- 一个线程调用该类的静态同步方法,另一个线程调用该类的实例的同步方法,此时两个线程互不干扰;
- 一个线程调用该类的静态同步方法1,另一个线程调用该类的静态同步方法2,此时这两个线程会实现同步;
简单点如下:
// 类的简要实现
public class Something{
public synchronized void syncA(){}
public synchronized void syncB(){}
public static synchronized void staticSyncA(){}
public static synchronized void staticSyncB(){}
public void ordinaryMethod(){}
}
- 情况1:x.syncA() 与 x.syncB()不能同时访问;
- 情况2:x.syncA() 与 y.syncA()可以同时访问;
- 情况3:x.syncA() 与 x.ordinaryMethod()可以同时访问;
- 情况4:Something.staticSyncA() 与 Something.staticSyncB()不能同时访问;
情况5:Something.staticSyncA() 与 x.syncA()可以同时访问。
3、synchronized底层原理
3.1 monitorenter和monitorexit指令
这两个指令是javac编译后的字节码指令,底层也是由对象锁(monitor)机制提供的。synchronized关键字可以作用在代码块上,也可以直接修饰方法,二者经过javac编译后的结果是不同的。
(1)同步代码块public void method(){ synchronized(new Object()){ do something... } }
synchronized关键字修饰同步代码块,经过javac编译之后会在同步块的前后分别形成monitorenter和monitorexit这两个字节码指令,对象锁(monitor)内维护着一个计数器,这个计数器的值代表这当前monitor锁被多少个线程拥有。这两个字节码指令的具体过程如下:
当进入同步代码块时,通过monitorenter指令获得Monitor对象的所有权,此时计数器的值count+1,Monitor对象的owner指向当前线程(Monitor对象的具体结构见3.2节);
- 如果当前线程已经是Monitor对象的owner了,再次进入synchronized代码块时,依然会将count+1;
- 当线程执行完synchronized代码块里的内容后,会执行monitorexit,对应的count-1,直到count为0时,才认为Monitor对象不再被线程占有,其他线程才可以尝试获取Monitor对象。
(2)同步方法
public synchronized void method(){
do something...
}
当线程调用被synchronized修饰的方法时,会判断一个标志位:ACC_SYNCHRONIZED。当方法是同步方法时,会有这个标志位,该标志位会去隐式调用那两个指令:monitorenter和monitorexit去获得和释放Monitor对象。
3.2 ObjectMonitor.hpp
3.1节介绍的是字节码层面上synchronized的底层原理,3.2节具体介绍一下在HotSpot虚拟机中是如何实现这种对象锁(Monitor)机制的。在HotSpot虚拟机中,monitor是由ObjectMonitor实现的,其源码是用C++来实现的,位于HotSpot虚拟机源码ObjectMonitor.hpp文件中。ObjectMonitor主要数据结构如下:
ObjectMonitor() {
_header = NULL;
_count = 0; // monitor进入数,就是上面介绍的monitor对象的计数器
_waiters = 0,
_recursions = 0; // 线程的重入次数
_object = NULL;
_owner = NULL; // 标识拥有该monitor的线程
_WaitSet = NULL; // 等待线程组成的双向循环链表,_WaitSet是第一个节点
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ; // 假定继承者
_cxq = NULL ; // 多线程竞争锁进入时的单向链表
FreeNext = NULL ;
_EntryList = NULL ; // 处于等待锁block状态的线程,会被加入到该列表
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}
上面的_count和_owner3.1节已经介绍了用途,下面重点介绍一下这三个链表:_cxq、_WaitSet、_EntryList以及_succ的概念:
- _cxq:竞争队列,逻辑上是一个后进先出(LIFO)的队列,实际是一个单向链表,每次新加入node时都是采用头插法。所有试图获取锁而被阻塞的线程首先会被放入这个队列中,cxq 链表的节点会在某个时刻被进一步转移到 _EntryList 链表中,具体时刻后面紧接着会介绍;
- _EntryList:_cxq队列中有资格成为候选资源的线程会被移入到该链表中,当持有锁的线程释放锁后,_EntryList 链表头结点的线程会被唤醒,然后该线程会尝试获取锁;
- _succ:当持有锁的线程释放锁后,_EntryList 链表头结点的线程会被唤醒,这个被唤醒的线程被称为successor,也叫假定继承者;
- _WaitSet:当我们调用 wait() 时,线程会被放入 _WaitSet,直到调用了 notify()/notifyAll() 后,线程才被重新放入 _cxq 或 _EntryList,默认放入 _cxq 链表头部。
ObjectMonitor这个几个概念之间的关系或者流转图如下:
上面提到_cxq 链表的节点会在某个时刻被进一步转移到 _EntryList 链表,那这个时刻到底是什么?这个参考的是全网最硬核的 synchronized 面试题深度解析这篇博客中的内容。通常来说,可以认为是在持有锁的线程释放锁时,该线程需要去唤醒链表中的下一个线程节点,此时如果检查到 _EntryList 为空,并且 _cxq 不为空时,会将 _cxq 链表的节点转移到 _EntryList 中。不过也不全是这样,_cxq 链表和 _EntryList 链表的排队策略的排队策略(QMode)和执行顺序如下:
- 当 QMode = 1 时,将 _cxq 链表的节点转移到 _EntryList 中的尾部,并且调换顺序,也就是原来在_cxq 排在前面的,会变到 _EntryList 尾部中排在后面的。除了 QMode = 1 之外,其他模式都是将 _cxq 链表的节点转移到 _EntryList 中,并且节点顺序一致;
- 当 QMode = 2 时,此时 _cxq 比 EntryList 优先级更高,如果此时 _cxq 不为空,则会首先唤醒 _cxq 链表的头结点。除了 QMode = 2 之外,其他模式都是唤醒 _EntryList 的头结点;
- 当 QMode = 3 时,无论 _EntryList 是否为空,都会直接将 _cxq 链表中的节点转移到 _EntryList 链表的末尾;
- 当 QMode = 4 时,无论 _EntryList 是否为空,都会直接将 _cxq 链表中的节点转移到 _EntryList 链表的头部。
至于获取被唤醒的线程为什么要通过两个链表(_cxq 链表和_EntryList链表),应该是降低热点问题,避免全部操作都集中在一个链表中。
4、JDK1.6 对synchronized锁的优化
JDK 1.6之前synchronized锁就是我们常说的“重量级”锁,JDK 1.6之后对synchronized进行了优化,引入了新的偏向锁和轻量级锁的概念,并有锁升级和锁降级的过程,使synchronized锁的性能与Lock锁的性能基本持平,因此JDK 1.6之后性能已不再是选择synchronized还是Lock锁的决定因素,而是根据业务场景决定。
4.1 对象头(Markword)
之前介绍JVM数据运行分区时讲了对象是在堆内存中分配空间的,这里再详细说一下对象在堆内存中的存储格式,如下图:
具体包括三个部分:
- 对象头:对象头包含两部分:
- Mark Word(标记字段):默认存储对象的HashCode、分代年龄、锁标志位信息等。Mark Word的数据结构在运行期间会根据锁标志位的变化而变化,是一个动态变化的结构;
- Klass Point(类型指针):对象指向它类元数据的指针,虚拟机通过这个指针来确定对象是哪个类的实例。
- 实例数据:这部分主要存放的是类的数据信息以及父类信息;
- 填充数据:由于虚拟机要求对象起始地址必须是8字节的整数倍,填充数据不是必须存在的,仅仅是为了字节对齐。
由于对象头的信息是与对象自身定义的数据没有关系的额外存储成本,因此考虑到JVM的空间效率,Mark Word 被设计成为一个非固定的数据结构,以便节省存储空间,它会根据对象本身的状态刷新自己的对象头信息,不同的状态下结构会有所不同,具体如下:
锁状态 | 25bit | 4bit | 1bit | 2bit | ||
---|---|---|---|---|---|---|
23bit | 2bit | 是否是偏向锁 | 锁标志位 | |||
无锁状态 | hashCode | 对象分代年龄 | 0 | 01 | ||
轻量级锁 | 执行栈中锁记录的指针 | 00 | ||||
重量级锁 | 执行栈中重量级锁记录的指针 | 10 | ||||
GC标记 | 空 | 11 | ||||
偏向锁 | 线程ID | Epoch | 对象分代年龄 | 1 | 01 |
4.2 偏向锁
之所以提出偏向锁的概念,是针对每次获取锁都是同一个线程这种场景。请求分为高峰期和非高峰期访问,使用synchronized的初衷肯定是为了避免多个线程竞争,但是不排除存在访问低峰期仅有一个线程访问,且每次都是同一个线程访问这种场景,针对这种场景JDK1.6优化时提出了偏向锁的概念。
4.2.1 匿名偏向锁
匿名偏向锁是该锁从未被获取过,也就是第一次偏向,此时锁对象的Mard Word中的线程ID为0。当第一个线程获取到偏向锁时,锁对象的Mark Word中的线程ID会从0修改为该线程的ID,之后Mark Word中的线程ID就不会为0了,因为撤销偏向锁时不会修改线程ID。
4.2.2 偏向锁加锁过程
- 当线程执行同步代码块试图获取偏向锁时,如果此时偏向锁状态为匿名偏向状态,即锁对象的Mard Word中的线程ID为0,会用CAS操作将当前线程的线程ID写入到锁对象的Mark Word中,并将偏向锁标志位置为1,此时当前线程就获取到了偏向锁;
- 下一个线程尝试获取锁时,会判断当前锁是否处于偏向锁状态,如果是偏向锁状态,并且Mark Word中的线程ID与当前尝试获取锁的线程ID一致,则立刻获取到偏向锁,不会做CAS操作;
- 下一个线程尝试获取锁时,如果是偏向锁状态,但是Mark Word中的线程ID与当前尝试获取锁的线程ID不一致,则表示这个偏向锁对象目前偏向于其他线程,此时需要撤销偏向锁模式(Revoke Rebias)。注意是撤销,不是解锁;
-
4.2.3 偏向锁撤销过程
偏向锁撤销需要等待全局安全点(Safe Point),在全局安全点时所有工作线程会停止字节码的执行,此时即STW(Stop The World);
- 进入锁膨胀流程时会先做一个判断:判断持有这个偏向锁的线程是否正在执行同步代码块,如果持有偏向锁的线程已经执行完同步代码块了,就会将偏向锁对象的Mark Word置为不可偏向的无锁状态,此时正在尝试获取偏向锁的线程可以通过CAS重新获取偏向锁;
- 如果持有偏向锁的线程还没有执行完同步代码块,尝试获取偏向锁的线程会遍历持有偏向锁线程中的栈帧,查到所有与当前偏向锁对象相关联的Lock Record,修改这些Lock Record里的内容为轻量级锁的内容,然后将最老的”(oldest)一个锁记录(Lock Record)的指针写到锁对象的Mark Word里,就好像是原来从没有使用过偏向锁,一直使用的是轻量级锁一样。此时轻量级锁由之前持有偏向锁的线程持有,继续将之前线程中没执行完的同步代码块执行完毕。
4.3 轻量级锁
提出轻量级锁的概念,是针对访问低峰期同一时刻仅有一个线程访问同步代码块,且可以是不同的线程。在真实生产环境下,并不是一直处于多线程竞争的,而是处于低竞争的状态,所以就发明出了轻量锁这种机制,大多数情况下线程A会先访问同步代码块,线程A访问完毕后线程B才会访问同步代码块,他们之间的访问类似于交替访问,并没有竞争问题,如果有竞争也只是轻微的竞争,只是几个线程之间竞争进入同步代码块,这时如果使用直接使用操作系统提供的mutex锁会显得大材小用,浪费空间。
如果对象是无锁状态,或者偏向锁升级,则会进入轻量级锁加锁流程,这里首先会在当前线程的栈帧中建立一个叫锁记录(Lock Record)的空间,这里就涉及到了两个概念:栈帧和Lock Record。4.3.1 栈帧和Lock Record
(1)栈帧
在介绍JVM的运行时数据分区里介绍过指向对象的引用、方法的局部变量时存储在线程的堆栈中的,堆栈是以栈帧为单位保存线程的状态的。栈帧是虚拟机进行方法调用和方法执行的基本数据结构,栈帧中存储了方法的局部变量表(局部变量表是栈帧中最主要的组成部分)、操作数栈、动态连接和方法返回地址等信息。JVM中一个方法从调用开始到执行完成,就对应着一个栈帧从虚拟机栈中入栈到出栈的过程。一个线程中可能存在多个调用,对应着多个栈帧,虚拟机堆栈中栈顶的栈帧才是当前栈帧,执行的字节码指令也是针对的当前栈帧操作的。
栈帧的概念结构如下图所示:
(2)锁记录(Lock Record)
进入同步代码块时,如果对象锁的状态为无锁状态,虚拟机会在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象的对象头中Mark Word的拷贝,官方称之为Displaced Mark Word。一个线程中可以同时拥有多个Lock Record。
Lock Record有两个重要属性:
- _displaced_header:轻量级锁中,会将对象锁的Mark Word部分拷贝一份放在Lock Record中,在Lock Record中的Mark Word的拷贝被称之为displaced_mark_word;
- _obj:指向对象锁。
Lock Record除了用于暂存对象的Mark Word之外,还有一个重要的功能是用于实现锁重入的计数器。当锁重入时,会新生成一个Lock Record用来记录,此时生成的Lock Record的_displaced_header为null。在解锁的时候,每解锁一次,就会移除一个Lock Record,移除时会判断_displaced_header是否为null。如果是则代表锁是重入的,不会真正执行解锁动作;否则代表这是第一次加锁时生成的Lock Record,此时会执行真正的解锁动作。
4.3.2 轻量级锁加锁流程
- 进入同步块的线程会在当前线程的栈帧中创建一个Lock Record;
- 拷贝对象锁中的对象头的Mark Word到当前栈帧中的Lock Record,Lock Record中的_displaced_header指向对象锁中的Mark Word的拷贝,如下图所示:
- 尝试使用CAS将锁对象中的Mark Word更新为指向当前线程栈帧中Lock Record的指针,并将Lock Record中的_obj指向当前锁对象,如果更新成功,表示当前线程获取到了锁,同时更新锁对象中的锁标志位为00,表示当前锁对象处于轻量级锁状态,如下图所示:
第3步中如果CAS更新失败,JVM会先检查锁对象中的Mark Word是否指向的是当前线程中的某个Lock Record,如果是则说明当前线程已经拥有了这个轻量级锁,直接执行同步代码块;否则说明有其他线程获取到了该锁对象,与当前线程是竞争关系,此时会直接升级为重量级锁。
4.3.3 轻量级锁解锁流程
使用CAS将当前Lock Record中的_displaced_header指向的Mark Word替换回锁对象的Mark Word中;
- 将Lock Record中的_obj置为null。
4.4 重量级锁
常说的synchronized是“重量级”锁,实际底层对应的是操作系统实现的互斥锁:mutex。基于mutex的synchronized重量级锁之所以效率低,原因是底层会涉及到操作系统内核态和用户态的切换,这个过程会消耗系统资源导致效率低下。
下面稍微介绍一下操作系统内核态和用户态相关的知识。
- 内核态:cpu可以访问内存的所有数据,包括外围设备,例如硬盘,网卡,cpu也可以将自己从一个程序切换到另一个程序;
- 用户态:只能受限的访问内存,且不允许访问外围设备,占用cpu的能力被剥夺,cpu资源可以被其他程序获取。
这里介绍的重量级锁跟第3节介绍的synchronized关键字底层原理是一致的,可以理解为第3节介绍的底层原理其实是重量级锁的底层原理。
4.4.1 重量级锁加锁流程
当轻量级锁出现竞争时,会膨胀为重量级锁。
- 分配一个ObjectMonitor,填充相关属性;
- 将锁对象的MarkWord指向1中生成的ObjectMonitor的地址,锁标志位修改成10;
- 当前线程尝试获取重量级锁,如果失败则进行自旋继续尝试获取锁;
- 如果当前线程多次自旋后均失败,则将该线程封装成ObjectWaiter,插入到_cxq链表中,当前线程进入阻塞状态;
- 当其他线程释放锁时,会唤醒链表中(一般是_EntryList链表)的头结点,被唤醒的头结点对应的线程会再次尝试获取锁,如果获取成功,将自己从链表中移除。
4.4.2 重量级锁解锁流程
- 先释放锁,将锁的持有者 owner 属性赋值为 null,此时其他线程已经可以获取到锁,例如自旋的线程;
- 从 EntryList 或 cxq 链表中唤醒下一个线程节点。
4.5 自旋锁
自旋锁不是在轻量级锁升级为重量级锁之前进行自选的,而是当锁状态是轻量级锁时,一旦出现线程竞争,会立刻膨胀为重量级锁,轻量级锁阶段不会进行自旋。自旋操作是在获取重量级锁的过程中进行的,如果获取重量级锁失败,会尝试自旋去获取锁。
自适应自旋锁有自旋次数限制,范围在:1000~5000。如果当次自旋获取锁成功,则会奖励自旋次数100次,如果当次自旋获取锁失败,则会惩罚扣掉次数200次。所以如果自旋一直成功,则JVM认为自旋的成功率很高,值得多自旋几次,因此增加了自旋的尝试次数。相反的,如果自旋一直失败,则JVM认为自旋只是在浪费时间,则尽量减少自旋。4.6 锁升级完整过程
锁升级完整流程如下图所示:4.7 网上一些博客需要纠正的点
(1)自旋锁
自旋操作不是发生在轻量级锁阶段,而是在尝试获取重量级锁的过程中自旋。
(2)锁降级
很多博客都说锁升级是不可逆过程,即不能锁降级,其实是可以锁降级的。具体的触发时机:在全局安全点(safepoint)中,执行清理任务的时候会触发尝试降级锁。当锁降级时,主要进行了以下操作:
- 恢复锁对象的 markword 对象头,使锁对象进入无锁状态;
- 重置 ObjectMonitor,然后将该 ObjectMonitor 放入全局空闲列表,等待后续使用。
5、synchronized对原子性、可见性和有序性的支持
5.1 原子性
原子性指的是一个或多个操作执行过程中不被打断的特性,被synchronized修饰的代码是具有原子性的,要么全部都能执行成功,要么都不成功。
synchronized无论是修饰代码块还是修饰方法,本质上都是获取监视器锁monitor(monitor enter 和 monitor exit指令)。获取了锁的线程就进入了临界区,锁释放之前别的线程都无法获得处理器资源,保证了不会发生时间片轮转,因此也就保证了原子性。
5.2 可见性
JMM中的happens-before原则支持了synchronized的可见性。JMM中关于synchronized有如下规定:
- 线程加锁时,必须清空工作内存中共享变量的值,从而使用共享变量时需要从主内存重新读取;
线程在解锁时,需要把工作内存中最新的共享变量的值写入到主存,以此来保证共享变量的可见性。
5.3 有序性
JMM中的as -if -serial 语义保证了synchronized的有序性。as-if-serial语句规定重排序要满足以下两个规则:
在单线程环境下不能改变程序执行的结果;
- 存在数据依赖关系代码(指令)片段的不允许重排序。
参考
Java多线程(三)—— synchronized关键字详解
Java多线程(四)—— synchronized关键字续
Java并发编程:synchronized
死磕synchronized底层实现
全网最硬核的 synchronized 面试题深度解析
Synchronized升级成重量级锁之后就下不来了?你错了!
白话Java锁—synchronized关键字
synchronized锁的优化
synchronized 关键字可以保证可见性吗?
字节面试官:synchronized能保证可见性吗