一、简介
synchronized 可以保证在同一时刻,只有一个线程可以执行某个方法或某个代码块,synchronized 把锁信息存放在对象头的MarkWord 中
。
synchronized 作用在非静态方法上是对方法的加锁,synchronized 作用在静态方法上是对当前的类加锁。
在早期的 JDK 版本中,synchronized 是一个重量级锁,保证线程的安全但是效率很低。后来对 synchronized 进行了优化,有了一个锁升级的过程:
无锁态(new)-->偏向锁-->轻量级锁(自旋锁)-->重量级锁
通过 MarkWord 中的 8 个字节也就是 64 位来记录锁信息。
二、实现原理
在 Java 代码中使用 synchronized 可是使用在代码块和方法中,根据 synchronized 用的位置可以有如下表所示这些使用场景:
这里的需要注意的是如果锁的是类对象的话,尽管 new 多个实例对象,依然会被锁住。
2.1、对象锁(monitor)机制
分析 synchronized 的具体底层实现:
public class SynchronizedDemo {
public static void main(String[] args) {
synchronized (SynchronizedDemo.class) {
System.out.println("hello synchronized!");
}
}
}
上述代码通过 synchronized 锁住当前类对象来进行同步,将 Java 代码进行编译之后通过 javap -v SynchronizedDemo .class
查看对应的 main 方法字节码如下:
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=1
0: ldc #2 // class com/codercc/chapter3/SynchronizedDemo
2: dup
3: astore_1
4: **monitorenter**
5: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
8: ldc #4 // String hello synchronized!
10: invokevirtual #5 // 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
从线程状态变化的角度来看,如果要想进入到同步块或者执行同步方法,都需要先获取到对象的 monitor
,如果获取不到则会变更为 BLOCKED 状态,对象,对象监视器,同步队列和线程状态的关系,具体过程如下图所示:
从上图可以看出任意线程对 Object 的访问,首先要获得 Object 的 monitor,如果获取失败,该线程就会进入到同步队列中,线程状态变为 BLOCKED。当 monitor 持有者释放后,在同步队列中的线程才会有机会重新获取 monitor,才能继续执行。
2.2、synchronized 的 happens-before 关系
happens-before 规则,其中有一条就是监视器锁规则:对同一个监视器的解锁 happens-before 于对该监视器的加锁
。为了进一步了解 synchronized 的并发语义,通过示例代码分析这条 happens-before 规则,示例代码如下:
public class MonitorDemo {
private int a = 0;
public synchronized void writer() { // 1
a++; // 2
} // 3
public synchronized void reader() { // 4
int i = a; // 5
} // 6
}
在并发时,第 5 步操作中读取到的变量 a 的值是多少呢?这就需要通过 happens-before 规则来进行分析,示例代码的 happens-before 关系如下图所示:
上图中每一个箭头连接的两个节点就代表之间的 happens-before 关系,黑色的是通过程序顺序规则推导出来,通过监视器锁规则可以推导出线程 A 释放锁 happens-before 线程 B 加锁,即红色线表示。蓝色的线则是通过传递性规则进一步推导的 happens-before 关系。最终得到的结论就是操作2 happens-before 5
,通过这个关系可以得出什么?
根据 happens-before 的定义中的一条:如果 A happens-before B,则 A 的执行结果对 B 可见。那么在该示例代码中,线程 A 先对共享变量 A 进行加 1,由 2 happens-before 5 关系可知线程 A 的执行结果对线程 B 可见,即线程 B 所读取到的 a 的值为 1。
2.3、锁获取和锁释放的内存语义
JMM 核心为两个部分:happens-before 规则
以及内存抽象模型
。在分析完 synchronized 的 happens-before 关系后还是不太完整的,接下来看看基于 Java 内存抽象模型的 synchronized 的内存语义,具体过程如下图所示:
针对线程 A 的操作而言,从上图可以看出线程 A 会首先先从主内存中读取共享变量 a=0
的值然后将该变量拷贝到线程本地内存。然后基于该值进行数据操作后变量 a 变为 1,然后会将值写入到主内存中。
对线程 B 而言执行流程如上图所示。线程 B 获取锁的时候会强制从主内存中共享变量 a 的值,而此时变量 a 已经是最新值了。接下来线程 B 会将该值拷贝到工作内存中进行操作,同样的执行完操作后也会重新写入到主内存中。
从横向来看,线程 A 和线程 B 都是基于主内存中的共享变量互相感知到对方的数据操作,并基于共享变量来完成并发实体中的协同工作,整个过程就好像线程 A 给线程 B 发送了一个数据变更的通知,这种通信机制就是基于共享内存的并发模型结构导致
。
三、synchronized 优化
synchronized 最大的特征就是在同一时刻只有一个线程能够获得对象 monitor,从而确保当前线程能够执行到相应的同步逻辑,对线程之间而言表现为互斥性(排它性) 。自然而然这种同步方式会有效率相对低下的弊端,既然同步流程不能发生改变,那么能不能让每次获取锁的速度更快或者降低阻塞等待的概率呢?也就是通过局部的优化来提升系统整体的并发同步的效率
。比如去收银台付款的场景,之前的方式是大家都去排队,然后去纸币付款收银员找零。甚至有的时候付款的时候还需要在包里拿出钱包拿出钱,这个过程是比较耗时的。针对付款的流程,就可以通过线上化的手段来进行优化,在现在只需要通过支付宝扫描二维码就可以完成付款了,也省去了收银员找零的时间。尽管整个付款场景还是需要排队,但是因为付款(类似于获取锁释放锁)这个环节的优化导致耗时大大缩短,对收银台(系统整体并发效率)而言操作效率就极大的带来提升。如此类比,如果能对锁操作过程进行优化的话,也会对并发效率带来极大的提升。
3.1、CAS 操作
3.1.1、什么是 CAS
使用锁时,线程获取锁是一种悲观锁策略,即假设每一次执行临界区代码都会产生冲突,所以当前线程获取到锁的时候同时也会阻塞其他线程获取该锁。而 CAS 操作(又称为无锁操作)是一种乐观锁策略
,它假设所有线程访问共享资源的时候不会出现冲突,既然不会出现冲突自然而然就不会阻塞其他线程的操作。因此,线程就不会出现阻塞停顿的状态。那么,如果出现冲突了怎么办?无锁操作是使用 CAS(compare and swap) 又叫做比较交换来鉴别线程是否出现冲突,出现冲突就重试当前操作直到没有冲突为止。
3.1.2、CAS 的操作过程
CAS 比较交换的过程可以通俗的理解为 CAS(V,O,N)
,包含三个值分别为:V 内存地址存放的实际值
;O 预期的值(旧值)
;N 更新的新值
。当 V 和 O 相同时,也就是说旧值和内存中实际的值相同表明该值没有被其他线程更改过,即该旧值 O 就是目前来说最新的值了,自然而然可以将新值 N 赋值给 V。反之,V 和 O 不相同,表明该值已经被其他线程改过了则该旧值 O 不是最新版本的值了,所以不能将新值 N 赋给 V,返回 V 即可。当多个线程使用 CAS 操作一个变量时,只有一个线程会成功,并成功更新,其余会失败。失败的线程会重新尝试,当然也可以选择挂起线程。
CAS 的实现需要硬件指令集的支撑,在 JDK1.5 后虚拟机才可以使用处理器提供的 CMPXCHG
指令实现。
Synchronized VS CAS
:
元老级的 Synchronized (未优化前)最主要的问题是:在存在线程竞争的情况下会出现线程阻塞和唤醒锁带来的性能问题,因为这是一种互斥同步(阻塞同步)。而 CAS 并不是武断的将线程挂起,当 CAS 操作失败后会进行一定的尝试,而非进行耗时的挂起唤醒的操作,因此也叫做非阻塞同步。这是两者主要的区别。
3.1.3、CAS 的应用场景
在 J.U.C
包中利用 CAS 实现类有很多,可以说是支撑起整个 concurrency 包的实现,在 Lock 实现中会有 CAS 改变 state 变量,在 atomic 包中的实现类也几乎都是用 CAS 实现。
3.1.4、CAS 的问题
- 1、ABA问题
因为 CAS 会检查旧值有没有变化,这里存在这样一个有意思的问题。比如一个旧值 A 变为了成 B,然后再变成 A,刚好在做 CAS 时检查发现旧值并没有变化依然为 A,但是实际上的确发生了变化。解决方案可以沿袭数据库中常用的乐观锁方式,添加一个版本号可以解决。原来的变化路径 A->B->A 就变成了 1A->2B->3C。在 java 1.5 后的 atomic 包中提供了AtomicStampedReference
来解决 ABA 问题,解决思路就是这样的。 - 2、自旋时间过长
使用 CAS 时非阻塞同步,也就是说不会将线程挂起,会自旋(无非就是一个死循环)进行下一次尝试,如果这里自旋时间过长对性能是很大的消耗。如果 JVM 能支持处理器提供的pause
指令,那么在效率上会有一定的提升 - 3、只能保证一个共享变量的原子操作
当对一个共享变量执行操作时 CAS 能保证其原子性,如果对多个共享变量进行操作,CAS 就不能保证其原子性。有一个解决方案是利用对象整合多个共享变量,即一个类中的成员变量就是这几个共享变量。然后将这个对象做 CAS 操作就可以保证其原子性。atomic 中提供了AtomicReference
来保证引用对象之间的原子性。
3.2、Java对象头
在同步的时候是获取对象的 monitor
,即获取到对象的锁。那么对象的锁怎么理解?无非就是类似对对象的一个标志,那么这个标志就是存放在 Java 对象的对象头
。Java 对象头里的 Mark Word 里默认的存放的对象的 Hashcode, 分代年龄和锁标记位。32 为 JVM Mark Word 默认存储结构为(注:java 对象头以及下面的锁状态变化摘自《java 并发编程的艺术》一书。
如图在 Mark Word 会默认存放 hasdcode,年龄值以及锁标志位等信息。
Java SE 1.6 中,锁一共有 4 种状态,级别从低到高依次是:无锁状态
、偏向锁状态
、轻量级锁状态
和重量级锁状态
,这几个状态会随着竞争情况逐渐升级。锁可以升级但不能降级
,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率
。对象的 MarkWord 变化为下图:
3.3、偏向锁
HotSpot 的作者经过研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得
,为了让线程获得锁的代价更低而引入了偏向锁
。
3.3.1、偏向锁的获取
当一个线程访问同步块并获取锁时,会在 对象头
和 栈帧中的锁记录
里存储锁偏向的 线程ID
,以后该线程在进入和退出同步块时不需要进行 CAS 操作来加锁和解锁,只需简单地测试一下对象头的 Mark Word
里是否存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得了锁。如果测试失败,则需要再测试一下 Mark Word 中偏向锁的标识是否设置成 1(表示当前是偏向锁):如果没有设置,则使用 CAS 竞争锁;如果设置了,则尝试使用 CAS 将对象头的偏向锁指向当前线程
3.3.2、偏向锁的撤销
偏向锁使用了一种等到竞争出现才释放锁的机制
,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。
如图,偏向锁的撤销,需要等待 全局安全点
(在这个时间点上没有正在执行的字节码)。它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着,如果线程不处于活动状态,则将对象头设置成无锁状态;如果线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的 Mark Word 要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程。
下图线程1展示了偏向锁获取的过程,线程2展示了偏向锁撤销的过程。
3.3.3、如何关闭偏向锁
偏向锁在 Java 6 和 Java 7 里是默认启用的,但是它在应用程序启动几秒钟之后才激活,如有必要可以使用 JVM 参数来关闭延迟:-XX:BiasedLockingStartupDelay=0
。如果确定应用程序里所有的锁通常情况下处于竞争状态,可以通过 JVM 参数关闭偏向锁:-XX:-UseBiasedLocking=false
,那么程序默认会进入轻量级锁状态
3.4、轻量级锁
3.4.1、加锁
线程在执行同步块之前,JVM 会先在当前线程的栈桢中 创建用于存储锁记录的空间
,并将对象头中的 Mark Word 复制到锁记录中,官方称为 Displaced Mark Word
。然后线程尝试使用 CAS 将对象头中的 Mark Word 替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。
3.4.1、解锁
轻量级解锁时,会使用原子的 CAS 操作将 Displaced Mark Word 替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。下图是两个线程同时争夺锁,导致锁膨胀的流程图。
因为自旋会消耗 CPU,为了避免无用的自旋(比如获得锁的线程被阻塞住了),一旦锁升级成重量级锁,就不会再恢复到轻量级锁状态。当锁处于这个状态下,其他线程试图获取锁时,都会被阻塞住,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程就会进行新一轮的夺锁之争。
3.5、各种锁比较
四、synchronized 的使用
synchronized 可以用在实例方法和静态方法上,是隐式使用。
synchronized 可以用在代码块上,是显式使用。
synchronized 锁是可重入锁
、独享锁
、悲观锁
。
public class SynchronizedDemo {
public static void main(String[] args) {
final Counter counter1 = new Counter();
final Counter counter2 = new Counter();
new Thread(new Runnable() {
public void run() {
counter1.add();
// Counter.staticAdd();
}
}).start();
new Thread(new Runnable() {
public void run() {
counter2.add();
// Counter.staticAdd();
}
}).start();
}
}
class Counter {
public static volatile int a;
//用在实例方法上,是synchronized(this)
public synchronized void add() {
System.out.println("线程:" + Thread.currentThread().getName());
a++;
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
//用在静态方法上,是synchronized(Counter.class)
public synchronized static void staticAdd() {
System.out.println("线程:" + Thread.currentThread().getName());
a++;
LockSupport.parkNanos(1000 * 1000 * 1000 * 2);
}
public void demo() {
//用在代码块上
synchronized (this) {
a++;
}
}
}
上面的例子,当 synchronized 用在实例方法上,其实就是对 this 加锁,也就是实例化的对象,当实例化多个对象时,其实就是加了多个锁,当在多个线程多个实例调用的时候,不会出现阻塞;synchronized 用在静态方法上,其实就是对类对象进行加锁。
用在实例方法上,是 synchronized(this)
。
用在静态方法上,是 synchronized(Counter.class)
。
public class SynchronizedDemo02 implements Runnable {
private static int count = 0;
public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
Thread thread = new Thread(new SynchronizedDemo02());
thread.start();
}
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("result: " + count);
}
public void run() {
synchronized (SynchronizedDemo.class) {
for (int i = 0; i < 1000000; i++)
count++;
}
}
}
五、锁升级过程详解
- 当给一个对象增加 synchronized 锁之后,相当于上了一个偏向锁。
- 当有一个线程去请求时,就把这个对象 MarkWord 的 ID 改为当前线程指针 ID(JavaThread),只允许这一个线程去请求对象。
- 当有其他线程也去请求时,就把锁升级为轻量级锁。每个线程在自己的线程栈中生成 LockRecord,用 CAS 自旋操作将请求对象 MarkWordID 改为自己的 LockRecord,成功的线程请求到了该对象,未成功的对象继续自旋。
- 如果竞争加剧,当有线程自旋超过一定次数时(在 JDK1.6 之后,这个自旋次数由 JVM 自己控制),就将轻量级锁升级为重量级锁,线程挂起,进入等待队列,等待操作系统的调度。
加锁的字节码实现:
synchronized 关键字被编译成字节码之后会被翻译成 monitorenter
和 monitorexit
两条指令,进入同步代码块时执行 monitorenter,同步代码块执行完毕后执行 monitorexit。
六、锁消除
在某些情况下,如果 JVM 认为不需要锁,会自动消除锁
,比如下面这段代码:
public void add(String a,String b){
StringBuffer sb=new StringBuffer();
sb.append(a).append(b);
}
StringBuffer 是线程安全的,但是在这个 add 方法中 stringbuffer 是不能共享的资源,因此加锁只会徒增性能消耗,JVM 就会消除 StringBuffer 内部的锁。
七、锁粗化
在某些情况下,JVM 检测到一连串的操作都在对同一个对象不断加锁,就会将这个锁加到这一连串操作的外部
,比如:
StringBuffer sb=new StringBuffer();
while(i<100){
sb.append(str);
i++;
}
上述操作 StringBuffer 每次添加数据都要加锁和解锁,连续 100 次,这时候JVM就会将锁加到更外层(while)部分。
评论