Java并发篇(4)synchronized与锁实现

synchronized的修饰范围

我们都知道,synchronized关键字可以作用于方法或者方法内的某一个代码块,在表层实现来看,两种方式都是尝试获取对象上的锁进而执行对应的代码,但是在底层实现上,两者却大有不同,下面就来说明synchronized修饰方法和代码块的底层实现。

修饰成员方法

下面的代码显示了synchronized修饰方法

1
2
3
4
5
6
7
8
9
10
11
/**
* @author Zeng
* @date 2020/4/25 9:53
*/
public class SynchronizedTest {

public synchronized void lockA() {
System.out.println("lockA() execute.");
}

}

利用反编译指令javap -verbose SynchronziedTest.class对字节码解析得到下面结果

1
2
3
4
5
6
7
8
9
10
11
12
public synchronized void lockA();
descriptor: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
stack=2, locals=1, args_size=1
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #3 // String lockA() execute.
5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
LineNumberTable:
line 8: 0
line 9: 8

flags中含有ACC_SYNCHRONIZED标识符,该标识符存在于同步方法的常量池中,表示这是一个同步方法,当线程开始访问方法lockA()时,会查看flags是否含有ACC_SYNCHRONIZED标识符,如果有就会去尝试获取监视器锁,获取成功就执行方法,否则就会被阻塞等待。值得注意的是,如果同步方法在执行过程中出现异常并且在内部没有处理,那么线程会被抛出方法之外时会自动释放监视器锁

如果synchronized修饰静态成员方法会获取Class实例的锁,如果修饰成员方法会尝试获取SynchronizedTest实例对象锁

修饰同步代码块

观察下面这段代码

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* @author Zeng
* @date 2020/4/25 9:53
*/
public class SynchronizedTest {

public void lockA() {
syschroinzed(this) {
System.out.println("lockA() execute.");
}
}

}

同样用javap -verbose反编译后得到下面结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public void lockA();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=3, args_size=1
0: aload_0
1: dup
2: astore_1
3: monitorenter
4: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
7: ldc #3 // String lockA() execute.
9: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
12: aload_1
13: monitorexit
14: goto 22
17: astore_2
18: aload_1
19: monitorexit
20: aload_2
21: athrow
22: return

看到了monitorentermonitorexit两条指令,它们分别代表获取和释放监视器锁。两条字节码指令都需要显式指定一个reference类型的加锁和解锁对象。在执行monitorenter指令过程中会尝试获取监视器锁,如果获取成功则锁计数器自增1,执行monitorexit指令时锁计数器自减1,计数器为0时锁会被释放。

总结

  1. synchronized修饰方法时隐式指定锁对象进行加锁,标识符为ACC_SYNCHRONIZED,在执行方法前需要获取锁对象。
  2. synchronized修饰代码块时需要显式指定锁对象进行加锁,在执行monitorenter指令时需要先获取锁对象。
  3. synchronized修饰的锁方法和同步代码块是可重入的,锁的计数器会不断自增,所以对同一个锁对象加锁多少次就要释放多少次。
  4. synchronized修饰方法时会根据该方法是否是静态方法去判断获取的是Class对象锁还是本类的实例对象锁

了解synchronized的基本原理后,是不是觉得有些明朗了,其实里面还有非常多学问:Java对象模型Java的对象头Monitor的实现原理,这里安利3篇Hollis大大的文章,写得真的非常不错,在本文不再做详细介绍了。

synchroinzed的瓶颈

synchronized无论是修饰方法还是同步代码块,如果获取锁失败,会导致线程无限期阻塞,极大降低了系统的可伸缩性。所以针对synchronized的各种优化方案应运而生:自旋锁、锁消除、锁膨胀、轻量级锁、偏向锁

这些优化方案都已经被封装在synchronized里了,也就是说如果需要加锁,那么使用synchronized就对了。

synchronized的优化方案

自旋锁

CAS介绍

CAS全称是Compare And Swap,它利用不断循环的机制对某个状态进行检测,如果满足条件那么可以设置新的值。

在Java线程中,获取锁失败后不会被阻塞,会一直轮询该对象锁的状态,如果发现对象锁处于可获取状态则会尝试占用该锁。

使用synchronized实现的锁称为悲观锁,基于CAS的锁实现称为乐观锁

乐观锁和悲观锁的区别:

  1. 在获取锁失败后,悲观锁会阻塞其它线程,乐观锁不会阻塞其它线程,而是让线程轮询,一直检测该锁的状态,在CPU层面看就是悲观锁获取失败后会放弃CPU进入阻塞状态,而乐观锁不会放弃CPU。

CAS操作过程

CAS的操作分成三步:CAS(V, O, N)。V(内存地址存放的实际值)、O(预期的旧的值)、N(更新的值),只有当OV相同时,线程认为该值没有被修改过,那么O已经是当前最新的值了,可以更新值。反之如果OV不相同,代表其它线程修改过该变量值,该线程的此次CAS操作失败,继续轮询。

只要有一个线程CAS成功,其它线程就会CAS失败。

CAS应用场景

以获取锁为例

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* @author Zeng
* @date 2020/4/25 9:53
*/
public class SynchronizedTest {

public void lockA() {
syschroinzed(this) {
System.out.println("lockA() execute.");
}
}

}

如果SynchronizedTest对象的锁状态是(空闲),线程A使用CAS获取锁,线程B也使用CAS获取锁,最终结果是线程A获取到锁了,状态变为(占有),线程B的CAS判定条件为(V, O, N)=(占有,空闲,占有),OV不相同,则会获取锁失败,继续轮询。

基于CAS的操作场景还有很多:

  1. JUC(java.util.concurrency)包
  2. 原子类(AtomicInteger······)
  3. Lock类

CAS的常见问题

  1. (V, O, N)中的VO相等不能确保数据没有被更改(ABA问题)

假设线程A第一次获取变量i的状态为i = 1,线程A需要更新值i = 2时,再次轮询i的状态i = 1,但是在第二次轮询之前线程B将i = 3,然后又改为i = 1,在线程A看来是没有更新过数据的。

可以观察下图来理解:

解决方案:给每个状态加上一个版本号,保证不仅值相等而且版本号也要相等

  1. 自旋时间过长

如果一直无法获取锁,那么将导致线程一直轮询,一直占用着CPU资源。

解决方案:给自旋时间加上期限,超过期限则自动放弃获取锁,在JDK1.6中加入了自旋锁的自适应。如果同一个线程多次对同一个锁对象成功加锁,那么允许下次自旋更长的时间;反之如果一直获取失败,那么下次自旋更短的时间。

  1. 只能保证单个变量的原子操作

只能对变量i进行CAS操作,无法同时对ij进行CAS操作

解决方案:将变量ij封装到一个对象中,对该对象进行CAS操作。

锁消除

如果有些同步代码被检测到不可能发生共享数据竞争就会将该同步代码块进行锁消除。锁消除的依据来源于逃逸分析。JIT编译器会判断锁对象是否会被发布到其它线程使用,如果不会,那么在该对象上加锁是完全没有必要的。

例如如下代码

1
2
3
4
5
6
7
8
public class Test {
public void A() {
Test test = new Test();
synchronized(test) {
System.out.print(test);
}
}
}

在JIT编译器动态即时编译之后,这段代码会忽略所有的同步措施而直接执行。

1
2
3
4
5
6
public class Test {
public void A() {
Test test = new Test();
System.out.print(test);
}
}

锁粗化

如果一系列的连续操作都对同一个对象反复加锁和解锁,即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗,此时虚拟机探测到这样一种情况会把加锁同步的范围扩展(粗化)到整个序列的外部。下面代码就是一个例子:

1
2
3
4
5
6
7
8
public class Test {
public String concatString(String s1, String s2, StringBuffer s3) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
sb.append(s3);
}
}

StringBufferappend()方法是一个同步方法,多次连续执行append()方法会被虚拟机进行锁粗化,扩展到第一个append()方法到最后一个append()方法,只需要加锁一次就可以了。

Java的对象头

在了解轻量级锁和偏向锁之前,必须要先掌握Hotspot虚拟机中的对象内存布局(对象头信息),才能对锁的状态转换很好的理解。下图为32位虚拟机下Mark Word的信息:

JDK1.6开始,加锁的状态有3种:轻量级锁、重量级锁和偏向锁,这几种状态随着竞争程度的提高不断升级,锁可以升级但是不能被降级。升级的过程,锁的标志位不断自增1,对象头信息会根据锁的状态复用自己的存储空间。

轻量级锁

如果同步对象没有被加锁,那么虚拟机将会在当前线程栈帧上创建Lock Record锁空间,用于存储锁对象目前的Mark Word拷贝。如下图所示:

线程通过CAS操作尝试把对象的Mark Word更新为指向Lock Record的指针。

对象被一个线程获取轻量级锁的过程如下所示:

如果CAS操作失败,说明有其它线程已经获取了该锁,那么将会进行CAS不断轮询。

如果出现两个以上的线程争用同一个锁,轻量级锁会膨胀成重量级锁。

解锁的过程与加锁的过程相反,将Lock Record当中的Mark Word替换回加锁对象中,也是一个CAS操作。如果解锁过程CAS操作失败了,证明其它线程也尝试获取该对象锁,此时唤醒其它线程。

轻量级加锁和解锁的流程图如下:

轻量级锁加锁解锁以及锁膨胀

偏向锁

这个锁总会偏向第一个获得它的线程,如果接下来执行过程中一直没有其它线程获取该锁,那么持有偏向锁的线程永远不需要在该锁上进行同步。

当前锁对象被第一次获取时会将对象头的标志位设置为“01”,偏向模式设置为“1”,表示进入偏向模式。同时用CAS操作把获取到该锁的线程ID保存在Mark Word当中。之后该线程再次访问该锁对象就不需要进行同步了。

一旦出现另外一个线程尝试获取该锁,偏向模式马上宣告结束,锁偏向位置为“0”。撤销后标志位恢复到“01”的无锁状态或者是“00”的轻量级锁定状态。后续的同步操作按照上面的轻量级锁去执行。

偏向锁和轻量级锁的状态转换图如下:

注意:

  1. 锁膨胀到重量级锁后,释放锁时不是变回轻量级锁。
  2. 偏向锁释放锁失败时如果有线程正在请求该对象锁,那么会直接锁升级为轻量级锁并释放锁。

总结

到这里为止,已经掌握了这些知识:

  1. synchronized关键字的基本原理
  2. synchronized锁的瓶颈及优化方案
  3. CAS的原理,存在的问题及解决方案
  4. HotSpot虚拟机的对象布局Mark Word基本组成
  5. 各种锁的实现方式和锁的状态转换

结束语:淡泊名利,宁静致远。不念过往,不惧未来。滴水之恩,涌泉相报。

巨人的肩膀:

Java虚拟机是如何执行线程同步的

深入理解多线程(一)——Synchronized的实现原理

深入理解多线程(五)—— Java虚拟机的锁优化技术

彻底理解synchronized

《深入理解Java虚拟机》第三版

《Java并发编程的艺术》

0%