一、synchronized基本使用
上篇文章详细讲解了volatile关键字,我们知道volatile关键字可以保证共享变量的可见性和有序性,但并不能保证原子性。如果既想保证共享变量的可见性和有序性,又想保证原子性,那么synchronized关键字是一个不错的选择。
synchronized的使用很简单,可以用它来修饰实例方法和静态方法,也可以用来修饰代码块。值的注意的是synchronized是一个对象锁,也就是它锁的是一个对象。因此,无论使用哪一种方法,synchronized都需要有一个锁对象
1.修饰实例方法
synchronized修饰实例方法只需要在方法上加上synchronized关键字即可。
arduino
public synchronized void add(){
i++;
}
此时,synchronized加锁的对象就是这个方法所在实例的本身。
2.修饰静态方法
synchronized修饰静态方法的使用与实例方法并无差别,在静态方法上加上synchronized关键字即可
arduino
public static synchronized void add(){
i++;
}
此时,synchronized加锁的对象为当前静态方法所在类的Class对象。
3.修饰代码块
synchronized修饰代码块需要传入一个对象。
csharp
public void add() {f
synchronized (this) {
i++;
}
}
二、Java对象头与Monitor对象
oop-klass
简单地说,一个Java类在JVM中被拆分为了两个部分:数据和描述信息,分别对应OOP和Klass。OOP表示java对象应该承载的数据,而Klass表示描述对象有多大,函数地址,对象大小,静态区域大小。
oop
Ordinary Object Pointer (普通对象指针),它用来表示对象的实例信息,看起来像个指针实际上是藏在指针里的对象。Klass是在class文件在加载过程中创建的,OOP则是在Java程序运行过程中new对象时创建的。
一个OOP对象包含以下几个部分:
Klass
Klass包含元数据和方法信息,用来描述Java类。 Klass主要有两个功能:
- 实现语言层面的Java类
- 实现Java对象的分发功能
一般jvm在加载class文件时,会在方法区创建InstanceKlass,表示其元数据,包括常量池、字段、方法等。
这里着重介绍InstanceKlass的两个字段:
- _prototype_header :原型头,用于用于标识Mark Word 原型,在对象被创建出来以后,会从***_prototype_header*** 拷贝数据到对象头的Mark Word中。
- revocation_count:撤销计数器,每次该class的对象发生偏向锁撤销操作时,计数器会自增1,当达到批量重偏向阈值(默认20)时,会执行批量重偏向;当达到批量撤销的阈值(默认40)时,会执行批量撤销。
java
class Model
{
public static int a = 1;
public int b;
public Model(int b) {
this.b = b;
}
}
public static void main(String[] args) {
int c = 10;
Model modelA = new Model(2);
Model modelB = new Model(3);
}
上述代码的OOP-Klass模型如下所示
在JVM中,对象在内存中存储的布局可以分为三个区域,分别是对象头、实例数据以及填充数据。
- 实例数据 存放类的属性数据信息,包括父类的属性信息,这部分内存按4字节对齐。
- 填充数据 由于虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐。
- 对象头 在HotSpot虚拟机中,对象头又被分为两部分,分别为:Mark Word(标记字段)、Class Pointer(类型指针)。如果是数组,那么还会有数组长度。
1.对象头
java的对象头由以下三部分组成:
1,Mark Word
2,指向类的指针
3,数组长度(只有数组对象才有)
1.1 Mark Word
Mark Word记录了对象和锁有关的信息,当这个对象被synchronized关键字当成同步锁时,围绕这个锁的一系列操作都和Mark Word有关。
Mark Word在32位JVM中的长度是32bit,在64位JVM中长度是64bit。 Mark Word 在32位 JVM 中:
Mark Word 在64位 JVM 中:
-
锁标志位(lock) 区分锁状态,11时表示对象待GC回收状态, 只有最后2位锁标识(11)有效。
-
biased_lock 是否偏向锁,由于无锁和偏向锁的锁标识都是 01,没办法区分,这里引入一位的偏向锁标识位。
-
分代年龄(age) 表示对象被GC的次数,当该次数到达阈值的时候,对象就会转移到老年代。
-
对象的hashcode(hash) 运行期间调用System.identityHashCode()来计算,延迟计算,并把结果赋值到这里。当对象加锁后,计算的结果31位不够表示,在偏向锁,轻量锁,重量锁,hashcode会被转移到Monitor中。
-
偏向锁的线程ID(JavaThread): 偏向模式的时候,当某个线程持有对象的时候,对象这里就会被置为该线程的ID。 在后面的操作中,就无需再进行尝试获取锁的动作。
-
epoch 偏向锁在CAS锁操作过程中,偏向性标识,表示对象更偏向哪个锁。
-
ptr_to_lock_record 轻量级锁状态下,指向栈中锁记录的指针。当锁获取是无竞争的时,JVM使用原子操作而不是OS互斥。这种技术称为轻量级锁定。在轻量级锁定的情况下,JVM通过CAS操作在对象的标题字中设置指向锁记录的指针。
-
ptr_to_heavyweight_monitor 重量级锁状态下,指向对象监视器Monitor的指针。如果两个不同的线程同时在同一个对象上竞争,则必须将轻量级锁定升级到Monitor以管理等待的线程。在重量级锁定的情况下,JVM在对象的ptr_to_heavyweight_monitor设置指向Monitor的指针。
1.2 指向类的指针
该指针在32位JVM中的长度是32bit,在64位JVM中长度是64bit。
Java对象的类数据保存在方法区。
1.3 数组长度
只有数组对象保存了这部分数据。
该数据在32位和64位JVM中长度都是32bit。
2.Monitor对象
Monitor对象被称为管程或者监视器锁。在Java中,每一个对象实例都会关联一个Monitor对象。这个Monitor对象既可以与对象一起创建销毁,也可以在线程试图获取对象锁时自动生成。当这个Monitor对象被线程持有后,它便处于锁定状态。
在HotSpot虚拟机中,Monitor是由ObjectMonitor实现的,它是一个使用C++实现的类,主要数据结构如下:
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 ; // 阻塞队列,线程被唤醒后根据决策判读是放入cxq还是EntryList
FreeNext = NULL ;
_EntryList = NULL ; // 没有抢到锁的线程会被放到这个队列
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}
ObjectMonitor中有五个重要部分,分别为_ower,_WaitSet,_cxq,_EntryList和count。
- _ower 用来指向持有monitor的线程,它的初始值为NULL,表示当前没有任何线程持有monitor。当一个线程成功持有该锁之后会保存线程的ID标识,等到线程释放锁后_ower又会被重置为NULL;
- _WaitSet 调用了锁对象的wait方法后的线程会被加入到这个队列中;
- _cxq 是一个阻塞队列,线程被唤醒后根据决策判读是放入cxq还是EntryList;
- _EntryList 没有抢到锁的线程会被放到这个队列;
- count 用于记录线程获取锁的次数,成功获取到锁后count会加1,释放锁时count减1。
- 当多个线程同时访问同步代码块时,首先会进入到EntryList中,然后通过CAS的方式尝试将Monitor中的owner字段设置为当前线程,同时count加1,若发现之前的owner的值就是指向当前线程的,recursions也需要加1。如果CAS尝试获取锁失败,则进入到EntryList中。
- 当获取锁的线程调用
wait()
方法,则会将owner设置为null,同时count减1,recursions减1,当前线程加入到WaitSet中,等待被唤醒。 - 当前线程执行完同步代码块时,则会释放锁,count减1,recursions减1。当recursions的值为0时,说明线程已经释放了锁。
三、synchronized底层实现原理
在JDK1.6之前,synchronized属于重量级,是一个效率比较低下的锁,因为监视器锁(monitor)是依赖于底层的操作系统的Mutex Lock来实现的,挂起线程和恢复线程都需要转入内核态去完成,阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态切换需要耗费处理器时间,时间成本相对较高。
在JDK1.6后,JVM为了提高锁的获取与释放效率对 synchronized 进行了优化,引入了偏向锁和轻量级锁,所以锁的状态就有了四种(无锁、偏向锁、轻量级锁、重量级锁),会随着竞争的激烈而逐渐升级。
1. 锁的分类
锁状态 | 存储内容 | 标志位 |
---|---|---|
无锁 | 对象的hashCode、对象分代年龄、是否是偏向锁(0) | 01 |
偏向锁 | 向线程ID、偏向时间戳、对象分代年龄、是否是偏向锁(1) | 01 |
轻量级锁 | 指向栈中锁记录的指针 | 00 |
重量级锁 | 指向互斥量的指针 | 11 |
1.1 无锁
无锁是指没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功。
无锁的特点是修改操作会在循环内进行,线程会不断的尝试修改共享资源。如果没有冲突就修改成功并退出,否则就会继续循环尝试。如果有多个线程修改同一个值,必定会有一个线程能修改成功,而其他修改失败的线程会不断重试直到修改成功。
1.2 偏向锁
偏向锁,顾名思义,它会偏向于第一个访问锁的线程
- 如果在运行过程中,同步锁只有一个线程访问,不存在多线程争用的情况,则线程是不需要触发同步的,这种情况下,就会给线程加一个偏向锁。线程第二次到达同步代码块时,会判断此时持有锁的线程是否就是自己,如果是则正常往下执行。由于之前没有释放锁,这里也就不需要重新加锁。如果自始至终使用锁的线程只有一个,很明显偏向锁几乎没有额外开销,性能极高。
- 如果在运行过程中,遇到了其他线程抢占锁,则持有偏向锁的线程会被挂起,JVM会消除它身上的偏向锁,将锁恢复到标准的轻量级锁。偏向锁通过消除资源无竞争情况下的同步原语,进一步提高了程序的运行性能。一旦有第二个线程加入锁竞争,偏向锁就升级为轻量级锁(自旋锁)。升级为轻量级锁的时候需要撤销偏向锁,撤销偏向锁的时候会导致STW(stop the word)操作;
初次执行到synchronized代码块的时候,锁对象变成偏向锁(通过CAS修改对象头里的锁标志位),字面意思是"偏向于第一个获得它的线程"的锁。执行完同步代码块后,线程并不会主动释放偏向锁。当第二次到达同步代码块时,线程会判断此时持有锁的线程是否就是自己(持有锁的线程ID也在对象头里),如果是则正常往下执行。由于之前没有释放锁,这里也就不需要重新加锁。如果自始至终使用锁的线程只有一个,很明显偏向锁几乎没有额外开销,性能极高。
偏向锁是指当一段同步代码一直被同一个线程所访问时,即不存在多个线程的竞争时,那么该线程在后续访问时便会自动获得锁,从而降低获取锁带来的消耗,即提高性能。
当一个线程访问同步代码块并获取锁时,会在 Mark Word 里存储锁偏向的线程 ID。在线程进入和退出同步块时不再通过 CAS 操作来加锁和解锁,而是检测 Mark Word 里是否存储着指向当前线程的偏向锁。轻量级锁的获取及释放依赖多次 CAS 原子指令,而偏向锁只需要在置换 ThreadID 的时候依赖一次 CAS 原子指令即可。
偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程是不会主动释放偏向锁的。
关于偏向锁的撤销,需要等待全局安全点,即在某个时间点上没有字节码正在执行时,它会先暂停拥有偏向锁的线程,然后判断锁对象是否处于被锁定状态。如果线程不处于活动状态,则将对象头设置成无锁状态,并撤销偏向锁,恢复到无锁(标志位为01)或轻量级锁(标志位为00)的状态。
1.3 轻量级锁(自旋锁)
轻量级锁是指当锁是偏向锁的时候,却被另外的线程所访问,此时偏向锁就会升级为轻量级锁,其他线程会通过自旋(关于自旋的介绍见文末)的形式尝试获取锁,线程不会阻塞,从而提高性能。
轻量级锁的获取主要由两种情况:
① 当关闭偏向锁功能时;
② 由于多个线程竞争偏向锁导致偏向锁升级为轻量级锁。
一旦有第二个线程加入锁竞争,偏向锁就升级为轻量级锁(自旋锁)。这里要明确一下什么是锁竞争:如果多个线程轮流获取一个锁,但是每次获取锁的时候都很顺利,没有发生阻塞,那么就不存在锁竞争。只有当某线程尝试获取锁的时候,发现该锁已经被占用,只能等待其释放,这才发生了锁竞争。
在轻量级锁状态下继续锁竞争,没有抢到锁的线程将自旋,即不停地循环判断锁是否能够被成功获取。获取锁的操作,其实就是通过CAS修改对象头里的锁标志位。先比较当前锁标志位是否为"释放",如果是则将其设置为"锁定",比较并设置是原子性发生的。这就算抢到锁了,然后线程将当前锁的持有者信息修改为自己。
长时间的自旋操作是非常消耗资源的,一个线程持有锁,其他线程就只能在原地空耗CPU,执行不了任何有效的任务,这种现象叫做忙等(busy-waiting)。如果多个线程用一个锁,但是没有发生锁竞争,或者发生了很轻微的锁竞争,那么synchronized就用轻量级锁,允许短时间的忙等现象。这是一种折衷的想法,短时间的忙等,换取线程在用户态和内核态之间切换的开销。
1.4 重量级锁
重量级锁显然,此忙等是有限度的(有个计数器记录自旋次数,默认允许循环10次,可以通过虚拟机参数更改)。如果锁竞争情况严重,某个达到最大自旋次数的线程,会将轻量级锁升级为重量级锁(依然是CAS修改锁标志位,但不修改持有锁的线程ID)。当后续线程尝试获取锁时,发现被占用的锁是重量级锁,则直接将自己挂起(而不是忙等),等待将来被唤醒。
重量级锁是指当有一个线程获取锁之后,其余所有等待获取该锁的线程都会处于阻塞状态。
简言之,就是所有的控制权都交给了操作系统,由操作系统来负责线程间的调度和线程的状态变更。而这样会出现频繁地对线程运行状态的切换,线程的挂起和唤醒,从而消耗大量的系统资
实现原理
csharp
public void run() {
//其他操作.......
synchronized (this){ //this表示当前对象实例,这里还可以使用syncTest.class,表示class对象锁
for (int j = 0; j < 10000; j++) {
i++;
}
}
}
查看代码字节码指令如下:
csharp
1 dup
2 astore_1
3 monitorenter //进入同步代码块的指令
4 iconst_0
5 istore_2
6 iload_2
7 sipush 10000
10 if_icmpge 27 (+17)
13 getstatic #2 <com/company/syncTest.i>
16 iconst_1
17 iadd
18 putstatic #2 <com/company/syncTest.i>
21 iinc 2 by 1
24 goto 6 (-18)
27 aload_1
28 monitorexit //结束同步代码块的指令
29 goto 37 (+8)
32 astore_3
33 aload_1
34 monitorexit //遇到异常时执行的指令
35 aload_3
36 athrow
37 return
从上述字节码中可以看到同步代码块的实现是由monitorenter
和monitorexit
指令完成的,其中monitorenter
指令所在的位置是同步代码块开始的位置,第一个monitorexit
指令是用于正常结束同步代码块的指令,第二个monitorexit
指令是用于异常结束时所执行的释放Monitor指令。
synchronized作用于同步方法原理
arduino
private synchronized void add() {
i++;
}
查看字节码如下:
bash
0 getstatic #2 <com/company/syncTest.i>
3 iconst_1
4 iadd
5 putstatic #2 <com/company/syncTest.i>
8 return
发现这个没有monitorenter
和 monitorexit
这两个指令了,而在查看该方法的class文件的结构信息时发现了Access flags
后边的synchronized标识,该标识表明了该方法是一个同步方法。Java虚拟机通过该标识可以来辨别一个方法是否为同步方法,如果有该标识,线程将持有Monitor,在执行方法,最后释放Monitor。
原理大概就是这样,最后总结一下,面试中应该简洁地如何回答
synchroized
的底层原理这个问题。
答:Java虚拟机是通过进入和退出Monitor对象来实现代码块同步和方法同步的,代码块同步使用的是monitorenter
和 monitorexit
指令实现的,而方法同步是通过Access flags
后面的标识来确定该方法是否为同步方法。