synchronized

synchronized

使用 synchronized 关键字会隐式的获取锁,固定的先获取锁再释放,相比Lock显示的获取和释放锁会相对的简单和易用,但扩展性就不如了。

第一节 概述

1.1 什么是synchronized?

synchronized 是一种重量级锁,一种可重入、独占、悲观锁。与对象监视器方法组合来实现 等待/通知模式

1.2 作用

synchronized 作用:

  1. 确保线程间互斥的访问代码。
  2. 保证对共享变量可见性。
  3. 解决重排序问题。

synchronized 可以修饰方法,可以修饰静态方法,可以修饰代码块。

通过设计下列代码来看前两者区别。当方法 printName()printNameTwo() 不加锁时,结果如下:

1
2
3
4
5
6
7
8
printName start
printNameTwo start
printName: Tom
printName execute
printNameTwo: Tom Tom
printNameTwo execute
printName end
printNameTwo end

分别加 synchronizedstatic ,变为四个方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
public synchronized void printName(){ //方法1
System.out.println("printName start");
System.out.println("printName: " + name);
try {
System.out.println("printName execute");
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("printName end");
}

public synchronized void printNameTwo(){ //方法2
System.out.println("printNameTwo start");
System.out.println("printNameTwo: " + name + " " + name);
try {
System.out.println("printNameTwo execute");
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("printNameTwo end");
}

public static synchronized void printStatic(){ //方法3
System.out.println("printStatic: ");
try {
System.out.println("printStatic execute");
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("printStatic end");
}

public static synchronized void printStaticTwo(){ //方法4
System.out.println("printStaticTwo: ");
try {
System.out.println("printStaticTwo execute");
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("printStaticTwo end");
}

先通过对象Tom调用 sync 普通方法:方法1和方法2,结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
Person person1 = new Person("Tom");
Person person2 = new Person("Lee");
new Thread(new Runnable() {
@Override
public void run() {
person1.printName();
}
}).start();

new Thread(new Runnable() {
@Override
public void run() {
person1.printNameTwo();
}
}).start();

//结果如下
printName start
printName: Tom
printName execute
printName end
printNameTwo start
printNameTwo: Tom Tom
printNameTwo execute
printNameTwo end

再通过对象Tom调用 sync 静态方法:方法3和方法4,结果如下:

1
2
3
4
5
6
7
//结果如下
printStatic:
printStatic execute
printStatic end
printStaticTwo:
printStaticTwo execute
printStaticTwo end

再测试多个对象调用,结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//Tom和Lee调用普通方法1
printName start
printName start
printName: Lee
printName execute
printName: Tom
printName execute
printName end
printName end

//Tom和Lee调用静态方法3
printStatic:
printStatic execute
printStatic end
printStatic:
printStatic execute
printStatic end

要了解在类文件中的这些域的区别,当 synchronized 修饰普通方法时,在方法调用时取得是对象锁,对象的锁信息会显式当前锁的状态,所以调用同一对象的方法时,前个线程获得锁后,后一个线程就因为取到同一个对象锁而阻塞。而不同对象调用时,取到的对象锁不同,所以就不会起到同步的效果了。

而静态方法锁的对象是 T.class ,所以对于同一个类文件的对象来说调用被锁的静态方法都会竞争,两个锁的对象不同所以静态方法被调用了,普通方法还可以继续调用,两者不影响。

synchronized 锁是可重入的,获取同个对象锁的线程可以继续调用同对象内的其它加同一个锁的方法,而子类在结构上等同于包裹了父类对象,也可以重入。


第二节 原理

锁不同对象时的关系:

  1. 修饰普通方法,是当前实例对象的锁。
  2. 修饰静态同步方法,是当前类的Class对象的锁。
  3. 同步方法块,是块内配置的对象的锁。

对应的锁分类:

  • 对象锁:使用 synchronized 修饰非静态的方法以及 synchronized(this) 同步代码块使用的锁是对象锁。
  • 类锁:使用 synchronized 修饰静态的方法以及 synchronized(class) 同步代码块使用的锁是类锁。
  • 私有锁:在类内部声明一个私有属性如 private Object lock ,在需要加锁的同步块使用 synchronized(lock)

2.1 底层执行细节

用到的指令符:

  • monitorenter 指令指向同步代码块的开始位置。
  • monitorexit 指令指明同步代码块的结束位置。

在同步方法中会包含 ACC_SYNCHRONIZED 标记符。该标记符指明了该方法是一个同步方法,从而执行相应的同步调用。

每个对象都会有一个监视器(monitor)当监视器被占用时对象就会处于锁定状态线程在执行 synchronized 时会通过指令符 monitorenter 去获取监视器的所有权

实际运行中的过程是这样的:若monitor计数为0,线程进入,计数加1,此线程即为监视器所有者。线程已拥有所有权,再重新进入,计数继续加1。其它线程访问时,发现监视器已被占用,进入阻塞状态,直到计数归0,再竞争监视器所有权。

在前几篇博客整理锁机制时,有写到Object的 wait()notify() 等方法也依赖于监视器,所以这些方法应该限制在同步的域中调用。

2.2 锁的存储位置

synchronized 所用的锁存储在Java对象头内。

Java对象保存在内存中由三部分组成:

  1. 对象头。
  2. 实例数据。
  3. 对齐填充字节。

2.2.1 对象头结构

Java对象头也包括三部分:

内容 长度(32/64位JVM) 说明
Mark Word 32/64bit 存储对象的hashcode或锁信息等
Class Metadata Adress 32/64bit 存储到对象类型数据的指针
Array Length 32/64bit 数组的长度(仅对象为数组)

32位的JVM中Mark Word存储结构如下:

锁状态 25bit 4bit 1bit是否是偏向锁 2bit锁标志位
无锁状态 对象的hashcode 对象分代年龄 0 01

运行时Mark Word状态变化如下:

锁状态25bit4bit1bit2bit
23bit2bit是否偏向锁锁标志位
轻量级锁指向栈中锁记录的指针00
重量级锁指向互斥量(重量级锁)的指针10
GC标记11
偏向锁线程IDEpoch对象分代年龄101

64位JVM存储结构如下:

锁状态25bit31bit1bit4bit1bit2bit
cms_free分代年龄偏向锁锁标志位
无锁unusedhashcode001
偏向锁ThreadID(54bit) Epoch(2bit)101

2.2.2 重量级锁的状态

监视器的实现依赖于底层操作系统的 Mutex Lock ,操作系统切换线程比较耗时,所以这类锁也叫重量级锁。JDK在更新中也对 synchronized 进行了很多次的优化,目的就是为了减少使用这种重量级锁带来的巨大性能损耗。

Java 1.6版本后,锁的四种状态:

  1. 无锁状态:是否偏向锁位为0,锁标志位为01。
  2. 偏向锁:是否偏向锁位为1,锁标志位为01。
  3. 轻量级锁:锁标志位为00。
  4. 重量级锁:锁标志位为10。

次序即级别由低到高,可以顺序单向升级,但不能降级。这样设计并不是为了代替重量级锁,而是为了在不必要的场景下尽量少的去使用重量级锁。轻量级锁的使用场景是线程交替执行同步块,当出现多线程同时访问同步块时,轻量级锁就会升级为重量级锁


(1)偏向锁

来源:锁不仅要多线程竞争,也会单线程多次获取,需要某个机制可以降低线程获得锁的开销。

  1. 获取偏向锁。

偏向锁机制

  1. 撤销偏向锁,偏向锁机制是只有有竞争时才会释放。

偏向锁初始化的流程

  1. 关闭偏向锁,高版本Java默认启动偏向锁,可以通过: -XX:UseBiasedLocking=false 关闭偏向锁,使程序进入轻量级锁状态。

(2)轻量级锁
  1. 加锁:线程在执行同步块之前,JVM会先在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的 Mark Word 复制到锁记录中,官方称为 Displaced Mark Word 。然后线程尝试使用 CAS 将对象头中的 Mark Word 替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁

  2. 解锁:轻量级解锁时,会使用原子的CAS操作将 Displaced Mark Word 替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。因为自旋会消耗CPU,为了避免无用的自旋(比如获得锁的线程被阻塞住了),一旦锁升级成重量级锁,就不会再恢复到轻量级锁状态。当锁处于这个状态下,其他线程试图获取锁时,都会被阻塞住,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程就会进行新一轮的夺锁之争。

争夺锁导致的锁膨胀流程图


(3)锁的状态变迁

内部的实现过程(无锁->轻量锁):

  1. 执行到同步块时,若对象锁为无锁状态,JVM在当前线程的栈帧中开辟一块名为锁记录(Lock Record)的空间,用来存储锁对象当前 Mark Word (对象的头信息)的拷贝副本。
  2. 拷贝 Mark Word 并复制到 Lock Record 中。
  3. JVM通过CAS操作尝试将对象的 Mark Word 更新为指向 Lock Record 的指针,并将 Lock Recordowner 指针指向对象的 Mark Word
  4. 若上述更新成功,线程就拥有了此对象的锁,此时线程和对象有相互指针指向对方,锁标志位设置为00。此线程可以继续执行同步块内的代码。
  5. 若以上更新失败说明有多个线程竞争,则轻量锁要升级为重量锁,锁标志位设置为10,当前线程通过自旋的方式循环获取锁。

内部的实现过程(偏向锁->轻量锁):

  1. 访问对象当前 Mark Word ,确认对象锁偏向锁位为1,锁标志位为01,当前为偏向锁。
  2. 测试线程ID是否指向当前线程,如果是就执行同步代码,否则进入下一步骤。
  3. 通过CAS获取偏向锁,竞争成功则将 Mark Word 中线程ID更新为当前线程ID。
  4. 若取锁失败,表示有线程竞争,当到达安全点时获得偏向锁的线程会被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续执行
  5. 取锁成功则执行同步代码。

当有线程竞争时,偏向锁和轻量锁都会升级,持有偏向锁的线程会被动释放锁

三者转换

(4)几种锁的优缺点

锁的优缺点对比


2.3 其它优化

  1. 适应性自旋:获取轻量级锁执行CAS操作失败时,需要通过自旋来获取重量级锁,自旋是一个持续占用CPU性能的操作,所以JDK采用适应性自旋模式,设定一个自旋次数,自旋成功了就增加自旋次数,自旋失败就减少自旋次数。

  2. 锁粗化:将多次连接在一起的(同个对象多次调用方法)加锁和解锁操作合并,将多个连续的锁扩展为更大范围的锁。

  3. 锁消除:删除不必要的加锁操作,通过代码逃逸技术,判断一段代码内的数据不会逃逸出当前线程,就认为这段代码是线程安全的,不需要加锁。

若线程间竞争激烈,锁升级这一设计反而会适得其反,这种环境需要禁用偏向锁。


部分参考以下内容:

《Java核心技术 卷Ⅰ》

《Java虚拟机规范》

《Java并发编程的艺术》

Java并发编程:volatile关键字解析

Java并发编程:Synchronized底层优化(偏向锁、轻量级锁)

若内容涉及侵权请及时告知,我会尽快修改和删除相关内容