Synchronized 关键字

synchronized 是 Java 中的关键字,是一种同步锁。它修饰的对象有以下几种:

  1. 修饰一个代码块,被修饰的代码块称为同步语句块,其作用的范围是大括号{}括起来的代码,作用的对象是调用这个代码块的对象

  2. 修饰一个方法,被修饰的方法称为同步方法,其作用的范围是整个方法,作用的对象是调用这个方法的对象

    虽然可以使用 synchronized 来定义方法,但 synchronized 并不属于方法定义的一部分,因此,synchronized 关键字不能被继承。如果在父类中的某个方法使用了 synchronized 关键字,而在子类中覆盖了这个方法,在子类中的这个方法默认情况下并不是同步的,而必须显式地在子类的这个方法中加上 synchronized 关键字才可以。当然,还可以在子类方法中调用父类中相应的方法,这样虽然子类中的方法不是同步的,但子类调用了父类的同步方法,因此,子类的方法也就相当于同步了

  3. 修改一个静态的方法,其作用的范围是整个静态方法,作用的对象是这个类的所有对象

  4. 修改一个类,其作用的范围是 synchronized 后面括号括起来的部分,作用主的对象是这个类的所有对象

售票案例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class SaleTicket{
//一共有30张票
private int num = 30;

/**
* 售票方法
*/
public synchronized void sale(){
if(num > 0){
num--;
System.out.println(Thread.currentThread().getName() + "买到了一张票, 还剩下" + num + "张票");
}
}
}
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
public class Test1 {

/**
* 测试方法
* @param args
*/
public static void main(String[] args) {
//构建一个售票对象
SaleTicket saleTicket = new SaleTicket();

//启动两个线程去买票
new Thread(() ->{
for (int i = 0; i < 30; i++) {
try {
Thread.sleep(1000);
}catch (Exception e){
e.printStackTrace();
}
saleTicket.sale();
}
},"同学A").start();

new Thread(() ->{
for (int i = 0; i < 30; i++) {
try {
Thread.sleep(1000);
}catch (Exception e){
e.printStackTrace();
}
saleTicket.sale();
}
},"同学B").start();
}

}

如果一个代码块被 synchronized 修饰了,当一个线程获取了对应的锁,并执行该代码块时,其他线程便只能一直等待,等待获取锁的线程释放锁,而这里获取锁的线程释放锁只会有两种情况:

  • 获取锁的线程执行完了该代码块,然后线程释放对锁的占有;
  • 线程执行发生异常,此时 JVM 会让线程自动释放锁。

那么如果这个获取锁的线程由于要等待 IO 或者其他原因(比如调用 sleep 方法)被阻塞了,但是又没有释放锁,其他线程便只能干巴巴地等待,试想一下,这多么影响程序执行效率。

隐式可重入锁案例

可重入锁,也叫做递归锁,指的是同一线程外层函数获得锁之后 ,内层递归函数仍然有获取该锁的代码,可以再次获取锁而不会出现死锁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class SaleTicket{
//一共有30张票
private int num = 30;
//获取锁的次数
private int count = 0;
/**
* 售票方法
*/
public synchronized void sale(){
if(num > 0){
num--;
System.out.println(Thread.currentThread().getName() + "买到了一张票, 还剩下" + num + "张票");
count++;
System.out.println(Thread.currentThread().getName() + "获取到锁的次数为:" + count);
sale();
}

}
}
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
public class Test {
public static void main(String[] args) {
SaleTicket saleTicket = new SaleTicket();

new Thread(() -> {
for (int i = 0; i < 30; i++) {
try {
Thread.sleep(300);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
System.out.println("锁释放了");
}
saleTicket.sale();
}
}, "同学A").start();


new Thread(() -> {
for (int i = 0; i < 30; i++) {
try {
Thread.sleep(300);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
System.out.println("锁释放了");
}
saleTicket.sale();
}
}, "同学B").start();
}
}

对象头

在 JVM 中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充

  • 实例数据:存放类的属性数据信息,包括父类的属性信息,如果是数组的实例部分还包括数组的长度,这部分内存按 4 字节对齐。

  • 填充数据:由于虚拟机要求对象起始地址必须是 8 字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐。

  • HotSpot虚拟机的对象头为例,HotSpot虚拟机的对象头分为两部分信息

    • 第一部分用于存储对象自身运行时数据,如哈希码、GC 分代年龄等,这部分数据的长度在 32 位和 64 位的虚拟机中分别为 32 位和 64 位。官方称为Mark Word

    • 另一部分用于存储指向对象类型数据的指针,如果是数组对象的话,还会有一个额外的部分存储数组长度。

虚拟机位数对象头结构描述
32 位/64 位Mark Word存储对象的哈希码、GC 分代年龄(15 岁)、锁信息(有没有被加锁)等
32 位/64 位Class MetaData Address指向对象类型数据的指针
32 位/64 位数组长度如果是数组对象的话,有这一部分,否则没有

由于对象头的信息是与对象自身定义的数据没有关系的额外存储成本,因此考虑到 JVM 的空间效率,Mark Word 被设计成为一个非固定的数据结构,以便存储更多有效的数据,它会根据对象本身的状态复用自己的存储空间。

以一个 32 位的虚拟机为例

分代年龄:代表经历过几次垃圾回收,因为是一个整形(int),所以是 4 个字节

  • 偏向锁为 0,锁标志位为 01,代表没有锁
  • 偏向锁为 1,锁标志位为 01,代表偏向锁(同一个时间,只有一个线程在使用,没有竞争)
  • 锁标志位为 00,代表轻量级锁(假设有其它线程要操作对象时,发现已经有线程在使用偏向锁操作对象,那么等到其它线程使用的时候再次加锁就是加的轻量级锁了)
  • 锁标志位为 10,代表重量级锁(同理,已经有轻量级锁,再有线程需要使用就是加的重量级锁)
  • 线程 ID,为哪个线程加锁

Synchronized 优化

Synchronized 是通过对象内部的一个叫做监视器锁(monitor)来实现的,监视器锁本质又是依赖于底层的操作系统的 Mutex Lock(互斥锁)来实现的。而操作系统实现线程之间的切换需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么 Synchronized 效率低的原因。因此,这种依赖于操作系统 Mutex Lock 所实现的锁我们称之为“重量级锁”。

Java SE 1.6 为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”:锁一共有 4 种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态。锁可以升级但不能降级。

每一个锁都对应一个 monitor 对象,在 HotSpot 虚拟机中它是由 ObjectMonitor 实现的(C++实现)。每个对象都存在着一个 monitor 与之关联,对象与其 monitor 之间的关系有存在多种实现方式,如 monitor 可以与对象一起创建销毁或当线程试图获取对象锁时自动生成,但当一个 monitor 被某个线程持有后,它便处于锁定状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
ObjectMonitor() {
_header = NULL;
_count = 0; //锁计数器
_waiters = 0,
_recursions = 0;
_object = NULL;
_owner = NULL;
_WaitSet = NULL; //处于wait状态的线程,会被加入到_WaitSet
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
_EntryList = NULL ; //处于等待锁block状态的线程,会被加入到该列表
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}

锁膨胀

上面讲到锁有四种状态,并且会因实际情况进行膨胀升级,其膨胀方向是:无锁——>偏向锁——>轻量级锁——>重量级锁,并且膨胀方向不可逆。

偏向锁

一句话总结它的作用:减少统一线程获取锁的代价。在大多数情况下,锁不存在多线程竞争,总是由同一线程多次获得,那么此时就是偏向锁。

核心思想

如果一个线程获得了锁,那么锁就进入偏向模式,此时 Mark Word 的结构也就变为偏向锁结构,当该线程再次请求锁时,无需再做任何同步操作,即获取锁的过程只需要检查 Mark Word 的锁标记位为偏向锁以及当前线程 ID 等于 Mark Word 的 ThreadID即可,这样就省去了大量有关锁申请的操作。

轻量级锁

轻量级锁是由偏向锁升级而来,当存在第二个线程申请同一个锁对象时,偏向锁就会立即升级为轻量级锁。注意这里的第二个线程只是申请锁,不存在两个线程同时竞争锁,可以是一前一后地交替执行同步块

重量级锁

重量级锁是由轻量级锁升级而来,当同一时间有多个线程竞争锁时,锁就会被升级成重量级锁,此时其申请锁带来的开销也就变大

重量级锁一般使用场景会在追求吞吐量,同步块或者同步方法执行时间较长的场景。

锁消除

消除锁是虚拟机另外一种锁的优化,这种优化更彻底,在 JIT 编译时,对运行上下文进行扫描,去除不可能存在竞争的锁。

比如下面代码的 method1 和 method2 的执行效率是一样的,因为 object 锁是私有变量,不存在锁的竞争关系。

锁粗化

锁粗化是虚拟机对另一种极端情况的优化处理,通过扩大锁的范围,避免反复加锁和释放锁。

比如下面 method3 经过锁粗化优化之后就和 method4 执行效率一样了。

自旋锁与自适应自旋锁

轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。

自旋锁:许多情况下,共享数据的锁定状态持续时间较短,切换线程不值得,通过让线程执行循环等待锁的释放,不让出 CPU。如果得到锁,就顺利进入临界区。如果还不能获得锁,那就会将线程在操作系统层面挂起,这就是自旋锁的优化方式。但是它也存在缺点:如果锁被其他线程长时间占用,一直不释放 CPU,会带来许多的性能开销。

自适应自旋锁:这种相当于是对上面自旋锁优化方式的进一步优化,它的自旋的次数不再固定,其自旋的次数由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定,这就解决了自旋锁带来的缺点。