Java之synchronized关键字

时间:2022-09-28 01:10:47

前言

synchronized 中文意思是同步,在 Java 里是一个关键字,也称之为“同步锁“。它的作用是保证在同一时刻,被修饰的代码块或方法只会有一个线程执行,以达到保证并发安全的效果。
在 JDK1.5 之前 synchronized 是一个重量级锁,效率低下。随着 JDK1.6 对 synchronized 进行的各种优化后,从而有了偏向锁、轻量级锁、自旋锁、适应性自旋锁、锁消除、锁粗化等等,让 synchronized 并不会显得那么重了。

使用方式

在Java中,synchronized有两种使用形式,同步方法和同步代码块。代码如下:

public class SynchronizedTest {

    public synchronized void doSth(){
        System.out.println("Hello World");
    }

    public void doSth1(){
        synchronized (SynchronizedTest.class){
            System.out.println("Hello World");
        }
    }
}

表层原理

我们先来使用Javap来反编译以上代码,结果如下(部分无用信息过滤掉了):

public synchronized void doSth();
    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 Hello World
         5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: return

  public void doSth1();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=1
         0: ldc           #5                  // class com/hollis/SynchronizedTest
         2: dup
         3: astore_1
         4: monitorenter
         5: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         8: ldc           #3                  // String Hello World
        10: invokevirtual #4                  // 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

我们可以看到Java编译器为我们生成的字节码。在对于doSth和doSth1的处理上稍有不同。也就是说。JVM对于同步方法和同步代码块的处理方式不同。
对于同步方法,JVM采用ACC_SYNCHRONIZED标记符来实现同步。当某个线程要访问某个方法的时候,会检查是否有ACC_SYNCHRONIZED,如果有设置,则需要先获得锁,然后开始执行方法,方法执行之后再释放锁。这时如果其他线程来请求执行方法,会因为无法获得锁而被阻断住。值得注意的是,如果在方法执行过程中,发生了异常,并且方法内部并没有处理该异常,那么在异常被抛到方法外面之前监视器锁会被自动释放。
对于同步代码块。JVM采用monitorentermonitorexit两个指令来实现同步。可以把执行monitorenter指令理解为加锁,执行monitorexit理解为释放锁**。**

锁实现

那线程从什么地方去获取锁相关的信息呢?是从类的对象头里获取的。
对象头主要由两部分组成:Mark Word 和 元类型指针,如果是数组对象,还会包含一个数组长度。我们主要看Mark Word(注意的是JDK旧版本的只有重量级锁,这里以Java 8为例子):

  • 用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。synchronized 锁升级就依赖锁标志、偏向线程等锁信息,垃圾回收新生代对象转移到老年代则依赖于GC分代年龄。

Java之synchronized关键字

锁主要存在四种状态:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态。锁可以从偏向锁升级到轻量级锁,再升级的重量级锁。但是锁的升级是单向的,不会出现锁的降级。
锁膨胀方向:无锁 → 偏向锁 → 轻量级锁 → 重量级锁:
Java之synchronized关键字

其中重量级锁用到了监视器锁

监视器锁Monitor

无论是ACC_SYNCHRONIZED还是monitorenter、monitorexit都是基于监视器锁来实现,而这个锁就是Monitor,在Java虚拟机(HotSpot)中,Monitor是基于C++实现的,由ObjectMonitor实现,结构如下:

ObjectMonitor() {
    _header       = NULL;
    _count        = 0;
    _waiters      = 0,
    _recursions   = 0;
    _object       = NULL;
    _owner        = NULL;
    _WaitSet      = NULL;
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;
    FreeNext      = NULL ;
    _EntryList    = NULL ;
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
  }

当多个线程同时请求ObjectMonitor对象的锁时,只有一个线程能够获取锁并开始执行,其余线程都必须等待。具体的流程如下:

  1. 初始状态下,ObjectMonitor对象的_owner为null,_EntryList队列为空,_WaitSet队列也为空。
  2. 当一个线程请求获取ObjectMonitor对象的锁时,如果锁当前未被其他线程持有,则该线程会成功获取锁并成为_owner。同时,_EntryList队列为空,该线程可以立即开始执行。
  3. 如果另一个线程也请求获取ObjectMonitor对象的锁,但是该锁当前已被其他线程持有,那么该线程会被阻塞,并加入到_WaitSet队列中等待锁的释放。
  4. 如果更多的线程请求获取锁,它们也会被阻塞,并加入到_WaitSet队列中。
  5. 当_owner线程执行完毕,释放了ObjectMonitor对象的锁后,ObjectMonitor对象会从_WaitSet队列中按照优先通知策略选择一个线程,并将其移动到_EntryList队列中,并将其状态设置为就绪状态。被唤醒的线程会尝试再次获取ObjectMonitor对象的锁,如果获取成功,它就可以继续执行。
  6. 如果_WaitSet队列中有多个线程等待获取锁,ObjectMonitor对象会从_WaitSet队列中选择一个线程并将其移动到_EntryList队列中。这个过程可能会重复执行多次,直到_EntryList队列中没有等待执行的线程为止。
  7. 如果有多个线程在_EntryList队列中等待执行,ObjectMonitor对象会按照某种策略选择一个线程,将其设置为_owner,并从_EntryList队列中移除。被选中的线程会立即开始执行。

优先通知策略:

  1. 首先,会优先唤醒最早进入等待队列的线程(即"先进先出")。
  2. 如果等待队列中有多个线程同时等待锁,那么优先唤醒优先级较高的线程。
  3. 如果等待队列中有多个优先级相同的线程,那么选择其中一个线程进行唤醒,具体选择哪个线程是由JVM内部的实现策略来决定的。

特性

synchronized根据原理是展现出多种特性出来的。

原子性

这里的原子性是指一个线程操作是不可中断的,即使在执行过程中,由于某种原因,比如CPU时间片用完,线程放弃了CPU,但是它并没有进行解锁,而由于synchronized的锁是可重入的,下一个时间片还是只能被他自己获取到,还是会继续执行代码。直到所有代码执行完。这就保证了原子性。
而通过监视器锁可以保证被synchronized修饰的代码在同一时间只能被一个线程访问,在锁未释放之前,无法被其他线程访问到。因此,在Java中可以使用synchronized来保证方法和代码块内的操作是原子性的。

可见性

可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
Java内存模型规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存中保存了该线程中是用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量的传递均需要自己的工作内存和主存之间进行数据同步进行。所以,就可能出现线程1改了某个变量的值,但是线程2不可见的情况。
前面我们介绍过,被synchronized修饰的代码,在开始执行时会加锁,执行完成后会进行解锁。而为了保证可见性,happen-before有一条规则是这样的:对一个变量解锁之前,必须先把此变量同步回主存中。这样解锁后,后续线程就可以访问到被修改后的值。
所以,synchronized关键字锁住的对象,其值是具有可见性的。

有序性

在并发时,程序的执行可能会出现乱序。给人的直观感觉就是:写在前面的代码,会在后面执行。但是synchronized提供了有序性保证,这其实和as-if-serial语义有关。
as-if-serial语义是指不管怎么重排序(编译器和处理器为了提高并行度),单线程程序的执行结果都不能被改变。编译器和处理器无论如何优化,都必须遵守as-if-serial语义。只要编译器和处理器都遵守了这个语义,那么就可以认为单线程程序是按照顺序执行的,由于synchronized修饰的代码,同一时间只能被同一线程访问。那么可以认为是单线程执行的。所以可以保证其有序性。
但是需要注意的是synchronized虽然能够保证有序性,但是无法禁止指令重排和处理器优化的。