synchronized详解
在Java中在语法层面上提供了synchronized关键字来实现互斥同。
一:Synchronized的使用
- 一把锁只能同时被一个线程获取,没有获得锁的线程只能阻塞等待
- synchronized修饰的方法,无论方法正常执行完毕还是抛出异常,都会释放锁
- 每个实例都对应有自己的一把锁(this),不同实例之间互不影响;例如锁对象是*.class以及synchronized修饰的是static方法的时候,所有对象公用同一把锁
1、对象锁
包括
方法锁
(默认锁对象为this,当前实例对象)和
同步代码块锁
(自己指定锁对象)
1.1、代码块形式:手动指定锁定对象,也可是是this,也可以是自定义的锁
示例1:同步代码块形式——
锁为this
public class SynchronizedObjectLock implements Runnable {
static SynchronizedObjectLock instence = new SynchronizedObjectLock();
@Override
public void run() {
// 同步代码块形式——锁为this,两个线程使用的锁是一样的,线程1必须要等到线程0释放了该锁后,才能执行
synchronized (this) {
System.out.println("我是线程" + Thread.currentThread().getName());
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "结束");
}
}
public static void main(String[] args) {
Thread t1 = new Thread(instence);
Thread t2 = new Thread(instence);
t1.start();
t2.start();
}
}
//输出结果:
我是线程Thread-0
Thread-0结束
我是线程Thread-1
Thread-1结束
示例2:手动指定锁定对象
public class SynchronizedObjectLock implements Runnable {
static SynchronizedObjectLock instence = new SynchronizedObjectLock();
// 创建2把锁
Object block1 = new Object();
Object block2 = new Object();
@Override
public void run() {
// 这个代码块使用的是第一把锁,当他释放后,后面的代码块由于使用的是第二把锁,因此可以马上执行
synchronized (block1) {
System.out.println("block1锁,我是线程" + Thread.currentThread().getName());
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("block1锁,"+Thread.currentThread().getName() + "结束");
}
synchronized (block2) {
System.out.println("block2锁,我是线程" + Thread.currentThread().getName());
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("block2锁,"+Thread.currentThread().getName() + "结束");
}
}
public static void main(String[] args) {
Thread t1 = new Thread(instence);
Thread t2 = new Thread(instence);
t1.start();
t2.start();
}
}
//输出结果
block1锁,我是线程Thread-0
block1锁,Thread-0结束
block2锁,我是线程Thread-0 // 可以看到当第一个线程在执行完第一段同步代码块之后,第二个同步代码块可以马上得到执行,因为他们使用的锁不是同一把
block1锁,我是线程Thread-1
block2锁,Thread-0结束
block1锁,Thread-1结束
block2锁,我是线程Thread-1
block2锁,Thread-1结束
1.2、方法锁形式:synchronized修饰普通方法,锁对象默认为this
示例:
public class SynchronizedObjectLock implements Runnable {
static SynchronizedObjectLock instence = new SynchronizedObjectLock();
@Override
public void run() {
method();
}
public synchronized void method() {
System.out.println("我是线程" + Thread.currentThread().getName());
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "结束");
}
public static void main(String[] args) {
Thread t1 = new Thread(instence);
Thread t2 = new Thread(instence);
t1.start();
t2.start();
}
}
//输出结果:
我是线程Thread-0
Thread-0结束
我是线程Thread-1
Thread-1结束
2、类锁
指synchronize修饰静态的方法或指定锁对象为Class对象
2.1、synchronize修饰静态方法
示例1:
public class SynchronizedObjectLock implements Runnable {
static SynchronizedObjectLock instence1 = new SynchronizedObjectLock();
static SynchronizedObjectLock instence2 = new SynchronizedObjectLock();
@Override
public void run() {
method();
}
// synchronized用在普通方法上,默认的锁就是this,当前实例
public synchronized void method() {
System.out.println("我是线程" + Thread.currentThread().getName());
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "结束");
}
public static void main(String[] args) {
// t1和t2对应的this是两个不同的实例,所以代码不会串行
Thread t1 = new Thread(instence1);
Thread t2 = new Thread(instence2);
t1.start();
t2.start();
}
}
//输出结果
我是线程Thread-0
我是线程Thread-1
Thread-1结束
Thread-0结束
示例2:
public class SynchronizedObjectLock implements Runnable {
static SynchronizedObjectLock instence1 = new SynchronizedObjectLock();
static SynchronizedObjectLock instence2 = new SynchronizedObjectLock();
@Override
public void run() {
method();
}
// synchronized用在静态方法上,默认的锁就是当前所在的Class类,所以无论是哪个线程访问它,需要的锁都只有一把
public static synchronized void method() {
System.out.println("我是线程" + Thread.currentThread().getName());
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "结束");
}
public static void main(String[] args) {
Thread t1 = new Thread(instence1);
Thread t2 = new Thread(instence2);
t1.start();
t2.start();
}
}
//输出结果
我是线程Thread-0
Thread-0结束
我是线程Thread-1
Thread-1结束
2.2、synchronized指定锁对象为Class对象
public class SynchronizedObjectLock implements Runnable {
static SynchronizedObjectLock instence1 = new SynchronizedObjectLock();
static SynchronizedObjectLock instence2 = new SynchronizedObjectLock();
@Override
public void run() {
// 所有线程需要的锁都是同一把
synchronized(SynchronizedObjectLock.class){
System.out.println("我是线程" + Thread.currentThread().getName());
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "结束");
}
}
public static void main(String[] args) {
Thread t1 = new Thread(instence1);
Thread t2 = new Thread(instence2);
t1.start();
t2.start();
}
}
//输出结果
我是线程Thread-0
Thread-0结束
我是线程Thread-1
Thread-1结束
二:Synchronized原理分析
1、深入JVM字节码
public class SynchronizedDemo2 {
Object object = new Object();
public void method1() {
synchronized (object) {
}
method2();
}
private static void method2() {
}
}
>javac SynchronizedDemo2.java
使用javap命令反编译查看.class文件的信息
>javap -verbose SynchronizedDemo2.class
监视器锁(Monitor 另一个名字叫管程)本质是依赖于底层的操作系统的 Mutex Lock(互斥锁)来实现的。
每个对象都存在着一个 monitor 与之关联
,对象与其 monitor 之间的关系有存在多种实现方式,如 monitor 可以与对象一起创建销毁或当线程试图获取对象锁时自动生成,但当一个 monitor 被某个线程持有后,它便处于锁定状态。
在 Java 虚拟机 (HotSpot) 中,Monitor 是基于 C++ 实现的,由 ObjectMonitor 实现的, 几个关键属性:
- _owner:指向持有 ObjectMonitor 对象的线程
- _WaitSet:存放处于 wait 状态的线程队列
- _EntryList:存放处于等待锁 block 状态的线程队列
- _recursions:锁的重入次数
-
count:用来记录该线程获取锁的次数
竞争过程:
当多个线程同时访问一段同步代码时,首先会进入
_EntryList
集合,当线程获取到对象的 monitor 后进入
_Owner
区域并把 monitor 中的 owner 变量设置为当前线程同时 monitor 中的计数器
count 加 1
。
若线程调用
wait()
方法,将释放当前持有的 monitor,
owner
变量恢复为 null,count 自减 1,同时该线程进入
WaitSet
集合中等待被唤醒。
若当前线程执行完毕也将释放
monitor
(锁)并复位变量的值,以便其他线程进入获取 monitor(锁)。
在 Java 中,最基本的互斥同步手段就是 synchronised关键字,经过编译之后会在同步块前后分别插入 monitorenter, monitorexit 这两个字节码指令,而这两个字节码指令都需要提供一个 reference 类型的参数来指定要锁定和解锁的对象(synchronised使用中已经分析过),具体表现如下所示:
- 在普通同步方法,reference 关联和锁定的是当前方法示例对象;
- 对于静态同步方法,reference 关联和锁定的是当前类的 class 对象;
- 在同步方法块中,reference 关联和锁定的是括号里制定的对象;
2、Java 对象头
synchronised 用的锁也存在 Java 对象头里,在 JVM 中,对象在内存的布局分为三块区域:对象头、实例数据、对其填充。
- 对象头:MarkWord 和 Metadata Address ,也就是图中对象标记和元数据指针;
- 实例对象:存放类的属性数据,包括父类的属性信息,如果是数组的实例部分还包括数组的长度,这部分内存按 4 字节对齐;
- 填充数据:由于虚拟机要求对象起始地址必须是 8 字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐;
对象头是 synchronised 实现的关键
,使用的锁对象是存储在 Java 对象头里的,jvm 中采用 2 个字宽(一个字宽代表 4 个字节,一个字节 8bit)来存储对象头(如果对象是数组则会分配 3 个字宽,多出来的 1 个字宽记录的是数组长度)。其主要结构是由
Mark Word 和 Class Metadata Address
组成。
- Mark word :存储对象的 hashCode、锁信息或分代年龄或 GC 标志等信息
- Class Metadata Address :类型指针指向对象的类元数据,JVM 通过这个指针确定该对象是哪个类的实例。
- Array length:数组的长度(如果当前对象是数组)
当某个对象被 synchronized 关键字当成同步锁时,那么围绕这个锁的一系列操作都和
Mark word
有关系。
锁标志位的表示意义:
- 锁标识 lock=00 表示轻量级锁
- 锁标识 lock=10 表示重量级锁
- 偏向锁标识 biased_lock=1 表示偏向锁
- 偏向锁标识 biased_lock=0 且锁标识=01 表示无锁状态
三:Monitor总结
- Monitor(监视器锁)本质是依赖于底层的操作系统的 Mutex Lock(互斥锁)来实现的。Mutex Lock 的切换需要从用户态转换到核心态中,因此状态转换需要耗费很多的处理器时间。所以 synchronized 是 Java 语言中的一个重量级操作。
- 多个线程访问同步代码块时,相当于去争抢对象监视器修改对象中的锁标识, ObjectMonitor 这个对象和线程争抢锁的逻辑有密切的关系。
- Java 中的每个对象都派生自 Object 类,而每个 Java Object 在 JVM 内部都有一个 native 的 C++对象进行对应。其次,线程在获取锁的时候,实际上就是获得一个监视器对象(monitor) ,monitor 可以认为是一个同步对象,所有的 Java 对象是天生携带 monitor。
- Synchronized的happens-before规则,即监视器锁规则:对同一个监视器的解锁,happens-before于对该监视器的加锁
四:可重入原理:加锁次数计数器
上面的demo中在执行完同步代码块之后紧接着再会去执行一个静态同步方法,而这个方法锁的对象依然就这个类对象,那么这个正在执行的线程还需要获取该锁吗? 答案是:
不需要获取该锁
,从上图中就可以看出来,执行静态同步方法的时候就只有一条monitorexit指令,并没有monitorenter获取锁的指令。这就是锁的重入性,即在同一锁程中,线程不需要再次获取同一把锁。
Synchronized先天具有重入性。
每个对象拥有一个计数器,当线程获取该对象锁后,计数器就会加一,释放锁后就会将计数器减一
。
五:JVM中锁的优化
通过上面分析我们知道Mutex Lock严重的影响程序的性能的,所以jdk6中对锁的实现引入了大量的优化,如**锁粗化(Lock Coarsening)、锁消除(Lock Elimination)、轻量级锁(Lightweight Locking)、偏向锁(Biased Locking)、适应性自旋(Adaptive Spinning)**等技术来减少锁操作的开销。
锁粗化(Lock Coarsening):也就是减少不必要的紧连在一起的unlock,lock操作,将多个连续的锁扩展成一个范围更大的锁。
锁消除(Lock Elimination):通过运行时JIT编译器的
逃逸分析
来消除一些没有在当前同步块以外被其他线程共享的数据的锁保护,通过逃逸分析也可以在线程本地Stack上进行对象空间的分配(同时还可以减少Heap上的垃圾收集开销)。
偏向锁(Biased Locking):是为了在无锁竞争的情况下避免在锁获取过程中执行不必要的CAS原子指令,因为CAS原子指令虽然相对于重量级锁来说开销比较小但还是存在非常可观的本地延迟。
适应性自旋(Adaptive Spinning):当线程在获取轻量级锁的过程中执行CAS操作失败时,在进入与monitor相关联的操作系统重量级锁(mutex semaphore)前会进入忙等待(Spinning)然后再次尝试,当尝试一定的次数后如果仍然没有成功则调用与该monitor关联的semaphore(即互斥锁)进入到阻塞状态。
轻量级锁(Lightweight Locking):这种锁实现的背后基于这样一种假设,即在真实的情况下我们程序中的大部分同步代码一般都处于无锁竞争状态(即单线程执行环境),在无锁竞争的情况下完全可以避免调用操作系统层面的重量级互斥锁,取而代之的是在monitorenter和monitorexit中只需要依靠一条CAS原子指令就可以完成锁的获取及释放。当存在锁竞争的情况下,执行CAS指令失败的线程将调用操作系统互斥锁进入到阻塞状态,当锁被释放的时候被唤醒(具体处理步骤下面详细讨论)。
在Java6里Synchronied同步锁,一共有四种状态、并且锁膨胀方向:
无锁 → 偏向锁 → 轻量级锁 → 重量级锁 (此过程是不可逆的)
1、锁粗化
原则上,我们都知道在加同步锁时,尽可能的将同步块的作用范围限制到尽量小的范围(只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变小。在存在锁同步竞争中,也可以使得等待锁的线程尽早的拿到锁)。
大部分上述情况是完美正确的,但是如果存在连串的一系列操作都对同一个对象反复加锁和解锁,甚至加锁操作时出现在循环体中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要地性能操作。
JVM会检测到这样一连串地操作都是对同一个对象加锁,那么JVM会将加锁同步地范围扩展(粗化)到整个一系列操作的 外部,使整个系列操作只需要加锁一次就可以了
2、锁消除
锁消除是指虚拟机即时编译器运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。
锁消除的主要判定依据来源于逃逸分析的数据支持。意思就是:JVM会判断再一段程序中的同步明显不会逃逸出去从而被其他线程访问到,那JVM就把它们当作栈上数据对待,认为这些数据时线程独有的,不需要加同步。此时就会进行锁消除。
当然在实际开发中,我们很清楚的知道那些地方时线程独有的,不需要加同步锁,但是在Java API中有很多方法都是加了同步的,那么此时JVM会判断这段代码是否需要加锁。如果数据并不会逃逸,则会进行锁消除。比如在操作String类型数据时,由于String是一个不可变类,对字符串的连接操作总是通过生成的新的String对象来进行的。因此Javac编译器会对String连接做自动优化。
public static String test03(String s1, String s2, String s3) {
String s = s1 + s2 + s3;
return s;
}
JVM判断该段代码并不会逃逸,则将该代码带默认为线程独有的资源,并不需要同步,所以执行了锁消除操作。
3、偏向锁
在大多实际环境下,锁不仅不存在多线程竞争,而且总是由
同一个线程多次获取
,那么在同一个线程反复获取所释放锁中,其中并还没有锁的竞争,那么这样
多次的获取锁和释放锁带来了很多不必要的性能开销和上下文切换。
为此Java6 中对Synchronized进行了优化,引入了偏向锁。当一个线程访问同步快并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和推出同步块时不需要进行CAS操作来加锁和解锁。只需要
检查对象头的Mark Word里是否存储着指向当前线程的偏向锁
。如果成功,表示线程已经获取到了锁。
偏向锁的撤销
偏向锁使用了一种等待竞争出现才会释放锁的机制。所以
当其他线程尝试获取偏向锁时,持有偏向锁的线程才会释放锁
。但是偏向锁的撤销需要等到全局安全点(就是当前线程没有正在执行的字节码)。它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着。如果线程不处于活动状态,直接将对象头设置为无锁状态。如果线程活着,JVM会遍历栈帧中的锁记录,栈帧中的锁记录和对象头要么偏向于其他线程,要么恢复到无锁状态或者标记对象不适合作为偏向锁。
4、轻量级锁
4.1、自旋锁
在没有加入锁优化时,Synchronized是一个非常“胖大”的家伙。在多线程竞争锁时,当一个线程获取锁时,它会阻塞所有正在竞争的线程,这样对性能带来了极大的影响。在挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作对系统的并发性能带来了很大的压力。同时HotSpot团队注意到在很多情况下,共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起和回复阻塞线程并不值得。在如今多处理器环境下,完全可以让另一个没有获取到锁的线程在门外等待一会(
自旋)
,但不放弃CPU的执行时间。等待持有锁的线程是否很快就会释放锁。为了让线程等待,我们只需要让线程执行一个忙循环(自旋)。
自旋锁早在JDK1.4 中就引入了,只是当时默认时关闭的。在JDK 1.6后默认为开启状态。自旋锁本质上与阻塞并不相同,先不考虑其对多处理器的要求,如果锁占用的时间非常的短,那么自旋锁的性能会非常的好,相反,其会带来更多的性能开销(因为在线程自旋时,始终会占用CPU的时间片,如果锁占用的时间太长,那么自旋的线程会白白消耗掉CPU资源)。因此自旋等待的时间必须要有一定的限度,如果自选超过了限定的次数仍然没有成功获取到锁,就应该使用传统的方式去挂起线程了,在JDK定义中,自旋锁默认的自旋次数为10次,用户可以使用参数-XX:PreBlockSpin来更改。
如果
线程锁在线程自旋刚结束就释放掉了锁,那么是不是有点得不偿失
。所以这时候我们需要更加聪明的锁来实现更加灵活的自旋。来提高并发的性能。(这里则需要
自适应自旋锁
!)
4.2、自适应自旋锁
在JDK 6中引入了自适应自旋锁。这时
自旋的时间不再固定了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的
。如果在同一个锁对象上,自旋等待刚刚成功获取过锁,并且持有锁的线程正在运行中,那么JVM会认为该锁自旋获取到锁的可能性很大,会自动增加等待时间。比如增加到100此循环。相反,如果对于某个锁,自旋很少成功获取锁。那再以后要获取这个锁时将可能省略掉自旋过程,以避免浪费处理器资源。有了自适应自旋,JVM对程序的锁的状态预测会越来越准确,JVM也会越来越聪明。
4.2、轻量级锁
在JDK 6之后引入的轻量级锁,需要注意的是轻量级锁并不是替代重量级锁的,而是对在大多数情况下同步块并不会有竞争出现提出的一种优化。它可以减少重量级锁对线程的阻塞带来地线程开销。从而提高并发性能。
对象头地内存布局,上面介绍Java对象头也详细介绍过。在对象头中(Object Header)存在两部分。第一部分用于存储对象自身的运行时数据,HashCode、GC 年龄、锁标记位、是否为偏向锁。等。一般为32位或者64位(视操作系统位数定)。官方称之为Mark Word,它是实现轻量级锁和偏向锁的关键。 另外一部分存储的是指向方法区对象类型数据的指针(Klass Point),如果对象是数组的话,还会有一个额外的部分用于存储数据的长度。
加锁
在线程执行同步块之前,JVM会先在当前线程的栈帧中创建一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝(JVM会将对象头中的Mark Word拷贝到锁记录中,官方称为Displaced Mark Ward)。
1、如果当前对象没有被锁定,那么锁标志位位01状态,JVM在执行当前线程时,首先会在当前线程栈帧中创建锁记录Lock Record的空间用于存储锁对象目前的Mark Word的拷贝
2、 然后,虚拟机使用CAS操作将标记字段Mark Word拷贝到锁记录中,并且将Mark Word更新为指向Lock Record的指针。如果更新成功了,那么这个线程就有用了该对象的锁,并且对象Mark Word的锁标志位更新为(Mark Word中最后的2bit)
00
,即表示此对象处于轻量级锁定状态。
3、如果这个更新操作失败,JVM会检查当前的Mark Word中是否存在指向当前线程的栈帧的指针,如果有,说明该锁已经被获取,可以直接调用。如果没有,则说明该锁被其他线程抢占了,
如果有两条以上的线程竞争同一个锁,那轻量级锁就不再有效,直接膨胀位重量级锁,没有获得锁的线程会被阻塞
。此时,锁的标志位为10.Mark Word中存储的时指向重量级锁的指针。
解锁
轻量级解锁时,会使用原子的CAS操作将Displaced Mark Word替换回到对象头中,如果成功,则表示没有发生竞争关系。如果失败,表示当前锁存在竞争关系。锁就会膨胀成重量级锁。两个线程同时争夺锁,导致锁膨胀。
六:锁的优缺点对比
七:Synchronized与Lock
synchronized的缺点:
- 效率低:锁的释放情况少,只有代码执行完毕或者异常结束才会释放锁;试图获取锁的时候不能设定超时,不能中断一个正在使用锁的线程,相对而言,Lock可以中断和设置超时
- 不够灵活:加锁和释放的时机单一,每个锁仅有一个单一的条件(某个对象),相对而言,读写锁更加灵活
- 无法知道是否成功获得锁,相对而言,Lock可以拿到状态,如果成功获取锁,…,如果获取失败,…
Lock的优点:
1、可中断锁-lockInterruptibly()响应中断
2、无阻塞获取锁-tryLock() 尝试获取锁,返回一个boolean值
3、锁超时响应-tryLock(long,TimeUtil)尝试获取锁,可以设置超时
4、Synchronized只有锁只与一个条件(是否获取锁)相关联,不灵活,Condition与Lock的结合解决
多线程竞争一个锁时,其余未得到锁的线程只能不停的尝试获得锁,而不能中断。高并发的情况下会导致性能下降。ReentrantLock的lockInterruptibly()方法可以优先考虑响应中断。 一个线程等待时间过长,它可以中断自己,然后ReentrantLock响应这个中断,不再让这个线程继续等待。有了这个机制,使用ReentrantLock时就不会像synchronized那样产生死锁了。
八:Synchronized使用注意
- 1、锁对象不能为空,因为锁的信息都保存在对象头里
- 2、作用域不宜过大,影响程序执行的速度,控制范围过大,编写代码也容易出错
- 3、避免死锁
- 4、在能选择的情况下,既不要用Lock也不要用synchronized关键字,尽量使用非阻塞同步CAS、java.util.concurrent包中的各种原子类,或者无同步方案,例如栈封闭、ThreadLocal、可重入代码等。
- 5、在满足业务的情况下,可以使用synchronized关键,因为代码量少,避免出错。
- 6、synchronized实际上是非公平的,新来的线程有可能立即获得监视器,而在等待区中等候已久的线程可能再次等待,这样有利于提高性能,但是也可能会导致饥饿现象。