1. 锁的概念
Java语言为了解决并发编程中存在的原子性、可见性和有序性问题,提供了一系列和并发处理相关的关键字,比如synchronized、volatile、final、concurren包等
2. Synchronized的基本使用
synchronized
是Java提供的一个并发控制的关键字。主要用法有两种,分别是同步方法和同步代码块。
synchronized
可以修饰方法,用法如下:
public class Demo{
//普通方法
public synchronized void functionA(){
//...
}
//静态方法
public static synchronized void functionB(){
//...
}
}
synchronized
修饰普通方法时,对当前调用对象上锁,可以理解为:
synchronized(this)
synchronized
修饰静态方法时,将对类本身上锁,可以理解为:
synchronized(Demo.class)
synchronized
也可以修饰代码块,用法如下:
public class Demo{
public void functionA(){
Object obj = new Object();
synchronized(obj){
//...
}
}
}
被synchronized
修饰的代码块及方法,不允许被多个线程同时访问。
3. Synchronized实现原理
synchronized
,是Java中用于并发情况下数据同步访问的一个重要关键字。当我们想要保证一个共享资源,在同一时间只会被一个线程访问到的时候,我们可以在底阿妈中使用synchronized
关键字对类或者对象加锁。那么synchronized
关键字到底如何实现上锁的呢?
3.1 Synchronized 修饰在代码块上
我们将下述代码进行反编译:
public class Main {
public static void main(String[] args) {
synchronized (Main.class) {
System.out.println("hello synchronized");
}
}
}
反编译后,我们将得到如下的字节码指令:
public class com.company.Main {
public com.company.Main();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public static void main(java.lang.String[]);
Code:
0: ldc #5 // class com/company/Main
2: dup
3: astore_1
4: monitorenter
5: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
8: ldc #6 // String hello synchronized
10: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
13: aload_1
14: monitorexit
15: goto 23
18: astore_2
19: aload_1
20: monitorexit
21: aload_2
22: athrow
23: return
我们主要关注 main() 方法中被synchronized
修饰的代码块的字节码指令:
public class com.company.Main {
public static void main(java.lang.String[]);
Code:
//...
4: monitorenter
//...
14: monitorexit
//...
20: monitorexit
编译后的代码中,出现了一些特别的字节码指令,其中monitorenter
字节码指令理解为加锁,monitorexit
字节码指令理解为释放锁。每个对象维护者一个记录着被锁次数的计数器(它将被记录在MarkWord中,后续会谈到)。
代码中有两处monitorexit
指令,第二处的monitorexit
指令将在出现异常时被调用,进行锁的释放。
上述指令也反映了:synchronized
修饰代码块的时候,不需要我们主动加锁和解锁的字段,因为它自动在代码块开始和结束的地方替我们补充了加锁和解锁的指令,这也是为什么synchronized
还被称为内置锁。
3.2 Synchronized 修饰在方法上
synchronized
除了修饰在代码块上,还可以修饰在方法上。如果synchronized
修饰在方法上,同步方法的常量池中会有一个ACC_SYNCHRONIZED
标志,当某个线程需要访问某个方法的时候,会检查是否有ACC_SYNCHRONIZED
标志,如果有,则首先需要获得monitor锁(监视器锁),然后才开始进入临界区,执行方法,方法执行之后再释放monitor锁(监视器锁)。
同样的,如果方法执行过程中遇到异常,并且在方法内部并没有处理该异常,那么再异常被抛到方法外面之前,monitor锁(监视器锁)将会被自动释放。
4. Synchronized具有原子性与可见性,但没有有序性
4.1 原子性,可见性,有序性概念
原子性
原子性,即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行
Java内存模型中,有read
,load
等指令直接保证原子性操作,如果需要更大范围的原子性保证,则可以通过lock
和unlock
来做块的同步,虚拟机提供了字节码指令monitorenter
和monitorexit
来隐式地使用lock
和unlock
这两个操作。反映到Java代码中,就是我们上述提到的synchronized
关键字。
可见性
可见性,即当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。
Java内存模型通过:1)在变量修改后,将新值同步回主内存;2)在变量读取之前,从主存刷新变量的值。这两种方式来实现可见性。
Java可以通过volatile
,synchronized
和final
来实现可见性。
有序性
有序性,即所有指令的执行顺序是有序的
Java可以通过volatile
关键字来保证有序性。
4.2 Synchronized 具有原子性
由synchronized
修饰的临界区代码是具有原子性的。可能有多个线程争抢执行同一个临界区的代码,但当一个线程持有该临界区的锁时,即使其他正在争抢的线程被线程调度分到时间片,也无法进入临界区。所有其他线程都要等到当前持有锁的线程执行完临界区代码并退出,才有可能在被调度时,争抢到锁并执行临界区代码。
4.3 Synchronized 具有可见性
被synchronized
修饰的代码,会在开始执行的时候调用字节码指令monitorenter
加锁,执行完成后,会调用字节码指令monitorexit
解锁。
为了保证可见性,有一条规则是这样的:“在unlock之前,必须把变量同步回主内存中”,也就是在对一个变量解锁之前,必须先把该变量同步回主存中,这样解锁后,后续线程就可以访问到被修改后的值,从而保证了可见性。
4.4 Synchronized 没有有序性
由于处理器优化和指令重排等,CPU有可能对代码进行乱序执行。而需要注意的是,synchronized
是无法禁止指令重排和处理器优化的。如果要阻止指令重排等来实现指令执行的有序性,则需要使用到volatile
,这不是本章要讨论的内容,不做过多赘述。
5. 对象的内存布局与锁升级
5.1 普通对象和数组独享的内存布局图
对象在实例化之后,是被存放在堆内存中的。这里的对象由三部分组成,如下图:
左边是普通对象,对象头(Object header)包含着MarkWord和类型指针。而数组对象的对象头还多包含了数组长度。
成员变量存储的都是对象的真正的有效数据,也就是各个成员变量属性字段的值,如果拥有父类,还会包含父类的成员变量。
具体对齐填充的规则与JVM有关,在Hotspot中。对象的大小要求向8Byte对齐,当对象长度不足8字节的整数倍时,需要再对象中进行填充操作。
5.1 Markword
在对象头中,MarkWord一共有64个bit,用于存储对象自身的运行时数据,标记对象处于一下状态中的某一种:
在jdk6之前,通过synchronized
关键字加锁的时候,无差别地使用重量级锁,重量级锁会使CPU在用户态和和心态之间频繁切换,有很大的系统消耗。后来随着synchronized
的不断优化,提出了锁升级的概念,在MarkWord中,通过锁的标志位来表示当前对象的锁状态。
5.3 锁升级
我们知道了MarkWord可以用于锁升级,那么锁升级的过程是如何的呢?
synchronized
不断优化中,引入了偏向锁,轻量级锁,重量级锁的概念,在不同的情况逐步进行锁膨胀。过程如下图:
5.3.1 偏向锁的开启
JVM参数可以开启偏向锁:
-XX:UseBiasedLocking
默认偏向锁在程序启动4s后开启。在JVM还没开启偏向锁的时候,如果用synchronized
修饰,将会直接升级为轻量级锁。
当偏向锁开启后,对象实例化后,还没有线程对它上锁(还没有线程调用的方法对该实例使用synchronized
修饰),将会是匿名偏向状态,线程ID(JavaThread* ) 指向 000…00000。也就是还没偏向任何一个线程。
当偏向锁开启后,第一个对该对象上锁(调用的方法中对该实例使用synchronized
修饰)的线程,将会把线程ID(JavaThread* )记录到 MarkWord中。此时MarkWord的偏向锁位被置为 1
,且锁标志位为 01
。 此时这个对象就可以理解为偏向该线程。
需要注意的是,如果计算过对象的
hashcode
,则对象无法进入偏向状态,只能直接进入轻、重量级锁。在轻量级锁中,对象的hashcode
存在Lock Record
中,在重量级锁中,对象的hashcode
被记录在Objectmonitor
中。
问:为什么需要偏向锁?
答:有的对象可能大多使用的场合都只在一个线程中运行,只有少数情况会遇到多线程的竞争,我们不需要直接设置竞争机制,或者直接上消耗资源的锁。我们可以通过偏向锁这样,只在MarkWord上加一个线程ID的标记的低消耗的方式,来标志对象正在被使用。当遇到争抢的时候,再依情况进行锁升级。例如StringBuffer
中很多方法都被synchronized
修饰,但是它的使用场景经常只在一个线程中执行。
5.3.2 偏向锁->轻量级锁
当只有一个线程访问偏向锁的时候,仅仅是把线程ID记录到MarkWord中,没有额外的系统消耗。但是,当有新的线程也需要对该对象上锁,而且发现该对象已经被别的线程持有偏向锁(观察到MarkWord中有别的线程ID),此时为竞争升级为轻度竞争。
系统将会撤销偏向锁,并将锁升级为轻量级锁(自旋锁)。
轻量级锁简单地将对象头部作为指针,指向持有锁的线程堆栈的内部,来判断一个线程是否持有对象锁。线程的Lock Record
记录着锁的记录,各个线程自旋争抢,把Lock Record
写到MarkWord
为争抢成功。别的线程只能CAS自旋,不断检查锁是否被释放。
问:有偏向锁,为什么还需要轻量级锁?
答:偏向锁在撤销的时候会消耗系统资源,在争抢激烈的时候,效率没有轻量级锁高。
问:为什么JVM默认一开始不打开偏向锁?
答:JVM启动过程中会有很多线程争抢,所以默认启动时不打开偏向锁,而是默认等待4s过后才打开偏向锁。
5.3.3 轻量级锁 -> 重量级锁
当发生重度竞争时,即1)有线程超过10次自旋, 2)自旋线程数超过了CPU核数的一般。JVM将会把锁升级为重量级锁。
重量级锁需要内核态的参与, JVM 向操作系统申请ObjectMonitor
,这是一个C++对象,需要内核态才能访问。
重量级锁拥有等待队列,没有争抢到锁的线程不再自旋等待,而是直接进入阻塞状态,等待被唤醒。再次启动去争取锁的过程将比较耗时。
问:有轻量级锁,为什么还需要重量级锁?
答:线程自旋占CPU资源,重度竞争的时候,自旋的时间很长,或者自旋的线程很多,CPU花了更多时间在线程切换上。重量级锁通过内核态参与,将没有争抢到锁的线程阻塞,排队等待,减轻了系统资源的消耗。
5.3.4 代码中如何选择锁状态的?
我们前面说到,Java通过synchronized
关键字对类或者对象上锁,在字节码指令中会有monitorenter
和monitorexit
,在底层代码中,monitorenter
首先判断偏向锁是否开启,尝试加偏向锁:
fast_enter();
如果成功,就将对象上偏向锁。
如果失败,将会尝试加轻量级锁:
slow_enter();
如果失败,将会直接通过内核态申请重量级锁:
inflate();