Java并发编程(5) —— synchronized关键字

时间:2023-04-08 12:54:03

上一篇:Java并发编程(4) —— Java 内存模型(JMM)详解

在上一篇中我们提到了volatile关键字可通过插入内存屏障的方式来保证变量的可见性(每次使用都到主存中进行读取)和有序性(不允许指令重排序),但是volatile关键字不保证对变量复合操作的原子性,例如i++操作在jvm层面实际上是通过多条指令操作完成,而若用synchronized 关键字将操作代码块包起来,则同一时刻只有一个线程能进入进行操作,这就保证了原子性。

在 Java 早期版本中,synchronized 属于 重量级锁,效率低下。这是因为监视器锁(monitor)是依赖于底层的操作系统的 Mutex Lock 来实现的,Java 的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高。在 Java 6 之后, synchronized 引入了大量的优化如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销,这些优化让 synchronized 锁的效率提升了很多。

一、synchronized的使用方法

synchronized 块是 Java 提供的一种原子性内置锁,Java 中的每个对象都可以把它当作一个同步锁来使用。

synchronized关键字可以用来修饰实例方法、静态方法、代码块,表示对其进行加锁,当线程进入 synchronized 代码块前只有获取到相应的锁才能访问,否则自动进入自旋或阻塞状态(BLOCKED)等待锁被其他线程释放后竞争锁。

1、修饰实例方法 (锁当前对象实例)

给当前对象实例加锁,进入同步代码前要获得 当前对象实例的锁 。

synchronized void method() {
    //业务代码
}

2、修饰静态方法 (锁类对象)

给当前类加锁,进入同步代码前要获得 当前类class对象的锁。这是因为静态成员不属于任何一个实例对象,归整个类所有,不依赖于类的特定实例。

synchronized static void method() {
    //业务代码
}

3、修饰代码块 (锁指定对象/类对象)

  • synchronized(object) 表示进入同步代码前要获得 给定对象的锁。
  • synchronized(类.class) 表示进入同步代码前要获得 给定 Class对象 的锁。
synchronized(this) {
    //业务代码
}

volatile和synchronized的区别是什么?
volatile 关键字用于修饰变量,可保证变量的可见性和有序性。synchronized关键字用于修饰方法或代码块,可保证代码块的原子性以及代码块内变量的可见性,以及代码块外部和内部之间的有序性(代码块内部的有序性不保证,例如DCL单例指令重排问题)。

占有锁的线程在什么情况下会释放锁?
1:占有锁的线程执行完了该代码块,然后释放对锁的占有;
2:占有锁线程执行发生异常,此时JVM会让线程自动释放锁;
3:占有锁线程进入 WAITING 状态从而释放锁,例如在该线程中调用wait()方法等

二、synchronized的底层原理

在JVM中每个对象都关联了一个monitor监视器(C++实现),synchronized的同步机制就是基于monitor实现的,当线程进入synchronized块时需要获取对应对象的锁 也就是获取对象监视器 monitor的持有权,首先检查对象markword中的锁标记,若是不可获取则进入阻塞状态(BLOCKED)并进入对应对象的锁池,等待其他线程释放锁。

对于synchronized同步代码块,编译后在代码块前后分别有一个monitorenter 和 monitorexit 指令,在JVM中当线程执行到monitorenter指令时尝试获取指定对象的锁,执行到monitorexit 指令则释放锁。
Java并发编程(5) —— synchronized关键字
对于synchronized同步方法,编译后方法中有一个ACC_SYNCHRONIZED标识,在JVM中当线程执行到有此标识的方法时则尝试获取对应类实例/类对象的锁,退出方法后则释放锁。
Java并发编程(5) —— synchronized关键字

三、synchronized的锁升级过程

早期synchronized实现的同步锁为重量级锁。但是重量级锁会造成线程阻塞排队(串行执行),且会使CPU在用户态和核心态之间频繁切换,所以代价高、效率低。因此 Java6 对 synchronized 锁进行了优化,增加了轻量级锁和偏向锁。为了提高效率,不会一开始就使用重量级锁,JVM在内部会根据需要,按如下步骤进行锁的升级:

  1. 初期锁对象刚创建时,还没有任何线程来竞争,对象的markword是下图的第一种情形,这偏向锁标识位是0,锁状态01,说明该对象处于无锁状态(无线程竞争它)。

  2. 当有一个线程来竞争锁时,先用偏向锁,会在对象的markword中记录线程threadID,并且偏向锁不会主动释放锁,因此以后此线程再次获取锁的时候,通过比较当前线程的 threadID 和 对象头中的threadID 发现一致,则无需使用 CAS 来加锁、解锁,即这个线程要执行这个锁关联的任何代码,不需要再做任何检查和切换,这种竞争不激烈的情况下,效率非常高。如下图第二种情形。

  3. 当有第二个线程开始竞争这个锁对象,通过对比markword中记录线程threadID发现不一致,那么需要查看Java 对象头中记录的线程 1 是否存活(偏向锁不会主动释放锁),如果没有存活,那么锁对象被重置为无锁状态,其它线程(线程 2)可以竞争将其设置为偏向锁;如果存活,那么立刻查找该线程(线程 1)的栈帧信息,如果还是需要继续持有这个锁对象,那么暂停当前线程 1,撤销偏向锁,升级为 轻量级锁,如果线程 1 不再使用该锁对象,那么将锁对象状态设为无锁状态,重新偏向新的线程。如下图第三种情形。
    Java并发编程(5) —— synchronized关键字

  4. 如果竞争的这个锁对象的线程更多,导致了更多的切换和等待,JVM会把该锁对象的锁升级为重量级锁,这个就叫做同步锁,这个锁对象Mark Word再次发生变化,会指向一个监视器对象,这个监视器对象用集合的形式,来登记和管理排队的线程。如下图第四种情形。

Java并发编程(5) —— synchronized关键字

锁消除
消除锁是虚拟机另外一种锁的优化,这种优化更彻底,Java 虚拟机在 JIT 编译时通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过这种方式消,除没有必要的锁,可以节省毫无意义的请求锁时间,我们知道StringBuffer 是线程安全的,里面包含锁的存在,但是如果我们在函数内部使用 StringBuffer 那么代码会在 JIT 后会自动将锁释放掉哦。


参考:

  1. 由浅入深,逐步了解 Java 并发编程中的 Synchronized!
  2. java对象头 MarkWord